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]
Important
Concurrency is already configured in the reusable workflows: you don’t need to re-specify it in your calling workflow.
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 (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)When 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 adds a strictly newer version to the full test matrix as a
continue-on-errorprobe (test-matrix.variations.osplus atest-matrix.unstableentry) rather than migrating onto it. The cell cannot fail the build, and the suite starts exercising the image immediately, which is what surfaces a dependency breaking there while there is runway to report it upstreamStrictly 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})
📐 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)¶
Automatically fixes typos in the codebase using
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 --delete-unmodified --delete-excludedto sync all repomatic-managed files: thin-caller workflows, configuration files, and skill definitionsRemoves unmodified config files identical to bundled defaults and cleans up excluded or stale files (disabled opt-in workflows, auto-excluded skills)
Holds the upstream
uses:pin back through theminimum-release-agecooldown, but only when it would adopt a release newer than the one the repository already pins. A pin at or above the running version is left untouched, and a step-back never lands below the pin already committed, so a repository that deliberately moved to a fresh release is never dragged back and a rollback to an older repomatic is honored. Pass--no-cooldownto adopt the running version immediately, or--versionto pin an exact tagPrunes 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])
📬 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 sourcesPR 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 tablePR body lists each updated pin with old and new versions
Requires:
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 docs (update-docs)¶
Regenerates Sphinx autodoc files using
sphinx-apidoc, converting the generated RST stubs to MyST markdown when the docs tree uses itRuns
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¶
🩺 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
✂️ .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 --fix. Warns without failing about over-long entries and released sections holding no entryA 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 runsBefore publishing,
repomatic lint-anchorsresolves every same-page](#fragment)link written in the Markdown sources against the anchors the build actually produced, and fails the job when one lands nowhereRequires:
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 surfaceRuns the same
lint-anchorscheck as the job above, ahead of the size trim below so it reads the tree as Sphinx wrote itFiles over Direct Upload’s 25 MiB per-file limit are dropped before the upload, each named in the log:
wranglerwould otherwise fail the whole deploy on the first one it meets, publishing nothing instead of everything elseChoose 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 → Edit
That one secret is a prerequisite, not an enhancement: the job fails without it, so
lint-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 aheadNo second identifier beside that secret: the 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
Same-page links are checked against the built site¶
Both deploy jobs run repomatic lint-anchors between the Sphinx build and the upload. It reads every ](#fragment) written in the Markdown sources and resolves it against the id and name anchors of the page that source was rendered into, failing the job with the file, the fragment and the page it looked in.
This is the one cross-reference nothing else covers. A {ref} or {doc} role goes through Sphinx, which reports a missing target under nitpicky; a literal fragment is copied into the HTML untouched, so the build has nothing to resolve and stays green. check-broken-links above does not close the gap either, because a Markdown checker has to compute the slug rather than read it, and the two answers differ: on the heading ## The pages.dev hostname, myst-parser builds the-pages-dev-hostname while lychee’s GitHub-style slugger wants the-pagesdev-hostname, each reporting the other as broken. That disagreement is why intra-docs fragments are excluded from lychee in [tool.lychee], and reading the built page is what fills the hole the exclusion leaves.
Only authored fragments are checked, so a theme’s own footnote backrefs and header permalinks never enter, and both Sphinx HTML builders are handled by probing {name}.html then {name}/index.html. A source that produced no page (one left out of every toctree, or a fragment meant only to be included elsewhere) is reported and skipped rather than failed: which sources become pages is the build’s call.
🏷️ .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 (package name, Sphinx docs, project description) and Dependabot configuration using
repomatic lint-repo. Readspyproject.tomldirectly. WhenREPOMATIC_PATis configured, also validates PAT capabilities (contents, issues, pull requests, Dependabot alerts, workflows permissions). Warns when the fork PR workflow approval policy is weaker thanfirst_time_contributors. Warns about missingVIRUSTOTAL_API_KEYwhen Nuitka binary compilation is active. Warns about missingREPOMATIC_NOTIFICATIONS_PATwhen the unsubscribe workflow is enabled. Warns about a missingCLOUDFLARE_API_TOKENwhensite.deploytargets Cloudflare Pages: the token is the whole of the credential, with the account derived from it at run time. 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 committedwrangler.tomlcontradicts the declared Cloudflare project name or compatibility date.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 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 indefinitelyFails 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 ownFails when a workflow’s
run:line asksrepomatic 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 badlyRequires:
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: no long-lived API token is required.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-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/arm64). 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 onx64/arm64Linux 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 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 metadata’srelease_notes_with_admonition), so it never depends on a later cross-lane edit; non-PyPI projects fall back to the plain release notesBinaries are attached independently by each
compile-binariesmatrix entry as they complete (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 --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 --man 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>= 8: the--output-dir DIRoption toclick-extra wrap --manwrites one.1file per resolved (sub)command intoDIR, creating the directory if missingSuccessful
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)¶
Uploads compiled binaries (
.binand.exe) to VirusTotal viarepomatic scan-virustotal, polls for analysis completion, and records each binary’sflagged / 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 commits the files directly to the default branch viarepomatic git-commit-push. 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 as a direct push to the default branch, not a pull request: it captures facts about an already-published release, and the binaries page must be live while the release is fresh. This is the only file-modifying operation exempt from the PR convention: see § Release-lane direct commits 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]
🕸️ 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 diagramLives in the release engine because a release push is its only firing moment (ordinary pushes would only churn the graph with transitive noise), and
autofix.yaml, its former home, now skips version-bump pushes wholesaleRuns on: Release commits only
Requires:
Python package with a
uv.lockfile
🔧 .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/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/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
Reconstructs an exact star curve for every GitHub repository the token administers, from the per-star timestamps GitHub still serves an admin: those curves are complete from their first star rather than from the day sampling started
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
Commits the store straight to the default branch: the diff records what an API answered at a moment that has passed, so there is nothing a pull request could review (see § Sampling commits directly)
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 push to a protected default branch and to read the per-star timestamps of the repositories it administers
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/arm64) 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)
Processes threads in batches (default: 200 per run) to stay within API rate limits
Supports 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 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 metadata --list-keys:
$ repomatic 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. │
│ 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 attached 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 commit directly or act silently. Every proposed change creates a PR; every action needed opens an issue. You review and decide — nothing lands without your approval.
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, so triaging red CI does not require eyeballing a run’s job list by hand.
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: no manual bump required.
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 flag in [tool.uv] ignores packages released within a short window, providing a buffer against freshly-published broken releases. The window is managed by the sync-uv-lock updater, which rolls the exclude-newer date forward automatically.
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. Release-asset URLs (the binary downloads in docs/install.md, the plugin archive in .claude-plugin/marketplace.json) ratchet forward instead: 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.
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 '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.
repomatic.github.workflow_sync API¶
Generation, sync, and lint for downstream workflows.
Downstream repositories consuming reusable workflows from kdeldycke/repomatic
manually write caller workflows that often miss triggers like
workflow_dispatch. This module provides tools to generate, synchronize, and
lint those callers by parsing the canonical workflow definitions.
render_thin_caller_for_target() is the single entry point that turns a
canonical workflow into a downstream file on disk; repomatic init drives it.
Generating and reshaping workflow content in Python, rather than
hand-maintaining YAML, keeps logic out of the platform-specific GitHub Actions
surface: a tested generator that fails loudly beats a static YAML artifact that
can silently drift, and the smaller GHA surface eases a future migration to
another CI platform. _render_publish_pypi_job derives each downstream
publish-pypi job from the canonical release.yaml this way.
Caution
PyYAML destroys formatting and comments on round-trip. Until we find a layout-preserving YAML parsing and rendering solution, we use raw text extraction to manipulate workflow files while preserving formatting and comments.
- repomatic.github.workflow_sync.cooldown_env_block()[source]¶
Render the supply-chain cooldown
env:block every workflow carries.Rendered from
minimum_release_agerather than written by hand, so the literal in the YAML has exactly one source. The same text is asserted verbatim against every checked-in workflow bytests/test_workflows.py, and emitted into the downstreamrelease.yamlcaller by_generate_release_caller().Caution
The comment travels into every downstream repository, so it must read true there too. It deliberately does not name
tests/test_workflows.py: that file exists only here, and a synced copy would point its readers at a path they do not have. Keep any wording added below equally context-free, and name a repomatic-private path only in a comment that never ships.Note
A workflow-level
env:block cannot referenceneeds, which is why the window is a literal here instead of ametadatajob output: themetadatajob runsuvxto compute its own outputs, so anything sourced from it would leave that bootstrap install ungated. Seeclaude.md§ Cooldown on every install.- Return type:
- Returns:
The comment and
env:mapping, newline-terminated, ready to splice above a workflow’sjobs:line.
- repomatic.github.workflow_sync.PERMISSION_RANK: Final[dict[str, int]] = {'none': 0, 'read': 1, 'write': 2}¶
Relative strength of the
permissions:levels GitHub accepts.Used to union the same scope granted at different levels across the jobs of a canonical workflow, keeping the most permissive one.
- repomatic.github.workflow_sync.DEFAULT_VERSION: Final[str] = 'main'¶
Default version reference for upstream workflows.
For release builds (e.g.,
repomatic==5.11.0), this resolves to the corresponding tag (v5.11.0). For development builds (5.11.1.dev0), it falls back tomainsince the tag does not exist yet.
- class repomatic.github.workflow_sync.WorkflowTriggerInfo(name, filename, non_call_triggers, call_inputs, call_secrets, has_workflow_call, concurrency, raw_concurrency)[source]¶
Bases:
objectParsed trigger information from a canonical workflow.
- class repomatic.github.workflow_sync.LintResult(message, is_issue, level=AnnotationLevel.WARNING)[source]¶
Bases:
objectResult of a single lint check.
- level: AnnotationLevel = 'warning'¶
Severity level for GitHub Actions annotations.
- repomatic.github.workflow_sync.workflow_triggers(data)[source]¶
Extract a parsed workflow’s
on:mapping.Note
PyYAML follows YAML 1.1, where a bare
onkey parses as the booleanTruewhile a quoted"on"stays a string. Both spellings occur in the wild, so every reader of a workflow’s triggers has to try the boolean key first and the string key second. Resolving that here once keeps the quirk from being re-remembered at each call site.
- repomatic.github.workflow_sync.canonical_caller_permissions(filename)[source]¶
Union the job-level
permissions:scopes of a canonical workflow.A caller job hands its own permissions down to the reusable workflow it calls, and the called workflow’s jobs are capped by them: they cannot escalate beyond what the caller granted. The canonical workflows pin a top-level
permissions: {}, so a job without its own block needs nothing and the union of the job-level blocks is the complete set the caller has to forward.A scope appearing at different levels across jobs resolves to the most permissive one, so no job is starved by another’s narrower grant.
- Parameters:
filename (
str) – Canonical workflow filename (e.g.,autofix.yaml).- Return type:
- Returns:
Scope-to-level mapping, sorted by scope. Empty when no job declares permissions, meaning the caller forwards nothing.
- Raises:
FileNotFoundError – If the workflow file is not bundled.
- repomatic.github.workflow_sync.extract_trigger_info(filename)[source]¶
Extract trigger information from a bundled canonical workflow.
Parses the workflow YAML and separates
workflow_callconfiguration from other triggers.- Parameters:
filename (
str) – Workflow filename (e.g.,release.yaml).- Return type:
- Returns:
Parsed trigger information.
- Raises:
FileNotFoundError – If the workflow file is not bundled.
- class repomatic.github.workflow_sync.PathsSpec(source_paths=None, extra_paths=<factory>, ignore_paths=<factory>, workflow_paths=<factory>)[source]¶
Bases:
objectBundle of downstream
paths:adaptation knobs.Each field maps to a
[tool.repomatic.workflow]option.- Parameters:
source_paths (
list[str] |None) – Substituted in for the canonicalrepomatic/**glob in every workflow that references it.Nonedrops the glob without substitution.extra_paths (
list[str]) – Appended to every workflow’spaths:list (after source substitution andignore_pathsfiltering, before render). Skipped for workflows listed in workflow_paths.ignore_paths (
list[str]) – Removed from every workflow’spaths:list by exact string match. Skipped for workflows listed in workflow_paths.workflow_paths (
dict[str,list[str]]) – Per-workflow override keyed by filename. The value is treated as the completepaths:list for that workflow; the other knobs do not apply.
- repomatic.github.workflow_sync.generate_thin_caller(filename, repo='kdeldycke/repomatic', version='main', commit_sha=None, paths_spec=None, with_permissions=False, existing=None)[source]¶
Generate a thin caller workflow for a reusable canonical workflow.
The generated caller mirrors the canonical workflow’s non-
workflow_calltriggers verbatim and delegates to the upstream workflow viauses:.workflow_dispatchis not injected: workflows that should expose manual dispatch declare it in the canonical definition. Declaredworkflow_callinputs and secrets are forwarded explicitly viawith:andsecrets:.Canonical
paths:filters are adapted via paths_spec (seePathsSpec).When commit_sha is provided, the
uses:reference is SHA-pinned (@sha # version), secure-by-default from the first commit. Thesync-action-pinsjob bumps it once a newer release clears the cooldown.- Parameters:
filename (
str) – Canonical workflow filename (e.g.,release.yaml).repo (
str) – Upstream repository (default:kdeldycke/repomatic).version (
str) – Version reference (default:main).commit_sha (
str|None) – Full 40-character commit SHA for the version tag. When provided, produces@sha # version. WhenNone, produces@version.paths_spec (
PathsSpec|None) – Full paths-adaptation spec; defaults to no adaptation.with_permissions (
bool) – Emit an explicit permissions contract: a top-levelpermissions: {}plus, on the managed job, the scopes the reusable workflow needs (seecanonical_caller_permissions()). Set when the downstream file carries extra jobs of its own, whose customsteps:are what make the top-level key worth pinning. Both halves ship together: the top-level{}alone would starve the managed call, which GitHub aborts at startup the moment a nested job asks for a scope the caller never granted.existing (
str|None) – Current content of the downstream file, when it already exists. Onlyrelease.yamlreads it, to carry over the extraneeds:edges of itsreleaselane; every other caller regenerates whole.
- Return type:
- Returns:
Complete YAML content for the thin caller workflow.
- Raises:
ValueError – If the workflow does not support
workflow_call.
- repomatic.github.workflow_sync.EXTRA_JOBS_SEPARATOR: Final[str] = '\n\n'¶
Gap between the last managed lane and the downstream extras below it.
Exactly one blank line, matching how
_generate_release_caller()separates its own jobs. Both sides are trimmed before it is applied, because neither end is stable on its own: the release caller ends on a trailing blank line where a plain thin caller does not, andextract_extra_jobs()slices from the end of the last managed job body, so it returns however many blank lines the file already had. Joining those two as-is added one blank line per sync, without bound.
- repomatic.github.workflow_sync.render_thin_caller_for_target(filename, target, *, repo='kdeldycke/repomatic', version='main', commit_sha=None, paths_spec=None)[source]¶
Render the complete downstream content of target, extras included.
The single seam between a canonical workflow and a file on disk: read what is already there, carry over what only the downstream copy knows, render the managed lanes, and re-attach the extras.
repomatic initis the only caller, so a preservation argument can only ever be wired up once.Caution
Do not inline this back into a caller. It previously existed as two near-identical copies, and the
existingargument that carries a consumer’sneeds:edges across a sync reached only one of them: every downstreamrepomatic initsilently dropped the edge while the test suite, which drove the other copy, stayed green.tests/test_workflow_sync.pypins the seam to a single call site.Reads target itself rather than taking its content, so a caller cannot forget to hand over the state that preservation depends on.
- Parameters:
filename (
str) – Canonical workflow filename (e.g.release.yaml).target (
Path) – Destination path, read when it already exists.repo (
str) – Upstream repository for theuses:refs.version (
str) – Version reference for theuses:refs.commit_sha (
str|None) – Full 40-character commit SHA for SHA-pinned refs.paths_spec (
PathsSpec|None) – Full paths-adaptation spec; defaults to no adaptation.
- Return type:
- Returns:
The content to write, and the current content of target (
Nonewhen it does not exist yet) so a caller can skip an unchanged write.- Raises:
ValueError – If filename declares no
workflow_calltrigger.
- repomatic.github.workflow_sync.GENERATED_CALLER_JOBS: Final[frozenset[str]] = frozenset({'build', 'publish-pypi', 'release'})¶
Every job the generated downstream
release.yamldefines.The canonical entry may hold repomatic-local jobs beyond these three (its own
pack-plugin, for one), and only these three are copied downstream. Anything the canonicalreleaselane names inneeds:outside this set has to be dropped, or the generated file would reference a job that does not exist there. See_merge_release_needs().
- repomatic.github.workflow_sync.identify_canonical_workflow(workflow_path, repo='kdeldycke/repomatic')[source]¶
Identify if a workflow is a thin caller for a canonical upstream workflow.
Scans jobs for a
uses:reference matching the upstream repository pattern.
- repomatic.github.workflow_sync.extract_extra_jobs(content, repo='kdeldycke/repomatic')[source]¶
Extract extra downstream jobs from an existing thin-caller workflow.
Parses the file with YAML to identify the managed thin-caller job (the one whose
uses:references the upstream repository), then returns all raw text after that job: blank lines, comments, and additional job definitions.Uses raw text slicing (not YAML round-tripping) to preserve formatting and comments, consistent with the rest of the module.
- repomatic.github.workflow_sync.extras_define_jobs(extra)[source]¶
Whether an extras fragment holds actual job definitions.
A fragment can be comments and blank lines only (a trailing note kept after the managed lanes): that content is worth carrying over verbatim, but it must not flip the caller into the explicit-permissions contract reserved for real downstream jobs.
- Return type:
- repomatic.github.workflow_sync.check_has_workflow_dispatch(workflow_path)[source]¶
Check that a workflow has a
workflow_dispatchtrigger.- Parameters:
workflow_path (
Path) – Path to the workflow file.- Return type:
- Returns:
Lint result.
- repomatic.github.workflow_sync.check_version_pinned(workflow_path, repo='kdeldycke/repomatic')[source]¶
Check that a thin caller pins to a version tag, not
@main.- Parameters:
- Return type:
- Returns:
Lint result.
- repomatic.github.workflow_sync.check_triggers_match(workflow_path, canonical_filename)[source]¶
Check that a thin caller’s triggers match the canonical workflow.
Verifies that the caller includes all non-
workflow_calltriggers defined in the canonical workflow.- Parameters:
- Return type:
- Returns:
Lint result.
- repomatic.github.workflow_sync.check_secrets_passed(workflow_path, canonical_filename)[source]¶
Check that a thin caller passes all required secrets explicitly.
Verifies that every secret declared by the canonical workflow is forwarded by the caller, either via explicit
secrets:mapping or viasecrets: inherit.- Parameters:
- Return type:
- Returns:
Lint result.
- repomatic.github.workflow_sync.generate_workflow_header(filename, paths_spec=None)[source]¶
Return the raw header of a canonical workflow.
The header is everything before the
jobs:line:name,ontriggers,concurrency, and any comments.Each
paths:block in the header is rewritten using paths_spec: upstream source references substituted, optional extras appended, ignored entries stripped, or replaced wholesale via a per-workflow override (seePathsSpec). When the resulting list is empty, the entirepaths:block is removed. Comments outside the rewritten blocks are preserved verbatim; comments inside an entry block are not supported.- Parameters:
- Return type:
- Returns:
Raw header text.
- Raises:
FileNotFoundError – If the workflow file is not bundled.
ValueError – If no
jobs:line is found.
- repomatic.github.workflow_sync.run_workflow_lint(workflow_dir, repo='kdeldycke/repomatic', fatal=False)[source]¶
Lint all workflow files in a directory.
For thin callers (workflows that delegate to a canonical upstream workflow via
uses:), runs caller-specific checks: version pinning, trigger match, and secrets passed. For standalone workflows, runscheck_has_workflow_dispatch()to flag missing manual triggers.Thin callers are exempt from
check_has_workflow_dispatch()becausecheck_triggers_match()is authoritative: a thin caller mirrors its canonical workflow exactly, and some canonical workflows (e.g.,cancel-runs.yaml) intentionally lackworkflow_dispatch.