| name | repomatic-ship |
| description | Orchestrate release preparation. Reconcile the changelog, code, and docs to the net release state, then commit, push, and babysit CI until the release PR is built and `main` is green. Stop before the merge. Review-gated in normal use, fully autonomous under `--dangerously-skip-permissions`. |
| compatibility | Designed for Claude Code. Recommended model: Opus. |
| allowed-tools | Bash Read Grep Glob Skill Agent |
Context
!grep -m1 'version' pyproject.toml 2>/dev/null
!awk '/^## \[/{n++} n==2{exit} {print}' changelog.md 2>/dev/null
!git tag --sort=-v:refname | head -3 2>/dev/null
!git log --oneline -25 2>/dev/null
!git status --short 2>/dev/null
![ -f repomatic/__init__.py ] && echo "CANONICAL_REPO" || echo "DOWNSTREAM"
Instructions
You drive a release from a working tree to a ready-to-merge release PR: reconcile the tree to its net state since the last tag, validate it locally, commit and push, then babysit CI until the auto-generated release PR is green. You stop there: the human marks the draft release PR ready for review and performs the final "Rebase and merge".
The release is push-driven: the prepare-release job in changelog.yaml runs repomatic prepare-release on push to main to build the freeze and unfreeze commits and open the release PR. Do not run prepare-release yourself: a local run previews a freeze that must not be committed (it marks the changelog "released", and on the canonical repo rewrites every workflow action ref). Your job is to make main clean enough that the auto-generated release PR is correct, then keep main green.
How this skill runs
- The review gate is the permission system, not a behavioral stop. Normal runs prompt on each
git commit, git push, and subagent write; step 4 shows the consolidated changelog diff before the first commit prompt, so approving that commit is the review gate and denying it stops the run. --dangerously-skip-permissions mutes the prompts so the full sequence runs autonomously; the skill cannot detect the mode and does not need to.
- Invocation method. When the context shows
CANONICAL_REPO, use uv run repomatic. Otherwise use uvx --exclude-newer '1 week' --exclude-newer-package repomatic=P0D -- repomatic, which applies the supply-chain cooldown to repomatic's dependency tree while keeping a fresh release installable (see claude.md § Cooldown on every install). References to <cmd> below resolve to one or the other.
- You hold no
Edit/Write of your own: the changelog skill and the spawned agents do the editing.
Sub-agent rules
The sweep agents (step 1) and the babysitter (step 6) all follow these rules. Restate them in every spawn prompt: a spawned agent only sees what the prompt carries.
- Commit attribution. Every commit this skill or any spawned agent makes carries a
Co-Authored-By: Claude <noreply@anthropic.com> trailer by default, so unattended changes stay traceable, and that default holds even where a downstream CLAUDE.md has not synced repomatic's claude.md § Agent behavior policy. It is a default, not an absolute: a maintainer's explicit standing rule against AI attribution outranks it, because the trailer lands in their repository's permanent history and that call is theirs. Check for such a rule before the first commit, not after the push: stripping a trailer from an already-pushed commit needs a force-push, which is off-limits, so the anomaly is then permanent. When an exemption applies, write it into the spawn prompt of every agent that may commit. An agent sees only what its prompt carries, so a withdrawal issued mid-run can arrive after it has already committed.
- Reports are sent, not written. A background agent's end-of-turn text is never delivered, so every spawn prompt must require the final report as a
SendMessage to the spawning (main) session, naming that recipient explicitly: a bare label like "orchestrator" may not resolve to an address, leaving the agent to guess where to route the report. A "return a report" instruction alone yields a silent idle even when the report was composed. On an idle notification without a report, chase once; the tree (git diff) stays the authoritative record either way.
- Expand
<cmd> before it reaches a spawn prompt. <cmd> is this document's placeholder, not a shell command, and a prompt that carries it verbatim (or half-expanded) hands the agent something that cannot run. The trap is run <tool>: the tool registry supplies most of them, so dropping the repomatic run prefix does not merely drift from the pinned version, it fails outright — a 7.8.0 spawn brief that expanded <cmd> run mypy -- into uv run --frozen -- mypy -- died on Failed to spawn: mypy, since mypy is not a project dependency. Write the invocation out in full (uv run repomatic run mypy --) and let the agent report back if it does not resolve.
- Trust the tree, not the report. A mid-run message to a busy agent is delivery without receipt: it can land after the agent composed its final report and be silently dropped. After tasking a running agent, confirm the tree reflects the request (
git diff the target file) before moving on. Read fresh every time rather than reusing an earlier capture — an edit can land in the gap between two checks, and a stale capture then reports a live fix as still missing. Grep for the of the old text ( returning ) instead of eyeballing a diff: an empty match is unambiguous where a diff read mid-scroll is not.
1. Reconciliation sweep
A release materializes the net state since the last tag, not the path taken to reach it: after a long cycle, the changelog, code, and docs all drift toward describing the journey. Reconcile all three against git diff v<last>..HEAD. Order matters: the changelog describes the net change, so reconcile the substance first (code and docs in parallel), then summarize it (changelog). A change introduced and then reverted before release is a no-op for users: no changelog entry, no scaffolding, no docs mention.
Before spawning, capture the unstaged diff (git diff against HEAD): those lines are the maintainer's in-progress drafts, not cycle work. Pass both diffs to each agent with the rule: preserve every line present only in the unstaged set (a curated TODO, a scratch note in a docstring) unless the maintainer explicitly asked for cleanup. Without the guard an agent strips unstaged scratch as "cycle scaffolding" and the draft silently vanishes. When in doubt, leave it.
Capture the job-level red inventory in the same breath: the latest conclusive run of each monitored workflow on main (gh run list --workflow tests.yaml --branch main --json databaseId,conclusion,headSha, then gh run view <id> --json jobs), listing every job at conclusion == "failure", ⁉️ probes included: continue-on-error folds their crashes into a green run-level conclusion, so no run-level read ever surfaces them. Under step 6's genuinely-green goal those reds are release work, they are visible now from history, and every one fixed before the first push saves a 40-90-minute babysit round-trip: seed the code agent's brief with the list.
Scan the open autofix PRs in the same breath, and read their diffs rather than their titles. An unattended fix-*/format-*/sync-* PR is a pending write to main that no one reviewed, and merging one mid-release silently reverts committed work: gh pr list --state open --json number,headRefName,title then gh pr diff <n> on every automated branch. What you are looking for is a false positive, a "fix" that is wrong in this repository and that the job will keep re-proposing until the underlying rule is taught otherwise. The tell is a diff that undoes something a human deliberately wrote. Fix it at the rule, not the file: add the word to [tool.typos] default.extend-words, the path to the linter's ignore list, the pattern to extend-ignore-re. Reverting the file alone guarantees the same PR returns on the next run. The archetype: fix-typos rewrote a 10b-quater check label to 10b-quarter, breaking the Latin ordinal series (bis, ter, quater) numbering a run of sibling checks; the maintainer reverted it by hand, the job re-proposed it, and the second PR merged during a release run, undoing the revert.
Judge that diff against current main, not against the PR's head. An open PR is pinned to the commit it branched from, so its diff can faithfully describe a file the maintainer has since fixed. Rewording is the third option beside allowlisting and reverting, and it is the one that leaves nothing behind: dropping the trigger word entirely also stops the job re-proposing, so a rule landed on top of it allowlists a word that now appears nowhere — dead config that reads as a live exception and quietly contradicts the fix the maintainer chose. So git grep the trigger before writing a rule for it, and when they have already solved it their way, leave it alone. Closing or merging the PR is theirs to decide: surface it, land the rule-level fix only while the trigger is still live in the tree, and say either way in the step-7 report.
Always read that run's headSha, and treat a red as live only if nothing since it could have fixed it. This is why the query above asks for headSha and not just conclusion. Most monitored workflows carry a paths: filter, so a commit that touches nothing in the filter triggers no run — and if that commit is the one that fixed the red, the latest conclusive run stays pinned to the superseded parent and keeps reporting a failure that no longer exists. It can sit there for hours looking like live release work. The failure mode is not hypothetical and it is expensive: it sends the code agent chasing a fixed bug, and it makes the whole matrix look broken. Before adding a red to the brief, run git log --oneline <run headSha>..HEAD and check whether an intervening commit touched the failing area; when the answer is unclear, settle it for the cost of one dispatch (gh workflow run <workflow> --ref main, no commit, no PR churn) rather than reasoning about it. The archetype: a docs-only commit regenerated a checked-in generated block, fixing the test that asserts it is in sync, but tests.yaml's paths: filter meant no Tests run ever observed the fix.
A green conclusive run proves nothing when supersession cancelled every run between it and HEAD. The rule above guards the direction where history over-reports a red; this is the direction where it under-reports, and it is the more expensive one, since a false red costs a wasted round-trip while a false green ships the break. Walking back past cancelled runs to reach the newest conclusive one skips exactly the commits a busy cycle pushed most recently, so that success can predate every line the cycle added. Before recording an empty red inventory, diff the gap: git log --oneline <newest success headSha>..HEAD. When cycle commits sit inside it, the workflow has never run on them and the green is stale by construction. Settle it with a dispatch (gh workflow run <workflow> --ref main, no commit, no PR churn) or by waiting out HEAD's own run, never by recording "no reds". The archetype: a feature commit interpolated a metadata value straight into a run: block, the two pushes behind it cancelled its Lint run before it ever dequeued, and the newest conclusive Lint run sat back on the post-release bump, green, while main was already failing 🔒 Lint workflow security.
The three substance passes own disjoint lanes (code owns Python including docstrings, docs owns prose under docs/ and readme.md, bundled assets owns .claude/), so spawn them as three Agent calls in a single tool-call block: sequential spawns waste the wall-clock of whichever finishes first.
-
Code: an Agent that reviews every file changed since the last tag for reuse, quality, simplification, and deduplication, and fixes what it finds (CLAUDE.md § Common maintenance pitfalls, "Simplify before adding"). Two layers: first strip scaffolding from reverted or superseded work within the cycle diff (abandoned workarounds, dead branches, WIP notes that never shipped); then harmonize what remains (collapse duplication, lift repeated literals to their canonical source, align new code with module patterns). Its constraints:
- Every edit stays behavior-preserving: step 2 is the safety net, a failing test vetoes.
- Type checks use the CI-equivalent
<cmd> run mypy (pinned version and --python-version), never a bare mypy whose newer interpreter raises false positives CI never sees. Pass it no arguments, exactly as step 2's Types gate does: the runner then resolves the same tracked-*.py list CI's lint job checks, tests/ and docs/conf.py included, and the two cannot diverge. A downstream CLAUDE.md "type checking" command is often the narrow dev-loop form scoped to the package only; do not inherit that scope when prompting the code agent, since a package-only run stays green on a tests/ or docs/ type error that reddens Lint post-push.
- Failures the pass believes pre-existing get reported, not silently scoped out: that verdict belongs to step 2's CI check.
- Adopting features from upgraded dependencies stays in
/repomatic-deps modernize.
- On the canonical repo, workflow invocations reading
uv --no-progress run --frozen -- repomatic are the intended unfrozen state (the freeze commit rewrites them to a uvx 'repomatic=={version}' PyPI pin at release): never flag the local form as a pin regression or downstream breakage. The invariant to check instead is that every uv-invoking job provisions setup-uv in its own steps. Do not "restore" an isolated uvx --from . here: the lockfile path is deliberate, since an index resolution can be made unsatisfiable by the install cooldown while a lockfile cannot.
- Docstring rendering belongs to this pass: build the docs and fix any broken cross-reference role a docstring introduced (the docs pass can surface but not fix them). Build only into the gitignored
docs/_build, never an ad-hoc path: a stray build tree pollutes git status and trips tool scans like .
If the sweep made no edits
A clean cycle, where every change since the last tag is already at its net end-state, is a normal outcome. With no working-tree edits, the commit-and-push spine collapses and three steps change shape:
- Step 2 becomes redundant: CI already ran on this exact commit (it is
HEAD of main), so verify that run's conclusion (gh run list --branch main) instead of paying for a fresh gate. Still quick-run the time-dependent external smoke checks (<cmd> run typos, <cmd> audit --fix): re-published binaries and new CVEs drift independently of code.
- Step 5 is a no-op: never force an empty commit.
- Step 6 reduces to verifying the existing run. When
gh pr list --head prepare-release shows a PR whose freeze commit sits on the current HEAD, confirm every stable job on HEAD is green and go to step 7, spawning /babysit-ci only on a real failure. When no current PR exists (the last push missed changelog.yaml's paths: filter), trigger one with gh workflow run changelog.yaml --ref main, still with no commit.
Steps 3, 4, and 7 are unchanged: the version advisory and the (empty) changelog diff still inform the maintainer.
If the sweep touched only prose
When the sweep's edits are confined to prose and Markdown (docs/, readme.md, changelog.md, .claude/; no .py, no pyproject.toml, no uv.lock), the full step-2 gate is disproportionate: tests, mypy, ruff, the binary self-test, and fresh resolution have no new surface to check. Narrow to what step 1's docs and bundled-asset passes do not already own: <cmd> run mdformat --verify -- <file> over the changed Markdown, plus <cmd> lint-changelog when changelog.md changed. Run it in the same position as the full gate — before the step-5 commit and push, never after. A lighter gate is still a pre-push gate: verifying format only once the push is already out defeats the point.
2. Validate locally (pre-push gate)
When the sweep rewrote code, prove it green before paying for a CI round-trip (no edits: see above). This is the same fast local channel /babysit-ci polls, run ahead of the first push. Launch the slow checks (tests, types, changelog lint) in parallel in the background, act on the fastest failure first (mypy and ruff in seconds, pytest in minutes), fix in the working tree, re-run only what failed, and iterate until every check is green. A check earns a blocking seat only while it reports faster than CI would surface the same failure: the push is what starts the 40-90-minute matrices, so holding it for a check CI's fast platforms reproduce at the same latency delays the release without adding earliness.
First read CI's conclusions on HEAD (gh run list --branch main): every red job there is cycle work this release must fix, and no "pre-existing failure" claim from the sweep is valid until checked against it. Read at the job level (gh run view <run> --json jobs), never the run level: continue-on-error hides a crashed ⁉️ probe inside a green run conclusion, and this read doubles as the check that the step-1 red-inventory fixes actually landed. An in-cycle lockfile bump can invalidate type: ignore comments and override signatures with zero source changes, so "the source did not change" never proves "the check still passes" (a dependency re-lock once widened a parent method, and CI Lint was red with exactly the 7 mypy errors the sweep had rationalized as pre-existing). When HEAD's own runs are still queued/in_progress (rapid pin/lock auto-commits plus hosted-runner backlog routinely leave them unfinished here), HEAD has no conclusions to read: read instead the latest conclusive run of each monitored workflow on an ancestor (gh run list --workflow tests.yaml --branch main --json conclusion,headSha,createdAt, skipping the cancelled/skipped supersession noise a busy cycle piles up). A failure there is a pre-existing red carried on main for several commits: fix it before the first push. A success there is not the mirror verdict, though: read it against step 1's rule on a stale green, since the supersession noise you just skipped is where the cycle's newest commits were tested. Miss it and a platform-gated failure the single-OS local gate cannot run surfaces only in step 6 (babysit), still fixed but at the cost of an extra CI round-trip.
The checks:
- Tests:
uv run pytest --no-header -q. Exception: an integration-heavy suite driving real external tooling can outrun a local background timeout and need tools not installed locally, so it is not a fast gate. Skip it, keep the rest of the gate, and treat the CI matrix on the exact commit as the authoritative test signal (step 6 covers dispatching one). Between the extremes, a suite whose local runtime approaches CI's fast platforms (~5-8 minutes from push) stops blocking. Start it with the gate, push once every fast check is green, and fold the still-running suite into step 6 as the first babysit channel: a failure lands as an immediate tight-loop fix at the same absolute time CI would have reported it, while a pass cost zero wall-clock.
- Types:
<cmd> run mypy, with no arguments. The runner resolves the same tracked-file list CI's lint job checks, docs/conf.py included. Do not pass directory names instead (<cmd> run mypy -- repomatic tests docs): directories change module resolution enough that mypy follows an installed dependency's own source, so a package pulled in by the docs group and written for a newer Python fails the run under --python-version 3.10 with a syntax error in a file this project does not own, which reads as a real failure and is not one.
- Changelog:
<cmd> lint-changelog. A ⚠ X.Y.Z: not found on PyPI warning for the still-unreleased version is expected and not a blocker.
- Shippable deps:
<cmd> lint-deps. Offline and instant, and it covers the one release failure nothing else in this gate can see: a [tool.uv.sources] override never reaches the published metadata, so tests, types, formatting and the build all pass on a tree whose wheel every user then fails to install. Run it even on a docs-only cycle, where a lockfile bump can still have moved a source. A blocker naming a git source paired with a .dev floor is the sync-dep-sources idiom mid-flight: the fix is to wait for that swap PR, not to edit pyproject.toml by hand. The release lane carries the same check as a hard gate, but it fires after the freeze commit is already on main, where the only recovery is to burn the version and ship the next one, so a red here is cheap and a red there is not.
- Formatting, reproduced with the pinned tools, never the dev-env
uv run ruff (a newer local ruff once silently disagreed on a PERF401 fix): git diff --name-only HEAD -- '*.py' | xargs <cmd> run autopep8 -- over the cycle's changed Python files (it wraps long-line comments ruff leaves), never as a shell variable holding the space-separated list, which the runner takes as one literal path and rejects with . Then and , and read . Both write in place, but only does so because the resolved ruff config sets — the runner injects no flag of its own, so a repo carrying a section without that key gets a read-only and an empty diff that means nothing. An empty diff past your reconciliation edits is green; fold a legitimate style fix into the reconciliation. For any Markdown the reconciliation touched (, ), ask , which reports what the write path would change without touching the tree; never a bare /, whose plugin set rewrites MyST directive colon-options (a 's /) to frontmatter form, diverging from CI's autofix.
The local gate is single-OS, so platform-specific failures surface only in CI. Shrink that window pre-push:
- The usual culprits: path resolution (
Path.resolve() canonicalizes Windows 8.3 names and POSIX symlinks), home-directory expansion, env-var casing, filesystem case-sensitivity, text-I/O encoding (Windows defaults to cp1252, so a bare open()/read_text()/write_text() breaks on the first non-ASCII character, and only in Windows CI: pass encoding="utf-8", and when the cycle touched file I/O, run the suite once with PYTHONWARNDEFAULTENCODING=1 to surface calls ruff's inference-limited PLW1514 cannot see), and direct execution of a generated script (Windows honors neither the executable bit nor the shebang, dispatching on file extension, so a chmod +x'd shebang script a test runs by bare path fails with WinError 193: emit a .cmd launcher beside a .py sidecar on Windows, or invoke the interpreter explicitly).
- The structural fix is to mirror the production transformation, not reconstruct it: a test asserting on a derived value should run the same pipeline the code runs, so the expectation matches by construction on every platform. Where expectations must diverge by platform, the CI matrix is authoritative: read every cell, not just your OS.
- Name what the gate cannot run: grep the cycle's changed test files for pytestmarks that exclude the local platform (
unless_*, skip_*, skipif) and diff-review those tests' expectations by hand, since a green local run says nothing about them. Extend the review to the inputs those tests consume, not just the test files: new docs prose or docstrings can redden a platform-gated conformance test whose skip list never met that reference class (a reworded docstring a Sphinx test asserts on, a first-ever stdlib cross-reference missing from a skip list). The cycle's earlier pushes already ran those tests in CI, which is why the read of CI's conclusions on HEAD above is what actually catches them pre-push.
- Reproducing a platform-specific failure churns the shared venv, and the wrong re-sync then reddens the rest of the gate with artifacts. Confirming a free-threaded or version-specific break with
uv run --python <other> (e.g. 3.14t for a free-threading race) recreates and repoints .venv to that interpreter. Restore it with uv sync --frozen --all-extras --group test --group typing: test mirrors tests.yaml, and typing — stubs-only, so it cannot perturb a test at runtime — is what keeps the gate's own honest, since a venv synced without it floods / in files the cycle never touched (even reads as ), a false red the restore itself manufactures. Never : it additionally pulls in the group whose imports perturb process-global-state-dependent tests (logging config, default theme) into spurious failures, while a default-only strips both needed groups (no at all). All of these are venv-provisioning artifacts, not code regressions: re-sync to the CI-matching groups before re-running the gate, and trust CI's / over a local gate re-run against a churned venv ( on the prior reporting exactly the real error set, and none of the stub noise, is the authoritative mypy signal).
3. Version advisory (never bumps, never blocks)
Read the consolidated unreleased section and classify the bump the net diff implies:
- A
**Breaking:** entry, or any removed or renamed public API: major.
- A new feature, command, or config key: minor.
- Only fixes, dependency bumps, and internal changes: patch.
State the classification and the single strongest reason, then keep going on the patch default (the unfreeze commit bumps the patch automatically). Do not merge a version-increment PR, and do not stop: for minor or major, surface an advisory ("this release looks like a minor: merge the minor-version-increment PR if you want that bump") and proceed. The maintainer merges that PR out of band, which re-triggers the release PR on its own.
4. Present the sweep
Show git diff of changelog.md plus a one-line summary of the code and docs changes the agents made. Consolidation drops and merges entries: surfacing this is what lets you catch an over-eager drop at the commit prompt before it ships.
5. Commit and push
Commit the reconciled tree with a message describing the net reconciliation (plus the attribution trailer), then push to main: the push regenerates the release PR through prepare-release.
Keep that message short, per claude.md § Commit messages: an imperative subject, and at most one paragraph of body. A reconciliation sweep touches many files and invites a paragraph per theme; resist it. The changelog already carries the user-facing story and the diff carries the rest, so a body earns its place only by explaining a decision neither of those shows. This commit is one of the rare ones where a paragraph is justified at all: most commits in this repository have no body.
Re-read git status immediately before staging, and never reach for git add -A. A sweep agent that has gone quiet is not necessarily finished: it can resume editing minutes later, after the step-2 gate has already run and while you are drafting the commit. git add -A then sweeps in files you never reviewed and whose changes the gate never covered. Diff every path that was not in the tree when you ran the gate, and re-run the gate before committing if any appeared: an edit landing after the gate is an ungated edit, whoever made it. This is not hypothetical bookkeeping — one such late edit dropped an explicit name= from a Click command, which would have silently renamed a CLI command the changelog advertises had the framework not happened to derive the same name from the function. The narrow-staging rule the sub-agent rules impose (git commit <path>, never -a) applies to the orchestrator's own reconciliation commit for exactly this reason.
Signed commits: sandbox off, and a hardware key is not a retry loop. With SSH signing (gpg.format = ssh), the harness sandbox blocks the key or socket under ~/.ssh/* (Operation not permitted): disable the sandbox for the git commit and git push calls only. A hardware-backed key (Secretive, YubiKey, TPM) additionally prompts the maintainer per signature, and one unanswered prompt wears three faces, sometimes in sequence across retries: agent refused operation?, Couldn't sign message (signer): communication with agent failed? (exit 128, then failed to write commit object), or no output whatsoever until something kills the command. The first two look like a real failure; the third is the expensive one, because a signing command silent for minutes is a prompt nobody answered rather than a slow command, so bound it with a timeout and hand off instead of waiting it out. Stop after one or two retries and ask the maintainer rather than burning prompts they may not be watching. When the fix already exists as an open, CI-green autofix PR (a sync-* or format-* branch), prefer merging that PR over authoring your own signed commit: GitHub signs the merge commit server-side, sidestepping the local key entirely. And gh pr merge may itself be walled — not by a live prompt but by a standing permissions.deny rule in the operator's settings, which no in-chat authorization can override; when both the merge and the signed commit are blocked, "ask the maintainer" means asking them to run the merge or push themselves, outside this session, not merely to approve in chat. The same applies to the babysitter in step 6: its skill carries the explicit hand-off contract.
6. Babysit CI to green
Step 2 cleared every locally-reproducible failure and step 1's red inventory pre-paid the debt already visible in past runs, so the first run should be close to green: babysit handles what only CI surfaces, platform-specific breaks and, when the project builds binaries, the slow Nuitka matrix.
The goal of this step is a genuinely green suite, not a catalogue of which reds are "non-blocking." A release is when test-suite debt gets paid down: fix every tests.yaml failure surfaced here, including flaky and pre-existing ones carried on main for months (a chronic environment-specific break, a "known-flaky" live-registry install, an allowed-failure ⁉️ probe that actually crashes), not only this cycle's regressions. Root-cause each red to its mechanism: the manager argv it builds, the dependency it imports, the assertion that drifted. Then fix it at the source: a real code bug gets the code fix, a genuinely-flaky live-registry install is folded into the test's tolerated-exit set (with the reasoned comment its peers carry), a partial-wheel import crash is converted to a clean skip gated on the library's own availability sentinel. A red's ⁉️/non-blocking status governs only whether it gates the merge, never whether it gets fixed: the glyph test in the line below decides what blocks the release, not what you leave broken.
Run a tight fix-loop: act on the first failing job, fix and push immediately, restart CI on the new commit, never wait out a 40-90-minute matrix per fix. The instant any job turns conclusion=="failure" (gh run view <run> --json jobs, broken on the earliest failure rather than the run's overall conclusion), fetch its log, reproduce and fix locally against the pinned gate on the touched files, commit with the attribution trailer, and push. The fresh push supersedes the obsolete in-progress run; cancelling it and letting the new commit trigger a clean run converges in fewer wall-clock hours than serially waiting for each full run to drain. Batch only fixes already root-caused and verified together; never wait to accumulate more failures before acting on one you already understand.
Time each push by what its diff rebuilds. The immediate push above is for source-affecting fixes (repomatic/**, tests/**, pyproject.toml, uv.lock): the matrix they cancel was verifying an obsolete tree, and their own run rebuilds everything. A matrix-skipping commit (changelog-only, docs-only: paths outside Metadata.binary_affecting_paths) inverts the economics on a binaries-enabled project, because release.yaml runs on every push in a per-branch cancel-in-progress group: pushed mid-drain, it cancels an in-flight binary matrix that its own run then skips rebuilding (skip_binary_build), and the lost verification is only re-buyable with a full re-dispatch. That cost scales with what is in flight: an ordinary push builds only the [tool.repomatic] nuitka.dev-targets canary subset, and no push cancels a full fleet, since release commits, schedule and workflow_dispatch runs each sit in their own concurrency group. Hold such commits (the post-babysit changelog reconciliation below included) until the heavy matrices on the current tree are terminal, or bundle them into the next source-affecting push; with binaries off, or only a canary build in flight, push freely: a cancelled release.yaml re-runs in minutes, and a prose push does not even cancel tests.yaml (paths-filtered, so no new run enters its concurrency group).
Spawn Agent, named so it stays addressable, on the sonnet model to run /babysit-ci to completion (the loop is mechanical: fetch logs, match patterns, fix, commit, push). Leave it on the default background mode, not run_in_background: false: the idle/chase cycle described below (a capped gh run watch ending its turn, a chase message resuming it) is exactly how a background agent behaves in this harness, and a literal foreground spawn would instead block the orchestrator's own turn for a wait that can span hours. It monitors tests.yaml, lint.yaml, autofix.yaml, docs.yaml, and release.yaml (whose engine runs the per-platform Nuitka matrix when the project enables binaries; its job names are templated, like ✅ {os}, {sha} build, so key the watch on the workflow, not a literal job id, and the ✅/⁉️ glyph across the test and release matrices marks cell stability, not outcome: a red ✅ (required) cell is release-blocking, a red ⁉️ (an allowed-failure probe, like the newest dev Python) is noise, so triage a red matrix on the absence of the unstable glyph — select(.conclusion=="failure" and (.name|contains("⁉️")|not)) — and not by which Python version failed. Never anchor that test at the start of the name: a release-engine job arrives through the reusable call as release / ✅ {os}, {sha} build, so startswith("✅") matches none of them and drops every binary-matrix red on the floor. <cmd> ci-status --branch main applies the same rule for you). If /babysit-ci is excluded here, the agent runs the same fetch-logs/fix/commit/push/re-poll loop inline (and the sub-agent rules cover a failed spawn). Its prompt restates the sub-agent rules (the trailer and narrow staging especially: its commits are exactly the unattended ones those rules exist for) and adds:
- The loop condition, verbatim: "re-poll after each push; do not return after a push without re-polling". The turn ends only when every monitored workflow on the latest
main HEAD has conclusion: success (or skipped for benign reasons), or on a real blocker it cannot resolve. Terser phrasings get misread as "report after first fix", and the agent returns while the slow jobs still build, doubling wall-clock when you re-spawn it.
- The poll cadence: every poll loop sleeps at least 45-60 seconds between iterations, with the
sleep inside the loop command. Zero-delay spins exhaust the shared REST quota (5,000 requests/hour) in minutes, and the exhaustion resurfaces as PAT-permission-shaped workflow failures and prepare-release hangs (see babysit's § GitHub API rate-limit exhaustion).
- Poll in-process; never detach a monitor: the poll loop must block inside the agent's turn (a foreground loop or
gh run watch), never a run_in_background Bash poller or a Monitor-tool stream the agent idles on awaiting notification. Name the Monitor tool in the prohibition: an agent told only "no background poller" does not classify Monitor as one, reaches for it, and idles mid-watch. Babysit itself forbids detached monitors, but a spawn prompt with an "as a background task" aside overrides that, and the agent follows the prompt: a failure landing in the idle window then goes unhandled.
- Hand it the in-turn mechanism, not just the prohibition. A bare shell
sleep is blocked in some harness shells, and an agent that hits that block will reach for Monitor as the only thing that appears to work — the prohibition alone leaves it no way to comply. Name both alternatives explicitly: gh run watch <run-id> --interval 60 (blocks in-turn until that run is terminal, satisfying the cadence without a sleep), and python3 -c "import time; time.sleep(60)" when a raw delay is genuinely needed.
- Expect to take the loop over for a long matrix; this is structural, not agent failure. The Bash tool caps a foreground command at ~10 minutes and auto-backgrounds it on expiry, which ends the agent's turn. A 40-90-minute Nuitka matrix therefore cannot be watched to completion by a sub-agent: each
gh run watch is capped and backgrounded, and the agent idles once per cap, needing a chase to resume. Re-spawning or re-chasing does not fix it — the next watch hits the same cap. Delegate babysitting for the fast workflows, and when the remaining wait is the binary matrix, with a (the orchestrator's own is blocked, which is exactly why the tool is right here and wrong there). Give that monitor a filter that emits only what you would act on — a failing stable job the moment it lands, and each run reaching a terminal state — since per-cell progress across a dozen cells will trip the event-volume cap.
Treat its return as a claim, not proof. Even with the verbatim prompt it can stop early (the long Nuitka wait is where it gives up). Re-poll gh run list --branch main yourself and read each monitored workflow's conclusion; anything still queued/in_progress or non-green means it stopped early: take over the loop inline rather than re-spawning it into the same idle. On takeover:
- Inspect the tree before improvising a fix (
git status, git diff --cached): a stood-down or idled agent often leaves a correct fix staged but uncommitted (a hardware signing prompt it could not answer). Adopt and verify that fix instead of re-deriving a parallel one that then collides with it.
- Poll at the job level (
gh run view <run> --json jobs), per the tight fix-loop above, never the run's conclusion alone: a run-level read hides an already-failed fast job for as long as the slowest cell runs (a failed once-tests job once hid for half an hour behind a 40-minute macOS cell that ultimately passed). Break the instant any required job turns conclusion == "failure" — required meaning any name without ⁉️ in it, which covers the glyph-less non-matrix jobs (🛡️ Lint types, 1️⃣ Run-once tests, 📦 Package install) as well as the ✅ cells.
- Log fetches may need the sandbox off:
gh run view --log-failed caches under ~/.cache/gh, which the sandbox denies; the creating cache entry ... operation not permitted failure masquerades as a gh bug.
- Foreground
sleep is blocked in the orchestrator's shell, so you cannot run the in-turn while … sleep poll loop the spawned babysitter uses. Drive the wait with the Monitor tool (an until-loop command that emits each workflow's terminal state and exits) or a bounded gh run watch <run-id>, acting on each landing. The babysitter's "never detach a monitor" rule governs the sub-agent polling in-turn; the orchestrator taking over must reach for Monitor/gh run watch precisely because its own sleep is disallowed.
- Write that watcher in Python, and never trust a green it reports without confirming against
gh. The failure that matters here is not a watcher that crashes, it is one that exits silently declaring success: its "all workflows terminal" line is indistinguishable from the real thing, and it arrives early, which reads as good news. A shell watcher is the easy way to get one, because macOS ships bash 3.2: declare -A is a hard error there, so the obvious "have I already reported this workflow?" map fails on the first iteration, every workflow falls through as already-seen, and the loop announces completion while the whole matrix is still queued. A stderr line scrolling past in the monitor's output file is the only hint. Python has no such trap and gives you a that works on any machine. Whatever you write, the confirmation is the same: re-read yourself before acting on a terminal claim, exactly as you do for the babysitter's own return.
Verify the Nuitka run yourself, starting with whether the project builds binaries at all:
-
A project with [tool.repomatic] nuitka.enabled = false or no CLI entry point emits no nuitka_matrix (see Metadata.nuitka_matrix in metadata.py): the engine's per-platform build and test jobs skip by design on every push, release commits included, and past releases carry no binary assets (gh release view <last-tag> --json assets settles the regime in one call). There is no binary signal to verify and none appears on merge either (the release_commits_matrix gate drives the publish/tag lanes, not the binary matrix): wheel-plus-sdist is the complete release shape.
-
When binaries are enabled, babysit's "every stable job passes" never covers them: its early exit declares success once the fast platforms are green, while macOS and the entire release.yaml matrix still build. Independently confirm the release.yaml run reached a terminal green state (gh run watch <release-run-id>, then read its conclusion); never infer the Nuitka result from babysit's summary. If a binary build fails, re-spawn babysit or fix inline.
-
A green conclusion also proves nothing on a HEAD that touched no Python source: release.yaml skips the entire compile-binaries matrix on such pushes (workflow-only, docs-only). Read the run's jobs and confirm the per-platform build and test jobs ran rather than skipped; when they skipped, the authoritative binary signal is the last run that actually compiled, valid only while its source tree matches the release tree. Tell a wholesale skip from individually-filtered cells by whether the job name expanded. The two are indistinguishable in any status count — both read as "N success, M skipped" — and they mean opposite things: filtered cells are the expected nuitka.dev-targets canary behavior, while an unevaluated matrix means zero binaries were built. The tell is the job name itself. A matrix that expanded yields named cells (✅ ubuntu-26.04, abc1234 build); a matrix that never evaluated leaves the raw template verbatim (${{ (((matrix.state == 'stable') && '✅') || '⁉️') }} ${{ matrix.os }}, ${{ matrix.short_sha }} build). Unexpanded ${{ }} in a job name always means the stage never ran. This misreads silently and expensively: a sub-agent reported a content-skipped matrix as "dev-canary subset built, rest correctly skipped" twice in one run, which would have shipped a release whose binaries no CI job ever compiled. When no compiled run matches the release tree (a binary-relevant commit's run cancelled by supersession, then the reconciliation HEAD content-skipped the matrix), you dispatch one: . A dispatch carries no push diff for the skip heuristic to read, so it compiles and self-tests the full per-platform matrix on the current tree, with no commit and no PR churn.
Close the coverage holes a busy cycle opens:
- Refresh the release PR after non-trigger pushes — verify the branch, not the run. A green
changelog.yaml run titled with your commit does not prove the PR moved onto it: the prepare-release job regenerates the PR only when it is triggered, and a push touching only files outside changelog.yaml's paths: filter (a tests/- or packaging-only reconciliation commit) may not trigger it. Recent repomatic adds a workflow_run backstop — prepare-release also runs after every Build & release — that re-bases the PR onto the current HEAD regardless of paths, so on a current pin this is usually already handled by the time main is green. Verify rather than assume: gh api repos/<owner>/<repo>/compare/<HEAD>...prepare-release --jq .behind_by must be 0. If it is non-zero (an older repomatic pin without the backstop, or a supersession that outran it), run gh workflow run changelog.yaml --ref main (a workflow_dispatch rebases onto the current HEAD), then re-check before step 7. This does not block the release either way — a "Rebase and merge" replays the freeze onto current main, and the PR's own checks are skipped by design — but a refreshed PR shows the reviewer the tree that will actually ship.
- A racing version-increment merge can leave your commit's heavy CI uncompleted. Merging the
minor-/major-version-increment PR mid-build cancels your in-flight tests/lint (shared concurrency group) while the bump commit is itself gated out of them, so the release PR can show them skipped. This is by design: step 2 is the authoritative pre-merge check, so read skipped tests/lint on a bump commit as expected and do not re-push to force a run.
- An autofix
sync-tool-versions commit landing mid-babysit leaves its new checksums unproven, and a green Autofix does not clear them. Only a job that actually runs the bumped tool exercises its SHA-256, so the green lane to check is the tool's consumer, not the workflow that happened to commit the bump. Grep for the tool's call sites (<cmd> run <tool>) and read whether any executed: several are gated (the gh attestation step is if: … release_commits_matrix, so it is skipped on every non-release commit and its checksum sails through the whole cycle untested, then runs for the first time on the release commit — where a bad hash fails attestation and strands the release as a draft). Read this at level, not job level: the enclosing job is green while the gated step inside it is skipped. When no consumer ran, verify directly rather than reasoning from provenance: downloads and checksum-verifies one platform, and followed by an empty re-derives platform hash from the live URL and proves the committed table. Both need the sandbox disabled (it blocks the tool cache and can truncate a download into a false mismatch).
Reconcile the changelog against every fix babysit committed: entries it added and omitted. Walk its commits and blame each fix against the last release tag: a bug that only ever existed in code introduced this same cycle is a user no-op (drop any entry babysit added for it); a bug that reached an earlier release deserves the entry babysit may have skipped. Re-run step 1.4's consolidation with both corrections and present the diff (step 4) before committing. Draft this pass during the slow tail (the work is local), but push it by the timing rule above: only once the heavy matrices on the final code HEAD are terminal green, so its changelog-only diff inherits the drained tree's signal instead of cancelling a binary build mid-run. Then confirm changelog.yaml regenerated the PR onto it (the refresh check above) before the step-7 confirmation.
Surface maintainer work that appeared during the wait. The 30+ minute CI wait gives the maintainer time to keep coding: those uncommitted files are theirs (sub-agent rules). List them in the step-7 report so the maintainer decides whether each belongs in this release (commit and push before merge) or the next.
7. Confirm and stop
Once main is green and the release PR exists (gh pr list --head prepare-release), report:
- the release PR URL,
- the version it will cut, plus the bump advisory from step 3,
- that the PR is opened as a draft (the
prepare-release PR template's frontmatter carries draft: true, which pr-sync re-applies on every refresh, not just at creation), so the remaining human actions are to mark it "Ready for review", then "Rebase and merge" (never squash).
Do not merge the PR, and do not mark it ready yourself. That final human action is the boundary this skill stops at.
Repairing a short ship
When the binary matrix came up short, say so here and name the platforms the version will lose. Publishing is what locks the asset list, so this is the last moment the gap is cheap to record; afterwards it is a permanent property of that version. Shipping short is intended behavior and never a reason to hold the release, per claude.md § A published release freezes what is missing from it.
Three artifacts then keep advertising binaries that are not there, each needing a hand after the merge:
- The version's changelog section, which takes a
> [!WARNING] naming the gap. Write it as a hand-written admonition (anything not starting with > `X.Y.Z` is), which lands in the editorial slot fix-changelog preserves rather than the availability slot it regenerates.
- The GitHub release body, rebuilt from that section. Immutability locks the assets and the tag, not the notes, so this stays editable after publishing.
sync-github-releases is the mechanism, but it skips drafts and caches the release list for 24h, so a same-day fix goes through gh release edit --notes-file with the body build_expected_body renders. Both converge on the same text, so a later CI sync is a no-op rather than a clobber.
docs/install.md, whose download URLs the freeze pins to the version being released, optimistically: the freeze commit is what triggers the build, so it cannot know whether the binaries will land. Re-point them at the last release that carries binaries via PrepareRelease.freeze_install_download_urls, and the next release's freeze ratchets them forward again. lint-repo's check_install_guide_downloads reports the gap but never repairs it, since an automated rewrite driven by a single API read could downgrade a healthy install page on a flaky response.
Report the three as follow-ups. Do not perform them: they land after a merge this skill does not make.
Upstream only: the release PR is rebase-merged, so its freeze and unfreeze commits arrive in a single push, and GitHub Actions reads workflow files from that push's head. `kdeldycke/repomatic`'s own release lane therefore always runs the **unfrozen** workflow content, whatever the freeze wrote into the release commit. A job that assumes the frozen `uvx 'repomatic==X.Y.Z'` form is what executes (and drops its checkout on that basis) dies on `Failed to spawn: repomatic`. Only downstream repos, which call the reusable workflow at its tag, ever run the frozen form.
8. Reflect and contribute back
This skill, the workflows it drives, and the conventions it enforces live upstream in kdeldycke/repomatic and sync down to each caller; a release is when their rough edges show. Before finishing, review the session and for each finding point at the exact source with a concrete fix: ../repomatic from a downstream repo, this very tree when the repo is repomatic (see § Shipping the findings).
- A skill instruction that misled you or forced a judgment call you got wrong: a dangling cross-reference, a missing step, an instruction a sub-agent should have inherited but didn't (archetype: the
Co-Authored-By trailer dropped because the attribution note leaned on an unsynced CLAUDE.md section).
- A workflow "failure" that turned out to be a real upstream bug: trace it to its template in
repomatic/data/ or .github/workflows/ instead of waving it off (archetype: release.yaml red on every push from a strategy.matrix evaluating fromJSON('')).
- A reconciliation the skill should have anticipated (archetype: the step-6 second consolidation pass, added after babysit fixes shipped spurious or missing entries).
Surfacing these is how the skill improves release-over-release.
Implement each finding in ../repomatic when that sibling checkout exists, and stop before the commit. Describing a fix leaves the maintainer the whole implementation; a diff already sitting in their working tree leaves them only the review, which is the step they were going to do regardless. So edit the upstream code, update whatever tests the change breaks, and verify it there (uv run --project ../repomatic --frozen -- pytest …). Then do not commit, do not push, do not open a PR or an issue — the same boundary this skill stops at downstream. Report which files you touched and what verification you ran, so the review starts from evidence rather than from your summary.
Fix at the mechanism, not the symptom: a wording repeated across many generated files has one generator, and editing the generated copies leaves the next render to undo you. Check first whether the text you are about to hand-edit is emitted by a function or asserted verbatim by a test. When no ../repomatic checkout exists, fall back to describing the finding precisely enough to act on: the file, the mechanism, and the failure it causes.
Shipping the findings when the repo is repomatic
Inside kdeldycke/repomatic there is no ../repomatic, and the boundary above inverts. The canonical repo is both the subject of the release and the home of every skill, workflow and convention these findings target, so "propose it upstream" collapses into "fix it here". Leaving the diff uncommitted there buys nothing and costs the release: the next cycle ships the same rough edge, and the diff sits in the tree collecting conflicts against whatever the machinery rewrites meanwhile. Detect the case the way the invocation rule already does (the context shows CANONICAL_REPO), then land the findings as an ordinary reconciliation commit rather than a working-tree diff.
A late commit still ships, which is what makes folding them in possible at all. prepare-release regenerates the release PR on every push to main, replaying the freeze onto the new HEAD, so anything pushed before the maintainer merges lands in this release instead of the next. That is also why the pass stays here rather than moving before step 5: the findings worth shipping mostly do not exist until babysit has surfaced what CI does and the local gate cannot.
Treat that commit as a reconciliation like any other, which means four things the uncommitted-diff path never had to handle:
- Verify it with the same gate. Run the part of step 2 the change touches: the pinned
<cmd> run mdformat over an edited Markdown asset, pytest tests/test_claude_assets.py tests/test_skills.py over a bundled skill or agent, the full gate over Python. Bundled assets carry conformance tests that a docs page does not, and that difference decides how much you run.
- Give it a changelog bullet only when a user can observe it. A bundled skill, agent or workflow deploys verbatim to every downstream repo, so correcting one is a shipped fix and earns an entry. A change to this skill's own release choreography is not something a downstream user consumes, and earns none. When you do add one, re-run consolidation and present the diff (step 4) before committing.
- Time the push by what it rebuilds (step 6): a
.claude/-only or docs-only commit skips the matrices yet still cancels an in-flight release.yaml on a binaries-enabled project. Hold it until the heavy matrices are terminal, or bundle it into the next source-affecting push.