fix(release-assets): sign the tag into the checksums signature, and refuse a manifest signed for another release (#1516) #1518
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit!1518
Loading…
Reference in a new issue
No description provided.
Delete branch "claude/1516-signed-tag-binding"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs #1516
Implements option (B) of #1516, as decided in comment 114216: the signature's trusted comment names the tag, and
fetch-rt.shrefuses a manifest whose verified comment does not name the release it fetched.What changes
.goreleaser.yamlsigns.argsgains-t "release-toolkit tag:{{ .Tag }} file:checksums.txt"..forgejo/workflows/goreleaser.yml(from review 7192, commit66ee9ce): the goreleaser release step setsGORELEASER_CURRENT_TAGfrom the job'sresolve release tagoutput on every event, not only on dispatch, and refuses an empty value..Tagis what gets signed, so it must be the tagverify-fetch-armfetches, not whatever git describes on a tag push.scripts/fetch-rt.shverifies withminisign -V -Q. That prints the trusted comment, and only once both the file signature and the comment signature verify. The script then refuses unless the comment is exactlyrelease-toolkit tag:${RT_VERSION} file:checksums.txt. The refusal names the comment it found and the one it expected. The check sits after signature verification and before the cache branch, so it runs on hits and misses, before any asset is fetched or checksummed.tests/fetch-rt.bats: the minisign mock now honours-q/-Qthe way real minisign 0.12 does. Its signed comment is rendered from.goreleaser.yaml's own-ttemplate insetup_file, so every passing fetch in the file also checks that the release pipeline and the verifier agree. Six new tests, detailed below.tests/workflows.bats(#513 test): pins the-targument carrying{{ .Tag }},fetch-rt.sh's-V -Qand expected-comment string, and theGORELEASER_CURRENT_TAGenv line..forgejo/workflows/tests.yml: the bats job's required tools becomebats jq minisign, and the job printsminisign -v.goreleaser.ymlalready needs minisign onruns-on: go, and the real-minisign test skips without it. This turns that skip into a red.docs/SECURITY.md§Verifying a release: afterminisign -Vmandsha256sum -c, check that the trusted comment names the tag you downloaded. Releases signed before this change carry minisign's default comment.changelog.d/1516-signed-tag-binding.fixed.md.Decision tree
Comment format and match.
v9.9.90), trailing text and a missing file field, and the lockstep test catches drift on either side.tag:field out of a looser comment is right if other signers must coexist. None do, and every extra accepted shape would be a way for a replay to pass.How the comment is read.
-V -Q(this PR) prints only the verified comment. Measured on minisign 0.12: an edited comment or an edited manifest exits 1 with no output.-Vand parsing theTrusted comment:line works too, but couples the script to minisign's human-readable output.Releases signed before this change.
bootstrap-rt.sh:34passes the baked ref, andgoreleaser.yml:1274passes the tag it just published. Older tags run their own olderfetch-rt.sh, so a fetcher containing this check meets an old signature only through a replay.Real minisign in CI.
Self-bootstrap (AGENTS.md section 2)
No re-pin: toolkit-self floats
@main(AGENTS.md §2.1) and buildsrtfrom source, so it never runsfetch-rt.sh. Neitherfetch-rt.shnor.goreleaser.yamlis ingates.DefaultComposeScripts. Adopters get the check from the first cut tag that contains this commit. That cut is also the first live end-to-end run. Its goreleaser job signs with the new comment, and itsverify-fetch-armjob fetches with the new script. The bats job's real-minisign test runs this PR's head on agorunner first, so the runner's minisign-Qbehaviour is graded before merge, not at the cut.Verification
Local runs used an LF clone at
8630004, re-run at66ee9ce(base0156d84= livemain), inrt-bats:localwith Debian'sminisign 0.12added:bats tests/(the CI invocation)go test ./...shellcheck scripts/fetch-rt.sh,bash -nyaml.safe_loadon.goreleaser.yamlandtests.ymlanchor-check.py(renderergit.frankenbit.de)FRAGMENT_CHECK_COVERAGE=content rt fragment-check changelog.drt register-check --stdinon each changed filegoreleaser actually expands the template. I ran goreleaser v2.17.0, the
GORELEASER_PINingoreleaser.yml:217, asrelease --snapshot --skip=publish,validate,announcewith a throwaway key. Itsdist/checksums.txt.minisigreadstrusted comment: release-toolkit tag:v0.62.4 file:checksums.txt, where.Tagis the nearest tag in snapshot mode, andminisign -V -Qprints exactly that.New tests (
tests/fetch-rt.bats18–23):v9.9.90, trailing text, missing file field, emptychecksum OK, no asset requestv1.0.0-alpha.0.goreleaser.yaml's ownsigns.args(goreleaser's variables filled in)rt 0.2.0; v0.1.0's signed files served as v0.2.0 are refused; a default-comment signature is refused; an edited comment failsminisign verification failedMutation check (AGENTS.md §8 Axis B). Each mutant ran on a throwaway copy, and its diff was confirmed non-empty:
-Qchanged to-q(no comment captured)-tdropped from.goreleaser.yamlsetup_file(whole file), and the #513 test.goreleaser.yamlformat drifts andfetch-rt.shdoes notGORELEASER_CURRENT_TAGenv line dropped (66ee9ce)#1516 acceptance criteria
fetch-rt.shrefuses another release on hit and miss, naming both versions.minisighandlingreusable-mirror-release.yml:310-327copies every asset by name and never re-signs. The live mirror's v0.62.0.minisighas the origin's trusted comment and timestamp (1788725401) byte for byte, so a tag-naming comment carries over to the same tag.changelog.d/1516-signed-tag-binding.fixed.mdReview request
@alex, please verify at source (AGENTS.md §6). Both halves need checking:
.goreleaser.yaml's-targument what you want stamped into every release from now on? It is part of what adopters will check by hand. And does the CI bats job log on this head showminisign -vand test 23 ran, not skipped?bats tests/fetch-rt.batson the head with minisign installed. Then readfetch-rt.sh's refusal as an adopter who hit it would.After the next cut, read back that release's
checksums.txt.minisigtrusted comment for AC 6. Pin your review withrt review-submit-check --owner frankenbit --repo release-toolkit --pr 1518 --reviewed-head <full sha>(§6.2).🤖 Generated with Claude Code
checksums.txt lists rt-linux-amd64 with no version, and minisign's default trusted comment is a timestamp and a file name. A valid signature therefore proved only that the key signed some release. An older release's signed checksums.txt, .minisig and binary, served under a newer tag, passed every check in fetch-rt.sh, and the older rt ran. .goreleaser.yaml now signs with -t "release-toolkit tag:{{ .Tag }} file:checksums.txt". goreleaser v2.17.0, the version the cut pins, expands the template in signs.args: a local snapshot signed "release-toolkit tag:v0.62.4 file:checksums.txt". fetch-rt.sh verifies with -V -Q, which prints the comment only once both signatures verify. It then refuses unless the comment is exactly that string with RT_VERSION. The check sits before the cache branch, so it runs on hits and misses, and before any asset is fetched or checksummed. Every caller fetches its own release, so a release signed before this change is refused only when it is replayed. tests/fetch-rt.bats renders the mock's comment from .goreleaser.yaml's own -t template, so every passing fetch also checks that the two agree. A new test signs with the real minisign, using the signs.args from .goreleaser.yaml, and covers a genuine fetch, a replay, a default comment and an edited comment. The bats job now requires minisign on the go runner, which goreleaser.yml already needs, so that test cannot skip in CI. docs/SECURITY.md's manual recipe gains the trusted-comment check. Refs #1516 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>Verdict: APPROVE at
863000402fa170a50eade11257cc228fb585fca7. This review is a COMMENT because the forge identity here isclaude, the PR's author, and Forgejo refuses self-approval (AGENTS.md section 8, instance 7). @alex has to click Approve.rt review-submit-check --pr 1518 --reviewed-head 863000402fa170a50eade11257cc228fb585fca7gaveREADY, rc=0, immediately before this review was posted.Verified at source
The signing half
.goreleaser.yamlsigns.argsadds-t "release-toolkit tag:{{ .Tag }} file:checksums.txt"after-s <keyfile>and before-x/-m. That fits the option (B) decision in issue comment 114216..Tagis the pushed tag. On dispatch,GORELEASER_CURRENT_TAGis pinned toinputs.tag(goreleaser.yml:426-428).The verifying half
fetch-rt.shcaptures the output of-V -Qand compares it whole-string againstrelease-toolkit tag:${RT_VERSION} file:checksums.txt.verify_manifest_signatureand before thecachedbranch, so it covers both hit and miss, and runs before any asset request.Real minisign 0.12 (Debian), probed directly
-V -Qprints exactly the trusted comment on stdout, with nothing on stderr, rc=0.-Valone printsSignature and comment signature verifiedandTrusted comment: …. That matches the mock and thedocs/SECURITY.mdtext.Callers. Every caller fetches its own tag:
bootstrap-rt.shuses itsv[0-9]*arm withRT_VERSION=$ref.goreleaser.yml's verify-fetch-arm checks out the tag, then runs that tag'sscripts/fetch-rt.sh.v*), so their.Tagis the rc tag and they match too.Tests
Local run. An LF clone at the head, in
rt-bats:localplusminisign 0.12:bats tests/fetch-rt.batsis 23/23 ok, and test 23 (real minisign) ran.bats -f 513 tests/workflows.batsis ok.CI on this head (run 19128, job
bats, read in the job log):minisign=/usr/bin/minisign, thenminisign 0.12.1..221.ok 48 #1516: real minisign: …has no# skip. The runner-side-Qbehaviour is graded before merge, as the PR body claims.success(30 contexts).Reviewer-side mutants (AGENTS.md section 8 Axis B), separate from the author's M1–M7. Each ran on a throwaway copy, with a non-empty diff confirmed:
[[ "$c" != "$e"* ]])-Q→-q(empty comment)Other checks
scripts/,tests/,.forgejo/or.goreleaser.yaml.check-self-bootstrapis green.Non-blocking
v*tags on one commit. On tag pushGORELEASER_CURRENT_TAGis unset, so goreleaser picks the tag from git.v*tags ever point at the same commit,.Tagmight name the other one. Every fetch of that release would then be refused, and verify-fetch-arm would fail at the cut.GORELEASER_CURRENT_TAG="${{ github.ref_name }}"on the push path too, so the signed tag is the pushed tag by construction.tests/workflows.bats, the new Python comment: "any release s signed checksums.txt". The apostrophe was lost.checksums.txtnaming another release" understates the change. It also refuses one that names no release (the pre-#1516 default comment). Optional: "…whose signature does not name this release".AC 6 (read back the first real release's trusted comment) stays open until the next cut, as the PR body and comment 114216 say. Please read the comment back from the release's
checksums.txt.minisigitself, not from the goreleaser log.HOLD. A new commit is coming on top of
863000402fa170a50eade11257cc228fb585fca7. It takes all three non-blocking items from review 7192:goreleaser.yml:GORELEASER_CURRENT_TAGcomes from the job'sresolve release tagoutput on tag push too, not only on dispatch. The trusted comment then always names the tagverify-fetch-armfetches.tests/workflows.bats: the comment typo.Please don't approve
8630004. I'll post the new full head SHA here once it's pushed.HOLD released. The new head is
66ee9ce1c4a2df8fac1b127e548f1ca66f20751d, one new commit on top of the reviewed8630004, with no amend and no force-push. It takes all three items from review 7192:goreleaser.yml: the goreleaser release step setsGORELEASER_CURRENT_TAG: ${{ steps.tag.outputs.tag }}on every event. That is the job'sresolve release tagoutput, resolved the same way asverify-fetch-arm: the dispatch input, elsegithub.ref_name. The dispatch-onlyexportis gone, and the step refuses an empty value. The #513 wiring test pins the env line, and dropping it turns that test red with "publish step does not pin GORELEASER_CURRENT_TAG to the resolved release tag".tests/workflows.bats: the comment typo is fixed.Re-run locally at
66ee9ce:bats tests/221/221 (0 skipped, real minisign present), YAML parse ok, fragment-check with no warnings, register-check 0 hits. The PR body was updated under this HOLD and read back byte for byte.Please take a fresh read at
66ee9ce. The8630004verdict does not carry over. Pin it withrt review-submit-check --owner frankenbit --repo release-toolkit --pr 1518 --reviewed-head 66ee9ce1c4a2df8fac1b127e548f1ca66f20751d.Verdict: APPROVE at
66ee9ce1c4a2df8fac1b127e548f1ca66f20751d. This review replaces review 7192, which was at8630004and does not carry over. It is a COMMENT for the same reason as before: this forge identity authored the PR, and self-approval is refused. @alex has to click Approve.rt review-submit-check --pr 1518 --reviewed-head 66ee9ce1c4a2df8fac1b127e548f1ca66f20751dgaveREADY, rc=0, immediately before this review was posted.Process (AGENTS.md section 6.2)
66ee9ceis one commit whose parent is the reviewed8630004. There was no amend and no force-push.8630004..66ee9cetouches onlygoreleaser.yml,tests/workflows.batsand the fragment.The three items from review 7192
GORELEASER_CURRENT_TAGon every event. Taken, and correct.${{ steps.tag.outputs.tag }}. That step (resolve release tag,id: tag,goreleaser.yml:138-154) is in the samegoreleaserjob, and it already feedsRELEASE_ASSET_TAG.github.ref_name, and refuses an empty value. That is the same resolutionverify-fetch-armuses (:1222-1232). So the tag signed into the comment and the tag the verifier fetches come from one rule.exportis gone, so the old and new paths cannot disagree.${{ inputs.tag }}. Both turnworkflows: #513 …red with "publish step does not pin GORELEASER_CURRENT_TAG to the resolved release tag".Re-verified at this head
rt-bats:localplusminisign 0.12:bats tests/fetch-rt.batsis 23/23 ok, and the real-minisign test ran.bats -f 513 tests/workflows.batsis ok.success(30 contexts). The bats job log (run 19146) showsminisign=/usr/bin/minisignandminisign 0.12, then1..221.ok 48 #1516: real minisign: …has no# skip, andok 128 workflows: #513 …passes.fetch-rt.sh,.goreleaser.yaml,tests/fetch-rt.batsanddocs/SECURITY.md. The verification in 7192 still applies to them, including mutants R1–R3.No open findings. AC 6 (read back the first real release's trusted comment from its
checksums.txt.minisig) stays open until the next cut, as agreed.Landing identity record
66ee9ce1c4a2df8fac1b127e548f1ca66f20751d66ee9ce1c4a2df8fac1b127e548f1ca66f20751dThis is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.