Skip to content

Version-at-Land Cutover — flipping the single-writer bump bot to --push

STATUS: FLIPPED LIVE — 2026-07-17. version-at-land.yml runs --push; version-skill-check.yml accepts intent trailers; auto-release.yml's stranded detector exports PULP_ACCEPT_INTENT_TRAILERS=1.

MODEL B (post-merge assignment) — the chosen finish. versioning.json sets "post_merge_assignment": true, so PRs touch NO version files at all: version_bump_check.py --mode=apply is a verified no-op (so shipyard pr produces 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: a fix:/feat: the heuristic scores none on EVERY surface still HARD-FAILS (re-title, add an intent trailer, or Version-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_assignment back to false (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 */15 schedule with 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 versions subject and a Version-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_globs from 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.txt only the project(... VERSION x.y.z) number, and tools/scripts/agent_capability_manifest.py only the MANIFEST_REVISION / SURFACE_INVENTORY_VERSION counters, 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

  1. 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 via version_bump_check.py --accept-intent-trailers (see version_bump_render.render_report): a touched surface must EITHER be file-bumped, OR carry an intent trailer, OR carry a Version-Bump: skip reason="..." trailer — otherwise it still hard-fails.

  2. A single writer assigns the number after merge. .github/workflows/version-at-land.yml runs on every push to main. In --push mode it recomputes from a fresh origin/main, assigns each declared surface the next version from main's CURRENT value, commits chore: bump versions with a Version-Bump-Applied: marker, and pushes --ff-only with 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.

  3. The bot's chore: bump versions commit triggers auto-release.yml exactly 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 by gpu_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 local pulp-gpu-ledger merge driver cannot run. The bump commit advances main by itself, so a re-pin there costs nobody a rebase. PRs consequently do not re-pin at all; gpu_handoff_pin_freshness.py rejects 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 an authorities / upstream / vellum_paths / terminal_evidence edit. None of those is a decision a regenerator can make, and each still runs write --receipt in 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-only push (no --force): git's default non-fast-forward rejection means a second writer that advanced main between our recompute and our push is rejected, never clobbered. (The DELETED intent-bump-on-merge.yml did a bare git push origin HEAD:main with no --ff-only + retry — a merge during its ~30s window silently discarded the bump. That workflow and apply_intent_bump.py were 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-Applied marker and collapses to empty — so the loser no-ops instead of double-bumping.
  • the Version-Bump-Applied marker: 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)

  1. Land the safe prep FIRST (this branch): landmine deleted, trailer scoping fixed, apply_and_push hardened + tested, --accept-intent-trailers verified, stranded detector taught PULP_ACCEPT_INTENT_TRAILERS. All dry-run; nothing about releases changes. Merge it normally.

  2. Prove the release-bot commit path. The bot must push a commit to protected main. It already pushes tags from auto-release.yml; a commit additionally needs the bot on the branch-protection bypass list, and the commit must be SSH-signed via configure_release_bot_ssh_signing.sh (secret RELEASE_BOT_SSH_SIGNING_KEY). Verify on a scratch branch before touching main.

  3. 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 versions commit. Confirm main's version files are internally consistent.

  4. Apply the one-time straggler rule (below) to any PR that is already in flight and still carries a bump commit.

  5. 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-trailers on the version_bump_check.py invocation, and switch Shipyard / pulp pr to emit Version-Bump: <surface>=<level> trailers instead of applying file bumps. Do not pass both flags: --require-bump-for-fix-feat runs a separate check_fix_feat_requires_bump() that accepts only a chore: bump versions commit or a Version-Bump: skip trailer — never a positive intent — so the two flags FAIL CLOSED on exactly the intent-only PR shape this model produces. --accept-intent-trailers alone loses no coverage (a touched surface with no bump/intent/skip still hard-fails). Locked in by test_version_bump_intent.py::test_combined_flags_fail_closed. (Surface bump levels are already derived by version_bump_check's heuristic; the change is "declare, don't write".)

  6. Flip version-at-land.yml to --push — the one-line diff in the GO/NO-GO section. Give the job contents: write, add the SSH-signing step (tools/scripts/configure_release_bot_ssh_signing.sh), and a recursion guard is NOT needed (the Version-Bump-Applied marker makes the bot's own commit a no-op drain). The workflow has NO paths: filter — it runs on every push to main. 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-only retry — 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.

  1. Teach the stranded detector. In the Stranded fix/feat detector step of .github/workflows/auto-release.yml, export PULP_ACCEPT_INTENT_TRAILERS=1. This makes classify-unreleased-range treat 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 (while version-at-land is still dry-run) would blind the detector to genuinely stranded fixes, because nothing would be applying the intent.

  2. Watch the first N merges. Confirm each release-worthy merge produces exactly one bot chore: bump versions commit and one tag, and that release-cadence-check.yml stays 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 versions commit and the version-file edits from the branch, and add the Version-Bump: <surface>=<level> trailer instead. shipyard pr / pulp pr post-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" so version-at-land does 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_push pushes the chore: bump versions commit straight to main with --ff-only. This publishes releases today. It is incompatible with a "Require merge queue" branch rule — that rule blocks all direct pushes to main, which is exactly what broke releases on 2026-07-20 when the queue was briefly enabled.

  • pr (dormant until flipped): apply_via_pr pushes the bump to the fixed release/version-bump branch, opens a chore: bump versions PR, 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; a GITHUB_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 the Version-Bump-Applied marker commit and trips an auto-release false alarm.
  • Serialized. When PULP_BUMP_ROUTE=pr the workflow's concurrency group 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. The direct path 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 a dequeued event for another PR or a green bump-branch workflow_run would otherwise cancel a pending push drain and then skip its own job. Those runs get a per-run group instead. test_version_at_land.py evaluates the group expression and the job if against 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 last Version-Bump-Applied marker 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-lease at 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 is PULP_BUMP_COALESCE_MINUTES old (default 120; 0 disables), 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 a release_now: true dispatch. The */15 scheduled 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 the PULP_BUMP_HEAL_STALE_PR=true repo 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's dequeued event and on a non-green completion of a workflow reporting its required checks (workflow_run, scoped to release/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 than PULP_BUMP_COALESCE_MINUTES ago (default 120; 0 disables). 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 with release_now: true. The */15 scheduled 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 is coalesce_decision in version_at_land.py, covered by test_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.py fails if the grace drops under that sum or the workflow default and the script default disagree. Raising PULP_BUMP_COALESCE_MINUTES means raising the grace with it.
  • No regression / no double-bump. _strictly_increasing drops 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 main and green.
  • tools/scripts/test_version_at_land.py passes on main (scoping + concurrency proofs).
  • Release bot verified able to push a signed commit to protected main on a scratch branch (bypass list + RELEASE_BOT_SSH_SIGNING_KEY).
  • Merge queue drained; no open PR carries a hand-written chore: bump versions commit (or each straggler handled per the rule).
  • version-skill-check.yml flipped to --accept-intent-trailers AND Shipyard/pulp pr emits intent trailers (verified on one real PR).
  • auto-release.yml stranded detector exports PULP_ACCEPT_INTENT_TRAILERS=1 in the SAME change as the push flip.
  • version-at-land.yml job has contents: write + the SSH-signing step.
  • A dry-run of the flipped version-at-land.yml on 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.)