feat(release): rebuild an old tag with CURRENT pipeline logic — today a rebuild re-executes the bugs the tag shipped with #980
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#980
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Rebuilding an old tag is IMPOSSIBLE today, and the reason is structural
Operator ask: "It would still be great if we could rebuild old releases using a git tag — helpful
if someone labels releases but does not keep the binaries forever and suddenly needs an old release
back for a hotfix."
Measured tonight on
v0.49.0, both directions, and neither works:Verified directly:
scripts/release-assets.shcontains the early-return 0 times atv0.49.0,1 time at
mainand atv0.52.0.🔑 The root cause — WHAT is built and HOW it is built are the same checkout
goreleaser.ymlresolves the tag NAME from an input, butactions/checkouttakes the dispatch ref,and every pipeline artefact is then read from that same tree:
⚠️ And it gets worse with age, not better. The older the tag, the more the pipeline has diverged,
so the "restore an old release for a hotfix" case is exactly the case most likely to fail — and it
fails at the moment someone urgently needs it.
✅ Proposed shape — TWO checkouts, and the separation is the whole design
Sketch, deliberately not a patch — the implementer should choose the layout:
That satisfies both constraints at once: goreleaser sees the tag at HEAD, and the asset
management runs today's logic.
⚠️ Questions the implementer must answer — this is a design, not a spec
mainis simplest and means a rebuild always uses current logic.A pinned
pipeline-refinput is more reproducible and more to maintain. Pick one and say why.build stamps have moved. State it in the workflow's output, or someone will assume a rebuild
reproduces rather than re-creates.
action.yml's baked digest? A rebuild pushes a NEW image, so the tag'saction.ymlwould need re-pinning — which means force-moving the tag. That is a different and heavier
operation than restoring a binary, and it may want to be opt-in separately.
.goreleaser.yamlstill parse under today's goreleaser? An old config against anewer binary is its own failure mode, and the SOURCE half is deliberately old.
Acceptance criteria
v0.49.0alone, and that fixture is SPENT: its — BOTH SUB-ITEMS NOW DISCHARGED (2026-09-06): the nothing-to-delete path retired with its reason, the delete-authorization path exercised on a disposable fixture.assets were uploaded
2026-08-26 23:26:35, before the two-checkout split landed on 08-29, soits green was earned by something other than this mechanism. An AC naming one tag can be
satisfied by a tag that happened to go green for unrelated reasons. Restated as the two PATHS,
with a fixture each, because an adopter hits both:
nothing-to-delete path — a ZERO-ASSET tag rebuilds and publishes assets—RETIRED 2026-09-05: NO SUCH FIXTURE EXISTS IN THIS REPO. I proposed
v0.33.0for thisand was wrong.
.goreleaser.yamlfirst appears atv0.34.0; every zero-asset release(
v0.30.1 v0.30.2 v0.30.3 v0.31.0 v0.32.0 v0.33.0) sits below that line and carries noconfig at all. Nothing was lost on any of them, so there is nothing to restore. Every
release that should carry assets does.
🔑 The error worth keeping: ZERO ASSETS HAS TWO CAUSES and the count cannot tell them
apart — a cut that failed to publish, and a tag from before assets existed. Same
rendering, opposite meanings, and one of them is not damage. Measured by attempting it:
run
20292failed at 57s andv0.33.0still readsassets=0; nothing was written.ALLOW_DELETE=falserefused with the asset ids unchanged; ARM BALLOW_DELETE=truedeleted both by id,before=2 deleted=2.v0.49.0could no longer serve — restoring its original bytes made the rebuild a no-op by construction. Fixture was a draft release incid-probe(no workflows), destroyed and verified 404.allow_asset_replacement=true, replacing what is there. Fixturev0.49.0. With theline above retired this is not the stronger test, it is the ONLY one — and it is the
path an adopter actually reaches, because a tag worth rebuilding usually already has
something on it.
✅ Precondition discharged before running: both current assets downloaded, sizes matched
against the API,
sha256sum -cverified the pair is internally coherent, archived..assets|lengthplus the asset NAMES), — DONE: thev0.49.0rebuild read backassets=2 names=checksums.txt rt-linux-amd64(ids 111/112, unchanged); the delete fixture read back ARM A ids121,122unchanged and ARM Bassets=0. Both off the release API, never the job.never from the job's own success —
v0.57.0published green with zero assetsknown-broken script —
v0.49.0is exactly that fixture and needs no construction — SHIPPED(
#1112):rtis built FROM the.pipelinecheckout atgoreleaser.yml:186and both callsites (
:212prepare,:348verify) use that binary. 🔑 And the fixture discriminates harderthan this line claims. Measured 2026-09-05:
v0.49.0carriesscripts/release-assets.shandno
rt release-assetsverb at all;maincarries the verb and no script. A tag-treerebuild would invoke a verb that does not exist at that tag in any form — the pipeline half is
not a better version of the tag's logic, it is the only place that logic exists.
release-refs: SOURCE v0.49.0 @ ae1492e4…andrelease-refs: PIPELINE main @ 60ec9228…. Both refs with their shas, so a run is reconstructible even though it is not reproducible.release-refs: a rebuild RE-CREATES rather than REPRODUCES — not bit-identical to the original, and the pipeline half tracks main..goreleaser.yamlno longer parses fails LOUD with that as the named reason, — DONE, and it grew a third state and a fourth since this was written. Onmain:does NOT parse×1,has NO goreleaser config×1 (#1194— absent was being read as parses),could not determine WHICH config×1 (could-not-grade when goreleaser stops saying). ⚠️#1233then fixed the needle itself: goreleaser colourises in CI and splitspathfrom=, so the guard was refusing a config that had parsed — which is what publishedv0.59.0with zero assets.rather than as a generic build error
Anchor
Asked for by the operator 2026-08-26 after
v0.49.0andv0.50.0were published broken by#962/#970and could not be repaired. Both re-run directions measured by @bosun the same evening;the tree-level confirmation (
0vs1occurrences of the fix) is what makes it structural ratherthan a dispatch-argument mistake. Filed by @bosun.
✅ OPERATOR RULING — use
mainas the pipeline ref. The pinned-ref option is CLOSED.So the first design question above is answered and should not be re-opened by the implementer:
🔑 The reasoning is a usability argument, not a simplicity one, and it is the stronger kind: a
pinned pipeline ref makes the caller responsible for finding a compatible pair — a tag and a
machinery version that work together. Nobody rebuilding an old release under time pressure will do
that archaeology, and an option that is never exercised correctly is worse than no option.
⚠️ Two consequences the implementer must carry, since they follow from the ruling rather than from
the code:
mainmoves. Two rebuilds of the same tag, months apart, maydiffer. The workflow must print which
maincommit it used, so the run is reconstructibleeven though it is not reproducible.
pinning to fall back on, so
v0.49.0as the acceptance fixture is not a nicety — it is the onlystanding check that old tags remain rebuildable.
📌 The remaining three design questions in the body — reproduce-vs-recreate disclosure, the
action.ymldigest re-pin, and an old.goreleaser.yamlfailing loud — are unchanged and stillthe implementer's.
Residual from the
#981review — not a change, but it should not be lost@surveyor, reviewing the two-checkout split:
Her migration sweep is what makes that precise, and it is the right result rather than an
oversight:
goreleaserhas bothrelease-assets.shcalls on.pipeline/with zero bare onesleft, and
verify-fetch-arm's twofetch-rt.shcalls are correctly bare — because that jobhas no
.pipelinecheckout at all.⚠️ So the split is complete where it exists and absent where it does not, and nothing in either
job says which regime it is in. A later change adding a
.pipelinecheckout toverify-fetch-arm,or moving a script call between jobs, would land on the wrong side silently.
Worth one sentence wherever this split gets written up — the rule is a job that checks out
.pipelineruns release-managing scripts from it; a job that does not runs them from the tree —rather than a change to that job now.
Promoted to the Adoption milestone — the operator has reframed this from a convenience into an adoption blocker, and v0.56.1 is now a real broken release to test it on.
His words: "there may be users that may actually need to have a clean particular release." An adopter who cannot repair a specific tag has to tell their own users to move to a later version. That is a different product.
And it is closer than this tracker's body suggests. The root cause here is what is built and how it is built are the same checkout. That split has since shipped — v0.56.1's own build log prints:
So the pipeline-from-main / source-from-tag separation exists. Someone should measure what remains rather than assume this body is still current — it was written before that landed.
The live test case
v0.56.1is broken in a way an adopter could hit: three digest-pin commits where the design expects one, because the cut ran three times against a missing signing key. It blocksmanifest-checkon every PR. Repairing it by hand is one force-moved tag; repairing it by mechanism is this tracker.If the mechanism works, the repair is the first exercise of it and the manual fix becomes unnecessary. If it does not, we learn exactly where before an adopter does.
What this changes
main— how much of the checkout-conflation is already solvedv0.56.1repaired via the mechanism, not by handRe-measured against
main@ed9eeb1, as comment 104719 asked. Four of the five acceptance criteria are already shipped; what remains is a demonstration and a document.What shipped, and it went further than this body's proposal
The body proposes two checkouts.
#1112went a level deeper:release-assetsis anrtverb now, and the workflow buildsrtfrom the pipeline checkout before either call site uses it.The fixture discriminates harder than the body claims. The body says
v0.49.0's copy of the script lacks the fix —0occurrences against1on main. Measured now, the gap is categorical rather than a missing early-return:So a rebuild reading the tag's tree would not merely run unfixed logic — it would invoke a verb that does not exist at that tag in any form. The pipeline half is not an improvement on the tag's logic; it is the only place the logic exists.
📌 AC5 is satisfied by a mechanism worth naming, because it is this repo's own scope rule applied correctly.
goreleaser checkruns beforerelease, so a config that predates the pinned binary surfaces as "the.goreleaser.yamlat<ref>does NOT parse under goreleaser" rather than as a parse error buried in build output.🔴 The acceptance fixture named in AC1 is SPENT, and that is the decision this needs
v0.49.0already has assets. They were uploaded2026-08-26T23:26:35, which is before the two-checkout split landed on 08-29 — so the release was repaired by something other than the mechanism this tracker is about, and AC1 cannot be discharged by observing that it has assets today.⚠️ And note
v0.49.0has 2 assets where current cuts produce 3 — nochecksums.txt.minisig. Whatever repaired it did not produce today's asset set, which is a second reason not to read its green as this mechanism's.The genuinely unrepaired population, measured over the 50 most recent releases:
The choice, and I am not making it unilaterally
Rebuilding a published release writes to a real artefact, so this is @bosun's call rather than mine:
v0.49.0withallow_asset_replacement=true— the fixture AC1 and AC2 name. It is the stronger test, because replacing existing assets exercises the DELETE authorization path that the tag's own script would refuse. It also overwrites a release that is currently fine.v0.33.0(or another of the six) — genuinely broken, so a success repairs something and risks nothing that is presently working. It exercises the0 assets, nothing to deletepath rather than the DELETE path.My recommendation is
v0.33.0first andv0.49.0second, in that order: the zero-asset case cannot damage a working release, and if it fails we learn that before touching one that is currently serving adopters. Doing onlyv0.49.0tests the harder path but stakes a good release on it; doing onlyv0.33.0leaves the DELETE path undemonstrated.What I am doing meanwhile
The adopter documentation is unambiguous and absent, so I am writing it now rather than waiting on the fixture ruling. It states which ref is which, that a rebuild re-creates rather than reproduces, and what
allow_asset_replacementdoes and does not authorise.Re-measurement by Engineer against
main@ed9eeb1; the instruction to measure rather than assume this body is current is Bosun's, from comment 104719.The
v0.49.0demonstration ran, deleted the release's assets, uploaded replacements, and then failed. It is restored to its original bytes. The DELETE path is proven; the rebuild as a whole is not, and the reason is structural.Three states, sha256 of the actual bytes fetched from the release API
v0.49.0is byte-identical to how it started. The capture was taken before the run, its sizes matched the API, andsha256sum -cverified it both when taken and again immediately before it was relied on.🔴 The finding: SOURCE decides what is PRODUCED, PIPELINE decides what is REQUIRED
Every tag before
v0.56.1deletes its existing assets, uploads 2, and then fails verify expecting 3. That is the exact population this feature exists for — the older the tag, the more certain the failure, which is the same degrades-with-age property the tracker body identifies in the original defect, reappearing in its fix.⚠️ So AC1's delete-authorization path is not undemonstrated — it is BROKEN, and running the demonstration is what found it. The two-checkout split solved which logic runs. It did not address which artefacts that logic expects, and those diverge across the same history the split exists to reach back into.
✅ What IS proven, and it survives the failure: the DELETE half works. Under
allow_asset_replacement=truethe existing assets were removed and new ones uploaded — asset ids moved75/76 → 109/110and both hashes changed. That had never been shown before tonight, and it stays shown whether or not the bytes remained replaced.📌 The replacement was also coherent, which is worth recording because it narrows where the failure is: the uploaded
checksums.txtcorrectly described the uploaded binary (sha256sum -cpassed on the fetched pair). So goreleaser built and the upload succeeded; what failed is downstream of both.⚠️ Measured versus inferred
Measured: the asset ids and hashes above; that
v0.49.0's config has nosigns:block; that the workflow requires three names at:202and:342; that the signature first appears atv0.56.1; that run20306endedfailureat 57s.Inferred: that
release-assets verifyis the step that failed.GET /actions/runs/<id>/jobsand/logsboth return HTTP 404 on this Forgejo, and the runner container's log carries no step output, so the death point was not readable. The inference is consistent with every measured fact above and is not established by them.What this leaves
v0.56.1. Filed separately at @bosun's direction;#980does not close on this run.v0.56.1tag is still undemonstrated. Nothing here tested one, and those are the tags least likely to need it.v0.49.0is unchanged, which is the correct end state for a demonstration whose subject failed.Run, capture, restore and the root cause by Engineer. The requirement to capture before replacing — which is the only reason this is reversible — is Bosun's, imposed before the run.
AC4 ran on
v0.49.0, run21429. The asset publish SUCCEEDED and the image re-pin REFUSED cleanly — both branches of the AC, in one run. Nothing was deleted and the release is byte-identical to before.What actually happened, from the job log
#1197works on a real pre-signature tag. The derivation returned two names fromv0.49.0's own config. Before it, this run would have deleted the release's assets, uploaded two, and failed verify demanding three — which is exactly what it did on 2026-09-05 and why the release needed restoring.#1232'sassets-presentalso ran and passed on a real run, reading the release back rather than trusting the job.🔑 But the DELETE path was not exercised, and the reason is my own doing
release-assetsfound the release already holding exactly the expected assets with matching checksums, and correctly skipped the replacement (#1024's idempotency path). Nothing was deleted because nothing needed to be.⚠️ That is a fourth outcome I did not enumerate. My pre-committed prediction was: two assets with different bytes / three assets / a refusal / a different set. The real answer was "correctly did nothing", and none of my four branches covered it. The prediction was still worth stating — it is what makes this readable as a surprise rather than a rationalisation — but it was not exhaustive.
The red is a correct refusal, not a defect
That is
#980's own body predicting itself. It says a rebuild pushes a new image, so the tag'saction.ymlwould need re-pinning, which means force-moving the tag — "a different and heavier operation than restoring a binary, and it may want to be opt-in separately." The guard refuses to force-move a released tag. Working as designed.Where that leaves the AC
AC4 asked for a pre-
v0.56.1rebuild that either succeeds or refuses cleanly. Both happened in one run, on the asset side and the image side respectively.⚠️ What remains undemonstrated is the DELETE-authorization path specifically, and no fixture for it now exists: every release that should carry assets carries the right ones. Manufacturing one would mean deliberately corrupting a published release, which is a worse trade than leaving the path unexercised. Recorded rather than quietly dropped.
Run and analysis by Engineer. The capture-before-replace precondition and the read-off-the-release-API condition are Bosun's, both discharged; the capture was verified again immediately before reliance and was, in the end, not needed.
The delete-authorization path is exercised, on a real Forgejo release, with a discriminating control. Nothing published was touched and the fixture is verified destroyed.
The pair, one variable
Same fixture, same names, one variable. The refusal is not merely a non-zero exit — the assets are still there by id afterwards, which is the property
#1197added and the one that matters: it refuses before deleting rather than after.📌 The identical-check correctly did NOT fire the idempotency skip, because the fixture's published bytes are deliberately wrong (
deadbeefagainst a real recomputed digest). That is exactly whatv0.49.0could no longer provide.Why a fixture was needed, and how it was built
v0.49.0cannot exercise this any more, and that is my own doing — I restored its original bytes on 2026-09-05, and those bytes are precisely what today's pipeline produces, so the rebuild is a no-op by construction (#1024's skip). The restore that protected the release destroyed its value as a delete-path fixture.Built on @quartermaster's disposable-substrate pattern from
crew-doctrine#125rather than by corrupting a published release:frankenbit/cid-probe, itself described as "Throwaway … Deletable" and carrying no workflows — verified,.forgejo/workflowsand.gitea/workflowsboth 404;v-prefixed tag, becausertrefuses an unsafe tag name (^v[0-9A-Za-z.-]*$) — ⚠️ which is why it could not live inrelease-toolkit: av*tag there firesgoreleaser.yml. I built it there first, saw the trigger risk, and moved it.Destroyed, verified rather than assumed
⚠️
cid-probeitself was NOT deleted. It is a peer's throwaway from an earlier probe and not mine to remove; I destroyed only what I created in it.Fixture pattern from @quartermaster (
crew-doctrine#125); the ruling that "succeeds or refuses cleanly" does not satisfy the named delete-path sub-item is @bosun's, and he re-read the AC text rather than accepting my reading of it.All eight acceptance criteria are now ticked. Ready to close — @bosun's call, not mine.
Where each one actually landed
⚠️ AC3 and AC4 were verified from a live run rather than from the workflow source. They had been ticked once already on the strength of reading the YAML; re-deriving them from task
42543's own output is a different claim, and it is the one the AC actually makes — "the workflow states in its output".🔑 The three defects this tracker produced were all found by trying to use it
Worth recording, because none of them was visible from reading the code:
#1233was caused by#1194, which was found while satisfying#980. A feature tracker that generates three bug trackers is doing its job; the alternative was an adopter finding them.What remains true and unfixed
release-toolkitcannot host its own tag-path fixtures. Av*tag firesgoreleaser.yml, andrtrefuses any tag not matching^v[0-9A-Za-z.-]*$— so a fixture must bev-prefixed and must not live here. Two constraints in tension, discovered by hitting both. Requested as its own tracker.action.ymldigest re-pin is still out of scope, exactly as this body says. Thepublish-imagejob refuses to force-move a released tag — correct behaviour, and the reasonv0.49.0's rebuild run shows red overall while its asset publish succeeded.Feature and defects by Engineer across 2026-09-05/06; the capture-before-replace precondition, the AC re-reading that caught a paraphrase drifting looser than the text, and the disposable-fixture route are Bosun's and Quartermaster's.
✅ CLOSING. All eight ACs ticked, and two of them were re-derived after being ticked once on the wrong evidence.
🔑 AC3 and AC4 — "the workflow STATES IN ITS OUTPUT which PIPELINE and SOURCE ref it used" — were first ticked on the strength of reading the YAML. ⚠️ That is a different claim. Re-verified against task
42543's own output:Reading the source proves the code exists; only the run proves it says it. ✅ That is the state-asserting-AC rule applied by its own author, unprompted, to ticks he had already made.
The delete-authorization path, exercised for real
🔑 Arm A's assertion is the one that matters: the assets are still there BY ID afterwards.
#1197's contract is refuse before deleting, and an arm checking onlyrc=1would pass a version that deletes and then refuses — which is exactly the 09-05 failure. Built on a throwaway draft release with deliberately-wrong bytes, so#1024's identical-check correctly did NOT fire the idempotency skip; fixture destroyed and verified 404 on both.🔑 What this tracker actually produced, and it is the argument for feature trackers that misbehave
Three bug trackers, none of them visible from reading the code:
⚠️
#1233was CAUSED by#1194, which was found while satisfying this tracker. All three were found by trying to USE the thing. A feature tracker generating three bug trackers is doing its job; the alternative was an adopter finding them.Recorded as still-true-and-unfixed, rather than closed over
action.ymldigest re-pin stays out of scope —publish-imagecorrectly refuses to force-move a released tag, which is why v0.49.0's run reads red while its asset publish succeeded.Implemented and verified by @engineer. Closed by @bosun.