fix(upgrade-guard): parse the version witness as semver, and stop reading "unreadable" as "legacy" - #5650
Conversation
|
Verdict: CLOSE-SUPERSEDED — in favor of #5675, which is your own next-day rewrite of the same fix and is stronger where it counts. Requesting that three pieces unique to this PR be ported there before it merges (list below). Both PRs make the same two changes — parse the witness with
Three things here are better than #5675 and should be ported before closing:
Coordination note: #5625 (anisoptera) is still open and rewrites the same functions plus the same docs table; whichever lands first forces a manual rebase of the survivors. Your composition analysis in this PR body is the right one — carry it over to #5675, which currently references neither PR. |
|
This PR has been added to the review queue. Track progress: https://factory.gascity.com/city/maintainer-city/runs/gcg-5436965277228230 |
…ding "unreadable" as "legacy" The cross-era upgrade guard (#4907) refused five production workspaces outright: legacy Dolt server workspace detected; explicit migration is required before this bd version can open or modify the workspace. Every one was current-era. The witness was a Go pseudo-version, `v1.1.1-0.20260805093327-bf97b73749ac`, and `currentVersionWitness` split it on "." expecting exactly three fields. It got four. Measured blast radius before the operator hand-rewrote nine `.local_version` files to `1.1.0`: every `bd` invocation against five cities failed, taking every order that shells out to bd with it — one gate sweep alone logged 644 failures in 6 hours, which froze PR review gates entirely (no bead ever left `needs-review`), stalled triage, and left demand-driven pools at zero (one city ran 1 session against 8 declared agents). Rewriting the witness files took the sweep to 0 failures/6h immediately. Two defects, one in the parse and one in the policy. ## The parse `.local_version` holds `main.Version` verbatim, and release tooling injects that by ldflags: goreleaser passes `{{.Version}}`, the release workflow passes `steps.version.outputs.version`, and other build paths pass whatever they resolved. So the writer can emit a plain release, a release candidate, a build carrying metadata, or a Go pseudo-version — all valid semantic versions, none of them three bare numeric fields. bd could not read what bd wrote. The writer is right; recording anything but the true version would destroy the ordering `CompareVersions` needs. The reader was counting dots. `classifyVersionWitness` now parses with `golang.org/x/mod/semver` (already an indirect dependency) and reports an era. `legacyVersionMinor` shares the parse, so a pre-1.0 pseudo-version or release candidate is now *correctly* classified as legacy where it used to fall through unrecognized. Shapes strict semver rejects but a distributor could still stamp on a legacy build — zero-padded or four-component versions — are still read as legacy from their major component alone, so the pre-1.0 guard only tightens. ## The policy Before this change an unparseable witness meant "legacy workspace, refuse every command". That is the part that turned a trivial parse bug into a fleet outage, and it is wrong on its own terms: every pre-1.0 bd wrote a plain X.Y.Z through this same writer, so a string that fails a real semver parse is affirmative evidence *against* a legacy workspace. The guard was inferring "legacy" from data that positively excludes it, using a file the codebase treats as advisory everywhere else — `.local_version` is gitignored, clone-local, written best-effort, and `bd doctor` downgrades a bad value to a warning whose suggested fix is "run any bd command", which the guard made impossible. So a witness that is *present but unreadable* is now its own era, unknown: bd warns and opens the workspace. A *missing* witness is unchanged and still refused — a workspace that never announced itself is genuinely ambiguous — as is any witness that reads as pre-1.0. The warning is self-healing rather than permanent noise: the guard runs in PersistentPreRunE immediately before `trackBdVersion`, which rewrites the witness whenever it differs from `Version`, so the admitted command leaves a readable witness behind and the next command is silent. ## Coverage `TestClassifyVersionWitness` pins the production string, plain and v-prefixed releases, release candidates, build metadata, the pre-1.0 side of each, empty, and garbage. `TestVersionWitnessRoundTrip` pins the contract the guard depends on — everything bd can write, including `Version` itself, bd reads back and recognizes as current, which also holds the writer inside the witness reader's bounded size. `TestLegacyUpgradeGuardStillRefusesPreOneWorkspaces` and the missing- witness test prove the cross-era guard did not relax. Red before the parse fix on 15 cases across 5 tests. Refs GH#5603. That report reaches the same conclusion from a different writer — Homebrew stamps `HEAD-<shortsha>` into `main.Version` for `--HEAD` installs — and the unknown era admits those workspaces too (`TestLegacyUpgradeGuardAdmitsBrewHeadStamp`). It does not silence their warning: a HEAD stamp is rewritten identically every run, so it never heals. Silencing it wants the shape recognizer in GH#5625 (anisoptera), which this deliberately does not duplicate; the two changes compose. Co-Authored-By: Claude <noreply@anthropic.com>
The legacy-upgrade guard's witness reader collapsed a present-but-blank
`.local_version` (0-byte or whitespace-only, e.g. from an interrupted or
disk-full best-effort write) into the same ("", false) result as a
genuinely missing witness. In server mode with a local Dolt root the
`if ok` reader-gate then routed present-blank onto the missing->refuse
path, hard-refusing a possibly-current workspace as "legacy Dolt server
workspace" with no self-heal -- the exact false-refusal class this PR
removes, left open for the blank-witness shape.
Make legacyUpgradeVersionWitness report presence independently of blank
contents: a present, bounded, regular witness returns ("", true) so the
guard classifies it witnessEraUnknown and warns-and-opens (matching the
present-but-unparseable case and the documented upgrading.md contract),
while a missing, non-regular, or oversized witness stays ("", false) and
still refuses, preserving the pre-1.0 guard safety invariant.
Adds guard-level tests for present-blank (0-byte / newline-only /
spaces+tabs) -> warn+open and missing -> refuse, plus a reader-contract
test pinning present-blank->present and missing/oversized->absent.
Addresses the maintainer review's one major finding (Codex).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7d4624f to
80d5910
Compare
Maintainer Adoption ReviewThanks for the contribution, @julianknutsen! This PR fixes a production-outage-class bug in the legacy-Dolt upgrade guard: the version-witness parser was counting dots instead of parsing semver, so valid pseudo-version witnesses like This PR was reviewed and adopted with maintainer fixes pushed directly to the PR branch. Original PR ReviewDecision: request_changes resolved to approve. Specific gaps fixed:
Review findings addressed:
Non-blocking follow-ups (shared for visibility -- NOT completed in this PR):
Maintainer ChangesOne maintainer commit was pushed to the PR branch: Final Review StatusReady for the merge queue: final head CI: https://github.com/gastownhall/beads/actions/runs/32819001787 Review Iterations2 review passes performed. Iteration 1 returned request_changes on the present-but-blank major finding. After the maintainer fix, iteration 2 re-verified both original defect fixes and the legacy-guard safety property, confirmed the major was resolved, and approved -- an additional reviewer test-hygiene finding was verified against the reviewed code and refuted. Adopted via |
…mits) Conflict: cmd/bd/legacy_upgrade_guard.go - upstream gastownhall#5650 rewrote the version-witness parse as a three-way era classifier (semver-based, unreadable != legacy), superseding our bda-hcs5 suffix-tolerant parser with a richer design covering the same defect family. Resolved keeping upstream's classifier; our hcs5 pinning test re-pointed at classifyVersionWitness (clause 219) - the suffix-tolerance property it pins is preserved, with one deliberate semantics change recorded in the test ('1.2' is v1-era under x/mod semver, no longer unparseable).
The outage
The cross-era upgrade guard (#4907) refused five production workspaces outright:
All five were current-era. Their
.beads/.local_versionheld a Go pseudo-version,v1.1.1-0.20260805093327-bf97b73749ac, andcurrentVersionWitnesssplit it on.expecting exactly three fields. It got four.
Because the guard runs in
PersistentPreRunEbefore any store opens, this failedevery
bdinvocation — including the read-only ones an operator would reach for todiagnose it. Measured blast radius before the witness files were hand-rewritten to
1.1.0: one gate sweep logged 644 failures in 6 hours, which froze PR review gatesentirely (no bead ever left
needs-review), stalled triage, and left demand-drivenpools at zero — one city ran 1 session against 8 declared agents. Rewriting nine
.local_versionfiles took the sweep to 0 failures/6h immediately. That workaroundis a band-aid: any tooling that writes a pseudo-version back re-breaks the fleet.
Which side is wrong
The writer. And it isn't.
.local_versionholdsmain.Versionverbatim, and release tooling injects that byldflags — goreleaser passes
{{.Version}},.github/workflows/release.ymlpassessteps.version.outputs.version, other build paths pass whatever they resolved. So thewriter can legitimately emit a plain release, a release candidate (
1.2.0-rc.1— therepo shipped
v1.1.0-rc.1), a build carrying metadata, or a Go pseudo-version. Everyone of those is a valid semantic version; none is three bare numeric fields. bd could
not read what bd wrote.
The writer is correct and stays as it is: recording anything but the true version would
destroy the ordering
CompareVersionsand upgrade detection depend on (a pseudo-versionsorts before the release it names). The reader was counting dots. This PR fixes the
reader and pins the round trip so it cannot drift again.
Two fixes
1. Parse.
classifyVersionWitnessparses withgolang.org/x/mod/semver(already anindirect dependency; promoted to direct) and reports an era instead of a boolean.
legacyVersionMinorshares the parse, so a pre-1.0 pseudo-version or release candidateis now correctly classified as legacy where it previously fell through unrecognized.
Shapes strict semver rejects but a distributor could still stamp onto a legacy build —
zero-padded or four-component versions — are still read as legacy from their major
component alone, so the pre-1.0 guard only tightens.
2. Policy — the part that matters. Before this change an unparseable witness meant
"legacy workspace, refuse everything". That is what turned a trivial parse bug into a
fleet outage, and it is wrong on its own terms:
X.Y.Zthrough this same writer. A string that fails areal semver parse cannot have come from one. The guard was inferring "legacy" from
data that positively excludes legacy.
38-byte advisory text file."
.local_versionis gitignored,clone-local, and written best-effort (
_ = writeLocalVersion(...)).bd doctordowngrades a bad value to a warning whose suggested fix is "run any bd command to
reset version tracking" — which the guard made impossible.
population it protects against is empty, because a genuine 0.55–0.62 workspace has a
parseable witness and is already caught one branch earlier by
legacyServerVersion.So a witness that is present but unreadable is now its own era,
witnessEraUnknown:bd warns and opens the workspace. A missing witness is unchanged and still refused —
a workspace that never announced itself is genuinely ambiguous, and that is the dominant
real-world legacy shape since the file is gitignored. Any witness that reads as pre-1.0
is still refused.
The warning is self-healing rather than permanent noise. The guard runs immediately
before
trackBdVersion, which rewrites the witness whenever it differs fromVersion,so the admitted command leaves a readable witness behind and the next command is silent
(
TestLegacyUpgradeGuardWarningIsSelfHealing).Relationship to #5603 / #5625
#5603 reports the same defect from a different writer: Homebrew stamps
HEAD-<shortsha>into
main.Versionfor--HEADinstalls. Its analysis reaches the same conclusion thisPR argues — no legacy-era channel could have produced that shape — and it independently
confirms the
1.1.0-rc.1case.The unknown era admits those workspaces too, so this fixes the brew-
--HEADoutage aswell (
TestLegacyUpgradeGuardAdmitsBrewHeadStamp). It does not silence theirwarning: a HEAD stamp is rewritten identically on every run, so it never heals. That
wants the shape recognizer in #5625 (@anisoptera), which this PR deliberately does not
duplicate, along with that PR's doctor-side fixes which are orthogonal to the guard.
The two compose and both are worth landing. If #5625 lands first this reduces to the
policy commit; the parse here is a superset of
versionCore(a real semver parse ratherthan cutting at the first
-/+), so the merge is mechanical either way. Not opened tosupersede — see the review comment on #5625.
Red before
15 cases across 5 tests fail against the unfixed parser, verified by expressing the
original dot-counting predicates in the new era vocabulary. Representative:
Coverage
TestClassifyVersionWitness— the production pseudo-version, plain1.1.0,v-prefixed release, release candidates, build metadata, prerelease+metadata,
whitespace, the pre-1.0 side of each, empty, whitespace-only, garbage, binary noise.
TestVersionWitnessRoundTrip— everything bd can write, includingVersionitself,bd reads back and recognizes as current. This also holds the writer inside the witness
reader's bounded 64-byte size.
TestLegacyUpgradeGuardStillRefusesPreOneWorkspaces(0.9.1,v0.49.6,0.55.0,0.62.21, a 0.x pseudo-version,0.62.0.1) andTestLegacyUpgradeGuardRefusesWorkspaceWithoutAnyWitnessprove the cross-era guarddid not relax in either direction.
"malformed ⇒ admit with a warning". That flip is the policy change, called out here
rather than buried.
Gates
go build ./...,go vet ./...— cleanmake ci-pr-lint(gofmt + golangci-lint v2.10.1, native and cross-lintedwindows/amd64 nocgo) — 0 issues both lanes
scripts/test.sh ./cmd/bd ./cmd/bd/doctor ./internal/configfile ./internal/beads—all
ok(cmd/bd274s, the full package suite)scripts/check-doc-freshness.sh,scripts/check-doc-flags.sh— PASSNot run: the rest of
make test.cmd/bd/doctor/fixfails on this box before and afterthe change — it needs a Dolt test container (
dial tcp 127.0.0.1: connection refused) —and no other package touches the guard, the witness, or version tracking.
🤖 Generated with Claude Code