diff --git a/.github/bump-callers/README.md b/.github/bump-callers/README.md index 87adde0..2047ca5 100644 --- a/.github/bump-callers/README.md +++ b/.github/bump-callers/README.md @@ -5,9 +5,9 @@ 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, keep one bump PR per caller - current). It is the single source of truth; the workflow entrypoints are +- **`bump-callers.sh`** — the one, fleet-agnostic bump script (fetch the caller + list from its fleet's Actions variable, mask private repo names, rewrite the + pin, keep one bump PR per caller current). It is the single source of truth; the 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. - **One open bump PR per (repo, fleet), updated in place.** The head branch is @@ -38,19 +38,22 @@ Its 12 live callers are known and correctly wired (each pins a full 40-hex SHA against this repo's path, i.e. exactly what the rewrite moves). It is unseeded anyway, on purpose. -Every roster reaches `bump-callers.sh` through the step's `env:` block, and +Every roster used to reach `bump-callers.sh` through the step's `env:` block, and Actions prints that block — values and all — before the script's `::add-mask::` -can run. This repo is public, so each seeded fleet already publishes its roster -in a world-readable log. That is the known gap documented in every `bump-*` -header. The difference here is that two of this fleet's callers are non-public -repos that appear in **no** already-seeded roster, so seeding would publish two -names that are not out yet — and a public log entry cannot be unpublished. - -So the trade is: seed now and take an irreversible disclosure, or leave it -unseeded and take a red run. The red run is reversible and is already the -designed behaviour for an empty roster, so it wins. Seed the variable as the -immediate follow-on to the masking fix — never as a way to turn that red run -green. +could run. This repo is public, so each seeded fleet had already published its +roster in a world-readable log. The difference here was that two of this fleet's +callers are non-public repos appearing in **no** already-seeded roster, so +seeding would have published two names that are not out yet — and a public log +entry cannot be unpublished. The trade was: seed now and take an irreversible +disclosure, or leave it unseeded and take a red run. The red run is reversible +and is already the designed behaviour for an empty roster, so it won. + +**That blocker is gone** (BE-6482 — the roster is fetched and masked inside the +script), but the variable is still unset, so the red run persists until someone +seeds it. Do that once a dispatch of the entrypoint has been seen reading its +variable *without* the "cannot read Actions variables" error — that error means +the App still lacks **Variables: read**, and a roster the script cannot read is +one it cannot mask either. Never seed it merely to turn the red run green. ### Reusables with no fleet — deliberate, not an oversight @@ -154,13 +157,39 @@ 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. -> **Known gap.** Each entrypoint hands the roster to the script through the -> step's `env:`, and Actions prints a step's env block *before* the step runs — -> so the raw roster appears in the (public) log ahead of any masking. Closing it -> means fetching the variable at run time (`gh variable get`) and masking it -> before first use, which needs a token permission the fleets do not mint today. -> It is fleet-wide; no single entrypoint can fix it. Until then, assume the -> roster is public. +The roster is **fetched at run time by the script**, keyed by the `VAR_NAME` its +entrypoint passes, and masked before the first log line — it is not bound into +the step's `env:` block any more (BE-6482). That binding was the leak: Actions +prints a step's env block *before* the step runs, so the raw roster reached the +public log ahead of the masking meant to cover it. Fetching inside the script +puts the read, the shape check and the `::add-mask::` in an order the log cannot +get in front of. + +Two consequences worth knowing: + +- **Two tokens, not one.** A downscoped App token cannot read Actions variables + — the app-permissions schema (the installation-token request body) has no + `variables` key, so there is no `permission-variables` input to ask for, and + only a token minted with *no* `permission-*` inputs carries the App's + **Variables: read** grant. Rather than widen the token that writes across the + whole fleet, each entrypoint mints a **second** token scoped to this repo alone + (`repositories: github-workflows`) and hands it to the script as `VAR_TOKEN`, + used for the roster read and nothing else. The write token (`GH_TOKEN`) keeps + its `contents` / `pull-requests` / `issues` downscoping. `VAR_TOKEN` is + optional: unset, the read falls back to `GH_TOKEN` (manual runs). +- **The App needs the grant.** The Cloud Code Bot App itself must hold the + repository permission **Variables: read**, approved on the Comfy-Org + installation, or every fleet fails with an explicit error naming that grant. + +The read tries the **repo-level** variable first and falls back to the +**org-level** one, matching what the `${{ vars.* }}` binding it replaced +resolved (repo wins on a name clash). Absent at both scopes is an *empty roster* +— handled by `ALLOW_EMPTY` exactly as an empty variable is — but it always logs +a `::warning::`, because a 404 is also how GitHub answers "this token cannot see +the repository at all". + +`CALLERS_JSON` still works as an explicit override for a manual run and is what +the test suite drives; a set-but-empty value means "empty roster", not "fetch". Adding/removing a caller needs **no public commit** — edit the variable: diff --git a/.github/bump-callers/bump-callers.sh b/.github/bump-callers/bump-callers.sh index 64a9bb1..a8583e9 100755 --- a/.github/bump-callers/bump-callers.sh +++ b/.github/bump-callers/bump-callers.sh @@ -20,15 +20,39 @@ # every repo name is `::add-mask::`ed out of the run logs before it is ever # echoed. # +# The roster is FETCHED HERE at run time rather than handed in through the +# entrypoint's step `env:` block (BE-6482). Actions prints a step's env block into +# the run log BEFORE the step runs, so an `env:`-passed roster appeared in this +# PUBLIC repo's world-readable logs ahead of the `::add-mask::` loop below — the +# masking could not win a race it started after. Reading the variable inside the +# script puts the fetch, the validation and the mask in an order the log cannot +# get in front of. +# # Required environment: # GH_TOKEN Token with contents:write + pull-requests:write on the callers # (the Cloud Code Bot app token generated by the entrypoint). +# This is the WRITE token and stays downscoped; it is used for +# the roster read only as a fallback when VAR_TOKEN is unset. # 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. +# VAR_NAME Name of the fleet's Actions variable. The roster is READ from +# it at run time (and it names the variable in error messages). +# GITHUB_REPOSITORY Owner/name of THIS repo, for that read (Actions sets it). # TAG Human tag for branch/commit/PR text (e.g. "cursor-review"). # WORKFLOW_FILE Reusable workflow filename referenced in the PR body. # Optional: +# VAR_TOKEN Token used for the roster read ALONE. The entrypoints mint a +# second app token for it, scoped to THIS repo only and with no +# `permission-*` downscoping, because the app-permissions API +# schema (the installation-token request body) has no `variables` +# key to ask for — only an un-downscoped token carries the App's +# "Variables: read" grant (BE-6477). Splitting it off is what +# keeps GH_TOKEN, which writes to every caller repo in the fleet, +# on its narrow trio. Falls back to GH_TOKEN when unset. +# CALLERS_JSON OVERRIDE for the runtime fetch — the caller list, verbatim. +# Used by the test suite and available for an emergency manual +# run; the entrypoints deliberately do NOT set it. Set-but-EMPTY +# is an override too (it means "empty roster", handled by +# ALLOW_EMPTY below); only a genuinely UNSET value fetches. # 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 @@ -53,10 +77,105 @@ set -uo pipefail : "${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}" WIRE_BOT_SCRIPT="${WIRE_BOT_SCRIPT-}" +# Load the roster. Normally it is READ HERE, from this repo's own Actions +# variable named by VAR_NAME — see the header for why it is not passed in +# through the step's `env:`. +# +# The test is `+x`, i.e. "is CALLERS_JSON set AT ALL", NOT `-z "$CALLERS_JSON"`. +# A set-but-EMPTY override must keep meaning "the roster is empty" and fall +# through to the ALLOW_EMPTY handling below — the suite drives the empty-variable +# hard-fail that way, and more importantly an emergency manual run that passes +# `CALLERS_JSON=''` must not silently reach past it and bump the LIVE roster. +if [[ -z "${CALLERS_JSON+x}" ]]; then + if [[ -z "${GITHUB_REPOSITORY:-}" ]]; then + echo "::error::GITHUB_REPOSITORY is unset, so the ${VAR_NAME} Actions variable cannot be read. Run this from Actions, or pass the roster explicitly in CALLERS_JSON." + exit 1 + fi + # `gh api`, NOT `gh variable get`: only the REST path surfaces the HTTP status, + # and telling 404 from 403 is the whole point here. `gh variable get` exits 1 + # for both, so an absent variable (a legitimate empty roster for a seeded-empty + # fleet) would be indistinguishable from a token that cannot read variables at + # all — one is a clean no-op, the other needs an operator to grant a permission. + # + # The read runs under VAR_TOKEN when the entrypoint mints one: a SECOND app + # token, scoped to THIS repo alone (`repositories: github-workflows`) and left + # un-downscoped so it carries the App's "Variables: read" grant. That is what + # lets GH_TOKEN — the token that actually WRITES across every caller repo — + # keep its narrow `permission-contents`/`pull-requests`/`issues` trio, instead + # of the whole fleet's write token being widened to full installation + # permissions just to enable one variable read (BE-6482 review). Falling back + # to GH_TOKEN keeps a manual/legacy invocation working. + VAR_ERR=$(mktemp) || { + echo "::error::Could not create a temp file for the ${VAR_NAME} read's stderr (mktemp failed)." + exit 1 + } + trap 'rm -f "$VAR_ERR"' EXIT + VAR_READ_TOKEN="${VAR_TOKEN:-${GH_TOKEN:-}}" + + # Reduce an API error body to ONE safe log line: the REASON only, never the + # value (the value goes to stdout; this reads stderr). The run logs of this + # public repo are a workflow-command sink, so a `\r::` in an error body could + # otherwise inject a command of its own. The `::` squash LOOPS to a fixpoint — + # a single `s|::|:|g` pass is not one, since `::::x::::` collapses straight + # back into `::x::`. + sanitize_api_error() { + local s + s=$(tr -d '\r' <"$1" | tr '\n' ' ' | sed -E 's|[[:space:]]+| |g; s|^ ||; s| $||') + while [[ "$s" == *::* ]]; do s="${s//::/:}"; done + printf '%s' "$s" + } + + # A non-404 failure of the repo-scope read. Never recoverable here, so it ends + # the run — but with the RIGHT remediation: 403 is not only "missing grant". + var_read_die() { + local reason; reason=$(sanitize_api_error "$VAR_ERR") + if grep -qiE 'rate limit|secondary rate|abuse detection' "$VAR_ERR"; then + echo "::error::Could not read the ${VAR_NAME} Actions variable — GitHub throttled the request (retry later, no permission change needed): ${reason}" + elif grep -qi 'HTTP 403' "$VAR_ERR"; then + # Overwhelmingly the missing grant, so lead with it — but a 403 is also how + # GitHub answers an IP-allow-list / SAML-enforcement / suspended-installation + # denial, so carry the sanitized reason rather than asserting the cause. + echo "::error::App token cannot read Actions variables — grant the Cloud Code Bot App repository permission 'Variables: read' and approve it on the Comfy-Org installation (BE-6477). A 403 can also mean an IP allow-list, SAML enforcement or a suspended installation — API said: ${reason}" + else + echo "::error::Could not read the ${VAR_NAME} Actions variable: ${reason}" + fi + exit 1 + } + + if ! CALLERS_JSON=$(GH_TOKEN="$VAR_READ_TOKEN" gh api "repos/${GITHUB_REPOSITORY}/actions/variables/${VAR_NAME}" --jq '.value' 2>"$VAR_ERR"); then + grep -qi 'HTTP 404' "$VAR_ERR" || var_read_die + # A repo-scope 404 is not yet "no roster". The `${{ vars.* }}` binding this + # read replaced ALSO resolved ORG-level variables (repo-level winning on a + # name clash), so try the org before concluding the roster is empty — + # otherwise a fleet whose roster is defined org-wide silently becomes an + # empty one. Best-effort: this repo's rosters are repo-level by convention + # and the App may hold no org-level Variables grant at all, so a FAILED org + # probe must not turn today's clean no-op into a hard failure. + VAR_OWNER="${GITHUB_REPOSITORY%%/*}" + if ! CALLERS_JSON=$(GH_TOKEN="$VAR_READ_TOKEN" gh api "orgs/${VAR_OWNER}/actions/variables/${VAR_NAME}" --jq '.value' 2>"$VAR_ERR"); then + # Genuinely absent at BOTH scopes == an empty roster, spelled the same way + # an empty variable is, so the ALLOW_EMPTY block below keeps its EXACT + # semantics: a clean no-op for a fleet seeded empty, and the loud + # clobber-detecting error for a fleet that must always have callers + # (auto-label, cursor-review, detect-unreviewed-merge). + # + # Say so OUT LOUD either way. A 404 is also what GitHub returns when the + # token cannot see the repository at all — App uninstalled, repo-selection + # drift, a renamed repo, a VAR_NAME typo in some future fleet — and for the + # ALLOW_EMPTY=true fleets that would otherwise be a completely silent + # "nothing to do" while the whole fleet quietly stops being bumped, which + # is the exact pin rot this bumper exists to prevent. + echo "::warning::No ${VAR_NAME} Actions variable found on ${GITHUB_REPOSITORY} or on org ${VAR_OWNER} (HTTP 404 at the repo scope; org probe: $(sanitize_api_error "$VAR_ERR")) — treating the roster as EMPTY. If this fleet is supposed to have callers, the variable is missing or misnamed, or the token cannot see this repository." + CALLERS_JSON="" + fi + fi + rm -f "$VAR_ERR" + trap - EXIT +fi + SHORT="${NEW_SHA:0:7}" # Stable branch per (repo, TAG) — deliberately NOT SHA-stamped. A fixed head # branch is what lets a subsequent bump reuse (and update in place) the one open @@ -141,7 +260,16 @@ fi # Parse the JSON into repo|file|label|wire_bot 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. jq's `//` treats +# per-repo output shows *** instead of a private repo name. +# +# INVARIANT (BE-6482): this loop is the FIRST place the roster's repo names are +# used for anything, and nothing above it may log the roster — not the fetch, not +# the empty/shape validation, not an error path. Everything before this point +# either handles the value silently or reports only a REASON, and the order +# fetch → validate → mask must stay that way. The whole reason the roster is read +# here instead of arriving through the step's `env:` is that Actions printed the +# env block before this loop could run; adding an echo of the value above it +# re-opens exactly that hole, in a public log, irreversibly. jq's `//` treats # both `false` and `null` as falsy, so an absent/false/null wire_bot all print # the empty string here — exactly the "not flagged" case. CALLERS=() diff --git a/.github/bump-callers/tests/test_bump_callers.sh b/.github/bump-callers/tests/test_bump_callers.sh index 98d7903..2e1ffa0 100755 --- a/.github/bump-callers/tests/test_bump_callers.sh +++ b/.github/bump-callers/tests/test_bump_callers.sh @@ -20,7 +20,12 @@ # same and a longer key ending in `workflows_ref` are neither rewritten nor # misread as a stale pin, # * an empty seeded-empty fleet is a clean no-op while a must-have-callers -# fleet still hard-fails, and a malformed variable hard-fails. +# fleet still hard-fails, and a malformed variable hard-fails, +# * the roster is FETCHED from the fleet's Actions variable when CALLERS_JSON +# is unset (BE-6482), with 404 folding into the existing empty-roster +# semantics and 403 producing the actionable Variables-grant error — while an +# explicitly-set CALLERS_JSON (empty or not) still overrides and makes no +# variables API call at all. # # No network: `gh` is a PATH stub that serves a fixture file and captures the # Git Data API calls (the blob content + the tree's file list) so we can inspect @@ -93,7 +98,7 @@ while (( i < ${#args[@]} )); do f="${args[$((i+1))]}" [[ "$f" == content=* ]] && content="${f#content=}" i=$((i+2));; - repos/*) path="${args[$i]}"; i=$((i+1));; + repos/*|orgs/*) path="${args[$i]}"; i=$((i+1));; *) i=$((i+1));; esac done @@ -140,6 +145,41 @@ case "$method:$path" in esac # GET dispatch by resource path. +if [[ "$path" == *"/actions/variables/"* ]]; then + # The run-time roster read (BE-6482). Log EVERY call so a case can assert the + # fetch happened — or, for an explicit CALLERS_JSON override, that it did not. + # The token is logged alongside the path because WHICH token performs this read + # is load-bearing: it must be the narrow, repo-scoped VAR_TOKEN, so that the + # fleet's write token can stay downscoped. + echo "$path token=${GH_TOKEN:-}" >> "$STUB_PUT_DIR/varapi.log" + if [[ "$path" == orgs/* ]]; then + # The ORG-scope fallback, tried only after a repo-scope 404 because the + # `${{ vars.* }}` binding this read replaced also resolved org variables. + # Default is 404 (this repo's rosters are repo-level), i.e. "not there + # either" — the fallback must not manufacture a roster out of nothing. + case "${STUB_ORG_VAR_STATUS:-404}" in + 200) printf '%s\n' "${STUB_ORG_VAR_VALUE:-[]}"; exit 0;; + 403) echo "gh: Resource not accessible by integration (HTTP 403)" >&2; exit 1;; + *) echo "gh: Not Found (HTTP 404)" >&2; exit 1;; + esac + fi + # STUB_VAR_STATUS models the two failures the script must tell apart: 404 (no + # such variable → an empty roster) and 403 (token cannot read variables at all + # → the operator-actionable error). gh writes the status to stderr and exits 1. + case "${STUB_VAR_STATUS:-}" in + 404) echo "gh: Not Found (HTTP 404)" >&2; exit 1;; + 403) echo "gh: Resource not accessible by integration (HTTP 403)" >&2; exit 1;; + 429) echo "gh: You have exceeded a secondary rate limit (HTTP 403)" >&2; exit 1;; + # STUB_VAR_ERRBODY lets a case drive an ADVERSARIAL error body through the + # script's sanitizer (the run logs of the public repo are a workflow-command + # sink, so an error body is untrusted input). + 5*) printf '%s\n' "${STUB_VAR_ERRBODY:-gh: Internal Server Error (HTTP 500)}" >&2; exit 1;; + esac + # The script passes `--jq .value`; the stub's arg loop discards --jq, so emit + # the post-jq value directly (same convention as the git/commits branch below). + printf '%s\n' "${STUB_VAR_VALUE:-[]}" + exit 0 +fi if [[ "$path" == *"/contents/"* ]]; then # Simulate content-fetch failures so the script's 404-vs-transient handling is # exercised. STUB_404_FILE: a contents GET whose (decoded-ish) path contains @@ -181,7 +221,12 @@ run_bump() { # runs the real script, capturing stdout+stderr and exit code. # NOT recognized as assignment prefixes, so `env` is required here. # OUT/RC are consumed by check()'s `eval`, which shellcheck can't see. # shellcheck disable=SC2034 + # GITHUB_REPOSITORY is what the run-time roster read is keyed on (BE-6482). It + # is set for every case, not just the fetch ones, so a case that passes + # CALLERS_JSON explicitly proves the override wins on the merits — the fetch + # path is fully available to it and is skipped anyway. OUT=$(env GH_TOKEN=x NEW_SHA="$NEW_SHA" STUB_CONTENT_FILE="$STUB_CONTENT_FILE" \ + GITHUB_REPOSITORY=Comfy-Org/github-workflows \ STUB_PUT_DIR="$STUB_PUT_DIR" "$@" bash "$BUMP" 2>&1) RC=$? } @@ -936,6 +981,210 @@ check "no spurious attribution warning" "! grep -q 'pin comments untouc check "the marker WAS refreshed" "grep -qF 'github-workflows main ($SHORT)' \"$PUT\"" check "no stale short SHA left behind" "! grep -qF 'main (1111111)' \"$PUT\"" +# --------------------------------------------------------------------------- +# Run-time roster fetch (BE-6482). The roster used to arrive through the +# entrypoint's step `env:`, which Actions prints into this PUBLIC repo's run log +# BEFORE the step — i.e. before `::add-mask::` could cover the private names. The +# script now reads it itself, from the Actions variable VAR_NAME names, and the +# cases below pin the four behaviours that makes load-bearing. +# --------------------------------------------------------------------------- + +echo "== CALLERS_JSON UNSET: the roster is FETCHED, masked, and bumps identically (BE-6482) ==" +new_case fetch +FETCH_ROSTER='[{"repo":"Comfy-Org/secret-fetched","file":".github/workflows/ci-cursor-review.yml","label":""}]' +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 0" "[[ $RC -eq 0 ]]" +check "read the fleet's variable" "grep -qF 'repos/Comfy-Org/github-workflows/actions/variables/CURSOR_REVIEW_CALLERS' \"\$STUB_PUT_DIR/varapi.log\"" +check "masked the fetched repo name" "grep -q '::add-mask::Comfy-Org/secret-fetched' <<<\"\$OUT\"" +# The mask directive is the FIRST use of the name; nothing above it may log the +# roster. Anything printed before that line would be an unmasked public-log leak +# — the exact failure this change exists to remove. +check "mask is the first mention" "[[ \"\$(grep -n 'secret-fetched' <<<\"\$OUT\" | head -1)\" == *'::add-mask::'* ]]" +check "reported PR opened" "grep -q 'PR opened' <<<\"\$OUT\"" +PUT="${STUB_PUT_DIR}/put.last.txt" +check "bumped exactly like the injected-roster path" "grep -qF '$NEW_SHA' \"$PUT\"" + +echo "== the roster read uses VAR_TOKEN, never the fleet's write token ==" +# Load-bearing, not cosmetic: it is what lets the entrypoints keep GH_TOKEN +# downscoped to contents/pull-requests/issues. If the read ever falls back to +# GH_TOKEN in CI, the only way to make it work again is to un-downscope the token +# that writes to every caller repo in the fleet. +new_case vartoken +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + VAR_TOKEN=readonly-token STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 0" "[[ $RC -eq 0 ]]" +check "read ran under VAR_TOKEN" "grep -qF 'token=readonly-token' \"\$STUB_PUT_DIR/varapi.log\"" +check "not under the write token" "! grep -qF 'token=x' \"\$STUB_PUT_DIR/varapi.log\"" +# The write path must be UNAFFECTED — VAR_TOKEN is for the read alone. +check "commit still used GH_TOKEN" "grep -qF 'PR opened' <<<\"\$OUT\"" + +echo "== no VAR_TOKEN: the read falls back to GH_TOKEN (manual/legacy invocation) ==" +new_case vartokenfallback +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 0" "[[ $RC -eq 0 ]]" +check "fell back to GH_TOKEN" "grep -qF 'token=x' \"\$STUB_PUT_DIR/varapi.log\"" + +echo "== fetch 404 + ALLOW_EMPTY=true: absent variable == empty roster, clean no-op ==" +new_case fetch404ok +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=AGENTS_MD_CALLERS TAG=agents-md-integrity WORKFLOW_FILE=agents-md-integrity.yml ALLOW_EMPTY=true \ + STUB_VAR_STATUS=404 +check "exit 0 on 404 + ALLOW_EMPTY" "[[ $RC -eq 0 ]]" +check "logged the no-op" "grep -q 'no callers yet' <<<\"\$OUT\"" +check "no commit made" "[[ ! -f \"\$STUB_PUT_DIR/count\" ]]" +check "no Variables-grant error" "! grep -q 'Variables: read' <<<\"\$OUT\"" +# A 404 is ALSO how GitHub answers "the token cannot see this repository at all" +# (App uninstalled, repo-selection drift, a renamed repo, a VAR_NAME typo). For +# an ALLOW_EMPTY fleet that would otherwise be a completely silent no-op while +# the whole fleet quietly stopped being bumped, so it must be said out loud. +check "warned about the 404" "grep -q '::warning::No AGENTS_MD_CALLERS Actions variable found' <<<\"\$OUT\"" +check "names both scopes tried" "grep -q 'or on org Comfy-Org' <<<\"\$OUT\"" + +echo "== repo-scope 404 falls back to the ORG-level variable (vars.* resolved both) ==" +# The REST repo-variable endpoint is NARROWER than the `${{ vars.* }}` binding it +# replaced: that also resolved org-level and inherited variables. Without this +# fallback a fleet whose roster lives at the org level silently becomes empty. +new_case fetchorg +ORG_ROSTER='[{"repo":"Comfy-Org/secret-org-level","file":".github/workflows/ci-cursor-review.yml","label":""}]' +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_STATUS=404 STUB_ORG_VAR_STATUS=200 STUB_ORG_VAR_VALUE="$ORG_ROSTER" +check "exit 0" "[[ $RC -eq 0 ]]" +check "tried the repo scope first" "grep -qF 'repos/Comfy-Org/github-workflows/actions/variables/CURSOR_REVIEW_CALLERS' \"\$STUB_PUT_DIR/varapi.log\"" +check "then the org scope" "grep -qF 'orgs/Comfy-Org/actions/variables/CURSOR_REVIEW_CALLERS' \"\$STUB_PUT_DIR/varapi.log\"" +check "used the org roster" "grep -q '::add-mask::Comfy-Org/secret-org-level' <<<\"\$OUT\"" +check "org name masked before use" "[[ \"\$(grep -n 'secret-org-level' <<<\"\$OUT\" | head -1)\" == *'::add-mask::'* ]]" +check "not treated as empty" "! grep -q 'variable is missing or empty' <<<\"\$OUT\"" + +echo "== a repo-level roster WINS over an org-level one (repo scope is read first) ==" +new_case fetchrepowins +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_VALUE="$FETCH_ROSTER" STUB_ORG_VAR_STATUS=200 STUB_ORG_VAR_VALUE="$ORG_ROSTER" +check "exit 0" "[[ $RC -eq 0 ]]" +check "used the repo-level value" "grep -q '::add-mask::Comfy-Org/secret-fetched' <<<\"\$OUT\"" +check "never probed the org" "! grep -qF 'orgs/Comfy-Org' \"\$STUB_PUT_DIR/varapi.log\"" + +echo "== a FAILED org probe is best-effort: still an empty roster, never a hard error ==" +# The App may hold no org-level Variables grant at all, and this repo's rosters +# are repo-level by convention — so a 403 from the org fallback must not turn a +# seeded-empty fleet's clean no-op into a red run. +new_case fetchorg403 +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=AGENTS_MD_CALLERS TAG=agents-md-integrity WORKFLOW_FILE=agents-md-integrity.yml ALLOW_EMPTY=true \ + STUB_VAR_STATUS=404 STUB_ORG_VAR_STATUS=403 +check "exit 0" "[[ $RC -eq 0 ]]" +check "logged the no-op" "grep -q 'no callers yet' <<<\"\$OUT\"" +check "still warned" "grep -q '::warning::No AGENTS_MD_CALLERS Actions variable found' <<<\"\$OUT\"" + +echo "== fetch 404 + ALLOW_EMPTY=false: still the loud clobber-detecting error ==" +# A must-have-callers fleet (auto-label, cursor-review, detect-unreviewed-merge) +# must not silently no-op just because the variable read came back empty via a +# different route than before. +new_case fetch404bad +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=AUTO_LABEL_CALLERS TAG=auto-label WORKFLOW_FILE=cursor-review-auto-label.yml \ + STUB_VAR_STATUS=404 +check "exit 1 on 404 must-have fleet" "[[ $RC -eq 1 ]]" +check "error names the variable" "grep -q '::error::AUTO_LABEL_CALLERS variable is missing or empty' <<<\"\$OUT\"" + +echo "== fetch 403: the actionable Variables-grant error, and NO roster in the log ==" +new_case fetch403 +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_STATUS=403 STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 1 on 403" "[[ $RC -eq 1 ]]" +check "names the missing grant" "grep -q \"::error::App token cannot read Actions variables\" <<<\"\$OUT\"" +check "points at the App setting" "grep -qF \"'Variables: read'\" <<<\"\$OUT\"" +check "roster never echoed" "! grep -q 'secret-fetched' <<<\"\$OUT\"" +check "not misread as empty" "! grep -q 'variable is missing or empty' <<<\"\$OUT\"" +check "no commit made" "[[ ! -f \"\$STUB_PUT_DIR/count\" ]]" +# The grant is the overwhelmingly likely cause, so it leads — but a 403 is also +# how GitHub answers an IP-allow-list / SAML / suspended-installation denial, so +# the error must carry the API's own reason rather than only asserting a cause. +check "carries the API reason" "grep -q 'API said: gh: Resource not accessible by integration (HTTP 403)' <<<\"\$OUT\"" +check "no org probe after a 403" "! grep -qF 'orgs/Comfy-Org' \"\$STUB_PUT_DIR/varapi.log\"" + +echo "== a rate-limited 403 is NOT reported as a missing permission ==" +# GitHub returns 403 for primary/secondary rate limiting too. Blaming the grant +# there hands the operator a confident, wrong remediation for a transient +# throttle — one that reads as "go widen an App permission". +new_case fetchratelimit +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_STATUS=429 STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 1" "[[ $RC -eq 1 ]]" +check "names the throttle" "grep -q '::error::Could not read the CURSOR_REVIEW_CALLERS Actions variable — GitHub throttled' <<<\"\$OUT\"" +check "does not blame the grant" "! grep -q 'Variables: read' <<<\"\$OUT\"" +check "roster never echoed" "! grep -q 'secret-fetched' <<<\"\$OUT\"" + +echo "== a non-404/403 fetch failure fails loudly, sanitized ==" +new_case fetch500 +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_STATUS=500 STUB_VAR_VALUE="$FETCH_ROSTER" +check "exit 1 on transient failure" "[[ $RC -eq 1 ]]" +check "error names the variable" "grep -q '::error::Could not read the CURSOR_REVIEW_CALLERS Actions variable' <<<\"\$OUT\"" +check "roster never echoed" "! grep -q 'secret-fetched' <<<\"\$OUT\"" + +echo "== an adversarial error body cannot smuggle a workflow command into the log ==" +# An API error body is untrusted input echoed into a PUBLIC run log, which is a +# workflow-command sink. A single `s|::|:|g` pass is NOT a fixpoint — `::::x::::` +# collapses straight back into `::x::` — so the squash has to loop. +new_case fetchinject +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_STATUS=500 \ + STUB_VAR_ERRBODY='boom ::::add-mask::::x:::::set-output name=y:::: (HTTP 500)' +check "exit 1" "[[ $RC -eq 1 ]]" +check "error names the variable" "grep -q 'Could not read the CURSOR_REVIEW_CALLERS Actions variable' <<<\"\$OUT\"" +check "no injected add-mask" "! grep -q '::add-mask::x' <<<\"\$OUT\"" +check "no injected set-output" "! grep -q '::set-output' <<<\"\$OUT\"" +# Exactly one `::` remains on that line: the script's OWN leading `::error::` +# marker, which the sanitizer never touches because it only rewrites the reason. +check "only the error marker's ::" "[[ \"\$(grep 'Could not read the CURSOR_REVIEW_CALLERS' <<<\"\$OUT\" | grep -o '::' | wc -l | tr -d ' ')\" == 2 ]]" + +echo "== an explicit CALLERS_JSON still OVERRIDES — no variables API call at all ==" +new_case override +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_VALUE="$FETCH_ROSTER" \ + CALLERS_JSON='[{"repo":"Comfy-Org/secret-injected","file":".github/workflows/ci-cursor-review.yml","label":""}]' +check "exit 0" "[[ $RC -eq 0 ]]" +check "no variables API call" "[[ ! -f \"\$STUB_PUT_DIR/varapi.log\" ]]" +check "used the injected roster" "grep -q '::add-mask::Comfy-Org/secret-injected' <<<\"\$OUT\"" +check "ignored the variable's value" "! grep -q 'secret-fetched' <<<\"\$OUT\"" + +echo "== CALLERS_JSON set-but-EMPTY means EMPTY, never 'go fetch' ==" +# The distinction the `+x` test exists for. `-z` here would send an explicit +# `CALLERS_JSON=''` — a deliberate "bump nothing" — off to read the LIVE roster +# and bump the whole fleet, and would break this suite's empty-variable cases. +new_case emptyoverride +STUB_CONTENT_FILE="$CR_FIXTURE" run_bump \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + STUB_VAR_VALUE="$FETCH_ROSTER" CALLERS_JSON='' +check "exit 1 (empty must-have fleet)" "[[ $RC -eq 1 ]]" +check "no variables API call" "[[ ! -f \"\$STUB_PUT_DIR/varapi.log\" ]]" +check "error names the variable" "grep -q 'CURSOR_REVIEW_CALLERS variable is missing or empty' <<<\"\$OUT\"" +check "no commit made" "[[ ! -f \"\$STUB_PUT_DIR/count\" ]]" + +echo "== fetch path needs GITHUB_REPOSITORY — unset is a named error, not a crash ==" +new_case norepo +# shellcheck disable=SC2034 +OUT=$(env -u GITHUB_REPOSITORY GH_TOKEN=x NEW_SHA="$NEW_SHA" \ + STUB_CONTENT_FILE="$CR_FIXTURE" STUB_PUT_DIR="$STUB_PUT_DIR" \ + VAR_NAME=CURSOR_REVIEW_CALLERS TAG=cursor-review WORKFLOW_FILE=cursor-review.yml \ + bash "$BUMP" 2>&1) +RC=$? +check "exit 1" "[[ $RC -eq 1 ]]" +check "error names the variable" "grep -q '::error::GITHUB_REPOSITORY is unset' <<<\"\$OUT\"" +check "no variables API call" "[[ ! -f \"\$STUB_PUT_DIR/varapi.log\" ]]" + echo echo "== $PASS passed, $FAIL failed ==" [[ $FAIL -eq 0 ]] diff --git a/.github/workflows/bump-agents-md-callers.yml b/.github/workflows/bump-agents-md-callers.yml index 6a3df08..f227cc8 100644 --- a/.github/workflows/bump-agents-md-callers.yml +++ b/.github/workflows/bump-agents-md-callers.yml @@ -78,19 +78,47 @@ jobs: private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} owner: Comfy-Org + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows + - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: AGENTS_MD_CALLERS TAG: agents-md-integrity WORKFLOW_FILE: agents-md-integrity.yml # Seeded empty; an empty list is a clean no-op, not an error. ALLOW_EMPTY: "true" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo or its logs. - CALLERS_JSON: ${{ vars.AGENTS_MD_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/.github/workflows/bump-assign-reviewers-callers.yml b/.github/workflows/bump-assign-reviewers-callers.yml index 1ef7906..d616936 100644 --- a/.github/workflows/bump-assign-reviewers-callers.yml +++ b/.github/workflows/bump-assign-reviewers-callers.yml @@ -83,19 +83,47 @@ jobs: private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} owner: Comfy-Org + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows + - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: ASSIGN_REVIEWERS_CALLERS TAG: assign-reviewers WORKFLOW_FILE: assign-reviewers.yml # Seeded empty; an empty list is a clean no-op, not an error. ALLOW_EMPTY: "true" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo or its logs. - CALLERS_JSON: ${{ vars.ASSIGN_REVIEWERS_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/.github/workflows/bump-auto-label-callers.yml b/.github/workflows/bump-auto-label-callers.yml index b7d9bbb..f006e80 100644 --- a/.github/workflows/bump-auto-label-callers.yml +++ b/.github/workflows/bump-auto-label-callers.yml @@ -34,14 +34,14 @@ name: Bump cursor-review-auto-label callers # repo-level Actions variable `AUTO_LABEL_CALLERS` (config, not a credential — a # variable, not a secret, since secrets are write-only via the API) as a JSON # array of {"repo","file","label"} objects, same shape as CURSOR_REVIEW_CALLERS. -# `bump-callers.sh` `::add-mask::`es every repo name before IT echoes anything. -# KNOWN GAP (shared by all six entrypoints, tracked separately): passing the -# roster through this step's `env:` means Actions prints the raw value in the -# step's env dump before the script — and its masking — ever runs. Closing it -# means reading the variable at run time (`gh variable get`) and masking it -# first, which needs a token permission this fleet does not mint today; it is a -# fleet-wide change, not one this entrypoint can make alone. Until then, treat -# the roster as visible in the public run log. +# `bump-callers.sh` READS that variable itself, at run time, and +# `::add-mask::`es every repo name before anything is echoed — the roster is +# never bound into this step's `env:`, because Actions prints a step's env block +# into the (public) run log BEFORE the step runs, which put the raw roster in +# front of the masking that was supposed to cover it (BE-6482, closing BE-6477). +# That read needs a token able to read Actions variables, which a downscoped +# token is not — so it runs under a SECOND, repo-scoped app token minted below, +# leaving the fleet's WRITE token downscoped as it always was. # Unlike the fleets seeded empty, this one is seeded NON-EMPTY (the # callers already existed before the bumper did), so ALLOW_EMPTY is false below: # an empty list here means the variable was clobbered, not that the fleet has no @@ -112,34 +112,62 @@ jobs: # this token must reach are not knowable at authoring time — and naming # them here would leak private caller names into a public file. # - # The PERMISSIONS can be narrowed without naming anything, so they are: - # unset, the minted token carries every permission the app holds on every - # repo it is installed on, far past what this bumper needs. It commits - # the rewritten caller file via the Git Data API (contents) and opens or - # updates the bump PR (pull-requests); `gh pr create --label` stamps the - # caller's label through the issues API (issues) — same trio, and the - # same reasoning, as the groom fleet's entrypoint. - owner: Comfy-Org + # This is the WRITE token and it STAYS downscoped to the three + # permissions the bumper actually uses: it commits the rewritten caller + # file (contents), opens/updates the bump PR (pull-requests) and stamps + # its label (issues). A downscoped token cannot read Actions variables, + # so the run-time roster read (BE-6482) does NOT use this token — the + # separate, repo-scoped var_token step below does. Do not merge the two: + # one token for both jobs would have to be minted with no `permission-*` + # inputs AND owner-wide, i.e. full installation permissions on every repo + # the App is installed on, just to read one variable. permission-contents: write permission-pull-requests: write permission-issues: write + owner: Comfy-Org + + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: AUTO_LABEL_CALLERS TAG: auto-label WORKFLOW_FILE: cursor-review-auto-label.yml # Seeded NON-EMPTY (the callers predate this fleet), so an empty list # means the variable was clobbered — fail rather than no-op silently. ALLOW_EMPTY: "false" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo. NOTE: this env binding is itself the - # known log-exposure gap documented in the header — Actions dumps the - # step env before the script's `::add-mask::` runs. - CALLERS_JSON: ${{ vars.AUTO_LABEL_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/.github/workflows/bump-cursor-review-callers.yml b/.github/workflows/bump-cursor-review-callers.yml index 151b19b..628030f 100644 --- a/.github/workflows/bump-cursor-review-callers.yml +++ b/.github/workflows/bump-cursor-review-callers.yml @@ -90,17 +90,45 @@ jobs: private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} owner: Comfy-Org + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows + - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: CURSOR_REVIEW_CALLERS TAG: cursor-review WORKFLOW_FILE: cursor-review.yml - # JSON array of {"repo","file","label","wire_bot"} — see the header - # comment for the update flow. Kept in a variable (not the file) so - # private caller names never land in this public repo or its logs. - CALLERS_JSON: ${{ vars.CURSOR_REVIEW_CALLERS }} # Only this fleet's callers ever carry `wire_bot` (BE-1814) — the # agents-md-integrity entrypoint never sets this, so bump-callers.sh # skips wiring there even if its variable somehow set the flag. diff --git a/.github/workflows/bump-detect-unreviewed-merge-callers.yml b/.github/workflows/bump-detect-unreviewed-merge-callers.yml index 749b33a..40deca2 100644 --- a/.github/workflows/bump-detect-unreviewed-merge-callers.yml +++ b/.github/workflows/bump-detect-unreviewed-merge-callers.yml @@ -25,25 +25,31 @@ name: Bump detect-unreviewed-merge callers # repo-level Actions variable `DETECT_UNREVIEWED_MERGE_CALLERS` (config, not a # credential — a variable, not a secret, since secrets are write-only via the # API) as a JSON array of {"repo","file","label"} objects, same shape as the -# other fleets' variables. `bump-callers.sh` `::add-mask::`es every repo name -# before IT echoes anything. -# KNOWN GAP (shared by every bump-* entrypoint, tracked separately): passing the -# roster through this step's `env:` means Actions prints the raw value in the -# step's env dump before the script — and its masking — ever runs. Closing it -# means reading the variable at run time (`gh variable get`) and masking it -# first, which needs a token permission this fleet does not mint today; it is a -# fleet-wide change, not one this entrypoint can make alone. Until then, treat -# the roster as visible in the public run log. +# other fleets' variables. +# `bump-callers.sh` READS that variable itself, at run time, and +# `::add-mask::`es every repo name before anything is echoed — the roster is +# never bound into this step's `env:`, because Actions prints a step's env block +# into the (public) run log BEFORE the step runs, which put the raw roster in +# front of the masking that was supposed to cover it (BE-6482, closing BE-6477). +# That read needs a token able to read Actions variables, which a downscoped +# token is not — so it runs under a SECOND, repo-scoped app token minted below, +# leaving the fleet's WRITE token downscoped as it always was. # -# CONSEQUENCE — this fleet ships DELIBERATELY UNSEEDED. Every other seeded fleet -# already publishes its roster this way, so their names are out; this fleet's -# callers include two non-public repos that no existing roster contains, and -# seeding before the masking fix lands would publish them on the very first run. -# A public log entry cannot be unpublished; a red run can. So the variable stays -# unset until the masking fix lands, and the resulting hard-fail is the -# intentional nag (see ALLOW_EMPTY below) rather than an oversight. Seed it — -# `gh variable set` per the update flow below — as the immediate follow-on to -# that fix, NOT as a way to turn the red run green. +# CONSEQUENCE — this fleet shipped DELIBERATELY UNSEEDED, and is still unseeded +# as of this commit. Every already-seeded fleet had published its roster through +# the old env-block leak, so those names are out; this fleet's callers include +# two non-public repos that appear in NO existing roster, and seeding before the +# fix would have published them on the very first run. A public log entry cannot +# be unpublished; a red run can — so the variable stayed unset and the resulting +# hard-fail was the intentional nag (see ALLOW_EMPTY below), not an oversight. +# +# THE BLOCKER IS NOW GONE: the roster is read at run time and masked before the +# first log line. Seeding is the follow-on — `gh variable set` per the update +# flow below — once a dispatch of this entrypoint has been observed reading its +# variable WITHOUT the "cannot read Actions variables" error (that error means +# the App still lacks the repository "Variables: read" grant, and a roster it +# cannot read is a roster it cannot mask either). Seed it because that check +# passed, never as a way to turn the red run green. # # ALLOW_EMPTY is set to "false" below — already `bump-callers.sh`'s default, but # stated explicitly (as `bump-auto-label-callers.yml` does) so the property @@ -52,14 +58,13 @@ name: Bump detect-unreviewed-merge callers # never be a silent no-op: that would leave every caller un-bumped with nobody # noticing, exactly the drift this directory exists to prevent. # -# READ THIS BEFORE ACTING ON A RED RUN. An empty roster right now is the EXPECTED -# PRE-SEED STATE described above, not a clobbered variable. Until the masking fix -# lands, the correct response to that red run is to leave it red — do NOT "fix" -# it by seeding the variable, which is precisely the irreversible disclosure this -# header exists to prevent. Once masking is in place, seeding is the follow-on -# and the run goes green on its own. (After that point, and only after it, an -# empty roster does mean the variable was clobbered and should be restored.) -# Either way the answer is never to flip ALLOW_EMPTY on. +# READ THIS BEFORE ACTING ON A RED RUN. While this variable is still unset, an +# empty roster is the EXPECTED PRE-SEED STATE described above, not a clobbered +# variable — the fix removed the reason to WAIT, it did not seed anything. The +# response to that red run is to work the seeding follow-on above, not to reach +# for ALLOW_EMPTY. Once the variable has been seeded, and only then, an empty +# roster does mean it was clobbered and should be restored. Either way the answer +# is never to flip ALLOW_EMPTY on. # # Update flow — adding/removing a caller needs NO public commit: # gh variable set DETECT_UNREVIEWED_MERGE_CALLERS --repo Comfy-Org/github-workflows \ @@ -129,22 +134,56 @@ jobs: # this token must reach are not knowable at authoring time — and naming # them here would leak private caller names into a public file. # - # The PERMISSIONS can be narrowed without naming anything, so they are: - # unset, the minted token carries every permission the app holds on every - # repo it is installed on, far past what this bumper needs. It commits - # the rewritten caller file via the Git Data API (contents) and opens or - # updates the bump PR (pull-requests); `gh pr create --label` stamps the - # caller's label through the issues API (issues) — same trio, and the - # same reasoning, as the auto-label and groom fleets' entrypoints. - owner: Comfy-Org + # This is the WRITE token and it STAYS downscoped to the three + # permissions the bumper actually uses: it commits the rewritten caller + # file (contents), opens/updates the bump PR (pull-requests) and stamps + # its label (issues). A downscoped token cannot read Actions variables, + # so the run-time roster read (BE-6482) does NOT use this token — the + # separate, repo-scoped var_token step below does. Do not merge the two: + # one token for both jobs would have to be minted with no `permission-*` + # inputs AND owner-wide, i.e. full installation permissions on every repo + # the App is installed on, just to read one variable. permission-contents: write permission-pull-requests: write permission-issues: write + owner: Comfy-Org + + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: DETECT_UNREVIEWED_MERGE_CALLERS TAG: detect-unreviewed-merge WORKFLOW_FILE: detect-unreviewed-merge.yml @@ -152,17 +191,13 @@ jobs: # header: this fleet has a known non-empty set of live callers, so an # empty roster is never "this fleet has no members" and must fail # loudly regardless of what that default becomes. RIGHT NOW that - # failure is EXPECTED — the roster ships unseeded until the masking - # fix lands (header, "READ THIS BEFORE ACTING ON A RED RUN"). Do NOT - # respond to it by seeding the variable; only after masking is in - # place does an empty roster mean it was clobbered. + # failure is still EXPECTED — the roster is not seeded yet. The masking + # blocker that kept it unseeded is GONE (header, "THE BLOCKER IS NOW + # GONE"), so seeding is now the follow-on rather than something to hold + # off on: seed it per the header's precondition — once a dispatch has + # been observed reading the variable without the "cannot read Actions + # variables" error — and never merely to turn the red run green. ALLOW_EMPTY: "false" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo. NOTE: this env binding is itself the - # known log-exposure gap documented in the header — Actions dumps the - # step env before the script's `::add-mask::` runs. - CALLERS_JSON: ${{ vars.DETECT_UNREVIEWED_MERGE_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/.github/workflows/bump-groom-callers.yml b/.github/workflows/bump-groom-callers.yml index 2ccf223..c3e824a 100644 --- a/.github/workflows/bump-groom-callers.yml +++ b/.github/workflows/bump-groom-callers.yml @@ -95,29 +95,59 @@ jobs: # this token must reach are not knowable at authoring time — and naming # them here would leak private caller names into a public file. # - # The PERMISSIONS can be narrowed without naming anything, so they are: - # unset, the minted token carries every permission the app holds on every - # repo it is installed on, far past what this bumper needs. It commits - # the rewritten caller file via the Git Data API (contents) and opens or - # updates the bump PR (pull-requests); `gh pr create --label` stamps the - # caller's label through the issues API (issues) — same trio, and the - # same reasoning, as groom.yml's own PR job. - owner: Comfy-Org + # This is the WRITE token and it STAYS downscoped to the three + # permissions the bumper actually uses: it commits the rewritten caller + # file (contents), opens/updates the bump PR (pull-requests) and stamps + # its label (issues). A downscoped token cannot read Actions variables, + # so the run-time roster read (BE-6482) does NOT use this token — the + # separate, repo-scoped var_token step below does. Do not merge the two: + # one token for both jobs would have to be minted with no `permission-*` + # inputs AND owner-wide, i.e. full installation permissions on every repo + # the App is installed on, just to read one variable. permission-contents: write permission-pull-requests: write permission-issues: write + owner: Comfy-Org + + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: GROOM_CALLERS TAG: groom WORKFLOW_FILE: groom.yml - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so caller names never - # land in this public repo or its logs. - CALLERS_JSON: ${{ vars.GROOM_CALLERS }} # groom is still rolling out repo by repo, so an empty list is a clean # no-op rather than a hard failure — same posture as the agents-md / # pr-size / assign-reviewers fleets. Flip to the default (unset) once diff --git a/.github/workflows/bump-pr-risk-callers.yml b/.github/workflows/bump-pr-risk-callers.yml index bc16ec7..2582165 100644 --- a/.github/workflows/bump-pr-risk-callers.yml +++ b/.github/workflows/bump-pr-risk-callers.yml @@ -24,15 +24,15 @@ name: Bump pr-risk callers # repo-level Actions variable `PR_RISK_CALLERS` (config, not a credential — a # variable, not a secret, since secrets are write-only via the API) as a JSON # array of {"repo","file","label"} objects, same shape as the other fleets' -# variables. `bump-callers.sh` `::add-mask::`es every repo name before IT echoes -# anything. -# KNOWN GAP (shared by every entrypoint in this family, tracked separately): -# passing the roster through this step's `env:` means Actions prints the raw -# value in the step's env dump before the script — and its masking — ever runs. -# Closing it means reading the variable at run time (`gh variable get`) and -# masking it first, which needs a token permission this fleet does not mint -# today; it is a fleet-wide change, not one this entrypoint can make alone. -# Until then, treat the roster as visible in the public run log. +# variables. +# `bump-callers.sh` READS that variable itself, at run time, and +# `::add-mask::`es every repo name before anything is echoed — the roster is +# never bound into this step's `env:`, because Actions prints a step's env block +# into the (public) run log BEFORE the step runs, which put the raw roster in +# front of the masking that was supposed to cover it (BE-6482, closing BE-6477). +# That read needs a token able to read Actions variables, which a downscoped +# token is not — so it runs under a SECOND, repo-scoped app token minted below, +# leaving the fleet's WRITE token downscoped as it always was. # The variable is seeded EMPTY (`[]`) — an empty list is a clean no-op here # (ALLOW_EMPTY below); the rollout's per-repo caller tickets add entries as # callers land. @@ -117,33 +117,61 @@ jobs: # this token must reach are not knowable at authoring time — and naming # them here would leak private caller names into a public file. # - # The PERMISSIONS can be narrowed without naming anything, so they are: - # unset, the minted token carries every permission the app holds on every - # repo it is installed on, far past what this bumper needs. It commits - # the rewritten caller file via the Git Data API (contents) and opens or - # updates the bump PR (pull-requests); `gh pr create --label` stamps the - # caller's label through the issues API (issues) — same trio, and the - # same reasoning, as the groom and auto-label fleets' entrypoints. - owner: Comfy-Org + # This is the WRITE token and it STAYS downscoped to the three + # permissions the bumper actually uses: it commits the rewritten caller + # file (contents), opens/updates the bump PR (pull-requests) and stamps + # its label (issues). A downscoped token cannot read Actions variables, + # so the run-time roster read (BE-6482) does NOT use this token — the + # separate, repo-scoped var_token step below does. Do not merge the two: + # one token for both jobs would have to be minted with no `permission-*` + # inputs AND owner-wide, i.e. full installation permissions on every repo + # the App is installed on, just to read one variable. permission-contents: write permission-pull-requests: write permission-issues: write + owner: Comfy-Org + + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: PR_RISK_CALLERS TAG: pr-risk WORKFLOW_FILE: pr-risk.yml # Seeded empty; an empty list is a clean no-op, not an error. ALLOW_EMPTY: "true" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo. NOTE: this env binding is itself the - # known log-exposure gap documented in the header — Actions dumps the - # step env before the script's `::add-mask::` runs. - CALLERS_JSON: ${{ vars.PR_RISK_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/.github/workflows/bump-pr-size-callers.yml b/.github/workflows/bump-pr-size-callers.yml index e9eda92..0a2d579 100644 --- a/.github/workflows/bump-pr-size-callers.yml +++ b/.github/workflows/bump-pr-size-callers.yml @@ -82,19 +82,47 @@ jobs: private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} owner: Comfy-Org + # A SECOND app token, for the ROSTER READ ALONE. Scoped to THIS repo + # (`repositories: github-workflows` — public, so nothing leaks by naming it) + # and deliberately left un-downscoped, so it carries the App's + # "Variables: read" grant: the `app-permissions` schema — the + # installation-token request body — has no `variables` key at all, so there + # is no `permission-variables` input to ask for in ANY version of + # create-github-app-token, and asking for nothing is the only spelling that + # yields that grant (BE-6477). + # + # It exists so the WRITE token above can stay downscoped. One token serving + # both purposes would have to be un-downscoped AND owner-wide — full + # installation permissions on EVERY repo the App is installed on, most of + # them private callers — purely to enable one read of this repo's own + # variable. This token is un-downscoped too, but `repositories:` confines it + # to this ONE public repo, so that is the whole of its blast radius. + - name: Generate roster-read token (this repo only) + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 — pinned: see dependabot.yml ignore (v2+ breaks cross-repo `owner:` token scoping) + id: var_token + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.CLOUD_CODE_BOT_PRIVATE_KEY }} + owner: Comfy-Org + repositories: github-workflows + - name: Bump SHA in caller repos env: GH_TOKEN: ${{ steps.token.outputs.token }} + # The this-repo-only token, used ONLY for the roster read, so the write + # token above can stay downscoped (BE-6482 review). + VAR_TOKEN: ${{ steps.var_token.outputs.token }} NEW_SHA: ${{ github.sha }} + # Names the fleet's Actions variable. bump-callers.sh READS the roster + # out of it at run time (BE-6482) — it is deliberately NOT passed in + # through this `env:` block, because Actions prints a step's env into + # this PUBLIC repo's run log BEFORE the step runs, i.e. before the + # script's `::add-mask::` could cover the private caller names. VAR_NAME: PR_SIZE_CALLERS TAG: pr-size WORKFLOW_FILE: pr-size.yml # Seeded empty; an empty list is a clean no-op, not an error. ALLOW_EMPTY: "true" - # JSON array of {"repo","file","label"} — see the header comment for the - # update flow. Kept in a variable (not the file) so private caller names - # never land in this public repo or its logs. - CALLERS_JSON: ${{ vars.PR_SIZE_CALLERS }} run: | # The main-only ref guard above cannot catch a manual RE-RUN of an # older main run: github.ref is refs/heads/main but github.sha is diff --git a/AGENTS.md b/AGENTS.md index 568bc36..2b4e004 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,8 +111,9 @@ tests — run the matching command above for whatever you touched. `assign-prs-to-author.yml` have no fleet because they have no callers; `detect-unreviewed-merge.yml`'s fleet is `bump-detect-unreviewed-merge-callers.yml`; its `DETECT_UNREVIEWED_MERGE_CALLERS` - roster is deliberately UNSEEDED (so it hard-fails) until the run-log masking - gap is closed — see the bump-callers README. + roster is still UNSEEDED (so it hard-fails) — the run-log masking gap that + blocked seeding is closed (BE-6482), so seeding is now an operator follow-on; + see the bump-callers README. - `bump-cursor-cli-pin.yml` — weekly PR moving `CURSOR_CLI_VERSION` / `CURSOR_CLI_SHA256` in `cursor-review.yml` (BE-5870). Not a caller bumper: merging it trips `bump-cursor-review-callers.yml`'s path filter, which rolls