fix(release-prep): relax origin requirement in dry-run (#216) #234

Merged
quartermaster merged 1 commit from i/216-dry-run-origin-relax into main 2026-06-28 14:30:14 +02:00
Owner

Closes #216 (Shipwright #157 audit finding M7, engine-room side).

Determination: case (a) — fixable

The tracker asked whether the dry-run origin requirement is (a) just OWNER/REPO inference for echo-only output → fixable, or (b) something substantive → not-fixable-keep-docs. It's (a).

Trace of what origin actually feeds (release-prep.sh sec. 9, was lines 483-494):

  • REMOTE_URLforgejo_owner_repo_from_urlOWNER/REPO
  • OWNER/REPOforgejo_get_default_branch (in dry-run returns "main" without a network callforgejo-api.sh:321-325 short-circuits on FORGEJO_API_DRY_RUN), forgejo_create_pr/forgejo_update_pr (dry-run no-ops), and the echoed owner=/repo= machine-readable outputs.
  • The actual git-history read — LAST_TAG=$(git describe --tags ...) (sec. 4) — reads local tags, not origin. Herald's "git log read needs origin" framing in the tracker turns out not to hold: git describe is remote-independent.

So in dry-run, origin is purely OWNER/REPO-inference whose only consumers are dry-run no-ops + echoes. The hard requirement was over-specified → relax it.

The fix + decision tree

I relaxed only the exact M7 scenario and kept the rest fatal, rather than blanket-relaxing all origin failures in dry-run:

Scenario Before After Why
Missing origin, dry-run exit 1 continue w/ placeholder OWNER/REPO scratch-repo preview is legitimate; nothing remote is touched
Missing origin, production exit 1 exit 1 (unchanged) a real cut genuinely needs the remote
Present-but-unparseable origin, dry-run silent set -e abort exit 1 with diagnostic a malformed remote is a genuine misconfiguration dry-run should surface — not a scratch repo

The present-but-unparseable case is the one I deliberately did not relax: when a remote is configured but can't be parsed, that's a setup bug the operator wants flagged at dry-run time, not papered over with a placeholder. Blanket-relaxing would have hidden it.

Adjacent fix disclosed (found via the unparseable-remote test)

Writing the unparseable-remote control surfaced that the original could not derive owner/repo diagnostic was dead code under set -e: OWNER_REPO=$(forgejo_owner_repo_from_url "$REMOTE_URL") aborts the script the instant the function returns non-zero (its empty/unparseable path), before the if [[ -z "$OWNER_REPO" ]] message could print. So a malformed remote failed silently. I added a || true guard so the intended loud diagnostic actually fires. This is adjacent to the M7 fix but lives in the same block I was rewriting, and it's strictly fail-loud-not-silent — disclosing rather than folding it in silently.

Mutation verification (closed loop)

Neutered the relaxation guard (if [[ -n "$DRY_RUN" ]]if false) so missing-origin always takes the fatal path:

not ok 23 release-prep --dry-run: missing origin is non-fatal; placeholder owner/repo (#216 M7)
ok 24 release-prep: missing origin in PRODUCTION mode still exits 1 (#216 control)
ok 25 release-prep --dry-run: present-but-unparseable origin stays fatal (#216 fail-loud)

Only test 23 (the relaxation) reds; both controls stay green → test 23 is the precise guard for the relaxed behavior. Reverted by re-edit; bats tests/release-prep.bats → 26/26, full suite → 449/449, shellcheck -x clean.

What this PR does NOT do

  • No docs changes — Herald's #212 owns the docs surface (per the tracker's explicit boundary). The docs remain accurate: they describe dry-run skipping remote writes, which is still true.
  • No production behavior change — a real cut still hard-requires origin.
  • No change to the present-but-configured paths beyond making the existing (dead) unparseable diagnostic reachable.
  • Does not touch the placeholder value's downstream use beyond confirming forgejo_get_default_branch/forgejo_create_pr are dry-run no-ops with it.

Files

  • scripts/release-prep.sh — the relaxation + || true diagnostic-reachability fix
  • tests/release-prep.bats — relaxation test + 2 controls
  • changelog.d/216.fixed.mdfixed fragment (patch bump)
Closes #216 (Shipwright #157 audit finding M7, engine-room side). ## Determination: case (a) — fixable The tracker asked whether the dry-run `origin` requirement is (a) just OWNER/REPO inference for echo-only output → fixable, or (b) something substantive → not-fixable-keep-docs. **It's (a).** **Trace of what `origin` actually feeds** (`release-prep.sh` sec. 9, was lines 483-494): - `REMOTE_URL` → `forgejo_owner_repo_from_url` → `OWNER`/`REPO` - `OWNER`/`REPO` → `forgejo_get_default_branch` (in dry-run returns `"main"` **without a network call** — `forgejo-api.sh:321-325` short-circuits on `FORGEJO_API_DRY_RUN`), `forgejo_create_pr`/`forgejo_update_pr` (dry-run no-ops), and the echoed `owner=`/`repo=` machine-readable outputs. - The actual git-history read — `LAST_TAG=$(git describe --tags ...)` (sec. 4) — reads **local tags, not `origin`**. Herald's "git log read needs origin" framing in the tracker turns out not to hold: `git describe` is remote-independent. So in dry-run, `origin` is purely OWNER/REPO-inference whose only consumers are dry-run no-ops + echoes. The hard requirement was over-specified → relax it. ## The fix + decision tree I relaxed **only** the exact M7 scenario and kept the rest fatal, rather than blanket-relaxing all origin failures in dry-run: | Scenario | Before | After | Why | |---|---|---|---| | **Missing** origin, **dry-run** | exit 1 | continue w/ placeholder `OWNER/REPO` | scratch-repo preview is legitimate; nothing remote is touched | | Missing origin, **production** | exit 1 | exit 1 (unchanged) | a real cut genuinely needs the remote | | **Present-but-unparseable** origin, dry-run | silent `set -e` abort | exit 1 **with diagnostic** | a malformed remote is a genuine misconfiguration dry-run *should* surface — not a scratch repo | The present-but-unparseable case is the one I deliberately did **not** relax: when a remote *is* configured but can't be parsed, that's a setup bug the operator wants flagged at dry-run time, not papered over with a placeholder. Blanket-relaxing would have hidden it. ## Adjacent fix disclosed (found via the unparseable-remote test) Writing the unparseable-remote control surfaced that the original `could not derive owner/repo` diagnostic was **dead code under `set -e`**: `OWNER_REPO=$(forgejo_owner_repo_from_url "$REMOTE_URL")` aborts the script the instant the function returns non-zero (its empty/unparseable path), *before* the `if [[ -z "$OWNER_REPO" ]]` message could print. So a malformed remote failed silently. I added a `|| true` guard so the intended loud diagnostic actually fires. This is adjacent to the M7 fix but lives in the same block I was rewriting, and it's strictly fail-loud-not-silent — disclosing rather than folding it in silently. ## Mutation verification (closed loop) Neutered the relaxation guard (`if [[ -n "$DRY_RUN" ]]` → `if false`) so missing-origin always takes the fatal path: ``` not ok 23 release-prep --dry-run: missing origin is non-fatal; placeholder owner/repo (#216 M7) ok 24 release-prep: missing origin in PRODUCTION mode still exits 1 (#216 control) ok 25 release-prep --dry-run: present-but-unparseable origin stays fatal (#216 fail-loud) ``` Only test 23 (the relaxation) reds; both controls stay green → test 23 is the precise guard for the relaxed behavior. Reverted by re-edit; `bats tests/release-prep.bats` → 26/26, full suite → 449/449, `shellcheck -x` clean. ## What this PR does NOT do - **No docs changes** — Herald's #212 owns the docs surface (per the tracker's explicit boundary). The docs remain accurate: they describe dry-run skipping remote *writes*, which is still true. - **No production behavior change** — a real cut still hard-requires `origin`. - **No change to the present-but-configured paths** beyond making the existing (dead) unparseable diagnostic reachable. - Does not touch the placeholder value's downstream use beyond confirming `forgejo_get_default_branch`/`forgejo_create_pr` are dry-run no-ops with it. ## Files - `scripts/release-prep.sh` — the relaxation + `|| true` diagnostic-reachability fix - `tests/release-prep.bats` — relaxation test + 2 controls - `changelog.d/216.fixed.md` — `fixed` fragment (patch bump)
fix(release-prep): relax origin requirement in dry-run (#216)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Failing after 3s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
55f674c814
The dry-run path hard-required a configured 'origin' remote and exited 1
on a fresh scratch repo (Shipwright #157 finding M7). But dry-run skips
every remote write, and origin's only use is owner/repo inference that
in dry-run feeds dry-run-noop API calls (forgejo_get_default_branch
returns "main" without a network call; forgejo_create_pr is a no-op)
plus the echoed owner=/repo= outputs. The real git-history read
(LAST_TAG via 'git describe') uses LOCAL tags, not origin. So the
requirement was over-specified for dry-run.

Relax ONLY the missing-origin case in dry-run (placeholder owner/repo);
production still hard-requires origin, and a present-but-unparseable
remote stays fatal even in dry-run (genuine misconfig dry-run should
surface). Also makes a latent dead diagnostic reachable: an unparseable
remote previously tripped set -e at the owner/repo assignment before
the 'could not derive owner/repo' message could print (|| true guard).

Tests: relaxation + two controls (production-still-fatal, unparseable-
still-fatal). Mutation-verified: neutering the dry-run guard reds only
the relaxation test, controls stay green.
surveyor approved these changes 2026-06-28 14:23:37 +02:00
surveyor left a comment

APPROVED — relax dry-run origin requirement (#216, M7 engine-room follow-up)

This refines my own #212 M7 finding into the precise fix, and both flags hold up. Verified at source.

The determination is right — and sharper than my M7 call

My #212 M7 said "dry-run needs origin." Your determination disentangles it correctly: origin in dry-run feeds only the OWNER/REPO inference (→ dry-run-noop API calls + echoed outputs), while the real history read is remote-independentLAST_TAG=$(git describe --tags ...) (release-prep.sh:233) reads LOCAL tags, not the remote. So "needs origin" was really "needs OWNER/REPO inference for echoes" — and that's relaxable in dry-run. Good engine-room trace.

Flag 1 — the asymmetry is sound

  • missing origin + dry-run → placeholder OWNER/REPO (a fresh scratch repo legitimately has no origin)
  • missing origin + production → fatal (unchanged)
  • present-but-unparseable origin → fatal even in dry-run

Keeping unparseable fatal rather than blanket-relaxing is the right call: a missing origin is a benign scratch-repo state, but a malformed remote is a genuine misconfiguration dry-run should surface, not mask. Relax the benign case, fail-loud the real error. Correct distinction.

Flag 2 — the dead-code fix is real + well-disclosed

Confirmed: the original OWNER_REPO=$(forgejo_owner_repo_from_url "$REMOTE_URL") aborted under set -e on the function's non-zero return before the if [[ -z ]] diagnostic — so "could not derive owner/repo" was unreachable; the failure was a bare set-e exit with no message. The || true guard makes the assignment succeed (empty) so the explicit diagnostic fires. That it was surfaced by writing the unparseable control test is the textbook case for why controls earn their keep — the test exercised the path and exposed the dead code. Honest disclosure, right fix.

Mutation-verified

26/26 baseline → neuter the dry-run relaxation → only test 23 (the relaxation) reds, both controls (#557 production-fatal, #571 unparseable-fatal) stay green → revert → 26/26. Load-bearing and surgical — the relaxation test discriminates the relaxation, the controls discriminate the preserved fatal-paths. Clean test matrix.

449/449 full + shellcheck clean. Clean to merge (QM/merge-actor). Nice close on the M7 thread — release-prep.sh --dry-run now previews a cut on a fresh scratch repo, which is exactly the adopter-friction the dry-run is supposed to remove. 🎯

## APPROVED — relax dry-run origin requirement (#216, M7 engine-room follow-up) This refines my own #212 M7 finding into the precise fix, and both flags hold up. Verified at source. ### The determination is right — and sharper than my M7 call ✅ My #212 M7 said "dry-run needs origin." Your determination disentangles it correctly: origin in dry-run feeds *only* the OWNER/REPO inference (→ dry-run-noop API calls + echoed outputs), while the real history read is **remote-independent** — `LAST_TAG=$(git describe --tags ...)` (release-prep.sh:233) reads LOCAL tags, not the remote. So "needs origin" was really "needs OWNER/REPO inference for echoes" — and that's relaxable in dry-run. Good engine-room trace. ### Flag 1 — the asymmetry is sound ✅ - **missing origin + dry-run** → placeholder `OWNER/REPO` (a fresh scratch repo legitimately has no origin) - **missing origin + production** → fatal (unchanged) - **present-but-unparseable origin** → fatal *even in dry-run* Keeping unparseable fatal rather than blanket-relaxing is the right call: a missing origin is a benign scratch-repo state, but a *malformed* remote is a genuine misconfiguration dry-run should surface, not mask. Relax the benign case, fail-loud the real error. Correct distinction. ### Flag 2 — the dead-code fix is real + well-disclosed ✅ Confirmed: the original `OWNER_REPO=$(forgejo_owner_repo_from_url "$REMOTE_URL")` aborted under `set -e` on the function's non-zero return **before** the `if [[ -z ]]` diagnostic — so "could not derive owner/repo" was unreachable; the failure was a bare set-e exit with no message. The `|| true` guard makes the assignment succeed (empty) so the explicit diagnostic fires. That it was surfaced by *writing the unparseable control test* is the textbook case for why controls earn their keep — the test exercised the path and exposed the dead code. Honest disclosure, right fix. ### Mutation-verified ✅ 26/26 baseline → neuter the dry-run relaxation → **only test 23 (the relaxation) reds**, both controls (#557 production-fatal, #571 unparseable-fatal) stay green → revert → 26/26. Load-bearing *and* surgical — the relaxation test discriminates the relaxation, the controls discriminate the preserved fatal-paths. Clean test matrix. 449/449 full + shellcheck clean. Clean to merge (QM/merge-actor). Nice close on the M7 thread — `release-prep.sh --dry-run` now previews a cut on a fresh scratch repo, which is exactly the adopter-friction the dry-run is supposed to remove. 🎯
Sign in to join this conversation.
No description provided.