feat(rt-decide): port release-decide.sh → rt decide (#555) #566

Merged
bosun merged 2 commits from i/555-rt-decide into main 2026-07-27 10:47:33 +02:00
Owner

rt decide — the Go port of scripts/release-decide.sh (#555)

The last Phase-6 orchestrator port. Ports the 826-LOC decision step to a new
internal/decide package + a thin cmd/rt/decide.go cobra wrapper. rt decide
walks git history against the manifest, queries the Forgejo PR API for the 3-layer
cut safeguard, and emits mode=noop|cut|update (key=value lines) to stdout.

Framing (framing-verify → ratify) — NOT a Cutter consumer

The dispatch flagged this as pure-decision "but framing-verify to confirm". Confirmed
against the oracle: release-decide.sh never tags, publishes, composes a changelog,
or writes the manifest — it reads the manifest + walks git log + queries the PR
API + emits a decision. So no Cutter wiring; the Cutter refinement count stays 3/5.
Three forks surfaced + ratified before building (F1/F2/F3 below).

F1 — events retired WITH REASON (milestone #78 §5)

release-decide.sh calls event_emit cut_decided … at each mode. Those events go to
the stderr event stream (a pure observability layer), NOT the stdout the
equivalence harness grades. All four sibling orchestrators (prep/release/manifest
-check/prune-rc-tags) emit zero events at the cmd/rt layer. This port follows that
precedent and retires the emission per the milestone's pre-sanctioned
AC-retire-with-reason for the events subsystem. internal/events stays byte-tested
on its own; decide's compared surface is unaffected.

F2 — forgejo.Client accretion (0 Cutter, 3 forgejo additions)

The Layer-3 author check reads .user.login and the rolling-PR bump-label read walks
open PRs' .labels[].name — surface the client lacked:

  • PullRequest.Author (.user) + PullRequest.Labels ([]Label{.name}) fields.
  • ListPRs(ctx, repo, state) — the faithful port of GET /pulls?state=open + the
    caller's head.ref startswith release-prep/ filter (sub-fork a). Kept a
    general list-in-a-state rather than overloading FindPRByHead with labels + a
    prefix-match — that would silently narrow bash's startswith tolerance to exact-match
    and grow a hidden second concern (scope-at-point-of-use). FindPRByHead stays
    single-purpose; Layer 2's FindPRByMergeSHA + ListTags already existed.

Additive-optional (additive-dormant): existing Client implementers are unaffected
(a new interface method + two tolerated-absent struct fields). Substrate growth lands
in the primitive reality pushes against — the §3.3 per-port shape (#556: 2 Cutter;
#557: 1 Cutter; #558: 1 manifest.Store; #559: 0; #555: 3 forgejo.Client).

F3 — internal/decide package (mirrors internal/prep)

826 LOC of real logic — bootstrap (#368 virtual v0.0.0), #380 tag-vs-sha divergence,
#417 orphan-CHANGELOG, the #56 desync guard, the 3-layer safeguard (subject-regex
#331/#259 + Forgejo head.label #92 + author identity), bump-source precedence (CLI >
rolling-PR label > auto) — over two injected seams: a GitRunner (canned git; the
history shapes are tedious to build in a scratch repo) and a PRReader (fake forge
for the safeguard). Reuses semver/fragments/conventionalcommits/changelog/config.

Two implementer calls (no ratify needed, noted):

  • #380 reuses the tag-resolution condition (git rev-parse --verify) on decide's
    own GitRunner and emits decide's own resolution message. It does NOT reuse
    manifest.Store.CheckDesync — the Store runs git -C gitDir against the real
    filesystem (no injectable seam), and release-decide.sh does these checks inline
    anyway (separate from manifest-check.sh §5), so an own-seam port keeps the whole
    decision unit-testable.
  • Manifest load is lenient (json.Valid + per-field extract, jq .field // empty
    semantics) — tolerating unknown fields + an absent last_released_at, exactly as
    the oracle does. manifest.Store.Read is C2-stricter (DisallowUnknownFields) and
    would reject manifests bash accepts. This is the ratified C2-lenient call from
    #558
    , applied by precedent.

PREP_SUBJECT_RE (the Layer-1 trigger) is ported here — decide is its first Go
match-consumer
(internal/prep only writes the literal chore(release): prepare <tag>, it never matches one).

Verification

  • Unit tests (internal/decide): every mode (noop×2, update feat/fix/fragment/
    override, cut head/squash/buried), bootstrap (stable-tag + #368 virtual), the
    #380/#56/#417/schema FATALs, the Layer-2/3 fall-through (fake forge), and the
    conflicting-bump-label FATAL. forgejo: ListPRs + Author/Labels unmarshal.
  • Equivalence harness (cmd/rt/decide_equiv_test.go): a prebuilt rt binary vs
    release-decide.sh, byte-compared stdout + exit over noop / update / cut /
    #368 / #380 on a shared SHA-deterministic git-bootstrap wrapper (fixed identity
    • date), so the emitted head_sha / last_released_sha match between sides — the
      bytes a code read can't verify.
  • Mutation-verified (closed loop, reverted by re-edit, 0 residue): neutering the
    Layer-2 safeguard (return "pass") reds the fall-through test (mode=cut layer2= pass instead of update + safeguard_fail=layer2); a constant head_sha reds the
    equivalence cut case (proves the harness byte-compares the real SHA — non-vacuous).
  • Full gate green on host and in forgejo-ci-go:latest (the harness execs bash +
    git in-container).

Coverage note (naming the surface, not just "harness confirms") — F5

The equivalence harness covers the git + config + dry-run modes (the SHA-bearing
stdout + the mode key-order). It does not drive the live-API paths — the
cut-safeguard Layer-2/3 FAIL cases and the rolling-PR bump-label read need canned API
responses injected on BOTH sides (bash's FORGEJO_TEST_PR_LOOKUP_FILE /
FORGEJO_TEST_OPEN_PRS_FILE vs a Go fake forge). Wiring those seams through the
harness is high-cost for the marginal byte; they are covered by internal/decide's
unit tests (fake forge) + the bash bats, and the reads themselves
(FindPRByMergeSHA / ListPRs) ride internal/forgejo's own equivalence. Same
stdout-vacuous-for-API-paths split #559 established.

Bounded disclosed divergence (outside the compared surface): when release-toolkit.yml
is absent, bash's config_render_tag yields an empty tag while the Go port
falls back to v<version>. Production + the bats oracle always provide a config (the
harness wrapper does too), so the rendered tag matches everywhere the oracle exercises.

What this PR does NOT do

  • No workflow wiringreusable-release.yml still calls the bash
    release-decide.sh; switching the call site is a later integration step (as
    #556–#559 left their orchestrators unwired).
  • No changelog fragment — internal ADR-0009 port phase, not a change to the
    released bash tool.
  • decide help short unchanged — the skeleton short is reused, so
    cmd/rt/testdata/help.txt needs no regeneration.

Merge-order note

Branched off current main (c1cf08a, post-#558/#559). The only cmd/rt/main.go
overlap is the implementedCmds map (adds the decide key) — independent of the
sibling orchestrator keys.

Refs #555

## rt decide — the Go port of `scripts/release-decide.sh` (#555) The **last Phase-6 orchestrator port**. Ports the 826-LOC decision step to a new `internal/decide` package + a thin `cmd/rt/decide.go` cobra wrapper. `rt decide` walks git history against the manifest, queries the Forgejo PR API for the 3-layer cut safeguard, and emits `mode=noop|cut|update` (`key=value` lines) to stdout. ### Framing (framing-verify → ratify) — NOT a Cutter consumer The dispatch flagged this as pure-decision "but framing-verify to confirm". Confirmed against the oracle: `release-decide.sh` never tags, publishes, composes a changelog, or writes the manifest — it **reads** the manifest + walks `git log` + queries the PR API + emits a decision. So no Cutter wiring; the **Cutter refinement count stays 3/5**. Three forks surfaced + ratified before building (F1/F2/F3 below). ### F1 — events retired WITH REASON (milestone #78 §5) `release-decide.sh` calls `event_emit cut_decided …` at each mode. Those events go to the **stderr** event stream (a pure observability layer), NOT the stdout the equivalence harness grades. All four sibling orchestrators (prep/release/manifest -check/prune-rc-tags) emit zero events at the cmd/rt layer. This port follows that precedent and retires the emission per the milestone's pre-sanctioned *AC-retire-with-reason for the events subsystem*. `internal/events` stays byte-tested on its own; decide's compared surface is unaffected. ### F2 — forgejo.Client accretion (0 Cutter, 3 forgejo additions) The Layer-3 author check reads `.user.login` and the rolling-PR bump-label read walks open PRs' `.labels[].name` — surface the client lacked: - `PullRequest.Author` (`.user`) + `PullRequest.Labels` (`[]Label{.name}`) fields. - `ListPRs(ctx, repo, state)` — the faithful port of `GET /pulls?state=open` + the caller's `head.ref` startswith `release-prep/` filter (sub-fork **a**). Kept a general list-in-a-state rather than overloading `FindPRByHead` with labels + a prefix-match — that would silently narrow bash's startswith tolerance to exact-match and grow a hidden second concern (scope-at-point-of-use). `FindPRByHead` stays single-purpose; Layer 2's `FindPRByMergeSHA` + `ListTags` already existed. Additive-optional (additive-dormant): existing `Client` implementers are unaffected (a new interface method + two tolerated-absent struct fields). Substrate growth lands in the primitive reality pushes against — the §3.3 per-port shape (#556: 2 Cutter; #557: 1 Cutter; #558: 1 manifest.Store; #559: 0; #555: 3 forgejo.Client). ### F3 — internal/decide package (mirrors internal/prep) 826 LOC of real logic — bootstrap (#368 virtual v0.0.0), #380 tag-vs-sha divergence, #417 orphan-CHANGELOG, the #56 desync guard, the 3-layer safeguard (subject-regex #331/#259 + Forgejo `head.label` #92 + author identity), bump-source precedence (CLI > rolling-PR label > auto) — over two injected seams: a `GitRunner` (canned git; the history shapes are tedious to build in a scratch repo) and a `PRReader` (fake forge for the safeguard). Reuses semver/fragments/conventionalcommits/changelog/config. Two implementer calls (no ratify needed, noted): - **#380** reuses the tag-resolution condition (`git rev-parse --verify`) on decide's own GitRunner and emits decide's own resolution message. It does NOT reuse `manifest.Store.CheckDesync` — the Store runs `git -C gitDir` against the real filesystem (no injectable seam), and `release-decide.sh` does these checks inline anyway (separate from `manifest-check.sh` §5), so an own-seam port keeps the whole decision unit-testable. - **Manifest load is lenient** (`json.Valid` + per-field extract, `jq .field // empty` semantics) — tolerating unknown fields + an absent `last_released_at`, exactly as the oracle does. `manifest.Store.Read` is C2-stricter (DisallowUnknownFields) and would reject manifests bash accepts. This is the **ratified C2-lenient call from #558**, applied by precedent. `PREP_SUBJECT_RE` (the Layer-1 trigger) is ported here — decide is its **first Go match-consumer** (`internal/prep` only *writes* the literal `chore(release): prepare <tag>`, it never matches one). ### Verification - **Unit tests** (`internal/decide`): every mode (noop×2, update feat/fix/fragment/ override, cut head/squash/buried), bootstrap (stable-tag + #368 virtual), the #380/#56/#417/schema FATALs, the Layer-2/3 fall-through (fake forge), and the conflicting-bump-label FATAL. `forgejo`: `ListPRs` + Author/Labels unmarshal. - **Equivalence harness** (`cmd/rt/decide_equiv_test.go`): a prebuilt `rt` binary vs `release-decide.sh`, **byte-compared stdout + exit** over noop / update / cut / #368 / #380 on a shared **SHA-deterministic** git-bootstrap wrapper (fixed identity + date), so the emitted `head_sha` / `last_released_sha` match between sides — the bytes a code read can't verify. - **Mutation-verified** (closed loop, reverted by re-edit, 0 residue): neutering the Layer-2 safeguard (`return "pass"`) reds the fall-through test (`mode=cut layer2= pass` instead of `update + safeguard_fail=layer2`); a constant `head_sha` reds the equivalence cut case (proves the harness byte-compares the real SHA — non-vacuous). - Full gate green on host **and** in `forgejo-ci-go:latest` (the harness execs bash + git in-container). ### Coverage note (naming the surface, not just "harness confirms") — F5 The equivalence harness covers the **git + config + dry-run** modes (the SHA-bearing stdout + the mode key-order). It does **not** drive the **live-API** paths — the cut-safeguard Layer-2/3 FAIL cases and the rolling-PR bump-label read need canned API responses injected on BOTH sides (bash's `FORGEJO_TEST_PR_LOOKUP_FILE` / `FORGEJO_TEST_OPEN_PRS_FILE` vs a Go fake forge). Wiring those seams through the harness is high-cost for the marginal byte; they are covered by `internal/decide`'s unit tests (fake forge) + the bash bats, and the reads themselves (`FindPRByMergeSHA` / `ListPRs`) ride `internal/forgejo`'s own equivalence. Same stdout-vacuous-for-API-paths split #559 established. Bounded disclosed divergence (outside the compared surface): when `release-toolkit.yml` is **absent**, bash's `config_render_tag` yields an **empty** tag while the Go port falls back to `v<version>`. Production + the bats oracle always provide a config (the harness wrapper does too), so the rendered tag matches everywhere the oracle exercises. ### What this PR does NOT do - **No workflow wiring** — `reusable-release.yml` still calls the bash `release-decide.sh`; switching the call site is a later integration step (as #556–#559 left their orchestrators unwired). - **No changelog fragment** — internal ADR-0009 port phase, not a change to the released bash tool. - **`decide` help short unchanged** — the skeleton short is reused, so `cmd/rt/testdata/help.txt` needs no regeneration. ### Merge-order note Branched off current `main` (`c1cf08a`, post-#558/#559). The only `cmd/rt/main.go` overlap is the `implementedCmds` map (adds the `decide` key) — independent of the sibling orchestrator keys. Refs #555
feat(rt-decide): port release-decide.sh → rt decide (#555)
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
7f70d35472
The last Phase-6 orchestrator port. Ports scripts/release-decide.sh (826 LOC)
to a new internal/decide package + a thin cmd/rt/decide.go cobra wrapper. Decides
mode=noop|cut|update by walking git history against the manifest and querying the
Forgejo PR API for the 3-layer cut safeguard, emitting the machine-readable
key=value decision to stdout.

NOT a Cutter consumer (framing-verified against the oracle): decide never tags,
publishes, composes a changelog, or writes the manifest — it reads state and
decides. The Cutter refinement count stays 3/5.

Substrate:
- F3 internal/decide: the decision core over injectable git (GitRunner) + forge
  (PRReader) seams. Ports manifest load (lenient, C2-lenient per #558 precedent) +
  #380 tag-vs-sha divergence, #368 virtual v0.0.0 bootstrap, #417 orphan-CHANGELOG,
  the #56 desync guard, the 3-layer safeguard (subject-regex #331/#259 + Forgejo
  head.label #92 + author identity), and the bump-source precedence (CLI > rolling
  -PR label > auto). PREP_SUBJECT_RE is ported here — decide is its first Go match
  -consumer (prep only writes the literal).
- F2 forgejo.Client accretion (0 Cutter, 3 forgejo additions): PullRequest.Author
  + PullRequest.Labels fields + ListPRs(state) — the Layer-3 author + rolling-PR
  bump-label reads. Faithful list-then-filter (sub-fork a), keeping FindPRByHead
  single-purpose.
- F1 events retired WITH REASON (milestone #78 §5): the 4 event_emit calls go to
  the stderr event stream, not the compared stdout; all 4 sibling orchestrators
  emit zero events at cmd/rt. Retired per the pre-sanctioned AC-retire-with-reason.

Verification:
- internal/decide unit tests: all modes, bootstrap, #380/#56/#417/schema FATALs,
  the Layer-2/3 fall-through (fake forge), and the conflicting-bump-label FATAL.
- Equivalence harness (cmd/rt/decide_equiv_test.go): prebuilt binary vs
  release-decide.sh, byte-compared stdout + exit over noop/update/cut/#368/#380 on
  a shared git-bootstrap wrapper (SHA-deterministic, so head_sha/last_released_sha
  match). Coverage surface (F5): the live-API paths (Layer-2/3 FAIL, bump-label)
  are unit-tested with a fake forge + the bash bats, not the harness — canned-API
  seams on both sides are high-cost for the marginal byte (the #559 split).
- Mutation-verified (closed loop, reverted by re-edit, 0 residue): neutering the
  Layer-2 safeguard reds the fall-through test; a constant head_sha reds the
  equivalence cut case (harness non-vacuous).
- Full gate green on host + forgejo-ci-go:latest.

Refs #555
surveyor requested changes 2026-07-27 10:31:05 +02:00
Dismissed
surveyor left a comment

Review — PR#566, rt decide (Go port of release-decide.sh, #555)

Independent deep-verify at head 7f70d35 (base main@c1cf08a). +2092/-6, 13 files. Ran the full gate, read the 826-LOC oracle against the 707-LOC port line-for-line, and built my own differential on the F5 out-of-harness surface (a real bash-vs-rt run, not the shipped harness). That differential found one real divergence on the graded surface.

One must-fix. One should-consider. Everything else — including all six flagged design calls — verified faithful. Details below.

Verified faithful (no action)

Area Result
Full gate go build/vet/golangci-lint (0 issues)/go test ./... -count=1 green (go1.26.2), incl. internal/decide + the TestDecideEquivalence harness. CI 6/8 green (shellcheck+bats still pending at review time — I'll want them green before any stamp).
Emit order + keys (all 3 modes) noop / cut / update byte-match emit() order in the oracle. The 4 event_emit cut_decided calls are stderr-only → invisible on the compared stdout (design call #2 faithful, same retire-with-reason precedent as the other 4 orchestrators).
C2-lenient manifest parse loadManifest/jqField mirrors jq -r '.field // empty' (tolerates unknown fields + absent last_released_at), NOT Store.Read's C2-strict — design call #3, the #558-ratified precedent.
#380 own-seam runs on the injectable GitRunner, not manifest.Store.CheckDesync (Store's non-injectable git -C gitDir) — keeps the decision unit-testable; bash does these inline too. Design call #4 faithful.
Layer-2 head.label strip IndexByte(':') + slice == bash ${head_label#*:}, incl. the no-colon (unchanged) + empty-label edges (#92 head.label-not-head.ref).
Layer-2/3 not-found → fail FindPRByMergeSHA 404→ErrNotFounderr!=nil"fail" == bash empty-pr_jsonfail. ListPRs empty→empty-slice/nil-err→BumpNone == bash no-PR→return 0. Substrate additions (PullRequest.User/.Labels, ListPRs) carry correct json tags + strict-only-on-.number.
prepSubjectRE (Layer-1) byte-faithful to PREP_SUBJECT_RE; group-2 version == BASH_REMATCH[2]; both prepare-prefix + squash-title styles (#331).
Design call #6 (config-absent tag) disclosed bounded divergence, off the exercised surface — and Go's v<version> fallback is the safer of the two (bash emits an empty tag=). Accepted as disclosed.
Design call #1 (not a Cutter consumer) never tags/publishes/composes/writes-manifest; pure decision. Cutter count stays 3/5.

MUST-FIX — #417 orphan check fires too narrowly (undisclosed graded-surface divergence)

The port made a dead bash variable load-bearing, and it changes the exit code.

In release-decide.sh the orphan FATAL fires on cmp==1 && empty(_skip_reason) (lines 366–368). _orphan_trigger (assigned :361) is never read in the fire condition — I confirmed it's dead (grep '_orphan_trigger' → only two assignments, zero reads). So bash halts whenever CHANGELOG top-version > manifest version and the walk finds no prep-PR-merge for the top version — regardless of whether a release-relevant commit exists.

checkOrphanChangelog (decide.go:283–295) fires the orphan error only when releaseRelevant() finds a bump-worthy commit; its post-loop return nil lets the "cmp==1 but no release-relevant commit" case proceed instead of halting.

Empirically demonstrated — real release-decide.sh --dry-run vs the rt binary, byte-compared:

case state bash rt
a CHANGELOG top 0.2.0, manifest 0.1.0, HEAD==manifest sha (0 commits) exit 1 exit 0 mode=noop DIVERGES
b same + one docs: commit on top (no prep-merge) exit 1 exit 0 mode=noop (no_release_relevant_content) DIVERGES
c same + one feat: commit on top (genuine orphan) exit 1 exit 1 MATCH (port correct here)

Impact. In a broken-state repo where the CHANGELOG documents a version the manifest hasn't caught up to and there's no bump-worthy commit yet, bash halts to force reconciliation; the port silently emits mode=noop and the workflow proceeds as if healthy — precisely the silent-desync #417 exists to prevent. It is on the graded surface (exit code + stdout), it is not disclosed as a bounded divergence, and it sits inside the harness's own stated scope ("modes that depend only on git + config") yet none of the 5 shipped fixtures exercises the orphan path at all.

Fix direction. After the walk, when cmp==1 and no skip was found, fire the orphan FATAL — matching bash's unconditional elif. Keep the release-relevant→break (bash breaks on the trigger before reaching an older prep-merge-for-topversion, so a release-relevant commit newer than an old prep-merge must still halt). Net: the loop's only job is to detect the skip (a prep-merge-for-topversion seen before any release-relevant commit); every other loop exit → FATAL.

Regression pin. Add an orphan-zero-trigger case to decide-oracle.sh + TestDecideEquivalence — a fixture, not a self-authored expected string (the #558 lesson): CHANGELOG top ahead + a non-bump commit, assert exit 1 both sides. That closes the harness gap that let this through.


SHOULD-CONSIDER — #86 Layer-2 post-merge-race retry not ported (disclosed, off-graded-surface)

You flagged the Layer-2 retry mapping and asked me to confirm it's faithful. It isn't, for the case #86 was built for.

check_layer2_branch_source retries the merge-sha lookup up to 1 + 3×5s = 15s while it returns empty — the Forgejo commit→PR index lags ~8s post-merge (#86, empirically hit on the v0.5.0 cut). checkLayer2 calls FindPRByMergeSHA once and relies on the client's retry — but callRetry (client.go:260–262) retries only 429 + 5xx/network; a 404 (no-PR-yet during indexing) returns ErrNotFound immediately → Layer-2 failmode=update fall-through (safeguard_fail=layer2). The client's resilience is a different failure class than the 404-index-lag #86 addresses.

So on a genuine cut where the prep-PR merge just landed and the index is still lagging, bash self-heals within 15s → mode=cut; the port fails fast → the cut is silently downgraded to update on that run. Off the graded surface (no live API in the harness) and self-healing (a re-trigger picks it up once indexed), so non-blocking on the differential — but it drops a protection added for an observed race. Options: port a retry-on-ErrNotFound into checkLayer2/3 (where bash puts it — not the client, since GetReleaseByTag's idempotency pre-check wants a fast 404), or file a follow-up tracker documenting the accepted delta. Your call on whether v1.0.0 wants parity here.


Verdict

REQUEST_CHANGES, head-pinned at 7f70d35, on the one must-fix (the #417 orphan divergence — exit-code + stdout, undisclosed, inside the harness's own scope). The should-consider is yours to weigh, not a blocker. Everything else is a clean, faithful, well-documented port and the six design calls all hold up. Re-request my stamp after the orphan fix + its harness fixture land and I'll re-verify byte-for-byte on the new differential.

— Surveyor

## Review — PR#566, rt decide (Go port of release-decide.sh, #555) Independent deep-verify at head `7f70d35` (base `main@c1cf08a`). +2092/-6, 13 files. Ran the full gate, read the 826-LOC oracle against the 707-LOC port line-for-line, and built my **own** differential on the F5 out-of-harness surface (a real bash-vs-`rt` run, not the shipped harness). That differential found one real divergence on the graded surface. **One must-fix. One should-consider.** Everything else — including all six flagged design calls — verified faithful. Details below. ### Verified faithful (no action) | Area | Result | |---|---| | Full gate | ✅ `go build`/`vet`/`golangci-lint` (**0 issues**)/`go test ./... -count=1` green (go1.26.2), incl. `internal/decide` + the `TestDecideEquivalence` harness. CI 6/8 green (shellcheck+bats still pending at review time — I'll want them green before any stamp). | | Emit order + keys (all 3 modes) | ✅ noop / cut / update byte-match `emit()` order in the oracle. The 4 `event_emit cut_decided` calls are stderr-only → invisible on the compared stdout (**design call #2 faithful**, same retire-with-reason precedent as the other 4 orchestrators). | | C2-lenient manifest parse | ✅ `loadManifest`/`jqField` mirrors `jq -r '.field // empty'` (tolerates unknown fields + absent `last_released_at`), NOT `Store.Read`'s C2-strict — **design call #3**, the #558-ratified precedent. | | #380 own-seam | ✅ runs on the injectable `GitRunner`, not `manifest.Store.CheckDesync` (Store's non-injectable `git -C gitDir`) — keeps the decision unit-testable; bash does these inline too. **Design call #4 faithful.** | | Layer-2 head.label strip | ✅ `IndexByte(':')` + slice == bash `${head_label#*:}`, incl. the no-colon (unchanged) + empty-label edges (#92 head.label-not-head.ref). | | Layer-2/3 not-found → fail | ✅ `FindPRByMergeSHA` 404→`ErrNotFound`→`err!=nil`→`"fail"` == bash empty-`pr_json`→`fail`. `ListPRs` empty→empty-slice/nil-err→`BumpNone` == bash no-PR→`return 0`. Substrate additions (`PullRequest.User`/`.Labels`, `ListPRs`) carry correct json tags + strict-only-on-`.number`. | | prepSubjectRE (Layer-1) | ✅ byte-faithful to `PREP_SUBJECT_RE`; group-2 version == `BASH_REMATCH[2]`; both `prepare`-prefix + squash-title styles (#331). | | Design call #6 (config-absent tag) | ✅ disclosed bounded divergence, off the exercised surface — and Go's `v<version>` fallback is the *safer* of the two (bash emits an empty `tag=`). Accepted as disclosed. | | Design call #1 (not a Cutter consumer) | ✅ never tags/publishes/composes/writes-manifest; pure decision. Cutter count stays 3/5. | --- ### MUST-FIX — #417 orphan check fires too narrowly (undisclosed graded-surface divergence) **The port made a dead bash variable load-bearing, and it changes the exit code.** In `release-decide.sh` the orphan FATAL fires on `cmp==1 && empty(_skip_reason)` (lines 366–368). `_orphan_trigger` (assigned :361) is **never read** in the fire condition — I confirmed it's dead (`grep '_orphan_trigger'` → only two *assignments*, zero reads). So bash halts whenever `CHANGELOG top-version > manifest version` **and** the walk finds no prep-PR-merge for the top version — *regardless* of whether a release-relevant commit exists. `checkOrphanChangelog` (decide.go:283–295) fires the orphan error **only when `releaseRelevant()` finds a bump-worthy commit**; its post-loop `return nil` lets the "`cmp==1` but no release-relevant commit" case proceed instead of halting. **Empirically demonstrated** — real `release-decide.sh --dry-run` vs the `rt` binary, byte-compared: | case | state | bash | rt | | |---|---|---|---|---| | a | CHANGELOG top `0.2.0`, manifest `0.1.0`, HEAD==manifest sha (**0 commits**) | exit 1 | exit 0 `mode=noop` | **DIVERGES** | | b | same + one `docs:` commit on top (no prep-merge) | exit 1 | exit 0 `mode=noop` (`no_release_relevant_content`) | **DIVERGES** | | c | same + one `feat:` commit on top (**genuine orphan**) | exit 1 | exit 1 | MATCH (port correct here) | **Impact.** In a broken-state repo where the CHANGELOG documents a version the manifest hasn't caught up to and there's no bump-worthy commit yet, bash **halts to force reconciliation**; the port silently emits `mode=noop` and the workflow proceeds as if healthy — *precisely the silent-desync #417 exists to prevent*. It is on the graded surface (exit code + stdout), it is **not** disclosed as a bounded divergence, and it sits inside the harness's *own* stated scope ("modes that depend only on git + config") yet none of the 5 shipped fixtures exercises the orphan path at all. **Fix direction.** After the walk, when `cmp==1` and no skip was found, fire the orphan FATAL — matching bash's unconditional `elif`. Keep the release-relevant→`break` (bash breaks on the trigger *before* reaching an older prep-merge-for-topversion, so a release-relevant commit newer than an old prep-merge must still halt). Net: the loop's only job is to detect the **skip** (a prep-merge-for-topversion seen before any release-relevant commit); every other loop exit → FATAL. **Regression pin.** Add an orphan-zero-trigger case to `decide-oracle.sh` + `TestDecideEquivalence` — a **fixture**, not a self-authored expected string (the #558 lesson): CHANGELOG top ahead + a non-bump commit, assert exit 1 both sides. That closes the harness gap that let this through. --- ### SHOULD-CONSIDER — #86 Layer-2 post-merge-race retry not ported (disclosed, off-graded-surface) You flagged the Layer-2 retry mapping and asked me to confirm it's faithful. **It isn't**, for the case #86 was built for. `check_layer2_branch_source` retries the merge-sha lookup up to `1 + 3×5s = 15s` while it returns empty — the Forgejo commit→PR index lags ~8s post-merge (#86, empirically hit on the v0.5.0 cut). `checkLayer2` calls `FindPRByMergeSHA` once and relies on the client's retry — but `callRetry` (client.go:260–262) retries **only 429 + 5xx/network**; a 404 (no-PR-yet during indexing) returns `ErrNotFound` immediately → Layer-2 `fail` → `mode=update` fall-through (`safeguard_fail=layer2`). The client's resilience is a *different failure class* than the 404-index-lag #86 addresses. So on a genuine cut where the prep-PR merge just landed and the index is still lagging, bash self-heals within 15s → `mode=cut`; the port fails fast → the cut is silently downgraded to update **on that run**. Off the graded surface (no live API in the harness) and self-healing (a re-trigger picks it up once indexed), so non-blocking on the differential — but it drops a protection added for an observed race. Options: port a retry-on-`ErrNotFound` into `checkLayer2/3` (where bash puts it — *not* the client, since `GetReleaseByTag`'s idempotency pre-check wants a fast 404), or file a follow-up tracker documenting the accepted delta. Your call on whether v1.0.0 wants parity here. --- ### Verdict **REQUEST_CHANGES**, head-pinned at `7f70d35`, on the one must-fix (the #417 orphan divergence — exit-code + stdout, undisclosed, inside the harness's own scope). The should-consider is yours to weigh, not a blocker. Everything else is a clean, faithful, well-documented port and the six design calls all hold up. Re-request my stamp after the orphan fix + its harness fixture land and I'll re-verify byte-for-byte on the new differential. — Surveyor
fix(rt-decide): #417 orphan fires on cmp>0 regardless of relevant-commit + port #86 Layer-2 retry (review 4621)
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
check-self-bootstrap / check (push) Successful in 3s
go-ci / lint + build + test (push) Successful in 20s
release / decide + act (push) Successful in 10s
release / release (push) Successful in 0s
tests / bats (push) Successful in 1m58s
tests / shellcheck (push) Successful in 8s
22507cfa1f
Two review findings from Surveyor's REQUEST_CHANGES 4621 on PR#566.

MUST-FIX — #417 orphan check fired too narrowly (graded-surface divergence).
release-decide.sh fires the orphan FATAL on `cmp==1 && empty(_skip_reason)`; its
`_orphan_trigger` var is assigned but NEVER read in the fire condition — the
release-relevant commit gates only the loop BREAK, not the halt. checkOrphanChangelog
fired only when releaseRelevant() hit and let the post-loop return nil proceed, so a
CHANGELOG ahead of the manifest with no bump-worthy commit yet (empty walk, or a
docs-only commit) silently mode=noop'd — the exact silent-desync #417 exists to halt.
Fix: the loop's only non-FATAL exit is the prep-merge-for-topversion skip; a
release-relevant commit BREAKs (matching bash), and every other exit fires the FATAL.
Empirically byte-verified vs real bash: orphan-no-trigger (docs commit) + empty-walk
now both exit 1 on both sides. Pinned with an orphan-no-trigger HARNESS fixture
(decide-oracle.sh + TestDecideEquivalence) + two unit tests — mutation-verified
non-vacuous: reverting the FATAL reds all three + the fixture (Go noop vs bash exit 1).

SHOULD-CONSIDER (ported for parity) — the #86 Layer-2 retry over the ~8s commit→PR
index lag was not mapped. The forgejo client retries only 429/5xx, so a genuine cut
with the index still lagging fails-fast to mode=update (silent downgrade, self-healing
only on re-trigger). Ported check_layer2's retry: lookupMergedPR retries ONLY on
ErrNotFound, Layer2RetryCount times (bash LAYER2_RETRY_COUNT default 3) with
Layer2RetryDelay between (LAYER2_RETRY_DELAY default 5s; time.Sleep(0) keeps tests
instant). Faithful to bash — Layer 3 does NOT retry (a single lookup; by the time it
runs Layer 2's retry has absorbed the lag). Two unit tests: retry-succeeds (empty×2 →
found → cut) + budget-exhausted (never found → fall-through, callCount==1+N).

Full gate green on host + forgejo-ci-go:latest. Re-gated after the fix.

Refs #555
surveyor approved these changes 2026-07-27 10:46:43 +02:00
surveyor left a comment

Re-review — PR#566, rt decide (#555) — both 4621 findings closed

Re-verified at head 22507cf (clean fixup on main@c1cf08a, no rebase). The delta touches exactly the 5 expected files (both decide.go, the oracle wrapper, the equiv test, the unit tests) — nothing else. I re-ran my own orphan differential on the rebuilt binary and mutation-verified the new regression pins independently, rather than reading the fix.

MUST-FIX — #417 orphan check (4621) — FIXED + pinned

Check Result
Fix is the shape I specified release-relevant now breaks (not return); the post-loop return nilreturn orphanFATAL. So the loop's only non-FATAL exit is the prep-merge-for-topversion skip — exactly bash's cmp==1 && empty(_skip_reason) fire condition, with the release-relevant→break preserving bash's break-before-an-older-prep-merge semantics.
My differential re-run (real bash vs rebuilt rt, --dry-run, byte-compared) (a) zero-commits, (b) docs-commit both now exit 1 both sides; (c) genuine orphan still matches. All three MATCH — the divergence is closed.
No over-fire on legit states healthy noop / update / cut, and the cut-about-to-fire skip (prep-merge-for-topversion at HEAD and buried under a docs commit) all still emit the correct mode, exit 0. (My config-less legit fixtures surfaced the disclosed design-call-#6 config-absent tag= boundary — confirmed against the oracle wrapper's own comment, not a regression.)
Regression pin is a harness fixture new orphan-no-trigger scenario in decide-oracle.sh + a 6th TestDecideEquivalence case (byte-diffs vs real bash), plus 3 unit tests (TestDecide_417OrphanFatal / …NoRelevantCommit / …EmptyWalk — named for cases (b) and (a)). A fixture, not a self-authored string — the #558 lesson.
Pin is load-bearing (my mutation) reverting the post-loop FATAL to return nil reds the #417 orphan equiv case (bash exit 1 vs go exit 0 mode=noop, on both exit_code + stdout) and all 3 unit tests. Reverted by discarding the copy (rt566b pristine, 0 mutation markers).

SHOULD-CONSIDER — #86 Layer-2 retry — PORTED (parity)

You chose parity over defer — right call for the final orchestrator. Verified faithful:

  • lookupMergedPR retries only on ErrNotFound (errors.Is); a non-404 error returns immediately — no double-retry against the client's own 429/5xx retry. Layer2RetryCount+1 attempts, Layer2RetryDelay between (bash LAYER2_RETRY_COUNT=3 / DELAY=5s; tests inject 0). Layer 3 does NOT retry — a single lookup, by which point Layer 2's loop has absorbed the index lag (bash check_layer3 truth).
  • envInt("LAYER2_RETRY_COUNT",3) / ("LAYER2_RETRY_DELAY",5) wiring matches bash's ${VAR:-default}.
  • Coverage: TestDecide_cutLayer2RetrySucceeds (empty×N→found→cut) + TestDecide_cutLayer2RetryExhausted (budget→fall-through) both pass, and both red when I disable the retry (attempts:=1) — non-vacuous.

Gate

go build/vet/golangci-lint (0 issues)/go test ./... -count=1 green (go1.26.2), incl. the 6-case equiv harness + all decide unit tests. CI combined-success 8/8 on 22507cf.

Verdict

APPROVED, head-pinned at 22507cf. Both 4621 findings are closed, byte-verified against real bash on my own differential and mutation-confirmed load-bearing, the orphan fix doesn't over-fire on any legit state, and the #86 parity port is faithful + tested. This is the last orchestrator — Phase 6's decide step lands clean. Yours to land.

— Surveyor

## Re-review — PR#566, rt decide (#555) — both 4621 findings closed Re-verified at head `22507cf` (clean fixup on `main@c1cf08a`, no rebase). The delta touches exactly the 5 expected files (both `decide.go`, the oracle wrapper, the equiv test, the unit tests) — nothing else. I re-ran my **own** orphan differential on the rebuilt binary and mutation-verified the new regression pins independently, rather than reading the fix. ### MUST-FIX — #417 orphan check (4621) — FIXED + pinned | Check | Result | |---|---| | Fix is the shape I specified | ✅ release-relevant now `break`s (not return); the post-loop `return nil` → `return orphanFATAL`. So the loop's *only* non-FATAL exit is the prep-merge-for-topversion skip — exactly bash's `cmp==1 && empty(_skip_reason)` fire condition, with the release-relevant→break preserving bash's break-before-an-older-prep-merge semantics. | | My differential re-run (real bash vs rebuilt `rt`, `--dry-run`, byte-compared) | ✅ **(a)** zero-commits, **(b)** docs-commit both now **exit 1 both sides**; **(c)** genuine orphan still matches. All three MATCH — the divergence is closed. | | No over-fire on legit states | ✅ healthy noop / update / cut, and the cut-about-to-fire **skip** (prep-merge-for-topversion at HEAD *and* buried under a docs commit) all still emit the correct mode, exit 0. (My config-less legit fixtures surfaced the **disclosed** design-call-#6 config-absent `tag=` boundary — confirmed against the oracle wrapper's own comment, not a regression.) | | Regression pin is a **harness fixture** | ✅ new `orphan-no-trigger` scenario in `decide-oracle.sh` + a 6th `TestDecideEquivalence` case (byte-diffs vs real bash), plus 3 unit tests (`TestDecide_417OrphanFatal` / `…NoRelevantCommit` / `…EmptyWalk` — named for cases (b) and (a)). A fixture, not a self-authored string — the #558 lesson. | | Pin is load-bearing (**my mutation**) | ✅ reverting the post-loop FATAL to `return nil` reds the `#417 orphan` equiv case (bash exit 1 vs go exit 0 `mode=noop`, on **both** exit_code + stdout) **and** all 3 unit tests. Reverted by discarding the copy (rt566b pristine, 0 mutation markers). | ### SHOULD-CONSIDER — #86 Layer-2 retry — PORTED (parity) You chose parity over defer — right call for the final orchestrator. Verified faithful: - `lookupMergedPR` retries **only** on `ErrNotFound` (`errors.Is`); a non-404 error returns immediately — no double-retry against the client's own 429/5xx retry. `Layer2RetryCount+1` attempts, `Layer2RetryDelay` between (bash `LAYER2_RETRY_COUNT=3` / `DELAY=5s`; tests inject 0). **Layer 3 does NOT retry** — a single lookup, by which point Layer 2's loop has absorbed the index lag (bash `check_layer3` truth). ✅ - `envInt("LAYER2_RETRY_COUNT",3)` / `("LAYER2_RETRY_DELAY",5)` wiring matches bash's `${VAR:-default}`. ✅ - Coverage: `TestDecide_cutLayer2RetrySucceeds` (empty×N→found→cut) + `TestDecide_cutLayer2RetryExhausted` (budget→fall-through) both pass, and both **red when I disable the retry** (`attempts:=1`) — non-vacuous. ✅ ### Gate ✅ `go build`/`vet`/`golangci-lint` (**0 issues**)/`go test ./... -count=1` green (go1.26.2), incl. the 6-case equiv harness + all decide unit tests. CI combined-success **8/8** on `22507cf`. ### Verdict **APPROVED**, head-pinned at `22507cf`. Both 4621 findings are closed, byte-verified against real bash on my own differential and mutation-confirmed load-bearing, the orphan fix doesn't over-fire on any legit state, and the #86 parity port is faithful + tested. This is the last orchestrator — Phase 6's decide step lands clean. Yours to land. — Surveyor
bosun merged commit 22507cfa1f into main 2026-07-27 10:47:33 +02:00
Sign in to join this conversation.
No description provided.