Reusable workflows¶
The repomatic CLI operates in CI through reusable GitHub Actions workflows. You configure behavior via [tool.repomatic] in pyproject.toml; the workflows are the execution layer.
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/lint.yaml@v6.9.0
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 |
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 |
🪄 .github/workflows/autofix.yaml jobs¶
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, commit statuses) using the same checks as
lint-repoKeeps the issue open with a diagnostic table when the PAT exists but permissions are incomplete
When Nuitka binary compilation is active, includes a VirusTotal API key setup step and keeps the issue open until the key is configured
Automatically closes the issue once the secret is configured and all permissions are verified
Skipped if:
upstream
kdeldycke/repomaticrepo,workflow_calleventssetup-guide = falsein[tool.repomatic]
Formatters — rewrite files to enforce canonical style:
🐍 Format Python (
format-python)📐 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
mdformatRequires:
Markdown files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx}) in the repository
🐚 Format Shell (
format-shell)Auto-formats shell scripts using
shfmtRequires:
Shell files (
**/*.{bash,bats,ksh,mksh,sh,zsh}) or shell dotfiles (.bashrc,.zshrc, etc.) in the repository
🔧 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)Detects vulnerable packages using
uv auditagainst the Python Packaging Advisory Database and creates PRs to upgrade themUses
uv lock --upgrade-packagewith--exclude-newer-packagebypass to resolve fix versions that may be within theexclude-newercooldown periodPR body includes a table of vulnerabilities and updated package versions with release notes
Requires:
Python package (with a
pyproject.tomlfile)
🖼️ 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]
🔄 Sync bumpversion config (
sync-bumpversion)Syncs the
[tool.bumpversion]configuration inpyproject.tomlusingrepomatic sync-bumpversionSkipped if:
[tool.bumpversion]section already exists inpyproject.tomlbumpversion.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)
In the upstream repository, regenerates the bundled
repomatic/data/renovate.json5from the root 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
uv.lock(sync-uv-lock)Runs
uv lock --upgradeto update transitive dependencies to their latest allowed versions usingrepomatic sync-uv-lockOnly creates a PR when the lock file contains real dependency changes (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
Replaces Renovate’s
lockFileMaintenance, which cannot reliably revert noise-only changesRequires:
Python package with a
pyproject.tomlfile
Skipped if:
uv-lock.sync = falsein[tool.repomatic]
🕸️ Update dependency graph (
update-deps-graph)Generates a Mermaid dependency graph of the Python project using
repomatic update-deps-graphRequires:
Python package with a
uv.lockfile
📚 Update docs (
update-docs)Regenerates Sphinx autodoc files using
sphinx-apidocRuns
docs/docs_update.pyif present to generate dynamic content (tables, diagrams, Sphinx directives)Requires:
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 using
lock-threads
🩺 .github/workflows/debug.yaml jobs¶
🩺 Dump context (
dump-context)Dumps GitHub Actions context and runner environment info across all build targets using
ghaction-dump-contextUseful 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
Prevents 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-versionSyncs
uv.lockto include the new version in the same commitUses 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 --fixRuns 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
Uses
bump-my-versionandrepomatic changelogMust 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¶
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-pagesRequires:
Python package with a
pyproject.tomlfiledocsdependency groupSphinx configuration file at
docs/conf.py
🔗 Sphinx linkcheck (
check-sphinx-links)Runs Sphinx’s built-in
linkcheckbuilder to detect broken auto-generated links (intersphinx, autodoc, type annotations) that Lychee cannot seeCreates/updates issues for broken documentation links found
Requires:
Python package with a
pyproject.tomlfiledocsdependency groupSphinx configuration file at
docs/conf.py
Skipped for:
Pull requests
prepare-releasebranchPost-release version bump commits
💔 Check broken links (
check-broken-links)Checks for broken links in documentation using
lycheeCreates/updates issues for broken links found
Requires:
Documentation files (
**/*.{markdown,mdown,mkdn,mdwn,mkd,md,mdtxt,mdtext,mdx,rst,tex}) in the repository
Skipped for:
All PRs (only runs on push to main)
prepare-releasebranchPost-release bump commits
🏷️ .github/workflows/labels.yaml jobs¶
🔄 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]
📁 File-based PR labeller (
file-labeller)Automatically labels PRs based on changed file paths using
labelerSkipped for:
prepare-releasebranchBot-created PRs
📝 Content-based labeller (
content-labeller)Automatically labels issues and PRs based on title and body content using
issue-labelerSkipped for:
prepare-releasebranchBot-created PRs
💝 Tag sponsors (
sponsor-labeller)Adds a
💖 sponsorslabel to issues and PRs from sponsors using the GitHub GraphQL APISkipped for:
prepare-releasebranchBot-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, commit statuses permissions). Warns when the fork PR workflow approval policy is weaker thanfirst_time_contributors. Warns about missingVIRUSTOTAL_API_KEYwhen Nuitka binary compilation is active.Requires:
Python package (with a
pyproject.tomlfile)
🔤 Lint types (
lint-types)Type-checks Python code using
mypyRequires:
Python files (
**/*.{py,pyi,pyw,pyx,ipynb}) in the repository
Skipped for:
prepare-releasebranch
📄 Lint YAML (
lint-yaml)Lints YAML files using
yamllintRequires:
YAML files (
**/*.{yaml,yml}) in the repository
Skipped for:
prepare-releasebranchBot-created PRs
🐚 Lint Zsh (
lint-zsh)Syntax-checks Zsh scripts using
zsh --no-execRequires:
Zsh files (
**/*.zsh) in the repository
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¶
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.
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.
🧯 Detect squash merge (
detect-squash-merge)Detects squash-merged release PRs, opens a GitHub issue to notify the maintainer, and fails the workflow
The 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
📦 Build package (
build-package)Builds Python wheel and sdist packages using
uv buildRequires:
Python package with a
pyproject.tomlfile
✅ Compile binaries (
compile-binaries)Compiles standalone binaries using
Nuitkafor Linux/macOS/Windows onx64/arm64On release pushes, each binary generates an attestation and uploads itself to the GitHub release as its build completes
Requires:
Python package with CLI entry points defined in
pyproject.toml
Skipped if
[tool.repomatic] nuitka = 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-deps-graph(dependency graph docs)
✅ Test binaries (
test-binaries)Runs test plans against compiled binaries using
repomatic test-planRequires:
Compiled binaries from
compile-binariesjobTest plan file (default:
./tests/cli-test-plan.yaml)
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
🐍 Publish to PyPI (
publish-pypi)Uploads packages to PyPI with attestations using
uv publishRequires:
PYPI_TOKENsecretBuilt packages from
build-packagejob
🐙 Create release draft (
create-release)Creates a GitHub release draft with the Python package attached using
gh release createBinaries are attached independently by each
compile-binariesmatrix entry as they complete (uploading to drafts is allowed)Requires:
Successful
create-tagjob
🎉 Publish release (
publish-release)Publishes the draft GitHub release after all assets have been uploaded
Supports GitHub immutable releases: once published, tags and assets are locked
Uses
always()so it runs even whencompile-binariesis skipped (non-binary projects) or partially fails (unstable platforms)Requires:
Successful
create-releasejob (draft must exist)
🛡️ VirusTotal scan (
scan-virustotal)Uploads compiled binaries (
.binand.exe) to VirusTotal viarepomatic scan-virustotal, then appends analysis links to the GitHub release body. A second step polls for analysis completion and replaces the table with detection statistics (flagged / totalengine counts)Seeds AV vendor databases to reduce false positive detections for downstream distributors (Chocolatey, Scoop, etc.)
Requires:
VIRUSTOTAL_API_KEYrepository secret (free API key)Successful
publish-releasejob
Skipped if:
VIRUSTOTAL_API_KEYsecret is not configuredpublish-releasejob did not succeed
🔄 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 (e.g.
v6.1.1.dev0) is force-updated to point to the latestmaincommitAutomatically cleaned up when a real release is created
Runs on: Non-release pushes to
mainonlyRequires:
build-packageandcompile-binariesjobs (usesalways()for resilience)
Skipped if:
dev-release.sync = falsein[tool.repomatic]
🆕 .github/workflows/renovate.yaml jobs¶
🚚 Migrate to Renovate (
migrate-to-renovate)Automatically migrates from Dependabot to Renovate by creating a PR that:
Exports
renovate.json5configuration file (if missing)Removes
.github/dependabot.yamlor.github/dependabot.yml(if present)
PR body includes a prerequisites status table showing:
What this PR fixes (config file creation, Dependabot removal)
What needs manual action (security updates settings, token permissions)
Links to relevant settings pages for easy access
Uses
peter-evans/create-pull-requestfor consistent PR creationSkipped if:
No changes needed (
renovate.json5already exists and no Dependabot config is present)
🆕 Renovate (
renovate)Materializes the bundled default
renovate.json5at runtime when the file is absent, so downstream repos can safely remove unmodified copies viaclean-unmodified-configsValidates prerequisites before running (fails if not met):
No Dependabot config file present
Dependabot security updates disabled
Runs self-hosted Renovate to update dependencies
Creates PRs for outdated dependencies with stabilization periods
Handles security vulnerabilities via
vulnerabilityAlertsRequires:
REPOMATIC_PATsecret with Dependabot alerts permission
🔬 .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,3.14t,3.15)Installs all optional extras (
--all-extras) to catch incompatibilities between optional dependency groupsRuns
pytestwith coverage reporting to CodecovRuns self-tests against the CLI test plan
Job names prefixed with ✅ (stable) or ⁉️ (unstable, e.g., unreleased Python versions)
🖥️ 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/update-checksums.yaml jobs¶
🔄 Update checksums (
update-checksums)Workaround for renovatebot/renovate#42263: Renovate’s
postUpgradeTaskssilently drops file changes when the task modifies the same file the regex manager already updatedTriggers when Renovate pushes a version bump to
repomatic/tool_runner.pyon arenovate/**branchDownloads each binary tool at its new version, computes the SHA-256, and commits the corrected checksums to the PR branch
Uses
REPOMATIC_PATfor the push so the fix commit re-triggers CI checks on the PRSafe against infinite loops: a second trigger finds all checksums already correct and exits without pushing
Source-repo only: not bundled for downstream repos (they have no tool registry)
🔕 .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 actingRequires:
REPOMATIC_NOTIFICATIONS_PATsecret (skips silently when not configured)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
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.
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.
Dogfooding¶
This repository uses these workflows for itself.
Dependency strategy¶
All dependencies are pinned to specific versions for stability, reproducibility, and security.
Pinning mechanisms¶
Mechanism |
What it pins |
How it’s updated |
|---|---|---|
|
Project dependencies |
Renovate PRs |
Hard-coded versions in YAML |
GitHub Actions, npm, Python |
Renovate PRs |
|
Transitive dependencies |
Time-based window |
Tagged workflow URLs |
Remote workflow references |
Release process |
|
CLI from local source |
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 eslint@9.39.1 # Pinned npm package
GitHub Actions are pinned to full commit SHAs via Renovate’s helpers:pinGitHubActionDigestsToSemver preset, which rewrites every uses: ref to a 40-character SHA with the semver tag preserved as a trailing comment. A custom regex manager handles npm packages pinned inline in workflow files.
Renovate cooldowns¶
To avoid update fatigue, and mitigate supply chain attacks, renovate.json5 uses stabilization periods (with prime numbers to stagger updates).
This ensures major updates get more scrutiny while patches flow through faster.
uv.lock and --exclude-newer¶
The uv.lock file pins all project dependencies, and Renovate keeps it in sync.
The --exclude-newer flag ignores packages released in the last 7 days, providing a buffer against freshly-published broken releases.
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 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, and freezes CLI invocations to a PyPI 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, and bumps the version to the next patch.
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 use --from . repomatic to run the CLI from local source (dogfooding). The freeze commit pins these to 'repomatic==X.Y.Z' so tagged releases reference a published package. The unfreeze commit reverts them for the next development 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. 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).
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.