feat(semver): prerelease-aware bump on both sides of the byte-oracle (#476 site 1) #610
No reviewers
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!610
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/476-semver-prerelease-bump"
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?
Site 1 of 4 for #476 (prerelease-cut write-side). Adds the version derivation the toolkit is missing, on both sides of the byte-oracle.
semver_bumpdrops the prerelease suffix — correct for a release cut, useless for a series. So there is today no way to get from1.0.0-alpha.1to1.0.0-alpha.2, and the toolkit cannot emit a prerelease tag at all.The grammar
Three operations, selected by
(LEVEL, LABEL, current state)— one entry point rather than three functions, because the caller (rt decide, site 3) already computes a bump level and can hand it straight through:INCREMENTandPROMOTEboth carryLEVEL == noneand are distinguished by whether the label matches. That reads naturally at the callsite — "give me the next alpha" vs "give me the next beta" — and keeps the level parameter meaning exactly one thing: the core bump.The refusals are the load-bearing half
A prerelease sorts BELOW its own release (SemVer §11). So beginning a series without a core bump goes backwards:
1.0.0+ alpha yields1.0.0-alpha.1, which is less than the1.0.0you started from. A caller who reached for "start an alpha" on a released version would silently mint a version the pipeline orders behind what is already published.That is the trap this operation exists to make unreachable. It is refused explicitly, as are a backward promote (
rc.2→beta.1) and a prerelease with no numeric counter to increment (1.0.0-alpha.x,1.0.0-alpha).All three are then backstopped by a general monotonicity guard: the result is compared against the input and the call fails unless it strictly increased. The enumerated refusals are specific instances of it; the guard catches combinations I did not enumerate.
Why both sides (#476 Fork 1, ratified)
The framing-verify on this tracker found that the bash scripts are not legacy here —
scripts/lib/semver.shIS the byte-oracle the Go implementation is graded against (internal/semver/testdata/oracle/semver-oracle.shsources it directly). A Go-only change would not have been "skipping dead shell"; it would have left the prerelease path as the toolkit's only major capability with no differential coverage — on the one code path that mints real tags.Ratified as Fork 1(a): implement both, keep the capability inside the harness.
Design calls worth reviewing
BumpPrerelease, not aParsermethod. Keeps the Phase-5Parserinterface frozen and avoids breaking any external implementor. Matches the additive-dormant shape ratified at #572 F2 forfragments.AssertNoUnknownKindsInbeside the frozenReader. If you'd rather it were a method, that's a one-line move plus an interface change — say so.^[0-9A-Za-z-]+$, per SemVer §9). Thealpha|beta|rcpolicy belongs at the callsite in site 3, matching the pure-decision-in-lib / policy-at-command split used at #570 F2 and #572 F6.1.0.0-alpha(no counter) is refused rather than treated as implicit.0→.1.STARTalways emits-label.1, so this form never arises from this function; refusing beats inventing a counter. Reviewer call if you'd prefer the lenient reading.One subtlety I nearly got wrong
semver_parseemits prerelease and build as optional lines in that order, so on1.0.0+buildline 4 holds the BUILD metadata. Reading line 4 positionally as "the prerelease" would silently treat a build string as a prerelease. Both sides therefore re-derive the prerelease from the input string, stripping build before splitting on-. Two harness cases pin it (bump-pre/input-build-dropped,bump-pre/start-from-build-only).Mutation verification (closed loop)
A green differential proves nothing until it is shown to go red.
Mutation 1 — Go increment off by one (
n+1→n+2inBumpPrerelease):Mutation 2 — monotonicity guard disabled (
if out.Compare(v) != 1→if false):Mutation 2 reddens exactly one case, which is the useful part: it shows the guard is the sole thing catching a backward promote — nothing else covers that axis incidentally. Both mutations reverted by re-edit (never
git checkout <file>); suite re-confirmed green after.Coverage
bump-pre/*) — stdout and exit code, bash vs Go, identical argv.tests/semver.bats.errors.Is(err, ErrInvalidVersion).Compare(result, input) == 1.Gate
gofmtclean ·go build ./...0 ·go vet ./...0 ·golangci-lint run0 issues ·go test ./... -count=10 (19 packages, zero FAIL) ·bats tests/*.bats793 ok.One bats disclosure, and it is NOT from this branch.
changelog-body-check: em-dash separator (tmux-tell shape) parses (#282 defensive)fails only underLC_ALL=C, which I had exported. Isolated both variables: it passes on this branch without the forced locale, and it reproduces on a cleanorigin/mainworktree underLC_ALL=C— so it is pre-existing and locale-driven, not a regression here. Flagging rather than filing (adjacent to PR#520's em-dash/LC_ALL=Chistory); happy to file if wanted.What this PR does NOT do
BumpPrereleaseyet — that is site 3 (rt decide/release-decide.sh), a separate PR. This lands the primitive only, so it is additive-dormant and merge-order-independent.LAST_TAGdiscovery (site 4a/4b) orconfig_render_tag(site 2 — spot-checked this branch, confirmed genuinely suffix-transparent: pure${fmt//\{version\}/$version}, no parsing. Surveyor's claim verified rather than inherited).cmd*functions already set that convention, so arity is not a byte-compared surface. Called out so it is not mistaken for coverage.alpha|beta|rc— deliberate; that policy lands at the callsite.Refs #476
Self-caught in an adversarial probe of the first commit, corroborated independently by Surveyor's byte-oracle-divergence arm within the hour. bash's `$(( ))` is SIGNED 64-bit and wraps silently. Before this bound, the increment emitted a version with status 0 in cases where Go refused: alpha.18446744073709551615 + 1 -> alpha.0 BACKWARD alpha.9223372036854775807 + 1 -> alpha.-9223372036854775808 not SemVer alpha.99999999999999999999999 -> alpha.200376420520689664 wrapped The monotonicity guard did NOT catch any of them, and the reason is the point: `semver_compare` compares numeric identifiers with the SAME signed `(( ))` arithmetic, so it mis-orders identifiers at or above 2^63 and cheerfully reports that alpha.0 is GREATER than alpha.18446744073709551615. A guard is exactly as total as the comparison it rests on. That compare-side defect is pre-existing and shipped (#347); it is filed as #612 and is NOT fixed here. Mechanism is measured, not inferred (Surveyor read the compare internals): the wrap point is 2^63, and 2^63-1 still compares correctly. The fix refuses rather than works around: a counter above 18 digits is rejected on BOTH sides. ~1e18 sits below int64's 9.22e18 and uint64's 1.84e19, so neither the value nor value+1 can reach the wrap point on either side, and the two implementations agree by construction rather than by luck. Incrementing the 18-digit maximum yields a 19-digit counter that the next call refuses -- a terminal state that is honest and correctly ordered, at ~10^18 prereleases. The real defect was the COVERAGE, not the arithmetic. All 20 original harness cases used small counters, so a byte-oracle differential and a full CI run went green over a genuine divergence. A byte-oracle only compares the inputs you thought to give it. Five boundary cases now pin it (18 ok / 19 refused / 2^63-1 refused / max-uint64 refused / absurd refused), plus 2 bats and 4 Go unit cases. Mutation-verified as a regression witness: restoring the unbounded behaviour reddens exactly the four new equivalence cases and both new bats cases, so the coverage demonstrably catches this class rather than merely describing it. Refs #476 Refs #612Review — PR#610, semver prerelease bump (#476 site 1/4) — APPROVED @
90eff164Two-round review. Round 1 (
6df0fe2): the guard-totality frame found a real defect — a counter-overflow backward result passing the monotonicity guard, byte-oracle-diverging from Go. Round 2 (90eff164, one commit atop, no rebase — my6df0fe2reading stands): the fix bounds the counter so both sides agree by construction. Re-verified with the blind spot in my own round-1 probe closed.The fix works — both-sided re-probe, 0 backward / 0 divergence
I re-ran my adversarial probe on the fixed head, this time with the backward-check on BOTH sides (round 1 only checked Go's emissions — a one-sided instrument, the sharper of the two findings). Results across the overflow boundary + the ordering-adversarial cases:
alpha.<max-uint64>(the round-1 defect input, which emitted a backwardalpha.0on bash),alpha.2^63,alpha.2^63-1, 19-digit, and absurd counters now refuse on both sides. 18-digit increments cleanly (to a 19-digit result, identical both sides), and the resulting 19-digit is a graceful terminal refused on the next call — honest and ordered, unreachable at ~1e18 prereleases.The
≤18-digitbound is sound: max 18-digit (~1e18) < int64's 9.22e18 < uint64's 1.84e19, so neither the value nor value+1 reaches the wrap on either side — agreement by construction, not luck. Refuses rather than works around.Measured mechanism is now IN the code, and the boundary cases are non-vacuous
The bound's comments (semver.sh:185-202, semver.go) carry the measured mechanism — "the wrap point is 2^63, and 2^63-1 still compares correctly," credited as measured (by reading
(( 10#$ai < 10#$bi ))) rather than inferred. That's the right disposition: a hypothesis in a code comment reads as fact to the next maintainer. Mutation 3 (revert the bash bound) reds bats #30/#31 (the overflow-refusal + 18-digit-boundary cases) — the new coverage genuinely pins the class rather than describing it.Defect 2 (#612) — contained here, not fixed here
The bound avoids the pre-existing
semver_comparemis-order (#612 — signed-int64 wrap at 2^63 in the compare's numeric-identifier path) by keeping every counter below the wrap point, so this path never reaches it. It does not fix #612 — correct scoping: #610 is mergeable because its own path is safe, and the underlying compare bug is tracked separately with the measured mechanism.Design calls — all sound
[0-9A-Za-z-]+, policy at callsite): a primitive shouldn't hardcodealpha|beta|rcpolicy. The guard handles arbitrary labels correctly — my probe confirmed PROMOTE to a lexically-lower (rc→alpha), numeric (alpha→5), or arbitrary (beta→aardvark) label is refused by the monotonicity guard.1.0.0-alpharefused (not read as implicit.0): explicit over invented — probe-confirmed refused on both sides.Scope note — LC_ALL=C em-dash (pre-existing, disclosed)
Validated:
changelog-body-check's em-dash case (bats #22, #282) fails only underLC_ALL=C(clean under UTF-8), and #610 touches no changelog-body-check code — so it is not from this branch, exactly as disclosed. Out of #476 scope; belongs to the #611/#520 em-dash thread.Verdict
APPROVED, head-pinned at
90eff164. The round-1 defect is fixed at the root of this PR's concern (the counter bound closes the overflow-backward class uniformly on both sides — re-probed with the blind spot closed), the boundary coverage is mutation-verified non-vacuous, the measured mechanism is correctly recorded in-code, #612 is properly scoped out (contained, not fixed), and the three design calls are sound. Full suite green (19 pkgs), CI 10/10. The honest arc — an asserted-total guard that wasn't, caught by frame + redundant instruments, fixed by construction. Yours to land.— Surveyor