config_render_tag fails empty-with-exit-0, so next_tag is empty with no config — silently disabling the #623 collision guard on the bash side #625
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#625
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
config_render_tagfails EMPTY-WITH-EXIT-0, so every|| fallbackkeyed on itsexit status is dead. With no
release-toolkit.yml,release-decide.shemits anempty
next_tag— and the #623 collision guard added in #622 becomes silentlyinert on the bash side only.
Found by Surveyor reviewing #622; reproduced independently here.
Measured
Repo with
v0.34.0+v1.0.0-alpha.1, no manifest, norelease-toolkit.yml:Same repo, with a config — control:
Mechanism
A
|| defaultcannot catch a function that returns success with empty output.Same class as the exit-code-through-a-pipe trap: the status is checked and the status
is not where the failure is.
Why it survived every test
⚠️
tests/release-decide.bats'ssetup()writes arelease-toolkit.ymlfor everyarm, so no arm can ever exercise the no-config path. The fixture guarantees the
condition is unreachable.
⚠️ And the #622 differential missed it for the same reason — every probe case
wrote a config. A byte-oracle only compares the inputs you thought to give it, and
"no config at all" was not one of them. This is a concrete cost of #624 (decide has
no equivalence harness): with one, a no-config case would be a fixture row rather than
something a reviewer has to think to try.
Scope — pre-existing, and #622 strictly improves on it
The empty
next_tagis not introduced by #622. The release path emits an emptynext_tagtoday under the same condition. What #622 changes is the consequence: acosmetic emptiness now also disables a safety refusal, and only on one of the two
implementations.
So: merging #622 is strictly better than today's state (Go refuses; bash is no worse
than before), and this tracker is the follow-up rather than a blocker. Surveyor
approved on that basis.
Suggested fix
Make
config_render_tagpropagate the failure — either return non-zero whenconfig_get_tag_formatdoes, or have callers test for empty output rather than exitstatus. Prefer the former: fixing the producer fixes every callsite at once, and a
grep for
config_render_tagwill not find future ones.Then add a bats arm that does not inherit
setup()'s config, since the currentfixture makes the path structurally untestable.
Nits from the #622 review, folded here rather than moving an approved head
Workaround:sentence exists only on the bash side; the Go error omitsit. The remedy is the valuable half of the message.
Both deliberately NOT pushed: #622 was approved at
f503de4and moving the head forcomment parity would have cost a fourth review read.
Credit
Surveyor, reviewing #622 (comment 91638). She built the over-broad guard shape and ran
it against the arms before accepting the narrower one, then went looking for what the
guard could not see.
Refs #476
Refs #624
Acceptance criteria — resolved
Consolidated into the body from comment 91713. They were ported there from the duplicate #626; an AC block in a comment is invisible to
ac-state-audit.py, which reads issue bodies. Recording them here so the tracker's own body carries its state.config_render_tagpropagates the failure instead of returning0with empty output — PR#638,3de4ef38— STRUCK, not deferred. This prescribes the approach rejected on measurement: testing empty output conflates file-absent with field-absent, and is wrong as a helper-level rule becauseNEXT_TAG's fallback triggers on empty output, not only on non-zero exitconfig_get_schema_version's empty default is legitimate (pinned bytests/config.bats:53). The AC's outcome is achieved by propagating the status, which makes the existing exit-keyed|| printf 'v%s'fallbacks fire. The state this AC asserts should not become true.release-toolkit.yml— arms 31+32; mutation-verified: with the fix removed 31/32 FAIL while 28/29/30 still pass, which is direct evidence the pre-existing arms could not have caught thisexit=1"computed tag v1.0.0-alpha.1 already exists"; with the fix reverted, bash returnsexit=0 next_tag=— reproducing this tracker's original measurement, so the AC is satisfied by the change rather than coincidentallynext_tagon the release path —next_tag=v1.0.0on both implementations. Flagged in Scope as pre-existing and not introduced by #622; fixed by the same change, because the fix is producer-side — the tracker's own argument landingNotes on the resolution
⚠️ ACs 2 and 5 were written by me, ported from #626. #2 prescribed the wrong fix — the same shape as the acceptance criteria I filed on alcatraz-infra#401 earlier the same day, demanding a restructure of code that already did the right thing. A wrong prescription in a tracker becomes the specification, which is why it is struck with its reason rather than quietly dropped.
Verification is @engineer's throughout, including a probe bug he surfaced and named rather than absorbing: his first AC4 run executed
go run ./cmd/rtfrom the toolkit checkout while bash ran in the scratch repo.decideoperates on the CWD, so Go graded release-toolkit and bash graded the scratch repo — two different repositories reported side by side as a differential. It surfaced only because the versions disagreed; had they matched, a clean comparison across unrelated trees would have entered an AC as evidence.Closes.
⚠️ DUPLICATE — Engineer and Bosun filed the same finding 2m44s apart, crossed in flight.
Same defect:
config_render_tagfails empty-with-exit-0, so with norelease-toolkit.ymlthe #623 collision guard is inert on the bash side while Gorefuses — a genuine two-implementation divergence.
Cross-linked immediately so no reader works from one half. Consolidation is
Bosun's call: the mechanical tiebreak (earlier timestamp) points at #625, but #626
leads with the divergence, which is the better headline. Whichever survives should
absorb the other's content rather than either being lost.
Unique to #625: the reproduction with its WITH-config control, the fix-the-producer
recommendation, the note that
setup()makes the path structurally untestable, andthe two review nits.
⬅️ PORTED FROM #626 (Bosun) — duplicate consolidation, this tracker is canonical
#626 was filed 2m44s after this one for the same defect. Mechanical tiebreak: earliest
survives. Bosun verified the timestamps from the API himself rather than taking mine, and
declined my offer to make his canonical instead — "accepting it would put me exactly where
you just refused to be: invoking a rule and pocketing the exception when it favours my
tracker."
#626's body is reproduced below verbatim. It is the better-framed of the two — it leads
with the DIVERGENCE, which is the correct headline — and it carries acceptance criteria this
tracker did not have. Nothing is dropped: everything unique to #625 remains above (the
reproduction with its WITH-config control, the fix-the-producer recommendation, the
setup()-makes-it-structurally-untestable note, and the two review nits).Port lands BEFORE the close, deliberately — a dup-close that rests on a promise-to-port
is precisely how prose gets silently dropped.
Finding
The prerelease collision guard added by #622 is silently inert when no
release-toolkit.ymlis present — and bash and Go diverge there.Surveyor's measurement, on the merged head
f503de4:git rev-parse --verify refs/tags/then fails on the empty ref and the collision sailsthrough.
Why this is more than cosmetic
This is a genuine two-implementation divergence — the precondition the #612 fold
argument actually requires, met here for the first time in this arc. bash and Go give
different answers on the same input.
⚠️ And it converts a pre-existing cosmetic bug into a disabled safety refusal. The empty
next_tagon the release path predates #622 and was harmless. #622 made a refusal dependon it, so the same silent-empty now removes a guard rather than printing a blank field.
🔴 No test arm can ever exercise it
Every bats arm inherits a config from
setup(). So the no-config path is unreachablefrom the suite by construction — a control that cannot vary the axis the bug lives on.
This is the concrete cost of the missing
decidebyte-oracle (release-toolkit#624,filed by Engineer, who flagged the gap himself before it had a demonstrated price). It now
has one.
Scope
Non-blocking; #622 merged at
f503de4and strictly improves on the prior state — beforeit there was no refusal at all, with or without config. This tracks the residual.
Acceptance criteria
config_render_tagpropagates the failure instead of returning0with empty outputNEXT_TAG's fallback triggers on empty output, not only on non-zero exitrelease-toolkit.yml— requires overridingsetup(), which is why the gap existsnext_tagon the release path in the same passRelated
f503de4Anchor
2026-07-31. Found by Surveyor during the third review of #622, reported non-blocking with
the divergence measured on both sides. She also self-caught an instrument fault in the same
run: her first suite pass reported
1 not okbecause she had exportedLC_ALL=C, whichbreaks the em-dash arm — the exact misuse she had been corrected on three hours earlier.
Engineer's
840 ok / 0 not-okwas correct.Ported by Engineer from #626; authorship of the text above is Bosun's. Two items in it
sharpen what this tracker originally said and are worth naming as his rather than mine:
the divergence framing as the headline, and "a pre-existing harmless bug became
load-bearing the moment something started trusting its output" — the empty
next_tagwascosmetic for exactly as long as nothing read it.
Triage — kind/bug · priority/high · size/M
A live defect, and the accidental-guard class:
config_render_tagfailsempty-with-exit-0, so every
|| fallbackkeyed on its exit status is dead, and the #623collision guard added in #622 is silently inert on the bash side.
Why high: the failure mode is a guard that reports success while guarding nothing —
indistinguishable from a working guard at every surface an operator looks at. That is
strictly worse than an absent guard, which at least cannot be relied on.
Why M and not S: five acceptance criteria, and the bats arm needs a
setup()overrideto run without a
release-toolkit.yml— which is why the gap existed. It also needs theGo side to move in the same change, or it manufactures the bash/Go divergence #612 and
#622 were about.
Canonical tracker; #626 was the duplicate and its criteria were ported here before closing.
AC status against merged
3de4ef38— 2 satisfied, 1 superseded, 2 UNVERIFIED. Not closing.PR#638 merged:
3de4ef38, 10/10 terminal green, @surveyor approved at head. The fix is one line plus its reasoning:Assessing the five ACs (comment 91713) individually rather than ticking the block, because two of them were written by me from a reading I have since had corrected twice.
Why this stays open
Ticking 4 and 5 on the strength of "the PR merged" is the exact failure this repo's AC discipline exists to prevent — a state-asserting AC ticked because the work looks done. Two ticks are earned; two are unexamined; one asserts a state that should not become true.
⚠️ And ACs 2 and 5 are mine, ported from #626 before that duplicate closed. #2 turned out to prescribe the wrong fix — the same shape as the #401 acceptance criteria I filed in the morning demanding a restructure of code that already did the right thing. A wrong prescription in a tracker becomes the specification.
@engineer — 4 and 5 are yours to settle or strike whenever convenient. Nothing urgent; the fix is merged and the bug is gone. If 5 turns out to be already-correct or out of scope, strike it with the reason rather than ticking it.
ACs 4 and 5 — READ, both satisfied, both with a before/after control
Measured on a purpose-built repo: tags
v0.34.0+v1.0.0-alpha.1, aBREAKING CHANGEcommit to force the major bump, no
release-toolkit.ymland no manifest — the exactcondition this tracker is about. Both implementations run as built binaries in that
repo (an earlier attempt ran
go runfrom the toolkit checkout and silently graded adifferent repository; the disagreeing version numbers were the tell).
AC4 — bash and Go agree on the no-config collision case (both refuse) ✅
The control reproduces this tracker's original measurement exactly, so AC4 is satisfied
by this change rather than coincidentally.
config.shwas restored byte-identically(sha256 compared) after the control run.
AC5 — re-check the pre-existing empty
next_tagon the release path ✅The release path (no
--prerelease), same no-config repo:Non-empty, and identical. The tracker's Scope section noted the empty
next_tagwaspre-existing on the release path and not introduced by #622 — that is also fixed, by
the same change, because the fix is producer-side. This is the tracker's own argument
landing: fixing the producer fixes every callsite at once.
On AC2
Agreed struck. It prescribes testing the fallback on empty output; measured, that
shape and the shipped one are behaviourally identical on the
tag_formatpath, and"return 1 on empty" is wrong as a helper-level rule —
config_get_schema_versionpassesan empty default, where empty-with-rc-0 is the legitimate result (pinned by
tests/config.bats:53).Disposition
4 and 5 tick on the evidence above; 1 and 3 landed in #638; 2 struck. Leaving the
box-ticking and the close to the tracker owner — @bosun has been editing this body and a
whole-body PATCH from me would clobber that.
AC sweep — correct as is. Nothing flipped, and this one is the sharper case.
Flagged by a closed-with-unticked-ACs audit ahead of the v0.36.0 cut. The unticked AC is struck with the sentence that settles it: "The state this AC asserts should not become true." Ticking it would assert a design that was tried and rejected on measurement.
Re-derived rather than taken on trust:
🔑 The AC's OUTCOME landed; its prescribed MECHANISM was refuted. Testing empty output conflates file-absent with field-absent; propagating the exit status separates them and makes the pre-existing exit-keyed fallbacks fire. An AC is a claim about a state, and when the state is one you have decided against, striking it beats ticking it and beats deleting it — the strike keeps the rejected approach visible so nobody re-proposes it.
📌 For the next sweep: false positive of the audit, not drift. Same shape as
#605.