Skip to content

Versioning & Skill-Sync Policy

Pulp versions three surfaces independently:

  • SDK / CLI — CMakeLists.txt project(... VERSION x.y.z). Cascades to PULP_SDK_VERSION in generated headers and to the CLI binary's pulp --version. The source-only combined-installer recipe is also mapped to this surface: SDK consumers execute it from a detached Pulp checkout at the SDK provenance SHA, so a recipe fix needs a new SDK tag even though the script is not installed in the SDK prefix yet. The provenance marker authoring helper and installed PulpSdkProvenance.cmake contract are mapped here too: changing either changes the identity or admission rules of the SDK that downstream consumers receive.
  • Claude Code plugin — .claude-plugin/plugin.json version and .claude-plugin/marketplace.json version.
  • Shipyard pinned binary — [shipyard] in tools/toolchain/manifest.toml (version, repo, and the sha256 of every release asset), generated into tools/shipyard.toml and tools/toolchain/shipyard.sha256, consumed by tools/install-shipyard.sh. This is an upstream release we consume, not a surface we ship; see Dependency Update Workflow for pin bumps.

After a pin bump, regenerate every generated toolchain projection with python3 tools/toolchain/generate.py; never hand-edit the derived files.

This pin governs what a fresh checkout installs, so an already-provisioned machine keeps running whatever it has and any drift between the two is invisible from the repo. When reasoning about it, check what the hosts actually run (shipyard --version on each) rather than assuming this file describes them.

The first two are enforced: PRs that change code in a surface's trigger paths without bumping its version are rejected before merge. The third is covered by the existing tools/deps/audit.py path and is outside this guide's scope.


Three-layer gate

Layer 1 (fast, per-edit, agent-specific)
    hooks/scripts/cli-plugin-sync.sh, Claude Code PostToolUse hooks
    → advisory "hint" mode output only

Layer 2 (pre-push, agent-agnostic, ENFORCING — pulp #1144)
    .githooks/pre-push
    → same scripts, "report" mode, BLOCKS push by default
    → PULP_DISABLE_PREPUSH_GATES=1 demotes to advisory (the old default)
    → PULP_DISABLE_PREPUSH_DIFF_COVER=1 demotes the diff-cover gate only
    → PULP_SKIP_PREPUSH=1 skips ALL gates (true emergencies)

Layer 3 (PR gate, authoritative)
    .github/workflows/version-skill-check.yml
    tools/shipyard.toml `version-skill-check` stage
    → same scripts, "report" mode, no bypass without commit trailer

Every layer calls into the same two Python scripts — tools/scripts/version_bump_check.py and tools/scripts/skill_sync_check.py. Heuristics live in the scripts; layer differences are only about where and how the exit code is enforced.


Platform-asymmetric gates

Every enforcing gate above runs on macOS, so a break only another toolchain rejects passes all three layers. version-skill-check.yml therefore also runs the self-tests for the compiler-asymmetry lints:

Lint Catches
raw_pid_probe_lint.py a liveness probe written as os.kill(pid, 0), which on Windows sends Ctrl+C to the whole console; probe through process_liveness.pid_alive
intel_canary_lint.py arm64-only code that drops the SSE/scalar fallback
designated_initializer_lint.py duplicate designators in one aggregate — legal to Clang, C7560 on MSVC
win32_include_lint.py raw <windows.h> in an installed header — leaks the min/max macros, C2589 on MSVC
safe_path_guard_lint.py an untrusted-path guard in core/ or tools/cli/ that trusts is_absolute(), which on Windows accepts /x, \x and C:x, instead of calling pulp::runtime::is_safe_relative_path
text_encoding_lint.py Python text I/O (read_text, write_text, open, subprocess with text=True) without encoding=, which decodes with cp1252 on Windows; the existing backlog is held by a per-file baseline (text_encoding_baseline.json) that can only go down
migration_note_guard.py a pull request adding docs/migrations/vX.Y.Z.md by hand. The release version is assigned after the change lands, so the note belongs in docs/migrations/unreleased/<topic>.md, which the version-at-land bump folds; only a bump commit (one with the Version-Bump-Applied: marker) may add a released note. Editing a released note is allowed
script_inputs_precheck.py a new add_test(NAME <name> COMMAND ...) that runs a Python, shell or Node script but has no entry in test/ctest_script_inputs.json. Build-free counterpart of CI's script-test-inputs-drift: it reads only the CMake text the range adds. A computed name or the old add_test(<name> <cmd>) form is reported as not checked, never passed; # script-inputs: not-gated <reason> on or above the call skips a deliberately ungated test. Stale entries (a script that gained an import) still need the configured check
python_floor_syntax_check.py a tracked *.py file that Python 3.12 parses but 3.11, the gates' floor, rejects: an f-string field that reuses the enclosing quote or holds a backslash (including a line continuation, or in a nested f-string's text), comment or (single-quoted) line break. CI runs 3.12, so only a 3.11 run (CMake's default on a stock Homebrew Mac) would see it

gates.sh runs designated_initializer_lint.py diff-scoped and win32_include_lint.py over core/*/include before a push, and safe_path_guard_lint.py over core/ and tools/cli/. gates.sh and the pre-push hook also run text_encoding_lint.py --base, the same invocation as CI's Enforce version & skill sync, so a missing encoding="utf-8" fails before the push rather than in CI, and script_inputs_precheck.py --base, which catches a new script-driven ctest with no test/ctest_script_inputs.json entry without the configured build script-test-inputs-drift needs. All are canaries, not substitutes for building on the target: they check what a regex can check without a compiler, which for designated initializers means duplicates but not declaration order.

win32_include_lint.py covers a case neither the macos gate nor Pulp's own Windows lane can see. <windows.h> has an include guard, so the first header to reach it decides NOMINMAX for the whole translation unit — a raw include is latent and breaks a consumer only when it wins that race. The Windows lane builds the library, not a plug-in against the cmake --installed headers, so the failure lands on downstream consumers. Use pulp/platform/win32_sane.hpp.

The scripts

version_bump_check.py

Answers "which surfaces need a bump for this diff, and are they bumped?" Modes:

Mode Behavior
report Exit 1 if any surface needs a bump it hasn't gotten. CI + pre-push.
apply Rewrites version files in place for surfaces that need a bump. Called from pulp pr.
hint Always exit 0; prints advisory text. Agent PostToolUse hooks.

Verdict rules per surface, in order:

  1. Path heuristic picks a default. Public-header edits → minor-required. Internal-only edits → patch-suggested (warning, not hard fail). Non-trivial comment-or-whitespace-only diffs are downgraded to patch-suggested.
  2. Conventional-commit signals (feat:, fix:, BREAKING:, !: in subjects) can raise a surface's level — but only if that surface's paths were touched. A plugin-only feat: never upgrades the SDK. Reverts (Revert "..." subject or Revert-Of: trailer) suppress their signal.
  3. Explicit trailer wins as the ceiling/override:
    Version-Bump: sdk=major reason="removed PulpFoo::bar"
    Version-Bump: plugin=skip reason="docs-only PR, no plugin behavior change"
    
    Per-surface. The trailer applies only to surfaces whose paths were actually touched — it cannot authorize a bump for an untouched surface.

skill_sync_check.py

Answers "did the diff touch paths mapped to a skill, and was that skill's SKILL.md updated?" One skill map — tools/scripts/skill_path_map.json — is the single source of truth. If a change touches a skill's paths and the SKILL.md isn't updated, the check hard-fails unless the tip commit carries:

Skill-Update: skip skill=<name> reason="mechanical rename, no new lesson"

The script's self-check also fails if any directory under .agents/skills/ lacks an entry in the map — the map is deliberately explicit so it's reviewed alongside skill changes.

skill_path_map_lint.py

Checks the map itself. The gate above is only as good as its rules, and a rule that matches nothing does not report a problem — it reports nothing, which is indistinguishable from a clean run. Runs from gates.sh, the pre-push hook, and the version-skill-check workflow; pure stdlib and sub-second.

Rule Scope What it rejects Escape hatch
schema whole-tree The map violating skill_path_map.schema.json, or that file being absent. Catches an entry written as a bare array (parses to zero patterns), an unknown key, a malformed pattern, a placeholder annotation. none
submodule whole-tree A pattern rooted inside a git submodule. The superproject's diff carries the planning gitlink, never a path beneath it, so such a pattern cannot fire for any commit. none — no annotation makes it reachable
empty whole-tree A pattern matching no tracked file. Whole-tree because the usual cause is the tree moving out from under a pattern nobody edited. _doc.empty-ok, restricted to external/ SDK paths and to entries claiming no paths at all
co-claim diff-scoped Newly claiming an entire <root>/<sub>/** subsystem another skill already claims. Every edit under it would then demand several SKILL.md updates, which trains reflexive Skill-Update: skip trailers. _doc.scope on the entry, saying why

The schema is validated by json_schema_lite.py, a small stdlib validator for the JSON Schema subset Pulp's config schemas use. A keyword it does not implement raises rather than being skipped, so a schema author cannot write a constraint that is silently never checked.

Fixture and real-map tests live in tools/scripts/test_skill_path_map_lint.py and tools/scripts/test_skill_path_map.py, both imported by tools/scripts/test_gates.py — the module CI's "Gate-script fixture tests" step runs. A TestCase not imported there runs nowhere.


Pre-push hook

# One-time, per-checkout
tools/scripts/install-githooks.sh
# or:
git config core.hooksPath .githooks

After that, every git push runs both scripts. As of pulp #1144 the gates block the push by default — fix the violation locally rather than burning a 20-min CI roundtrip. Bypass knobs (use sparingly; CI runs the same gates regardless):

PULP_DISABLE_PREPUSH_GATES=1 git push          # demote skill/version/compat/deps to advisory
PULP_DISABLE_PREPUSH_DIFF_COVER=1 git push     # demote diff-cover gate only
PULP_SKIP_PREPUSH=1 git push                   # skip ALL gates (true emergencies)

The legacy PULP_ENFORCE_PREPUSH=1 and PULP_ENFORCE_PREPUSH_DIFF_COVER=1 env vars are accepted as silent no-ops — they used to promote advisory warnings to hard failures, which is now the default. Setting them just confirms what you already get.

A push to a queued PR is refused

A PR in the merge queue never needs a rebase or a merge of main: the queue builds it on top of current main. GitHub also refuses pushes to a queued branch, so pushing one anyway only ran the multi-minute pre-push build before the rejection, and led agents to dequeue and retry in a loop. The hook therefore runs tools/scripts/prepush_queue_rebase_hint.py --refuse-queued first, before every gate and the diff-coverage build, and refuses the push when GraphQL shows the branch's open PR in the queue.

Setting Effect
(default) refuse a push to a queued PR; armed-but-not-queued only gets the advisory hint
PULP_QUEUE_REBASE_TIMEOUT lookup budget in seconds (default 3)

There is no override, and PULP_SKIP_PREPUSH=1 does not bypass the refusal: an override recreates the dequeue loop. To change a queued PR, either open a new PR, or dequeue it through the guarded ghapp path with a stated reason (GHAPP_QUEUE_REMOVAL_REASON=defect-fix) and then push. A lookup that cannot answer (no ghapp/gh, auth, network, timeout, bad JSON) prints queued-PR check skipped and lets the push continue.


CI workflow

.github/workflows/version-skill-check.yml runs on every PR to main or develop. It fetches full history (so origin/base_ref is reachable) and invokes the two scripts in report mode. Failure blocks merge. Its concurrency uses cancel-in-progress: false (2026-07-21): because it posts the required Enforce version & skill sync check, cancelling an in-flight run under a churning main would leave that required check stuck at cancelled (never success), silently blocking merge. Do not flip a required-check gate back to cancel-in-progress: true.

Same-repository PR and merge-group runs select the restricted organization Linux pool through PULP_AUTO_LINUX_RUNS_ON_JSON; fork PRs and an unset selector fall back to hosted Linux. Only trusted manual dispatch may use the separate repository-scoped PULP_LOCAL_LINUX_RUNS_ON_JSON lane. Keeping those selectors distinct prevents automatic branch content from matching the less-isolated repository runner through GitHub's subset-based label selection.

Every gate in this workflow compares HEAD against one diff base, which the "Resolve diff base" step computes once through tools/scripts/ci_diff_base.py (tests: tools/scripts/test_ci_diff_base.py, run by the same workflow).

  • On merge_group the base is the queue entry's own github.event.merge_group.base_sha. A merge-group commit is built on top of every entry ahead of it, so a merge-base with origin/main would put those entries' commits in this entry's range. A fix: touching no surface in an entry ahead would then eject the innocent entry behind it while the offender merged. A run with no base_sha, or one that is not an ancestor of HEAD, fails closed instead of falling back.
  • On pull_request and manual dispatch the base is the merge-base of the base branch and HEAD. The base tip is the wrong anchor for any gate that reads a file at the base (node_abi_gate.py's virtual order, skill_path_map_lint.py's co-claim map, hotspot_size_guard.py's new-file list): a PR merely behind main would be blamed for whatever main changed after it branched, such as a virtual appended to a header the PR never touched. Those three scripts also resolve the merge-base themselves, so the pre-push hook and gates.sh, which pass origin/main, behave the same way. Three-dot diffs and base..HEAD commit ranges give identical results under either anchor.

Alongside the version and skill gates, this same workflow enforces two house invariants over Pulp's own source, both hard-failing:

  • Framework-neutrality (framework_neutrality_check.py): Pulp's source names no other framework and adopts none of their type names. See the guard's own header for scope and exemptions.

It also runs in the pre-push gates.sh, so a violation is caught before the push.

The header fan-out guard (header_fanout_guard.py, ledger header_fanout_guard.json) runs in the same workflow, gates.sh, and the pre-push hook. It counts the translation units whose static #include closure contains each tracked header. A tracked header over its max_tus ceiling fails only when the PR grew it relative to the merge-base, which is the same rebase-stable rule the hotspot guard uses. A Fanout-Grow: <path|all> reason="..." trailer authorizes deliberate growth.

The workflow runs every registration listed in tools/ci/source_selftests.json with tools/ci/source_selftests.py run --min-count 130. Those are Python ctests that read only the checkout; the required macos gate excludes their source-selftest label on gate events, so this workflow is where they block a merge. The run is split across jobs by lane_parts(): - The Source-only selftests step of the Version & skill sync checks job runs --part shared, the entries that share the machine. - The Exclusive source selftests 1/2 and 2/2 jobs run --part exclusive --shard N/2. These are the entries with PROCESSORS above 1, each still alone on its runner.

The required context Enforce version & skill sync is a third job. It needs both, runs if: always(), and fails unless both concluded success, so a failed, cancelled or skipped part fails the check. Before the split, the whole-machine entries ran one after another at the end of a single job. That was about 70% of the selftest step and most of the check's ~10 min P50. Every job needs the full-history checkout and nothing but the standard library. See test-lanes.md for how a test joins or leaves the lane.

When the repository variable PULP_CLASSIFY_WIDE_NON_NATIVE is 1, one more step, Wide non-native tier, runs tools/ci/wide_non_native_checks.json through the same runner (after installing PyYAML for tools_registry_check.py; the install goes through tools/ci/net-retry.sh, three attempts spaced 20 s and 40 s apart, so a PyPI blip does not fail this required context). Those are the macOS gate's repository scanners that read tools/** without naming files; they must run here because build.yml's classify job may then skip the native matrix for a tooling-only change. Unset, the step is skipped.

The planning-gitlink guard (tools/scripts/planning_gitlink_guard.py) checks that an authorized planning re-pin is an ancestor of pulp-planning main, by fetching that repository's commit history. This workflow has no credential for the private planning repository, so it passes --ci-warn-only: an ancestry it cannot verify becomes a ::warning annotation and the step passes, while a pin it could fetch and found off main still fails. Pre-push and gates.sh run without the flag and fail closed on an unverifiable pin, using the developer's own access. Granting this workflow a read-only token for pulp-planning and dropping --ci-warn-only makes CI enforce the ancestry too.

The bump skips the advisory coverage run

coverage.yml ANDs github.head_ref != 'release/version-bump' into every job's condition, so the release bot's bump pull request no longer starts a coverage run (about 2.5 h of hosted runners for a tree that is main's plus version files). No coverage context is required; every other pull request, schedule and dispatch runs as before. tools/scripts/test_bump_fastpath.py checks the guard on every job.

coverage.yml routes a fork pull request, including one whose fork was deleted, to GitHub-hosted runners on every leg; the gate's verdict for a fork is unchanged.

Action-major pins in the gate workflows

The gate workflows (version-skill-check.yml, coverage.yml) run their scripts on a actions/setup-python step, so a retired action runtime degrades the enforcement layer itself. GitHub forces an obsolete major onto a newer Node runtime and reports it only as a deprecation annotation on a live run, which no local gate can see.

The pin therefore lives in one asserted invariant rather than in each workflow: tools/scripts/test_workflow_lint.py::ActionMajorPinTests fails when any actions/<name>@vN in .github/workflows falls below MINIMUM_MAJOR, or when one action is pinned to two different majors across workflows. setup-python is pinned at v6. When bumping an action, raise MINIMUM_MAJOR — do not add a version assertion to an individual workflow's test.

There is deliberately no bypass in CI other than the commit trailers. The audit trail lives in git, not in GitHub labels or PR-body text.

CI cadence — macOS is the product (2026-07-17)

Pulp ships on macOS; other platforms are "know they don't break," not the product. So CI runs on a per-platform CADENCE, not per-branch: the macOS gate + macOS sanitizers + the required checks run on EVERY PR; heavy advisory / secondary-platform lanes (coverage, non-skia-build-guard, android, visual-harness, tracing-build, sandbox-e2e, install-consumer-smoke, pulp-react-build) moved OFF pull_request to push: main + nightly + workflow_dispatch — caught within one merge, not on every PR-update. This un-starves the GitHub-hosted ubuntu pool the release bot + version-skill run on. It is a reversible DIAL: ratchet a platform back up by restoring its pull_request trigger. See planning/fleet/2026-07-17-ci-destarve-analysis.md.

Relative-timing / perf tests are off the required gate (2026-07-21). ctest labels performance, bench, and quality-lab are excluded from the required PR/merge_group macos run (build.yml label_exclude). These are ratio / CPU-budget / benchmark tests (e.g. heritage-performance's shipping-CPU-budget gate) that tolerate steady load but flake under the load variance of the Studio's 2 concurrent build VMs (cap=2) — a perf/ratio test cannot be a required gate on a runner that (correctly) runs 2 VMs at once. They still run on push (informational) and belong in a dedicated cap=1 nightly/perf lane. Same reversible-DIAL principle: drop the labels from the exclude to re-gate.

fix/feat-needs-bump (issue #1009)

Intent-trailer model is LIVE (2026-07-17): on pull_request events the workflow now runs version_bump_check.py --accept-intent-trailers (a SWAP of the flag below, not an add — the two fail closed together). A release-worthy PR DECLARES Version-Bump: <surface>=<level> and touches no version files; the post-merge version-at-land bot assigns the number. A touched surface with no bump/intent/skip still hard-fails, so coverage is unchanged. See docs/guides/version-at-land-cutover.md. The historical file-bump behavior below still describes what a file-bump satisfies.

On pull_request events, the workflow additionally runs version_bump_check.py --require-bump-for-fix-feat. This asserts that PRs whose title or any live commit-derived signal carries the Conventional Commits fix: or feat: prefix include either (explicit reverts cancel their target signals, while reverting a revert restores them):

  • a commit with subject chore: bump versions in the diff range (the canonical subject pulp pr writes when the bump was applied), OR
  • a top-level Version-Bump: skip reason="..." trailer (with non-empty reason) on any commit in the range.

Otherwise the check hard-fails with a message that suggests both fix paths. Commit subjects are part of the signal because GitHub's COMMIT_OR_PR_TITLE squash policy uses the sole commit subject for a one-commit PR and the editable PR title for a multi-commit PR. The motivating incident (PR #1008, 2026-04-30) merged a user-facing fix without a bump; the existing per-surface verdict heuristic classified the diff as patch-suggested (advisory), so nothing blocked the merge and auto-release.yml had nothing to tag.

For PRs targeting main, the required check's run summary also reports the SDK and plugin tags expected after merge. The prediction uses the PR tree, actual PR commit count and PR title for squash guard semantics, currently fetched tags, and sticky Release: skip state. It is advisory evidence for the PR queue; the actual signed tags are still created post-merge by auto-release.yml.

For full design + recommended branch protection see docs/guides/release-watchdog.md.


Shipyard stage

tools/shipyard.toml gains a version-skill-check stage that mirrors the CI workflow. This keeps shipyard pr (the primary PR/shipping path) in lockstep with GitHub Actions.

Note: Shipyard configuration changes (the tools/shipyard.toml file itself, tools/install-shipyard.sh, and everything under .github/workflows/) are mapped to the ci skill in skill_path_map.json. That means editing the merge workflow automatically demands a ci SKILL.md review — the skill-sync gate catches shipyard-config drift the same way it catches subsystem-code drift.


"Push a PR" — the one-command path

Typing shipyard pr (or saying "push a PR" / "ship this" / "we're done" to an agent configured with this policy in CLAUDE.md) runs the full pipeline:

  1. skill_sync_check.py --mode=report — hard-fails here if a mapped path is touched without a SKILL.md update. The only reason to bounce back to you is to add a gotcha or a bypass trailer.
  2. version_bump_check.py --mode=apply — applies the required bump(s) to CMakeLists.txt / plugin.json / marketplace.json, staging them. Appends a CHANGELOG stub.
  3. git commit — single "chore: bump ..." commit.
  4. Branch push + PR creation + Shipyard state recording.
  5. Cross-platform validation + merge on green.
  6. On merge, .github/workflows/auto-release.yml diffs the version files against the previous push, creates the matching tag(s), and the existing tag-triggered release workflows publish binaries.

The PR title and body come from the branch's non-merge commits (origin/main..HEAD), never only the tip commit, so the body is never empty. .shipyard/config.toml sets [pr.body] attribution, which Shipyard appends once to every body it composes; a PR opened this way needs no hand edit to add the attribution line.

Never type gh pr create + shipyard ship separately. Never run the version-bump scripts by hand unless debugging. Direct gh pr create is a manual bypass only and can leave a PR outside Shipyard's tracked state until reconciled.


One-time setup: RELEASE_BOT_TOKEN secret

The auto-release workflow needs a fine-grained PAT to push tags so that the tag-triggered binary workflows (release-cli.yml, sign-and-release.yml) actually fire. Without this secret, auto-release silently degrades: tags are still created via GITHUB_TOKEN, but GitHub Actions deliberately does not chain workflows from GITHUB_TOKEN-pushed tags (anti-infinite-loop safety), so the binary release workflows never run and no GitHub Release appears.

Run pulp doctor to check whether the secret is configured. If it shows RELEASE_BOT_TOKEN secret — missing, set it up:

  1. Generate the token. github.com → top-right avatar → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
  2. Token name: pulp-release-bot (or any descriptive name).
  3. Expiration: 1 year (mark your calendar to renew).
  4. Resource owner: the org or user that owns this repo.
  5. Repository access: Only select repositories → this repo only.
  6. Permissions (Repository permissions section): Contents: Read and write. Leave everything else at the default.
  7. Generate, copy the token (starts with github_pat_…).
  8. Add to repo secrets: github.com/<owner>/<repo>/settings/secrets/actions → New repository secret. Name: RELEASE_BOT_TOKEN. Value: paste the token.

That's it — no code change needed. The workflow already reads ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }}. pulp doctor will then report RELEASE_BOT_TOKEN secret — configured .... pulp pr will also stop printing the heads-up warning before each push.

Manual fallback when the secret isn't set

The chain still works but requires one manual step per release after the auto-tag appears:

gh workflow run release-cli.yml --ref v<x.y.z>
gh workflow run sign-and-release.yml --ref v<x.y.z>

(Pulp's first auto-released tag, v0.4.0, used this fallback before RELEASE_BOT_TOKEN was provisioned.)


Release pipeline — how CHANGELOG.md and the Release page are produced

Two artifacts are produced for every release: the GitHub Release page (shown to users at github.com/.../releases/tag/vX.Y.Z) and the repo's CHANGELOG.md (shown to developers on main). The Release page is composed by Pulp's release workflow; CHANGELOG.md is rendered inside the version-bump commit.

Division of labor:

  • Shipyard — pre-merge. shipyard pr creates/tracks PRs and validates them. It does not touch tags, CHANGELOG.md, or the Release page.
  • Pulp's .github/workflows/version-at-land.yml — post-merge. Writes the version bump and, in the same commit, the derived files that embed or summarize releases. tools/scripts/version_at_land.py lists them in _DERIVED_REGENERATORS; one is shipyard changelog regenerate, which rewrites CHANGELOG.md from every tag cut so far. The workflow installs the pinned Shipyard (tools/install-shipyard.sh) for that step, passing GITHUB_TOKEN so the installer's release lookup is authenticated. Under GitHub Actions the installer refuses to run without GITHUB_TOKEN or SHIPYARD_GITHUB_TOKEN, because anonymous lookups from shared runner IPs hit the API rate limit (HTTP 403); tools/scripts/install_shipyard_token_check.py (run by workflow-lint.yml) flags any workflow step that installs Shipyard without one. Local runs stay anonymous.
  • Pulp's .github/workflows/auto-release.yml — post-merge. On push to main, diffs version files and, if an SDK version moved, creates the vX.Y.Z tag.
  • Pulp's .github/workflows/release-cli.yml — post-tag. On vX.Y.Z push, builds binaries and creates the GitHub Release with a body composed from tools/scripts/compose_release_notes.py Highlights, GitHub's generated "What's Changed" / "Full Changelog" block, and the install instructions.

End-to-end sequence:

PR merges through the queue
       ↓
version-at-land.yml  (bump PR: versions + CHANGELOG.md rendered from existing tags)
       ↓  (bump PR merges through the queue)
auto-release.yml  (diffs version files, pushes vX.Y.Z tag)
       ↓
release-cli.yml  (build binaries, compose_release_notes.py, GitHub Release body)

One bump PR covers many merges. On the PR route the drain does not open a bump PR per merge. While other PRs are waiting in the merge queue and the last bump landed under two hours ago (PULP_BUMP_COALESCE_MINUTES, default 120), it holds the new bump PR, which then covers every merge since the last marker. Dispatch version-at-land.yml with release_now: true when a release needs the number sooner. The pending-intent liveness alarm's grace (210 minutes) sits above that window. Details: docs/guides/version-at-land-cutover.md, "Coalescing".

The changelog trails by one release. The bump for vX.Y.Z is written before its tag exists, so vX.Y.Z's section reaches CHANGELOG.md with the next bump. A separate post-tag PR would close that gap but pay the required gate a second time per release for one docs file; the GitHub Release page is current the moment the tag lands.

CHANGELOG.md source of truth: shipyard changelog regenerate (shipyard v0.11+), configured via [release.changelog] in .shipyard/config.toml. With no args it rewrites CHANGELOG.md from the full tag graph and is idempotent. If a bump run logs derived regenerator failed, leaving CHANGELOG.md stale, the next bump retries it; to catch up by hand, run it locally and open a docs PR.

GitHub Release body source of truth: release-cli.yml calls compose_release_notes.py to render grouped Highlights from the tag range, then calls GitHub's generated-notes API for the native "What's Changed" and "Full Changelog" block. Release-Note: <one-line summary> trailers override the raw PR title in Highlights; missing trailers fall back to the title and never block a release.

Editing by hand: manually editing CHANGELOG.md between releases is fine; the generator picks up edits on the next render as long as they land in the right release's bullet block.


Agent parity

Both Claude Code and Codex pick up this policy from CLAUDE.md. Codex reads AGENTS.md which is a thin pointer at CLAUDE.md — the single source of truth for both agents. There is no separate policy file for Codex, and AGENTS.md intentionally stays empty so the two never drift.

codex_hooks remains an experimental Codex feature (confirmed 2026-04-12 via codex features list). A .codex/hooks.json may be added as an advisory path later, but CI is the authoritative gate — agent hooks only make failures visible earlier in the loop, not block anything.


Bypassing a check

All three bypass trailers live on the tip commit, never in the PR body. The audit trail must be in git.

Check Trailer
Version bump Version-Bump: <surface>=<patch|minor|major|skip> reason="..."
Skill update Skill-Update: skip skill=<name> reason="..."
Compat update Compat-Update: skip prefix=<section\|*> reason="..."
Config doc Config-Doc: skip reason="..."
Auto-release Release: skip reason="..."

A bypass is a recorded admission that the author thought about the rule and decided it doesn't apply. Empty-reason bypasses are rejected.

The compat-update gate uses the same three-layer pattern as the version-bump and skill-update gates; see compat-sync.md for the path map, requirement kinds, and rollout state (#1029 / #1027).

Config→doc drift gate

Some CI/release config surfaces are described by a human-facing guide that silently goes stale when the config changes without a matching doc edit. tools/scripts/config_doc_check.py closes that gap with the same three-layer pattern as the gates above: for each entry in tools/scripts/config_doc_map.json, if any of the entry's config paths changed in the diff range but none of its docs did, the gate fails.

The seeded map covers the Shipyard validation config (.shipyard/config.toml, .shipyard.local/config.toml.example), the Shipyard binary pin + installer (tools/shipyard.toml, tools/install-shipyard.sh), the build workflows (build.yml, build-macos.yml → local-ci.md), the release-watchdog trio (auto-release.yml, auto-release-watchdog.yml, release-cadence-check.yml → release-watchdog.md), and the enforcement workflows themselves (version-skill-check.yml, coverage.yml → this guide).

It runs advisory (--mode=hint) in the agent PostToolUse hook, enforcing (--mode=report) in .githooks/pre-push, tools/scripts/gates.sh, and the version-skill-check.yml PR gate. Bypass a genuinely doc-irrelevant edit with a Config-Doc: skip reason="..." trailer on any commit in the range.

Instrumented lane failure semantics

Test wall-clock budgets are scaled, not removed, on an instrumented tree. A coverage build compiles at -O0 with an instrumentation counter update on every region; the ASan and TSan lanes compile the same -O0 Debug tree and add a shadow-memory check on every access. Both run on shared hosts, so a TIMEOUT authored for an optimized build becomes a false failure. ctest --timeout cannot rescue it: that is only a default, and a per-test TIMEOUT property always wins.

tools/cmake/PulpTestTimeout.cmake multiplies every pulp_add_test_suite budget by PULP_TEST_TIMEOUT_COVERAGE_SCALE (4) when PULP_COVERAGE_ENABLED is set, or by PULP_TEST_TIMEOUT_SANITIZER_SCALE (8) when PULP_SANITIZER is set, clamped to PULP_TEST_TIMEOUT_CEILING (3600s). The sanitizer number is the coverage number composed with the sanitizer runtimes' own overhead over an equivalent uninstrumented tree, so it is a floor over the coverage scale rather than an independent guess. The two are mutually exclusive in a real configure (PulpInstrumentation.cmake refuses the combination), and the resolver takes the larger rather than the product so the answer does not depend on branch order. That ceiling must stay strictly below the CI lane's own job_timeout (7200s): a test clamped at the job budget can never time out first, so the job is killed instead and the run reports cancelled with no failing test named — which defeats the purpose of keeping budgets finite. Pin PULP_TEST_TIMEOUT_SCALE to override; a value below 1 fails configuration because it would shorten every budget in the tree.

What the scale does not reach. The multiplier is applied by pulp_add_test_suite and by explicit pulp_scaled_test_timeout calls, which together cover 65 of the 215 TIMEOUT declarations in test/cmake/. The other 150 are literals on raw set_tests_properties(... TIMEOUT n) and are unscaled on every instrumented lane; widening one of those is an edit at the site.

A test that declares no TIMEOUT property at all is outside this mechanism entirely. It is bounded by the lane's ctest --timeout (120s on the ASan, TSan and UBSan legs of sanitizers.yml), and because a per-test property only ever overrides that default, no scale factor here can widen it. Such a test times out at just over the lane default, which is the signature to look for before reaching for this module: a ***Timeout at ~120s on a sanitizer lane is the workflow's default, not a scaled budget, and the fix is either to give the test a real budget or to raise the lane's own --timeout.

Read a ***Timeout row as a censored observation. CTest reports the wall clock at the moment it killed the test, which is the budget plus scheduling delta, so it is a lower bound on the time the test needed and never evidence that the test was about to finish. Overshooting a budget "by a fraction of a percent" does not argue for a small scale.

The budget stays finite on purpose. An unbounded test cannot distinguish "slow under instrumentation" from "wedged", which is the only question a timeout answers — so test/test_ctest_timeout_kills_wedged.sh runs a real CTest over a planted never-ending process and asserts it is still killed at the scaled budget.

C++ and diff coverage (coverage.yml) are advisory, never merge gates. The PR signal is named Diff coverage advisory and is intentionally absent from protected main's required-check set. The coverage matrix runs the instrumented build + a coverage-focused ctest suite under an internal time budget (below the job's timeout-minutes) enforced by a watchdog that terminates the suite before the job cap. When that budget is hit the leg drops any partial report and records budget_hit. The Windows leg builds with examples OFF, as the normal Windows build does. Windows remains advisory (and runs on the self-hosted Windows x64 lane; a busy lane or a pull request leaves it out, see docs/guides/coverage.md), but Linux and macOS are receipt-authoritative: their Cobertura verifier still runs and fails when the report is absent, so a green workflow cannot hide a missing native upload. The shared coverage CTest policy excludes the slow/soak/configuration proofs already enforced by the primary build matrix; both scripts/run_coverage.sh and the local pre-push diff-coverage hook consume it across GitHub-hosted and SSH/self-hosted M1/M3 callers. Use --include-slow-tests for intentional full local collection. The suite also runs ctest in parallel with a per-test --timeout so it fits the budget; the receipt-aware upload watchdog remains the cross-run alarm. Report verification and Codecov transport are separate contracts: missing or structurally empty reports fail the producing leg, while the shared upload action records transport failure without making Codecov availability a merge prerequisite. Report eligibility follows the semantic artifact contract, not the test runner's exit code: after a complete traversal, a nonzero test assertion may coexist with a parseable report and that report remains eligible. Configure/build failure or a budget interruption produces no eligible report. Native and Python verification remain independent, so either lane can upload a valid report when the other verifier fails. coverage-upload-watchdog.yml counts a main run as fresh only when the action emitted exact, non-expired Linux, macOS, Python-tools, Apple Swift, Android Kotlin, and Pulp React receipts and Codecov reports those exact-run uploads as merged. Windows remains an explicitly inventoried best-effort producer while its bounded instrumented suite can exceed the job cap, except on a dispatch that names a Windows runner: there coverage_publish_barrier requires the Windows Cobertura report too. The barrier runs under always() and fails when the native matrix did not succeed, because a skipped barrier reads green. Upload-axis Codecov flags do not carry forward, so a failed current lane cannot masquerade as fresh coverage from an older SHA. The native graph retains example-driven tests that exercise first-party core code; hosted macOS reclaims only inactive Xcodes and bounds linker parallelism to avoid ENOSPC without shrinking the measured test surface. Build and CTest parallelism are separate in the shared script: low-memory consumers can bound link workers while the default eight test workers prevent suite growth from consuming the upload window on GitHub-hosted, local, and SSH coverage. The native suite's 180-minute internal budget reserves the final 30 minutes of the 210-minute job for report generation, side-language coverage, artifacts, and receipts, avoiding a hard job cancellation after a nominal budget hit.


The hosted coverage disk check is an arrival floor, not a capacity gate

coverage.yml's "Reclaim hosted macOS coverage disk" step deletes inactive Xcode bundles and then refuses to continue below 10 GiB free. That threshold answers exactly one question — did the cleanup free anything, or did this runner arrive short? — and it is checked once, before the Skia fetch and before the build.

It cannot see a build that consumes the disk itself, and that is the failure that actually occurs. Measured across ten failing and ten succeeding hosted macOS coverage jobs, free space at that checkpoint was 75-76 GiB in all twenty: failing and succeeding runners are indistinguishable there. So no threshold between 10 and 76 GiB changes any outcome, and anything above 76 refuses every run. Do not "fix" this by raising the number.

Run-time disk pressure is bounded where it is actually created, in scripts/run_coverage.sh: per-process %p-%m profiles are absorbed into a running profdata while the suite runs and deleted as they are absorbed, so the raw-profile peak stops growing monotonically with the test count. A shard is absorbed only when kill -0 on the PID embedded in its filename fails — never on mtime, which would eventually reclaim a slow test's live shard and drop its coverage silently.

The hosted Linux coverage leg has its own "Reclaim hosted Linux coverage disk" step. The ubuntu image arrives with roughly 14 GiB free on its root disk, and a full Linux run exhausted it (about 93 minutes in, then about 2 hours in after deleting preinstalled SDKs alone). A runner that fills its disk dies without a log. On GitHub-hosted runners only, the step deletes preinstalled SDKs the job never uses (.NET, Android, GHC, CodeQL, Boost, Swift and similar), and, when the runner's separate /mnt scratch disk has more room than root, makes build-coverage a symlink to /mnt/build-coverage. The instrumented objects and every raw profile then live off the root disk the runner logs to.

On the Linux leg only (the macOS leg's launch is unchanged, and it already runs close to full, so a 3 GiB guard could stop it), while the suite runs the coverage step prints a disk-sample: line every minute: free space on root and on the build disk and available memory, plus, every fifth sample, the sizes of build-coverage, its profraw and coverage subdirectories, and ~/.cache/pulp. A runner lost to disk or memory exhaustion loses its whole log, so crossing 20, 10, 5 and 2 GiB free disk, or 2, 1 and 0.5 GiB available memory, is also raised as an annotation, which is stored apart from the log. run_coverage.sh raises a coverage phase notice at each phase (configure, build, tests, merge, report, export) for the same reason: the last one shows how far a lost run got. Annotations and the log only upload when a step ends, though, so a runner that dies mid-step loses both. The sampler therefore guards the root disk: below 3 GiB free it prints du of the root-disk directories (/tmp, $HOME, the runner directory, /var) and stops the suite, and the step ends with "the root-disk guard stopped the coverage suite" and its log intact. The reclaim step also points TMPDIR at /mnt/coverage-tmp, so test temporaries leave root along with the build, and routes core files to /mnt/cores; the suite itself runs with ulimit -c 0, since a crashing instrumented binary can write gigabytes of core in seconds. Root free space is checked every 10 seconds for the same reason. The suite's own stdout and stderr go to build-coverage/coverage-suite.log (on /mnt), because the runner spools a step's output to root before uploading it and about 20k tests can produce gigabytes; the step prints only the sampler's progress lines (with the current phase), then the phase lines, annotation commands and, on failure, the last 200 lines. The full log is uploaded as the coverage-suite-log-<os>-<sha> artifact. Read those before changing anything else.

Before the Python tools pass, the Linux leg fetches the full commit history and origin/main (about 550 MiB with blobs, which a test that clones the checkout at an older commit needs). actions/checkout clones one commit deep, and about ten tools tests check git ancestry, HEAD^, origin/main..HEAD or pinned revisions; on a truncated history they fail for that reason alone.

Shipyard-binary pin bumps

When a new Shipyard release drops, the pin lives in [shipyard] of tools/toolchain/manifest.toml: the release tag, the repository, and the sha256 of every release asset. tools/toolchain/generate.py writes the tag and repository into tools/shipyard.toml (the file Shipyard itself reads) and the digests into tools/toolchain/shipyard.sha256. Bumping it is a normal dependency-pin update, not a Pulp-versioning event:

python3 tools/deps/audit.py --strict --check-upstream --format markdown
python3 tools/toolchain/generate.py --shipyard vX.Y.Z
./tools/install-shipyard.sh && shipyard --version
python3 tools/deps/validate_hosts.py

generate.py --shipyard takes every asset digest from the release's own checksums.sha256 and refuses a tag older than the current pin (a downgrade is a hand edit of the manifest in a reviewed PR). shipyard pin bump still edits only tools/shipyard.toml, so its change fails the toolchain-test_generate-selftest drift test, whose message names the command above. Use generate.py --shipyard until shipyard pin bump calls it.

The upstream installer verifies no checksum, so tools/install-shipyard.sh downloads the platform assets itself (the CLI and, on Linux and Windows, the workstream provider; on macOS the signed .dmg) and refuses any whose sha256 differs from the pin before the installer runs. It names the URL, both digests and the command that accepts a re-published asset in a reviewed PR. After the installer finishes it checks that the installed binary is the verified one (on macOS, the binaries inside the verified .dmg), because the installer downloads again. On a mismatch it removes the new binaries and puts back the ones it replaced, so nothing unverified is left on PATH. The upstream install.sh itself is pinned too (installer_sha256 in the manifest): the wrapper downloads it from the pinned repository's tag, refuses it if its sha256 differs, and runs only the verified copy. SHIPYARD_SKIP_DOWNLOAD=1 reuses an installed binary and prints that the binary was not verified.

Pulp tracks the latest verified public Shipyard release by default while still recording an immutable exact tag. Automation should propose the newest release; hold an older pin only for a documented incompatibility with an owner and exit condition. Never replace the tag with a moving branch or latest URL: exact pins keep checkouts reproducible and let the fleet prove binary parity.

pulp pr and pulp status treat the pin as a floor within its major: an installed Shipyard at or above the pin under the same major passes, so the fleet's rollout of every release does not trip the guard between pin bumps; a release below the pin, or under another major, is a pin mismatch. The pin is the oldest release the repository's .shipyard/config.toml parses under, so a change that needs a newer Shipyard raises the pin in the same PR.

The current v0.335.0 pin carries shipyard review-verdict --pr N [--head SHA] --json, the repository-side review rule (exit 0 approved, 1 refused, 2 unreadable) that the merge-group backstop calls. It reads the signers who may sign the fleet App's APPROVE only from the base's [auto_merge] reviewer_signers. Its shipyard changelog regenerate renders CHANGELOG.md byte-identically to v0.269.1 for the same tag graph, and shipyard config show merges Pulp's configuration identically under both.

The fleet guard reads [auto_merge] carry_excluded_paths from the base's .shipyard/config.toml. These are the paths whose change stops an APPROVE from carrying to a new head after a merge-only refresh. Setting the key replaces Shipyard's DEFAULT_CARRY_EXCLUDED_PATHS; it does not add to it. Pulp's list therefore restates the default (workflows, .agents/contract.toml, tools/ci/, the guard and arm code, release-please files, and signing and notarization), with one change. In place of the default's .shipyard/** it names only the policy files: .shipyard/config.toml, .shipyard/ci-profiles/** and .shipyard/vm-image*.toml. Generated data such as .shipyard/changed-surface-families.toml keeps carrying, so a PR that only regenerates it can still land on its approval. The guard only reads the key, so the Shipyard pin does not need to support it.

The earlier v0.269.1 pin parses executable_reuse in a target's changed_surface_selection. Shipyard reads that table with deny_unknown_fields, so a host on an older release cannot parse a policy that declares it and plans the full suite on every PR: the pin and the lane hosts reach v0.269.1 before Pulp's policy declares the table. It also carries keyed shadow runs and their host re-derivation (shipyard reuse rederive, rederive-sweep). Its shipyard changelog regenerate renders CHANGELOG.md byte-identically to v0.262.0 for the same tag graph.

The earlier v0.262.0 pin brought the pin up to the commands Pulp's workflows and scripts already invoke (shipyard landing, shipyard landability, shipyard pr --target, none of which v0.143.0 had), and the changed-surface planner the bounded mac lane depends on: merge-base planning for a head behind its recorded base, and changed-surface-plan --record for shadow-only plan records. It retains v0.143.0's default-off, receipt-fenced zero-job recovery primitive (an exact queued Build and Test attempt, an exhaustive zero-job census, no cancel and no merge; a serialized protected-main worker, dry run and live canary remain required before activation) and the schema-v3 selected build-and-test transactions carried by v0.110.0. Version-at-land's shipyard changelog regenerate renders CHANGELOG.md byte-identically under v0.143.0 and v0.262.0 for the same tag graph. A bump is checked three ways: the changelog render against a fixed tag graph under both pins, a clean-HOME install-shipyard.sh whose --status matches the pin, and every subcommand and flag that .github/workflows and tools/ execute resolving in the new binary's --help.

See CLAUDE.md § Dependency Update Workflow for the full procedure. The ci skill's path map catches the file change and demands a SKILL.md review.

v0.83.0 floor — fleet health and formal stacks remain load-bearing

v0.83.0 adds fail-closed formal-stack inspection to every Shipyard merge or enqueue mutation boundary, including the cross-repository runner steward. Pulp can therefore run a small native GitHub stacked-PR pilot without allowing an unattended steward tick to send one layer through the legacy unstacked queue path. Shipyard remains observe-only for formal stacks until it durably models GitHub's asynchronous stack-merge request and completion lifecycle.

v0.81.4 preserves v0.81.3's attached supervised PR push. v0.81.3 keeps v0.81.1's valid shell tag extraction and v0.81.2's fully qualified release-bot refspec, then attaches the deterministic PR branch before supervised push so Pulp's pre-push hook sees both a branch and SHIPYARD_PR_RUNNING=1. v0.81.0 makes runner fleet-status account for the full registered-runner inventory and configured expected metal hosts, including machines that have not registered yet. It also fails visibly when Tart disk headroom falls below its admission floor, ccache exceeds its configured maximum, or a merge-group Linux job remains on ubuntu-latest while compatible self-hosted capacity is idle. Pulp relies on these checks for the MacPro Linux pool and unfinished Mac Mini Intel lane, so expected-host config must not land ahead of this pin.

Pins from v0.78.0 through v0.81.4 carried post-tag changelog hook fixes (push_mode = "pr", the [skip ci] subject split, signed bot setup). Pulp no longer uses that hook: CHANGELOG.md is rendered in the version-bump commit (see Release pipeline above), and every workflow installs Shipyard through tools/install-shipyard.sh, which reads this pin, so no workflow carries an inline SHIPYARD_VERSION that could drift from it.

v0.80.3 fixes the local queue scheduler so a worker slot is refilled as soon as its target finishes rather than only after the entire dispatched batch. That is a throughput dependency for Pulp's shared Macs, not a cosmetic upgrade. The same release expands Shipyard's own version trigger to root-level src/*.rs; without it, the scheduler fix could merge without creating the CLI release referenced by this pin.

v0.80.4 keeps Git-over-SSH pushes alive during Pulp's long pre-push proof while preserving any explicitly configured SSH identity or proxy command. Without it, a successful local proof can still end in an expired SSH connection and waste the entire validation cycle.

Coverage cadence

The hosted macOS Coverage workflow is advisory and runs on the main schedule or through an arm-time workflow_dispatch of the approved PR head. It does not start for every pull request push; the required version and skill checks remain unchanged.