Imported from kdeldycke/dotfiles (
dotfiles/.agents/skills/repomatic-ship/SKILL.md). Install upstream withnpx skills add kdeldycke/dotfiles --skill repomatic-ship. Copyright stays with the author.
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-permissionsmutes 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, useuv run repomatic. Otherwise useuvx --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. References to<cmd>below resolve to one or the other. - You hold no
Edit/Writeof 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 downstreamCLAUDE.mdsays nothing about commit attribution. 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
SendMessageto 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. Do not pair the message with a "write the same report to a file" fallback: the harness refuses a spawned agent that write, so the instruction only spends the agent's attention on something it will report back as blocked. - 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 isrun <tool>: the tool registry supplies most of them, so dropping therepomatic runprefix does not merely drift from the pinned version, it fails outright โ a7.8.0spawn brief that expanded<cmd> run mypy --intouv run --frozen -- mypy --died onFailed 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 diffthe 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 absence of the old text (grep -c '<old phrase>' <file>returning0) instead of eyeballing a diff: an empty match is unambiguous where a diff read mid-scroll is not. - Never revert the shared working tree. The agents share one tree, so disjoint lanes do not make them race-free: no agent may run a working-tree-reverting git command (
checkout,restore,stash,reset,clean), which silently discards the other agents' uncommitted edits. For full isolation instead, spawn withisolation: "worktree"and merge on join: disjoint files won't conflict. - Fix commits stage narrowly (
git commit <path>, never-a). Uncommitted files an agent did not create are the maintainer's in-progress work: never revert them and never sweep them into a commit. - Degrade gracefully. A cross-referenced skill excluded from this repo is a fallback path, not a blocker: apply its principle via an
Agentor inline. When anAgentspawn itself fails (a terminal API error), do the work in the main thread.
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
Agentthat reviews every file changed since the last tag for reuse, quality, simplification, and deduplication, and fixes what it finds, simplifying before adding: existing code or a tool often already covers the case. 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 baremypywhose 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-*.pylist CI's lint job checks,tests/anddocs/conf.pyincluded, and the two cannot diverge. A downstreamCLAUDE.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 atests/ordocs/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 -- repomaticare the intended unfrozen state (the freeze commit rewrites them to auvx '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 everyuv-invoking job provisionssetup-uvin its own steps. Do not "restore" an isolateduvx --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 pollutesgit statusand trips tool scans likerun typos. - Shortening an over-long workflow line to satisfy yamllint's 120-char limit must not lift
hashFiles(...)(or anyrunner.*) into a workflow-levelenv:var: that context exposes onlygithub/secrets/inputs/vars, so the expression resolves to empty at run-init before checkout and silently breaks the value โ a cachekey:shortened this way ships a broken key to every downstream repo. Shorten the literal itself instead (trim a shared key prefix, say). - The red inventory from the pre-spawn capture is part of its brief: root-cause each repo-fixable red (a chronic platform break, a flaky live-registry install, a crashing
โ๏ธprobe) and fix it at the source per step 6's taxonomy, even when the failing file saw no change this cycle. - Pending work the cycle introduced belongs in a
```{todo}admonition where Sphinx renders it, not in a bare# TODO/XXXcomment: a comment never reaches the published todo list, which is the project's inventory of what it owes. This applies to committed cycle-diff lines only, and does not loosen the unstaged-scratch guard above: a# TODOpresent only in the unstaged set is the maintainer's draft and stays untouched. - Retire a
{todo}whose trigger fired, deleting the shim it guards in the same edit. Bound the check to what moved:git diff v<last>..HEAD -- pyproject.toml uv.lockplus the tool-version registry names the upstream releases this cycle actually adopted, and a todo naming one of them is the only kind worth re-reading. Never poll every linked ticket, and leave a todo whose trigger has not fired alone. A published todo list advertising work already done is worse than no list.
-
Docs: an
Agentthat verifies prose docs against current behavior, not the journey (version references, CLI output, removed or renamed features go stale every cycle). Its constraints:- Manually-maintained version examples (install commands, binary download URLs,
uses:refs) track the latest released tag, never the version being prepared, because the docs site deploys on every push tomain. "Manually-maintained" is the load-bearing word and it is not a synonym for "underdocs/": the freeze reaches into that tree in at least one repo (the canonical one rewritesdocs/install.md), so decide file by file from the freeze's actual scope per the next bullet, never from the path. The tracking runs both ways: advance a sample that lags the released tag (still at N-1 after release N published) up to it, applying the bump directly rather than deferring it as a version advisory; only bumping a sample forward to the not-yet-released version is off-limits. A stale sample hides in plain sight, so grep every version string indocs/andreadme.mdrather than trusting a sub-agent's list. - What the freeze rewrites varies by repo. The canonical repo pins workflow refs and CLI invocations; a downstream freeze may touch only
changelog.md,citation.cff,__init__.py, andpyproject.toml. Read the last freeze commit's actual diff (git show <last-freeze-sha>, never just--stat) and treat every version sample it does not rewrite,readme.mdquick-start output included, as hand-maintained tracking the released tag: samples presumed freeze-managed have shipped stale through a release. - Freeze-management is per line, not per file. A file list is exactly the wrong granularity to decide it: a file the freeze touches can still carry hand-maintained samples it never rewrites, so
--statclears the whole file and the stale ones survive. The archetype:docs/install.mdsits in the freeze commit, which rewrites precisely one line of it (theuvx <cli>@X.Y.Zpin), while the>>> <pkg>.__version__REPL capture further down the same page is hand-maintained and had been a full release behind since the previous cycle. - Settle each sample individually with
git log -S '<the literal string>' -- <file>, which shows whether a freeze commit or a human last moved it. - On a cycle that migrated the release tooling itself, the historical freeze under-predicts the new one. A pre-repomatic freeze touching only
changelog.mdsays nothing about the repomatic freeze, which also rewritescitation.cff,__init__.py, andpyproject.toml: treat every version sample as hand-maintained until the regenerated release PR's diff shows the new freeze's actual scope. - The mirror of the hand-maintained rule: a freeze-managed field legitimately shows the dev version between releases, so never flag one as stale against the released tag or as a dead link.
pyproject.toml'surls.Downloadreading.../releases/tag/vX.Y.Z.devN(a tag that has no GitHub release yet) is the expected post-bump state that the freeze rewrites to the release tag at cut. Hand-maintained samples track the released tag while freeze-managed fields track the dev version, so classify a version string by which mechanism owns it before judging it stale. - A third owner sits beside hand-maintained and freeze-managed: artifacts regenerated by
_release-engine.yaml'supdate-dep-graphjob, whoseif:gates onrelease_commits_matrix, so it fires only on release commits (never on ordinary pushes, to avoid noise from transitive dependency churn). It sits in the release engine rather thanautofix.yamlbecause a release push is its only firing moment, and that is exactly the pushautofix.yamlnow skips wholesale, so do not go looking for it there.docs/assets/dependencies.mmdtherefore lagspyproject.tomlfor the whole cycle: a runtime dependency added since the last release is expected to be missing from it, and the graph catches up through its own PR once the release lands. Never hand-forge the file, and never run<cmd> update-dep-graphto "fix" it mid-sweep: a local run uses whatever repomatic version the sweep resolved rather than the version the job pins, so it produces churn the next regeneration reverts. Classify a stale-looking generated artifact by the job that owns it before reporting it as drift. - Executable doc blocks fail open: a
{click:run}invocation that no longer parses renders the usage error into the published page instead of failing the build (docs.yamlstayed green while a stale option printedNo such option; onlyclick:treeandclick:confighard-error). Verify each{click:run}invocation against the current CLI, or grep the built HTML forError: No such option-class output. - Correcting one description of a convention means correcting every description of it in the same pass: a rule restated in more than one place (an overview line and its worked example, two docs pages) drifts as a set, so grep for the sibling statements and align them together โ fixing one in isolation leaves the others contradicting the fix (a freeze-cutoff overview still said "the day after" while its worked example had been corrected to "the second day after", reconciled only on a second pass).
- The same "align the siblings" rule governs quantities, not just wording. A cycle that measured the same thing twice ships two answers, so cross-check every timing, size, count and ratio a page states against the other statements of it, the changelog's copy included (a performance page opened "every timing below comes from one batch of runs" while two of its tables disagreed
2.3xon the same step at the same settings, each table internally consistent, because two commits had each measured their own batch; the changelog repeated the wrong figure). Surrounding prose that quotes a ratio the tables no longer support is the tell. Report the contradiction with the conflicting values and which one the other statements corroborate, rather than silently re-deriving numbers on hardware that is not the maintainer's: the figures are theirs to own, but they cannot fix what nobody flagged. - Commit provenance decides whether a clashing figure is reported or fixed, and the two need opposite handling. Blame each one (
git log -S, or read the commit that introduced it). When the older figure entered earlier in this same cycle and a later commit measured the thing again, it is not a rival result: it is superseded intra-cycle scaffolding, which layer 1 of the code pass strips like any other. Replace it, and cite the surviving measurement's source beside it so the two cannot drift apart again. Only when neither figure obsoletes the other are they independent measurements, and only then does the report-don't-touch rule above apply. Getting the direction wrong fails both ways: it either ships a stale number dressed as a competing result, or silently overwrites a real measurement nobody asked you to re-take. - The docs build has a single owner, the code agent (which already builds for docstring cross-references): verify prose against that build instead of launching a second
sphinx-buildinto the same output dir. - Any docs-pass edit touching a
.pyfile (typicallydocs/conf.py) is re-verified with a bare<cmd> run mypybefore the agent returns:docs/conf.pymay import from the docs group's higher Python floor while mypy checks the project minimum, and the break otherwise surfaces only in CI's lint job. Give it no arguments here too โ namingdocs/as a directory is the form step 2 warns against, since a directory changes module resolution enough that mypy follows an installed dependency's own source. - Changelog released sections (
## [X.Y.Z]blocks) are immutable history: a command, option, or config key named there was correct for that release, so never flag or rewrite a since-renamed name in one. When the changelog seeds the checklist for a rename, reconcile only the unreleased section (the docs pass once flaggedupdate-deps-graphin three released sections that a7.4.0rename had superseded). - A
```{todo}on a docs page follows the same two rules the code pass applies to docstrings: pending work the cycle introduced is written as one rather than as a loose closing sentence, and one whose trigger fired this cycle goes, along with the paragraph it qualified. The todo list page publishes on every push, so a stale entry there is a public claim about work already done.
- Manually-maintained version examples (install commands, binary download URLs,
-
Bundled assets: an
Agent(useqa-engineer, the gatekeeper for agent and skill definitions) that checks.claude/skills/**and.claude/agents/**against what the cycle actually changed. These files deploy verbatim to every downstream repo throughrepomatic init, so a claim this cycle falsified ships as confidently as it did when it was true, and neither substance lane owns them: the code pass sees no Python and the docs pass is scoped todocs/. Its constraints:- Scope it to claims the cycle invalidated, not a general review. Grep the cycle diff for the behaviors these files describe (cadences, gating conditions, job names, default values, config keys) and verify each surviving statement against its source, rather than reading for style.
- Verify against the workflow or module, never against the summary in the brief. The point of the pass is that prose drifted from code; a second-hand description is the same failure one level up.
- Fix the claim, do not rewrite the surrounding strategy: a stale fact inside good advice is a fact bug.
- A pre-existing error in the same class, found while checking, is in scope โ these ship downstream too, and nothing else audits them. Report it separately from the cycle-caused ones so the maintainer can tell which the release introduced.
- Cross-references must degrade gracefully; skills stay self-contained (no upstream-only
docs/URLs or paths), since downstream repos have neither.
The archetype:
7.9.0narrowed ordinary pushes to a canary binary subset, and twobabysit-ciclaims plus arepomatic-depscadence line kept describing the old behavior. Nothing in the code or docs lanes would have caught them. -
Changelog: once the three passes settle, invoke
/repomatic-changelog consolidatethrough theSkilltool, so the consolidated entries (and the version advisory reading them) reflect the reconciled tree, renames included. It collapses superseded values and drops intra-cycle reverts. Consolidation assumes the entries already exist, though: when the unreleased section under-represents the net cycle (a maintainer left one stub bullet for a multi-feature cycle), runaddfirst, or the bare/repomatic-changelogdefault that runsaddthenconsolidate, so the shipped changes are drafted before they are collapsed. When bothaddand consolidation legitimately find nothing user-facing (a purely mechanical cycle), consolidation now backfills one generic maintenance bullet rather than leaving the section empty (/repomatic-changelogconsolidation rule 08): a published release heading with no bullets reads as broken. If the skill is excluded here, degrade gracefully (sub-agent rules).
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
HEADofmain), 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-releaseshows a PR whose freeze commit sits on the currentHEAD, confirm every stable job onHEADis green and go to step 7, spawning/babysit-cionly on a real failure. When no current PR exists (the last push missedchangelog.yaml'spaths:filter), trigger one withgh 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.
"The source did not change" never proves "the check still passes". An in-cycle lockfile bump can invalidate type: ignore comments and override signatures with zero source changes: 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, read an ancestor instead. Rapid pin/lock auto-commits plus hosted-runner backlog routinely leave them unfinished here, so HEAD has no conclusions to read: take 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.pyincluded. 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 thedocsgroup and written for a newer Python fails the run under--python-version 3.10with 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 PyPIwarning 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.devfloor is thesync-dep-sourcesidiom mid-flight: the fix is to wait for that swap PR, not to editpyproject.tomlby hand. The release lane carries the same check as a hard gate, but it fires after the freeze commit is already onmain, 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 aPERF401fix):- Run autopep8 over the cycle's changed Python files:
git diff --name-only HEAD -- '*.py' | xargs <cmd> run autopep8 --, which wraps long-line comments ruff leaves. Never pass a shell variable holding the space-separated list, which the runner takes as one literal path and rejects with[Errno 2] No such file or directory: 'a.py b.py c.py '. - Then
<cmd> run ruff -- checkand<cmd> run ruff -- format, and readgit diff. Both write in place, butcheckonly does so because the resolved ruff config setsfix = trueโ the runner injects no flag of its own, so a repo carrying a[tool.ruff]section without that key gets a read-onlycheckand 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 (
changelog.md,docs/), ask<cmd> run mdformat --verify -- <file>. It reports what the write path would change without touching the tree. Never a baremdformat/mdformat --with mdformat-myst, whose plugin set rewrites MyST directive colon-options (a{list-table}'s:header-rows:/:widths:) to---frontmatter form, diverging from CI's autofix. - Landmine: autopep8 relocates a trailing
# type: ignore[...]off a >88-char line onto its own line, voiding the suppression (Lint red underwarn_unused_ignores); ruff format usually reverts the relocation, so only wraps that survive the full pinned sequence are real formatting debt. Never commit the relocation: fix the line length at the source so the comment rides the opening line.
- Run autopep8 over the cycle's changed Python files:
- Workflow YAML, whenever the cycle touched
.github/workflows/:<cmd> run actionlint --,<cmd> run zizmor -- ., and<cmd> run yamllint --over every workflow. Nothing else in this gate reads workflow YAML, yet step 1's code pass is told to edit it, so a regression the sweep itself introduces stays invisible until CI โ and on this repo a bundled workflow carries it to every downstream repo, which gets the workflow without the conformance tests guarding it here. The class worth knowing, because a reviewer's eye slides straight past it: GitHub Actions evaluates anif:as an expression only when the value is entirely one${{ โฆ }}. A folded block scalar (if: >) wrapped around one appends a trailing newline, so the value interpolates to a non-empty string and the step is truthy forever, gating nothing. Write a multi-line condition bare, with no wrapper (a leading!rules out the bare single-line form, since YAML reads it as a tag indicator). Elevenautofix.yamlsteps stopped gating this way and sat red onmainacross two commits before a release run caught them. - Autofix externals: smoke-run
<cmd> run typos, every formatter that downloads a checksum-pinned binary (<cmd> run biomeand peers), and the vulnerable-deps scan<cmd> audit --fix(parses liveuv auditoutput). An upstream re-publish flips a pinned SHA-256 and kills the step; pytest mocks these, so the drift (or a changed output schema) surfaces only here or in CI'sautofixrun. A pin living upstream inrepomaticbreaks every downstream repo and cannot be patched here: surface it for the step-8 upstream report. Invocation rules:- Run each tool bare. A bare
<cmd> run <tool>now builds the invocation CI performs, resolving the tool's own file list, so it is both the correct smoke test and the one that cannot be got wrong. Passing.instead is what to avoid:biome check --write .andshfmt -- .resemble whatautofix.yamlruns and are not it, and biome ships no.gitignoreawareness by default, so one.once walked adocs/_buildtree into 79,553 reported errors while retabbing two tracked files no CI job formats. - A tool the repo has no files for is skipped by the runner rather than invoked pathless, so a bare run is safe even where an
xargspipe was not. To check a pin alone without running the tool over anything,<cmd> run <tool> -- --versionstill downloads and checksum-verifies the binary. - Not every non-zero smoke is drift, and only drift is worth reporting upstream. Three failure shapes are environmental and block nothing:
No binary for <platform> <arch>. Available: โฆ(anUnsupportedPlatformError) means the pinned release ships no build for the dev machine's platform, which says nothing about CI's Linux runners โ and it is the interpreter's arch that decides, so an Apple-silicon host running an x86-64 interpreter under Rosetta resolves to macOS x86-64 and fails on a tool that ships only macOS ARM64, where the native interpreter would have succeeded;Truncated download for <url>: got N of M bytesis a short body from a proxy or a sandboxed network, not a bad artifact (the runner raises this deliberately so it cannot be mistaken for a bad digest, so trust the wording); andPermissionError: [Errno 1] Operation not permittedunder the binary cache dir is a sandbox denial. Retry the last two with the sandbox off before concluding anything. Real drift readsSHA-256 mismatch for <url>: expected โฆ, got โฆ: that one alone breaks every downstream repo and belongs in the step-8 report. - These run in write mode (
run typosdefaults to--write-changes;audit --fixrewrites pins), and a smoke run can still touch files outside CI'sautofixscope: revert any mutation that is not part of this release's net diff before the step-5 commit; a fix that genuinely belongs to the cycle can be kept and folded into the reconciliation. Revert a formatter move as a move: it is a paired add-plus-delete, and reverting only the added copy silently deletes the block from the file (a[tool.uv]key nearly vanished this way). A mutated file carrying no reconciliation edit reverts whole withgit checkout -- <file>: safe at this gate, where the sweep agents have already joined (their no-revert rule protects a live shared tree).
- Run each tool bare. A bare
- Binary self-test plan, against the source build:
uv run -- click-extra test-suite --command <source-entrypoint> --jobs max, the same enginetests.yaml(--command) andrelease.yaml(--binary) drive. It catches the two failures otherwise hidden until the ~90-minute matrix: a case assertion drifted from current CLI output (colors stripped by the piped harness, a moved string), and a plan that cannot load under the binary runner's stdlib-only base deps.- Keep the plan TOML or JSON. A YAML or json5 plan raises "format support disabled" and silently falls back to a trivial suite. Confirm a non-TOML plan parses under stdlib
tomllib, since the full-venv source run has the format extras and hides the gap. - It cannot catch a case whose expected output depends on the target platform's identity. The source run is single-OS, so an assertion keyed on the release runner's distro (a
manylinuxcontainer correctly detected as its RHEL/AlmaLinux base) or a manager present only there passes locally and still reds the binary matrix. A cycle that bumps a platform-detection dependency (extra-platforms) can silently shift such attributions: expect test-plan breakage in the binary matrix and check it there, not just in the source self-test.
- Keep the plan TOML or JSON. A YAML or json5 plan raises "format support disabled" and silently falls back to a trivial suite. Confirm a non-TOML plan parses under stdlib
- Fresh resolution:
uvx --no-progress --from . <cmd-bare> --version. A fresh isolated env resolves[project.dependencies]from scratch, surfacing transitive conflicts the already-synced venv hides; CI's๐งฌ Project metadatajob runs exactly this on every workflow, and end users installing viauvxhit the same resolution, so a failure is release-blocking. Fix at the dependency level (drop, swap, or wait on upstream), never with environment-scoped overrides:uvx --from .does not read[tool.uv] override-dependencies.- A cooldown filter is not a resolution failure โ check for it before declaring one.
uvxreads the user-level~/.config/uv/uv.toml, which on a maintainer's machine typically carries a supply-chainexclude-newer(repomatic recommends one). A release that raises a dependency floor to a version published inside that window then fails to resolve on the maintainer's machine only, for a reason no end user shares and no dependency conflict caused. The tell is in uv's own output: ahint: <pkg> was filtered by exclude-newer to only include packages uploaded before <date>. The latest version satisfying the requirement is vX, published at <later date>โ that is environment, not a conflict. Note this is self-clearing: the same command passes untouched once the dependency ages past the window. - Re-run with the cutoff lifted wholesale, and judge the gate on that result. Use
uvx --no-progress --exclude-newer 2100-01-01 --from . <cmd-bare> --version. Lifting it per-package (--exclude-newer-package <pkg>=0d) just surfaces the next floor in the chain, one round-trip at a time. - The
uv-build ... missing an upper boundwarning here is expected, never a resolution failure. repomatic deliberately leavesuv-builduncapped across managed repos (rationale in repomatic's ownpyproject.toml[build-system]comment: pure-Python projects with no packaging edge cases, and a hard ceiling risks a chicken-and-egg deadlock when a project's uv moves ahead of repomatic's next release), so never flag it or add a cap.
- A cooldown filter is not a resolution failure โ check for it before declaring one.
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 bareopen()/read_text()/write_text()breaks on the first non-ASCII character, and only in Windows CI: passencoding="utf-8", and when the cycle touched file I/O, run the suite once withPYTHONWARNDEFAULTENCODING=1to surface calls ruff's inference-limitedPLW1514cannot see), and direct execution of a generated script (Windows honors neither the executable bit nor the shebang, dispatching on file extension, so achmod +x'd shebang script a test runs by bare path fails withWinError 193: emit a.cmdlauncher beside a.pysidecar 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 onHEADabove 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>(like3.14tfor a free-threading race) recreates and repoints.venvto that interpreter.- Restore it with
uv sync --frozen --all-extras --group test --group typing.testmirrorstests.yaml, andtypingโ stubs-only, so it cannot perturb a test at runtime โ is what keeps the gate's own<cmd> run mypyhonest, since a venv synced without it floodsimport-untyped/import-not-foundin files the cycle never touched (evenpytestreads asimport-not-found), a false red the restore itself manufactures. - Never
--all-groups. It additionally pulls in thedocsgroup whose imports perturb process-global-state-dependent tests (logging config, default theme) into spurious failures, while a default-onlyuv syncstrips both needed groups (nopytestat all). - Trust CI's
lint.yaml/tests.yamlover a local gate re-run against a churned venv. All of these are venv-provisioning artifacts, not code regressions, so re-sync to the CI-matching groups before re-running the gate.lint.yamlon the priorHEADreporting exactly the real error set, and none of the stub noise, is the authoritative mypy signal.
- Restore it with
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: an imperative subject under 72 characters, and a body only when the sweep bundled orthogonal strands of work. That body is then one short line per strand, never a paragraph per theme. The changelog already carries the user-facing story and the diff carries the rest, so nothing else belongs there. Most commits in this repository have no body at all.
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 need the sandbox off. 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 key is not a retry loop. A hardware-backed key (Secretive, YubiKey, TPM) 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.
Prefer merging a green autofix PR over authoring your own signed commit. When the fix already exists as an open, CI-green sync-* or format-* branch, GitHub signs the merge commit server-side, sidestepping the local key entirely.
"Ask the maintainer" can mean asking them to act outside this session. 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 them to run the merge or push themselves, 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, never the run's conclusion. Fix and push immediately, restart CI on the new commit, and 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 on the sonnet model to run /babysit-ci to completion, named so it stays addressable. The loop is mechanical: fetch logs, match patterns, fix, commit, push. If /babysit-ci is excluded here, the agent runs that same loop inline, and the sub-agent rules cover a failed spawn.
- Leave it on the default background mode, never
run_in_background: false. The idle/chase cycle described below (a cappedgh run watchending 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, andrelease.yaml. The last one's engine runs the per-platform Nuitka matrix when the project enables binaries. - Key the watch on the workflow, never a literal job id. Release-engine job names are templated, like
โ {os}, {sha} build. - Read the
โ/โ๏ธglyph as 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.<cmd> ci-status --branch mainapplies the same rule for you. - Never anchor that glyph test at the start of the name. A release-engine job arrives through the reusable call as
release / โ {os}, {sha} build, sostartswith("โ ")matches none of them and drops every binary-matrix red on the floor.
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
mainHEAD hasconclusion: success(orskippedfor 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
sleepinside 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 andprepare-releasehangs (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 arun_in_backgroundBash poller or aMonitor-tool stream the agent idles on awaiting notification. Name theMonitortool in the prohibition: an agent told only "no background poller" does not classifyMonitoras 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
sleepis blocked in some harness shells, and an agent that hits that block will reach forMonitoras 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 asleep), andpython3 -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 watchis capped and backgrounded, and the agent idles once per cap, needing a chase to resume. Re-spawning or re-chasing does not fix it โ th
*Truncated - read the full file at https://github.com/kdeldycke/dotfiles/blob/efe368cec41285e4e8cfdc2dfc44da54f116b8b7/dotfiles/.agents/skills/repomatic-ship/SKILL.md