feat(scripts): one-shot release backfill to the public mirror (all tags, newest 10 binaries) - #659
Conversation
…gs, newest 10 binaries) scripts/backfill-releases.sh carries this repository's historical releases onto the public deliverable mirror once; mirror-publish.yml covers every release cut after the mirror exists. Decision: every published release gets its tag, its GitHub release and its text assets (install.sh, install.ps1, SHA256SUMS, anything SHA256SUMS does not list); binaries and their .sig/.cert only for the newest BINARY_KEEP (default 10) releases. Mirror tags are annotated release markers on the mirror's default-branch head carrying the original date and message, since the mirror has no source commit to point at. Reuses publish-mirror.sh `target` for the mirror-name rule and publish-guard.sh for the string scan of every text asset and release body; binaries are verified against the source SHA256SUMS before upload. Fails closed (exit 2 on any incomplete read), refuses per release (exit 1), idempotent (a re-run over a complete mirror writes nothing), resumable (--from-tag / --only-tag). Dry-run is the default. scripts/tests/backfill-releases-verify.sh: 30 offline assertions against a recording fake gh, plus --mutations proving 6 anchored rules load-bearing. Both wired into the Installer (shell) job. RELEASE_CHECKLIST.md gains §8. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
bugbot run |
…s; --notes source is the opt-in Historical release bodies are GitHub's generated pull-request lists, and nearly every one carries strings the publish guard's report tier counts. The public mirror should not repeat them, so the backfill now writes the same fixed notes mirror-publish.yml writes for new releases by default, plus the original-date footer. `--notes source` still carries the source body on explicit request and runs it through the guard as before. Tests: the default path now expects fixed notes and no trace of the source body; `--notes source` has its own positive test; the bad-body refusal and the --strict report-tier refusal run under `--notes source`, with a paired default-notes run proving the body is never staged. The mutation list gains `notes-default-fixed` (flips the default back), which reddens four tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
bugbot run |
|
Decision recorded in bf1e1e1: |
…) is seen, not swallowed Nearly every jq_of call sits inside a "$(...)" assignment. die2 wrote its ::error:: reason to stdout, so inside the substitution the reason was captured into the variable and never printed; the subshell's exit 2 ended only the subshell, and the parent died under set -e with a bare status 2 the operator could not name. The prerelease read sat inside a "[ ... ]" test in an && list, where set -e is suspended, so that failure did not end the run at all - a malformed release.json read as "stable". - die2 writes to stderr: the reason reaches the operator from any depth, and the substitution's status 2 still aborts the assignment under set -e. - the prerelease jq_of is hoisted into its own assignment before the test. - harness: the fake gh gains FAKE_GH_GARBLE_RE (a call that "succeeds" with a non-JSON body); a new case pins that a garbled read parsed inside a substitution is exit 2 naming the file and filter with the reason in the output; a die2-stderr mutation (stdout die2) must redden it - proven: the mutant reddens exactly that case, 8 caught / 0 survived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
bugbot run |
saqlainsyed007
left a comment
There was a problem hiding this comment.
Reviewed the full script (scripts/backfill-releases.sh, 543 lines) and the
test harness, not just the diff hunks or PR description.
What I verified as SAFE (contrary to the "no re-run" framing in the title,
this is actually genuinely idempotent and non-destructive):
- No destructive git operations anywhere: grepped for force-push,
tag -f,
-X DELETE,--force— none exist. Every write is an additive POST
(git/tags, git/refs, releases, release upload). - Assets are compared by SHA256 and skipped if already matching; if an
asset already on the mirror has a DIFFERENT digest than the source, it
is refused, never overwritten ("a published asset is never replaced"). - Mirror tags: an existing tag is accepted only if it already points at a
commit the mirror has; a dangling tag is refused, never repointed. - Mirror target name comes from the existing, already-reviewed
publish-mirror.shtarget(unset/malformed/self-referential mirror is
refused there) — not re-derived here. - Every text asset and every release body is scanned by the existing
publish-guard.sh (forbidden-string list + gitleaks) before any upload;
binaries are verified byte-for-byte against the source's SHA256SUMS
before upload and refused by name on mismatch. - Prereleases and drafts are excluded by default (only via
--include-prerelease), so nothing internal-only leaks to the public
mirror by accident. - The previously-flagged Bugbot finding ("error exits swallowed in
substitutions") is fixed: die2() now writes to stderr (visible even
when the call site is wrapped in$(...)), and I grepped the whole
script for the anti-pattern (a die2-capable substitution living inside
&&/||/[ ], whereset -eis suspended) — found none; every
jq_of/gh_read assignment that can call die2 is its own standalone
statement, so the exit 2 from the subshell aborts the script.
Two Bugbot findings remain open and I confirmed both are real:
-
"Refused newest leaves no latest" (Medium) — confirmed at the
REL_ACTION=create block:make_latestis only set true for
NEWEST_STABLE, and every other release is created with
make_latest=false. If NEWEST_STABLE itself gets refused (guard hit,
SHA mismatch, dangling tag), no release ends up marked latest and
the mirror'sreleases/latestwill 404 until a human notices the
refusal-table exit-1 and reruns--only-tagfor that release. Not
data-corrupting, but a real silent gap worth a one-line guard (e.g.
promote the next-newest surviving stable release to latest, or at
least call it out explicitly in the summary line). -
"Scratch commit honors global gpgsign" (Medium) — confirmed at the
guard's scratch-checkout commit: only-c user.name/-c user.emailare set, no-c commit.gpgsign=false. On an operator
machine with a globalcommit.gpgsign=true, this commit will try
(and fail) to sign asbackfill@localhost, and the whole run
die2's before any release is even planned. Fails closed (no
corruption risk), but it's an avoidable false negative for the
human who's supposed to run this once — worth the one-line fix
since this script (unlike the CI publisher using the same idiom)
is explicitly run from a human's machine.
Recommend fixing both before merge — they're small, well-isolated fixes
and this is exactly the kind of one-shot public-mirror script where
"we'll deal with it if it comes up" isn't a great posture. Everything
else — the idempotency model, the guard integration, the SHA
verification, the binary retention window, the annotated-tag anchoring —
is careful and well-tested (30 assertions + 6/6 mutation kill rate).
…he newest is refused; scratch commit is never signed Two review findings, both confirmed against the script: 1. make_latest=true travelled only on the newest stable release's own POST, every older release being created with make_latest=false. When the newest stable was refused before that POST (SHA mismatch, guard hit, dangling tag) nothing on the mirror was marked latest and releases/latest answered 404 until a human noticed the exit 1 and re-ran --only-tag. After the loop, an --apply run whose newest stable was refused AT CREATE TIME now PATCHes the newest stable release it did write to make_latest=true and says so; a refusal of a release the mirror already has leaves latest untouched, and a run that wrote no stable release warns and names the re-run. The happy path is unchanged (still 48 writes for 12 releases). 2. The guard's scratch checkout committed with only user.name/user.email overrides; a global commit.gpgsign=true on the operator's machine would try to sign as backfill@localhost, fail, and end the run before a release was planned. The scratch commit is never published: -c commit.gpgsign=false. Harness: the fake gh now returns real release ids and serves PATCH repos/<mirror>/releases/<id>; writes() counts PATCH; the sha-mismatch case (which refuses v0.1.11, the newest stable) pins exactly one PATCH marking v0.1.10 latest; a new case runs under GIT_CONFIG_GLOBAL with gpgsign=true and a failing signer and demands a clean plan. Two mutations (latest-fallback, scratch-commit-unsigned) each redden exactly their case: 37 passed, 10 caught / 0 survived. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
bugbot run |
saqlainsyed007
left a comment
There was a problem hiding this comment.
Re-reviewed at head 24929ca3 (moved from 6cfa9bf0).
My two original findings are genuinely fixed, verified against the live code:
- "Refused newest leaves no latest" —
NEWEST_STABLE_REFUSEDis now set whenever the
newest-stable tag is refused during its own create path, and the post-loop block
marks the last stable release actually written this run as latest (or warns by name
if none was written). Pinned by a dedicated harness case and alatest-fallback
mutation anchor. - "Scratch commit honors global gpgsign" — the scratch checkout commit now carries
-c commit.gpgsign=false(scripts/backfill-releases.sh:264). Pinned by a harness case
that runs under a forced-failinggpg.programand ascratch-commit-unsigned
mutation anchor.
But Bugbot's new finding on this same commit is real, and the fix for the first item
introduces it: the fallback PATCH at line 552 —
gh_write "$TMP/latest.json" api -X PATCH "repos/$MIRROR/releases/$LAST_STABLE_CREATED_ID" -F make_latest=true
— uses -F, which types make_latest as a JSON boolean. GitHub's release-update API
takes make_latest as a string enum ("true"/"false"/"legacy"), which is exactly
why the create path two lines away (line 525) uses -f "make_latest=$LATEST" instead.
A boolean value here will 422, die2 fires, and the table never prints — so the exact
scenario this fallback exists to handle (newest-stable refused) ends with the fallback
itself failing closed and releases/latest still 404ing. Confirmed by reading the file
directly and comparing both call sites, not just trusting Bugbot's claim.
One-line fix: change -F make_latest=true to -f make_latest=true at line 552, to
match the string-typed create-path call.
Requesting changes for this one regression — everything else from my prior review
stands fixed.
… API takes, and the fake gh refuses a typed one
The fallback PATCH added in the previous commit used -F make_latest=true,
which gh types as a JSON boolean. The releases API takes make_latest as a
string enum ("true"/"false"/"legacy") - the create path two lines away uses
-f for exactly that reason - so the PATCH would 422, die2 would fire, and
the one scenario the fallback exists for ended with the fallback failing
closed and releases/latest still 404ing. Now -f, matching the create path.
The harness let this through because the fake gh treated -f and -F alike.
It now records which fields arrived typed and answers a typed make_latest on
either release call with the API's 422, so the type is enforced where the
real endpoint enforces it. A latest-string-typed mutation (the -F line)
reddens the sha-mismatch and fallback cases: 37 passed, 11 caught / 0
survived.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit cc68221. Configure here.
saqlainsyed007
left a comment
There was a problem hiding this comment.
Fourth and final pass on this one. All three real bugs found across this review are now confirmed fixed by reading the actual file content at each step, not diffs or commit messages:
- Newest-stable-refusal left no release marked
latest— fixed via theNEWEST_STABLE_REFUSEDfallback that marks the last stable release actually written as latest, or warns by name if none landed. - The guard's scratch git commit didn't override
commit.gpgsign— fixed with-c commit.gpgsign=false. - The fallback's PATCH call typed
make_latestas a JSON boolean (-F) instead of the string enum GitHub's API expects — fixed to-f make_latest=true, matching the create-path convention at line 525.
All CI is green (Build/Test/Lint/CodeQL/govulncheck/quality/*, both Cursor Bugbot and bugbot/review), mergeable, and all 4 review threads are resolved with 0 open. No new findings surfaced on this final commit.
Approving.
Summary
One-shot, idempotent backfill of this repository's historical releases onto the public deliverable mirror.
mirror-publish.ymlpublishes each new release going forward; releases that predate the mirror are carried over once byscripts/backfill-releases.sh, run by a human.The decision it implements (stated in the script header and
scripts/RELEASE_CHECKLIST.md§8):install.sh,install.ps1,SHA256SUMS, anything SHA256SUMS does not list);.sig/.certcompanions only for the newestBINARY_KEEPreleases (default 10) — older pinned binary URLs 404 on the mirror; the documented answer is "re-run the installer";--include-prerelease.Tag anchoring. The mirror holds a README, so no tag can point at the commit a release was built from. Each mirror tag is an annotated tag on the mirror's default-branch head; the annotation carries the original date (the source tag's tagger date when annotated, else the release's
created_at) and the original message, and states that it is a release marker, not a source snapshot. A tag already on the mirror is accepted only if it points at a commit the mirror has; a dangling tag is refused, never repointed. The release body gets a footer with the original publish date (GitHub does not let a created release carry a past date).Reuse, not copies.
publish-mirror.sh targetdecides the mirror name (unset / malformed / equal to source refused by the same rule the workflow applies).publish-guard.shscans every text asset and every release body against.publish-forbidden(+BACKFILL_EXTRA_FORBIDDENprivate needles, + gitleaks) before any write; a hit refuses that release naming the tier. Binaries are opaque to the string scan by design and are instead verified against the source release'sSHA256SUMSbefore upload — a mismatch refuses, naming the asset.Fail closed. Any read that does not complete is exit 2 ("could not tell", naming the failing call) — never "treated as absent". A refused release is reported in the per-release table and the run continues; exit 1 at the end if anything was refused. Anything already on the mirror with an identical SHA256 is skipped, so a re-run over a complete mirror makes zero writes.
--from-tag/--only-tagresume partial runs without changing the binary decision (computed over the full filtered list). Dry-run is the default;--applywrites.Type
Test plan
scripts/tests/backfill-releases-verify.sh— offline;ghis a recording fake serving fixtures (12 stable releases, 1 prerelease, an annotated source tag) and mutating a JSON mirror state on every write. 30 assertions, covering everything in the ask:--applymakes exactly 48 writes for 12 releases (tag object, ref, release, one upload each), oldest first, newest stable last and the onlymake_latest=true;--applywrites nothing and downloads nothing it can compare by digest;BINARY_KEEPboundary: the 10th newest carries binaries + sig/cert, the 11th exactly the three text assets;--include-prereleaseshifts the boundary;MIRROR_REPOunset / equal to source (case-insensitive) refused by publish-mirror's rule before anyghcall;gh repo view, no repository name hardcoded;--notes fixedalternative;--strictpromotes the report tier;--only-tag/--from-tag.--mutationsmode: copies the script, breaks 6 anchored rules one at a time (releases-read-fail-closed, prerelease-filter, binary-keep, idempotent-skip, sha-check, guard-refusal), proves each mutation landed (anchor found exactly once, copy differs, mutant parses), runs the suite against the mutant and demands red — 6/6 caught.Local evidence:
shellcheck -S warning -xclean on both scripts (also parses under macOS bash 3.2),bash -nclean, suite 30/30, mutations 6/6,go build ./...unchanged (no Go changes),actionlintclean onbuild.yml. Both harness modes are wired into theInstaller (shell)job (~1 min + ~1.5 min).Open questions for review
--notes sourceis the default and carries the source body (guard-scanned, footer appended). The workflow uses fixed notes for new releases. Backfilled releases will therefore read differently from future ones unless the backfill is run with--notes fixed. Which should the runbook step use?--strictwould refuse almost every release. Run non-strict, or--notes fixed?.sig/.certare small text files but travel with their binary (a signature without the thing it signs is noise), so they are also cut atBINARY_KEEP. Confirm.Checklist
scripts/RELEASE_CHECKLIST.md§8; script header carries the contract)🤖 Generated with Claude Code
Note
Medium Risk
Operator-run tooling that can write mirror releases and assets via
gh, but dry-run is default and the same publish guard/mirror rules as production are enforced and heavily tested in CI.Overview
Adds
scripts/backfill-releases.sh, a human-run, idempotent tool to copy historical GitHub releases from the source repo onto the public mirror (complementing ongoingmirror-publish.yml). Every matching release gets tags, GitHub releases, and text assets; binaries and cosign.sig/.certonly for the newestBINARY_KEEP(default 10). Mirror tags are annotated markers on the default-branch head; default--notes fixedmatches the workflow (with--notes sourceopt-in). Reusespublish-mirror.shtargetandpublish-guard.sh; binaries are checked against sourceSHA256SUMS; conflicting mirror assets and dangling tags are refused, not overwritten.Documents the procedure in
scripts/RELEASE_CHECKLIST.md§8. CI gains shellcheck/bash -nfor the new scripts plusscripts/tests/backfill-releases-verify.sh(fakegh, ~30 property tests) and a--mutationsstep that breaks anchored rules and expects the suite to fail.Reviewed by Cursor Bugbot for commit cc68221. Bugbot is set up for automated code reviews on this repo. Configure here.