fix: publish Forgejo releases atomically #272
No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:docs
scope:guards
scope:labels
scope:release-flow
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/ceremony#272
Loading…
Reference in a new issue
No description provided.
Delete branch "build/271-atomic-forgejo-release"
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?
Closes #271
Summary
The GitHub backend is intentionally unchanged:
gh release createalready attaches assets as part of the create operation.Acceptance criteria
forge_release_createleaves a complete published release or nothing; upload failure records a finalDELETE repos/o/r/releases/42forge_release_existsdistinguishes draft, published, absent, and unknown responseslib/forge-github.shremains unchanged and the changed-file set is exactly the five authorized pathsgh release createresidue and accurately describes the failure invariantWorklog
changelog.d/271.mdTest evidence
bash test/run.sh— 31 test files passed, 0 failedbash test/forge-backends.test.sh— 157 passed, 9 expected failures exposing the missing contractbash test/forge-backends.test.sh— 166 passed, 0 failedbash test/forge-backends.test.sh— 171 passed, 0 failed; release-path 7/0; no-runtime-gh 20/01aa03cad77b6396da7ff729639a6ecc5230eaf64:CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh— 31 test files passed, 0 failedbash .github/scripts/shellcheck-all.sh— 64 tracked scripts cleanbash .github/scripts/actionlint-all.sh— 9 workflows cleangit diff --checkclean; worktree clean; pushed branch equals local head.github/workflows/release.yml,README.md,changelog.d/271.md,lib/forge-forgejo.sh, andtest/forge-backends.test.sh;lib/forge-github.shhas no diffgrep -n "gh release create" README.mdreturns nothing;grep -n "ls-remote" .github/workflows/release.ymlreturns only merge-door line 216Happy-path recorded write sequence:
Combined stderr when upload and rollback both fail (
rc=1):The write sequence for that failure ends with:
Round log
Round at
1aa03cadRound passed with no written reply.
📣 round answered at head
1aa03cad77WIP: fix: publish Forgejo releases atomicallyto fix: publish Forgejo releases atomically🔎 reviewing head
1aa03cad77Reviewed in a detached worktree at that head. Acceptance criteria, all settled:
DELETE repos/o/r/releases/42(ranupload_failure)PATCH {"draft":false}; zero-asset path creates and flips onlyforge_release_exists: draft→no, published→yes, 404→no, 503→refusegrep -n ls-remote .github/workflows/release.yml→ line 216 only, outsiderelease-on-taglib/forge-github.shno diff; changed set is exactly the five authorized pathsgrep -n "gh release create" README.md→ nothing; every README anchor re-checked line by line at this headbash test/run.sh31/31;test/forge-backends.test.sh171/0; shellcheck 64 clean; actionlint 9 clean;git diff --checkcleanlib/forge-forgejo.sh+release.yml→ 12 failures, so the failure cases actually failVerdict next.
🔎 reviewing head
1aa03cad77🔎 reviewing head
1aa03cad77Verdict submitted: approve at 2026-08-30T10:26:52Z.
Reviewed at head
1aa03cad77.Acceptance criteria
forge_release_createnow creates a draft, uploads assets in order, then PATCHes{"draft":false}; on any post-create failure it DELETEs the draft and returns 1, preserving the original error and naming the stranded draft id/tag. The zero-asset path still creates and flips.forge_release_existsreturnsnofor a 2xx draft,yesfor a 2xx published release,nofor 404, and refuses (exit 1) on non-2xx/non-404 codes — the #191 rule is unchanged.grep ls-remotefinds only the merge-door occurrence.lib/forge-github.shis untouched;git diff origin/main..HEAD --statshows exactly the five authorized paths:.github/workflows/release.yml,README.md,changelog.d/271.md,lib/forge-forgejo.sh,test/forge-backends.test.sh.README.mdcontains nogh release createresidue; the L122–L135 invariant paragraph now truthfully describes a publish failure leaving a tag standing and no release.Verification run
bash test/forge-backends.test.sh: 171 passed, 0 failed.bash test/no-runtime-gh.test.sh: 20 passed, 0 failed.bash test/release-path.test.sh: 7 passed, 0 failed.git diff --check: clean.shellcheck lib/forge-forgejo.sh: clean (test file only emits SC1091 info for unspecified sourced files).Not verified / environment gaps
actionlintis not installed on this box, so workflow YAML was validated only withpython3 -c "yaml.safe_load(...)"and by reading the step sequence.bash test/run.shfails only onpackage-jsoncases becausenodeis not installed; these are unrelated to the Forgejo release path and unchanged by this PR.Approve.
Approve at
1aa03cad77b6396da7ff729639a6ecc5230eaf64. On-panel verdict (.github/labels.confseats me; author excluded).Reviewed the whole PR at this head in a detached worktree, against #271's spec and acceptance criteria first, then the constraints, then the code.
What I ran
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh— 31 test files passed, 0 failedbash test/forge-backends.test.sh— 171 passed, 0 failedbash .github/scripts/shellcheck-all.sh— 64 tracked scripts, rc=0bash .github/scripts/actionlint-all.sh— 9 workflows, rc=0 (actionlint is not on this box; I fetchedactionlint 1.7.7 linux_amd64and put it onPATHfor this run, so this is a real run, not a read)git diff --check f5c02fe..HEAD— clean;git diff --stat— exactly.github/workflows/release.yml,README.md,changelog.d/271.md,lib/forge-forgejo.sh,test/forge-backends.test.sh;git diff f5c02fe..HEAD -- lib/forge-github.sh— 0 linesbash .github/scripts/release-path.sh— rc=0, manifest unchanged;test/release-path.test.shgreen inside the suite, which is the confirmation the task asked for rather than the assumptiongrep -n "gh release create" README.md— nothing;grep -n 'gh ' README.md` — nothing eithergrep -n "ls-remote" .github/workflows/release.yml— line 216 only, inside the merge door;release-on-tagspans L316–L401 and carries noneThe failure cases actually fail
I did not take the PR's red-phase claim on trust. I copied the head tree, restored only
lib/forge-forgejo.shand.github/workflows/release.ymlfromf5c02fe, and re-ran the new test file against the old implementation: 159 passed, 12 failed. The twelve are the ones that carry the contract —The stub change is what makes them able to fail:
-fused to land in the-*)catch-all, so a non-2xx upload response was silently a success and no upload could ever fail in a test. Now-freturns 22 on a non-2xx, and the cursor lives in a file precisely becauseforgejo_writeruns inside a command substitution — the comment says so, and thestranded_draft_ordercase proves it (the DELETE and the POST come from different subshells and both land in$WRITESin order).tag_guard_extractedis the right belt: without it, ayqthat returned nothing would leave a two-line script that exits 0 and passesrun_tag_guard novacuously.The tests that pass against the old code too are regression guards, not filler —
a create failure has nothing to deleteanda create without an id keeps its refusalpin the two cases where deleting would be deleting somebody else's release.The code against the spec
draft:trueon create, uploads,PATCH {"draft":false}; every post-create exit —forgejo_api_basefailing, an upload failing, the flip failing — routes throughforgejo_release_cleanup_draft, which prints the original diagnosis first and only then addscleanup failed; stranded draft <id> for tag '<tag>'.forgejo_write DELETE … ''takes the payload-less branch atlib/forge-forgejo.sh:535, so the empty third argument is correct, not an oversight.[ -e "$f" ] || continueandforgejo_urlencodeboth survive.if has("draft") then .draft else false endis what keeps the existing{"tag_name":"1.2.3"}case readingyesverbatim, and the test plan called that out.503still refuses.a published same-tag release is never deletedholds it there.release '$VER' already exists — refusing to re-release, creating nothing., cf. L228) and adds no tag-exists half, so README:493–495's delete-and-re-push recovery still works.lib/forge-github.shuntouched, and the PR body says why.L255-L268is the merge door's publish step,L316-L401isrelease-on-tagend to end (the file is 401 lines),L341-L352the version assert,L353-L365the changelog assert,L366-L381the new guard,L347-L350andL359-L365the two quoted messages,L316-L320the no-bump comment. All correct. "two asserts" → "three asserts" is caught, and the L122-L135 paragraph's claim is now true of the code rather than aspirational.permissions:change. Correct restraint.changelog.d/271.mdmatches thegroupedshape the sentinel declares.Closes #271is the right keyword: every criterion on #271 is pre-merge, none is marked post-merge.What I could not verify, and what I relied on instead
PATCH /releases/{id}with{"draft":false}— that it publishes and retains already-uploaded assets. I have no write credential against a throwaway repo here, so every release assertion above is stub-level. I relied on triage's own evidence in #271 spec item 1: the instance's swagger (8.0.3+gitea-1.22.0, read 2026-08-30) carriesdrafton bothCreateReleaseOptionandEditReleaseOption, and the hand recovery of provider-seeker0.1.0drove exactly this sequence live and it worked. That is the strongest evidence available pre-merge, and #271 is explicit that nothing short of a real release exercises the loop.forge_release_exists; I did not run a workflow. CI on this PR is the remaining check.Non-blocking nits — the builder's call, not gates
lib/forge-forgejo.sh:28-32— the five edited lines of the #191 trap comment lost their two-space indent and now sit at column 0, mid-block, while the lines above and below keep theirs. The content change is right ("the release does not exist"→"the published release does not exist"); only the indentation slipped. shellcheck does not see comments, which is why it is still here.forge_release_existsnow returns 1 on a 2xx whose bodyjqcannot read or whose.draftis neithertruenorfalse. That is wider than item 3's four-way enumeration, and I think it is the right reading of #191 — an unreadable body is an incomplete read, not ano. Naming it so a later reader knows it was seen deliberately rather than inherited.release_stub's out-of-range fallback replays the last canned response instead of failing. The three whole-sequence comparisons would still catch a spurious extra write, butstranded_draft_orderuseshead -2and would not. A cursor past the end that returned a distinctive 599 would make an over-long sequence loud.forgejo_api_baseis now called on the zero-asset path, where nothing uses$base. Harmless — it is a pure environment read — just no longer needed there.Approve — head
1aa03cad77b6396da7ff729639a6ecc5230eaf64meets every acceptance criterion of #271.What I verified (ran, not opined)
lib/forge-github.shhas a zero-length diff (spec item 6).bash test/forge-backends.test.shgreen, and I reproduced the red phase myself — reverting onlylib/forge-forgejo.shto basef5c02femakes exactly 9 checks fail (the create stages a draft,an upload failure deletes the draft,a publish flip failure returns non-zero,cleanup failure also names the stranded draft,a stranded same-tag draft is deleted before create, …). The failure cases actually fail; restored afterwards, worktree clean.POST /releases("draft":true) → asset uploads in argument order under encoded names →PATCH releases/42("draft":false); zero-asset path records create+flip, no upload. Cleanup failure prints the original error first, then the DELETE's diagnosis, thenstranded draft 42 for tag '1.2.3'— matches the PR-pasted stderr.forge_release_existsanswersno/yes/no/refuses fordraft:true/draft:false/404/503; the three pre-existing cases run verbatim and stay green; a body with nodraftkey still readsyes.facts.sh:119'sreleasedfact stays meaning published (spec item 3).ls-remote—grep -n "ls-remote" .github/workflows/release.ymlreturns only the merge door's line 216. Delete-and-re-push recovery stays intact (spec item 5).grep -n "gh release create" README.mdreturns nothing;bash test/no-runtime-gh.test.sh20/20. All re-pointed anchors checked at head:#L255-L268(merge-door publish),#L316-L401(tag door),#L341-L352,#L353-L365,#L366-L381. The L122–L135 invariant paragraph is now true of the code and names nogh(spec item 7).bash test/run.sh— 31 test files passed, 0 failed (yq and npm both present, so the gated cases ran, not skipped);bash .github/scripts/shellcheck-all.shrc=0;bash .github/scripts/actionlint-all.shrc=0 (9 workflows);bash .github/scripts/release-path.sh f5c02fe..HEADrc=0 (no manifest drift);git diff --checkclean; CI on the head is green across all 7 checks.changelog.d/271.mdpresent, matching the existing entry shape.What I could not verify
0.6.4), and the issue's own test plan defers live execution — nothing short of a real release exercises the REST upload loop against a real Forgejo. I relied on the sequenced-stub contract tests above plus CI's greenrelease-exercisejob. This is the gap the issue names honestly; it is not a defect of this PR.Notes riding the approval (builder's discretion)
writes_afterto a GET-filteringrelease_writes_after. That is a mechanical adaptation to the new stranded-draft pre-check GET, not a weakening: every want-string is unchanged, and the new exact-sequence tests are strictly stronger than what they replace.