Skip to content

relctl: one implementation of the versioning rule, shared by humans and CI - #655

Merged
Philip Lombardi (plombardi89) merged 31 commits into
mainfrom
feat/relctl
Aug 25, 2026
Merged

relctl: one implementation of the versioning rule, shared by humans and CI#655
Philip Lombardi (plombardi89) merged 31 commits into
mainfrom
feat/relctl

Conversation

@plombardi89

@plombardi89 Philip Lombardi (plombardi89) commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Problem

Release state has no single place to look. RELEASING.md said so itself: "There is no single dashboard." Whether main is releasable, which candidate trains are live, whether a release will be marked Latest, where one has got to across three workflows - each of those needed a different gh invocation, or could not be answered at all.

The versioning rule deciding all of it lived in ~500 lines of shell that only CI ever called. Building a CLI on top of that would have put the rule in two places, and the two would drift.

Solution

The rule moves into Go and the pipelines call that, so humans and CI share one implementation instead of agreeing by convention.

relctl is the result. status, next, preflight, classify and watch answer questions about a release; cut, rc, promote, branch create, soak and publish drive one. The shell it replaces is deleted rather than left alongside.

gh stays authoritative: the workflows are the interface and relctl is a convenience over them. The one exception is version resolution, where release-prepare invokes relctl next itself, so the tool and the pipeline agree by construction rather than by review.

RELEASING.md is reorganised around the two questions people actually arrive with - how to cut a minor or major, and how to cut a patch for a series that already shipped - with the reference material behind them.

RELEASING.md:150 says of deciding whether main is releasable: "There is no
single dashboard." Nor is there one for what is in flight, which trains
are live, or where a release has got to. This is the start of one.

Auth prefers an existing gh login over a provisioned token. Every
maintainer who can cut a release already has gh working, and requiring a
PAT to run a read-only status would be the friction that stops a tool
being used. GITHUB_TOKEN and GH_TOKEN are checked first, because a
workflow always has one and may not have gh installed at all; a test
pins that order, since reversing it would work locally and fail only in
CI.

The client is built per command rather than at the root. Version
resolution is pure git and has to keep working with no credential, in a
clone or in a workflow that was never granted one.
The versioning rule moves first because it is the smallest piece and the
one everything else depends on: main cuts minors and majors, release-X.Y
cuts patches, and nothing else may release at all.

Two test suites, and the distinction matters. The ported table cases are
the coverage, and have to keep protecting this once the shell is gone. On
top of that, a differential test runs both implementations over 62
inputs and asserts they agree on the answer and on whether they refused
at all. That corpus is deliberately wider than either implementation's
own cases, since the cases encode what we thought to test and the point
is to catch what we did not: unicode digits, embedded newlines, prefixed
and suffixed branch names, a nine-digit ceiling.

The differential test is temporary and goes when the shell does. Proved
it bites first: relaxing the regex to allow leading zeros fails exactly
on release-01.2, which is the case the shell refuses because v01.2.3 is
not a version and that series could never match a tag.
408 lines of bash that decide which tag a release mints and at which
commit. Ported function by function rather than rewritten, because the
comments record why each rule exists and several of them are scar
tissue: rc.18 losing to rc.9 under a lexical maximum, rc.08 read as
octal and silently skipped, promote guessing between trains and
orphaning v0.1.24 at rc.18.

Two suites, and the distinction is the whole plan. resolve_test.go
carries all 76 fixtures from next-version-test.sh and is the coverage
that has to keep protecting this once the shell is gone. The
differential test is a temporary equivalence proof and goes with it.

The cases were not transcribed. bash parsed its own suite's expect
invocations and emitted the Go table, so the fixtures and expectations
are the shell's, not my reading of them. All 76 passed against the Go
implementation on the first run.

The differential test compares 345 resolutions: the 76 ported fixtures,
62 branch-policy inputs, and 207 generated combinations of finals,
candidates, off-branch tags and malformed metadata across every mode and
bump. The generated half matters more than the ported half. The written
cases encode what we expected to matter; a port goes wrong in the shapes
nobody considered.

Proved it bites before trusting it. A minor bump that forgets to reset
patch fails three cases; dropping reachability scoping from tag
discovery fails the release-branch cases, which is exactly the property
that keeps a release branch inside its own series.

git is behind an interface so the resolver is a pure function of a tag
set, and Repo.TagExists is deliberately repository-wide while tag
discovery is reachability-scoped. Those answer different questions: one
is "is this name taken anywhere", the other "what has this branch
released". Collapsing them is how a train gets started on a core that
can never be promoted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the full diff against both shell originals, the workflow that still calls them, the NOTICE collector and the lint config.

The port is faithful. I walked every branch of next-version.sh against resolve.go/modes.go and found no divergence in the resolved tag or base. Report ordering matches, mode dispatch matches, the validation sequence (shape, series, tag-exists, ancestry, describe) matches, and both pieces of scar tissue survive intact: numeric max_rc, and repository-wide has_final kept distinct from reachability-scoped discovery.

The findings below are not in the port. They are in the evidence, and in what survives the oracle's deletion.

Three I would want settled before the shell goes:

  1. 34 of the 76 permanent cases assert only that an error happened. branch_test.go matches on the message; the resolve cases do not. That is 45% of the suite that would pass if every refusal collapsed into one.
  2. Live, Stale, LatestFinal, Report and Warnings have no assertions in either suite. The differential compares Tag and Base only. The live/stale model is what the rest of the CLI is built on, and it is the exact thing the shell got wrong with the v0.1.24 orphan.
  3. The differential runs the shell with os.Environ(). next-version.sh reads BUMP/SERIES/PRE/VERSION/ALLOW_CONCURRENT_TRAINS with :- defaults, and request() builds the Go side from tc.env only. "345 comparisons, all agreeing" holds on a clean environment; the generated 207 set only BUMP=.

A fake Repo in resolve_test.go is the cheapest route: it closes 1 and 2, removes the ambient-config exposure in the first inline comment, and cuts the local test loop substantially. The differential genuinely needs real git; the permanent suite does not.

The gh package landing ahead of its callers reads as deliberate (dependency approval settled early, command commits stay small). Noting it only so a later reviewer does not re-raise it.

Nits not worth their own thread: relctl.go:32 defer stop() is unreachable before os.Exit(1); the strconv.Atoi error branch in prerelease is dead since preShape already bounds to nine digits, and unlike parseCore it does not say why it is kept; the NOTICE commit message says it picks up "go-github and its go-querystring transitive", but hack/cmd/notice/internal/gomod/gomod.go:35 is direct-deps-only and one entry was added, so the file is right and the message is not; SplitRepo defends the URL paste explicitly but accepts Azure/un bounded; Options.BaseURL is documented "for tests" with no test using it.

Worth considering putting the differential behind a build tag or testing.Short(). It is a temporary equivalence proof and it is currently paid for on every local make test, which also implies lint.

Comment thread hack/cmd/relctl/relctl/version/resolve_test.go Outdated
Comment thread hack/cmd/relctl/relctl/version/differential_resolve_test.go Outdated
Comment thread hack/cmd/relctl/relctl/version/resolve_test.go
Comment thread hack/cmd/relctl/relctl/version/resolve.go
Comment thread hack/cmd/relctl/relctl/version/resolve.go
Comment thread hack/cmd/relctl/relctl/version/git.go Outdated
Comment thread hack/cmd/relctl/relctl/version/git.go
Comment thread hack/cmd/relctl/relctl/version/branch.go
Comment thread hack/cmd/relctl/relctl/version/modes.go
Comment thread hack/cmd/relctl/relctl/version/resolve.go Outdated
notice-check runs in CI, so adding a module without this turns the build
red. One entry: hack/cmd/notice collects direct dependencies only, so the
transitive go-querystring does not appear.
Review found the evidence weaker than the port. Two gaps, both in what
survives the oracle's deletion rather than in the resolution itself.

34 of the 76 cases asserted only that an error happened. That is 45% of
the suite passing if every refusal collapsed into one message, and the
reasons ARE the behaviour here: refusing a core whose final exists off
this branch is a different fact from refusing a version outside its
series. Each refusal now names its reason, and a guard test pins the
number of DISTINCT reasons, because per-case substrings alone would not
notice them converging.

Live, Stale, LatestFinal, Report and Warnings had no assertions at all.
That is the model status, prerelease and promote are built on, and the
one the shell historically got wrong: v0.1.24 reached rc.18 and was
orphaned when a v0.2.0 train started beside it. trains_test.go asserts
it directly, including that shape.

The suite now runs against a fake Repo rather than real git. That is
what makes the train model assertable without scraping output, drops any
dependence on the developer's git config, and takes the loop from 630ms
to 6ms. The fixtures keep their meaning: the fake is built from the same
@new and @off specs.

A fake the suite trusts is a fake that can drift, so the next commit
makes git prove it.
Two things the previous commit left owing.

The permanent suite now runs against a fake Repo, so nothing checked the
fake against git. The differential is the only other code touching real
git and it is deleted at the same time as the shell, which would have
left the suite testing the fake rather than the resolver.
TestFakeRepoMatchesGit resolves the same fixtures through both and
requires the same tag, latest final, live and stale, and the same base
IDENTITY. Hashes cannot be compared, so the base is compared by what it
points at: HEAD, or the tag whose commit it is. Confirmed it bites by
making the fake ignore reachability, which is the drift that would
matter, and it fails.

The differential ran the shell with os.Environ(). next-version.sh reads
five variables with `:-` defaults while the Go side is built from the
case alone, so the two implementations were being fed different inputs
and "345 comparisons agreeing" was a statement about my shell. Exporting
SERIES=0.3 made 33 cases disagree. Every variable the script reads is
now set explicitly, defaulted to match Request, with only PATH and HOME
inherited. It passes with SERIES, BUMP, PRE, VERSION,
ALLOW_CONCURRENT_TRAINS and MAJOR all set hostilely.

The proof also compared the tag and base only, which left the train
model outside it. It now parses Latest final, Live trains and Stale
trains from the shell's report and compares those too. That is not
cosmetic: swapping live and stale does not change the computed tag, so
the old differential could not have seen it. It now fails 77 cases.
From review:

  - defer stop() sat above an os.Exit(1), which skips defers, so the
    signal handler was left uninstalled on every failing invocation. The
    work moves into run() returning an exit code.
  - The strconv.Atoi branch in prerelease is unreachable, since preShape
    already bounds the suffix to nine digits with no leading zero. Kept,
    but it now says why, matching how parseCore treats the same
    situation. Silence was the actual complaint.
  - SplitRepo defended against a pasted URL but accepted "Azure/un
    bounded". It now validates the shape GitHub allows, so a mistyped
    argument fails there rather than as a 404 that reads like a
    permissions problem.
  - Options.BaseURL was documented "for tests" and used by none. An
    httptest case now exercises it, which is what would notice if
    go-github changed how a base URL is applied; without it the first
    symptom would have been a suite quietly talking to the real API.

The exit-code work turned up something the review did not: `relctl
bogus-subcommand` printed help and exited ZERO. cobra skips argument
validation entirely for a command it cannot run, so Args: cobra.NoArgs
did nothing on a root with no RunE. A mistyped release command looking
like success is worse than any of the above, and it is the sort of fault
nothing notices, since the tool appears to work having done nothing.
Root now has a RunE, and three tests pin unknown-subcommand, bare
invocation and the --repo default.
@plombardi89

Copy link
Copy Markdown
Collaborator Author

All three settled, plus the nits. Verified each finding before acting on it.

1. 34 refusals asserting only that an error happened

Confirmed: 34 of 76, 44%. branch_test.go matched on messages; the resolve cases did not.

Every refusal now names its reason. On its own that is not enough — per-case substrings would not notice the reasons converging — so TestEveryRefusalNamesADistinctReason pins the number of distinct reasons, currently 19 against a floor of 15. It is a floor, not a target.

2. No assertions on the train model

Confirmed: both suites asserted Tag and Base only.

trains_test.go now asserts LatestFinal, Live, Stale, Report and Warnings directly, including the v0.1.24 shape you named: candidates exist, no final was cut, a newer series shipped, so it must report as stale rather than live.

I also extended the differential to compare the state model, not just the Go suite. That turned out to matter more than expected — swapping live and stale does not change the computed tag, so the old proof could not have seen it. It now fails 77 cases.

3. The differential inheriting the environment

Confirmed, and it reproduces exactly as you describe:

$ SERIES=0.3 go test -run TestResolveMatchesTheShell
33 cases FAIL

Every variable the script reads is now set explicitly, defaulted to match Request, with only PATH and HOME inherited. Both sides see the same inputs by construction.

The proof:

clean env    ok
SERIES=0.3 BUMP=major PRE=rc.9 VERSION=v9.9.9 ALLOW_CONCURRENT_TRAINS=true MAJOR=true    ok

Since fixing input plumbing is exactly the change that can neuter a test, I re-confirmed it still bites afterwards: a minor bump that forgets to reset patch fails 31 cases.

The fake Repo, and the gap it opened

Taking your suggestion closed 1 and 2 and dropped the permanent suite from 630ms to 6ms. It also created a hazard worth naming: with the permanent suite on a fake, and the differential — the only other code touching real git — deleted alongside the shell, nothing would check the fake against git. The suite would become a test of the fake.

TestFakeRepoMatchesGit closes that: same fixtures through both, requiring the same tag, latest final, live and stale, and the same base identity. Hashes cannot be compared, so the base is compared by what it points at, HEAD or the tag whose commit it is. Confirmed it bites by making the fake ignore reachability, which is the drift that would matter.

Nits

All four taken. defer stop() was the real one: it sat above os.Exit(1), which skips defers, so the signal handler leaked on every failing invocation. Atoi stays but now says why, matching parseCore — the silence was the complaint. SplitRepo validates the shape GitHub allows. BaseURL has an httptest case, which is what would notice if go-github changed how a base URL is applied.

That work turned up something worse than any nit. relctl bogus-subcommand printed help and exited zero: cobra skips argument validation entirely for a command it cannot run, so Args: cobra.NoArgs did nothing on a root with no RunE. A mistyped release command looking like success is the sort of fault nothing notices, because the tool appears to work having done nothing. Fixed, with tests.

Not taken

Gating the differential behind a build tag or testing.Short(). It is 3.6s against 6ms, and it is deleted in a few commits — but making a correctness proof run less often is the wrong direction, and a build tag would need a CI change for something short-lived. Happy to revisit if it becomes annoying.

The NOTICE commit message was wrong, as you spotted: gomod.go:35 is direct-deps-only and one entry was added. Amended and force-pushed with the author's sign-off, since this repo requires that for a rewrite.

You are right that the gh package landing ahead of its callers is deliberate — dependency approval was settled early so the command commits could stay small. Confirming so it does not get re-raised.

State

501 assertions in version/, 0 skipped. make lint 0 issues, CI=1 make test green, notice-check up to date.

classify-release.sh answers two questions that are not the same
question, and an earlier design answered both with one version
comparison and got the first one wrong.

  FromMain  provenance, not ordering. unbounded-stable soaks main and
            only main. Ordering cannot answer this: the cluster can be
            running a candidate newer than the newest final, so an
            ordering test says deploy when the honest answer is that a
            release-branch release has no business there at all.

  Latest    ordering, because Latest is what releases/latest/download
            resolves to, which is the install command in README.md and
            every guide.

The Latest half needs true semver precedence, which is why the shell
delegated it to hack/cmd/semver: sort -V ranks v1.0.0 BELOW v1.0.0-rc.1
where clause 11.3 requires the opposite. That tool has no other caller,
so it is absorbed here and deleted with the shell. It uses x/mod/semver,
already a dependency.

Deliberately NOT reusing greaterFinal, which refuses prereleases for
exactly that reason. Both ends now say so, because relaxing greaterFinal
later to "simplify" the two into one would silently invert the resolver.

Repo grows AllTags alongside ReachableTags. They answer different
questions and conflating them breaks in both directions: discovery must
be reachability-scoped so a stray tag on someone's branch cannot drive
the numbering, and the Latest query must NOT be, because a release
branch's own tags are invisible from main and are exactly what could
outrank it. Mutating the second one away fails the backfill cases in
both suites, which is the bug that flips the Latest marker backwards.

The tag pattern stays looser than the resolver's, accepting alpha and
beta: this classifies what was actually tagged, and a historical tag
must remain readable rather than merely unfashionable.

7 ported cases plus refusals, and an 18-comparison differential. The
oracle needed a prebuilt semver binary rather than the script's `go run`
default, which resolves against the working directory and would look for
a module inside the fixture.
status, next, preflight and watch. RELEASING.md:150 says of deciding
whether main is releasable: "There is no single dashboard." preflight is
that; status is the same for the release itself.

The hard part is watch. Three workflows are involved and only the first
names the tag: a tag push sets release.yaml's head_branch to the TAG, so
those runs are identifiable. release-upgrade fires on workflow_run,
reports the default branch, and the API exposes no link back to what
triggered it.

Matching on head_sha alone is ambiguous, and not theoretically: a
promoted final and its last candidate share a commit. v0.3.0 and
v0.3.0-rc.1 are both 3c9621d, as are v0.2.4 and v0.2.4-rc.1. Both tags
therefore produce build runs with that sha, and so do their soaks. So
the sha narrows server-side and a TIME WINDOW disambiguates: a soak
belongs to this build if it started after it and before the next build
of the same commit. Manual workflow_dispatch retries land inside the
window, which is right, since they are attempts at the same soak.

Tested against a stub that filters the way GitHub does, because a stub
ignoring head_sha would exercise a path that cannot happen and hide one
that can. Removing the time window fails those cases. Verified against
the real repository too: watch v0.4.0 resolves build 32747498643 and
soak 32749611291, which are the runs it actually used.

next carries a github output format emitting byte-identical
tag=/base= lines, so release-prepare can call it in place of the shell
without its downstream steps changing. A test pins that to exactly two
lines: anything extra lands in $GITHUB_OUTPUT and can corrupt it.

The GitHub client is built per command rather than at the root, and a
test pins that next works with no credential at all. Version resolution
is pure git, and it has to keep working in a workflow that was never
granted a token.

Running status against the repository turns up 24 draft releases going
back to v0.1.17, each a release that built and never shipped. Reported
with that explanation rather than as a bare list.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass, over e14bb31e..1da4c2a6.

The previous round was addressed properly

Prior finding Status
Error identity Fixed. All 34 refusals carry wantErr, plus TestEveryRefusalNamesADistinctReason as a meta-guard. That guard is a better idea than what I asked for: per-case substrings alone would not notice the reasons converging.
Live/Stale/Report untested Fixed. trains_test.go asserts the model directly, and the differential now parses the shell's stderr report and compares it too. "Swapping live and stale does not change the computed tag" is the right observation, and 77 cases now fail if you do.
Env leak in the differential Fixed, and verified rather than assumed. "Exporting SERIES=0.3 made 33 cases disagree" establishes the bug was real, not theoretical.
Repo interface unused Fixed, and better than asked. The fake landed with TestFakeRepoMatchesGit, comparing base by identity rather than by hash. I did not ask for the conformance test; it is the thing that makes the fake safe to trust, and mutating reachability out of it to prove it bites is the right check.
Nits defer stop(), the Atoi branch, SplitRepo, BaseURL all handled. The cobra NoArgs bug found along the way is a worse fault than anything I raised: a mistyped release command exiting zero.

One prior finding is only partly closed, and two are now load-bearing in a place they were not before. Both are inline.

New findings

The two that matter are classify.go failing open toward Latest = true on a git error, and watch never ending on a failed soak. Both inline.

Behind those, a coverage question. cmd_status.go, cmd_preflight.go, cmd_watch.go and gh/releases.go are 767 lines with no test files, and relctl.Options has no seam to add them through. Given this PR spent three commits making the resolver's evidence hold up, the command layer arriving untested stands out.

Nits

runSummary.Ref holds a head branch for builds and an event name for soaks under one JSON key, so a consumer sees "ref": "workflow_run". watchVerdict compares State != "success" stringly when Run.Succeeded() exists, and State() returns "completed" for a done run with no conclusion, which would render as a build failure. runNext takes both out and cmd when out is cmd.OutOrStdout(). sort.Slice appears twice more in gh/runs.go. baseLabel hand-rolls strings.TrimSuffix to avoid importing strings.

Hermeticity is still open, though narrower than before: TestResolve moved off git, but fixture (resolve_test.go:84), classifyFixture.build and tagRepo are three copies of the same builder and none neutralises ambient git config. git_test.go and both classify suites are permanent and still touch real git, so commit.gpgsign=true still fails them. hack/release/next_version_test.go remains the model.

One question for the pipeline switch

next --output=github emits tag= and base= only, and TestNextGitHubOutputMatchesTheWorkflowContract pins exactly two lines. But release-prepare.yaml reads steps.bump.outputs.bump in its Push tag step and steps.bump.outputs.series in Compute tag. Deleting bump-for-branch.sh needs both keys, and next already computes them and exposes them in the JSON output. Worth deciding now whether next replaces both scripts or only next-version.sh, since the two-line assertion will resist the answer.

Comment thread hack/cmd/relctl/relctl/version/classify.go
Comment thread hack/cmd/relctl/relctl/version/git.go
Comment thread hack/cmd/relctl/relctl/cmd_watch.go Outdated
Comment thread hack/cmd/relctl/relctl/relctl.go
Comment thread hack/cmd/relctl/relctl/cmd_next.go
Comment thread hack/cmd/relctl/relctl/cmd_status.go Outdated
Comment thread hack/cmd/relctl/relctl/gh/releases.go Outdated
Comment thread hack/cmd/relctl/relctl/gh/runs.go
Comment thread hack/cmd/relctl/relctl/version/resolve_cases_test.go Outdated
Comment thread hack/cmd/relctl/relctl/version/resolve_test.go Outdated
Review found two ways a failure produced an answer instead of an error,
and the direction is what makes them different.

Classify marked a release Latest when the tag query broke. ReachableTags
and AllTags both returned nil, nil on any git error, so an empty
candidate set reached the "nothing has shipped" branch and set Latest =
true. That is what releases/latest/download resolves to, which is the
install command in README.md and every guide.

The tool this port absorbed refused exactly that case, and said why:

  An entirely empty stream means the caller's `git tag` produced
  nothing, which is a broken invocation rather than an answer, and
  answering "false" would let a release deploy on the strength of a
  failed command.

So the port collapsed two states the original deliberately kept apart,
in a change whose whole justification was fidelity. They are apart
again: the tag being classified is itself a tag, so an empty result
cannot be honest, and "tags exist but none is final" remains a real
answer.

Elsewhere a swallowed git error fails toward refusing, because ancestry
catches it. Here it failed toward shipping.

The swallows are gone rather than papered over. Checked the exit codes
rather than assuming: `git tag --merged HEAD --list` exits 0 with empty
output when nothing matches and 128 only for an unborn HEAD or a missing
repository, and `git tag --list` exits 0 even with no commits at all. So
there was no legitimate absence being absorbed. TagExists keeps the one
real distinction, since rev-parse exits 1 for a merely missing tag and
128 for a broken one; reporting the latter as absence would let validate
fall through its duplicate-tag guard.

watch never ended on a failed soak. It is the soak that publishes, so a
failed one leaves a draft forever, and Done was read from !Draft: the
loop polled every twenty seconds until the ninety minute timeout with
nothing running, then reported "gave up waiting". watchVerdict already
knew the answer and never got to run. The terminating conditions are now
a pure function, so they are testable without a client, and only the
NEWEST soak decides - an earlier failure followed by a running retry is
a recovery in progress.

Mutation-tested all four, and the third attempt found a gap worth the
trouble: the resolver failure tests use a fake Repo, so restoring the
swallow in GitRepo changed nothing. Nothing exercised GitRepo's own
error handling. TestGitRepoReportsQueryFailures does, against a
directory that is not a repository, and it fails when either swallow
comes back.
The Go port grew three copies of the same builder and none of them
neutralised the ambient git configuration. hack/release/next_version_test.go
solved this for the shell suite and said why; the port dropped the guard
and reintroduced exactly the problem it describes.

Reproduced before fixing. With commit.gpgsign=true set globally:

  fatal: failed to write commit object
  FAIL

That is 76 resolver cases, the classify suite and hundreds of
differential comparisons going red at once, on a maintainer's machine
only, for a reason nothing in the output points at.

One builder now, with GIT_CONFIG_GLOBAL and GIT_CONFIG_SYSTEM pointed at
/dev/null and the identity supplied through the environment rather than
`git config`, so a fixture needs no configuration at all and cannot
inherit a template or hooks path before its first commit. Verified
against a config setting commit.gpgsign, tag.gpgSign, core.hooksPath and
init.templateDir together: both packages pass.
Review: cmd_status.go, cmd_preflight.go, cmd_watch.go and gh/releases.go
came to 767 lines with no test file between them, and Options had no way
to point a client anywhere but api.github.com. That was the reason for
the split rather than a coincidence: next is the one command needing no
client, and next was the one command with tests.

Options gains a hidden --base-url, so the three command tests can aim at
an httptest server. Hidden because it is a test seam and not a supported
route to a GitHub Enterprise instance; nothing else here is written for
one.

Eleven cases now cover status, preflight and watch, including the two
that encode release policy: a red nightly blocks, and a green one older
than 48 hours blocks, because "green recently" is the actual
requirement and a week-old pass describes a tree nobody has released
since. The stub filters by branch, head_sha and status the way GitHub
does, so the tests exercise the paths that can happen rather than ones
that cannot.

Separately, both commands rendered a local failure as a fact. `if err ==
nil` left LatestFinalTag, Live and Stale at their zero values, and the
text output printed "Live trains:  (none)" and dropped the stale row.
"I could not tell" and "there are none" are different answers, and for
the command whose whole purpose is being the dashboard the second is the
worse one to guess. Ordinary causes are a stale checkout, a wrong
--repo-path, or running outside a clone entirely.

NextFromMain is renamed NextFromLocal, because it is resolved against
whatever is checked out. On a release branch it was reporting that
branch's next version under a label saying main.
All three are cases of a query that looks like it answers a question and
answers a narrower one.

Drafts fetched a single page and filtered it in Go, then status printed
the count as though it were a total. It was really "drafts among the most
recent 30 releases", and this repository already has 24 drafts in that
window, so the two answers were about to diverge. Paginated, and the
limit argument is gone rather than raised, because no caller wants a
partial list of these.

inFlight fetched ten runs per workflow and discarded the finished ones,
so ten completed runs newer than a still-running one hid it entirely.
That is not hypothetical: concurrent tag pushes produce exactly that.
ListWorkflowRunsOptions has a Status field, so in_progress and queued are
now filtered server-side.

ListRuns.Limit was documented as capping how many are returned but wired
to per_page with no pagination, so Limit: 200 silently became 100.
Renamed for what it is and capped, so it cannot quietly return fewer
results than asked for. Nothing wants the whole history; every caller
wants a recent window.

Also a correlation edge the design leaned on and the tests did not reach.
created_at has second resolution, so two builds of the same commit within
one second both failed the After test, leaving the window open-ended and
merging both builds' soaks into one report. Run IDs are monotonic and now
break the tie. Confirmed the case fails without it.

sort.Slice replaced with slices.SortFunc while here.
…cripts

Two changes that the pipeline switch depends on.

--branch was policy only and never checked against the checkout. Tag
discovery is separately scoped by reachability from local HEAD, so the
two are independent inputs, and the default was main regardless. On a
release-0.3 checkout the bare command therefore applied main's
minor-bump policy to that branch's history and answered confidently.

It now defaults to the checked-out branch, and warns when an explicit
--branch disagrees rather than refusing, since resolving a hypothetical
is legitimate and the workflow always passes one. A detached HEAD asks
for --branch rather than guessing, which is what a CI checkout of a tag
looks like. On a feature branch the bare command now refuses outright,
which is the correct answer and was previously a confident v0.5.0.

The github output emits all four keys. release-prepare reads bump= and
series= from bump-for-branch.sh and tag=/base= from next-version.sh;
relctl computes the policy internally in order to resolve at all, so
emitting it separately would be reporting a value it already had. One
call replaces both scripts and the workflow loses a step. series is
written even when empty, because a missing key and an empty one differ
to whatever reads it.

The contract test goes from two lines to four, and caught something
while doing it: the fixtures ran `git init` with the global config
neutralised, so init.defaultBranch was unset and git created master.
Every fixture was a branch the tool would refuse. Now explicitly main,
which is what they stand in for.
Straightforward first: the mangled apostrophe from the scripted edit;
cmpInt and contains replaced with cmp.Compare and slices.Contains;
sort.Slice with slices.SortFunc; a local named max that shadowed the
builtin; baseLabel's hand-rolled TrimSuffix; runNext taking both out and
cmd when one is derived from the other.

The distinct-reason floor was 15 against a table naming 19, so four
reasons could have merged into an existing message and the guard written
to catch that would not have. It is the number the table actually names.

runSummary.Ref carried a head branch for builds and an event name for
soaks under one JSON key, so a consumer saw "ref": "workflow_run". Two
different facts, now two fields. While there, the verdict is carried
rather than re-derived by comparing State against "success": a completed
run with no conclusion renders as "completed", which that comparison
would have read as a build failure.

Two the port dropped rather than changed, both invisible when wrong.

The shell wrote ::warning:: and ::error:: directly, which is what makes a
line an annotation in the run summary. A library returning a Warnings
slice should not embed them, so they are added at the point of rendering
when GITHUB_ACTIONS is set. Miss this and "cutting vX.Y.Z while <train>
is still in flight; that train will be stranded" simply stops appearing
where a human would have seen it, and nothing fails. In the github output
format they go to stderr, so they annotate without landing in
$GITHUB_OUTPUT.

candidateCommit's error lost the tag listing the shell included. During
an incident that listing is the useful half: it says what IS there, which
is usually a malformed or legacy suffix that discovery declined to treat
as a candidate.
@plombardi89

Copy link
Copy Markdown
Collaborator Author

All of it, across six commits. Verified each finding before acting on it.

The two that mattered

classify marked a release Latest when git failed. You were right that it was reachable through a partial failure, and right about the direction being the problem. The part I'd add: the tool this port absorbed refused that case deliberately, with a comment saying why, and the port collapsed the two states. A change whose whole justification was fidelity lost a property the original had.

Fixed in ae8754a8. The tag being classified is itself a tag, so an empty candidate set cannot be honest; "tags exist but none is final" remains a real answer.

watch never ended on a failed soak. Fixed. The terminating conditions are now a pure function so they're testable without a client, and only the newest soak decides — an earlier failure followed by a running retry is a recovery in progress.

The swallows are gone rather than narrowed. I checked the exit codes independently and got your result: git tag --merged HEAD --list exits 0 on no match and 128 only on real failure; git tag --list exits 0 even with no commits. So there was nothing legitimate being absorbed. TagExists keeps the one genuine distinction — exit 1 is a missing tag, 128 is broken.

Mutation testing found a gap on the third attempt. Restoring the swallow in GitRepo changed nothing, because the failure tests use the fake Repo. Nothing exercised GitRepo's own error handling at all. TestGitRepoReportsQueryFailures closes it.

Hermeticity

Reproduced before fixing:

$ GIT_CONFIG_GLOBAL=<gpgsign=true> go test ./...
fatal: failed to write commit object

One builder now (f7ebe15d), with identity supplied through the environment so a fixture needs no git config at all. Verified against a config setting commit.gpgsign, tag.gpgSign, core.hooksPath and init.templateDir together.

That fix then surfaced something else: with the global config neutralised, init.defaultBranch is unset and git creates master. Every fixture was a branch the tool refuses. Now explicitly main.

Coverage

053a4e21 adds the --base-url seam, hidden because it's a test seam and not a supported route to GHES. Eleven cases now cover status, preflight and watch, including the two encoding release policy: a red nightly blocks, and a green one older than 48 hours blocks.

You were right that the seam's absence was the cause rather than a coincidence.

The gh layer

All three under-reporting findings fixed in f6d287d2: Drafts paginates, inFlight filters Status server-side, Limit is named and capped for what it is. Plus the second-resolution edge you flagged — run IDs are monotonic and now break the tie. Confirmed that case fails without it.

--branch

Now defaults to the checked-out branch and warns on an explicit mismatch. Worth noting what that exposed: on this very branch, relctl next used to answer v0.5.0 as though from main. It now refuses, which is correct.

The pipeline question

I checked what those outputs are for. bump feeds two report lines in Push tag; series only ever fed next-version.sh. So next --output=github emits all four keys, the separate step disappears, and the contract test is four lines rather than two.

The two the port dropped

::warning::/::error:: are added back at the point of rendering when GITHUB_ACTIONS is set — a library returning a Warnings slice shouldn't embed them, but losing the annotation is silent. And candidateCommit's tag listing is restored; during an incident it's the useful half.

Not fixed here

The .golangci.yaml finding is real and repo-wide — filed as #657 rather than fixed here, since enabling linters.default would surface findings across the whole tree. I'd single out nilerr: it would have caught both return nil, nil bugs above, including the one that marked a release Latest.

Also inherited rather than introduced: "existing tag refused" still doesn't test what it names, since promoteExplicit rejects before validate runs. "off-branch rc name still refused" covers the tag-exists guard, so it's a naming problem rather than a hole. Left alone rather than renamed mid-review; say if you'd rather it changed.

State

CI=1 make test green, make lint 0 issues, notice-check clean. Both hostile-environment checks pass: a git config with signing and hooks forced on, and SERIES/BUMP/PRE/VERSION/ALLOW_CONCURRENT_TRAINS/MAJOR all exported.

CI found what local runs could not: the command tests needed an ambient
GitHub credential. gh.New resolves a token before the base URL is ever
used, so pointing at an httptest server was not enough. They passed here
because gh is logged in, and failed in CI with "no GitHub credential
found" - which is the environment dependency the stub was added to
remove, reintroduced one layer up.

Options gains a Token seam, and Root is split so tests can build the
command tree around their own options rather than reaching through
flags. A seam rather than a hidden --token flag: a credential is not
something to accept on a command line.

Verified the way CI would: no GITHUB_TOKEN, no GH_TOKEN, and a gh on
PATH that exits non-zero.
release-upgrade turns from_main and latest into job conditions, so this
is the command that decides whether a cluster is touched and whether the
install command in README.md gets repointed.

A normal command rather than a hidden one. It answers a question worth
asking before cutting anything - would this tag soak, and would it be
marked Latest - and hiding it would make the pipeline's behaviour harder
to reproduce when a release goes sideways.

Two keys on stdout, byte-identical to what classify-release.sh emitted,
so the workflow steps reading them do not change. The reasoning goes to
stderr, where it annotates the run without landing in $GITHUB_OUTPUT; a
test pins that separation, since a stray line on stdout corrupts a step's
outputs.

Checked against the shell on the real repository rather than only on
fixtures: v0.4.0 and v0.3.0 agree on both keys.

The test fixtures found a gap while being written: gitIn neutralised the
git configuration but supplied no identity, so any fixture needing a
commit rather than just a tag failed. Both helpers now share one env.
release-upgrade is one line: its resolve job already checks out the
default branch with full history and has setup-go, and `go run` is what
classify-release.sh did internally anyway to reach hack/cmd/semver.

release-prepare needed restructuring. The old step copied hack/release/
over the working tree, which worked because the tooling was
self-contained shell; relctl is Go and needs go.mod and go.sum, and
overlaying main's module files onto a release branch would change the
module for every other step in the job. It becomes a separate checkout
under _release-tools, cone mode on hack/cmd/relctl, which brings the root
files along. Confirmed with `go list -deps` that relctl imports nothing
else in this repository, which is what makes that enough.

Two steps become one. relctl decides what the branch may cut and resolves
the version in the same pass, because it has to know the first to do the
second, and emits all four keys. Push tag and the dry-run summary read
bump from compute now; series has no consumer left, since the resolver
applies it internally rather than handing it back.

Three things the switch had to preserve, none of which the tool can do
for itself:

  --repo-path points at the primary workspace. The branch supplies the
  history, main supplies the tool. Pointing it at _release-tools would
  resolve main's tags while reporting the branch's policy, and only a
  release branch would ever show it.

  The argument list is built with explicit ifs. `[[ cond ]] && args+=()`
  evaluates false when the condition is false, which under set -e exits
  the step - and it would fire on the most ordinary invocation there is,
  the one passing no optional flags.

  The MAJOR guard stays. bump-for-branch.sh refused anything that was not
  true or false; a Go bool cannot express that refusal, and a boolean
  input arrives here as a string, so anything unexpected would silently
  become false.

Simulated the whole step locally before trusting it, including the
no-optional-flags case, and compared against the old two-script path on
the real repository: byte-identical for main, for --major, and for a
release branch. MAJOR=yes still exits 1.
Three scripts, their four test files, hack/cmd/semver, and the three
differential harnesses that proved the port faithful.

hack/cmd/semver goes with them. Its only caller was classify-release.sh,
and leaving an uncalled binary that answers semver questions would invite
someone to wire it back in beside the Go implementation, which is the
duplication this whole change removes. Its 27 cases came across with the
is-maintenance logic.

The differential harnesses go too, and that is the point rather than a
loss: they were a one-time equivalence proof, and they cannot outlive the
oracle they compared against. What survives is the coverage that was
deliberately built to - 179 assertions in version/, 257 across relctl,
none skipped - because a differential-only proof would have taken the
coverage with it.

Three stale references cleaned up while here: create-release-branch.yaml
citing next-version.sh's pattern, and wait_rollouts_test.go's requireGit,
which skipped with "skipping next-version.sh tests" in a file that has
nothing to do with it. Its only callers were the deleted resolver tests,
so it is gone rather than reworded.
RELEASING.md now gives each procedure twice, relctl first with the gh
form immediately beneath it in a details block. Adjacent rather than in
two separate sections: two procedures for one task drift, and a stale gh
block is only obvious in the diff if it sits next to the thing that
changed.

gh is named as authoritative, which matters more than it reads. The
workflows are the interface and gh dispatches them; relctl is a wrapper
that also answers questions gh cannot. Where they disagree, gh is right
and relctl has a bug. Version resolution is the one exception, because
release-prepare calls relctl next internally, so the two agree by
construction rather than by agreement.

The conceptual sections are untouched. The versioning rule, semver
compliance, how trains work, the tag and branch model and what gets
marked Latest describe the model rather than the mechanism, and none of
them changed.

Break glass stays gh only, and says so: relctl deliberately does not
dispatch, and making an unsoaked publish one word shorter is not a
convenience worth having.

Two places where relctl earns its keep rather than duplicating gh: which
trains are live has no gh equivalent, because it is computed from tags
rather than stored anywhere; and watch correlates runs across three
workflows, only one of which names the tag.

Also fixes the last stale reference to next-version-test.sh as the way
to test version resolution.
cut, rc, promote, branch create, soak and publish. Dropped during two
rounds of review fixes and picked back up.

Every one of these can mint a tag or ship a release, and GitHub's
dispatch endpoint returns 204 with no body: it does not say which run it
created, and it does not report an input a workflow ignored. So the
description printed before the prompt is the only chance to see what is
actually being sent, and each command prints the workflow, the ref and
every input before asking.

cut, rc and promote also show the version they will mint, resolved
locally by the same code release-prepare runs. That is the reason to
prefer them over gh: the workflow's own dry_run costs a dispatch and a
minute to answer a question computable here instantly. A failure to
preview is reported rather than fatal, since a stale clone does not stop
the workflow resolving correctly against its own checkout.

They dispatch on main even when cutting from a release branch.
release-prepare takes its tooling from the default branch deliberately,
and dispatching it on the branch would run that branch's copy of the
workflow, which is the property that step exists to prevent.

The break-glass paths take a TYPED confirmation instead of accepting
--yes: publish has no --yes flag at all, and soak --force-init will not
take one. A script that passes --yes everywhere must not be able to
reach a publish that skipped its soak. An ordinary soak retry is not
break-glass and is not made to feel like one.

Two things found while writing the tests. Looking for the run a dispatch
created polled a 30 second timeout, which every dispatch paid and the
tests paid sixty seconds of; it is ten seconds now, and the command says
where to look rather than holding the terminal when a run is merely
queued. And --max-notready-nodes needed Changed() rather than a zero
check, because 0 disables tolerance entirely and is the opposite of
leaving the flag alone.

Mutation-tested the confirmation: letting --yes satisfy a typed phrase
fails five cases, and dispatching before confirming fails another.
Twenty-four drafts went through joinOrNone into a tabwriter cell, so
status printed them as one space-joined line that wrapped. They are one
per line now.

Making them vertical exposed a second problem. The order was GitHub's,
passed through untouched, and it is neither semver nor date order: the
list interleaved rc.9 between rc.13 and rc.8, and v0.2.0-beta.3 sat
above a v0.1.24-rc.17 created two hours later. Wrapped across one line
nobody notices. In a column it reads as a bug. Sorted semver-descending
at collection, so the JSON is ordered too. Tags that will not parse sort
by name rather than staying wherever the API left them, so the output is
the same on every run.

Twenty-four lines would then dominate the dashboard, so only the last 30
days are enumerated and the rest collapse to one line naming the oldest
and how many there are. The header count stays the true total. Showing
"Drafts (2)" while 22 sit invisible would hide exactly the ones worth
cleaning up, and a backlog that stops being counted is a backlog nobody
deals with. --all lists everything; -o json is never windowed, because a
script asking for drafts wants all of them.

The window needed a date per draft, which meant adding CreatedAt to
gh.Release. PublishedAt could not do it: GitHub only sets published_at
on publication, so it is the zero time for all 24 drafts here, empty for
exactly the releases anything about draft age would want it for and
empty without an error to say so. That is now written down on the field.
A draft the API gave no date is shown rather than hidden, since a
missing date is our gap and not evidence the draft is old.

Mutation-tested: dropping the sort fails two, counting the window
instead of the total fails one, treating undated drafts as old fails
two, and windowing the JSON fails one.
status lists drafts with a COMMITTED column, and the JSON carries the
same date as RFC3339.

The column is headed COMMITTED because that is what it is. GitHub's
created_at for a draft is the date of the tagged commit, matching git to
the second on every draft in this repository, and it is the only date a
draft has: published_at stays null until publication. It is NOT when the
draft was made, GitHub does not expose that, and a bare date beside a
tag reads as though it were. As a staleness signal for cleanup the
commit date is arguably the better of the two, but the heading is what
stops it being misread, so it stays.

Drafts is now []draftInfo rather than []string, which retires the
draftCreated side map the window needed and leaves one representation
instead of two. Committed is a pointer: a draft the API gave no date is
absent from the JSON rather than serialising as 0001-01-01, which reads
as a real answer, and renders as an empty cell for the same reason.
Nothing consumed the old string list, so the shape changed for free now
rather than expensively later.

The dates also make the sort order look wrong at a glance, so both
comments now say what it is: highest version first, not newest first. A
lower version with a later commit date sits below a higher one. Version
order keeps an abandoned train together so it can go at once, which is
how drafts actually get dealt with; date order would interleave series.

The date column invalidated one of my own tests. It asserted each draft
line carried at most one field, which a second column makes false by
construction, so it now asserts one tag per line with a line count
matching the draft count, which is the property the wrapped-cell bug
actually violated. The line-extracting helper picks rows by a leading
"v" rather than by indentation, so the new heading and the summary line
are excluded structurally; the old one worked only because no test
produced both at once.

Nine mutations checked: rendering a zero date, dropping the heading,
dropping the column, pairing each date with the next row's tag, and
always setting Committed each fail; and the four from the previous
commit still fail after the refactor.
IsAncestor returned `err == nil, nil`, so it could never report a
failure. ae8754a gave TagExists exit-code discrimination three
functions above; merge-base --is-ancestor documents the identical
contract, "exit with status 0 if true, or with status 1 if not. Errors
are signaled by a non-zero status that is not 1", and was left alone.

The two callers fail in opposite directions, which is why this was easy
to read as harmless. In the resolver a swallowed error becomes "not an
ancestor", which refuses to tag: safe, and only the diagnosis was wrong,
since it accused the commit of being off-branch when git had actually
failed.

Classify is not the same shape. FromMain=false is not a withheld claim,
it is a provenance fact, and release-upgrade acts on it: the publish job
proceeds when from_main is not 'true' and deploy, Orca and smoke are all
skipped, which the workflow's own notice describes as publishing without
a soak. A broken git command would therefore ship a release that had
been deployed nowhere. Signatures and the BOM are still verified in the
publish job, so it is unsoaked rather than unverified, but that is the
same class as the Latest bug ae8754a fixed and in the same function.

classify.go already had `if err != nil { return nil, err }` around the
call. It was dead code until now.

The test needed two attempts and the first was wrong in an instructive
way. Injecting the failure through brokenRepo pins that Classify and
Resolve propagate an error from the Repo interface, but brokenRepo
embeds fakeRepo, so GitRepo.IsAncestor is never called and restoring the
swallow left every test passing. The real coverage has to run git: a
well-formed hash that resolves to nothing exits 128, which must be an
error, while a genuine non-ancestor still exits 1 and must not be. The
interface-level tests are kept as well, since they pin the callers.
Six small things, all from review.

sort.Strings and sort.Slice had come back in the dispatch commit, two
after 3ab0009 replaced them with the slices equivalents everywhere
else. Both gone again, along with the "sort" imports.

The GitHub client is built before the confirmation rather than after.
Ordering only shows up when the credential lookup fails, and then it
showed up at the worst moment: on a break-glass path you would type
`publish v0.4.0 unsoaked` in full and only then learn there was no
token, with the phrase to type again. Nothing covered this, because the
test harness always injects a working token, so it comes with a test
that makes the lookup fail and asserts no prompt was written.

--yes is now refused by name on a typed-phrase path instead of being
silently inert. It correctly never satisfied the phrase, but a script
passing --yes everywhere would sit on a prompt it cannot answer and
report an EOF, which says nothing about why. It still fails closed;
it just says what to do instead. The existing test only passed because
"y" is not the phrase, which would stay true if --yes were ignored, so
it now asserts the message.

branch create validates the series before dispatching. "v0.4" is the
obvious thing to type when every other argument takes a tag, and
learning otherwise cost a dispatch and a minute. The check is a new
exported version.CheckSeries rather than a second copy of the regex:
seriesShape is unexported and reaching it from package relctl is not
possible, and the resolver now calls the same function, so the pattern
and the message stay defined once.

The RunE wrapper in soakCommand was unnecessary. The cmd passed to RunE
is the command being run, so Changed() reads off it directly. The
distinction it captured is kept, since --max-notready-nodes=0 disables
tolerance entirely and is the opposite of leaving the flag alone.

And a note on max_notready_nodes being sent as a string while force_init
is sent as a bool, which reads as an inconsistency until you check the
workflow: they are declared `type: string` and `type: boolean`
respectively, and a dispatch whose input types disagree is rejected.

Four mutations checked: ignoring --yes again, dropping the series check,
swapping Changed() for a zero test, and moving the client back after the
prompt each fail a test.
The content was there; the way in was not. Sections were named for the
mechanism ("From main", "From a release branch"), so finding the answer
required already knowing it: someone asking "how do I ship a fix to
v0.3.x users" has to know release branches are the answer before the
heading means anything. The two guides are now named for the question.

Cutting a major was a single sentence after the fold, offering only
`-f major=true` while `relctl cut --major` went undocumented. It has its
own subsection with both forms and a line on when to ask for one.

Cutting a patch was worse: three of the four things you need to know
before starting were in the last fifty lines of the file, under headings
you would not think to look at. That the patch never soaks, that it may
not be marked Latest, and that a branch cut from an old tag runs no CI
at all and therefore cannot merge a cherry-pick, are now stated up front
where they are load-bearing. Each links to the reference section that
explains why, so the reasoning is still in one place while the facts are
where they are needed.

That last one is also more useful than it was. Instead of "a release
predating release-* CI coverage", which requires knowing which releases
those are, it names the boundary: v0.4.0 and later are fine, v0.3.x and
earlier need release-* added to ci.yaml as the branch's first commit.
And it points out that create-release-branch already detects this and
warns, so the reader does not have to carry it at all. Verified against
the tags rather than restated: v0.4.0 contains the trigger fix, v0.3.0
and v0.2.4 do not.

"Shipping an urgent fix" and "Common situations" were two decision
guides restating three of the same cases in nearly the same words. They
are one table now, which is also the router into the guides.

The reference material is unchanged prose, regrouped: the three pipeline
phases sit under "How the pipeline works" so the ordering is carried by
the grouping rather than by numbering sections 1 to 5, two of which were
never phases at all.

Also fixed the two em-dashes the file had picked up, which AGENTS.md
disallows.

Every internal anchor was checked against the generated slugs; the three
that moved are updated and all 23 resolve.
Fourteen places had code, workflows or a shell script citing the release
document. That inverts the authority: code is what a release actually
does, and a comment that defers to prose invites the prose to be treated
as the source. It also breaks silently. Reorganising RELEASING.md in the
previous commit would have left cmd_preflight.go citing "section 1" of a
document that no longer has one, with nothing to catch it.

Four needed the substance restated rather than the citation deleted,
because the citation was carrying the reason:

  - nightlyStaleAfter said RELEASING.md "asks for the nightly to be
    green, and green recently, without saying how recent". It now says
    what the rule is and why two days: a weekend gap in scheduling is
    allowed, a week-old result is not, because that describes a tree
    nobody has released since.
  - the relctl package doc and the status and preflight help strings all
    quoted "There is no single dashboard" to explain themselves. They
    now say what they do. Quoting a document at a user reading --help
    was never useful to them anyway.

Ten were redundant pointers sitting next to the thing they pointed at,
so the clause is gone and the surrounding text is unchanged: the
versioning rule in branch.go is already written out underneath it, the
rc-only rule in modes.go is already explained by the sentence it opens,
and the metalman error already prints the exact kubectl command to fix
the problem, which beats telling an operator mid-failure to go and read
a file.

Doc-to-doc links are untouched: CONTRIBUTING.md and the relctl README
still point at RELEASING.md, which is what cross-references are for.
@plombardi89
Philip Lombardi (plombardi89) marked this pull request as ready for review August 24, 2026 23:56
@plombardi89
Philip Lombardi (plombardi89) requested a review from a team August 24, 2026 23:57
One conflict, in the Makefile's .PHONY line, where both sides added a
target to it: main gained e2e-gantry (#656) and this branch gained
relctl and relctl-build. Both kept.

Resolved by starting from main's line and re-applying this branch's
addition, then checking that the result is exactly the union of the two
sides' target sets. Taking one side's line wholesale is how a target
goes missing here, and nothing would fail: .PHONY is a declaration, so a
dropped entry only shows up later as a target that stops working when a
file of the same name appears.

Everything else merged clean. Main touched eleven files, this branch
touches fifty-eight, and the Makefile is the only one in both.
Every other binary target is echoed there, forge included, and relctl
was not. Noticed while resolving the merge: main's new e2e-gantry target
appeared in the help output and relctl did not, which is what made the
omission visible.

`make help` is an explicit list rather than generated from the `##`
comments, so a new target is only discoverable if it is added by hand.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc-only pass over RELEASING.md at e2a1d564, checking every relctl claim against the code rather than reading it for prose. The guide-then-reference split works, and putting the gh form in a <details> next to each relctl form reads better than two separate sections would have.

Eleven issues. Five are inline; the rest sit on lines this PR did not touch, so they are below with citations.

Not anchorable, but the same class

RELEASING.md:664 - "each takes a typed confirmation rather than accepting --yes" is true for one of the two overrides.

publish has no --yes at all and requires the phrase. relctl soak v0.4.0 --max-notready-nodes 7, the very next example at :672, takes an ordinary Continue? [y/N] and accepts --yes. In cmd_soak.go the phrase is set only under forceInit.

That looks deliberate rather than accidental, and 39a557ad argues for it: "An ordinary soak retry is not break-glass and is not made to feel like one." But raising the NotReady ceiling is listed here as break-glass, so the doc and the code disagree about which paths are break-glass rather than merely about wording. Worth settling in one direction.

--force-init appears nowhere in this document. It is a typed-confirmation path in cmd_soak.go, a declared release-upgrade input, and it runs site init against the soak cluster instead of upgrade-apply. Break glass reads as the register of sanctioned overrides ("Two sanctioned overrides"), and this is the third, absent from it. It is also the one the code treats as most dangerous: it is what escalates soak from y/N to a typed phrase. Either it belongs beside the other two, or the section should say why it deliberately does not.

RELEASING.md:538 - bump is described as something that gets consulted, in a document that says twice that it does not exist: :145 "There is no bump input" and :345 "The bump follows from that choice and is not an input." Confirmed against the workflow: release-prepare.yaml declares mode, branch, major, pre, version, dry_run, allow_concurrent_trains, and no bump. The property the sentence states is real and worth keeping; it just needs saying without naming an input the reader has twice been told is not there.

RELEASING.md:637-656 duplicates :32-44 rather than continuing from it. :34 and :639 are the same sentence, and the make relctl-build block at :40 is repeated at :653. "Every procedure below" is also wrong at :639: that is line 639 of 764, and everything it refers to is above. The structure the doc sets up is right, and :44 states it ("The full account ... is in [relctl and gh]"), so this section should open with the account. The genuinely new material is the authority exception and the README link, and both are currently preceded by two paragraphs already read.

Duplication that has already drifted

  • :205 and :464 are the same paragraph about the nightly not covering release branches, with --branch release-0.3 in one and --branch release-0.4 in the other. Nothing keeps them in step, and they have not stayed in step.
  • :79-84 and :443-447 repeat the nightly and CI gh block verbatim, comments included, though :88 already links to the reference section for what the checks mean. Plausibly deliberate so the walkthrough stands alone, so I have not called it a defect.

The doc elsewhere prefers a link to a repeat, which is why these stand out rather than being a general style point.

Scope

All of this is prose against behaviour that is already correct, with one exception: the comment on :265 asks for a guard in cmd_classify.go, because the doc problem there is a symptom rather than the cause.

Comment thread RELEASING.md
Comment thread RELEASING.md Outdated
Comment thread RELEASING.md
Comment thread RELEASING.md
Comment thread RELEASING.md
version.Classify's doc comment says it expects a checkout of the default
branch and the command's help repeats it, and nothing checked. Run from
release-0.4, the tag v0.4.1 is reachable from HEAD, so from_main comes
back true for a release that was not cut from main, and latest is
computed against that branch's trunk instead of main's. Both answers
wrong, and neither able to fail, because there is nothing for them to be
inconsistent with.

RELEASING.md walked readers into exactly that: it had them check the
release branch out at step 2 and run classify at step 4, with a trailing
comment as the only guard. The document is fixed separately; the guard
belongs in the command, which also covers running it outside that
procedure.

A refusal rather than a warning, and next deliberately keeps its
warning. The two are not the same case. next takes an explicit --branch,
so a mismatch means policy and history disagree, and it says which one
it resolved against; more to the point its answer is still structurally
guarded, because a version computed from the wrong history either falls
outside the requested series or collides with an existing tag. Checked
rather than assumed: `next --branch release-0.3` from a main checkout
exits 1 with "computed tag v0.4.1 is outside series 0.3" and writes
nothing to stdout. classify has neither a flag nor a guard, there is no
reading of "reachable from HEAD" that is merely different rather than
false, and --output json and github put the answer on stdout where a
warning on stderr would not be seen.

A detached HEAD warns instead of refusing. CurrentBranch already reports
empty rather than guessing, and refusing on "cannot tell" would break a
legitimate checkout of a commit to defend against a case that cannot be
detected. It is also the safe direction for CI: release-upgrade checks
out ref: default_branch, which yields a real branch, so the guard is
silent there, and were it ever detached it would warn rather than break
the pipeline.

Three mutations checked: dropping the guard fails three tests, refusing
on detached HEAD fails one, and warning instead of refusing fails two.
A doc-only review checked every relctl claim against the implementation
rather than reading for prose. Most of what it found I introduced in
e30db7d, including the worst one.

`relctl branch create --dry-run` was documented as resolving the branch
point and warning about missing release-* CI triggers, under "You will
be warned rather than having to remember". It does neither.
branchCommand builds a static summary and dispatch.run returns before
the client is built, so nothing is resolved and nothing is fetched. The
workflow resolves the branch point, which is why its own dry_run=true
reports it and has to dispatch to do so. The document draws exactly that
distinction sixty lines earlier and I contradicted it, then told the
reader they could stop remembering something they still have to
remember. Both places now say which of the two produces the answer.

Break glass listed two overrides and claimed each takes a typed
confirmation. There are three, and they take three different
confirmations: raising the NotReady ceiling is an ordinary y/N prompt,
force_init and force_publish need a typed phrase. force_init was absent
entirely despite being the one the code guards hardest, and it runs
`site init` against the soak cluster, which on an already-initialised
cluster creates a fresh Site rather than migrating the existing one. It
now has a section, and the three sit in a table that says what each
bypasses and what it asks for, so the doc and the code can be compared
at a glance instead of by reading prose.

The patch procedure told the reader to check out release-0.3 and then
run classify, which answers relative to the trunk. That produced two
silently wrong answers; the guard added in 9ea8154 now refuses it, and
the procedure checks main out first. The step above it demonstrated the
`--branch` default by passing `--branch` explicitly, which showed the
opposite of what its comment said.

The claim that `next` "warns when the two disagree" undersold it: for
the pairing named in that same sentence it refuses, because the computed
tag falls outside the series. Checked rather than assumed.

Two duplications, one of which had already drifted: the nightly
paragraph existed twice with different branch numbers, and the relctl/gh
section repeated two paragraphs from the top of the file while saying
"every procedure below" from line 639 of 764. The reference copy now
carries only what is new. That drift is the risk we accepted when
inlining facts into the guides, and it arrived in the same commit that
introduced it, which is worth recording.

Also: one procedure used two different versions across its relctl and gh
halves; :350 was the last place in the repository calling the release
tooling "scripts" after 28e5556 deleted them; and How trains work
described `bump` being consulted, an input the document twice says does
not exist and release-prepare does not declare.

Verified by extracting every relctl invocation from the document and
checking each command and flag against the built binary's help, and by
re-running the anchor check: 28 links, all resolving.
@plombardi89

Copy link
Copy Markdown
Collaborator Author

All eleven addressed, plus a twelfth the review did not catch. Two commits: 9ea81548 (the guard) and b736a01b (the document).

The --dry-run claim at :194 was the worst of these and it was mine. I wrote that relctl branch create --dry-run resolves the branch point and warns, under "You will be warned rather than having to remember". It does neither: branchCommand builds a static summary and dispatch.run returns before the client is built. I had read the workflow's CI_OK check, confirmed the workflow warns, and then attributed that to relctl's --dry-run — conflating the two operations the document distinguishes at :135, in a paragraph I preserved. Telling a reader they can stop remembering something they still have to remember is worse than not mentioning it. Both places now say which of the two produces the answer.

:265 — guard added, not just reordering. runClassify now refuses when CurrentBranch() names a branch other than main, and the procedure checks main out first. Detached HEAD warns rather than refuses: CurrentBranch() already reports empty rather than guessing, and refusing on "cannot tell" would break a legitimate checkout to defend a case that cannot be detected. Safe in CI, since release-upgrade.yaml:54 checks out ref: default_branch and gets a real branch.

I deliberately did not give next the same treatment, and the reason is structural rather than stylistic. next --branch release-0.3 from a main checkout already exits 1 with computed tag v0.4.1 is outside series 0.3 and writes nothing to stdout — the series guard and the repository-wide tag-exists check mean a version computed from the wrong history refuses rather than misleads. classify has neither, and no internal consistency check could catch it. The commit message records this so the asymmetry does not read as an oversight later.

Break glass is a table of three. Your point that the doc and code disagreed about which paths are break-glass was the right framing. force_init now has a section, and the three sit in a table showing what each bypasses and what confirmation relctl asks for, so doc and code can be compared at a glance rather than by reading prose.

A twelfth, surfaced by a question about whether next needed the same fix: :267 said next "warns when the two disagree". For the pairing named in that same sentence it refuses. The doc undersold its own protection and named the wrong mechanism, which invites a reader to press on through a warning that never appears.

On the duplication. :205/:464 had already drifted to different branch numbers, and it happened inside the commit that introduced it. That was the accepted risk of inlining facts into the guides, and it did not survive one commit. The reference copy now drops the branch-specific example and links to the guide; the relctl/gh section keeps only the material that is new by that point.

Rest as described: :245 showed --branch while its comment described the default, :132 used a different version from its own procedure, :350 was the last place in the repo calling the tooling "scripts", :538 named a bump input the document twice denies.

Left alone deliberately: the repeated gh nightly block, which keeps the walkthrough standalone, as you allowed.

Verified by extracting every relctl invocation from the document and checking each command and flag against the built binary's help, plus the anchor check (28 links, all resolving). Three mutations on the guard: removing it fails three tests, refusing on detached HEAD fails one, warning instead of refusing fails two.

@plombardi89 Philip Lombardi (plombardi89) changed the title relctl: port the version resolver into Go (WIP) relctl: one implementation of the versioning rule, shared by humans and CI Aug 25, 2026
@plombardi89
Philip Lombardi (plombardi89) added this pull request to the merge queue Aug 25, 2026
Merged via the queue into main with commit cf8d7c2 Aug 25, 2026
30 checks passed
@plombardi89
Philip Lombardi (plombardi89) deleted the feat/relctl branch August 25, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants