Reusable workflows¶
The repomatic CLI is invoked in CI from reusable GitHub Actions workflows. You configure behavior via [tool.repomatic] in pyproject.toml; the workflows trigger jobs and wire their outputs together, the CLI does the work.
Example usage¶
The fastest way to adopt these workflows is with repomatic init (see Quick start). It generates all the thin-caller workflow files for you.
If you prefer to set up a single workflow manually, create a .github/workflows/lint.yaml file using the uses syntax:
name: Lint
on:
push:
pull_request:
jobs:
lint:
uses: kdeldycke/repomatic/.github/workflows/[email protected]
GitHub Actions limitations¶
GitHub Actions has several design limitations that the workflows work around:
Limitation |
Status |
Addressed by |
|---|---|---|
✅ Addressed |
||
✅ Addressed |
String parsing in |
|
✅ Addressed |
|
|
Static matrix can’t express conditional dimensions or array excludes |
✅ Addressed |
|
✅ Addressed |
||
✅ Addressed |
||
✅ Addressed |
||
✅ Addressed |
|
|
✅ Addressed |
A sync pull request restoring a removed trigger re-enables it for itself: exclude the workflow |
|
✅ Addressed |
||
✅ Addressed |
Custom PAT for tag operations |
|
✅ Addressed |
Manual defaults in |
|
✅ Addressed |
|
|
✅ Addressed |
Explicit |
|
✅ Addressed |
Random delimiters in |
|
✅ Addressed |
Always use |
|
✅ Addressed |
Force |
|
Windows runners use non-UTF-8 encoding for redirected output |
✅ Addressed |
Set |
❌ Not addressed |
Same root cause as PR close; partially mitigated by |
|
❌ Not addressed |
GitHub limitation; use |
|
🚫 Not addressable |
Linter limitation, not GitHub’s |
|
✅ Addressed |
Per-job runtime caps, enforced by |
Job runtime caps¶
Every job that occupies a runner declares timeout-minutes. GitHub offers no workflow-level or organization-level default for it, so the key has to be repeated on each job, and a job that omits it runs until the platform’s 6-hour ceiling. That default is the wrong shape for a shared account: the macOS and Windows runner pools are capped per account and shared by every repository in it, so one hung cell starves all the others for the rest of those six hours. The cost of the omission lands on projects that have nothing to do with the workflow that hung.
The caps are runaway backstops, not performance budgets. Each sits far above the job’s measured worst case, so ordinary growth never trips one:
Cap |
Applies to |
Measured worst case |
|---|---|---|
10 minutes |
|
~1-2 min (estimated; see the job’s own timeout comment) |
15 minutes |
Bounded local work or a handful of API calls: linting, formatting, |
2.8 min ( |
30 minutes |
Jobs that provision a toolchain, iterate a matrix cell, or paginate a whole issue history |
4.8 min ( |
45 minutes |
|
17.4 min (cold-cache compile), 10.4 min (the link crawl) |
Two jobs carry no cap, and cannot: release.yaml’s build and release delegate to a reusable workflow via uses:, where GitHub accepts only name, uses, with, secrets, needs, if and permissions. Their runtime is bounded by the caps on the jobs of the workflow they call.
Downstream repositories inherit all of this: the caps live on the reusable workflows’ own jobs, so a thin caller gets them without configuring anything.
🪄 .github/workflows/autofix.yaml jobs¶
This workflow runs on every push to main and on a weekly schedule so quiet repos that see few pushes still receive dependency and pin updates automatically. Version-bump pushes (a release’s [changelog] pair, manual major/minor bumps) skip every job: those commits are machine-generated and ship-gated, and any drift they could introduce is caught by the next ordinary push or the weekly sweep.
Setup — guide new users through initial configuration:
📖 Setup guide (setup-guide)¶
Detects missing
REPOMATIC_PATsecret and opens an issue with step-by-step setup instructionsWhen the PAT is present, validates all required permissions (administration, contents, issues, pull requests, Dependabot alerts, workflows) using the same checks as
lint-repoKeeps the issue open with a diagnostic table when the PAT exists but permissions are incomplete
For projects published to PyPI, probes the latest release’s PEP 740 provenance and keeps the issue open until a successful OIDC-attested upload confirms the Trusted Publisher entry is registered for this repo’s own
release.yamlIncludes the setup step of whichever host
site.deploynames, and only that one: the GitHub Pages deployment source (Sphinx projects, the only ones repomatic publishes there), or the Cloudflare Pages project and its deploy token (any repository declaring the target, since a site built by its own workflow needs the same secret). A site that also declaressite.cloudflare-r2-bucketgets one more step, for the R2 bucket and its upload key pair, and the issue stays open until both keys are setWhen Nuitka binary compilation is active, includes a VirusTotal API key setup step and keeps the issue open until the key is configured
When the unsubscribe workflow is enabled (
notification.unsubscribe = true), includes a notifications token setup step and keeps the issue open untilREPOMATIC_NOTIFICATIONS_PATis configuredAutomatically closes the issue once the secret is configured and all permissions are verified
Skipped if:
upstream
kdeldycke/repomaticrepo,workflow_calleventssetup-guide = falsein[tool.repomatic]
🖥️ Sync runner images (sync-runner-images)¶
Looks every runner label this repository runs up in the Available Images table, and opens a pull request carrying the mechanical half of whatever it finds, so the decision is made against a real CI run rather than against a description
A retirement rewrites every literal
runs-on:naming a deprecated image onto its successor. A released image always wins over a preview, since a forced move should not trade a known deadline for an unknown one; a newer preview passed over is named in the pull request body rather than takenAn upgrade to a strictly newer version has two halves. Every literal
runs-on:naming the old image is rewritten onto the new one, since a matrix cell cannot reach a job that names its image outright. The full test matrix also gains the image as acontinue-on-errorprobe (test-matrix.variations.osplus atest-matrix.unstableentry)The probe half is skipped when the matrix already runs the successor: its
unstableentry marks every cell on that imagecontinue-on-error, so one job left pinned to an older image would otherwise stop the current image from gating the build. The rewrite half still applies, and is what stops that job being left behindA rewritten
runs-on:is evidenced by the proposal’s own CI run only for a job that run executes. A workflow triggered byscheduleorworkflow_dispatchalone shows nothing on the pull request, so review its diff rather than its checksStrictly newer by version is what separates an upgrade from a flavour.
Windows 11 Arm64 with Visual Studio 2026sits at the same version asWindows 11 Arm64: a different toolchain, not a newer image, and it is never proposed as oneNothing bumps a
runs-on:value automatically (sync-action-pinsrewritesuses:references,sync-workflow-pinsrewrites version literals), so a retirement otherwise arrives as a failing build with no warningOnly literal
runs-on:values are rewritten. A value built from an expression draws on a matrix axis, which the axis owner movesThe announcement feed is deliberately not read. It reports what changed for anyone; the table reports what is true here, and only the second decides anything. The cost is that GitHub badges an image
deprecatedwhen deprecation begins rather than when it is announced, so a retirement surfaces months later than the feed would have shown it, still well before the image stops workingRuns on: the weekly schedule and manual
workflow_dispatchonlySkipped if:
A label is named in
[tool.repomatic.sync-runner-images] ignore, which is how a declined proposal stays declined: async-*job regenerates on every run, so closing its pull request alone brings the proposal backThe Available Images table cannot be read or parsed, which fails closed
Formatters — rewrite files to enforce canonical style:
🐍 Format Python (format-python)¶
Auto-formats Python code using
autopep8(comment wrapping) andruff(linting and formatting)When the project has no
[tool.ruff]section orruff.toml, repomatic’s bundled defaults are applied at runtimeRequires:
Python files (
**/*.{py,pyi,pyw,pyx,ipynb}) in the repository, ordocumentation files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx,rst,tex})
Todo
Collapse the job’s two Ruff steps, check then format, into one invocation once Ruff unifies linting and formatting behind a single command: astral-sh/ruff#8232. The autopep8 step goes the same way once Ruff wraps long comments: astral-sh/ruff#7414.
📐 Format pyproject.toml (format-pyproject)¶
Auto-formats
pyproject.tomlusingpyproject-fmtRequires:
Python package with a
pyproject.tomlfile
✍️ Format Markdown (format-markdown)¶
Auto-formats Markdown files using
mdformatand its pluginsOn an
awesome-*repository, follows up withrepomatic fix-awesome-tocto delete the table-of-contents entries awesome-lint forbids, fromreadme.mdand from everyreadme.{lang}.mdtranslation beside itRequires:
Markdown files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx}) in the repository
🐚 Format Shell (format-shell)¶
🔧 Format JSON (format-json)¶
Auto-formats JSON, JSONC, and JSON5 files using Biome
Requires:
JSON files (
**/*.{json,jsonc,json5},**/.code-workspace,!**/package-lock.json) in the repository
Fixers — correct or improve existing content in-place:
✏️ Fix typos (fix-typos)¶
🛡️ Fix vulnerable dependencies (fix-vulnerable-deps)¶
Invokes
repomatic audit --fix, which detects vulnerable packages from two advisory sources, unioned and deduplicated per package by advisory identity (a sharedadvisory_idor a cross-referenced CVE/GHSA/PYSEC alias):uv auditagainst the Python Packaging Advisory Database (OSV-backed). Works locally and in CI without a GitHub token.The repository’s Dependabot alerts feed against the GitHub Advisory Database. Catches CVEs (including transitive
uv.lockpackages) that the PyPA database has not yet ingested.
Uses
uv lock --upgrade-packagewith--exclude-newer-packagebypass to resolve fix versions that may be within theexclude-newercooldown periodPR body includes a table of vulnerabilities (with the source database that surfaced each one) and updated package versions with release notes
Opens no pull request when the patched release is out of reach, which happens when another dependency caps the vulnerable package below it.
uv lock --upgrade-packagekeeps the old version instead of failing, so the alert stays open until that cap lifts; the lockfile is restored to how the job found it, sinceuvrecords the cooldown bypass in its[options]table even when the resolution does not moveRequires:
Python package (with a
pyproject.tomlfile)uv>=0.11.15, for theuv audit --output-format jsonoutput thatrepomatic auditparses (an olderuvraises rather than silently scanning nothing)For the GitHub Advisory Database source: a token with
Dependabot alerts: Read-onlypermission (REPOMATIC_PATor the workflowGITHUB_TOKEN) and Dependabot alerts enabled on the repository
Skipped if:
vulnerable-deps.sync = falsein[tool.repomatic]
🖼️ Format images (format-images)¶
Losslessly compresses PNG and JPEG images using
repomatic format-imageswithoxipngandjpegoptimSkips files where savings are below
--min-savings(percentage, default 5%) or--min-savings-bytes(absolute, default 1024 bytes)Requires:
Image files (
**/*.{jpeg,jpg,png,webp,avif}) in the repository
Syncers — regenerate files from external sources or project state:
🙈 Sync .gitignore (sync-gitignore)¶
Regenerates
.gitignorefrom gitignore.io templates usingrepomatic sync-gitignoreRequires:
A
.gitignorefile in the repository
Skipped if:
gitignore.sync = falsein[tool.repomatic]
Fails if: the rebuild would drop a rule the committed
.gitignorecarries, since the generated file replaces it whole. The job lists the rules and stops without opening a pull request; move them intogitignore.extra-content, or add--drop-orphansto the step to discard them
🔄 Sync bumpversion config (sync-bumpversion)¶
Re-derives the
[tool.bumpversion]configuration inpyproject.tomlfrom the bundled template on every run usingrepomatic sync-bumpversion, overwriting canonical entries while preserving local-only additionsRequires:
A Python project that builds a distributable, gated on the
is_python_packagemetadata key rather thanis_python_project: a uv virtual project ([tool.uv] package = false) has a[project]table but nothing to version
Skipped if:
bumpversion.sync = falsein[tool.repomatic]
🔄 Sync repomatic (sync-repomatic)¶
Runs
repomatic init --upgrade --delete-unmodified --delete-excludedto sync all repomatic-managed files: thin-caller workflows, configuration files, and skill definitionsUpgrades the repository once a newer
repomaticrelease clears theminimum-release-agecooldown. The job hands off to that release throughuvx, so the upstreamuses:refs, the inlinerepomatic==pins and every managed file move in one pull request titled after that release, likeUpgrade repomatic to v7.17.0The upgrade pull request lists the
**Breaking:**and**Deprecated:**entries of every release it crosses, the warnings the new release printed (like a[tool.repomatic]key it no longer knows), the release notes, and the newer releases the cooldown still holds backNever moves the upstream pin back: a release pinned by hand inside the cooldown stays, and the job only regenerates its files. Set
upstream-pin.sync = falsein[tool.repomatic]to keep syncing at the pinned releaseRemoves unmodified config files identical to bundled defaults and cleans up excluded or stale files (disabled opt-in workflows, auto-excluded skills)
Prunes orphans of assets repomatic has dropped (renamed or removed skills, agents, or workflows), so an upstream rename propagates automatically instead of leaving a stale file behind. A skill or agent copy is deleted when its content matches any version repomatic shipped; a removed reusable workflow’s thin-caller is deleted when its
uses:line still points at the dropped upstream workflow. A locally modified copy (edited content, or a thin-caller with extra jobs) is reported for manual review, never deleted. Pass--keep-removedto report these without deleting, or--delete-removed-modifiedto also delete locally modified onesIn the upstream repository, regenerates bundled data files from the project’s own config (workflows are excluded via
[tool.repomatic])The upgrade pull request gives the command launching the
/repomatic-upgradeskill with both versions filled in. The skill applies what the new release lets the repository adopt, reuse or drop, and leaves the changes uncommitted. A manualrepomatic initthat moves the upstream pin prints the same command as its last next step
📬 Sync .mailmap (sync-mailmap)¶
Keeps
.mailmapfile up to date with contributors usingrepomatic sync-mailmapRequires:
A
.mailmapfile in the repository root
Skipped if:
mailmap.sync = falsein[tool.repomatic]
🔗 Sync dependencies (sync-deps)¶
One consolidated job runs four dependency updaters on a shared runner, sharing a single actions/checkout, astral-sh/setup-uv, and a cached ~/.cache/repomatic directory (repomatic’s TTL-gated HTTP cache of PyPI/GitHub/npm release metadata) across all four updaters.
Each updater still opens its own pull request on its own branch (sync-dep-sources, sync-uv-lock, sync-action-pins, sync-workflow-pins), all labelled 🔗 dependencies.
The working tree is reset (git checkout -- .) before each updater so their diffs never bleed together, keeping review and revert independent.
To run all enabled updaters locally, or a named subset, use repomatic sync-deps.
🔀 sync-dep-sources updater¶
Swaps a dependency tracked from a git branch back to its released version using
repomatic sync-dep-sourcesManages one idiom: a
[tool.uv.sources]entry tracking a git branch, paired with a.devversion floor naming the awaited release (likemango>=2.1.0.dev0); path or workspace sources,rev/tagpins, and floor-less branch tracks are never touchedOnce a stable, non-yanked release satisfying the floor ships on PyPI, one PR drops the source override, tightens the
.devfloor to its base release, freezes the adopted release through theexclude-newercooldown (anexclude-newer-packageentry thesync-uv-locklifecycle prunes once it ages out), and re-locksThe swap is all-or-nothing: a resolution conflict, or a lock landing on an unexpected version, restores the project untouched and reports nothing
PR body leads with a
Source swapstable (tracked branch, adopted release, ship date) above the usual updated-packages table and release notesRequires:
Python package with a
pyproject.tomlfile
Skipped if:
dep-sources.sync = falsein[tool.repomatic]
⛓️ sync-uv-lock updater¶
Runs
uv lock --upgradeto update transitive dependencies to their latest allowed versions usingrepomatic sync-uv-lockSyncs the canonical
[tool.uv]pins (required-version,exclude-newer) from the bundled template intopyproject.toml, so the lock resolves against the pinned uv floor and cooldown, while leaving every other project-owned[tool.uv]key untouchedOnly creates a PR when the lock file contains real dependency changes or a cooldown-bypass edit (timestamp-only noise is detected and skipped)
PR body includes a table of updated packages with version ranges linked to GitHub comparison diffs, plus collapsible release notes for all intermediate versions
PR body then tracks the
exclude-newer-packagecooldown bypasses in a singleCooldown bypassestable: one row per freeze with its held version and aHeld untilexpiry,📌 frozen:and🧹 cleared:labels on the entries the run rewrote or removed, and a🚧 unreleased:label with a needs release expiry for freezes holding git or path sources. A warning under the table names each cleared entry that the comment aboveexclude-newer-packagestill mentions, since only a human can rewrite that commentPR body closes on the releases held back by the
exclude-newercooldown, including those blocked by anexclude-newer-packagefreeze: newer versions already published but still too young to lock, with the date each ages out of the window. It comes last because it is the only section reporting what the run left alone rather than what it changedRequires:
Python package with a
pyproject.tomlfile
Skipped if:
uv-lock.sync = falsein[tool.repomatic]
📌 sync-action-pins updater¶
Bumps SHA-pinned GitHub Actions (
uses: owner/repo@<sha> # vX.Y.Z) to the latest release past theminimum-release-agecooldown usingrepomatic sync-action-pinsHandles the SHA-to-semver mapping automatically: reads the trailing
# vX.Y.Zcomment, fetches the latest release, resolves it to a commit SHA, and rewrites theuses:lineLeaves pins owned by
sync-repomaticuntouched: upstreamkdeldycke/repomaticrefs and anyuses:line inside a filerepomatic initdeploys verbatim (like thepublish-pypicomposite action). Bumping those here would be reset on the next init sync, ping-ponging the two pull requestsPR body lists each updated action with old and new versions
Requires:
Workflow files (
.github/workflows/**/*.yaml) in the repository
Skipped if:
action-pins.sync = falsein[tool.repomatic]
🔢 sync-workflow-pins updater¶
Bumps npm
pkg@x.y.zversion literals anduvx 'pkg==x.y.z'PyPI pins embedded in workflow YAML to their latest release past theminimum-release-agecooldown usingrepomatic sync-workflow-pinsTargets inline version literals that
sync-action-pinsdoes not cover (actionuses:lines are handled there;npm install,npxanduvxpins are handled here). Flags sitting between the command and the package (npx --yes pkg@1.2.3) are skipped over, and scoped npm names are matchedThe upstream toolkit’s own pin (like
uvx 'repomatic==x.y.z') is exempt from the cooldown: the repomaticuses:refs are its source of truth (kept current byrepomatic init’s thin-caller regeneration), and thelint-repojob fails on any drift between them, so the pin aligns to those refs in lockstep. Because that alignment ignores the cooldown, the rewrite also splices--exclude-newer-package {package}=P0Din ahead of the pin:uvxreads no per-package exemption from the environment, frompyproject.toml, or from an adjacentuv.toml(astral-sh/uv#20995 tracks the missing environment variable), so the flag has to ride on the command line for the workflow’s ownUV_EXCLUDE_NEWERnot to withhold the version just written. Its PR table row shows a⛓️ lockstepmarker in theReleasedcolumn instead of a PyPI upload date, since no cooldown-checked release listing was consultedBackfills that same
--exclude-newer-packageflag even on a run that moves no pin version at all, so a repository already pinned at the newest release still gets the splice instead of carrying a broken pin indefinitely. A PR opening for the splice alone carries a🩹 Restored cooldown exemptionsection in place of the usual pin-diff tableThe uv pin carries a second ceiling on top of the cooldown:
setup-uvverifies a download against a pinned hash only for the versions in the checksum table its own release bundles. Anything else installs with no verification beforev10.1.0, and against a hash fetched at run time fromv10.1.0on, so a uv absent from the table pinned in the repository is never adopted. The ceiling moves when the action pin moves, not when uv publishes. A pin already sitting above that ceiling is the one case where the job steps a version backwards, down to the newest release the pinned action can verify: waiting forsync-action-pinsto catch up repairs nothing while the newestsetup-uvis the one already pinned, and every job keeps installing uv without a pinned hash for as long as that holds.lint-reporeports the same condition. An unreadable table holds the uv pin where it is: a blind bump could overshoot the table, and the first run that reads it again would step the pin backPR body lists each updated pin with old and new versions. A uv pin stepped back onto the checksum table shows a
⏪ stepped backlabel in theChangecolumn, linking to this sectionWhen the bump moves
repomaticitself, the PR body also invites the maintainer to run the/repomatic-upgradeskill, with both versions filled into the launch commandRequires:
Workflow files (
.github/workflows/**/*.yaml) in the repository
Skipped if:
workflow-pins.sync = falsein[tool.repomatic]
Note
A fifth updater, sync-tool-versions, shares this family but not this job: it rewrites repomatic’s own tool registry, so it lives in the upstream-only self-maintenance.yaml.
🕸️ Update dependency graph (update-dep-graph)¶
Generates a Mermaid dependency graph of the Python project using
repomatic update-dep-graph, and opens a PR with the refreshed diagramKeeps the graph current between releases: it shows no package versions, so a lock refresh changes it only when the dependency tree changes shape or a declared version requirement changes
Covers uv virtual projects (
[tool.uv] package = false), which lock dependencies but never run a releaseRequires:
Python project with a
uv.lockfile
Skipped if:
dependency-graph.update = falsein[tool.repomatic]
📚 Update docs (update-docs)¶
Regenerates Sphinx autodoc files using
sphinx-apidoc, converting the generated RST stubs to MyST markdown when the docs tree uses itLists every generated page in the
toctreeof its parent index page (Subpackagesfor a package,Submodulesfor a module), so a module added later never sits orphaned. An index page lacking the section a new page needs is left alone and named in the job logRuns
docs/docs_update.pyif present to generate dynamic content (tables, diagrams, Sphinx directives)Refreshes self-updating directive blocks (like
{matrix}compatibility tables) indocs/andreadme.mdwithclick-extra refresh-directivesRe-formats the
pyproject.tomlfiles withpyproject-fmtafterwards, so adocs/docs_update.pyrewriting some of their sections cannot make this job’s pull request ping-pong with theformat-pyprojectjobRequires:
Python package with a
pyproject.tomlfiledocsdependency groupSphinx autodoc enabled (checks for
sphinx.ext.autodocindocs/conf.py)
🔒 .github/workflows/autolock.yaml jobs¶
🔒 Lock inactive threads (lock)¶
Automatically locks closed issues and PRs after 90 days of inactivity with
repomatic lock-threadsCounts the 90 days from a thread’s last update, so a closed thread people are still replying to is left alone
Posts a short comment pointing at a fresh issue before locking, and skips anything carrying the
🤖 cilabel, since those issues are meant to reopen when their condition recurs
🩺 .github/workflows/debug.yaml jobs¶
Opt-in: repomatic init only materializes this file for a repository that set debug.sync = true. Its output answers a question a maintainer asks while chasing a runner difference and nothing else reads, so a repository not asking it should not spend a monthly matrix of runners producing logs.
🩺 Dump context (dump-context)¶
Dumps the GitHub Actions contexts, the environment variables, and the runner’s kernel, disk, CPU and memory across all build targets
Reads only what each runner image already ships, installing nothing
Useful for debugging runner differences and CI environment issues
Runs on:
Push to
main(only whendebug.yamlitself changes)Monthly schedule
Manual dispatch
workflow_callfrom downstream repositories
Skipped if:
debug.sync = falsein[tool.repomatic](the default; the thin caller workflow is not generated)
✂️ .github/workflows/cancel-runs.yaml jobs¶
✂️ Cancel PR runs (cancel-runs)¶
Cancels all in-progress and queued workflow runs for a PR’s branch when the PR is closed using
repomatic cancel-runs. A run whose head commit carries[changelog] Releaseis spared, so pointing the sweep at a default branch cannot kill a release matrixPrevents wasted CI resources from long-running jobs (e.g. Nuitka binary builds) that continue after a PR is closed
GitHub Actions does not natively cancel runs on PR close — the
concurrencymechanism only triggers cancellation when a new run enters the same group
🆙 .github/workflows/changelog.yaml jobs¶
🆙 Bump version (bump-version)¶
Creates PRs for minor and major version bumps using
bump-my-versionRuns
uv lock --upgradeto refreshuv.lockin the same commit (matches thesync-uv-lockupdater in thesync-depsjob, so transitive marker drift does not produce a redundant follow-up PR)Uses commit message parsing as fallback when tags aren’t available yet
Requires:
bump-my-versionconfiguration inpyproject.tomlA
changelog.mdfile
Runs on:
Schedule (daily at 6:00 UTC)
Manual dispatch
After
release.yamlworkflow completes successfully (viaworkflow_runtrigger, to ensure tags exist before checking bump eligibility). Checks out the latestmainHEAD, not the triggering workflow’s commit.
📋 Fix changelog (fix-changelog)¶
Checks and fixes changelog dates, availability admonitions, and orphaned versions using
repomatic lint-changelog --fixWarns without failing about each unreleased entry longer than
changelog.bullet-word-thresholdwords (40by default, and0disables the check). An entry is a release note, not a commit message. Write one sentence of 10 to 25 words that names what changed for a reader. Add a second sentence only for a breaking change or a migration step. Mechanism, rationale and history belong in the commit, the pull request, a code comment ordocs/. Released sections are immutable and never flaggedWarns without failing about each released section holding no entry. The GitHub release body is rebuilt from that section, so an empty one publishes an empty release. Add one bullet that names what moved, even when the whole cycle was mechanical (a pin bump or a regenerated file). An availability or editorial admonition does not count as an entry. The unreleased section is never flagged, since the post-release bump creates it empty
A sanity gate exits the job with status
2(no file written, no PR opened) when the GitHub Releases or PyPI lookup looks unhealthy: a network error from GitHub combined with any existing GitHub coverage, or an empty PyPI response combined with three or more existing PyPI links. Without the gate, a transient API hiccup would silently strip every affected link from the changelog (pypi/warehouse#1388 and pypi/warehouse#9536 explain why a 404 or empty result from PyPI is not authoritative).Runs on:
Push to
main(whenchangelog.md,pyproject.toml, or workflow files change). Skipped during release cycles.After
release.yamlworkflow completes successfully (viaworkflow_runtrigger), when the GitHub release is published and visible to the public API.
🎬 Prepare release (prepare-release)¶
Creates a release PR with two commits: a freeze commit that freezes everything to the release version, and an unfreeze commit that reverts to development references and bumps the patch version
The PR body’s
How-to releasechecklist opens with two review links, the draft dev pre-release and the full changes againstmain, before the merge instructions; each is omitted when its GitHub data is unavailable (no dev pre-release, no prior release, or an unauthenticated run)The body opens on a dependency shippability verdict, regenerated on every push to
main: the usual “This PR is ready to be merged” sentence, or a[!CAUTION]block naming each dependency the release would ship unresolvable. See Dependency management § Shippable sourcesUses
bump-my-versionandrepomatic changelogRe-locks
uv.lockin both commits with a plainuv lock(never--upgrade: a version bump refreshes only the project’s own entry, never its dependencies), so a tag never ships withpyproject.tomlahead of its own lock entryMust be merged with “Rebase and merge” (not squash): the auto-tagging job needs both commits separate
Requires:
bump-my-versionconfiguration inpyproject.tomlA
changelog.mdfile
Runs on:
Push to
main(whenchangelog.md,pyproject.toml, or workflow files change)Manual dispatch
workflow_callfrom downstream repositories
📚 .github/workflows/docs.yaml jobs¶
Beside its push triggers, the workflow runs monthly, and the thin callers mirror that schedule downstream. The cron is the heartbeat for two things a push-only trigger cannot surface on a quiet repository: a lapsed Cloudflare API token (Cloudflare warns about neither an approaching expiry nor a passed one, so the first symptom must be a red run and its email), and the link rot check-broken-links only sees when it runs.
These jobs require a docs dependency group in pyproject.toml so they can determine the right Sphinx version to install and its dependencies:
[dependency-groups]
docs = [
"furo",
"myst-parser",
"sphinx",
# …
]
📖 Deploy Sphinx doc (deploy-docs)¶
Builds Sphinx-based documentation and publishes it to GitHub Pages using
sphinx,upload-pages-artifactanddeploy-pagesBuilder is
sphinx.builderin[tool.repomatic], defaulting tohtml; set it todirhtmlto publish extension-less URLs (/page/instead of/page.html)Runs only when
site.deployisgithub-pages, its default. The other target has its own job below, and exactly one of the two ever runsRequires:
Python package with a
pyproject.tomlfiledocsdependency groupSphinx configuration file at
docs/conf.pyPages deployment source set to GitHub Actions (the setup guide issue walks through it)
📖 Deploy Sphinx doc to Cloudflare Pages (deploy-docs-cloudflare)¶
Same build as the job above, uploaded to a Cloudflare Pages project with
wrangler pages deployinstead of the Pages artifact pair. Cloudflare never builds anything and needs no access to the repository; see the Cloudflare Pages guide for how this hosting model worksRuns only when
site.deployiscloudflare-pages. The job holds noid-token, nopagesscope and no environment, since it authenticates against Cloudflare rather than the repository’s own deployment surfaceFiles over Direct Upload’s 25 MiB per-file limit leave the tree before the upload, through
repomatic cloudflare-r2 --offload:wranglerwould otherwise fail the whole deploy on the first one it meets. Withsite.cloudflare-r2-bucketdeclared, each moves to that R2 bucket and its old path redirects there. A file that cannot move, or any file without a bucket, is dropped with an error annotation, and the job fails once everything else is publishedChoose it for what the edge can do rather than for speed: a Cloudflare Pages custom domain carries its own certificate, so a zone’s apex can be proxied, which is what a
_redirectsfile, a real404.htmland any apex edge rule all depend onRequires:
Everything the GitHub Pages job requires, minus the Pages deployment source
A Cloudflare Pages project named after the repository (or after
site.cloudflare-project, for a project that predates repomatic), created ahead of the first run:wranglerdeploys into an existing project and will not create one non-interactively, andrepomatic cloudflare-pages --createscripts that stepCLOUDFLARE_API_TOKENrepository secret, an account-owned token scoped to Account → Cloudflare Pages → EditOptionally,
CLOUDFLARE_R2_ACCESS_KEY_IDandCLOUDFLARE_R2_SECRET_ACCESS_KEY, an R2 key pair limited to the bucket, for a site that serves files over the size limit: see files over 25 MiB
CLOUDFLARE_API_TOKENis a prerequisite, not an enhancement: the job fails without it, solint-repowarns about the gap and the setup guide issue stays open until it is set. Give the token a one-year TTL and let the machinery watch it: the workflow’s monthly run turns a lapsed token into a red run and an email, and the drift job below starts warning a month aheadThe account is derived from the token at run time, and a credential reaching several accounts resolves it by which one owns the project. See § The token
🌩️ Check Cloudflare config drift (cloudflare-config-drift)¶
Runs
repomatic cloudflare-pages --checkagainst the live Pages project, diffing it against the[tool.repomatic] site.*declarations: the compatibility date, Smart Placement, the build image floor, and the Direct Upload invariants (no attached git source, no build command)Exists because those settings live only server-side, where they drift with nothing watching: they are invisible until they misbehave, and one project’s compatibility date sat three years behind the live value that way
Also warns when the API token is within a month of its expiry, which Cloudflare itself never signals
Runs for every repository whose
site.deployiscloudflare-pages, Sphinx or not: a site built by the repository’s own workflow drifts the same wayDeliberately a job of its own rather than a step of the deploy: drifted settings should be loud, but they must never hold up publishing
💔 Check broken links (check-broken-links)¶
Checks for broken links in documentation with two complementary scanners, then files a single combined issue via
repomatic broken-links:Creates/updates one issue covering the findings of both scanners
Requires:
Documentation files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx,rst,tex}) in the repositoryFor the Sphinx linkcheck step: a
docsdependency group and a Sphinx configuration file atdocs/conf.py
Skipped for:
All PRs (only runs on push to main)
prepare-releasebranchPost-release bump commits
🏷️ .github/workflows/labels.yaml jobs¶
None of these jobs read a label config committed to the repository. labels.toml is ephemeral, regenerated from [tool.repomatic] right before labelmaker reads it, and the labeller rules live in the package rather than in any file at all. The only thing a downstream repository maintains is its pyproject.toml.
🔄 Sync labels (sync-labels)¶
Synchronizes repository labels using
repomatic sync-labelsandlabelmakerUses
labels.tomlwith multiple profiles:defaultprofile applied to all repositoriesawesomeprofile additionally applied toawesome-*repositories
Skipped if:
labels.sync = falsein[tool.repomatic]
🏷️ Apply labels (apply-labels)¶
Labels freshly opened issues and PRs with
repomatic apply-labels: content rules match the title and body, file rules match a pull request’s changed pathsRules are configured as
[tool.repomatic.labels]tables mapping each label to its patterns, overlaid on the bundled defaultsAdditive only: labels already on the thread stay, and none is ever removed
Skipped for:
prepare-release,major-version-incrementandminor-version-incrementbranchesBot-created PRs
💝 Tag sponsors (sponsor-label)¶
Adds a
💖 sponsorlabel to issues and PRs from sponsors using the GitHub GraphQL API, and is the only job that sets itSkipped for:
prepare-release,major-version-incrementandminor-version-incrementbranchesBot-created PRs
🧹 .github/workflows/lint.yaml jobs¶
🏠 Lint repository metadata (lint-repo)¶
Validates repository metadata and settings using
repomatic lint-repo, which readspyproject.tomldirectly. Its checks, in report order:Warns when the package name differs from the repository name
Warns when a Sphinx project’s GitHub website field does not name the documentation URL it declares in
[project.urls](Documentation, thenDocs). A trailing slash and the case of the scheme and host are ignored, since GitHub stores the website field with the slash a browser appends. Moving a documentation site to a new domain is what this catches: Sphinx renders<link rel="canonical">fromhtml_baseurl, so every published page names the new origin while the repository sidebar keeps sending visitors to the old one. A project declaring no documentation URL keeps the presence-only checkWarns when a Sphinx project deploying to GitHub Pages (the
site.deploydefault) has the Pages source set toDeploy from a branch. Thedocs.yamldeploy job publishes withactions/deploy-pages, which needs the Pages source set toGitHub ActionsWarns about a missing
CLOUDFLARE_API_TOKENwhensite.deploytargets Cloudflare Pages. The token is the whole of the credential: the account is derived from it at run timeWarns about a missing
CLOUDFLARE_R2_ACCESS_KEY_IDorCLOUDFLARE_R2_SECRET_ACCESS_KEYwhensite.deploytargets Cloudflare Pages andsite.cloudflare-r2-bucketis declaredWarns when a site that moved to Cloudflare Pages no longer keeps its old
github.ioURLs redirecting:[project.urls]still naming thegithub.iohost, GitHub Pages disabled, or its custom domain unset or naming another host (details)Fails when a committed
_redirectsfile would lose rules to the Cloudflare Pages engine’s undocumented budget accounting, since a dropped rule is silently dead in production (details)Warns when a committed
wrangler.tomlcontradicts the declared Cloudflare project name or compatibility dateWarns about a tracked file over the 25 MiB Cloudflare Pages limit when
site.deploytargets Cloudflare Pages and no R2 bucket is declaredWarns when a Sphinx project still has a
gh-pagesbranch. Pages deploys through GitHub Actions, so the branch is no longer neededFails when the repository description differs from the project description
Warns when a GitHub topic matches no
[project] keywordsentry. A keyword matches the topic GitHub would store for it: lowercased, with each run of whitespace replaced by a hyphen, soCLIdeclares theclitopic andWeather forecastdeclaresweather-forecastWarns when the repository owner has a GitHub Sponsors listing but
.github/funding.ymlis missing, so the repository shows no Sponsor button. Forks are skippedWarns about draft releases whose tag does not end in
.dev0. Those are leftovers from an abandoned or failed release: the only expected drafts are the rolling dev pre-releases thatsync-dev-releasemanagesWarns when the repository carries a label that no configured source (the labeller’s bundled
labels.tomlprofiles, a hand-written or downloadedextra-labels/file, the inlinelabels.extrablock) declares.sync-labelsrunslabelmaker apply, which creates, updates and renames labels but never deletes one, so a label dropped from a source keeps existing on GitHub with nothing left to mention it again. Advisory only: deleting a label detaches every issue and pull request carrying it, and only the count of what each orphan holds tells a hand-made label from one still in active use. Extra label files are read in every formatlabelmakeraccepts (JSON, TOML, YAML, and JSON5 as far as its plain-JSON subset), and a file that cannot be fetched or read skips the check rather than reporting its labels as orphansWarns when a release download URL in
docs/install.mdnames a file its release does not carry. The release freeze pins those URLs before the binaries exist, so a failed build lane leaves the guide advertising 404s until the next release moves past it: this is the check that surfaces the gap instead of leaving it for a user to hit. Versionlessreleases/latest/downloadURLs are checked against the latest published release too, and rot longer: nothing rewrites them at release time, so a renamed asset leaves one pointing at a 404 indefinitelyWarns when an active ruleset targets tags, including one inherited from a parent level. Such a ruleset can stop the
create-tagjob from pushing release tags unlessREPOMATIC_PATis in its bypass listWarns when the repository has no active branch ruleset, which the check reads as a default branch open to deletion and force pushes. It does not verify that a ruleset targets the default branch itself (details)
Warns when a classic branch protection rule survives beside the rulesets. GitHub applies both together, so the branch policy splits across two settings pages (details)
Warns when a package’s repository has immutable releases disabled. That setting locks the tag and assets of a published release (see § Immutable releases)
Warns when the fork PR workflow approval policy is weaker than
first_time_contributorsWarns when the repository does not require actions to be pinned to a full-length commit SHA (
sha_pinning_required). With the setting on, GitHub itself refuses to run a workflow that names an action by a mutable tag or branch. This backs upzizmor, whose findings can be silenced inline (details)Warns when the latest PyPI release of a package carries PEP 740 provenance naming a repository or workflow other than this repository’s
release.yaml, which means the Trusted Publisher entry is registered wrongly. A package with no release yet, or no provenance, is reported as skipped (details)Warns when a workflow with steps of its own has no top-level
permissionskey. Also warns when a job calling a reusable workflow underpermissions: {}declares nopermissions:of its own: GitHub then aborts the run at startup once a nested job requests a scope the caller never grantedWarns when a
[tool.repomatic.test-matrix] excludeentry names a value found on no matrix axis, like a renamed runner. Such an entry never matches a combination, so the matrix drops it without a message (details)Warns when the lowest
Programming Language :: Python :: X.Yclassifier differs from therequires-pythonfloor. Also warns when a workflow test matrix listingpython-versionvalues literally omits either end of the advertised range, or tests a released version the classifiers do not advertiseWarns when a job runs on a
-latestalias, which GitHub repoints without a commit to review, or on an image outside the test matrix axes. Aruns-on:built from an expression is left alone (details)Reports which release-only steps a release commit would run or skip for a Python project, so a missing capability shows on an ordinary push instead of on release day. Where the reusable workflows live, warns when a step’s
if:never tests the capability it needsWarns when a project setting
[tool.repomatic] manpages.scriptlocks aclick-extraolder than9. Themanpagesrelease job renders withclick-extra wrap --help-format man, an invocation9.0.0introduced, and the job first runs on the commit that tags and publishes. Since a published release locks its asset list, a failure there costs that version its man pages for good rather than being repairable afterwards. The subject isuv.lock, which is whatuv sync --frozeninstalls; a project with no lockfile, or one whose lockfile never mentionsclick-extra, is reported as skippedWarns when a tool config seeded once (
[tool.ruff],[tool.pytest],[tool.coverage],[tool.mypy],[tool.mdformat]) lacks an entry its bundled template carries.initwrites those sections on the first run and hands them over, so a rule the template gains later reaches no repository and nothing names the gap. Only absence is reported, keys and list members alike: a value the repository tuned, a key it added and a comment it rewrote are all its own. A pytest section that keeps its keys under[tool.pytest.ini_options]is measured there, since pytest refuses a file holding both that table and the native keys. Advisory, since a template rule dropped on purpose looks identical to one never received, and only the maintainer can tell them apartWarns when a
[tool.lychee] excludeentry is the bare form of one the bundled template has since anchored. An ongoing sync grafts a local-only array item back on and retires nothing, so a pattern the template replaced rather than added survives beside its own replacement: lychee matches these as substrings, and a barex\.comkept beside^https://(www\.)?x\.com(/.*)?$goes on excluding every domain ending inx.com, which is what the anchoring was written to stop. A section no sync reaches holds the bare form alone, so there the warning names the anchored form to write in its place rather than advising a drop. Advisory, since only the maintainer can tell a superseded entry from one the repository narrowed on purposeFails when a workflow’s inline upstream pin (like
uvx 'repomatic==X.Y.Z') names a different version than itsuses:refs. A stale pin can lack a symbol the newer refs rely on: themetadatajob then fails, and a release can reach PyPI without a tagFails when a workflow’s inline upstream pin (like
uvx 'repomatic==X.Y.Z') resolves under a cooldown but carries no--exclude-newer-packageexemption beside it, checked only in a workflow that setsUV_EXCLUDE_NEWERat all.uvxreads no project configuration, so the flag on the command line is the only place the bypass can live: without it, a pin naming a release younger than the window cannot resolve, and since the pin usually sits in themetadatajob with every other jobneeds: metadata, the whole workflow fails at its first job while executing nothing.sync-workflow-pinsbackfills the flag on its next run, but a repository already pinned at the newest release never triggers that backfill on its own, which is what this check catches. The sharper of the two fatal pin checks, since the pin it guards takes everyneeds: metadatajob down with itWarns when an
astral-sh/setup-uvstep declares noversion:input, or when steps across the repository pin more than one uv version.[tool.uv] required-versionis only a floor; left unpinned,setup-uvinstalls whatever uv release is newest the moment the job runs, seconds after publication, making the one tool that enforces every cooldown the one tool carrying none of its ownWarns when the pinned uv is absent from the checksum table bundled into the pinned
astral-sh/setup-uvrelease. That table holds the only pinned hashessetup-uvverifies a download against, and a version missing from it is not refused. Beforev10.1.0it installs unverified, on a debug line no CI log shows by default. Fromv10.1.0on it is checked against a hash the action fetches at run time, which no pin fixes. Since uv ships weekly against the action’s monthly cadence, andsync-action-pinsandsync-workflow-pinswalk the two pins independently, a repository drifts into holding two perfectly good pins that together pin no hash.sync-workflow-pinsrepairs it by stepping the uv pin back onto the table, and async-action-pinsbump lets the pin move forward again. Reported as skipped rather than failed when the table cannot be readFails when a workflow’s
run:line asksrepomatic show-metadatafor a key that no longer exists, reading the invocation the way Click does so an option’s value is never mistaken for a positional key.repomatic initsyncs a header-only workflow’s header and itsuses:pins and leaves the job bodies to the repository, so a key retired upstream stays in arun:line nothing sweeps. The command answers an unknown key with aUsageError, and every job reaching the metadata job throughneeds:dies with it, which turns a retired key into a whole workflow failing at its first job on the next push. Fatal, like the inline-pin checks above: all three describe a workflow that is already broken rather than one that might age badlyWarns when a repository-local
pr-body --template-filetemplate sits outside.github/pr-templates/, does not exist, or has frontmatter without atitleor that keeps the attribution footer. Bothfooter: falseand the quotedfooter: 'false'opt out. A template that keeps the footer renders it twice (details)Warns about a missing
VIRUSTOTAL_API_KEYwhen Nuitka binary compilation is activeWarns about a missing
REPOMATIC_NOTIFICATIONS_PATwhen the unsubscribe workflow is enabledFails when a configured
REPOMATIC_PATlacks one of the permissions the automation needs: administration, contents, issues, pull requests, Dependabot alerts and workflowsWarns when a configured
REPOMATIC_PAThas access to all repositories instead of only the current one. The check reads the token’s repository selection, and falls back to probing push access on another repository of the same ownerWarns when a configured
REPOMATIC_PATstill grants theCommit statusespermission, whichrepomaticno longer uses. The probe posts a status to a SHA that resolves to no commit, so it writes nothing
Requires:
Python package (with a
pyproject.tomlfile)
🔤 Lint types (lint-types)¶
Type-checks Python code using
mypyRequires:
Python files (
**/*.{py,pyi,pyw,pyx,ipynb}) in the repository
Skipped for:
prepare-releasebranch
📄 Lint YAML (lint-yaml)¶
Lints YAML files using
yamllintRequires:
YAML files (
**/*.{yaml,yml}) in the repository
Skipped for:
prepare-releasebranchBot-created PRs
🐚 Lint Zsh (lint-zsh)¶
Syntax-checks Zsh scripts using
zsh --no-execRequires:
Zsh files in the repository:
**/*.zsh, the zsh dotfiles (.zshrc,.zprofile,.zshenv,.zlogin), and any**/*.shwhose shebang names zsh. Claiming.shby extension alone would hand every bash script tozsh --no-exec, so the shebang keeps this job andformat-shellfrom ever seeing the same file
Skipped for:
prepare-releasebranchBot-created PRs
⚡ Lint GitHub Actions (lint-github-actions)¶
Lints workflow files using
actionlintandshellcheckRequires:
Workflow files (
.github/workflows/**/*.{yaml,yml}) in the repository
Skipped for:
prepare-releasebranchBot-created PRs
🔒 Lint workflow security (lint-workflow-security)¶
Audits workflow files for security issues using
zizmor(template injection, excessive permissions, supply chain risks, etc.)Requires:
Workflow files (
.github/workflows/**/*.{yaml,yml}) in the repository
Skipped for:
prepare-releasebranchBot-created PRs
🌟 Lint Awesome list (lint-awesome)¶
Lints awesome lists using
awesome-lintRequires:
Repository name starts with
awesome-
Skipped for:
prepare-releasebranch
🔐 Lint secrets (lint-secrets)¶
Scans for leaked secrets using
gitleaksSkipped for:
prepare-releasebranchBot-created PRs
🚀 .github/workflows/release.yaml jobs¶
This is the entry workflow. It owns the push and workflow_dispatch triggers and wires three jobs: a build call to the _release-build.yaml fast lane, the publish-pypi job, and a release call to the _release-engine.yaml engine. Both publish-pypi and the engine lane depend on build. Because publish-pypi needs only the build lane, the wheel reaches PyPI as soon as it is built instead of after the whole engine (binary compilation, scanning) completes. The engine also waits on build so its create-release and sync-dev-release jobs can download the run-scoped wheel. Every downstream repo (repomatic included) has its own release.yaml that follows this same shape.
The publish-pypi job lives here rather than inside a reusable lane so each repo’s OIDC job_workflow_ref claim resolves to its own release.yaml: the exact filename each repo registers with PyPI as a Trusted Publisher. A job inside _release-build.yaml or _release-engine.yaml would mint a token pointing at the upstream path, breaking the publisher match on every downstream. See pypi/warehouse#11096.
repomatic init regenerates this file on every sync, and unlike a single-job thin caller it has jobs of its own, so two properties are worth knowing:
It carries the same deny-by-default top-level
permissions: {}as every other generated workflow, with each managed lane declaring only the scopes its reusable workflow needs. Without it, a consumer job appended below the managed lanes would run with the repository’s default token scopes.Extra
needs:edges a consumer declares on thereleaselane survive the sync. That is what lets a caller-side asset build job gate the engine, as § Extra release assets instructs. An edge naming a managed lane (already in the canonical set), a job that no longer exists, or a job that exists only in the upstream workflow is dropped: the last would make GitHub reject the workflow at startup.
🐍 Publish to PyPI (publish-pypi)¶
Uploads packages to PyPI with attestations using
uv publish --trusted-publishing automaticover OIDC.The job lives in each repo’s own
release.yamlentry, never in the_release-engine.yamlreusable: repomatic and downstreams alike publish from arelease.yaml(the same filename everywhere). It invokes thepublish-pypicomposite action. Composite actions inherit the calling job’s OIDC context, so the token’sjob_workflow_refclaim resolves to thatrelease.yaml: the path each repo registers with PyPI as a Trusted Publisher. This works around pypi/warehouse#11096, where a job inside the reusable engine would claim the upstream path and fail the publisher match.Requires:
A one-time PyPI Trusted Publisher registration for the repo’s
release.yamlentry, the same filename in every repo (repomatic included), so no per-repo workflow-name divergence (see PyPI Trusted Publishers docs).id-token: writepermission on the caller-side job (auto-emitted byrepomatic init workflows).The
release_commits_matrixoutput from thebuildlane (_release-build.yaml), which drives the matrix and gates the job to release commits.The
package_builtoutput from thebuildlane, reflecting whether thebuild-packagejob succeeded.
The job is guarded by
always()and gated onpackage_built, so it is decoupled from the run’s overall result: a wheel that built cleanly still publishes even when an unrelated job (like the binary tests in the engine lane) fails the run. PyPI receives only the wheel and sdist, never the compiled binaries, so a binary regression must not block the package upload.The job touches only PyPI; it does not edit the GitHub release. The PyPI availability admonition is baked into the release notes by the engine’s
create-releasejob at draft creation, which removes the cross-lane race where editing the release from this fast lane ran before the engine had created it (and silently dropped the admonition undercontinue-on-error).Runs on
ubuntu-26.04.
🧩 Pack Claude Code plugin (pack-plugin)¶
Note
Repomatic-only. This job is not part of the shape repomatic init generates: it exists in the upstream release.yaml alone, as the reference consumer of the release-assets handoff described under § Extra release assets. A downstream repository that wants its own extra asset writes an equivalent job of its own.
Runs
repomatic pack-plugin, which assembles.claude/.claude-plugin/plugin.jsonand every skill and agent the component registry declares intorepomatic-claude-plugin.zip, then uploads it as therelease-asset-repomatic-claude-plugin.ziprun artifact the engine’sextra-assetsjob collects. See § Claude Code plugin.Deliberately unconditional, with no
if:and no matrix. Thereleasejob gates on it, so a skip here would cascade into skipping the whole engine on ordinary pushes, takingsync-dev-releasewith it. Packing a zip is cheap enough to pay on every push.The artifact is only ever consumed on a release commit, where
mainHEAD is the freeze commit whose versionpack-pluginstamps into the packaged manifest.Runs on
ubuntu-26.04.
📦 .github/workflows/_release-build.yaml jobs¶
The release fast lane: it runs the squash-merge guard and the dependency shippability gate, computes project metadata, and builds (and signs) the Python wheel and sdist. The entry release.yaml calls it first so the publish-pypi job can ship to PyPI the moment the wheel exists, without waiting for the engine’s binary compilation. It exposes the package_built and release_commits_matrix outputs that publish-pypi consumes.
🧯 Detect squash merge (detect-squash-merge)¶
Detects squash-merged release PRs, opens a GitHub issue to notify the maintainer, and fails the workflow
Running it in the build lane fails fast:
release.yamlgates the engine onneeds: build, so a detected squash merge skips the engine (binaries, tag, release) entirelyThe release is effectively skipped:
create-tagonly matches commits with the[changelog] Release vprefix, so no tag, PyPI publish, or GitHub release is created from a squash mergeThe net effect of squashing freeze + unfreeze leaves
mainin a valid state for the next development cycle; the maintainer just releases the next version when readyRuns on:
Push to
mainonly
🔗 Lint deps (lint-deps)¶
Runs
repomatic lint-depsagainst the tree being released, refusing to publish a project whose dependencies do not all resolve from the index its users install fromAlso reports version-policy warnings (upper bounds, missing floors, unsorted lists, misplaced type stubs, uncommented floors, over-long floor comments) alongside the shippability findings; these never affect the gate, see § What is checked automatically
Fatal only on a release commit; every other push reports and annotates without failing, so test-driving a git branch mid-cycle stays frictionless
build-packagedepends on it, which is what makes it a gate: a failure skips the wheel build, leavingpackage_builtfalse sopublish-pypinever fires, and fails the lane so the engine’s tag, release and publish jobs are skipped with itChecks out the release commit rather than the push head: a rebase-merged release PR delivers the freeze and the post-release bump together, so
mainHEAD already carries the next.devNSee Dependency management § Shippable sources for the rules, the failure classes, and the
lint-deps.allowexemptionRequires:
Python project with a
pyproject.tomlfile
📦 Build package (build-package)¶
Builds Python wheel and sdist packages using
uv build, then signs each distribution with a PEP 740 attestationThe signed artifact is shared run-scoped with both
publish-pypi(PyPI upload) and the engine’screate-release(GitHub release), so a single build feeds bothRequires:
Python package with a
pyproject.tomlfileA green
lint-depsgate
🚀 .github/workflows/_release-engine.yaml jobs¶
Release Engineering is a full-time job, and full of edge-cases that nobody wants to deal with. This workflow automates most of it for Python projects. The entry release.yaml gates it on needs: build, so it starts once the fast lane’s wheel is ready (binary compilation therefore begins roughly one package build after the push).
Cross-platform binaries — Targets 6 platform/architecture combinations (Linux/macOS/Windows × x86_64/aarch64). Unstable targets use continue-on-error so builds don’t fail on experimental platforms. Job names are prefixed with ✅ (stable, must pass) or ⁉️ (unstable, allowed to fail) for quick visual triage in the GitHub Actions UI.
Canary builds on ordinary pushes — The full fleet only compiles for release commits, the weekly schedule trigger, and manual workflow_dispatch runs; an ordinary push rebuilds only the [tool.repomatic] nuitka.dev-targets canary subset. The Nuitka compilation page is the canonical reference for the build cadence, compile caching, and measured build times.
At a glance, the build lane feeds both the PyPI publish and this engine; the engine runs a binary lane and the tag-and-release sequence, with a separate dev-release path for non-release pushes (dotted edges are uploaded assets):
flowchart TD
push([Push to main]) --> squash{detect-squash-merge}
squash -->|squashed release PR| fail[Open issue, fail run]
squash -->|clean| deps{lint-deps}
deps -->|unshippable dependency| blocked[Fail lane, nothing published]
deps -->|clean| build[build-package]
build --> pypi[publish-pypi]
build --> nuitka[compile-binaries]
build --> relcommit{release commit?}
relcommit -->|no| dev[sync-dev-release]
relcommit -->|yes| tag[create-tag]
nuitka --> testbin[test-binaries]
tag --> draft[create-release draft]
build -. wheel + sdist .-> draft
nuitka -. binaries .-> draft
draft --> pubrel[publish-release]
pubrel --> vt[scan-virustotal]
build -. assets .-> dev
nuitka -. assets .-> dev
✅ Compile binaries (compile-binaries)¶
Compiles standalone binaries using
Nuitkafor Linux/macOS/Windows onx86_64/aarch64Linux targets compile inside digest-pinned
manylinux_2_28containers and macOS targets pinMACOSX_DEPLOYMENT_TARGET, so binaries keep the documented OS floors instead of inheriting the runner image’sPersists the Nuitka compile caches across runs with
actions/cache(ccache objects on the gcc targets, Nuitka’s internalclcacheobjects on MSVC, downloads and bytecode alongside them), so a warm build skips most of the C compilation. Release commits neither restore nor save the cache, and macOS is left out of it entirely: see Compile cachingOn non-release runs, self-tests the freshly-built binary in place with
click-extra test-suite; the standalonetest-binariesjob is reserved for release commitsVerifies each binary’s architecture and measures its actual glibc / macOS floor against the declared one (
repomatic verify-binary, parsing ELF/Mach-O/PE headers natively)On release pushes, each binary is attested and its sigstore bundle renamed after the binary it covers (
<binary-name>.attestation.json) byrepomatic pack-attestation, so no two targets collide once the bundles are merged. Binaries and bundles leave the job as run artifacts, andpublish-releaseattaches them to the releaseRequires:
Python package with CLI entry points defined in
pyproject.toml
Skipped if
[tool.repomatic] nuitka.enabled = falseis set inpyproject.toml(for projects with CLI entry points that don’t need standalone binaries)Skipped for branches that don’t affect code:
format-json(JSON formatting)format-markdown(documentation formatting)format-images(image formatting)sync-gitignore(.gitignoresync)sync-mailmap(.mailmapsync)update-dep-graph(dependency graph docs)
✅ Test binaries (test-binaries)¶
Runs test suites against compiled binaries using
click-extra test-suiteRelease commits only: re-validates each published artifact on a pristine VM, through the same upload/download round-trip a user’s binary takes; non-release builds self-test inside
compile-binariesinstead of paying a second runner-queue slot per targetLinux targets run inside the same
manylinux_2_28container as the compile job, proving the glibc2.28floor at runtimeRequires:
Compiled binaries from
compile-binariesjobTest suite file (configured via
[tool.click-extra.test-suite]; default./tests/cli-test-suite.toml)
Skipped for:
Same branches as
compile-binaries
📌 Create tag (create-tag)¶
Creates a Git tag for the release version
Requires:
Push to
mainbranchRelease commits matrix from
repomatic show-metadata
🐙 Create release draft (create-release)¶
Creates a GitHub release draft with the Python package attached using
gh release createThe draft notes carry the PyPI availability admonition from the start (baked in via
repomatic show-metadata’srelease_notes_with_admonition), so it never depends on a later cross-lane edit; non-PyPI projects fall back to the plain release notesAttaches no binary:
publish-releaseuploads them, with their attestation bundles, from thecompile-binariesrun artifacts while the release is still a draft (uploading to drafts is allowed)Requires:
Successful
create-tagjob
📖 Man pages (manpages)¶
Renders one roff
.1file per (sub)command in the Click tree declared by[tool.repomatic.manpages]by shelling out toclick-extra wrap --help-format man --output-dir man "${SCRIPT}"against the consumer’s already-synced venvBundles the pages as a single
<asset-name>.tar.gzand uploads them to the GitHub release draft viagh release upload --clobber, beforepublish-releasepublishes and locks the releaseThe tarball is attested with the same provenance chain as the compiled binaries: its sigstore bundle rides along as an
<asset-name>.tar.gz.attestation.jsonasset, named byrepomatic pack-attestationafter the file it covers, and provenance verifies withgh attestation verify <asset-name>.tar.gz --repo <consumer> --signer-repo kdeldycke/repomaticRequires:
manpages.script = "..."in[tool.repomatic]. The value follows the same shape asclick-extra wrap SCRIPT: amodule:functionpath (preferred when the console-script entry point dispatches through a wrapper), an entry-point name, a.pyfile path, or a plain importable module nameThe consumer’s
click-extrafloor is>= 9:--help-format manrenders the roff source, and its--output-dir DIRoption writes one.1file per resolved (sub)command intoDIR, creating the directory if missing. On8.xthe roff came fromwrap --man, which9.0repurposed to page the typeset manual instead, so a consumer still on8.xfails this job until it upgradesSuccessful
create-releasejob (the draft must exist; the asset must be attached beforepublish-releaselocks the release: see § Immutable releases)
The tarball stem defaults to
<package-name>-manpages; override withmanpages.asset-namein[tool.repomatic]to publish under a different nameSkipped if:
manpages.scriptis empty (the default), which keeps the job silent for every project that has not opted in
📎 Extra release assets (extra-assets)¶
Attaches consumer-built assets declared by the
release-assetsfilename list in[tool.repomatic]: each file must be uploaded as arelease-asset-<filename>run artifact by a job the consumer defines in its own release workflow, the same caller-side handoff the wheel’sbuildlane usesThe build code therefore stays in the downstream repository as regular workflow code, reviewed and linted there: the engine never executes consumer-supplied commands, it only downloads, attests, verifies, and uploads
Assets are attested with the same provenance chain as the compiled binaries and uploaded to the GitHub release draft together with their sigstore bundle, before
publish-releasepublishes and locks the release; provenance verifies withgh attestation verify <file> --repo <consumer> --signer-repo kdeldycke/repomaticrepomatic pack-attestationnames that bundle. A repository declaring a single asset gets<filename>.attestation.json, matching the binaries and the man-page tarball, so the sidecar sorts directly beside what it covers. Several declared assets share one bundle (actions/attestemits a single attestation listing every subject), which then falls back to<package-name>-extra-assets.attestation.jsonbecause no one filename can claim itA declared asset whose artifact never landed fails the job loudly, and that failure blocks
publish-release, so a broken consumer build lane cannot silently ship a release without its asset. The release stays a draft, which is the recoverable state: re-run the lane, or attach the file by hand, then publish. Once published the release is immutable and the asset can never be addedRequires:
A non-empty
release-assetslist in the consumer’spyproject.toml, with space-free filenamesA consumer-side job uploading each
release-asset-<filename>artifact; gate the engine call on it (likeneeds: buildfor the wheel) so the artifact exists before the engine reaches this jobSuccessful
create-releasejob (the draft must exist; the assets must be attached beforepublish-releaselocks the release: see § Immutable releases)
Skipped if:
release-assetsis empty (the default), which keeps the job silent for every project that has not opted in
🎉 Publish release (publish-release)¶
Publishes the draft GitHub release after all assets (Python package, binaries, man pages, extra assets) have been uploaded
Attaches the compiled binaries and their attestation bundles itself, from the run artifacts
compile-binariesleft behind.repomatic pack-binariescopies each versioned binary to a versionless alias (repomatic-linux-x64.bin) so thereleases/latest/downloadURLs keep resolving, then prints the upload list, leaving out the Python distributionscreate-releasealready attachedSupports GitHub immutable releases: once published, tags and assets are locked, so flipping
--draft=falseis the terminal step of the release engine and every asset-uploading job must run upstream of itUses
always()so it runs even whencompile-binaries,manpagesorextra-assetsis skipped (non-binary projects, no man pages, no extra assets), and still publishes whencompile-binariesormanpagespartially fails (unstable platforms): shipping the Python distributions beats blocking the release on one unstable platformThat trade-off is permanent rather than deferred. Publishing locks the asset list, so a binary missing at this point can never be attached to that version:
v6.30.0shipped withoutwindows-arm64,v7.5.0without either Windows build, andv7.7.0without any binary at all. This is the intended behavior, not a gap to plug: a short release is recovered by releasing again, which a fast cycle makes cheap, so fix the build and let the next version carry it. What a short ship does leave behind is a changelog section, a release body and an install guide still advertising the missing binaries: see § Repairing a short ship for that cleanupA failed
extra-assetsis the one blocker: a file the consumer declared inrelease-assetsmust be on the release before it locks, or it never can be. The release is left as a draft insteadRequires:
Successful
create-releasejob (draft must exist)Waits for
compile-binaries,manpagesandextra-assetsso every asset is attached before the release locks
🛡️ VirusTotal scan (scan-virustotal)¶
Downloads the release’s versioned binaries (
.binand.exe) withrepomatic scan-virustotal --download, which first waits for a freshly published release to list its assetsUploads those binaries to VirusTotal, polls for analysis completion, and records each binary’s
flagged / totalsnapshot indocs/assets/virustotal-scans.csvSeeds AV vendor databases to reduce false positive detections for downstream distributors (Chocolatey, Scoop, etc.)
Regenerates the binaries catalog (
docs/assets/binaries.csvand itsdocs/binaries.mdpage) from the GitHub Releases API and the scan history viarepomatic sync-binaries(with--backfill-recordsrecovering snapshots from legacy release-notes tables), then publishes the files through the job’s pull request viarepomatic pr-sync. Release notes stay clean: raw detection counts next to a download link read as a malware verdict without the context the page providesRequires:
VIRUSTOTAL_API_KEYrepository secret (free API key)Successful
publish-releasejob
Skipped if:
VIRUSTOTAL_API_KEYsecret is not configuredpublish-releasejob did not succeed
Recording steps skipped if:
binaries.sync = falsein[tool.repomatic](the scan still runs and seeds AV vendor databases; the catalog and scan history are not committed)
Important
The recording lands in one long-lived pull request every release appends to, merged whenever you like. The detection counts are a trend read across releases rather than a verdict on any one of them, so nothing is lost by leaving it open: only the published binaries page lags. See § Scanning accumulates in one pull request for the full rationale, and set binaries.sync = false to disable the recording while keeping the scan.
🔄 Sync dev pre-release (sync-dev-release)¶
Maintains a rolling dev pre-release on GitHub that mirrors the unreleased changelog section
Attaches binaries and Python packages from build jobs via
--upload-assetsThe dev tag (
vX.Y.Z.dev0) is force-updated to point to the latestmaincommitAutomatically cleaned up when a real release is created
Runs on: Non-release pushes to
mainonlyRequires:
The wheel from the build lane (
build-package, downloaded run-scoped) and thecompile-binariesjob (usesalways()for resilience)
Skipped if:
dev-release.sync = falsein[tool.repomatic]
🔧 .github/workflows/self-maintenance.yaml jobs¶
This workflow maintains repomatic’s own package source and is the one file in .github/workflows/ that repomatic init never materializes downstream. Because a consumer’s repository never receives it, its jobs need no github.repository guard and it can pick a schedule without spending downstream CI on runs that would skip every step.
🔼 Sync tool versions (sync-tool-versions)¶
Upstream-only: rewrites
repomatic/tooling/tool_registry.py, which exists only in this repository; downstream repos receive updated tool versions when they sync against a new repomatic releaseBumps every tool in the
repomatic runregistry to its latest release past theminimum-release-agecooldown: GitHub Releases for binary tools (actionlint, Biome, gh, gitleaks, labelmaker, lychee, oxipng, shfmt, typos), the npm registry for npm tools (awesome-lint), PyPI for the rest (autopep8, bump-my-version, mdformat, mypy, Nuitka, pyproject-fmt, ruff, yamllint, zizmor)Bumps the packages pinned alongside a tool in its
uvxenvironment too (mdformat’s plugin set), which no other updater seesRecomputes the SHA-256 checksums for every binary tool in the same pass, so version bump and checksum land in one PR branch
Runs via
uv runagainst the local editable source, rewritingrepomatic/tooling/tool_registry.pydirectlyRuns on: daily schedule and manual dispatch. Daily rather than weekly because the
minimum-release-agecooldown already delays every adoption on its own, and a release becomes eligible on whatever weekday its cooldown expiresRequires:
REPOMATIC_PATsecret with contents write permission
Skipped if:
tool-versions.sync = falsein[tool.repomatic]
📈 .github/workflows/metrics.yaml jobs¶
Opt-in: repomatic init only materializes this file for a repository that set metrics.sync = true, since an accumulating store is a commitment rather than a default.
📈 Sample forge metrics (sample-metrics)¶
Reads every repository in
[tool.repomatic.metrics] subjectsthrough whichever API its host speaks (GitHub, GitLab or Forgejo) withrepomatic sample-metrics, and appends one CSV row per subject, metric and dateA counter like the star count accrues, so its curve can be charted; an attribute like the date of the newest release or commit keeps a single row, restamped only when it moves, so a quiet week leaves the file untouched
Rebuilds every GitHub subject’s whole star curve from GitHub’s star history, one reading per week that gained a star: those curves are complete from their first star rather than from the day sampling started. The endpoint is anonymous, so this covers the repositories a project merely tracks as well as the ones it owns
Redraws the configured SVG charts, stamped with the newest reading of the metric they plot rather than the run date, so a week that moved nothing rewrites nothing
Publishes the store through one long-lived pull request that every run appends to, restoring the store from its branch before sampling so readings still awaiting review are added to rather than replaced (see § Sampling accumulates in one pull request)
Until that pull request is merged, the charts published from the default branch lag behind. Merging it starts a fresh accrual
Runs on: weekly schedule, manual dispatch, and
workflow_callfrom downstream repositories. Never on push: sampling the same value twice in a day writes the same rowRequires:
REPOMATIC_PATsecret with contents write permission, to open a pull request whose checks actually run
Skipped if:
metrics.sync = falsein[tool.repomatic], or no subject is declared
🔬 .github/workflows/tests.yaml jobs¶
📦 Package install (test-package-install)¶
Verifies the package can be installed and all CLI entry points run correctly via every install method:
uvx,uvx --from,uv run --with, module invocation (-m),uv tool install, andpipx runTests both the latest PyPI release and the current
mainbranch from GitHubRuns once on a single stable OS/Python — install correctness does not vary by platform
Requires:
cli_scriptsfrommetadatajob (skipped if no[project.scripts]entries)
🔬 Run tests (tests)¶
Runs the test suite across a matrix of OS (Linux/macOS/Windows ×
x86_64/aarch64) and Python versions:3.10,3.14, and thecontinue-on-errordevelopment3.15on every runner, plus the free-threaded3.14tas a stable single-runner smoke test (see test matrix)Installs all optional extras (
--all-extras) to catch incompatibilities between optional dependency groupsRuns
pytestunder the[tool.coverage] report.fail_undercoverage floor, excludingonce-marked tests (covered by the dedicatedonce-testsjob)Runs self-tests against the CLI test suite, through both the console script and
python -mJob names prefixed with ✅ (stable) or ⁉️ (unstable, e.g., unreleased Python versions)
1️⃣ Run-once tests (once-tests)¶
Runs the
once-marked tests (CLI invocability, plugin registration, metadata checks) on a single stable runner: their outcome does not vary across the OS/Python matrixThe matrix
testsjob excludes them withpytest -m "not once"Opts out of the coverage floor with
--cov-fail-under=0: this slice alone covers a fraction of the package, so the matrix job owns the ratchet
🖥️ Validate architecture (validate-arch)¶
Checks that the detected CPU architecture matches what the runner image advertises
Ensures runners are not silently using emulation (e.g., x86_64 on aarch64)
Requires:
Build targets from
metadatajob
🔕 .github/workflows/unsubscribe.yaml jobs¶
🔕 Unsubscribe from closed threads (unsubscribe-threads)¶
Unsubscribes from notification threads of closed issues and pull requests after a configurable inactivity period (default: 3 months)
Asks the API for the threads last updated before the cutoff and inspects them all, oldest first. Only the unsubscribes are capped (default: 200 per run, set with
[tool.repomatic] notification.max-unsubscribes), since each one costs two REST calls where fifty inspections cost one GraphQL pointSupports dry-run mode via
workflow_dispatchto preview candidates without actingStreams per-thread progress to the job log; the markdown report lands in the step summary
Requires:
REPOMATIC_NOTIFICATIONS_PATsecret, a classic PAT with thenotificationsscope (skips silently when not configured; the setup guide issue walks through creating it)notification.unsubscribe = truein[tool.repomatic](opt-in; thin caller workflow is not generated by default)
Skipped if:
upstream
kdeldycke/repomaticrepo (except viaworkflow_call)
🧬 What is this metadata job?¶
Most jobs in this repository depend on a shared parent job called metadata. It runs first to extract contextual information, reconcile and combine it, and expose it for downstream jobs to consume.
This expands the capabilities of GitHub Actions, since it allows to:
Share complex data across jobs (like build matrix)
Remove limitations of conditional jobs
Allow for runner introspection
Fix quirks (like missing environment variables, events/commits mismatch, merge commits, etc.)
This job relies on the repomatic show-metadata command to gather data from multiple sources:
Git: current branch, latest tag, commit messages, changed files
GitHub: event type, actor, PR labels
Environment: OS, architecture
pyproject.toml: project name, version, entry points
To see the full set of keys it exposes to downstream jobs, run repomatic show-metadata --list-keys:
$ repomatic show-metadata --list-keys
╭───────────────────────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Key │ Description │
├───────────────────────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ active_autodoc │ Active Sphinx autodoc extensions detected. │
│ binaries_sync │ Whether the release pipeline records released binaries into the repository. │
│ build_targets │ List of Nuitka build targets for all platforms. │
│ cli_scripts │ CLI script entry points from pyproject.toml. │
│ current_version │ Current version from pyproject.toml. │
│ doc_files │ List of documentation files. │
│ gitignore_exists │ Whether a .gitignore file exists in the repository. │
│ image_files │ List of image files. │
│ is_bot │ Workflow was triggered by a bot or automated process. │
│ is_python_package │ Repository builds a distributable Python package, not a uv virtual project. │
│ is_python_project │ Repository is a Python project with pyproject.toml. │
│ is_sphinx │ Sphinx configuration file is present. │
│ json_files │ List of JSON files in the repository. │
│ mailmap_exists │ Whether a .mailmap file exists in the repository. │
│ major_bump_allowed │ Major version bump is allowed by commit history. │
│ manpages_asset_name │ Filename stem (without the `.tar.gz` extension) for the man-page tarball uploaded to the GitHub release. │
│ manpages_script │ Click command target whose tree gets rendered as roff `.1` files and attached as a tarball asset on every GitHub release. │
│ markdown_files │ List of Markdown files. │
│ minor_bump_allowed │ Minor version bump is allowed by commit history. │
│ mypy_params │ Generated mypy command-line parameters. │
│ new_commits │ Hashes of new commits in the push event. │
│ new_commits_matrix │ Matrix of new commits with long and short SHA values. │
│ npm_min_release_age_days │ npm min-release-age cooldown, in whole days. │
│ nuitka_extras │ `[project.optional-dependencies]` extras to install before the Nuitka build. │
│ nuitka_matrix │ Matrix for Nuitka compilation workflows. │
│ package_name │ Package name as published on PyPI. │
│ project_description │ Project description from pyproject.toml. │
│ pyproject_files │ List of pyproject.toml files in the repository. │
│ python_files │ List of Python files in the repository. │
│ release_assets │ Extra asset filenames attached to every GitHub release. │
│ release_commits │ Hashes of release commits in the push event. │
│ release_commits_matrix │ Matrix of release commits with long and short SHA values. │
│ release_notes │ Release notes for the GitHub release. │
│ release_notes_with_admonition │ Release notes with PyPI availability admonition. │
│ released_version │ Version of the release commit, if any. │
│ runner_arch │ Architecture of each runner image in the full test matrix. │
│ shfmt_files │ List of shell files formattable by shfmt. │
│ site_cloudflare_project │ Name of the Cloudflare Pages project the site deploys into. │
│ site_deploy │ Where this repository's built site is published. │
│ skip_binary_build │ Binary builds should be skipped for this event. │
│ sphinx_builder │ Sphinx builder producing the deployed documentation site. │
│ test_matrix │ Full test matrix for non-PR events. │
│ test_matrix_pr │ Reduced test matrix for pull requests. │
│ uses_myst │ MyST-Parser is active in Sphinx configuration. │
│ workflow_files │ List of GitHub workflow files. │
│ workflows_changed │ Current event's commit range touches at least one GitHub workflow file. │
│ yaml_changed │ Current event's commit range touches at least one YAML file. │
│ yaml_files │ List of YAML files in the repository. │
│ zsh_changed │ Current event's commit range touches at least one Zsh file. │
│ zsh_files │ List of Zsh files. │
╰───────────────────────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Important
This flexibility comes at the cost of:
Making the whole workflow a bit more computationally intensive
Introducing a small delay at the beginning of the run
Preventing child jobs to run in parallel before its completion
But is worth it given how GitHub Actions can be frustrating.
How does it work?¶
uv everywhere¶
All Python dependencies and CLIs are installed via uv for speed and reproducibility.
Smart job skipping¶
Jobs are guarded by conditions to skip unnecessary steps: file type detection (only lint Python if .py files exist), branch filtering (prepare-release skipped for most linting), and bot detection.
Dynamic test matrices¶
GitHub’s strategy.matrix is a static Cartesian product: you list values per axis, optionally add or exclude fixed combinations, and that’s it. There is no way to conditionally add dimensions, replace values in-place, or remove axis entries based on project configuration.
repomatic generates matrices dynamically in the metadata job, applying a chain of transformations that downstream projects control via [tool.repomatic.test-matrix]:
replace: swap one axis value for another (e.g., pin a specific Python patch version).remove: delete values from an axis entirely.variations: add new dimensions or extend existing ones (full CI only, keeping PR feedback fast).exclude: remove matching combinations, with partial matching across axes.include: add or augment combinations, processed after excludes so they take priority.
Operations are applied in that order, so downstream projects can express matrix shapes that static YAML cannot: different dimensions for PR vs full CI, axis-level transformations without rewriting the entire matrix, and ordered operations that compose predictably.
For how to choose what the matrix tests (covering the shipped config broadly while keeping forward-looking axes cheap, pinning a dependency floor, selecting runners by measured speed) plus a runner-speed inventory and a worked example, see Test matrix.
Matrix fail-fast strategy¶
Whether a matrix job overrides the default fail-fast: true depends on what the cells produce, not on which workflow they live in. Three categories:
Asset-producing matrices that feed an immutable downstream artifact. Each cell builds something the next job ships and cannot retroactively fix. Override to
fail-fast: falseso a transient runner crash on one cell does not cancel siblings whose output was already valid: shipping partial coverage is strictly better than shipping nothing. Downstream gates must acceptresult != 'skipped'(not== 'success') so partial-success runs still flow through. Applies to:compile-binaries(binaries thatpublish-releaseattaches to the draft release before § Immutable releases locks them).Info-gathering matrices. Each cell collects diagnostic data and the value of the run scales with how many cells reported. Override to
fail-fast: falseso a single failure does not erase the rest of the snapshot. Applies to:tests(per-cellcontinue-on-erroralready decides what fails the workflow),dump-context, andtest-binaries(gated withalways()besides, so one failed build cell neither skips nor cancels the healthy targets’ tests: its own cell fails on the missing artifact, which the advisory nature tolerates).Advisory or single-cell matrices. Tests that do not gate publication, validations, or matrices that typically run with one cell. Keep the default
fail-fast: true: cancelling siblings on the first failure saves runner minutes, and a real regression is resolved by fixing the underlying code (then re-running) or, for already-published releases, by skipping that version (see § Immutable releases) rather than by exhaustively diagnosing every platform up front. Applies to:validate-archand the single-cell publish-pipeline matrices (build-package,create-tag,publish-pypi,create-release,publish-release,scan-virustotal).
GitHub resolves a job’s strategy.matrix during setup even when the job’s if: guard will skip it, so a matrix expression that resolves to an empty or null value can abort the entire run with Unexpected value '' before if: is ever checked. This surfaces when a project disables binary builds (nuitka.enabled = false makes nuitka_matrix null), turning every non-release push red. Two triggers exist: a workflow_call output read as a bare string (an empty release_commits_matrix becomes fromJSON('')), and a matrix-derived runs-on: ${{ matrix.os }} that cannot resolve against an absent matrix. The fix is a fallback to a valid empty matrix. matrix: ${{ ... || fromJSON('{"include":[]}') }} expands the job to zero runs, so it skips cleanly instead of failing the workflow. compile-binaries, test-binaries, and the caller’s publish-pypi job carry this fallback; a job that pins a static runs-on and has no other matrix-derived fields (like create-tag) already skips cleanly on a null matrix and needs none.
Maintainer-in-the-loop¶
Workflows never act silently. Every proposed change opens a pull request; every action needed opens an issue. You review and decide, and no change to your source lands without your approval.
Every file-modifying job goes through a pull request, scanning and sampling included: the two that accrue a history publish through one long-lived pull request each run appends to, rather than one per run nobody would read. The only writes reaching the default branch on their own are the version machinery’s [changelog] commits, which carry the release you triggered rather than a change proposed to you.
Configurable with sensible defaults¶
Downstream projects customize behavior via [tool.repomatic] in pyproject.toml. Workflows also accept inputs for fine-tuning, but the configuration file is the primary interface.
Idempotent operations¶
Safe to re-run: tag creation skips if already exists, version bumps have eligibility checks, PRs update existing branches.
Graceful degradation¶
Fallback tokens (secrets.REPOMATIC_PAT || secrets.GITHUB_TOKEN) and continue-on-error for unstable targets. Job names use emoji prefixes for at-a-glance status: ✅ for stable jobs that must pass, ⁉️ for unstable jobs (e.g., experimental Python versions, unreleased platforms) that are expected to fail and won’t block the workflow. repomatic ci-status reads the same glyphs back, reporting each workflow’s latest run and which of its failing jobs actually gate a merge.
Dogfooding¶
This repository uses these workflows for itself.
Dependency strategy¶
All dependencies are pinned to specific versions for stability, reproducibility, and security. The update machinery is entirely self-hosted: no third-party dependency bot is required.
Pinning mechanisms¶
Mechanism |
What it pins |
How it’s updated |
|---|---|---|
|
Project Python dependencies |
|
SHA-pinned |
GitHub Actions |
|
Inline version literals |
npm packages, |
|
Binary tool registry |
|
|
|
Transitive Python dependencies |
Time-based window |
Tagged workflow URLs |
Remote workflow |
Release process (freeze/unfreeze commits) |
|
CLI from the project lockfile |
Release freeze |
Hard-coded versions in workflows¶
GitHub Actions and npm packages are pinned directly in YAML files:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- run: npm install [email protected] # Pinned npm package
GitHub Actions are pinned to full commit SHAs, with the semver tag preserved as a trailing comment. The sync-action-pins updater reads the comment, fetches the latest release, and rewrites the uses: line with the new SHA. The sync-workflow-pins updater handles the npm and PyPI version literals.
Cooldowns¶
Every updater respects a cooldown, whether it runs inside sync-deps or on its own. sync-action-pins, sync-workflow-pins, and sync-tool-versions share minimum-release-age (default "1 week"): a release is only adopted once it has been public for at least that long, giving upstream time to yank a bad cut. uv’s --exclude-newer is its counterpart guarding sync-uv-lock, and sync-dep-sources adopts a fresh release through that same window with an explicit exclude-newer-package freeze.
To mitigate supply chain attacks, a new release reaching the cooldown threshold produces a PR automatically.
Each cooldown-gated PR mirrors the sync-uv-lock body. Above the update table it prints the effective cutoff date (today minus minimum-release-age). For pins that resolve to a GitHub source (every action, the GitHub-backed registry tools, and PyPI version literals in workflows), a Release notes dropdown then collects the adopted versions’ upstream notes; npm literals have no source-discovery path, so they carry no notes. A final ⏸️ Held back by cooldown section lists every scanned pin with a newer release still inside the window, alongside the date each becomes adoptable.
uv.lock and --exclude-newer¶
The uv.lock file pins all project Python dependencies. The sync-uv-lock updater runs uv lock --upgrade on a schedule and opens a PR when real changes are detected (timestamp-only noise is skipped).
The exclude-newer setting in [tool.uv] ignores packages released within a short window, providing a buffer against freshly-published broken releases. The window is a relative span (1 week), so it moves forward on its own at every lock. The sync-uv-lock updater re-applies it from the bundled template before each re-lock.
sync-uv-lock passes that window to uv lock as an explicit --exclude-newer flag instead of letting uv read it from pyproject.toml. Every workflow exports a UV_EXCLUDE_NEWER (see install-time cooldown below), and an environment variable outranks [tool.uv]: left implicit, a CI lock would resolve against a different window than a developer running the same command, and the two machines would keep reverting each other’s lock.
Install-time cooldown¶
The cooldowns above gate what gets written into a pin or a lockfile. A separate layer gates what any command resolves at run time: each workflow declares UV_EXCLUDE_NEWER and NPM_CONFIG_MIN_RELEASE_AGE in a workflow-level env: block, so every uvx, uv pip install, uv run --with, uv tool install, npm install and npx in every job refuses a package published inside the window, transitive dependencies included.
The block sits at workflow level rather than on each job or each command because the gap it closes is the command nobody thought to protect: a debugging step, a one-off experiment, a job added next year. Its window is a literal rather than a metadata job output, since a workflow-level env: block cannot reference needs, and the metadata job itself runs uvx to compute its outputs. tests/test_workflows.py holds that literal equal to minimum-release-age.
Three installs opt out, each as narrowly as it can:
Install |
Scope |
Why |
|---|---|---|
The frozen |
One package |
Moves in lockstep with the |
A security fix inside the window |
One package |
|
The |
One job |
Its subject is the fresh release, so a cooldown makes the question it answers unanswerable. It holds no secrets and inherits |
The handful of apt-get install steps are not a fourth exemption: a distro archive is not a live registry. It is frozen at release and moves only through the distribution’s own staging, so the delay a cooldown adds is already built in one layer down, and a distro version string names the maintainer’s build rather than an upstream publish date, leaving a publish-date filter nothing to filter on. meta-package-manager’s inventory marks these managers N/A rather than unsupported for that reason. A third-party repository added by hand (a PPA, a vendor .repo file) is the real exception, since it is a single-publisher registry with none of that staging behind it.
Tagged workflow URLs¶
Workflows in this repository are self-referential. The prepare-release job’s freeze commit rewrites workflow URL references from main to the release tag, ensuring released versions reference immutable URLs. The unfreeze commit reverts them back to main for development.
Release engineering¶
A maintainer cuts a release with the /repomatic-ship skill, which reconciles the tree, commits and pushes, and runs /babysit-ci until main is green. The maintainer then merges the release PR with “Rebase and merge”. Everything below is what that merge triggers.
A complete release consists of all of the following:
Git tag (
vX.Y.Z) created on the freeze commit.GitHub release with release notes matching the
changelog.mdentry.Binaries attached for all 6 platform/architecture combinations (linux-arm64, linux-x64, macos-arm64, macos-x64, windows-arm64, windows-x64).
PyPI package published at the matching version.
changelog.mdentry with the release date and comparison URL finalized.
If any item is missing, the release is incomplete.
Freeze and unfreeze commits¶
The prepare-release job creates a PR with exactly two commits that must be merged via “Rebase and merge” (never squash):
Freeze commit (
[changelog] Release vX.Y.Z): finalizes the changelog date and comparison URL, removes the “unreleased” warning, freezes workflow action references to@vX.Y.Z, freezes CLI invocations to a PyPI version, and re-locksuv.lockso the tag carries a lock entry matching its own version.Unfreeze commit (
[changelog] Post-release bump): reverts action references back to@main, reverts CLI invocations to local source, adds a new unreleased changelog section, bumps the version to the next patch, and re-locks again.
Not everything the freeze pins is reverted. Four release pins ratchet forward instead: the binary download URLs and the Specific version CLI example in docs/install.md, the plugin marketplace entry’s version in .claude-plugin/marketplace.json, and the version of the plugin manifest in .claude/.claude-plugin/plugin.json. The freeze moves them to the new tag and the unfreeze leaves them there, so main names the newest published release rather than a tag that does not exist yet. That marketplace entry’s ref beside it round-trips like a workflow reference, since a tag pin would freeze the plugin’s content for a whole cycle.
The auto-tagging job depends on these being separate commits: it uses release_commits_matrix to identify and tag only the freeze commit. Squashing would merge both into one, breaking the tagging logic.
On main, workflows run the CLI with uv --no-progress run --frozen -- repomatic, which installs the project from uv.lock (dogfooding). The freeze commit rewrites these to uvx --no-progress --exclude-newer-package repomatic=P0D 'repomatic==X.Y.Z' so tagged releases resolve a published package from PyPI, which is what a downstream repo needs: it has no lockfile for this project. The unfreeze commit reverts them for the next development cycle.
The asymmetry is deliberate. A lockfile entry is pinned and hash-verified, so it is a stronger guarantee than the publication-age cooldown, and unlike an index resolution it cannot be made unsatisfiable by one. An isolated uvx --from . re-resolved [project.dependencies] on every call while reading neither uv.lock nor any project configuration that could have carried an exclude-newer-package exemption, so raising a dependency floor onto a release younger than minimum-release-age took every workflow down at once, with nowhere to record the exemption.
Insulating this repository does not remove the hazard, it relocates it: a floor inside the window now resolves fine here and breaks only whoever installs the release from an index. A conformance test rejects such a floor before it can be merged.
The version string moves through the two commits and back to a fresh development cycle:
stateDiagram-v2
direction LR
[*] --> Development
Development: Dev cycle. X.Y.Z.dev0 on main, refs @main
Development --> ReleasePR: prepare-release opens the PR
state "Release PR, rebase-merge only" as ReleasePR {
[*] --> Freeze
Freeze: Freeze commit. Refs at @vX.Y.Z, CLI at X.Y.Z
Freeze --> Unfreeze
Unfreeze: Unfreeze commit. Refs back to @main, next patch
}
ReleasePR --> Tagged: rebase-merge, auto-tag hits the freeze commit
Tagged: Tagged release vX.Y.Z. Built, published, GitHub release
Tagged --> Development: unfreeze lands, main on next dev cycle
Squash merge safeguard¶
The detect-squash-merge job catches squash-merged release PRs by checking if the head commit message starts with Release `v (the PR title pattern) rather than [changelog] Release v (the canonical freeze commit pattern). When detected, it opens a GitHub issue assigned to the person who merged, then fails the workflow. Existing safeguards in create-tag prevent tagging, publishing, and releasing from a squashed commit.
The net effect of squashing freeze + unfreeze leaves main in a valid state for the next development cycle: the maintainer releases the next version when ready.
workflow_run checkout pitfall¶
When workflow_run fires, github.event.workflow_run.head_sha points to the commit that triggered the upstream workflow, not the latest commit on main. If the release cycle added commits after that trigger (freeze + unfreeze), checking out head_sha produces a stale tree.
The fix: use github.sha instead, which for workflow_run events resolves to the latest commit on the default branch. The workflow_run trigger’s purpose is timing (ensuring tags exist), not pinning to a specific commit. See actions/checkout#504 for context on checkout’s default merge commit behavior.
Immutable releases¶
The release workflow creates a draft, uploads all assets, then publishes. Once published with GitHub immutable releases enabled, tags and assets are locked. Tag names are permanently burned: reinforcing the skip-and-move-forward principle.
Immutability only blocks asset uploads and modifications on published releases (HTTP 422: Cannot upload assets to an immutable release). Published releases can still be deleted (along with their tags via --cleanup-tag).
Dev releases use drafts. The sync-dev-release job creates dev pre-releases as drafts (--draft --prerelease) rather than published pre-releases. Drafts allow the workflow to upload binaries and packages after creation. The release stays as a draft permanently: it is never published. On the next push, cleanup_dev_releases() deletes all existing .dev0 releases (drafts are always deletable) before creating a fresh one. See repomatic/github/dev_release.py for implementation.
Concurrency strategies¶
Workflows use two concurrency strategies depending on whether they perform critical release operations. Read the concurrency: block in each workflow file for the exact YAML.
release.yaml: SHA-based unique groups. Tagging, PyPI publishing, and GitHub release creation must run to completion. The block lives on the push-triggered entry workflow, not the reusable _release-engine.yaml it calls: GitHub decides run cancellation from the entry workflow’s group, and a block on the engine lane (reached via needs: build) joins its group only after the build lane finishes, too late to cancel queued or building runs. A simple thin caller cancels fine without its own block because its single job joins the reusable workflow’s group immediately; the release entry can’t, so it declares concurrency itself. Using conditional cancel-in-progress: false doesn’t work: it’s evaluated on the new workflow, not the old one. If a regular commit is pushed while a release workflow is running, the new workflow would cancel the release because they share the same concurrency group. The solution: give each release run its own unique group using the commit SHA. Both [changelog] Release and [changelog] Post-release patterns must be matched because when a release is pushed, the event contains two commits bundled together and github.event.head_commit refers to the most recent one (the post-release bump). schedule and workflow_dispatch runs are isolated the same way, keyed on github.run_id rather than a SHA: they compile the full target fleet on purpose, and a dispatch sharing the branch group was observed cancelled mid-build by the next push.
changelog.yaml: event-scoped groups. changelog.yaml includes github.event_name in its concurrency group to prevent cross-event cancellation. Without event_name, the workflow_run event (which fires when “🚀 Build & release” completes) would cancel the push event’s prepare-release job, then skip prepare-release itself (due to if: github.event_name != 'workflow_run'), so prepare-release would never run.
The generator behind these workflow files is documented on the repomatic.github.workflow_sync page.