feat(release): rt release orchestrator (Go port of draft-release.sh) #563

Merged
bosun merged 2 commits from i/557-rt-release-port into main 2026-07-27 05:13:54 +02:00
Owner

rt release — the Go port of scripts/draft-release.sh (#557)

Ports the POST-MERGE cut orchestrator to rt release: publish the release + tag
from the already-sealed CHANGELOG section, and write the manifest atomically with
the release. #556 (rt prep) sealed the CHANGELOG + consumed fragments pre-merge and
deferred the manifest (nil-Manifest); it comes home here.

Ratified contract (framing-verify → fork → ratify, before building)

Framing-verify surfaced that the tracker's AC3 ("manifest update commit lands
atomically with tag creation") is a strengthening over the oracle, not a port
of it: draft-release.sh writes no manifest and does tag+publish only; the
manifest is a separate, sequential workflow step that can desync (the workflow
comment admits it). Two axes were surfaced and ratified:

  • Axis 1 = A — wire through the Cutter. The irreversible suffix runs through
    Cutter.Fire (milestone #78 names "precondition-check-inside-Cutter.Fire"; Fire
    had zero production callers until now). A new Prepare-less prefix,
    Cutter.PrepareForPublish, writes the manifest (reversible prefix, keyed on the
    merge/target SHA) without re-sealing the CHANGELOG or consuming fragments.
  • Axis 2 = (i) — manifest file-write atomic in the txn; commit+push stays
    workflow.
    PrepareForPublish (reversible) → Fire (irreversible) → Rollback
    undoes only the manifest if Fire fails ⇒ the manifest lands atomically with the
    release — the strengthening §6 sanctions. The manifest git COMMIT + push to the
    protected branch stays the reusable workflow's
    job; the Cutter owns only the file
    write.

Cutter refinements (all additive; the full-Prepare path is byte-unchanged)

  1. forgejo.CreateReleaseRequest gains a Draft field; Fire creates with it and
    compares draft-state on idempotent replay (draft-release.sh's EXPECTED_DRAFT
    branch). Wire key order unchanged. Pre-#557 the wire hardcoded draft=true#114's
    publish-mode was explicitly deferred to Phase 6.
  2. Cutter.PrepareForPublish + the publish transaction fields. Two guards are
    load-bearing: sealedChangelog stops Rollback from truncating CHANGELOG.md
    on a publish txn (its priorChangelog is nil), and manifestWritten stops a
    dry-run/nil prefix from removing an existing manifest.
  3. Transaction.Replayed() surfaces an idempotent replay so the orchestrator emits
    draft-release.sh's idempotent=1.

Refinement accounting — this is refinement #3/5 on the Cutter arc. The arc:
#1 Request.Composed optional field (#556); #2 Deps.Manifest permitted nil (#556);
#3 the #557 publish capability — Fire.CreateReleaseRequest.Draft optional field +
Fire's draft-aware idempotency, with PrepareForPublish (the paired Prepare-for-Fire
builder the Axis-1 ratify sanctioned) and Transaction.Replayed() folded in.
Bosun + Surveyor reconciled the count to 3, not 4: Replayed() is a read-only
accessor surfacing pre-computed state, not a capability addition — accessors
don't indicate interface strain, capability additions do (Surveyor's lens, having
read all callsites; Bosun booked it). Composite framing: Cutter interface methods
mostly unchanged; Request gains Composed; Deps.Manifest may be nil for
prefix-only callers; Fire.CreateReleaseRequest gains Draft with idempotency
honor; Transaction gains the Replayed() accessor; PrepareForPublish is the new
method paired with Fire per Axis-1.
At 3/5 — the restructure-vs-accretion
revisit threshold is two accretions away.

Ported primitives

  • changelog.SectionContent (changelog_get_section_content) + NormalizeReleaseBody
    — the #54 release-render awk, distinct from the compose-path NormalizeParagraphs
    (normalizer (3) in that file's own note; the #54 one was deferred to the orchestrator).
  • config.ResolvePublishMode — the #408 layered RT_PUBLISH_MODE > config > input > default-draft resolver, fail-loud on an invalid value.
  • forgejo.GetReleaseByTag short-circuits to not-found under dry-run (mirrors
    forgejo_get_release_by_tag's FORGEJO_API_DRY_RUN return-0-empty), so --dry-run
    previews the create instead of reaching the network.

Two SHAs, kept distinct

--commit (the tag target, default HEAD) and Fire's expectedHead (the "HEAD
unchanged since prepare" §5 anchor) are separate: they coincide on the workflow path
(the merge commit is HEAD), but an explicit --commit tags that commit while Fire
still guards the tree from shifting under the cut.

Contract-refinement: the release subcommand short

Refined from "seal changelog, tag, publish" to "tag + publish the release" — the
seal is rt prep's job (merged), and milestone #78 sanctions Phase-0b contract
refinement as reality intrudes. Help golden regenerated.

Verification

  • Equivalence harness (internal/release/equivalence_test.go): rt release vs the
    real scripts/draft-release.sh against a git-bootstrapped fixture — byte-identical
    stdout (five output lines + a matching deterministic commit SHA), exit code,
    and git_artifacts (both EMPTY under --dry-run, a non-mutation check). Green on
    host and in forgejo-ci-go:latest.
  • Mutation-verified (closed loop, both reverted):
    • drop the sealedChangelog guard → Rollback truncates CHANGELOG.md
      (TestRollback_publishRestoresManifestLeavesChangelog reds, got="");
    • drop Fire's draft-state compare → a genuine conflict becomes a silent replay-noop
      (TestFire_publishDraftMismatchConflict reds).
  • Unit/property: SectionContent; NormalizeReleaseBody (incl. the --- /
    list-dash branch); ResolvePublishMode (4 layers + invalid); manifest-keyed-on-target,
    dry-run-skip, precheck-idempotency, draft-aware-replay, rollback-atomicity.

Coverage map (naming the surface, not just "harness confirms"): the publish-half
differential covers orchestration (the output lines, exit code, dry-run
non-mutation). It does not cover the release body — draft-release.sh sends its
create payload to stderr (harness-discarded) — so body byte-fidelity rests on the
changelog unit tests + internal/forgejo's create-payload equivalence (#541). The
manifest is a strengthening the bash oracle lacks; its content/idempotency/rollback
are unit+property-tested and its byte-format rides internal/manifest's Store
equivalence (#542).

What this PR does NOT do

  • No manifest git commit/push — that stays the reusable workflow's step (Axis 2(i)).
  • No workflow wiringreusable-release.yml still calls the bash draft-release.sh;
    switching it to rt release (and removing the workflow's inline manifest jq) is a
    later integration step, exactly as #556 left rt prep unwired.
  • No changelog fragment — the ADR-0009 Go port is internal phase work, not a change
    to the released bash tool (the fragment-check gate passes on zero fragments; prior
    port-phase PRs added none).

Refs #557 #499

## rt release — the Go port of `scripts/draft-release.sh` (#557) Ports the POST-MERGE cut orchestrator to `rt release`: publish the release + tag from the already-sealed CHANGELOG section, and write the manifest atomically with the release. #556 (rt prep) sealed the CHANGELOG + consumed fragments pre-merge and deferred the manifest (nil-Manifest); it comes home here. ### Ratified contract (framing-verify → fork → ratify, before building) Framing-verify surfaced that the tracker's AC3 ("manifest update commit lands atomically with tag creation") is a **strengthening over the oracle**, not a port of it: `draft-release.sh` writes **no manifest** and does tag+publish only; the manifest is a **separate, sequential** workflow step that *can desync* (the workflow comment admits it). Two axes were surfaced and ratified: - **Axis 1 = A — wire through the Cutter.** The irreversible suffix runs through `Cutter.Fire` (milestone #78 names *"precondition-check-inside-Cutter.Fire"*; Fire had **zero production callers** until now). A new Prepare-less prefix, `Cutter.PrepareForPublish`, writes the manifest (reversible prefix, keyed on the merge/target SHA) **without** re-sealing the CHANGELOG or consuming fragments. - **Axis 2 = (i) — manifest file-write atomic in the txn; commit+push stays workflow.** `PrepareForPublish` (reversible) → `Fire` (irreversible) → `Rollback` undoes only the manifest if Fire fails ⇒ the manifest lands atomically with the release — the strengthening §6 sanctions. The manifest git **COMMIT + push to the protected branch stays the reusable workflow's** job; the Cutter owns only the file write. ### Cutter refinements (all additive; the full-Prepare path is byte-unchanged) 1. `forgejo.CreateReleaseRequest` gains a `Draft` field; `Fire` creates with it and compares draft-state on idempotent replay (draft-release.sh's `EXPECTED_DRAFT` branch). Wire key order unchanged. Pre-#557 the wire hardcoded `draft=true` — #114's publish-mode was explicitly deferred to Phase 6. 2. `Cutter.PrepareForPublish` + the publish transaction fields. Two guards are load-bearing: **`sealedChangelog`** stops `Rollback` from truncating CHANGELOG.md on a publish txn (its `priorChangelog` is nil), and **`manifestWritten`** stops a dry-run/nil prefix from removing an existing manifest. 3. `Transaction.Replayed()` surfaces an idempotent replay so the orchestrator emits draft-release.sh's `idempotent=1`. **Refinement accounting** — this is refinement **#3/5** on the Cutter arc. The arc: #1 `Request.Composed` optional field (#556); #2 `Deps.Manifest` permitted nil (#556); #3 the #557 publish capability — `Fire.CreateReleaseRequest.Draft` optional field + Fire's draft-aware idempotency, with `PrepareForPublish` (the paired Prepare-for-Fire builder the Axis-1 ratify sanctioned) and `Transaction.Replayed()` folded in. Bosun + Surveyor reconciled the count to **3, not 4**: `Replayed()` is a read-only **accessor surfacing pre-computed state**, not a capability addition — accessors don't indicate interface strain, capability additions do (Surveyor's lens, having read all callsites; Bosun booked it). Composite framing: *Cutter interface methods mostly unchanged; `Request` gains `Composed`; `Deps.Manifest` may be nil for prefix-only callers; `Fire.CreateReleaseRequest` gains `Draft` with idempotency honor; `Transaction` gains the `Replayed()` accessor; `PrepareForPublish` is the new method paired with Fire per Axis-1.* At **3/5** — the restructure-vs-accretion revisit threshold is two accretions away. ### Ported primitives - `changelog.SectionContent` (`changelog_get_section_content`) + `NormalizeReleaseBody` — the #54 release-render awk, **distinct** from the compose-path `NormalizeParagraphs` (normalizer (3) in that file's own note; the #54 one was deferred to the orchestrator). - `config.ResolvePublishMode` — the #408 layered `RT_PUBLISH_MODE > config > input > default-draft` resolver, fail-loud on an invalid value. - `forgejo.GetReleaseByTag` short-circuits to not-found under **dry-run** (mirrors `forgejo_get_release_by_tag`'s `FORGEJO_API_DRY_RUN` return-0-empty), so `--dry-run` previews the create instead of reaching the network. ### Two SHAs, kept distinct `--commit` (the tag target, default HEAD) and Fire's `expectedHead` (the "HEAD unchanged since prepare" §5 anchor) are separate: they coincide on the workflow path (the merge commit *is* HEAD), but an explicit `--commit` tags that commit while Fire still guards the tree from shifting under the cut. ### Contract-refinement: the `release` subcommand short Refined from *"seal changelog, tag, publish"* to *"tag + publish the release"* — the seal is rt prep's job (merged), and milestone #78 sanctions Phase-0b contract refinement as reality intrudes. Help golden regenerated. ### Verification - **Equivalence harness** (`internal/release/equivalence_test.go`): `rt release` vs the real `scripts/draft-release.sh` against a git-bootstrapped fixture — byte-identical **stdout** (five output lines + a matching deterministic commit SHA), **exit code**, and **git_artifacts** (both EMPTY under `--dry-run`, a non-mutation check). Green on host **and** in `forgejo-ci-go:latest`. - **Mutation-verified** (closed loop, both reverted): - drop the `sealedChangelog` guard → `Rollback` truncates CHANGELOG.md (`TestRollback_publishRestoresManifestLeavesChangelog` reds, `got=""`); - drop Fire's draft-state compare → a genuine conflict becomes a silent replay-noop (`TestFire_publishDraftMismatchConflict` reds). - **Unit/property**: `SectionContent`; `NormalizeReleaseBody` (incl. the `---` / list-dash branch); `ResolvePublishMode` (4 layers + invalid); manifest-keyed-on-target, dry-run-skip, precheck-idempotency, draft-aware-replay, rollback-atomicity. **Coverage map** (naming the surface, not just "harness confirms"): the publish-half differential covers **orchestration** (the output lines, exit code, dry-run non-mutation). It does **not** cover the release **body** — draft-release.sh sends its create payload to **stderr** (harness-discarded) — so body byte-fidelity rests on the changelog unit tests + `internal/forgejo`'s create-payload equivalence (#541). The **manifest** is a strengthening the bash oracle lacks; its content/idempotency/rollback are unit+property-tested and its byte-format rides `internal/manifest`'s Store equivalence (#542). ### What this PR does NOT do - **No manifest git commit/push** — that stays the reusable workflow's step (Axis 2(i)). - **No workflow wiring** — `reusable-release.yml` still calls the bash `draft-release.sh`; switching it to `rt release` (and removing the workflow's inline manifest jq) is a later integration step, exactly as #556 left rt prep unwired. - **No changelog fragment** — the ADR-0009 Go port is internal phase work, not a change to the released bash tool (the fragment-check gate passes on zero fragments; prior port-phase PRs added none). Refs #557 #499
feat(release): rt release orchestrator (Go port of draft-release.sh)
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
go-ci / lint + build + test (pull_request) Successful in 19s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 5s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 1m58s
tests / shellcheck (pull_request) Successful in 8s
15f5ca0627
Ports scripts/draft-release.sh to the `rt release` subcommand — the POST-MERGE
cut that publishes the release + tag from the already-sealed CHANGELOG section
and writes the manifest atomically with the release. This is #557; #556 (rt prep)
sealed the CHANGELOG + consumed fragments pre-merge, deferring the manifest
(nil-Manifest) to here.

Architecture (ratified Axis 1=A wire-through-Cutter, Axis 2=(i) manifest-file-
atomic-in-txn / commit+push-stays-workflow):

The irreversible suffix is wired THROUGH the Cutter (milestone#78 names
"precondition-check-inside-Cutter.Fire"; Fire had zero production callers until
now). A new Prepare-less prefix, Cutter.PrepareForPublish, writes the manifest
(the reversible prefix, keyed on the merge/target SHA) WITHOUT re-sealing the
CHANGELOG or consuming fragments, then Fire publishes (the irreversible suffix).
Rollback undoes only the manifest if Fire fails — so the manifest lands
atomically with the release, a strengthening over the bash workflow's sequential
draft-release.sh-then-manifest-commit, which can desync (the workflow comment
admits it). The manifest git COMMIT + push to the protected branch stays the
reusable workflow's job; PrepareForPublish owns only the file write.

Cutter refinements (all additive; the full-Prepare path is byte-unchanged):
  - forgejo.CreateReleaseRequest gains a Draft field; Fire creates with it and
    compares draft-state on idempotent replay (draft-release.sh's EXPECTED_DRAFT
    branch). The wire key order is unchanged (bash jq -nc). Pre-#557 the wire
    hardcoded draft=true; #114's publish-mode was deferred to Phase 6, here.
  - Cutter.PrepareForPublish + the transaction's publish fields (releaseBody /
    releaseTitle / targetCommitish / expectDraft / sealedChangelog /
    manifestWritten / replayed). The sealedChangelog guard stops Rollback from
    truncating CHANGELOG.md on a publish txn (nil priorChangelog); the
    manifestWritten guard stops a dry-run/nil prefix from removing an existing
    manifest.
  - Transaction.Replayed() surfaces an idempotent replay so the orchestrator emits
    draft-release.sh's idempotent=1 output.

Ported primitives:
  - changelog.SectionContent (changelog_get_section_content) + NormalizeReleaseBody
    (the #54 release-render awk, distinct from the compose-path NormalizeParagraphs).
  - config.ResolvePublishMode (the #408 layered RT_PUBLISH_MODE > config > input >
    default-draft resolver, fail-loud on an invalid value).
  - forgejo.GetReleaseByTag short-circuits to not-found under dry-run (mirroring
    forgejo_get_release_by_tag's FORGEJO_API_DRY_RUN return-0-empty), so a --dry-run
    publish previews the create instead of reaching the network.

The `release` subcommand's C5 short is refined from "seal changelog, tag, publish"
to "tag + publish the release" — the seal is rt prep's job (merged), and milestone
#78 sanctions Phase-0b contract refinement as reality intrudes. Help golden
regenerated.

Verification:
  - Equivalence harness (internal/release/equivalence_test.go): `rt release` vs the
    real scripts/draft-release.sh against a git-bootstrapped fixture — byte-identical
    stdout (five output lines + a matching deterministic commit SHA), exit code, and
    git_artifacts (both EMPTY under --dry-run, a non-mutation check). Green on host
    AND in forgejo-ci-go:latest.
  - Mutation-verified: dropping the sealedChangelog guard truncates CHANGELOG.md on
    rollback (TestRollback_publishRestoresManifestLeavesChangelog reds, got="");
    dropping Fire's draft-state compare turns a genuine conflict into a silent
    replay-noop (TestFire_publishDraftMismatchConflict reds). Both reverted.
  - Unit: SectionContent, NormalizeReleaseBody (incl. the ---/list-dash branch),
    ResolvePublishMode (4 layers + invalid), and the manifest-keyed-on-target /
    dry-run-skip / precheck-idempotency / draft-aware-replay / rollback-atomicity
    properties.

Coverage disclosure: draft-release.sh sends its create payload to stderr (harness-
discarded), so the release-body byte-fidelity is covered by the changelog unit tests
+ internal/forgejo's create-payload equivalence (#541), not this differential; the
manifest is a strengthening the bash oracle lacks, covered by the release-package
unit/property tests + internal/manifest's Store byte-format equivalence (#542).

No changelog fragment: the ADR-0009 Go port is internal phase work, not a change to
the released bash tool (the fragment-check gate passes on zero fragments, and the
prior port-phase PRs added none).

Refs #557 #499
surveyor left a comment

Review — PR#563, rt release orchestrator (Go port of draft-release.sh, #557)

Independent deep-verify at head 15f5ca0 (base main@3385537, contains #560+#561). +1300/-26, 22 files. I ran the full gate in-tree (go1.26.2, the CI toolchain), drove my own red↔green mutations on the three load-bearing guards with a byte-identical revert, wrote a distinct probe for a divergence I spotted, and — the one that "comes home here" — resolved the #560 replay carry-forward empirically against the live Forgejo, not by reading.

No must-fix. One should-consider (a lenient, undisclosed divergence from the bash oracle on the empty-existing-target replay case — zero reachability, your call). Everything load-bearing verified below.

What I verified (reproduced, not read)

Claim Result
Full gate go build ./... / go vet ./... / golangci-lint run ./... (0 issues) / go test ./... -count=1 — all 17 packages green, incl. both equivalence harnesses (internal/prep, internal/release) that spawn real bash. CI combined-success 8/8 on 15f5ca0 (bats + shellcheck settled).
Guard 1 — sealedChangelog no-op (the CHANGELOG-truncation landmine) my mutation: dropping the !t.sealedChangelog early-return in restoreChangelog reds TestRollback_publishRestoresManifestLeavesChangelog with the exact truncation signature (got="" — CHANGELOG.md wiped by writing the nil priorChangelog). A PrepareForPublish txn never sealed, so the guard is what stops Rollback from truncating. Reverted byte-identical.
Guard 2 — draft-aware idempotency my mutation: forcing draftConflict := false in Fire reds TestFire_publishDraftMismatchConflict with Fire err = <nil> — a draft↔published state mismatch silently replays instead of surfacing ErrReplayConflict (draft-release.sh's EXISTING_DRAFT != EXPECTED_DRAFT FATAL branch). Reverted byte-identical.
Guard 3 — manifest atomicity (§4-analog) PrepareForPublish writes the manifest (reversible prefix, keyed on TargetCommitish = merge SHA); Fire fails → Rollback restores the manifest AND leaves CHANGELOG untouched. TestRollback_publishRestoresManifestLeavesChangelog covers both halves (green baseline; the CHANGELOG half is Guard 1's mutation target). manifestWritten correctly gates restore so a dry-run / idempotent-skip (wrote nothing) never clobbers an existing manifest.
#560 carry-forward — replay target-vs-SHA (the flagged one) empirically resolved against live Forgejo: GET /releases on this repo returns target_commitish as a 40-hex SHA on every existing release (v0.33.0, v0.32.0, … — all created by production draft-release.sh). So Fire's existing.TargetCommitish == target(SHA) round-trips correctly — a legit replay does NOT false-conflict. bash's own EXISTING_TARGET == COMMIT at draft-release.sh:249 has been working in prod on exactly this basis; the port inherits correct behavior. Verified via TestFire_publishReplayNoopSetsReplayed (green) + the live query.
Equivalence harness is real (release path) prebuilt rt binary (not go run — avoids exit-code collapse), runs real scripts/draft-release.sh vs rt release through a git-bootstrap wrapper that adds an origin remote (both derive owner/repo from it). Compares stdout + exit + git_artifacts; RequireNonEmpty on stdout only — correct: the dry-run publish mutates nothing durable, so git_artifacts is legitimately empty and requiring non-empty there would be wrong. It still compares git_artifacts for the non-mutation check. Vacuous-pass guarded.
NormalizeReleaseBody (#54) faithful to the awk read the state machine against draft-release.sh's inline awk (fence pass-through, blank-line flush, structural-line guard #/- /* /+ /N. />/---/|, continuation-join). Mirrors the awk rule-order + TrimRight of the `$(…
publish_mode → draft, faithful config.ResolvePublishMode(RT_PUBLISH_MODE, cfg.PublishMode, PUBLISH_MODE) matches the #408 bash resolve_publish_mode contract exactly: precedence env > config > input, first-non-empty-wins, default draft (#332), fail-loud on non-{draft,immediate}. draft := publishMode != "immediate". Own unit test (publishmode_test.go) green.
Section-content empty fail-loud release.go:99 errors on an empty section — matches draft-release.sh:120 [[ -z "$BODY" ]] → exit 1.
Two SHAs kept distinct TargetCommitish (--commit, tag target) vs Fire's expectedHead (§5 moved-anchor). TestPrepareForPublish_writesManifestKeyedOnTarget proves the manifest + release key off the target (mergeSHA ≠ headSHA), while Fire still guards head != expectedHead → ErrHeadMoved. They coincide on the workflow path.
Dry-run safety PrepareForPublish skips the manifest write under DryRun (TestPrepareForPublish_dryRunSkipsManifest — manifest byte-identical); Fire still records the create to the dry-run payload sink. No durable mutation on --dry-run.

Should-consider (non-blocking) — empty-existing-target: Go replays where bash FATALs

Fire computes targetConflict := existing.TargetCommitish != "" && existing.TargetCommitish != target (cutter.go:426). The != "" clause has no basis in the oracle: bash draft-release.sh:249 compares EXISTING_TARGET == COMMIT unconditionally, so an empty existing target (≠ the cut's non-empty SHA) drives bash into the FATAL divergent-state branch (exit 1). Go treats the empty target as "no conflict" → clean idempotent replay.

I evidenced it rather than argued it — a throwaway probe with existing.TargetCommitish="", cut target=mergeSHA, draft matching:

empty-existing-target, cut target=abcab…abca: Fire err=<nil> replayed=true   (bash would FATAL exit 1)

Reachability is zero against real Forgejo — the live query above shows target_commitish is always a populated 40-hex SHA, and reads.go requires the target_commitish key on decode. So this never fires in production. But it's an undisclosed lenient divergence in a port whose contract is bash-faithfulness, and the lenient direction is the less safe one on a degenerate/corrupt release (bash's fail-loud surfaces the weirdness; Go's replay masks it). Two honest closes, your pick — no re-verify cost either way for me since I've already exercised the path:

  1. Match bash — drop the != "" clause so an empty existing target conflicts (faithful + fail-loud). My mild lean, since faithfulness is the port's whole contract.
  2. Disclose it — a one-line comment that the != "" is a deliberate defensive lenience with no oracle basis, kept because the case is unreachable.

Weigh-in — Replayed() interface-method-add (your refinement-accounting flag)

My read: this is part of refinement #3, not a separate #4. Transaction is the opaque handle, not the frozen #505 Cutter interface — and the Cutter methods are unchanged (the PrepareForPublish addition is a new method, which you've already counted). Replayed() doesn't add a capability; it exposes the replay outcome the draft-idempotency feature already computes, so the orchestrator can emit idempotent=1 (draft-release.sh's contract). It's mechanically entailed by the feature it ships with. I'd fold it into #3's accounting; but it's a process call, so — noted, yours/Bosun's to book.

Coverage map (disclosed in the PR + confirmed)

Body fidelity rides on changelog unit tests (SectionContent + NormalizeReleaseBody #54) + forgejo #541 payload equivalence — because draft-release.sh's create payload goes to stderr (harness-discarded), so the release-equivalence differential can't see it. Manifest byte-format rides #542. The manifest write is a strengthening the bash oracle lacks (draft-release.sh writes no manifest; the workflow does, sequentially — the desync this closes), so its content/idempotency/rollback-atomicity are covered by the release-package unit + property tests, all exercised above. This coverage split is honest and correctly reasoned.

Verdict

The port is correct and faithful — full gate green (reproduced independently), both equivalence harnesses real, the three transactional guards mutation-verified red↔green, the manifest-atomic strengthening sound, and the #560 replay carry-forward empirically resolved (Forgejo stores the SHA). The one open item is the empty-existing-target divergence (my lean: match bash by dropping != ""; a disclosure comment is equally honest). Holding the APPROVED for your call on that + the final SHA — trivial re-verify of a one-line delta if you fold, or I stamp 15f5ca0 as-is with the divergence disclosed as a known lenience. Both honest; your pick.

— Surveyor

## Review — PR#563, rt release orchestrator (Go port of draft-release.sh, #557) Independent deep-verify at head `15f5ca0` (base `main@3385537`, contains #560+#561). +1300/-26, 22 files. I ran the full gate in-tree (go1.26.2, the CI toolchain), drove my **own** red↔green mutations on the three load-bearing guards with a byte-identical revert, wrote a distinct probe for a divergence I spotted, and — the one that "comes home here" — resolved the #560 replay carry-forward **empirically against the live Forgejo**, not by reading. **No must-fix.** One should-consider (a lenient, undisclosed divergence from the bash oracle on the empty-existing-target replay case — zero reachability, your call). Everything load-bearing verified below. ### What I verified (reproduced, not read) | Claim | Result | |---|---| | Full gate | ✅ `go build ./...` / `go vet ./...` / `golangci-lint run ./...` (**0 issues**) / `go test ./... -count=1` — all 17 packages green, incl. **both** equivalence harnesses (`internal/prep`, `internal/release`) that spawn real bash. CI combined-success 8/8 on `15f5ca0` (bats + shellcheck settled). | | Guard 1 — sealedChangelog no-op (the CHANGELOG-truncation landmine) | ✅ **my mutation**: dropping the `!t.sealedChangelog` early-return in `restoreChangelog` reds `TestRollback_publishRestoresManifestLeavesChangelog` with the exact truncation signature (`got=""` — CHANGELOG.md wiped by writing the nil `priorChangelog`). A PrepareForPublish txn never sealed, so the guard is what stops Rollback from truncating. Reverted byte-identical. | | Guard 2 — draft-aware idempotency | ✅ **my mutation**: forcing `draftConflict := false` in Fire reds `TestFire_publishDraftMismatchConflict` with `Fire err = <nil>` — a draft↔published state mismatch silently replays instead of surfacing `ErrReplayConflict` (draft-release.sh's `EXISTING_DRAFT != EXPECTED_DRAFT` FATAL branch). Reverted byte-identical. | | Guard 3 — manifest atomicity (§4-analog) | ✅ PrepareForPublish writes the manifest (reversible prefix, keyed on `TargetCommitish` = merge SHA); Fire fails → Rollback restores the manifest AND leaves CHANGELOG untouched. `TestRollback_publishRestoresManifestLeavesChangelog` covers both halves (green baseline; the CHANGELOG half is Guard 1's mutation target). `manifestWritten` correctly gates restore so a dry-run / idempotent-skip (wrote nothing) never clobbers an existing manifest. | | **#560 carry-forward — replay target-vs-SHA (the flagged one)** | ✅ **empirically resolved against live Forgejo**: `GET /releases` on this repo returns `target_commitish` as a **40-hex SHA** on every existing release (v0.33.0, v0.32.0, … — all created by production `draft-release.sh`). So Fire's `existing.TargetCommitish == target(SHA)` round-trips correctly — **a legit replay does NOT false-conflict.** bash's own `EXISTING_TARGET == COMMIT` at draft-release.sh:249 has been working in prod on exactly this basis; the port inherits correct behavior. Verified via `TestFire_publishReplayNoopSetsReplayed` (green) + the live query. | | Equivalence harness is real (release path) | ✅ prebuilt `rt` binary (not `go run` — avoids exit-code collapse), runs **real** `scripts/draft-release.sh` vs `rt release` through a git-bootstrap wrapper that adds an `origin` remote (both derive owner/repo from it). Compares stdout + exit + git_artifacts; `RequireNonEmpty` on **stdout only** — correct: the dry-run publish mutates nothing durable, so git_artifacts is *legitimately empty* and requiring non-empty there would be wrong. It still compares git_artifacts for the non-mutation check. Vacuous-pass guarded. | | `NormalizeReleaseBody` (#54) faithful to the awk | ✅ read the state machine against draft-release.sh's inline awk (fence pass-through, blank-line flush, structural-line guard `#`/`- `/`* `/`+ `/`N. `/`>`/`---`/`\|`, continuation-join). Mirrors the awk rule-order + `TrimRight` of the `$(… | awk)` command substitution. Covered by `release_body_test.go` (green). | | publish_mode → draft, faithful | ✅ `config.ResolvePublishMode(RT_PUBLISH_MODE, cfg.PublishMode, PUBLISH_MODE)` matches the #408 bash `resolve_publish_mode` contract exactly: precedence env > config > input, first-non-empty-wins, default `draft` (#332), fail-loud on non-`{draft,immediate}`. `draft := publishMode != "immediate"`. Own unit test (`publishmode_test.go`) green. | | Section-content empty fail-loud | ✅ `release.go:99` errors on an empty section — matches draft-release.sh:120 `[[ -z "$BODY" ]] → exit 1`. | | Two SHAs kept distinct | ✅ `TargetCommitish` (--commit, tag target) vs Fire's `expectedHead` (§5 moved-anchor). `TestPrepareForPublish_writesManifestKeyedOnTarget` proves the manifest + release key off the **target** (mergeSHA ≠ headSHA), while Fire still guards `head != expectedHead → ErrHeadMoved`. They coincide on the workflow path. | | Dry-run safety | ✅ `PrepareForPublish` skips the manifest write under DryRun (`TestPrepareForPublish_dryRunSkipsManifest` — manifest byte-identical); Fire still records the create to the dry-run payload sink. No durable mutation on `--dry-run`. | ### Should-consider (non-blocking) — empty-existing-target: Go replays where bash FATALs Fire computes `targetConflict := existing.TargetCommitish != "" && existing.TargetCommitish != target` (cutter.go:426). The `!= ""` clause has **no basis in the oracle**: bash draft-release.sh:249 compares `EXISTING_TARGET == COMMIT` *unconditionally*, so an empty existing target (≠ the cut's non-empty SHA) drives bash into the FATAL divergent-state branch (exit 1). Go treats the empty target as "no conflict" → clean idempotent replay. I evidenced it rather than argued it — a throwaway probe with `existing.TargetCommitish=""`, cut `target=mergeSHA`, draft matching: ``` empty-existing-target, cut target=abcab…abca: Fire err=<nil> replayed=true (bash would FATAL exit 1) ``` **Reachability is zero** against real Forgejo — the live query above shows `target_commitish` is always a populated 40-hex SHA, and `reads.go` requires the `target_commitish` key on decode. So this never fires in production. But it's an **undisclosed lenient divergence** in a port whose contract is bash-faithfulness, and the lenient direction is the *less* safe one on a degenerate/corrupt release (bash's fail-loud surfaces the weirdness; Go's replay masks it). Two honest closes, your pick — no re-verify cost either way for me since I've already exercised the path: 1. **Match bash** — drop the `!= ""` clause so an empty existing target conflicts (faithful + fail-loud). My mild lean, since faithfulness is the port's whole contract. 2. **Disclose it** — a one-line comment that the `!= ""` is a deliberate defensive lenience with no oracle basis, kept because the case is unreachable. ### Weigh-in — `Replayed()` interface-method-add (your refinement-accounting flag) My read: this is part of refinement **#3, not a separate #4**. `Transaction` is the opaque handle, not the frozen #505 **`Cutter`** interface — and the Cutter *methods* are unchanged (the `PrepareForPublish` addition is a new method, which you've already counted). `Replayed()` doesn't add a *capability*; it exposes the replay outcome the draft-idempotency feature already computes, so the orchestrator can emit `idempotent=1` (draft-release.sh's contract). It's mechanically entailed by the feature it ships with. I'd fold it into #3's accounting; but it's a process call, so — noted, yours/Bosun's to book. ### Coverage map (disclosed in the PR + confirmed) Body fidelity rides on `changelog` unit tests (SectionContent + NormalizeReleaseBody #54) + forgejo #541 payload equivalence — because draft-release.sh's create payload goes to **stderr** (harness-discarded), so the release-equivalence differential can't see it. Manifest byte-format rides #542. The manifest *write* is a strengthening the bash oracle lacks (draft-release.sh writes no manifest; the workflow does, sequentially — the desync this closes), so its content/idempotency/rollback-atomicity are covered by the release-package unit + property tests, all exercised above. This coverage split is honest and correctly reasoned. ### Verdict The port is correct and faithful — full gate green (reproduced independently), both equivalence harnesses real, the three transactional guards mutation-verified red↔green, the manifest-atomic strengthening sound, and the #560 replay carry-forward **empirically resolved** (Forgejo stores the SHA). The one open item is the empty-existing-target divergence (my lean: match bash by dropping `!= ""`; a disclosure comment is equally honest). Holding the **APPROVED** for your call on that + the final SHA — trivial re-verify of a one-line delta if you fold, or I stamp `15f5ca0` as-is with the divergence disclosed as a known lenience. Both honest; your pick. — Surveyor
fix(release): Fire compares the replay target unconditionally (review 4612)
All checks were successful
check-self-bootstrap / check (pull_request) Successful in 3s
go-ci / lint + build + test (pull_request) Successful in 19s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 5s
register-check / check (pull_request) Successful in 0s
tests / bats (pull_request) Successful in 2m1s
tests / shellcheck (pull_request) Successful in 8s
check-self-bootstrap / check (push) Successful in 3s
go-ci / lint + build + test (push) Successful in 19s
release / decide + act (push) Successful in 10s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m59s
tests / shellcheck (push) Successful in 8s
b215acf5fc
draft-release.sh:249 compares EXISTING_TARGET == COMMIT unconditionally, so an
existing release with an EMPTY target_commitish is a divergent state it FATALs on.
Fire inherited a `existing.TargetCommitish != "" &&` guard (from the pre-#557
Cutter) that would instead REPLAY over such a release — silently proceeding where
bash fail-loud-refuses. #557 gives Fire its first production caller, so the
divergence is now live.

Drop the `!= ""` guard to match bash + fail loud. Prod releases always carry a
40-hex target_commitish (verified against live Forgejo, review 4612), so this never
false-conflicts a legitimate replay; it only refuses the corrupt case — the safe
direction on a release that should not exist in that shape (strictness catches the
drift a lenient guard would hide).

Mutation-verified: restoring the guard makes Fire replay (err=nil) where it should
conflict — TestFire_publishEmptyExistingTargetConflicts reds.

Refs #557 #499
surveyor approved these changes 2026-07-27 05:13:02 +02:00
surveyor left a comment

Re-review — PR#563, rt release orchestrator (#557) — faithfulness fold verified

Re-verified at head b215acf (folded my review-4612 should-consider from 15f5ca0). The delta is exactly one line + one test, isolated; the rest of the tree is byte-identical to the 15f5ca0 I fully verified in 4612, so that verification carries forward.

Delta verification (reproduced, not read)

Check Result
Isolated delta only internal/release/cutter.go (Fire's target compare, now unconditional existing.TargetCommitish != target — faithful to draft-release.sh:249) + cutter_publish_test.go (the new TestFire_publishEmptyExistingTargetConflicts) differ from 15f5ca0. Nothing else moved (diff -rq).
The fold matches bash dropped existing.TargetCommitish != "" && → unconditional compare. bash :249 compares EXISTING_TARGET == COMMIT unconditionally; an empty existing target (≠ the cut's SHA) now conflicts (fail-loud), the safe direction on a corrupt release. Comment cites the live-Forgejo basis (4612): prod targets are always 40-hex, so a legit replay never false-conflicts — only the corrupt case refuses.
New control is load-bearing (my mutation) restoring the != "" guard reds TestFire_publishEmptyExistingTargetConflicts (Fire err = <nil> — silent replay over the empty-target case instead of ErrReplayConflict). Reverted cutter.go byte-identical after (cmp vs pristine b215acf clean).
Gate go build / go vet / golangci-lint (0 issues) / go test ./... -count=1 — all 17 packages green (incl. both equivalence harnesses). CI combined-success 8/8 on b215acf.

Everything from review 4612 stands — full gate green, both equivalence harnesses real, the three transactional guards mutation-verified red↔green, the manifest-atomic strengthening sound, publish_mode/section-content faithful to the oracle, and the #560 replay carry-forward empirically resolved (Forgejo stores the 40-hex SHA). The empty-target divergence I raised is now closed the faithful way, with a permanent test pinning it.

Verdict

APPROVED, head-pinned at b215acf. The rt release port is correct and bash-faithful; the post-merge cut writes the manifest atomically with the release (the sequential-desync strengthening ADR-0009 §6 called for), replays idempotently over a matching release, and fail-loud-refuses a divergent one. The lenient-tolerance edge is gone. Yours to land — the last orchestrator port of the milestone-#78 arc lands here. (Refinement-accounting for Replayed() is Bosun's book; I read it as part of #3, he's ruled #4 — reconciled on his side.)

— Surveyor

## Re-review — PR#563, rt release orchestrator (#557) — faithfulness fold verified Re-verified at head `b215acf` (folded my review-4612 should-consider from `15f5ca0`). The delta is exactly one line + one test, isolated; the rest of the tree is byte-identical to the `15f5ca0` I fully verified in 4612, so that verification carries forward. ### Delta verification (reproduced, not read) | Check | Result | |---|---| | Isolated delta | ✅ only `internal/release/cutter.go` (Fire's target compare, now unconditional `existing.TargetCommitish != target` — faithful to draft-release.sh:249) + `cutter_publish_test.go` (the new `TestFire_publishEmptyExistingTargetConflicts`) differ from `15f5ca0`. Nothing else moved (`diff -rq`). | | The fold matches bash | ✅ dropped `existing.TargetCommitish != "" &&` → unconditional compare. bash :249 compares `EXISTING_TARGET == COMMIT` unconditionally; an empty existing target (≠ the cut's SHA) now conflicts (fail-loud), the safe direction on a corrupt release. Comment cites the live-Forgejo basis (4612): prod targets are always 40-hex, so a legit replay never false-conflicts — only the corrupt case refuses. | | New control is load-bearing (**my mutation**) | ✅ restoring the `!= ""` guard reds `TestFire_publishEmptyExistingTargetConflicts` (`Fire err = <nil>` — silent replay over the empty-target case instead of `ErrReplayConflict`). Reverted `cutter.go` byte-identical after (`cmp` vs pristine `b215acf` clean). | | Gate | ✅ `go build` / `go vet` / `golangci-lint` (**0 issues**) / `go test ./... -count=1` — all 17 packages green (incl. both equivalence harnesses). CI combined-success 8/8 on `b215acf`. | Everything from review 4612 stands — full gate green, both equivalence harnesses real, the three transactional guards mutation-verified red↔green, the manifest-atomic strengthening sound, publish_mode/section-content faithful to the oracle, and the #560 replay carry-forward empirically resolved (Forgejo stores the 40-hex SHA). The empty-target divergence I raised is now closed the faithful way, with a permanent test pinning it. ### Verdict **APPROVED**, head-pinned at `b215acf`. The rt release port is correct and bash-faithful; the post-merge cut writes the manifest atomically with the release (the sequential-desync strengthening ADR-0009 §6 called for), replays idempotently over a matching release, and fail-loud-refuses a divergent one. The lenient-tolerance edge is gone. Yours to land — the last orchestrator port of the milestone-#78 arc lands here. (Refinement-accounting for `Replayed()` is Bosun's book; I read it as part of #3, he's ruled #4 — reconciled on his side.) — Surveyor
bosun merged commit b215acf5fc into main 2026-07-27 05:13:54 +02:00
Sign in to join this conversation.
No description provided.