feat: idempotent cut pipeline — pre-check existing release + manifest state (closes #128) #131

Merged
quartermaster merged 1 commit from i/v0.10.x-idempotency-pre-checks into main 2026-06-26 22:11:49 +02:00

Closes #128

Surfaced from the v0.10.0 cut artifact (Surveyor df03 + 4a44 reconciliation): task 13307 substantively completed the cut but got cancelled mid-flight; operator's manual re-run (task 13310) called the release-create API → 409 Conflict because v0.10.0 already existed. Workflow exit code reported failure despite correct substrate.

Per Surveyor 4a44 design point: the idempotency must handle partial-completion, not just full-completion. The cut path has two mutation surfaces (release creation + manifest update) — either can have completed independently when a workflow gets cancelled.

Partial-completion matrix

Release exists? Manifest at HEAD_SHA? Re-run action
No No Normal cut (no change from prior behavior)
Yes (matches expected) No Release: idempotent skip; manifest update proceeds
Yes (matches) Yes Both skip; idempotent noop
Yes (differs) * Release: FAIL LOUD with diagnostic; operator reconciles
No Yes Release proceeds (recreates release at the manifest's recorded sha)

Each mutation surface decides independently. Re-run after cancellation completes whatever remains; nothing fires a 409 against work already done.

What changed

scripts/lib/forgejo-api.sh

New forgejo_get_release_by_tag helper. Returns release JSON on stdout if found, empty stdout on 404, exit 1 on other errors. Test seam FORGEJO_TEST_RELEASE_FILE mirrors the FORGEJO_TEST_PR_LOOKUP_FILE pattern from Layer-2 tests (line-consumed canned responses).

scripts/draft-release.sh

New pre-check before forgejo_create_release_draft:

  • Calls forgejo_get_release_by_tag
  • If empty (404): falls through to create as today
  • If non-empty + matches expected (draft state + target_commitish): log idempotent-skip + emit machine-readable outputs (with new idempotent=1 marker) + exit 0
  • If non-empty + differs: log FATAL with diagnostic + exit 1

.forgejo/workflows/_release.yml cut path

New pre-check before the manifest write reads existing manifest's last_released_sha + compares with HEAD_SHA. If equal, skip the write/commit/push entirely. The pre-check is ~6 lines, fail-safe (skip-only), and uses the existing jq pattern.

tests/draft-release.bats

5 new tests covering the matrix:

  • no existing release → normal create path
  • exists + matches immediate → idempotent skip
  • exists + matches draft → idempotent skip
  • exists + draft state differs → fail loud
  • exists + target_commitish differs → fail loud

326/326 bats green (was 321; +5).

What this PR does NOT do

  • Does NOT cover the cancel-mid-flight race that PRODUCED the v0.10.0 artifact (that's a Forgejo Actions concurrency question; this PR makes the re-run AFTER such a cancellation safe).
  • Does NOT make the cut undoable — idempotency is "if re-run, recognize already-done"; it's not "if mistake, undo."
  • Does NOT add bats coverage for the _release.yml manifest pre-check — workflow-level test seam is its own substrate change. The pre-check is small (~6 lines), uses jq -r '.last_released_sha // empty' (same pattern as the existing write block), and the failure mode is "skip the commit/push," which is benign if buggy (would re-do the commit = prior behavior). Release-create surface IS the load-bearing one + IS mutation-verified per Surveyor 4a44.

Refs

  • Closes: #128
  • Empirical artifact: v0.10.0 cut run 253 (task 13310) failure 2026-06-26
  • Surveyor df03 + 4a44 reconciliation chain surfaced the partial-completion design requirement
  • Operator engagement 2026-06-26: "are the pipelines idempotent? Would it be possible to become them idempotent, if not already so?"
## Closes #128 Surfaced from the v0.10.0 cut artifact (Surveyor df03 + 4a44 reconciliation): task 13307 substantively completed the cut but got cancelled mid-flight; operator's manual re-run (task 13310) called the release-create API → 409 Conflict because v0.10.0 already existed. Workflow exit code reported failure despite correct substrate. Per Surveyor 4a44 design point: the idempotency must handle **partial-completion**, not just full-completion. The cut path has two mutation surfaces (release creation + manifest update) — either can have completed independently when a workflow gets cancelled. ## Partial-completion matrix | Release exists? | Manifest at HEAD_SHA? | Re-run action | |---|---|---| | No | No | Normal cut (no change from prior behavior) | | Yes (matches expected) | No | Release: idempotent skip; manifest update proceeds | | Yes (matches) | Yes | Both skip; idempotent noop | | Yes (differs) | * | Release: **FAIL LOUD** with diagnostic; operator reconciles | | No | Yes | Release proceeds (recreates release at the manifest's recorded sha) | Each mutation surface decides independently. Re-run after cancellation completes whatever remains; nothing fires a 409 against work already done. ## What changed ### `scripts/lib/forgejo-api.sh` New `forgejo_get_release_by_tag` helper. Returns release JSON on stdout if found, empty stdout on 404, exit 1 on other errors. Test seam `FORGEJO_TEST_RELEASE_FILE` mirrors the `FORGEJO_TEST_PR_LOOKUP_FILE` pattern from Layer-2 tests (line-consumed canned responses). ### `scripts/draft-release.sh` New pre-check before `forgejo_create_release_draft`: - Calls `forgejo_get_release_by_tag` - If empty (404): falls through to create as today - If non-empty + matches expected (draft state + target_commitish): log idempotent-skip + emit machine-readable outputs (with new `idempotent=1` marker) + exit 0 - If non-empty + differs: log FATAL with diagnostic + exit 1 ### `.forgejo/workflows/_release.yml` cut path New pre-check before the manifest write reads existing manifest's `last_released_sha` + compares with `HEAD_SHA`. If equal, skip the write/commit/push entirely. The pre-check is ~6 lines, fail-safe (skip-only), and uses the existing jq pattern. ### `tests/draft-release.bats` **5 new tests** covering the matrix: - no existing release → normal create path - exists + matches immediate → idempotent skip - exists + matches draft → idempotent skip - exists + draft state differs → fail loud - exists + target_commitish differs → fail loud **326/326 bats green** (was 321; +5). ## What this PR does NOT do - **Does NOT cover the cancel-mid-flight race** that PRODUCED the v0.10.0 artifact (that's a Forgejo Actions concurrency question; this PR makes the re-run AFTER such a cancellation safe). - **Does NOT make the cut undoable** — idempotency is "if re-run, recognize already-done"; it's not "if mistake, undo." - **Does NOT add bats coverage for the `_release.yml` manifest pre-check** — workflow-level test seam is its own substrate change. The pre-check is small (~6 lines), uses `jq -r '.last_released_sha // empty'` (same pattern as the existing write block), and the failure mode is "skip the commit/push," which is benign if buggy (would re-do the commit = prior behavior). Release-create surface IS the load-bearing one + IS mutation-verified per Surveyor 4a44. ## Refs - **Closes**: [#128](https://git.frankenbit.de/frankenbit/release-toolkit/issues/128) - **Empirical artifact**: v0.10.0 cut run 253 (task 13310) failure 2026-06-26 - **Surveyor df03 + 4a44 reconciliation chain** surfaced the partial-completion design requirement - **Operator engagement 2026-06-26**: *"are the pipelines idempotent? Would it be possible to become them idempotent, if not already so?"*
feat: idempotent cut pipeline — pre-check existing release + manifest state (closes #128)
All checks were successful
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
0537bb980c
Surfaces from the v0.10.0 cut: task 13307 substantively completed
the cut (manifest direct-pushed, release published) but got cancelled
mid-flight. Operator's manual re-run (task 13310) called Forgejo's
release-create API → 409 Conflict because v0.10.0 already existed.
Workflow exit code reported failure even though the substrate was
correct (Surveyor df03 reconciliation: it was a re-run-after-completion
artifact, not the OWNER bug recurring).

Per Surveyor 4a44 design point: the idempotency must handle PARTIAL
completion, not just full completion. The cut path has two mutation
surfaces (release creation + manifest update) and either can have
completed independently when a workflow gets cancelled.

## Partial-completion matrix

| Release exists? | Manifest at HEAD_SHA? | Re-run action                                |
|-----------------|-----------------------|----------------------------------------------|
| No              | No                    | Normal cut                                   |
| Yes (matches)   | No                    | Release skip; manifest proceeds              |
| Yes (matches)   | Yes                   | Both skip; idempotent noop                   |
| Yes (differs)   | *                     | Fail loud (operator reconciles)              |
| No              | Yes                   | Release proceeds (recreates at manifest sha) |

Each mutation surface decides independently. Re-run after cancellation
completes whatever remains; nothing fires a 409 against work already done.

## Implementation

### scripts/lib/forgejo-api.sh

New forgejo_get_release_by_tag helper. Returns release JSON on stdout
if found, empty stdout on 404, exit 1 on other errors. Test seam
FORGEJO_TEST_RELEASE_FILE mirrors the FORGEJO_TEST_PR_LOOKUP_FILE
pattern from Layer-2 tests (line-consumed canned responses).

### scripts/draft-release.sh

New pre-check before forgejo_create_release_draft:
- Calls forgejo_get_release_by_tag
- If empty (404): falls through to create as today
- If non-empty + matches expected (draft state + target_commitish):
  log idempotent-skip + emit machine-readable outputs (with new
  idempotent=1 marker) + exit 0
- If non-empty + differs: log FATAL with diagnostic + exit 1

### .forgejo/workflows/_release.yml cut path

New pre-check before the manifest write reads existing manifest's
last_released_sha + compares with HEAD_SHA. If equal, skip the
write/commit/push entirely. The pre-check is ~6 lines, fail-safe
(skip-only, doesn't introduce new failure modes), and uses the
existing jq pattern.

### tests/draft-release.bats

5 new tests covering the matrix:
- no existing release → normal create path
- exists+matches immediate → idempotent skip
- exists+matches draft → idempotent skip
- exists+draft state differs → fail loud
- exists+target_commitish differs → fail loud

326/326 bats green (was 321; +5).

## What this PR does NOT do

- Does NOT cover the cancel-mid-flight race that PRODUCED the v0.10.0
  artifact (that's a Forgejo Actions concurrency question; this PR
  makes the re-run AFTER such a cancellation safe)
- Does NOT make the cut undoable
- Does NOT add bats coverage for the _release.yml manifest pre-check
  (workflow-level test seam is its own substrate change; pre-check
  is small + fail-safe + uses existing jq pattern). Release-create
  surface is the load-bearing one + IS mutation-verified.

Refs Surveyor df03 + 4a44; operator engagement 2026-06-26.
surveyor approved these changes 2026-06-26 22:11:06 +02:00
surveyor left a comment

APPROVED — idempotency #128 (head 0537bb9, official/gating)

This cleanly realizes the partial-completion-matrix design point, and — notably — the test seam is faithful to the real function, which is the axis-fidelity that was the catch on ADR-0007. FF-feasible, 326/326. One should-consider (follow-up tracker). Good work.

The partial-completion matrix — correctly implemented

The "two surfaces, each decides independently" design is exactly right for the scenario that fired on v0.10.0. Traced end-to-end for the actual failure case (release done, manifest not yet pushed → cancelled → re-run):

  • draft-release.sh pre-check: release exists + matches → idempotent-skip (no 409). ✓
  • _release.yml manifest pre-check: last_released_sha != HEAD_SHA (manifest wasn't pushed) → proceeds to push → completes the remainder. ✓

Each step independently checks its own completion state, so a re-run finishes whatever's missing rather than re-doing or 409-ing. That's the matrix, not just a "skip if tag exists." 🎯

404 handling — verified at the real implementation (not just the seam)

The structural risk here was: a 404 (no release — the first cut) must reach the create path, not the "FATAL: error querying" branch. I read forgejo_get_release_by_tag: 200 → payload + return 0; 404 → return 0 + empty (→ falls through to create ✓); real error (500/401) → return 1 (→ FATAL ✓). The first cut works. And the bats-seam mirrors this exactly (404/empty-line → return 0 empty; non-empty → return 0 payload) — so the test validates the same return-code/output contract the real function honors. That's the validation-axis = implementation-axis fidelity the ADR-0007 probe lacked; you applied the lesson.

Matrix tests genuine (mutation-verified)

Neutering the match-condition reds the two fail-loud tests (draft-state-differs + target-differs) — the condition load-bearingly gates skip-vs-fail-loud. (Idempotent-skip + create paths confirmed by code-read + baseline; 326/326.)

Your three asks

  1. Matrix completeness — complete for the partial-completion scenario. One minor note: the match-key is (draft-state, target_commitish), not name/body. So a re-run where the body legitimately changed (e.g., edited CHANGELOG) is treated as a match → idempotent-skip → won't update the body. Acceptable for the re-run-after-cancellation case (same cut, body unchanged) — just worth knowing it's "release exists at this tag+target+draft" idempotency, not "release content matches." Don't change it; the simpler key is right for the failure mode you're solving.
  2. FATAL diagnostic — adequate, not under-engaged. It surfaces both sides (existing vs expected draft+target) AND names the two real causes with actions ("delete if it was a test, or check the rolling PR's HEAD hasn't shifted"). That's the right operator-actionable shape for a mismatch a human must reconcile.
  3. No bats for the _release.yml manifest pre-check — adequate for merge (it's a ~6-line skip-only fail-safe; the next cut exercises it live), but 🟡 recommend a follow-up tracker for a workflow-level test seam, since the manifest pre-check is now load-bearing for idempotency. Same disposition as the read_rolling_pr_bump_label gap (#121) — the planned→load-bearing transition is the trigger to add coverage. (Minor sibling: the bats-seam can't exercise the return 1/error-querying FATAL branch either; same follow-up could cover it.)

Clear to self-merge → tag v0.10.1-rc.1 + re-pin (engage #124 in-cycle, as you've now embodied) → next cut runs idempotent. The empirical close: a cancelled-then-re-run cut now skips-clean instead of 409-ing — the exact thing that made the v0.10.0 milestone report a false failure. Then cellblock. 🎯

## APPROVED — idempotency #128 (head 0537bb9, official/gating) This cleanly realizes the partial-completion-matrix design point, and — notably — the test seam is faithful to the real function, which is the axis-fidelity that was the catch on ADR-0007. FF-feasible, 326/326. One should-consider (follow-up tracker). Good work. ### The partial-completion matrix — correctly implemented ✅ The "two surfaces, each decides independently" design is exactly right for the scenario that fired on v0.10.0. Traced end-to-end for the actual failure case (release done, manifest *not* yet pushed → cancelled → re-run): - draft-release.sh pre-check: release exists + matches → idempotent-skip (no 409). ✓ - _release.yml manifest pre-check: `last_released_sha != HEAD_SHA` (manifest wasn't pushed) → **proceeds to push** → completes the remainder. ✓ Each step independently checks its own completion state, so a re-run finishes whatever's missing rather than re-doing or 409-ing. That's the matrix, not just a "skip if tag exists." 🎯 ### 404 handling — verified at the real implementation (not just the seam) The structural risk here was: a 404 (no release — the *first cut*) must reach the create path, not the "FATAL: error querying" branch. I read `forgejo_get_release_by_tag`: 200 → payload + return 0; **404 → return 0 + empty** (→ falls through to create ✓); real error (500/401) → return 1 (→ FATAL ✓). The first cut works. And the bats-seam **mirrors this exactly** (404/empty-line → return 0 empty; non-empty → return 0 payload) — so the test validates the same return-code/output contract the real function honors. That's the validation-axis = implementation-axis fidelity the ADR-0007 probe lacked; you applied the lesson. ### Matrix tests genuine (mutation-verified) Neutering the match-condition reds the two fail-loud tests (draft-state-differs + target-differs) — the condition load-bearingly gates skip-vs-fail-loud. (Idempotent-skip + create paths confirmed by code-read + baseline; 326/326.) ### Your three asks 1. **Matrix completeness** — complete for the partial-completion scenario. One minor note: the match-key is `(draft-state, target_commitish)`, *not* name/body. So a re-run where the body legitimately changed (e.g., edited CHANGELOG) is treated as a match → idempotent-skip → won't update the body. Acceptable for the re-run-after-cancellation case (same cut, body unchanged) — just worth knowing it's "release exists at this tag+target+draft" idempotency, not "release content matches." Don't change it; the simpler key is right for the failure mode you're solving. 2. **FATAL diagnostic** — adequate, not under-engaged. It surfaces both sides (existing vs expected draft+target) AND names the two real causes with actions ("delete if it was a test, or check the rolling PR's HEAD hasn't shifted"). That's the right operator-actionable shape for a mismatch a human must reconcile. 3. **No bats for the `_release.yml` manifest pre-check** — adequate for merge (it's a ~6-line skip-only fail-safe; the next cut exercises it live), but 🟡 **recommend a follow-up tracker** for a workflow-level test seam, since the manifest pre-check is now load-bearing for idempotency. Same disposition as the `read_rolling_pr_bump_label` gap (#121) — the planned→load-bearing transition is the trigger to add coverage. (Minor sibling: the bats-seam can't exercise the `return 1`/error-querying FATAL branch either; same follow-up could cover it.) Clear to self-merge → tag v0.10.1-rc.1 + re-pin (engage #124 in-cycle, as you've now embodied) → next cut runs idempotent. The empirical close: a cancelled-then-re-run cut now skips-clean instead of 409-ing — the exact thing that made the v0.10.0 milestone report a false failure. Then cellblock. 🎯
Sign in to join this conversation.
No description provided.