fix: publish Forgejo releases atomically #272

Merged
andres merged 6 commits from build/271-atomic-forgejo-release into main 2026-08-30 10:47:56 +00:00

Closes #271

Summary

  • stage Forgejo releases as drafts until every asset upload succeeds
  • remove post-create drafts on failure without masking the original error
  • distinguish draft releases from published releases and recover same-tag stranded drafts
  • guard the tag door against an already-published release

The GitHub backend is intentionally unchanged: gh release create already attaches assets as part of the create operation.

Acceptance criteria

  • forge_release_create leaves a complete published release or nothing; upload failure records a final DELETE repos/o/r/releases/42
  • happy path writes draft create, ordered asset uploads, and final publish PATCH; no-asset path still creates and flips
  • cleanup failure preserves the original error and names the stranded draft id and tag
  • forge_release_exists distinguishes draft, published, absent, and unknown responses
  • tag door refuses a published release without adding a tag-exists guard
  • lib/forge-github.sh remains unchanged and the changed-file set is exactly the five authorized paths
  • README contains no gh release create residue and accurately describes the failure invariant
  • full tests, diff check, shellcheck, and actionlint pass

Worklog

  • Trace the current Forgejo release path and establish a green baseline
  • Extend the release stub with sequenced responses and add failing atomicity tests
  • Implement draft-aware existence and atomic create/upload/publish cleanup
  • Add the published-release guard to the tag door
  • Correct README and add changelog.d/271.md
  • Run focused and full verification; record required sequences and stderr evidence

Test evidence

  • Baseline: bash test/run.sh — 31 test files passed, 0 failed
  • Red phase: bash test/forge-backends.test.sh — 157 passed, 9 expected failures exposing the missing contract
  • Adapter green phase: bash test/forge-backends.test.sh — 166 passed, 0 failed
  • Workflow green phase: bash test/forge-backends.test.sh — 171 passed, 0 failed; release-path 7/0; no-runtime-gh 20/0
  • Final head 1aa03cad77b6396da7ff729639a6ecc5230eaf64: CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh — 31 test files passed, 0 failed
  • bash .github/scripts/shellcheck-all.sh — 64 tracked scripts clean
  • bash .github/scripts/actionlint-all.sh — 9 workflows clean
  • git diff --check clean; worktree clean; pushed branch equals local head
  • Changed paths are exactly .github/workflows/release.yml, README.md, changelog.d/271.md, lib/forge-forgejo.sh, and test/forge-backends.test.sh; lib/forge-github.sh has no diff
  • grep -n "gh release create" README.md returns nothing; grep -n "ls-remote" .github/workflows/release.yml returns only merge-door line 216

Happy-path recorded write sequence:

POST repos/o/r/releases {"tag_name":"1.2.3","name":"1.2.3","body":"notes body\n","draft":true,"prerelease":false}
POST repos/o/r/releases/42/assets?name=first%20asset.tgz -F attachment=@…/first asset.tgz
POST repos/o/r/releases/42/assets?name=a%26b.tgz -F attachment=@…/a&b.tgz
PATCH repos/o/r/releases/42 {"draft":false}

Combined stderr when upload and rollback both fail (rc=1):

forge_release_create: asset upload failed for '…/a&b.tgz'
forge_api: HTTP 500 from 'DELETE repos/o/r/releases/42'
{"message":"delete refused"}
forge_release_create: cleanup failed; stranded draft 42 for tag '1.2.3'

The write sequence for that failure ends with:

POST repos/o/r/releases {"tag_name":"1.2.3","name":"1.2.3","body":"notes body\n","draft":true,"prerelease":false}
POST repos/o/r/releases/42/assets?name=a%26b.tgz -F attachment=@…/a&b.tgz
DELETE repos/o/r/releases/42

Round log

Round at 1aa03cad

Round passed with no written reply.

Closes #271 ## Summary - stage Forgejo releases as drafts until every asset upload succeeds - remove post-create drafts on failure without masking the original error - distinguish draft releases from published releases and recover same-tag stranded drafts - guard the tag door against an already-published release The GitHub backend is intentionally unchanged: `gh release create` already attaches assets as part of the create operation. ## Acceptance criteria - [x] `forge_release_create` leaves a complete published release or nothing; upload failure records a final `DELETE repos/o/r/releases/42` - [x] happy path writes draft create, ordered asset uploads, and final publish PATCH; no-asset path still creates and flips - [x] cleanup failure preserves the original error and names the stranded draft id and tag - [x] `forge_release_exists` distinguishes draft, published, absent, and unknown responses - [x] tag door refuses a published release without adding a tag-exists guard - [x] `lib/forge-github.sh` remains unchanged and the changed-file set is exactly the five authorized paths - [x] README contains no `gh release create` residue and accurately describes the failure invariant - [x] full tests, diff check, shellcheck, and actionlint pass ## Worklog - [x] Trace the current Forgejo release path and establish a green baseline - [x] Extend the release stub with sequenced responses and add failing atomicity tests - [x] Implement draft-aware existence and atomic create/upload/publish cleanup - [x] Add the published-release guard to the tag door - [x] Correct README and add `changelog.d/271.md` - [x] Run focused and full verification; record required sequences and stderr evidence ## Test evidence - Baseline: `bash test/run.sh` — 31 test files passed, 0 failed - Red phase: `bash test/forge-backends.test.sh` — 157 passed, 9 expected failures exposing the missing contract - Adapter green phase: `bash test/forge-backends.test.sh` — 166 passed, 0 failed - Workflow green phase: `bash test/forge-backends.test.sh` — 171 passed, 0 failed; release-path 7/0; no-runtime-gh 20/0 - Final head `1aa03cad77b6396da7ff729639a6ecc5230eaf64`: `CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh` — 31 test files passed, 0 failed - `bash .github/scripts/shellcheck-all.sh` — 64 tracked scripts clean - `bash .github/scripts/actionlint-all.sh` — 9 workflows clean - `git diff --check` clean; worktree clean; pushed branch equals local head - Changed paths are exactly `.github/workflows/release.yml`, `README.md`, `changelog.d/271.md`, `lib/forge-forgejo.sh`, and `test/forge-backends.test.sh`; `lib/forge-github.sh` has no diff - `grep -n "gh release create" README.md` returns nothing; `grep -n "ls-remote" .github/workflows/release.yml` returns only merge-door line 216 Happy-path recorded write sequence: ```text POST repos/o/r/releases {"tag_name":"1.2.3","name":"1.2.3","body":"notes body\n","draft":true,"prerelease":false} POST repos/o/r/releases/42/assets?name=first%20asset.tgz -F attachment=@…/first asset.tgz POST repos/o/r/releases/42/assets?name=a%26b.tgz -F attachment=@…/a&b.tgz PATCH repos/o/r/releases/42 {"draft":false} ``` Combined stderr when upload and rollback both fail (`rc=1`): ```text forge_release_create: asset upload failed for '…/a&b.tgz' forge_api: HTTP 500 from 'DELETE repos/o/r/releases/42' {"message":"delete refused"} forge_release_create: cleanup failed; stranded draft 42 for tag '1.2.3' ``` The write sequence for that failure ends with: ```text POST repos/o/r/releases {"tag_name":"1.2.3","name":"1.2.3","body":"notes body\n","draft":true,"prerelease":false} POST repos/o/r/releases/42/assets?name=a%26b.tgz -F attachment=@…/a&b.tgz DELETE repos/o/r/releases/42 ``` ## Round log <!-- round:1aa03cad77b6396da7ff729639a6ecc5230eaf64 --> **Round at 1aa03cad** _Round passed with no written reply._
codex-bot-andresmgsl added 1 commit 2026-08-30 09:24:15 +00:00
test: expose non-atomic Forgejo releases
Some checks failed
CI / action-exercise (pull_request) Successful in 7s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Failing after 3m57s
2ab9902c38
forgejo-actions added the
state:building
label 2026-08-30 09:24:42 +00:00
codex-bot-andresmgsl added 1 commit 2026-08-30 09:25:52 +00:00
fix: publish Forgejo releases atomically
All checks were successful
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Successful in 3m58s
c4315c2cfa
forgejo-actions added the
scope:release-flow
label 2026-08-30 09:26:20 +00:00
codex-bot-andresmgsl added 1 commit 2026-08-30 09:27:18 +00:00
fix: guard tag release publication
Some checks failed
CI / test (pull_request) Failing after 41s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
734676ab7d
codex-bot-andresmgsl added 1 commit 2026-08-30 09:28:05 +00:00
docs: describe atomic release publication
Some checks failed
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 13s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Failing after 42s
20cba4583d
forgejo-actions added the
scope:docs
label 2026-08-30 09:28:26 +00:00
codex-bot-andresmgsl added 1 commit 2026-08-30 09:31:26 +00:00
test: document deferred fixture expansion
All checks were successful
CI / action-exercise (pull_request) Successful in 6s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Successful in 3m53s
fe4ec57ff2
codex-bot-andresmgsl added 1 commit 2026-08-30 09:33:21 +00:00
docs: describe all tag-door assertions
All checks were successful
CI / self-guards (pull_request) Successful in 10s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 9s
CI / test (pull_request) Successful in 3m53s
Refs guard / refs-not-closing (pull_request) Successful in 7s
1aa03cad77
Author
Member

📣 round answered at head 1aa03cad77

📣 round answered at head 1aa03cad77b6396da7ff729639a6ecc5230eaf64
codex-bot-andresmgsl changed title from WIP: fix: publish Forgejo releases atomically to fix: publish Forgejo releases atomically 2026-08-30 09:35:51 +00:00
forgejo-actions added
blocker:unrequested
state:addressing
and removed
state:building
labels 2026-08-30 10:00:16 +00:00
codex-bot-andresmgsl requested review from glm-bot-andresmgsl 2026-08-30 10:15:36 +00:00
codex-bot-andresmgsl requested review from claude-bot-andresmgsl 2026-08-30 10:15:36 +00:00
codex-bot-andresmgsl requested review from kimi-bot-andresmgsl 2026-08-30 10:15:37 +00:00
codex-bot-andresmgsl added the
state:bots-reviewing
label 2026-08-30 10:15:37 +00:00
forgejo-actions removed the
blocker:unrequested
state:addressing
labels 2026-08-30 10:15:54 +00:00

🔎 reviewing head 1aa03cad77

Reviewed in a detached worktree at that head. Acceptance criteria, all settled:

  • atomic create — upload failure returns 1 and records DELETE repos/o/r/releases/42 (ran upload_failure)
  • happy path records draft create → ordered uploads → PATCH {"draft":false}; zero-asset path creates and flips only
  • cleanup failure keeps the original error and names the stranded draft id + tag
  • forge_release_exists: draft→no, published→yes, 404→no, 503→refuse
  • tag door refuses a published release; grep -n ls-remote .github/workflows/release.yml → line 216 only, outside release-on-tag
  • lib/forge-github.sh no diff; changed set is exactly the five authorized paths
  • grep -n "gh release create" README.md → nothing; every README anchor re-checked line by line at this head
  • bash test/run.sh 31/31; test/forge-backends.test.sh 171/0; shellcheck 64 clean; actionlint 9 clean; git diff --check clean
  • red-phase re-run: the new tests against base lib/forge-forgejo.sh + release.yml → 12 failures, so the failure cases actually fail

Verdict next.

🔎 reviewing head 1aa03cad77b6396da7ff729639a6ecc5230eaf64 Reviewed in a detached worktree at that head. Acceptance criteria, all settled: - [x] atomic create — upload failure returns 1 and records `DELETE repos/o/r/releases/42` (ran `upload_failure`) - [x] happy path records draft create → ordered uploads → `PATCH {"draft":false}`; zero-asset path creates and flips only - [x] cleanup failure keeps the original error and names the stranded draft id + tag - [x] `forge_release_exists`: draft→no, published→yes, 404→no, 503→refuse - [x] tag door refuses a published release; `grep -n ls-remote .github/workflows/release.yml` → line 216 only, outside `release-on-tag` - [x] `lib/forge-github.sh` no diff; changed set is exactly the five authorized paths - [x] `grep -n "gh release create" README.md` → nothing; every README anchor re-checked line by line at this head - [x] `bash test/run.sh` 31/31; `test/forge-backends.test.sh` 171/0; shellcheck 64 clean; actionlint 9 clean; `git diff --check` clean - [x] red-phase re-run: the new tests against base `lib/forge-forgejo.sh` + `release.yml` → 12 failures, so the failure cases actually fail Verdict next.

🔎 reviewing head 1aa03cad77

  • announced; read issue #271, REVIEWER/LABELS/CONTRIBUTING, full diff (5 authorized files)
  • spec items 1-7 traced in code; README anchors verified at head; forge-github.sh zero diff
  • ran: test/run.sh 31/31 green (yq+npm present), forge-backends green, shellcheck rc=0, actionlint rc=0, release-path rc=0, diff --check clean, grep criteria pass
  • red-phase reproduced: reverting lib/forge-forgejo.sh alone fails exactly 9 checks; restored clean
  • CI on head green (7/7 checks)
  • verdict: approve — submitting
🔎 reviewing head 1aa03cad77b6396da7ff729639a6ecc5230eaf64 - announced; read issue #271, REVIEWER/LABELS/CONTRIBUTING, full diff (5 authorized files) - spec items 1-7 traced in code; README anchors verified at head; forge-github.sh zero diff - ran: test/run.sh 31/31 green (yq+npm present), forge-backends green, shellcheck rc=0, actionlint rc=0, release-path rc=0, diff --check clean, grep criteria pass - red-phase reproduced: reverting lib/forge-forgejo.sh alone fails exactly 9 checks; restored clean - CI on head green (7/7 checks) - verdict: approve — submitting

🔎 reviewing head 1aa03cad77

Verdict submitted: approve at 2026-08-30T10:26:52Z.

🔎 reviewing head 1aa03cad77b6396da7ff729639a6ecc5230eaf64 Verdict submitted: approve at 2026-08-30T10:26:52Z.
kimi-bot-andresmgsl approved these changes 2026-08-30 10:26:59 +00:00
kimi-bot-andresmgsl left a comment
Member

Reviewed at head 1aa03cad77.

Acceptance criteria

  • forge_release_create now 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_exists returns no for a 2xx draft, yes for a 2xx published release, no for 404, and refuses (exit 1) on non-2xx/non-404 codes — the #191 rule is unchanged.
  • The tag door gained a published-release guard and no tag-exists guard; grep ls-remote finds only the merge-door occurrence.
  • lib/forge-github.sh is untouched; git diff origin/main..HEAD --stat shows 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.md contains no gh release create residue; 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

  • actionlint is not installed on this box, so workflow YAML was validated only with python3 -c "yaml.safe_load(...)" and by reading the step sequence.
  • bash test/run.sh fails only on package-json cases because node is not installed; these are unrelated to the Forgejo release path and unchanged by this PR.

Approve.

Reviewed at head 1aa03cad77b6396da7ff729639a6ecc5230eaf64. **Acceptance criteria** - `forge_release_create` now 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_exists` returns `no` for a 2xx draft, `yes` for a 2xx published release, `no` for 404, and refuses (exit 1) on non-2xx/non-404 codes — the #191 rule is unchanged. - The tag door gained a published-release guard and no tag-exists guard; `grep ls-remote` finds only the merge-door occurrence. - `lib/forge-github.sh` is untouched; `git diff origin/main..HEAD --stat` shows 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.md` contains no `gh release create` residue; 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** - `actionlint` is not installed on this box, so workflow YAML was validated only with `python3 -c "yaml.safe_load(...)"` and by reading the step sequence. - `bash test/run.sh` fails only on `package-json` cases because `node` is not installed; these are unrelated to the Forgejo release path and unchanged by this PR. Approve.
claude-bot-andresmgsl approved these changes 2026-08-30 10:28:07 +00:00
claude-bot-andresmgsl left a comment
Member

Approve at 1aa03cad77b6396da7ff729639a6ecc5230eaf64. On-panel verdict (.github/labels.conf seats 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.sh31 test files passed, 0 failed
  • bash test/forge-backends.test.sh171 passed, 0 failed
  • bash .github/scripts/shellcheck-all.sh — 64 tracked scripts, rc=0
  • bash .github/scripts/actionlint-all.sh — 9 workflows, rc=0 (actionlint is not on this box; I fetched actionlint 1.7.7 linux_amd64 and put it on PATH for 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.sh0 lines
  • bash .github/scripts/release-path.sh — rc=0, manifest unchanged; test/release-path.test.sh green inside the suite, which is the confirmation the task asked for rather than the assumption
  • grep -n "gh release create" README.md — nothing; grep -n 'gh ' README.md` — nothing either
  • grep -n "ls-remote" .github/workflows/release.ymlline 216 only, inside the merge door; release-on-tag spans L316–L401 and carries none

The 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.sh and .github/workflows/release.yml from f5c02fe, and re-ran the new test file against the old implementation: 159 passed, 12 failed. The twelve are the ones that carry the contract —

FAIL: forgejo: a draft is not a present release
FAIL: the create stages a draft
FAIL: the zero-asset path creates then publishes
FAIL: assets upload in argument order before the release is published
FAIL: an upload failure deletes the draft
FAIL: a publish flip failure returns non-zero
FAIL: a publish flip failure deletes the draft
FAIL: cleanup failure also names the stranded draft
FAIL: a stranded same-tag draft is deleted before create
FAIL: the tag door's published-release guard is extracted
FAIL: the tag door refuses an existing published release
FAIL: the tag door refuses when release state is unreadable

The stub change is what makes them able to fail: -f used 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 -f returns 22 on a non-2xx, and the cursor lives in a file precisely because forgejo_write runs inside a command substitution — the comment says so, and the stranded_draft_order case proves it (the DELETE and the POST come from different subshells and both land in $WRITES in order). tag_guard_extracted is the right belt: without it, a yq that returned nothing would leave a two-line script that exits 0 and passes run_tag_guard no vacuously.

The tests that pass against the old code too are regression guards, not filler — a create failure has nothing to delete and a create without an id keeps its refusal pin the two cases where deleting would be deleting somebody else's release.

The code against the spec

  • Item 1/2 (all-or-nothing). draft:true on create, uploads, PATCH {"draft":false}; every post-create exit — forgejo_api_base failing, an upload failing, the flip failing — routes through forgejo_release_cleanup_draft, which prints the original diagnosis first and only then adds cleanup failed; stranded draft <id> for tag '<tag>'. forgejo_write DELETE … '' takes the payload-less branch at lib/forge-forgejo.sh:535, so the empty third argument is correct, not an oversight. [ -e "$f" ] || continue and forgejo_urlencode both survive.
  • Item 3 (a draft is not a release). if has("draft") then .draft else false end is what keeps the existing {"tag_name":"1.2.3"} case reading yes verbatim, and the test plan called that out. 503 still refuses.
  • Item 4 (stranded-draft clear). Only a draft, only for this tag, and it says so on stderr. A published release falls through to the create's own 409 — which is what item 4 asked for, and a published same-tag release is never deleted holds it there.
  • Item 5 (tag door). The new step sits between the changelog assert and the artifact hook — inside "between the changelog assert and the publish", and earlier is better since it refuses before the consumer's hook burns a build. It reuses the merge door's sentence byte-for-byte (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.
  • Item 6. lib/forge-github.sh untouched, and the PR body says why.
  • Item 7 (README). I re-resolved every anchor the PR moved, by reading the lines: L255-L268 is the merge door's publish step, L316-L401 is release-on-tag end to end (the file is 401 lines), L341-L352 the version assert, L353-L365 the changelog assert, L366-L381 the new guard, L347-L350 and L359-L365 the two quoted messages, L316-L320 the 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.
  • Item 8. No root-cause claim, no merge-door resume path, no token or permissions: change. Correct restraint.

changelog.d/271.md matches the grouped shape the sentinel declares. Closes #271 is 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

  • The live Forgejo behaviour of 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) carries draft on both CreateReleaseOption and EditReleaseOption, and the hand recovery of provider-seeker 0.1.0 drove 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.
  • The doors themselves. I executed the tag door's new guard as extracted shell (the repo's own convention for workflow-shell contracts) with a faked 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

  1. 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.
  2. forge_release_exists now returns 1 on a 2xx whose body jq cannot read or whose .draft is neither true nor false. 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 a no. Naming it so a later reader knows it was seen deliberately rather than inherited.
  3. 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, but stranded_draft_order uses head -2 and would not. A cursor past the end that returned a distinctive 599 would make an over-long sequence loud.
  4. forgejo_api_base is now called on the zero-asset path, where nothing uses $base. Harmless — it is a pure environment read — just no longer needed there.
**Approve** at `1aa03cad77b6396da7ff729639a6ecc5230eaf64`. On-panel verdict (`.github/labels.conf` seats 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 failed** - `bash test/forge-backends.test.sh` — **171 passed, 0 failed** - `bash .github/scripts/shellcheck-all.sh` — 64 tracked scripts, **rc=0** - `bash .github/scripts/actionlint-all.sh` — 9 workflows, **rc=0** (actionlint is not on this box; I fetched `actionlint 1.7.7 linux_amd64` and put it on `PATH` for 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 lines** - `bash .github/scripts/release-path.sh` — rc=0, manifest unchanged; `test/release-path.test.sh` green inside the suite, which is the confirmation the task asked for rather than the assumption - `grep -n "gh release create" README.md` — nothing; `grep -n '`gh ' README.md` — nothing either - `grep -n "ls-remote" .github/workflows/release.yml` — **line 216 only**, inside the merge door; `release-on-tag` spans L316–L401 and carries none ## The 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.sh` and `.github/workflows/release.yml` from `f5c02fe`, and re-ran the new test file against the old implementation: **159 passed, 12 failed**. The twelve are the ones that carry the contract — ``` FAIL: forgejo: a draft is not a present release FAIL: the create stages a draft FAIL: the zero-asset path creates then publishes FAIL: assets upload in argument order before the release is published FAIL: an upload failure deletes the draft FAIL: a publish flip failure returns non-zero FAIL: a publish flip failure deletes the draft FAIL: cleanup failure also names the stranded draft FAIL: a stranded same-tag draft is deleted before create FAIL: the tag door's published-release guard is extracted FAIL: the tag door refuses an existing published release FAIL: the tag door refuses when release state is unreadable ``` The stub change is what makes them able to fail: `-f` used 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 `-f` returns 22 on a non-2xx, and the cursor lives in a file precisely because `forgejo_write` runs inside a command substitution — the comment says so, and the `stranded_draft_order` case proves it (the DELETE and the POST come from different subshells and both land in `$WRITES` in order). `tag_guard_extracted` is the right belt: without it, a `yq` that returned nothing would leave a two-line script that exits 0 and passes `run_tag_guard no` vacuously. The tests that pass against the old code too are regression guards, not filler — `a create failure has nothing to delete` and `a create without an id keeps its refusal` pin the two cases where deleting would be deleting somebody else's release. ## The code against the spec - **Item 1/2 (all-or-nothing).** `draft:true` on create, uploads, `PATCH {"draft":false}`; every post-create exit — `forgejo_api_base` failing, an upload failing, the flip failing — routes through `forgejo_release_cleanup_draft`, which prints the original diagnosis first and only then adds `cleanup failed; stranded draft <id> for tag '<tag>'`. `forgejo_write DELETE … ''` takes the payload-less branch at `lib/forge-forgejo.sh:535`, so the empty third argument is correct, not an oversight. `[ -e "$f" ] || continue` and `forgejo_urlencode` both survive. - **Item 3 (a draft is not a release).** `if has("draft") then .draft else false end` is what keeps the existing `{"tag_name":"1.2.3"}` case reading `yes` verbatim, and the test plan called that out. `503` still refuses. - **Item 4 (stranded-draft clear).** Only a draft, only for this tag, and it says so on stderr. A published release falls through to the create's own 409 — which is what item 4 asked for, and `a published same-tag release is never deleted` holds it there. - **Item 5 (tag door).** The new step sits between the changelog assert and the artifact hook — inside "between the changelog assert and the publish", and earlier is better since it refuses before the consumer's hook burns a build. It reuses the merge door's sentence byte-for-byte (`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. - **Item 6.** `lib/forge-github.sh` untouched, and the PR body says why. - **Item 7 (README).** I re-resolved every anchor the PR moved, by reading the lines: `L255-L268` is the merge door's publish step, `L316-L401` is `release-on-tag` end to end (the file is 401 lines), `L341-L352` the version assert, `L353-L365` the changelog assert, `L366-L381` the new guard, `L347-L350` and `L359-L365` the two quoted messages, `L316-L320` the 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. - **Item 8.** No root-cause claim, no merge-door resume path, no token or `permissions:` change. Correct restraint. `changelog.d/271.md` matches the `grouped` shape the sentinel declares. `Closes #271` is 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 - **The live Forgejo behaviour of `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) carries `draft` on both `CreateReleaseOption` and `EditReleaseOption`, and the hand recovery of provider-seeker `0.1.0` drove 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. - **The doors themselves.** I executed the tag door's new guard as extracted shell (the repo's own convention for workflow-shell contracts) with a faked `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 1. `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. 2. `forge_release_exists` now returns 1 on a 2xx whose body `jq` cannot read or whose `.draft` is neither `true` nor `false`. 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 a `no`. Naming it so a later reader knows it was seen deliberately rather than inherited. 3. `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, but `stranded_draft_order` uses `head -2` and would not. A cursor past the end that returned a distinctive 599 would make an over-long sequence loud. 4. `forgejo_api_base` is now called on the zero-asset path, where nothing uses `$base`. Harmless — it is a pure environment read — just no longer needed there.
glm-bot-andresmgsl approved these changes 2026-08-30 10:30:33 +00:00
glm-bot-andresmgsl left a comment
Member

Approve — head 1aa03cad77b6396da7ff729639a6ecc5230eaf64 meets every acceptance criterion of #271.

What I verified (ran, not opined)

  • Changed set is exactly the five authorized paths; lib/forge-github.sh has a zero-length diff (spec item 6).
  • Atomicity contract: bash test/forge-backends.test.sh green, and I reproduced the red phase myself — reverting only lib/forge-forgejo.sh to base f5c02fe makes 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.
  • Ordering: exact-sequence tests assert 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, then stranded draft 42 for tag '1.2.3' — matches the PR-pasted stderr.
  • Draft distinction: forge_release_exists answers no/yes/no/refuses for draft:true/draft:false/404/503; the three pre-existing cases run verbatim and stay green; a body with no draft key still reads yes. facts.sh:119's released fact stays meaning published (spec item 3).
  • Tag door guard: new step sits between the changelog assert and the publish, uses the merge door's wording byte-identically, refuses on unreadable state (#191 rule), and carries no ls-remotegrep -n "ls-remote" .github/workflows/release.yml returns only the merge door's line 216. Delete-and-re-push recovery stays intact (spec item 5).
  • README: grep -n "gh release create" README.md returns nothing; bash test/no-runtime-gh.test.sh 20/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 no gh (spec item 7).
  • Whole battery: 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.sh rc=0; bash .github/scripts/actionlint-all.sh rc=0 (9 workflows); bash .github/scripts/release-path.sh f5c02fe..HEAD rc=0 (no manifest drift); git diff --check clean; CI on the head is green across all 7 checks.
  • Changelog: changelog.d/271.md present, matching the existing entry shape.

What I could not verify

  • The live doors. No release window stands (#268 ruled no 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 green release-exercise job. This is the gap the issue names honestly; it is not a defect of this PR.

Notes riding the approval (builder's discretion)

  • The pre-existing release tests moved from writes_after to a GET-filtering release_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.
**Approve** — head `1aa03cad77b6396da7ff729639a6ecc5230eaf64` meets every acceptance criterion of #271. ## What I verified (ran, not opined) - **Changed set** is exactly the five authorized paths; `lib/forge-github.sh` has a zero-length diff (spec item 6). - **Atomicity contract**: `bash test/forge-backends.test.sh` green, and I reproduced the red phase myself — reverting only `lib/forge-forgejo.sh` to base `f5c02fe` makes 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. - **Ordering**: exact-sequence tests assert `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, then `stranded draft 42 for tag '1.2.3'` — matches the PR-pasted stderr. - **Draft distinction**: `forge_release_exists` answers `no`/`yes`/`no`/refuses for `draft:true`/`draft:false`/404/503; the three pre-existing cases run verbatim and stay green; a body with no `draft` key still reads `yes`. `facts.sh:119`'s `released` fact stays meaning *published* (spec item 3). - **Tag door guard**: new step sits between the changelog assert and the publish, uses the merge door's wording byte-identically, refuses on unreadable state (#191 rule), and carries no `ls-remote` — `grep -n "ls-remote" .github/workflows/release.yml` returns only the merge door's line 216. Delete-and-re-push recovery stays intact (spec item 5). - **README**: `grep -n "gh release create" README.md` returns nothing; `bash test/no-runtime-gh.test.sh` 20/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 no `gh` (spec item 7). - **Whole battery**: `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.sh` rc=0; `bash .github/scripts/actionlint-all.sh` rc=0 (9 workflows); `bash .github/scripts/release-path.sh f5c02fe..HEAD` rc=0 (no manifest drift); `git diff --check` clean; CI on the head is green across all 7 checks. - **Changelog**: `changelog.d/271.md` present, matching the existing entry shape. ## What I could not verify - **The live doors.** No release window stands (#268 ruled no `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 green `release-exercise` job. This is the gap the issue names honestly; it is not a defect of this PR. ## Notes riding the approval (builder's discretion) - The pre-existing release tests moved from `writes_after` to a GET-filtering `release_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.
codex-bot-andresmgsl requested review from andres 2026-08-30 10:45:42 +00:00
codex-bot-andresmgsl added the
state:needs-human
label 2026-08-30 10:45:43 +00:00
forgejo-actions removed the
state:bots-reviewing
label 2026-08-30 10:45:57 +00:00
andres merged commit 58ab50361d into main 2026-08-30 10:47:56 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#272
No description provided.