Version-at-Land Cutover — flipping the single-writer bump bot to --push¶
STATUS: FLIPPED LIVE — 2026-07-17.
version-at-land.ymlruns--push;version-skill-check.ymlaccepts intent trailers;auto-release.yml's stranded detector exportsPULP_ACCEPT_INTENT_TRAILERS=1.MODEL B (post-merge assignment) — the chosen finish.
versioning.jsonsets"post_merge_assignment": true, so PRs touch NO version files at all:version_bump_check.py --mode=applyis a verified no-op (soshipyard prproduces a version-file-free PR), the PR gate stops requiring a file bump for a touched surface, and the version-at-land bot infers the level from the same heuristic and assigns it on merge. This kills the version-cell conflict class outright (batch-mates can never race on the version line) and needs no Shipyard feature — the "shipyard emits trailers" path is NOT needed.Version-Bump:trailers remain OPTIONAL overrides for heuristic-underweighted cases. The one retained guard: afix:/feat:the heuristic scoresnoneon EVERY surface still HARD-FAILS (re-title, add an intent trailer, orVersion-Bump: skip reason="…") — else it would merge into a silent bot no-op and never ship.New semantic (state it, don't file it as a bug): the bot assigns ONE version per surface per drain, so a queue batch of three sdk fixes yields ONE sdk release covering all three. Versions are per-drain, not per-fix.
Rollback = set
post_merge_assignmentback tofalse(PRs file-bump again) and revert the--push/gate/detector trio to dry-run, recovering any outstanding pending intent FIRST (see Rollback).Bot liveness (2026-07-17): version-at-land also runs on a
*/15schedulewith an EVENT-SCOPED concurrency group (version-at-land-${{ github.event_name }}). GitHub holds one queued run per group, so on a high-churn main every push supersedes the bot's push-triggered run — a manual dispatch was observed CANCELLED this way (zero successful runs post-flip). The scheduled drain lands in its own group, immune to push supersession, and always completes within the cadence (the coverage.yml precedent). Concurrent push+schedule drains are safe (ff-only + marker idempotency).
This guide is the reviewed, sequenced procedure for turning off the per-PR version-bump treadmill and turning on post-merge version assignment ("option B"). It is release-critical: a wrong flip can drop a version silently. Do not flip without the GO/NO-GO checklist at the bottom passing.
Direct-writer safety boundary¶
The writer has a repository-owned writer_allowed_globs allowlist. Every
generated commit is checked against it before staging, and an empty or
unexpected path fails closed. This protects the writer from its own
regenerators or configuration drift; it does not replace GitHub authorization.
The live main-merge-queue ruleset currently has no bypass actor. GitHub
ruleset bypass permissions are actor-wide and cannot be restricted to
version-only paths. Therefore PULP_BUMP_ROUTE=direct must remain disabled
until GitHub can enforce an equivalent narrow, auditable restriction (or a
separate release-only protected ref is introduced). A compromised release bot
must not be able to push arbitrary code to main. Until that platform control
exists, keep PULP_BUMP_ROUTE=pr; the bump PR is the governed admission path,
while the allowlist and marker/idempotency checks still apply.
Cheap CI for the bump PR¶
The bump PR is verified, not rebuilt. In build.yml's classify job (and the
WebCLAP proof in wclap-cloudflare.yml) the protected base's
generated_version_bump_check.py first tries to reproduce the bump's tree byte
for byte. When it cannot (a migration note was folded in or removed, or the
group already carries an earlier queue entry), it gets a second pass,
--allowlist-diff, over the same signed, single-commit, fixed-branch bump:
- the commit has the canonical
chore: bump versionssubject and aVersion-Bump-Applied:trailer; - every path in the bump's diff, and in the event's own diff (the merge group
against its base), matches
writer_allowed_globsfrom the protected base, the same list the writer checks its own edits against (version_at_land.paths_outside_writer_allowlist); - every configured surface is at one consistent version and moved by one strict semantic-version step;
- a code or build file inside the allowlist changes only its version value:
CMakeLists.txtonly theproject(... VERSION x.y.z)number, andtools/scripts/agent_capability_manifest.pyonly theMANIFEST_REVISION/SURFACE_INVENTORY_VERSIONcounters, each by at most one. Any other byte, or a code-like path with no such bound, keeps the full suite. Data files (version JSON,CHANGELOG.md, migration notes, generated status) pass on a path match.
Both passes first require the commit's SSH signature to come from the release
bot's own key. Author name, email and the GitHub login are free text, and every
agent key registered to the same account also verifies, so the verifier reads
the key embedded in GitHub's verified signature and compares its fingerprint to
tools/scripts/release_bot_signing.json as committed on the PROTECTED BASE
(never the candidate). A missing or self-inconsistent pin keeps the full suite.
When both hold, the native matrix is skipped and macos reports success from
the skip-safe alias job. Fail closed: an unreadable diff, any path outside the
allowlist (a group that batches the bump with another change), a missing
marker, a non-bot, unsigned or wrong-key commit, or a protected base without
this flag keeps the full suite. The route, liveness alarm and marker semantics are
unchanged.
The problem being solved¶
Every fix: / feat: PR hand-writes a version number ABOVE main's
current value into CMakeLists.txt, .claude-plugin/plugin.json, and
.claude-plugin/marketplace.json. That is a shared counter — only
one PR can own main+1. With N validated PRs queued, each one that
merges bumps the number, and every other open PR now conflicts on the
version line, has to re-merge/rebase, and re-conflicts again the moment
the next one lands. This is the deterministic N-1 version-bump race.
The model¶
-
PRs DECLARE intent, they do not write numbers. A release-worthy PR carries a
Version-Bump: <surface>=<patch|minor|major>trailer and touches no version files. The PR-side gate accepts this viaversion_bump_check.py --accept-intent-trailers(seeversion_bump_render.render_report): a touched surface must EITHER be file-bumped, OR carry an intent trailer, OR carry aVersion-Bump: skip reason="..."trailer — otherwise it still hard-fails. -
A single writer assigns the number after merge.
.github/workflows/version-at-land.ymlruns on every push tomain. In--pushmode it recomputes from a freshorigin/main, assigns each declared surface the next version from main's CURRENT value, commitschore: bump versionswith aVersion-Bump-Applied:marker, and pushes--ff-onlywith retry (version_at_land.apply_and_push). No two PRs ever contend for the same number — the counter is advanced exactly once, in commit order, by the sole writer. -
The bot's
chore: bump versionscommit triggersauto-release.ymlexactly like a PR-side bump does today, so tagging and release are unchanged downstream of the bump commit.
The bump commit also carries the derived regenerators¶
version_at_land._refresh_derived runs a short list of regenerators inside the
same commit, and only when a version actually moved (regenerating on a no-op
plan would sweep unrelated working-tree drift into a bump). Two entries today:
docs/status/pulp-tooling-disposition.json— embeds the plugin surface's version, so the bot's own write stales it and the Vellum freeze gate fails on the bump commit itself.docs/status/gpu-vellum-handoff.yaml+docs/validation/gpu-handoff-provenance/receipt.json— the GPU/Vellum handoff ledger and its published receipt, regenerated together bygpu_handoff_provenance.py write --receipt. These do not embed a version; they are here for the same structural reason the versions themselves are. They pin ~93 Pulp paths, so any commit touching one staled them, and re-pinning per PR made two generated files a shared counter: ~148 non-merge commits in 25 days changed only those two, in 124 distinct subject spellings, and they collided on github.com where the localpulp-gpu-ledgermerge driver cannot run. The bump commit advancesmainby itself, so a re-pin there costs nobody a rebase. PRs consequently do not re-pin at all;gpu_handoff_pin_freshness.pyrejects one that tries. What a PR still owns is any editorial change — the whole document minus the three derived identity fields, so a pinned row added/removed/re-stated and equally anauthorities/upstream/vellum_paths/terminal_evidenceedit. None of those is a decision a regenerator can make, and each still runswrite --receiptin the PR.
write --receipt restamps the receipt's source_commit unconditionally, so
on a bump that re-pins nothing it would put a receipt-only diff in every bump
commit — and the bot's bump PR would then go DIRTY against any open PR
touching the receipt, which is the collision this move exists to end.
_drop_receipt_churn restores the committed receipt when the regenerated
ledger is byte-identical to HEAD's and HEAD's receipt already binds it:
the same two conditions gpu_handoff_provenance.py resolve calls CHURN. It
changes nothing about write's own semantics — only this caller declines to
carry the restamp.
Both ledger paths are listed in versioning.json's generated_globs, or the
bot's own bump PR would fail the required Enforce version & skill sync check
on files the bot wrote. A regenerator that fails is best-effort: it leaves
its file stale and prints which one failed, rather than aborting the bump —
the bot is the single writer for versions, and a wedged bot stops all releases.
The CHANGELOG.md regenerator (shipyard changelog regenerate) needs the
pinned Shipyard, which the workflow installs with tools/install-shipyard.sh
before the bump step. That install step passes GITHUB_TOKEN: the upstream
installer resolves the release through the GitHub API, and an anonymous lookup
from a shared runner IP fails with HTTP 403 once the 60/hour limit is spent,
which fails the whole drain run. Under GitHub Actions the wrapper refuses to
run without a token, and workflow-lint.yml rejects a tokenless install step.
The SDK bump also folds unreleased migration notes¶
A pull request cannot know the version it ships in, so it writes its
migration note as docs/migrations/unreleased/<topic>.md without a version.
When a bump assigns the sdk surface (the CLI version pulp upgrade --notes
compares against), _write_plan calls fold_migration_notes.fold, which moves
every unreleased note into docs/migrations/v<assigned>.md with the canonical
applies_if, appending to that file if it already exists. The written note and
the removed sources are part of the bump commit. Like the regenerators the fold
is best-effort: a malformed note leaves unreleased/ untouched for the next SDK
bump and the run log says why, rather than stopping the release. A plugin-only
bump folds nothing. On the PR route the bump PR is cut at one commit and merges
later, so a change merged while it waited ships in the release tagged on that
merge while its note is still unreleased; the fold therefore asks the release
tags (fold_migration_notes.shipped_version) for the earliest vX.Y.Z that
contains the commit adding each note, and appends a note whose change already
shipped to that release's vX.Y.Z.md instead of the new one. The bot fetches
tags first; a clone that lacks the newest tag keys the note to the new version,
which was the behaviour before. The upgrade skill maps only the two README files under
docs/migrations/, so neither an author's note nor the bot's fold owes a
SKILL.md update.
The SDK bump also assigns agent-capability counters¶
MANIFEST_REVISION and SURFACE_INVENTORY_VERSION in
tools/scripts/agent_capability_manifest.py were the same kind of shared
counter as a release version: every branch that changed capability material
claimed base + 1, and each merge staled the rest. Branches now leave them at the
base value; the regenerated snapshot is pending (its material differs from the
last state contract-history.json recorded under the same counter). When a bump
assigns the sdk surface, _assign_capability_counters runs the repo's own
agent_capability_manifest.py --assign-pending, which advances each pending
counter by one and regenerates the snapshot, surface ledger and history; the
changed paths join the bump commit, and the message carries a
Skill-Update: skip skill=agent-capabilities trailer because the files are the
skill's but the change is mechanical. A failure is reverted and logged, and
auto-release.yml's agent-capability pre-tag check withholds the SDK tag while
anything is pending (see release-watchdog.md).
Why the race can't lose or duplicate a version¶
apply_and_push is safe under concurrent post-merge runs because of three
independent properties, each covered by a test in
tools/scripts/test_version_at_land.py:
--ff-onlypush (no--force): git's default non-fast-forward rejection means a second writer that advancedmainbetween our recompute and our push is rejected, never clobbered. (The DELETEDintent-bump-on-merge.ymldid a baregit push origin HEAD:mainwith no--ff-only+ retry — a merge during its ~30s window silently discarded the bump. That workflow andapply_intent_bump.pywere removed; never reintroduce an unguarded push here.)- recompute-per-attempt: each retry re-syncs to the new tip, where the
drain range now starts AFTER the winner's
Version-Bump-Appliedmarker and collapses to empty — so the loser no-ops instead of double-bumping. - the
Version-Bump-Appliedmarker: the next drain (and any rerun) starts after it, so an already-assigned range is never reprocessed.
Intent is read --no-merges-scoped¶
version_at_land.intent_trailers reads Version-Bump: trailers from the
range's non-merge commits only (git_range_trailers(..., no_merges=True)).
A "Merge origin/main into <branch>" re-sync commit can carry a stale
intent trailer that was never meant to declare this range's release
(exactly the Version-Bump: sdk=minor sitting on merge commit
ce17af6ad); honoring it would silently escalate the version. A PR's real
intent lives on its own commits or the squash commit (single parent), so
--no-merges keeps every genuine declaration while dropping the re-sync
noise. The auto-release stranded-fix detector uses the same scoping (see
below). Do not change git_range_trailers' default (merge-walking) —
the bypass-trailer gates depend on it; use the opt-in no_merges= flag.
Claude plugin marketplace reads main HEAD (staleness window)¶
claude plugin marketplace add Generous-Corp/pulp tracks the repository's
default branch (main) HEAD — it reads .claude-plugin/marketplace.json
and .claude-plugin/plugin.json from main, NOT from a release tag
(docs/guides/claude-code-plugin.md; there is no @ref pin anywhere in the
install docs). auto-release.yml creates plugin-v* tags for changelog and
release notes, but the marketplace does not consume them.
Consequence under this model: between a plugin-surface PR merging (with
intent, plugin.json/marketplace.json unchanged) and the version-at-land
drain committing the bump, main HEAD's plugin version is briefly STALE — a
user who runs claude plugin marketplace update in that window sees the old
number. The window is seconds-to-minutes (the version-at-land concurrency
group serializes drains), and it is self-healing. This is strictly better
than today's treadmill, but it is a real behavior change worth stating: the
plugin version on main is eventually correct, not atomically correct at
merge. If atomic plugin versioning is ever required, pin the marketplace to a
tag instead of main HEAD.
Cutover sequence (do in order)¶
-
Land the safe prep FIRST (this branch): landmine deleted, trailer scoping fixed,
apply_and_pushhardened + tested,--accept-intent-trailersverified, stranded detector taughtPULP_ACCEPT_INTENT_TRAILERS. All dry-run; nothing about releases changes. Merge it normally. -
Prove the release-bot commit path. The bot must push a commit to protected
main. It already pushes tags fromauto-release.yml; a commit additionally needs the bot on the branch-protection bypass list, and the commit must be SSH-signed viaconfigure_release_bot_ssh_signing.sh(secretRELEASE_BOT_SSH_SIGNING_KEY). Verify on a scratch branch before touchingmain. -
Drain to zero, then FREEZE. Announce a short merge freeze. Let the queue drain so no open PR still carries a hand-written
chore: bump versionscommit. Confirm main's version files are internally consistent. -
Apply the one-time straggler rule (below) to any PR that is already in flight and still carries a bump commit.
-
Flip the gate to accept intent — SWAP the flag, do not add it. In
.github/workflows/version-skill-check.yml, replace the conditional--require-bump-for-fix-feat(lines ~117-120) with--accept-intent-trailerson theversion_bump_check.pyinvocation, and switch Shipyard /pulp prto emitVersion-Bump: <surface>=<level>trailers instead of applying file bumps. Do not pass both flags:--require-bump-for-fix-featruns a separatecheck_fix_feat_requires_bump()that accepts only achore: bump versionscommit or aVersion-Bump: skiptrailer — never a positive intent — so the two flags FAIL CLOSED on exactly the intent-only PR shape this model produces.--accept-intent-trailersalone loses no coverage (a touched surface with no bump/intent/skip still hard-fails). Locked in bytest_version_bump_intent.py::test_combined_flags_fail_closed. (Surface bump levels are already derived byversion_bump_check's heuristic; the change is "declare, don't write".) -
Flip
version-at-land.ymlto--push— the one-line diff in the GO/NO-GO section. Give the jobcontents: write, add the SSH-signing step (tools/scripts/configure_release_bot_ssh_signing.sh), and a recursion guard is NOT needed (theVersion-Bump-Appliedmarker makes the bot's own commit a no-op drain). The workflow has NOpaths:filter — it runs on every push tomain. A filter is a silent-loss hole: a fix/feat touching only a surface path the filter omits (e.g.experimental/pulp-rs/**) would declare intent, merge, never trigger the bot, and — since the stranded detector treats pending intent as covered — never alarm. An empty-range push is a cheap no-op, so correctness wins. The pending-intent liveness alarm (.github/workflows/pending-intent-liveness.yml+tools/scripts/pending_intent_liveness.py) is the time-based backstop: it pages if a positive intent stays unapplied past a grace window (210 minutes, above the PR route's coalescing window), catching a disabled/failed bot, a bot-permission failure, or an exhausted--ff-onlyretry — the whole "X exists, Y never arrives, detector quiet" class.
Coalescing (accepted behavior): two merges landing in the same drain
window are assigned together in ONE chore: bump versions commit and ONE
tag covering both. That delivers every change but is not one-tag-per-merge;
it is safe and intended.
-
Teach the stranded detector. In the
Stranded fix/feat detectorstep of.github/workflows/auto-release.yml, exportPULP_ACCEPT_INTENT_TRAILERS=1. This makesclassify-unreleased-rangetreat a pending authored intent as covered (not "consumers are stuck"), so every intent-carrying fix/feat merge does not false-warn. It is--no-merges-scoped, so a stray intent on a re-sync merge commit still correctly strands. Do this in the SAME change as step 5 — enabling it earlier (whileversion-at-landis still dry-run) would blind the detector to genuinely stranded fixes, because nothing would be applying the intent. -
Watch the first N merges. Confirm each release-worthy merge produces exactly one bot
chore: bump versionscommit and one tag, and thatrelease-cadence-check.ymlstays quiet.
One-time straggler rule (in-flight PRs)¶
At the moment of the flip, a PR that was opened before the flip may already
carry BOTH a hand-written chore: bump versions commit AND (after a rebase)
an intent trailer — which would double-bump (the PR's file bump lands,
then version-at-land assigns another number on top). For every PR still
open across the flip boundary:
- Strip the hand-written bump. Drop the
chore: bump versionscommit and the version-file edits from the branch, and add theVersion-Bump: <surface>=<level>trailer instead.shipyard pr/pulp prpost-flip does this automatically; a manually-managed PR needs it done by hand. - If a PR must keep its file bump (e.g. it merges during the freeze,
before step 5), let it bump normally and add
Version-Bump: skip reason="pre-cutover file bump"soversion-at-landdoes not assign a second number.
The rule exists only for the flip boundary; once the queue has cycled, every PR is intent-only and there are no stragglers.
Landing route: direct vs pr (merge-queue compatibility)¶
The bump can land two ways, selected by the PULP_BUMP_ROUTE repo variable
(version_at_land.py --route {direct,pr}; unset ⇒ direct ⇒ current behavior):
-
direct(default, live):apply_and_pushpushes thechore: bump versionscommit straight tomainwith--ff-only. This publishes releases today. It is incompatible with a "Require merge queue" branch rule — that rule blocks all direct pushes tomain, which is exactly what broke releases on 2026-07-20 when the queue was briefly enabled. -
pr(dormant until flipped):apply_via_prpushes the bump to the fixedrelease/version-bumpbranch, opens achore: bump versionsPR, and stops. It does not arm auto-merge: the merge steward is the only admission writer, enqueues the PR once it has an exact-head approval and green checks, and its stale-arm sweep would disarm an arm made here. So the bump lands through the merge queue like any other PR. Requirements and invariants: - PAT required. PR-route runs only with
RELEASE_BOT_TOKEN; aGITHUB_TOKEN-created PR does not trigger workflows, so its checks never run and auto-merge stalls. The workflow fails loudly if the PAT is absent. --merge, never--squash(.agents/contract.toml): squash folds theVersion-Bump-Appliedmarker commit and trips an auto-release false alarm.- Serialized. When
PULP_BUMP_ROUTE=prthe workflow'sconcurrencygroup collapses to a single constant so all drains run one-at-a-time — the shared-branch reclaim is only race-free without a competing drain. Thedirectpath keeps its event-scoped group unchanged. Only runs whose drain job actually executes join that group: a group holds one pending run and a new arrival cancels it, so adequeuedevent for another PR or a green bump-branchworkflow_runwould otherwise cancel a pending push drain and then skip its own job. Those runs get a per-run group instead.test_version_at_land.pyevaluates the group expression and the jobifagainst every event shape and fails if they diverge. - One standing bump PR, updated in place. There is at most one open bump
PR, on
release/version-bump, and it is never closed and recut. The bump is cumulative from the lastVersion-Bump-Appliedmarker on main, so when another merge lands while the PR is open the drain rebuilds the bump on the current main and replaces the branch head with one compare-and-swap push (--force-with-leaseat the head it observed), then refreshes the PR body (pr-updated). The PR number, label and conversation stay. The drain leaves the head alone (stale-wait, exit 0 with a::notice::) when:- the PR is in the merge queue (a push would eject it; its landing re-runs the drain, which assesses every first-parent merge after the bump's cut point and skips only the bump's own integration merge);
- the head already is the bump it would build: same files, same content, same sync trailers, cut from an ancestor of main. A landing that needs no new version therefore keeps a head a reviewer may already have approved;
- the branch could not be read.
- The window spaces updates. An update that is due waits
(
standing-held, exit 0 with a::notice::) until the standing head isPULP_BUMP_COALESCE_MINUTESold (default 120;0disables), measured from the bump commit's time. A surface that now needs a higher version than the head carries (a new surface, or patch to minor) updates at once, as does arelease_now: truedispatch. The*/15scheduled drain acts on the window ending. A head that lands before its update is still correct for everything up to its cut; the drain after its merge bumps the rest. - Dead-end bump PR. A standing bump PR that was ejected from the merge
queue, has a failed required check, or carries a reviewer HOLD at its
current head never lands. Nobody arms PRs under the steward admission
model, so an unarmed PR is not a dead end: it is waiting for review or for
the steward's next pass and is LIVE. It is dead only when it was ejected
from the queue, a required check on its head failed, or the last line of a
stamped comment by the verdict App is
verdict: HOLD head:<sha>with the sha equal to the current head. A HOLD on an older head leaves the current head unreviewed, which is live. Comments that cannot be read never make a PR dead. With thePULP_BUMP_HEAL_STALE_PR=truerepo variable (default off) the drain repairs it in place: it replaces the head (the same push as an update), which clears the verdict and reruns the checks. Nothing is closed or reopened, and the repair obeys the window, so a persistently red head is retried once per window. Without the variable a dead standing PR fails the run (stale-defer, exit 1). A draft (an explicit release hold) or an unreadable state fails the run the same way and is never pushed to. The workflow also runs on the bump PR'sdequeuedevent and on a non-green completion of a workflow reporting its required checks (workflow_run, scoped torelease/version-bump), so a dead end is repaired when it dies rather than on the next push to main. Those runs check out main, never PR code. - Coalescing. A bump PR recomputes every intent since the last marker,
so one bump PR covers any number of merges. Opening one per merge spends a
merge-queue validation on each bump. So when no bump PR is open, the drain
holds a new one (
coalesced, exit 0 with a::notice::) while BOTH of these are true: other PRs are waiting in the merge queue, and the last bump landed less thanPULP_BUMP_COALESCE_MINUTESago (default 120;0disables). It opens the PR on the first drain where either stops being true, or at once when a release needs the number: dispatch the workflow withrelease_now: true. The*/15scheduled drain makes sure the window ending is acted on. A fact the drain cannot read (the merge queue, or the time of the last landing) never holds the bump; it opens as it did before coalescing. An already-open bump PR is not held by this rule; it is updated in place under the window described above. The predicate iscoalesce_decisioninversion_at_land.py, covered bytest_version_at_land.py(CoalesceDecisionTest,PrRouteCoalesceTest). - Liveness grace stays above the window. The pending-intent alarm
(row 10 of
.agents/contract.toml) pages when an intent stays unapplied past its grace. That grace is 210 minutes: above the 120-minute window, the 15-minute drain cadence and a bump PR's queue validation, so a coalesced bump never reads as a stuck bot.test_pending_intent_liveness.pyfails if the grace drops under that sum or the workflow default and the script default disagree. RaisingPULP_BUMP_COALESCE_MINUTESmeans raising the grace with it. - No regression / no double-bump.
_strictly_increasingdrops any assignment that does not exceed the surface's version at the fresh head, so a stale drain can never walk a version backward (independent of whether the marker survives the merge).
The full rollout, merge-queue validation evidence, and canary checklist live in
planning/2026-07-20-merge-queue-reenable-plan.md.
Rollback¶
The flip is two workflow edits (version-at-land.yml back to --mode dry-run;
remove --accept-intent-trailers from version-skill-check.yml; remove the
PULP_ACCEPT_INTENT_TRAILERS=1 export from auto-release.yml) plus reverting
Shipyard to file-bump. Reverting leaves the bot in dry-run — safe. Any PR
already merged intent-only during the live window keeps its bot-assigned bump;
no data is lost by rolling back.
One caveat — do not declare rollback complete with a pending intent
outstanding. If a fix/feat merged intent-only (commit X) but the bot never
assigned its number (commit Y never landed — a mid-flight failure is the reason
you are rolling back), simply reverting the workflows to dry-run STRANDS X: no
bump, no tag, and the dry-run bot will not retry. Before reverting, drain the
outstanding range with one version_at_land.py --push (or hand-apply the bump
per the stranded-fix recovery recipe in auto-release.yml). The pending-intent
liveness alarm names exactly which surfaces are outstanding.
GO / NO-GO checklist for the --push flip¶
Flip only when ALL are true:
- Prep branch (landmine delete + scoping + hardening + tests) merged to
mainand green. -
tools/scripts/test_version_at_land.pypasses onmain(scoping + concurrency proofs). - Release bot verified able to push a signed commit to protected
mainon a scratch branch (bypass list +RELEASE_BOT_SSH_SIGNING_KEY). - Merge queue drained; no open PR carries a hand-written
chore: bump versionscommit (or each straggler handled per the rule). -
version-skill-check.ymlflipped to--accept-intent-trailersAND Shipyard/pulp premits intent trailers (verified on one real PR). -
auto-release.ymlstranded detector exportsPULP_ACCEPT_INTENT_TRAILERS=1in the SAME change as the push flip. -
version-at-land.ymljob hascontents: write+ the SSH-signing step. - A dry-run of the flipped
version-at-land.ymlon a scratch push shows the correct single assignment before enabling--push.
The exact one-line diff that performs the flip¶
In .github/workflows/version-at-land.yml, the Dry-run version assignment
step:
- - name: Dry-run version assignment
+ - name: Assign versions (single-writer push)
run: |
- python3 tools/scripts/version_at_land.py \
- --base "${{ steps.base.outputs.ref }}" \
- --head HEAD \
- --mode dry-run
- echo "::notice::version-at-land dry-run complete (no files written)."
+ python3 tools/scripts/version_at_land.py --push \
+ --base "${{ github.event.before }}"
(--push derives the range from a fresh origin/main itself and prefers the
Version-Bump-Applied marker; --base is only the first-run FALLBACK for the
very first drain before any marker exists — github.event.before bounds it to
exactly this push. The job's permissions: must become contents: write and
an SSH-signing step must run before this step — those are part of the same
reviewed flip, not a separate one-liner.)