Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/bump-callers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# bump-callers

The shared machinery that keeps SHA-pinned **callers** of this repo's reusable
workflows from rotting. When a reusable workflow is updated on `main`, it opens
a SHA-bump PR in every repo that pins a caller against it — so consumers move
forward automatically instead of silently drifting commits behind.

- **`bump-callers.sh`** — the one, fleet-agnostic bump script (parse the caller
list, mask private repo names, rewrite the pin, open one PR per caller). It is
the single source of truth; the two workflow entrypoints are thin wrappers
that only supply per-fleet parameters. A forked copy is how other shared
machinery in the org has drifted — this stays one file on purpose.
- **`tests/`** — a `bash` functional suite (stubs `gh`, no network), run by
[`test-bump-callers.yml`](../workflows/test-bump-callers.yml) plus shellcheck.

## The two fleets

| Entrypoint | Triggers on a change to | Caller variable | Seeded |
|---|---|---|---|
| [`bump-cursor-review-callers.yml`](../workflows/bump-cursor-review-callers.yml) | `cursor-review.yml` | `CURSOR_REVIEW_CALLERS` | non-empty (hard-fails if empty) |
| [`bump-agents-md-callers.yml`](../workflows/bump-agents-md-callers.yml) | `agents-md-integrity.yml` or `agents-md-integrity/**` | `AGENTS_MD_CALLERS` | empty `[]` (grows as callers land) |

They stay as two thin entrypoints rather than one matrix because their triggers
differ: a `cursor-review.yml` change must not spuriously bump agents-md callers,
and vice versa. Everything else (masking, the PR-per-caller flow, the trailing-
newline fix, the single-line PR body) lives once in `bump-callers.sh`.

## The caller variables

This repo is **public** — the workflow files and Actions run logs are both
publicly viewable — and most callers are private, so caller names must never
appear in a committed file or in the logs. Each fleet's caller list lives in a
repo-level Actions **variable** (config, not a credential) as a JSON array of
`{"repo","file","label"}` objects (`label` optional). `bump-callers.sh`
`::add-mask::`es every repo name out of the run logs before echoing it.

Adding/removing a caller needs **no public commit** — edit the variable:

```bash
gh variable set AGENTS_MD_CALLERS --repo Comfy-Org/github-workflows \
--body "$(jq -c . callers.json)"
```

Keep the canonical `callers.json` in a private infra/ops repo so variable edits
have a reviewed source of truth (the org audit log records each edit).
185 changes: 185 additions & 0 deletions .github/bump-callers/bump-callers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
#
# Generalized caller SHA-bumper for the reusable workflows in this repo.
#
# When a reusable workflow (e.g. cursor-review.yml or agents-md-integrity.yml)
# is updated on main, this opens a SHA-bump PR in every repo that pins a caller
# against it. It is fleet-agnostic: the thin workflow entrypoints
# (bump-cursor-review-callers.yml, bump-agents-md-callers.yml) invoke it with a
# different caller list, human tag, and reusable-workflow filename. There is ONE
# implementation so the two fleets cannot drift apart (a forked copy is how
# other shared machinery in the org has rotted); the entrypoints differ only in
# their path-filter triggers and the parameters below.
#
# This repo is PUBLIC (both the workflow files and the Actions run logs are
# publicly viewable) and most callers are private, so caller names must never
# appear in a committed file or in the logs. Each caller list lives in a
# repo-level Actions variable (config, not a credential — a variable, not a
# secret) as a JSON array of {"repo","file","label"} objects, and every repo
# name is `::add-mask::`ed out of the run logs before it is ever echoed.
#
# Required environment:
# GH_TOKEN Token with contents:write + pull-requests:write on the callers
# (the Cloud Code Bot app token generated by the entrypoint).
# NEW_SHA The github-workflows commit to pin callers to (github.sha).
# CALLERS_JSON The caller list (value of the fleet's Actions variable).
# VAR_NAME Name of that variable — used only in error messages.
# TAG Human tag for branch/commit/PR text (e.g. "cursor-review").
# WORKFLOW_FILE Reusable workflow filename referenced in the PR body.
# Optional:
# ALLOW_EMPTY "true" → an empty caller list is a clean no-op (a fleet that
# is seeded empty and grows). Default "false" → an empty/missing
# list is a hard error (a fleet that always has callers must
# never silently no-op — that would leave every caller un-bumped
# without anyone noticing).

# NOTE: deliberately no `set -e`. Each caller is bumped independently in
# bump_one(); a failure there returns non-zero and is downgraded to a per-repo
# warning so one bad repo cannot abort the whole fan-out. We still fail the job
# at the end if any repo failed.
set -uo pipefail

: "${NEW_SHA:?NEW_SHA is required}"
: "${VAR_NAME:?VAR_NAME is required}"
: "${TAG:?TAG is required}"
: "${WORKFLOW_FILE:?WORKFLOW_FILE is required}"
CALLERS_JSON="${CALLERS_JSON-}"
ALLOW_EMPTY="${ALLOW_EMPTY:-false}"

SHORT="${NEW_SHA:0:7}"
BRANCH="ci/bump-${TAG}-${SHORT}"

STRIPPED="${CALLERS_JSON//[[:space:]]/}"

# Empty handling is fleet-specific. A fleet seeded empty (ALLOW_EMPTY=true)
# treats an empty/absent list as an expected clean no-op — its per-repo caller
# tickets add entries as callers land. Every other fleet keeps the original
# hard-fail so a silent no-op dispatcher (which would leave every caller
# un-bumped) is impossible.
if [[ "$ALLOW_EMPTY" == "true" ]] && { [[ -z "$STRIPPED" ]] || [[ "$STRIPPED" == "[]" ]]; }; then
echo "${VAR_NAME} has no callers yet — nothing to bump for ${TAG}."
exit 0
fi

# Load the caller list. Hard-fail loudly on a missing/empty/invalid value.
if [[ -z "$STRIPPED" ]]; then
echo "::error::${VAR_NAME} variable is missing or empty. Seed it with the caller list — see this workflow's header comment for the update flow."
exit 1
fi
if ! jq -e 'type == "array" and length > 0 and all(.[]; (.repo | type == "string" and . != "") and (.file | type == "string" and . != ""))' <<<"$CALLERS_JSON" >/dev/null 2>&1; then
echo "::error::${VAR_NAME} is not a non-empty JSON array of {repo,file,label} objects (each needing a non-empty repo and file). Fix the variable — see the workflow header."
exit 1
fi

# Parse the JSON into repo|file|label tuples, and mask every repo name in the
# (publicly viewable) run logs BEFORE the loop that echoes it, so all per-repo
# output shows *** instead of a private repo name.
CALLERS=()
while IFS= read -r ENTRY; do
echo "::add-mask::${ENTRY%%|*}"
CALLERS+=("$ENTRY")
done < <(jq -r '.[] | "\(.repo)|\(.file)|\(.label // "")"' <<<"$CALLERS_JSON")

if (( ${#CALLERS[@]} == 0 )); then
echo "::error::${VAR_NAME} parsed to zero callers — refusing to run a no-op dispatcher."
exit 1
fi

# Bump a single caller repo. Every external call is guarded, so a bad repo
# returns non-zero here (caught by the loop) instead of killing the run. An
# expected skip (file missing / already pinned) is a successful `return 0`.
bump_one() {
local ENTRY="$1" REPO FILE LABEL FILE_ENC
REPO=$(cut -d'|' -f1 <<<"$ENTRY")
FILE=$(cut -d'|' -f2 <<<"$ENTRY")
LABEL=$(cut -d'|' -f3 <<<"$ENTRY")
FILE_ENC="${FILE//\//%2F}"

local DEFAULT_BRANCH
DEFAULT_BRANCH=$(gh api "repos/${REPO}" --jq '.default_branch') || {
echo "::warning::${REPO}: cannot read repo metadata — skipping"
return 1
}

# Fetch current file; a missing file is an expected skip (success).
local CURRENT
CURRENT=$(gh api "repos/${REPO}/contents/${FILE_ENC}?ref=${DEFAULT_BRANCH}" 2>/dev/null) || {
echo "::warning::${REPO}: ${FILE} not found — skipping"
return 0
}

local BLOB_SHA OLD_CONTENT
BLOB_SHA=$(jq -r '.sha' <<<"$CURRENT")
OLD_CONTENT=$(jq -r '.content' <<<"$CURRENT" | base64 -d)

# Already pinned to this SHA → nothing to do (success).
if grep -qF "$NEW_SHA" <<<"$OLD_CONTENT"; then
echo "${REPO}: already at ${SHORT} — skipping"
return 0
fi

# Replace every 40-char hex SHA (the caller's pin — cursor-review has one,
# agents-md-integrity has two: the `uses: ...@<sha>` and its `workflows_ref`)
# and normalize the stale `# github-workflows#NN` pin comment. The comment
# rewrite is a no-op for callers that use a different comment form (e.g.
# agents-md-integrity's `# v1`), so it is safe to share across fleets.
local NEW_CONTENT
NEW_CONTENT=$(sed -E "s/[0-9a-f]{40}/${NEW_SHA}/g; s|# github-workflows#[0-9]+|# github-workflows main (${SHORT})|g" <<<"$OLD_CONTENT")

# Create the branch from the default-branch tip (ignore 422 if it exists).
local MAIN_SHA
MAIN_SHA=$(gh api "repos/${REPO}/git/refs/heads/${DEFAULT_BRANCH}" --jq '.object.sha') || {
echo "::warning::${REPO}: cannot read ${DEFAULT_BRANCH} ref — skipping"
return 1
}
gh api --method POST "repos/${REPO}/git/refs" \
--field ref="refs/heads/${BRANCH}" \
--field sha="${MAIN_SHA}" 2>/dev/null || true

# Commit the updated file onto the branch. `$(...)` capture above (base64 -d,
# then sed) strips the file's trailing newline, so printf '%s\n' restores
# exactly one — otherwise the committed caller loses its EOF newline and trips
# the receiver repo's formatter (oxfmt/prettier both require a trailing
# newline).
local ENCODED
ENCODED=$(printf '%s\n' "$NEW_CONTENT" | base64 | tr -d '\n')
gh api --method PUT "repos/${REPO}/contents/${FILE_ENC}" \
--field message="ci: bump ${TAG} to github-workflows@${SHORT}" \
--field content="${ENCODED}" \
--field sha="${BLOB_SHA}" \
--field branch="${BRANCH}" \
> /dev/null || {
echo "::warning::${REPO}: commit to ${BRANCH} failed — skipping"
return 1
}

local LABEL_ARGS=()
[[ -n "$LABEL" ]] && LABEL_ARGS=(--label "$LABEL")

# Open the PR (a pre-existing PR for this branch is not an error). The body is
# a single line so the workflow parses it — a multi-line --body collapsed the
# step's YAML in an earlier revision.
if gh pr create \
--repo "${REPO}" \
--head "${BRANCH}" \
--base "${DEFAULT_BRANCH}" \
--title "ci: bump ${TAG} to github-workflows@${SHORT}" \
--body "Automatic SHA bump — \`${WORKFLOW_FILE}\` was updated in \`Comfy-Org/github-workflows\` at [\`${SHORT}\`](https://github.com/Comfy-Org/github-workflows/commit/${NEW_SHA}). _Opened by the \`bump-${TAG}-callers\` workflow._" \
"${LABEL_ARGS[@]}" 2>/dev/null; then
echo "${REPO}: PR opened"
else
echo "::warning::${REPO}: PR may already exist for ${BRANCH}"
fi
return 0
}

FAILED=()
for ENTRY in "${CALLERS[@]}"; do
bump_one "$ENTRY" || FAILED+=("${ENTRY%%|*}")
done

if (( ${#FAILED[@]} )); then
printf '::error::bump failed for %d repo(s): %s\n' "${#FAILED[@]}" "${FAILED[*]}"
exit 1
fi
echo "${TAG} bump complete for all callers."
Loading