Versioning & Skill-Sync Policy¶
Pulp versions three surfaces independently:
- SDK / CLI —
CMakeLists.txtproject(... VERSION x.y.z). Cascades toPULP_SDK_VERSIONin generated headers and to the CLI binary'spulp --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 installedPulpSdkProvenance.cmakecontract 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.jsonversionand.claude-plugin/marketplace.jsonversion. - Shipyard pinned binary —
[shipyard]intools/toolchain/manifest.toml(version, repo, and the sha256 of every release asset), generated intotools/shipyard.tomlandtools/toolchain/shipyard.sha256, consumed bytools/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:
- 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.
- 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-onlyfeat:never upgrades the SDK. Reverts (Revert "..."subject orRevert-Of:trailer) suppress their signal. - Explicit trailer wins as the ceiling/override: 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:
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_groupthe base is the queue entry's owngithub.event.merge_group.base_sha. A merge-group commit is built on top of every entry ahead of it, so a merge-base withorigin/mainwould put those entries' commits in this entry's range. Afix:touching no surface in an entry ahead would then eject the innocent entry behind it while the offender merged. A run with nobase_sha, or one that is not an ancestor of HEAD, fails closed instead of falling back. - On
pull_requestand 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 behindmainwould be blamed for whatevermainchanged 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 andgates.sh, which passorigin/main, behave the same way. Three-dot diffs andbase..HEADcommit 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_requestevents the workflow now runsversion_bump_check.py --accept-intent-trailers(a SWAP of the flag below, not an add — the two fail closed together). A release-worthy PR DECLARESVersion-Bump: <surface>=<level>and touches no version files; the post-mergeversion-at-landbot assigns the number. A touched surface with no bump/intent/skip still hard-fails, so coverage is unchanged. Seedocs/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 versionsin the diff range (the canonical subjectpulp prwrites 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:
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.version_bump_check.py --mode=apply— applies the required bump(s) toCMakeLists.txt/plugin.json/marketplace.json, staging them. Appends a CHANGELOG stub.git commit— single "chore: bump ..." commit.- Branch push + PR creation + Shipyard state recording.
- Cross-platform validation + merge on green.
- On merge,
.github/workflows/auto-release.ymldiffs 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:
- Generate the token. github.com → top-right avatar → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
- Token name:
pulp-release-bot(or any descriptive name). - Expiration: 1 year (mark your calendar to renew).
- Resource owner: the org or user that owns this repo.
- Repository access: Only select repositories → this repo only.
- Permissions (Repository permissions section): Contents: Read and write. Leave everything else at the default.
- Generate, copy the token (starts with
github_pat_…). - 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:
(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 prcreates/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.pylists them in_DERIVED_REGENERATORS; one isshipyard changelog regenerate, which rewritesCHANGELOG.mdfrom every tag cut so far. The workflow installs the pinned Shipyard (tools/install-shipyard.sh) for that step, passingGITHUB_TOKENso the installer's release lookup is authenticated. Under GitHub Actions the installer refuses to run withoutGITHUB_TOKENorSHIPYARD_GITHUB_TOKEN, because anonymous lookups from shared runner IPs hit the API rate limit (HTTP 403);tools/scripts/install_shipyard_token_check.py(run byworkflow-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 thevX.Y.Ztag. - Pulp's
.github/workflows/release-cli.yml— post-tag. OnvX.Y.Zpush, builds binaries and creates the GitHub Release with a body composed fromtools/scripts/compose_release_notes.pyHighlights, 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.