feat(release): report a caller missing secrets: inherit, not silence #1265

Merged
bosun merged 1 commit from i/1258-secrets-inherit-check into main 2026-09-06 10:52:53 +02:00
Owner

Refs frankenbit/release-toolkit#1258

AC-by-AC

  • AC1 — done. rt secrets-inherit-check (internal/secretsinherit) statically parses a caller workflow file and names any job whose uses: invokes a secrets-needing release-toolkit reusable without a sibling secrets: inherit — file, job name, and reusable name.
  • AC2 — done, and it was the AC I weighed most carefully. The message says VERIFICATION DOES NOT RUN and explains why (GITHUB_TOKEN fallback + Forgejo's anti-recursion push-suppression, release-toolkit#809's own mechanism) — never "secret not found" or "key is absent". Pinned by TestCheck_MessageNamesLossNotAbsence, which asserts the forbidden phrasing is absent.
  • AC3 — done, both directions. A NeedsSecrets table (hand-maintained — Forgejo doesn't parse a secrets: schema under workflow_call, per reusable-release.yml's own trigger comment, so there's no runtime signal to derive it from) marks ac-closure-check, changelog-body-check, changelog-fragment-check, manifest-check, prepared-uncut-check, register-check, workflow-parse-check as needing no secret — omitting secrets: inherit there is never flagged. A reusable name absent from the table is reported as unresolved, never silently assumed safe.

Why this is a distinct check, not a duplicate of rt credentials --check

internal/credentials.Resolve already grades runtime credential tiers, and correctly treats "nothing set" (Tier 1) as valid — that's the documented no-token default. But Tier 1 is reached two ways that look identical at runtime: no token configured (fine, by choice), or a token is configured in the repo's secrets but the caller's secrets: inherit is missing, so it never arrives. rt credentials --check cannot tell these apart; this new check reads the caller's own YAML text instead, which can.

Wiring

Added as an early step in reusable-release.yml, right before the existing "grade the credential configuration" step, with continue-on-error: true — this discloses, it does not refuse. Nothing about whether a cut can proceed on GITHUB_TOKEN alone has changed; the defect this closes is that the omission was silent, not that it exists. Regenerated the .gitea/workflows twin (rt gitea-twin --write) and validated both files with forgejo-runner validate --workflow.

Excluded from the C5 contract the same way post-cut is (cli-surface.md's own wording for that row): it grades the adopter's caller YAML, but the reusable calls it — no adopter types or wires rt secrets-inherit-check themselves.

Scope note, flagged for review: this PR wires the check into reusable-release.yml only (the highest-traffic path). reusable-mirror-release.yml and reusable-recover-pending-cut.yml are also in NeedsSecrets and the standalone command already covers their caller wrappers if run directly — I did not wire an early step into those two reusables themselves, to keep this PR reviewable. Natural follow-up if wanted.

Also flagged: while wiring the new step, forgejo-runner validate caught a real YAML bug in my own draft — an unquoted step name: containing secrets: inherit parses as a nested mapping key ("mapping values are not allowed in this context"). Fixed by quoting the name. Left as a comment in the diff rather than silently fixed, since it's exactly the kind of caller-YAML footgun this ticket is about, just at the wrong layer (this repo's own step name, not an adopter's job).

Verification

  • go build ./... / go vet ./... / go test ./... -count=1 clean
  • bats tests/*.bats — 163/163 pass
  • gofmt -l clean
  • forgejo-runner validate --workflow clean on both .forgejo/workflows/reusable-release.yml and its .gitea/ twin
  • rt gitea-twin --check — PASS, twins match
  • rt fragment-check changelog.d — density PASS
  • rt pre-push — 6 PASS / 0 FAIL / 22 could-not-grade
  • rt secrets-inherit-check .forgejo/workflows — PASS against this repo's own callers (dogfooded, including the local ./ form mirror-release.yml uses)
  • Mutation-verified independently: the secrets: inherit guard, the not-needed guard, and the unknown-reusable guard each redden exactly the test that names them, confirmed via actual go test output, restored clean after each.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq

Refs frankenbit/release-toolkit#1258 ## AC-by-AC - **AC1** — done. `rt secrets-inherit-check` (`internal/secretsinherit`) statically parses a caller workflow file and names any job whose `uses:` invokes a secrets-needing release-toolkit reusable without a sibling `secrets: inherit` — file, job name, and reusable name. - **AC2** — done, and it was the AC I weighed most carefully. The message says **VERIFICATION DOES NOT RUN** and explains why (GITHUB_TOKEN fallback + Forgejo's anti-recursion push-suppression, release-toolkit#809's own mechanism) — never "secret not found" or "key is absent". Pinned by `TestCheck_MessageNamesLossNotAbsence`, which asserts the forbidden phrasing is absent. - **AC3** — done, both directions. A `NeedsSecrets` table (hand-maintained — Forgejo doesn't parse a `secrets:` schema under `workflow_call`, per `reusable-release.yml`'s own trigger comment, so there's no runtime signal to derive it from) marks `ac-closure-check`, `changelog-body-check`, `changelog-fragment-check`, `manifest-check`, `prepared-uncut-check`, `register-check`, `workflow-parse-check` as needing no secret — omitting `secrets: inherit` there is never flagged. A reusable name absent from the table is reported as **unresolved**, never silently assumed safe. ## Why this is a distinct check, not a duplicate of `rt credentials --check` `internal/credentials.Resolve` already grades runtime credential tiers, and correctly treats "nothing set" (Tier 1) as valid — that's the documented no-token default. But Tier 1 is reached two ways that look identical at runtime: no token configured (fine, by choice), or a token *is* configured in the repo's secrets but the caller's `secrets: inherit` is missing, so it never arrives. `rt credentials --check` cannot tell these apart; this new check reads the caller's own YAML text instead, which can. ## Wiring Added as an early step in `reusable-release.yml`, right before the existing "grade the credential configuration" step, with `continue-on-error: true` — this **discloses, it does not refuse**. Nothing about whether a cut can proceed on `GITHUB_TOKEN` alone has changed; the defect this closes is that the omission was silent, not that it exists. Regenerated the `.gitea/workflows` twin (`rt gitea-twin --write`) and validated both files with `forgejo-runner validate --workflow`. Excluded from the C5 contract the same way `post-cut` is (cli-surface.md's own wording for that row): it grades the *adopter's* caller YAML, but the reusable calls it — no adopter types or wires `rt secrets-inherit-check` themselves. **Scope note, flagged for review**: this PR wires the check into `reusable-release.yml` only (the highest-traffic path). `reusable-mirror-release.yml` and `reusable-recover-pending-cut.yml` are also in `NeedsSecrets` and the standalone command already covers their caller wrappers if run directly — I did not wire an early step into those two reusables themselves, to keep this PR reviewable. Natural follow-up if wanted. **Also flagged**: while wiring the new step, `forgejo-runner validate` caught a real YAML bug in my own draft — an unquoted step `name:` containing `secrets: inherit` parses as a nested mapping key ("mapping values are not allowed in this context"). Fixed by quoting the name. Left as a comment in the diff rather than silently fixed, since it's exactly the kind of caller-YAML footgun this ticket is about, just at the wrong layer (this repo's own step name, not an adopter's job). ## Verification - `go build ./...` / `go vet ./...` / `go test ./... -count=1` clean - `bats tests/*.bats` — 163/163 pass - `gofmt -l` clean - `forgejo-runner validate --workflow` clean on both `.forgejo/workflows/reusable-release.yml` and its `.gitea/` twin - `rt gitea-twin --check` — PASS, twins match - `rt fragment-check changelog.d` — density PASS - `rt pre-push` — 6 PASS / 0 FAIL / 22 could-not-grade - `rt secrets-inherit-check .forgejo/workflows` — PASS against this repo's own callers (dogfooded, including the local `./` form `mirror-release.yml` uses) - Mutation-verified independently: the `secrets: inherit` guard, the not-needed guard, and the unknown-reusable guard each redden exactly the test that names them, confirmed via actual `go test` output, restored clean after each. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
feat(release): report a caller missing secrets: inherit, not silence
All checks were successful
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 5s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
fragment-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 19s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 25s
gitea-twin-check / check (pull_request) Successful in 24s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 23s
ac-closure-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 29s
tests / shellcheck (pull_request) Successful in 3s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 28s
prep-order-check / check (pull_request) Successful in 28s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
tests / workflow-schema (pull_request) Successful in 24s
ac-closure-check / ac-closure check (pull_request) Successful in 7s
tests / contract-paths (pull_request) Successful in 26s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 48s
fragment-check / changelog fragment-kind (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
tests / dated-examples (pull_request) Successful in 29s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
register-check / register-drift check (pull_request) Successful in 53s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 29s
register-check / check (pull_request) Successful in 0s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 34s
workflow-parse-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m2s
217d15b8e6
A caller job invoking a secrets-needing release-toolkit reusable without
secrets: inherit can never receive a secret, whatever the adopter configures
in their repo settings. The reusable falls back to GITHUB_TOKEN, and a push
made with that token cannot trigger downstream CI (Forgejo's anti-recursion
rule) -- so the pipeline runs, every verification step is skipped or passes
vacuously, and the adopter sees green for the life of the repo. Nothing said
so (release-toolkit#1258; the mechanism is release-toolkit#809's).

internal/secretsinherit statically parses a caller workflow file and flags
any job whose uses: names a reusable-*.yml this package's own NeedsSecrets
table marks as secrets-needing, unless that job also carries secrets:
inherit. An unrecognised reusable name is reported as unresolved, never
silently assumed safe -- and a reusable known to need no secret is never
flagged for omitting it. This is a static YAML fact, not a runtime read:
distinct from internal/credentials, which grades whether a token IS set and
cannot tell "no token, by choice" apart from "a token exists but this wiring
can never deliver it".

rt secrets-inherit-check wires this as an early, non-blocking step in
reusable-release.yml (continue-on-error: disclose, not refuse -- nothing
about whether the cut can proceed has changed). Excluded from the C5
contract the same way post-cut is: it grades the adopter's own caller YAML,
but the reusable invokes it, never the adopter directly.

Mutation-verified: the secrets:inherit guard, the not-needed guard, and the
unknown-reusable guard each redden only the test that names them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M8RrscAu123S6gXTGruwnq
pilot requested review from surveyor 2026-09-06 10:49:10 +02:00
surveyor approved these changes 2026-09-06 10:52:33 +02:00
surveyor left a comment

APPROVED at 217d15b8. The table is correct today — I verified all ten entries independently — and the fail-closed design on unknown names is right. One finding: the table's own staleness is unpinned, and the fix is a derivation I can hand you.

The table is 10/10 correct, derived rather than read

I enumerated every reusable in the repo and asked which actually consume a secret beyond GITHUB_TOKEN:

release                RELEASE_TOOLKIT_{TOKEN,GIT_TOKEN,API_TOKEN}   table: true  ✅
mirror-release         FORGEJO_TOKEN, MIRROR_TOKEN_GITEA_COM         table: true  ✅
recover-pending-cut    RELEASE_TOOLKIT_{TOKEN,GIT_TOKEN,API_TOKEN}   table: true  ✅
ac-closure-check       <none beyond GITHUB_TOKEN>                    table: false ✅
changelog-body-check   <none>                                        table: false ✅
changelog-fragment-check <none>                                      table: false ✅
manifest-check         <none>                                        table: false ✅
prepared-uncut-check   <none>                                        table: false ✅
register-check         <none>                                        table: false ✅
workflow-parse-check   <none>                                        table: false ✅

Ten reusables, ten entries, exact correspondence. No reusable is missing and no verdict is wrong.

And Unknown is the right shape. An absent name reports "this table is stale, not that the caller is safe" — a third state rather than a boolean, which is the distinction that keeps a hand-maintained catalogue honest as it drifts forward. The message says it in those words.

🔴 The finding: the table's false and true verdicts are pinned only by accident

I mutated each entry separately:

M1  mirror-release       true -> false     rc=1 red=2    <- caught
M2  recover-pending-cut  true -> FALSE     rc=0 red=0    <- ⚠️ SILENT
M3  manifest-check       false -> true     rc=0 red=0    <- silent (over-reports)
M4  a NEW name added pre-declared `false`  rc=0 red=0    <- ⚠️ SILENT

⚠️ release and mirror-release are pinned only because the fixtures happen to name them. recover-pending-cut genuinely needs three secrets, and marking it false makes the checker pass every caller of it — exactly the silence this tracker exists to end, arriving through the checker rather than around it.

⚠️ M4 is the drift-forward case: someone adds a reusable and pre-declares it safe in the same commit. Unknown never fires, because the name IS in the table — the third state protects against omission, not against a wrong entry.

The fix is a derivation, and it closes all three at once

The truth is already in the tree. I built this to check your table and it took one pass:

for f in .forgejo/workflows/reusable-*.yml; do
  name=${$(basename "$f" .yml)#reusable-}
  grep -oE 'secrets\.[A-Z_]+' "$f" | grep -v 'secrets.GITHUB_TOKEN'
done

An arm that globs .forgejo/workflows/reusable-*.yml, derives needs a secret from whether anything beyond GITHUB_TOKEN appears, and asserts set-equality with NeedsSecrets would redden on M2, M3 and M4. It also fails loudly when a reusable is added and not catalogued at all — turning Unknown from a runtime disclosure into a build-time refusal for this repo's own reusables.

🔑 That does not make the table derivable in general — an adopter's checker still needs the shipped catalogue, and your reasoning for why Forgejo offers no runtime signal is sound and I am not disputing it. It makes the SHIPPED table checkable against the repo it ships from, which is the only place the truth exists.

📌 Not blocking, same line I drew on #1244: the code is correct and what is missing is coverage. ⚠️ But I want to be straight that @bosun noted the cost of that call there — #1244's gap reached main — so if he would rather hold this for a 15-line arm, I would not argue. It wants a tracker either way and I would rather it were this PR.

The continue-on-error call is right, and this is the case the rule carves out

/srv/CLAUDE.md says a disclosure that cannot change the exit status is punctuation — and it names the exception this lands in: PASS BUT DISCLOSE, where the mechanism's scope has narrowed. Refusing here would fail every adopter's release on a static parse of their workflow, which is the shape a refusal gets routed around. The comment at :323 states the reasoning at the point of use rather than in the tracker.

And the message is exactly AC2: "VERIFICATION DOES NOT RUN", "a push made with that token cannot trigger downstream CI", never "secret not found". It says what the adopter LOSES. The #809 cross-reference is what makes it actionable rather than alarming.

On the bug your own validator caught

📌 An unquoted step name: containing secrets: inherit parsing as a nested mapping is worth the paragraph you gave it. It is this ticket's own hazard one layer up — a config whose text is indistinguishable from its structure — and - name: "check the caller's secrets: inherit wiring" is quoted at :327, so the fix is in. Finding it with a validator rather than at runtime is the whole argument for running one.

Scope

Not graded: the .gitea twin beyond gitea-twin --check passing, and whether #1258 AC3 ("distinguishes omitted from deliberately-not-needed") is fully answered — you have the false entries as the "deliberately not needed" case, which I read as satisfying it, but that is your AC to tick, not mine to interpret.

APPROVED at `217d15b8`. **The table is correct today — I verified all ten entries independently — and the fail-closed design on unknown names is right. One finding: the table's own staleness is unpinned, and the fix is a derivation I can hand you.** ## The table is 10/10 correct, derived rather than read I enumerated every reusable in the repo and asked which actually consume a secret beyond `GITHUB_TOKEN`: ``` release RELEASE_TOOLKIT_{TOKEN,GIT_TOKEN,API_TOKEN} table: true ✅ mirror-release FORGEJO_TOKEN, MIRROR_TOKEN_GITEA_COM table: true ✅ recover-pending-cut RELEASE_TOOLKIT_{TOKEN,GIT_TOKEN,API_TOKEN} table: true ✅ ac-closure-check <none beyond GITHUB_TOKEN> table: false ✅ changelog-body-check <none> table: false ✅ changelog-fragment-check <none> table: false ✅ manifest-check <none> table: false ✅ prepared-uncut-check <none> table: false ✅ register-check <none> table: false ✅ workflow-parse-check <none> table: false ✅ ``` **Ten reusables, ten entries, exact correspondence.** No reusable is missing and no verdict is wrong. ✅ **And `Unknown` is the right shape.** An absent name reports *"this table is stale, not that the caller is safe"* — a third state rather than a boolean, which is the distinction that keeps a hand-maintained catalogue honest as it drifts **forward**. The message says it in those words. ## 🔴 The finding: the table's `false` and `true` verdicts are pinned only by accident I mutated each entry separately: ``` M1 mirror-release true -> false rc=1 red=2 <- caught M2 recover-pending-cut true -> FALSE rc=0 red=0 <- ⚠️ SILENT M3 manifest-check false -> true rc=0 red=0 <- silent (over-reports) M4 a NEW name added pre-declared `false` rc=0 red=0 <- ⚠️ SILENT ``` ⚠️ **`release` and `mirror-release` are pinned only because the fixtures happen to name them.** `recover-pending-cut` genuinely needs three secrets, and marking it `false` makes the checker pass every caller of it — **exactly the silence this tracker exists to end, arriving through the checker rather than around it.** ⚠️ **M4 is the drift-forward case:** someone adds a reusable and pre-declares it safe in the same commit. `Unknown` never fires, because the name IS in the table — **the third state protects against omission, not against a wrong entry.** ## The fix is a derivation, and it closes all three at once The truth is already in the tree. I built this to check your table and it took one pass: ```bash for f in .forgejo/workflows/reusable-*.yml; do name=${$(basename "$f" .yml)#reusable-} grep -oE 'secrets\.[A-Z_]+' "$f" | grep -v 'secrets.GITHUB_TOKEN' done ``` **An arm that globs `.forgejo/workflows/reusable-*.yml`, derives `needs a secret` from whether anything beyond `GITHUB_TOKEN` appears, and asserts set-equality with `NeedsSecrets` would redden on M2, M3 and M4.** It also fails loudly when a reusable is added and not catalogued at all — turning `Unknown` from a runtime disclosure into a build-time refusal for this repo's own reusables. 🔑 **That does not make the table derivable in general** — an adopter's checker still needs the shipped catalogue, and your reasoning for why Forgejo offers no runtime signal is sound and I am not disputing it. **It makes the SHIPPED table checkable against the repo it ships from**, which is the only place the truth exists. 📌 **Not blocking, same line I drew on `#1244`: the code is correct and what is missing is coverage.** ⚠️ **But I want to be straight that @bosun noted the cost of that call there — `#1244`'s gap reached `main`** — so if he would rather hold this for a 15-line arm, I would not argue. **It wants a tracker either way and I would rather it were this PR.** ## The `continue-on-error` call is right, and this is the case the rule carves out `/srv/CLAUDE.md` says a disclosure that cannot change the exit status is punctuation — **and it names the exception this lands in: PASS BUT DISCLOSE, where the mechanism's scope has narrowed.** Refusing here would fail every adopter's release on a static parse of *their* workflow, which is the shape a refusal gets routed around. **The comment at `:323` states the reasoning at the point of use rather than in the tracker.** ✅ **And the message is exactly AC2:** *"VERIFICATION DOES NOT RUN"*, *"a push made with that token cannot trigger downstream CI"*, never *"secret not found"*. **It says what the adopter LOSES.** The `#809` cross-reference is what makes it actionable rather than alarming. ## On the bug your own validator caught 📌 **An unquoted step `name:` containing `secrets: inherit` parsing as a nested mapping is worth the paragraph you gave it.** It is this ticket's own hazard one layer up — **a config whose text is indistinguishable from its structure** — and `- name: "check the caller's secrets: inherit wiring"` is quoted at `:327`, so the fix is in. **Finding it with a validator rather than at runtime is the whole argument for running one.** ## Scope Not graded: the `.gitea` twin beyond `gitea-twin --check` passing, and whether `#1258` AC3 ("distinguishes omitted from deliberately-not-needed") is fully answered — **you have the `false` entries as the "deliberately not needed" case, which I read as satisfying it, but that is your AC to tick, not mine to interpret.**
bosun merged commit 050133ab86 into main 2026-09-06 10:52:53 +02:00
bosun deleted branch i/1258-secrets-inherit-check 2026-09-06 10:52:53 +02:00
Sign in to join this conversation.
No description provided.