repomatic package

Expose package-wide elements.

Subpackages

Submodules

repomatic.binaries_page module

Generate the binaries catalog: a CSV data file and its docs/binaries.md page.

The catalog inventories every compiled binary the repository ever released, one CSV row per binary: version (linking to the GitHub release), platform target (linking to the direct download), release date, and the VirusTotal detection snapshot (linking to the live analysis). It gives alpha and beta testers a single place to grab binaries from, and the maintainer an overview of how antivirus engines treat each release.

The data lives in docs/assets/binaries.csv, regenerated wholesale on every release from the GitHub Releases API (the single source of truth for published assets) and the JSON scan history maintained by scan-virustotal. The Markdown page renders it through a single csv-table directive and is otherwise static: it is created once from PAGE_TEMPLATE and only its marker-delimited region (the detection trend chart) is rewritten afterwards, so the intro and section prose stay hand-editable per repository.

Note

On the documentation site, the table is searchable and sortable client-side via the sphinx-datatables extension, which activates on the sphinx-datatable CSS class. The extension is optional: without it the csv-table directive still renders a plain table, and on GitHub the CSV file itself gets the built-in searchable grid viewer.

Note

Development builds are only linked, not cataloged: the rolling dev pre-release is refreshed on every push to the default branch, so any row frozen into the CSV would be stale within hours, while the workflow run artifacts behind the link always are the current builds.

repomatic.binaries_page.BINARY_ASSET_SUFFIXES = ('.bin', '.exe')

File extensions identifying compiled binaries among release assets.

Same set the scan-virustotal command uploads and the release workflow downloads (--pattern flags in _release-engine.yaml).

repomatic.binaries_page.CSV_HEADERS = ('Version', 'Platform', 'Released', 'VirusTotal')

Column headers of the binaries CSV.

Deliberately compact: the version cell carries the link to the GitHub release, the platform cell the direct binary download, and the VirusTotal cell the analysis link, so no column holds a bare URL, filename, or 64-character checksum.

repomatic.binaries_page.FLAGGED_DANGER_PCT = 10

Flagged-verdict share (percent) at which the catalog shield turns red.

Below it, a flagged binary is the routine Nuitka false-positive tail worth a warning tint; from one engine in ten upward, the release deserves a false-positive submission round (see the /av-false-positive skill).

repomatic.binaries_page.PAGE_END_MARKER = '<!-- binaries-end -->'

Closing marker of the generated chart region in the binaries page.

repomatic.binaries_page.PAGE_START_MARKER = '<!-- binaries-start -->'

Opening marker of the generated chart region in the binaries page.

repomatic.binaries_page.PAGE_TEMPLATE

Initial page content, used when the page does not exist yet.

The :repo_url: placeholder is substituted with str.replace (not str.format, which would choke on the csv-table directive’s braces). Everything outside the marker pair is written once and never touched again: repositories can reword the prose without fighting the generator.

repomatic.binaries_page.render_chart_section(records)[source]

Render the detection trend across releases as a Chart.js timeline.

Plots the share of antivirus engine verdicts flagging each release’s binaries (all platforms aggregated), using the at-release snapshot of every file, on a true time axis: spacing reflects the actual gaps between releases. Points reuse the catalog shields’ color language, read at view time from sphinx-design’s CSS variables so they match the theme exactly (with hardcoded fallbacks). The data is embedded in the page rather than fetched, so the chart also works on file:// previews; only the Chart.js bundle comes from its CDN, mirroring how the table’s DataTables assets load.

Return type:

str

Returns:

A ## VirusTotal detections section with a raw HTML fence, or an empty string when fewer than two releases have records (a one-point trend is not a trend).

repomatic.binaries_page.render_binaries_csv(repo_slug, releases, records)[source]

Render the catalog data as CSV, one row per released binary.

Rows cover every published release carrying compiled binaries, ordered by descending version then filename. Cells hold Markdown links (parsed by MyST inside the csv-table directive): the version to the GitHub release, the platform to the binary download, and the VirusTotal cell to the file’s analysis. The VirusTotal cell renders the at-release snapshot as a green check when no engine flags the binary, as the flagged-verdict share (tinted by FLAGGED_DANGER_PCT) otherwise, and as a bare analysis link when no snapshot exists. Assets without a digest get an empty VirusTotal cell.

Caution

The version and platform cells decorate their links with sphinx-design’s octicon role, so the rendering repository needs sphinx-design in its documentation build (already true across this ecosystem’s docs stacks).

Parameters:
Return type:

str

Returns:

The full CSV content, header row included.

repomatic.binaries_page.update_binaries_csv(csv_path, content)[source]

Write the catalog CSV, creating parent directories as needed.

Parameters:
Return type:

bool

Returns:

True when the file was created or its content changed.

repomatic.binaries_page.update_binaries_page(page_path, chart_section, repo_slug)[source]

Create the binaries page if missing and refresh its chart region.

A missing page is created (with parent directories) from PAGE_TEMPLATE. On an existing page only the region between PAGE_START_MARKER and PAGE_END_MARKER is replaced, leaving all surrounding prose untouched.

Parameters:
  • page_path (Path) – Path to the Markdown page.

  • chart_section (str) – Rendered chart from render_chart_section(), or an empty string to leave the region empty.

  • repo_slug (str) – Repository in owner/repo form, interpolated into the template on first creation.

Return type:

bool

Returns:

True when the file was created or its content changed.

Raises:

ValueError – When the page exists but lacks the markers. Loud on purpose: a page not written by this generator must never be overwritten.

repomatic.binary module

Binary build targets and verification utilities.

Defines the Nuitka compilation targets for all supported platforms and provides binary architecture verification using exiftool.

repomatic.binary.NUITKA_BUILD_TARGETS = {'linux-arm64': {'arch': 'arm64', 'extension': 'bin', 'os': 'ubuntu-24.04-arm', 'platform_id': 'linux'}, 'linux-x64': {'arch': 'x64', 'extension': 'bin', 'os': 'ubuntu-24.04', 'platform_id': 'linux'}, 'macos-arm64': {'arch': 'arm64', 'extension': 'bin', 'os': 'macos-26', 'platform_id': 'macos'}, 'macos-x64': {'arch': 'x64', 'extension': 'bin', 'os': 'macos-26-intel', 'platform_id': 'macos'}, 'windows-arm64': {'arch': 'arm64', 'extension': 'exe', 'os': 'windows-11-arm', 'platform_id': 'windows'}, 'windows-x64': {'arch': 'x64', 'extension': 'exe', 'os': 'windows-2025', 'platform_id': 'windows'}}

List of GitHub-hosted runners used for Nuitka builds.

The key of the dictionary is the target name, which is used as a short name for user-friendlyness. As such, it is used to name the compiled binary.

Values are dictionaries with the following keys:

  • os: Operating system name, as used in GitHub-hosted runners.

    Hint

    We choose to run the compilation only on the latest supported version of each OS, for each architecture. Note that macOS and Windows do not have the latest version available for each architecture.

  • platform_id: Platform identifier, as defined by Extra Platform.

  • arch: Architecture identifier.

    Note

    Maybe we should just adopt target triple.

  • extension: File extension of the compiled binary.

repomatic.binary.FLAT_BUILD_TARGETS = [{'arch': 'arm64', 'extension': 'bin', 'os': 'ubuntu-24.04-arm', 'platform_id': 'linux', 'target': 'linux-arm64'}, {'arch': 'x64', 'extension': 'bin', 'os': 'ubuntu-24.04', 'platform_id': 'linux', 'target': 'linux-x64'}, {'arch': 'arm64', 'extension': 'bin', 'os': 'macos-26', 'platform_id': 'macos', 'target': 'macos-arm64'}, {'arch': 'x64', 'extension': 'bin', 'os': 'macos-26-intel', 'platform_id': 'macos', 'target': 'macos-x64'}, {'arch': 'arm64', 'extension': 'exe', 'os': 'windows-11-arm', 'platform_id': 'windows', 'target': 'windows-arm64'}, {'arch': 'x64', 'extension': 'exe', 'os': 'windows-2025', 'platform_id': 'windows', 'target': 'windows-x64'}]

List of build targets in a flat format, suitable for matrix inclusion.

repomatic.binary.BINARY_AFFECTING_PATHS: Final[tuple[str, ...]] = ('.github/workflows/_release-engine.yaml', '.github/workflows/release.yaml', 'pyproject.toml', 'tests/', 'uv.lock')

Path prefixes that always affect compiled binaries, regardless of the project.

Project-specific source directories (derived from [project.scripts] in pyproject.toml) are added dynamically by binary_affecting_paths.

The release workflow entries cover both layouts: upstream keeps the _release-engine.yaml lane (which defines the Nuitka compile and binary self-test jobs) in-repo, while downstream repos call the engine cross-repo from their generated release.yaml, so a pin bump there rightly triggers a rebuild.

repomatic.binary.SKIP_BINARY_BUILD_BRANCHES: Final[frozenset[str]] = frozenset({'format-images', 'format-json', 'format-markdown', 'format-shell', 'sync-gitignore', 'sync-mailmap', 'update-deps-graph'})

Autofix branches whose changes cannot affect compiled binaries.

Members are PR branch names produced by autofix jobs that touch only repository housekeeping (.mailmap, .gitignore, JSON, Markdown, images, shell scripts, dependency graph). The binary output is unchanged, so skip_binary_build returns True when the PR head branch matches a member, saving an expensive Nuitka compilation.

Note

This set is intentionally disjoint from VERSION_BUMP_BRANCHES: version-bump branches do change binaries (they rewrite the version string baked into the build), so they belong to a different policy.

repomatic.binary.VERSION_BUMP_BRANCHES: Final[frozenset[str]] = frozenset({'major-version-increment', 'minor-version-increment', 'prepare-release'})

PR branches that carry only automated version-bump and lockfile churn.

Members are bot-authored draft PRs created by the bump-version and prepare-release jobs in changelog.yaml. Their working tree is byte-identical to main except for the version string in pyproject.toml, **/__init__.py, changelog.md, citation.cff, and uv.lock. Heavy PR-time workflows (tests.yaml, lint.yaml, labels.yaml) list these branches under pull_request.branches-ignore so the matrix doesn’t burn CI minutes for a guaranteed-passing run.

Note

These branches are not binary-neutral: the rewritten version string is baked into the Nuitka binary, so they are deliberately absent from SKIP_BINARY_BUILD_BRANCHES. Post-merge release artifacts on main are still produced.

repomatic.binary.MANUAL_VERSION_BUMP_COMMIT_PREFIXES: Final[frozenset[str]] = frozenset({'Bump major version to ', 'Bump minor version to '})

Head-commit-message prefixes for user-initiated version bumps.

Members are the bump-version job’s Bump $part version to \``v$version\ commit messages (rendered from the bump-version template’s title). These merges land as a single commit on main and carry no other payload, so workflows can short-circuit on them safely.

The release-cycle prefix [changelog] Post-release bump `` is deliberately absent from this set because the ``prepare-release merge bundles the post-release-bump commit with the actual release commit ([changelog] Release vX.Y.Z) in a single push. Workflows that gate on the head commit message (tests.yaml, release.yaml::compile-binaries) must run on those pushes to test the release commit and build its binary — so they consult only this subset.

repomatic.binary.VERSION_BUMP_COMMIT_PREFIXES: Final[frozenset[str]] = frozenset({'Bump major version to ', 'Bump minor version to ', '[changelog] Post-release bump '})

Full set of head-commit-message prefixes that mark a version-bump push.

Combines MANUAL_VERSION_BUMP_COMMIT_PREFIXES with the [changelog] Post-release bump `` prefix produced by ``prepare-release merges. Workflows without a release-artifact dependency (lint.yaml, labels.yaml) use this full set in their metadata job’s if: gate so the entire job graph skips for any push generated by the version-bump PR family. Workflows that do produce release artifacts on the same push use MANUAL_VERSION_BUMP_COMMIT_PREFIXES instead.

repomatic.binary.BINARY_ARCH_MAPPINGS: Final[dict[str, tuple[str, str]]] = {'linux-arm64': ('CPUType', 'Arm 64-bits'), 'linux-x64': ('CPUType', 'AMD x86-64'), 'macos-arm64': ('CPUType', 'ARM 64-bit'), 'macos-x64': ('CPUType', 'x86 64-bit'), 'windows-arm64': ('MachineType', 'ARM64'), 'windows-x64': ('MachineType', 'AMD64')}

Mapping of build targets to (exiftool_field, expected_substring) tuples.

ABI signatures reported by file(1) for each compiled binary:

  • linux-arm64: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped

  • linux-x64: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, stripped

  • macos-arm64: Mach-O 64-bit executable arm64

  • macos-x64: Mach-O 64-bit executable x86_64

  • windows-arm64: PE32+ executable (console) Aarch64, for MS Windows

  • windows-x64: PE32+ executable (console) x86-64, for MS Windows

repomatic.binary.get_exiftool_command()[source]

Return the platform-appropriate exiftool command.

On Windows, exiftool is installed as exiftool.exe.

Return type:

str

repomatic.binary.run_exiftool(binary_path)[source]

Run exiftool on a binary and return parsed JSON output.

Parameters:

binary_path (Path) – Path to the binary file.

Return type:

dict[str, str]

Returns:

Dictionary of exiftool metadata.

Raises:
repomatic.binary.verify_binary_arch(target, binary_path)[source]

Verify that a binary matches the expected architecture for a target.

Parameters:
  • target (str) – Build target (e.g., ‘linux-arm64’, ‘macos-x64’).

  • binary_path (Path) – Path to the binary file.

Raises:
Return type:

None

repomatic.cache module

Global cache for downloaded tool executables, HTTP API responses, and generated tool configurations.

Three cache subtrees under the user-level cache directory:

Binary cache (bin/): platform-specific tool executables, keyed by {tool}/{version}/{platform}/{executable}. Each cached binary has a .sha256 sidecar written after a verified archive download. Cache hits verify the binary against this sidecar to detect local tampering.

HTTP response cache (http/): JSON API responses from PyPI and GitHub, keyed by {namespace}/{key}.json. Freshness is controlled by a per-caller TTL (seconds); stale entries remain on disk until auto-purge removes them.

Config cache (config/): generated tool configuration files, keyed by {tool}/{filename}. Overwritten on every invocation from the current [tool.X] section in pyproject.toml or bundled defaults. Passed to tools via explicit --config flags so repomatic never writes to the user’s repository.

Note

The cache module is intentionally a pure storage layer. It does not know about checksums, registries, API semantics, or tool specifications. All trust and freshness decisions belong to the caller.

class repomatic.cache.CacheEntry(tool, version, platform, executable, size, path, mtime)[source]

Bases: object

A single cached binary with its metadata.

tool: str

Tool name (registry key).

version: str

Pinned version string.

platform: str

Platform key (e.g., linux-x64, macos-arm64).

executable: str

Executable filename.

size: int

File size in bytes.

path: Path

Absolute path to the cached binary.

mtime: float

File modification time (seconds since epoch).

class repomatic.cache.HttpCacheEntry(namespace, key, size, path, mtime)[source]

Bases: object

A single cached HTTP response with its metadata.

namespace: str

Cache namespace (e.g., pypi, github-releases).

key: str

Cache key within the namespace (e.g., requests, astral-sh/ruff).

size: int

File size in bytes.

path: Path

Absolute path to the cached response file.

mtime: float

File modification time (seconds since epoch).

class repomatic.cache.ConfigCacheEntry(tool, filename, size, path, mtime)[source]

Bases: object

A single cached tool configuration file with its metadata.

tool: str

Tool name (registry key).

filename: str

Config filename (e.g., yamllint.yaml, biome.json).

size: int

File size in bytes.

path: Path

Absolute path to the cached config file.

mtime: float

File modification time (seconds since epoch).

repomatic.cache.cache_dir()[source]

Resolve the cache root directory.

Precedence (highest to lowest):

  1. REPOMATIC_CACHE_DIR environment variable.

  2. cache.dir in [tool.repomatic].

  3. Platform-specific default.

Return type:

Path

Returns:

Absolute path to the cache root (may not exist yet).

repomatic.cache.cached_binary_path(name, version, platform_key, executable)[source]

Construct the cache path for a binary (does not check existence).

Parameters:
  • name (str) – Tool name.

  • version (str) – Pinned version.

  • platform_key (str) – Platform key (e.g., linux-x64).

  • executable (str) – Executable filename.

Return type:

Path

Returns:

Absolute path where the binary would be cached.

repomatic.cache.get_cached_binary(name, version, platform_key, executable)[source]

Return the cached binary path if it exists and is executable.

Does not verify the checksum. The caller is responsible for integrity checks since it owns the checksum value and the skip_checksum flag.

Parameters:
  • name (str) – Tool name.

  • version (str) – Pinned version.

  • platform_key (str) – Platform key.

  • executable (str) – Executable filename.

Return type:

Path | None

Returns:

Path to the cached binary, or None if not cached.

repomatic.cache.store_binary(name, version, platform_key, source)[source]

Copy an extracted binary into the cache atomically.

Writes to a temporary file in the target directory, then renames to the final name. This is atomic on POSIX (same-filesystem rename) and safe on Windows (Path.replace overwrites atomically).

Triggers auto_purge() after a successful store.

Parameters:
  • name (str) – Tool name.

  • version (str) – Pinned version.

  • platform_key (str) – Platform key.

  • source (Path) – Path to the extracted binary to cache.

Return type:

Path

Returns:

Path to the cached binary.

repomatic.cache.cache_info()[source]

List all cached binaries.

Return type:

list[CacheEntry]

Returns:

List of CacheEntry instances, sorted by tool name then version.

repomatic.cache.clear_cache(tool=None, max_age_days=None)[source]

Remove cached binaries.

Parameters:
  • tool (str | None) – If set, only remove entries for this tool. Otherwise remove all cached binaries.

  • max_age_days (int | None) – If set, only remove entries with mtime older than this many days. Otherwise remove all matching entries.

Return type:

tuple[int, int]

Returns:

Tuple of (files_deleted, bytes_freed).

repomatic.cache.get_cached_response(namespace, key, max_age_seconds)[source]

Return a cached HTTP response if it exists and is fresh.

Parameters:
  • namespace (str) – Cache namespace (e.g., pypi, github-releases).

  • key (str) – Cache key, may contain / for nested paths.

  • max_age_seconds (int) – Maximum age in seconds. Entries with mtime older than this are considered stale and ignored. <= 0 disables the cache (always returns None).

Return type:

bytes | None

Returns:

Raw cached response bytes, or None if not cached or stale.

repomatic.cache.store_response(namespace, key, data)[source]

Store an HTTP response in the cache atomically.

Uses the same write-to-temp-then-rename pattern as store_binary(). Triggers auto_purge() after a successful store.

Parameters:
  • namespace (str) – Cache namespace.

  • key (str) – Cache key, may contain / for nested paths.

  • data (bytes) – Raw response bytes to cache.

Return type:

Path | None

Returns:

Path to the cached response file, or None if the write failed (permissions, read-only filesystem, sandbox restrictions).

repomatic.cache.http_cache_info()[source]

List all cached HTTP responses.

Return type:

list[HttpCacheEntry]

Returns:

List of HttpCacheEntry instances, sorted by namespace then key.

repomatic.cache.clear_http_cache(namespace=None, max_age_days=None)[source]

Remove cached HTTP responses.

Parameters:
  • namespace (str | None) – If set, only remove entries in this namespace. Otherwise remove all cached responses.

  • max_age_days (int | None) – If set, only remove entries with mtime older than this many days. Otherwise remove all matching entries.

Return type:

tuple[int, int]

Returns:

Tuple of (files_deleted, bytes_freed).

repomatic.cache.store_config(tool_name, filename, content)[source]

Store a generated tool config in the cache atomically.

Uses the same write-to-temp-then-rename pattern as store_response(). Does not trigger auto_purge(): config files are tiny and overwritten on every invocation, so age-based pruning is unnecessary.

Parameters:
  • tool_name (str) – Tool name (registry key).

  • filename (str) – Config filename (e.g., yamllint.yaml).

  • content (str) – Config file content as text.

Return type:

Path | None

Returns:

Path to the cached config file, or None if the write failed (permissions, read-only filesystem, sandbox restrictions).

repomatic.cache.config_cache_info()[source]

List all cached tool configurations.

Return type:

list[ConfigCacheEntry]

Returns:

List of ConfigCacheEntry instances, sorted by tool name.

repomatic.cache.clear_config_cache(tool=None)[source]

Remove cached tool configurations.

Parameters:

tool (str | None) – If set, only remove entries for this tool. Otherwise remove all cached configurations.

Return type:

tuple[int, int]

Returns:

Tuple of (files_deleted, bytes_freed).

repomatic.cache.auto_purge()[source]

Remove cached entries older than the configured TTL.

Called automatically after store_binary() and store_response(). Purges both binary and HTTP cache entries. Resolves the TTL from REPOMATIC_CACHE_MAX_AGE env var, then cache.max-age in [tool.repomatic], then the CacheConfig.max_age field default. Set to 0 to disable.

Return type:

None

repomatic.changelog module

Changelog parsing, updating, and release lifecycle management.

This module is the single source of truth for all changelog management decisions and operations. It handles two phases of the release cycle:

Post-release (unfreeze)Changelog.update():

Decomposes the latest release section via Changelog.decompose_version(), transforms the elements into an unreleased entry (date → unreleased, comparison URL → ...main, body → development warning), renders via the release-notes template, and prepends the result to the changelog.

Release preparation (freeze)Changelog.freeze():

Decomposes the current unreleased section, sets the release date, freezes the comparison URL to ...vX.Y.Z, clears the development warning, renders via the release-notes template, and replaces the section in place.

Both operations follow the same decompose → modify → render → replace pattern, with the release-notes.md template as the single source of truth for section layout. Both are idempotent: re-running them produces the same result. This is critical for CI workflows that may be retried.

Note

This is a custom implementation. After evaluating all major alternatives — towncrier, commitizen, python-semantic-release, generate-changelog, release-please, scriv, and git-changelog (see issue #94) — none were found to cover even half of the requirements.

Why not use an off-the-shelf tool?

Existing tools fall into two camps, neither of which fits:

Commit-driven tools (python-semantic-release, commitizen, generate-changelog, release-please) auto-generate changelogs from Git history. This conflicts with the project’s philosophy of hand-curated changelogs: entries are written for users, consolidated by hand, and summarize only changes worth knowing about. Auto-generated logs from developer commits are too noisy and don’t account for back-and-forth during development.

Fragment-driven tools (towncrier, scriv) avoid merge conflicts by using per-change files, but handle none of the release orchestration: comparison URL management, GFM warning lifecycle, workflow action reference freezing, or the two-commit freeze/unfreeze release cycle. The multiplication of files across the repo adds complexity, and there is no 1:1 mapping between fragments and changelog entries.

Specific gaps across all evaluated tools:

  • No comparison URL management. None generate GitHub v1.0.0...v1.1.0 diff links, or update them from ...main to ...vX.Y.Z at release time.

  • No unreleased section lifecycle. None manage the [!WARNING] GFM alert warning that the version is under active development, inserting it post-release and removing it at release time.

  • No workflow action reference freezing. None handle the freeze/unfreeze cycle for @main@vX.Y.Z references in workflow files.

  • No two-commit release workflow. None support the freeze commit ([changelog] Release vX.Y.Z) plus unfreeze commit ([changelog] Post-release bump) pattern that changelog.yaml uses.

  • No citation file integration. None update citation.cff release dates.

  • No version bump eligibility checks. None prevent double version increments by comparing the current version against the latest Git tag with a commit-message fallback.

The custom implementation in this module is tightly integrated with the release workflow. Adopting any external tool would require keeping most of this code and adding a new dependency — more complexity, not less.

repomatic.changelog.CHANGELOG_HEADER = '# Changelog\n'

Default changelog header for empty changelogs.

repomatic.changelog.SECTION_START = '##'

Markdown heading level for changelog version sections.

repomatic.changelog.DATE_PATTERN = re.compile('\\d{4}\\-\\d{2}\\-\\d{2}')

Pattern matching release dates in YYYY-MM-DD format.

repomatic.changelog.VERSION_COMPARE_PATTERN = re.compile('v(\\d+\\.\\d+\\.\\d+)\\.\\.\\.v(\\d+\\.\\d+\\.\\d+)')

Pattern matching GitHub comparison URLs like v1.0.0...v1.0.1.

repomatic.changelog.RELEASED_VERSION_PATTERN = re.compile('^##\\s*\\[`?(\\d+\\.\\d+\\.\\d+)`?\\s+\\((\\d{4}-\\d{2}-\\d{2})\\)\\]', re.MULTILINE)

Pattern matching released version headings with dates.

Captures version and date from headings like ## `5.9.1 (2026-02-14) <...>`_. Skips unreleased versions which use (unreleased) instead of a date. Backticks around the version are optional.

repomatic.changelog.HEADING_PARTS_PATTERN = re.compile('^##\\s*\\[`?(?P<version>\\d+\\.\\d+\\.\\d+(?:\\.\\w+)?)`?\\s+\\((?P<date>[^)]+)\\)\\]\\((?P<url>[^)]+)\\)', re.MULTILINE)

Pattern extracting version, date/label, and URL from a heading.

Used by Changelog.decompose_version() to populate the heading fields of VersionElements.

repomatic.changelog.AVAILABLE_VERB = 'is available on'

Verb phrase for versions present on a platform.

repomatic.changelog.FIRST_AVAILABLE_VERB = 'is the *first version* available on'

Verb phrase for the inaugural release on a platform.

repomatic.changelog.GITHUB_LABEL = '🐙 GitHub'

Display label for GitHub releases in admonitions.

repomatic.changelog.GITHUB_RELEASE_URL = '{repo_url}/releases/tag/v{version}'

GitHub release page URL for a specific version.

repomatic.changelog.NOT_AVAILABLE_VERB = 'is **not available** on'

Verb phrase for versions missing from a platform.

repomatic.changelog.YANKED_DEDUP_MARKER = 'yanked from PyPI'

Dedup marker for the yanked admonition to prevent duplicate insertion.

repomatic.changelog.EMPTY_PYPI_SANITY_THRESHOLD = 3

Minimum number of existing PyPI links in the changelog above which an empty PyPI lookup is treated as a transient failure rather than a genuine “package has no releases” state.

Note

Two layers of ambiguity make this threshold necessary:

  1. repomatic.pypi._fetch_json() returns None on every failure mode (HTTP 4xx/5xx, network error, timeout, JSON parse error), collapsing “package not on PyPI” and “transient API failure” into the same empty result.

  2. Even when the HTTP status is preserved, a 404 from /pypi/<name>/json is not authoritative: Warehouse 404s registered projects that have no published releases, and registered packages can appear in the simple / list_packages indexes while still 404’ing on the JSON endpoint. See pypi/warehouse#1388 and pypi/warehouse#9536.

The threshold guards against a transient failure silently stripping every PyPI link from the changelog. Re-runs of lint-changelog --fix against a healthy API restore the file.

class repomatic.changelog.VersionElements(compare_url='', date='', version='', availability_admonition='', changes='', development_warning='', editorial_admonition='', yanked_admonition='')[source]

Bases: object

Discrete building blocks of a changelog version section.

Each field is a pre-formatted markdown block (or empty string when absent). Templates compose these elements into the final section layout. Empty variables produce empty strings, which render_template’s 3+ newline collapsing handles gracefully.

Heading fields (compare_url, date, version) are populated by Changelog.decompose_version() and used by the release-notes template to render the ## heading line. Body fields are unchanged.

compare_url: str = ''

GitHub comparison URL from the heading (e.g. repo/compare/vA...vB).

date: str = ''

Release date or unreleased label from the heading.

version: str = ''

Version string extracted from the heading (e.g. 1.2.3).

availability_admonition: str = ''

[!NOTE] or [!WARNING] block for platform availability.

changes: str = ''

Hand-written changelog entries (bullet points, prose).

development_warning: str = ''

[!WARNING] block for unreleased versions under active development.

editorial_admonition: str = ''

Hand-written GFM alert blocks not matching auto-generated patterns.

Multiple blocks are joined with double newlines.

yanked_admonition: str = ''

[!CAUTION] block for releases yanked from PyPI.

class repomatic.changelog.Changelog(initial_changelog=None, current_version=None)[source]

Bases: object

Helpers to manipulate changelog files written in Markdown.

update()[source]

Add a new unreleased entry at the top of the changelog.

Decomposes the current version section, transforms it into an unreleased entry (date set to unreleased, comparison URL retargeted to main, body replaced with the development warning), and prepends it to the changelog.

Idempotent: returns the current content unchanged if an unreleased entry already exists.

Return type:

str

freeze(release_date=None, default_branch='main')[source]

Freeze the current unreleased section for release.

Decomposes the current version section, sets the release date, freezes the comparison URL to the release tag, clears the development warning, and re-renders via the release-notes template.

Parameters:
  • release_date (str | None) – Date in YYYY-MM-DD format. Defaults to today (UTC).

  • default_branch (str) – Branch name for comparison URL.

Return type:

bool

Returns:

True if the content was modified.

classmethod freeze_file(path, version, release_date=None, default_branch='main')[source]

Freeze a changelog file in place.

Reads the file, applies all freeze operations via freeze(), and writes the result back.

Parameters:
  • path (Path) – Path to the changelog file.

  • version (str) – Current version string.

  • release_date (str | None) – Date in YYYY-MM-DD format. Defaults to today (UTC).

  • default_branch (str) – Branch name for comparison URL.

Return type:

bool

Returns:

True if the file was modified.

extract_repo_url()[source]

Extract the repository URL from changelog comparison links.

Parses the first ## `... <<repo_url>/compare/...>`_ heading and returns the base repository URL (e.g. https://github.com/user/repo).

Return type:

str

Returns:

The repository URL, or empty string if not found.

extract_all_releases()[source]

Extract all released versions and their dates from the changelog.

Scans for headings matching ## `X.Y.Z (YYYY-MM-DD) <...>`_. Unreleased versions (with (unreleased)) are skipped.

Return type:

list[tuple[str, str]]

Returns:

List of (version, date) tuples ordered as they appear in the changelog (newest first).

extract_all_version_headings()[source]

Extract all version strings from ## headings.

Includes both released and unreleased versions, so the caller can avoid false-positive orphan detection for the current development version.

Return type:

set[str]

Returns:

Set of version strings found in headings.

insert_version_section(version, date, repo_url, all_versions)[source]

Insert a placeholder section for a missing version.

The section is placed at the correct position in descending version order. The comparison URL points from the next-lower version to this one. After insertion, the next-higher version’s comparison URL base is updated to reference this version, keeping the timeline coherent.

Idempotent: returns False if the version heading already exists.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • date (str) – Release date in YYYY-MM-DD format.

  • repo_url (str) – Repository URL for comparison links.

  • all_versions (list[str]) – All known versions sorted descending.

Return type:

bool

Returns:

True if the content was modified.

update_comparison_base(version, new_base)[source]

Replace the base version in a version heading’s comparison URL.

Changes compare/vOLD...vX.Y.Z to compare/vNEW...vX.Y.Z in the heading for the given version.

Parameters:
  • version (str) – The version whose heading to update.

  • new_base (str) – New base version (without v prefix).

Return type:

bool

Returns:

True if the content was modified.

decompose_version(version)[source]

Decompose a version section into discrete elements.

Parses both the heading (version, date, URL) and the body (admonitions, changes).

Classifies each GFM alert block (consecutive > lines) as one of the auto-generated element types. Everything not classified as auto-generated is preserved as changes.

Parameters:

version (str) – Version string (e.g. 1.2.3).

Return type:

VersionElements

Returns:

A VersionElements with each field populated.

replace_section(version, new_section)[source]

Replace the entire section (heading + body) for a version.

Locates the version heading and replaces everything up to the next ## heading (or EOF) with new_section.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • new_section (str) – New section content including heading.

Return type:

bool

Returns:

True if the content was modified.

repomatic.changelog.build_release_admonition(version, *, pypi_url='', github_url='', first_on_all=False)[source]

Build a GFM release admonition with available distribution links.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • pypi_url (str) – PyPI project URL, or empty if not on PyPI.

  • github_url (str) – GitHub release URL, or empty if no release exists.

  • first_on_all (bool) – Whether every listed platform is a first appearance. When True, uses “is the first version available on” wording.

Return type:

str

Returns:

A > [!NOTE] admonition block, or empty string if neither URL is provided.

repomatic.changelog.build_unavailable_admonition(version, *, missing_pypi=False, missing_github=False)[source]

Build a GFM warning admonition for platforms missing a version.

Parameters:
  • version (str) – Version string (e.g. 1.2.3).

  • missing_pypi (bool) – Whether the version is missing from PyPI.

  • missing_github (bool) – Whether the version is missing from GitHub.

Return type:

str

Returns:

A > [!WARNING] admonition block, or empty string if neither platform is missing.

repomatic.changelog.split_changelog_bullets(changes)[source]

Split a version section’s change body into top-level bullet entries.

Each returned item is one entry: its - marker line plus any wrapped continuation lines and indented sub-bullets, joined with newlines. Blank lines and prose outside a bullet are dropped.

Parameters:

changes (str) – The hand-written body of a version section, as captured in VersionElements.changes.

Return type:

list[str]

Returns:

One string per top-level bullet, in document order.

repomatic.changelog.count_bullet_words(bullet)[source]

Count the words in a changelog bullet, ignoring list markers.

Leading -/* markers (on the entry and any nested sub-bullets) are stripped so they do not inflate the count; everything else, including inline code and link text, counts as written.

Return type:

int

repomatic.changelog.warn_on_long_bullets(changelog, threshold)[source]

Warn about over-long bullets in the unreleased section, non-fatally.

A changelog entry is a release note, not a commit message: one short sentence stating what changed. Canonical guideline: https://github.com/kdeldycke/repomatic/blob/main/claude.md#changelog-entry-length Each unreleased bullet longer than threshold words emits a logging.WARNING and a GitHub Actions warning annotation, without affecting the lint exit code.

Only the unreleased section is inspected. Released sections are immutable, so re-flagging historical entries on every run would be noise.

Parameters:
  • changelog (Changelog) – The parsed changelog to inspect.

  • threshold (int) – Word ceiling per bullet. 0 (or less) disables the check.

Return type:

None

repomatic.changelog.lint_changelog_dates(changelog_path, package=None, *, archive_path=None, fix=False, pypi_package_history=(), abandoned_versions=(), bullet_word_threshold=0)[source]

Verify that changelog release dates match canonical release dates.

Uses PyPI upload dates as the canonical reference when the project is published to PyPI. Falls back to git tag dates for projects not on PyPI.

Versions older than the first PyPI release are expected to be absent and logged at info level. Versions newer than the first PyPI release but missing from PyPI are unexpected and logged as warnings.

Also detects orphaned versions: versions that exist as git tags, GitHub releases, or PyPI packages but have no corresponding changelog entry. Orphans are logged as warnings and cause a non-zero exit code.

When fix is enabled, date mismatches are corrected in-place and admonitions are added to the changelog:

  • A [!NOTE] admonition listing available distribution links (PyPI, GitHub) for each version. Links are conditional: only sources where the version exists are included.

  • A [!WARNING] admonition listing platforms where the version is not available (missing from PyPI, GitHub, or both).

  • A [!CAUTION] admonition for yanked releases.

Caution

The fix-changelog workflow job skips this function during the release cycle (when release_commits_matrix is non-empty). At that point the release pipeline hasn’t published to PyPI or created a GitHub release yet, so this function would incorrectly add “not available” admonitions to the freshly-released version.

  • Placeholder sections for orphaned versions, with comparison URLs linking to adjacent versions.

Parameters:
  • changelog_path (Path) – Path to the changelog file.

  • archive_path (Path | None) – Optional path to a frozen changelog archive. Versions documented there are treated as present, suppressing false-positive orphan detection (and re-insertion under fix) for entries split out of the live changelog. Archived dates are not re-validated.

  • package (str | None) – PyPI package name. If None, auto-detected from pyproject.toml. If detection fails, falls back to git tags.

  • fix (bool) – If True, fix dates and add admonitions to the file.

  • pypi_package_history (Sequence[str]) – Former PyPI package names for renamed projects. Releases from each former name are merged into the lookup table so versions published under old names are recognized. The current package name wins on version collisions.

  • abandoned_versions (Sequence[str]) – Versions documented in the changelog but never published. Each listed version is reported as skipped (info log) instead of triggering the not found on PyPI warning, for both the PyPI lookup and the git-tag fallback. Use for releases that were frozen but skipped per the “skip and move forward” practice (botched build, broken artifact).

  • bullet_word_threshold (int) – Word count above which an unreleased-section bullet triggers a non-fatal length warning (see warn_on_long_bullets()). 0 disables the check. Never affects the exit code.

Return type:

int

Returns:

0 if all dates match or references were corrected in-place, 1 if any date mismatch or orphan is found without a fix being applied, 2 if the sanity gate refused a destructive rewrite because an upstream data source (GitHub Releases or PyPI) appeared to be returning incomplete or empty results while the existing changelog has substantial coverage on that platform.

repomatic.changelog.build_expected_body(changelog, version, *, admonition_override=None)[source]

Build the expected release body from the changelog.

Decomposes the changelog section into discrete elements and renders them through the github-releases template. This allows the GitHub release body to include a different subset of elements than the release-notes template used for changelog.md entries.

Parameters:
  • changelog (Changelog) – Parsed changelog instance.

  • version (str) – Version string (e.g. 1.2.3).

  • admonition_override (str | None) – If provided, replaces the availability_admonition from the changelog. Used by release_notes_with_admonition to inject a pre-computed admonition at release time.

Return type:

str

Returns:

The rendered release body, or empty string if the version has no changelog section.

repomatic.checksums module

Recompute SHA-256 checksums for the binary tool registry.

Iterates every TOOL_REGISTRY entry with a binary spec, downloads each platform’s release artifact, and rewrites stale hashes in-place in tool_runner.py (alongside the VERSIONS stamps). Driven by repomatic update-checksums and, with a version override, by sync-tool-versions so a version bump and its matching checksums land in one pass.

repomatic.checksums.update_registry_checksums(tool_runner_path, version_overrides=None)[source]

Recompute binary checksums and version stamps in tool_runner.py.

Iterates every TOOL_REGISTRY entry with a binary spec, downloads each platform URL (concurrently, sized by the global --jobs option and sequential at DEBUG verbosity or without an active CLI context), computes its SHA-256, and replaces stale hashes in-place. Also reconciles each tool’s VERSIONS stamp with the version the checksums were computed for, the basis of the offline staleness test.

Parameters:
  • tool_runner_path (Path) – Path to tool_runner.py.

  • version_overrides (dict[str, str] | None) – Optional mapping of tool name to a version to download instead of the in-memory ToolSpec.version. sync-tool-versions passes this so it can bump the version in the source and refresh the checksums in a single process: the in-memory registry still holds the pre-bump version because the file was edited, not reimported.

Return type:

list[tuple[str, str, str]]

Returns:

List of (url, old_hash, new_hash) for each updated checksum. Empty if all checksums are already correct.

repomatic.cli module

repomatic.cli.is_stdout(filepath)[source]

Check if a file path is set to stdout.

Prevents the creation of a - file in the current directory.

Return type:

bool

repomatic.cli.prep_path(filepath)[source]

Prepare the output file parameter for Click’s echo function.

Always returns a UTF-8 encoded file object, including for stdout. This avoids UnicodeEncodeError on Windows where the default stdout encoding is cp1252.

For non-stdout paths, parent directories are created automatically if they don’t exist. This absorbs the mkdir -p step that workflows previously had to do.

Note

When stdout is a captured in-memory stream with no backing file descriptor (Click’s test runner, the Sphinx {click:run} directive that live-renders CLI output in the docs), fileno() raises and we write to the stream directly. Such streams are already Python text objects, so the Windows cp1252 concern does not apply: that only bites a real terminal, which always has a descriptor.

Return type:

IO

repomatic.cli.generate_header(ctx)[source]

Generate metadata to be left as comments to the top of a file generated by this CLI.

Return type:

str

repomatic.cli.remove_header(content)[source]

Return content without blank lines and header metadata from above.

Return type:

str

class repomatic.cli.ComponentSelector[source]

Bases: ParamType

Accepts bare component names or qualified component/file selectors.

Bare names (e.g., skills) select an entire component. Qualified entries (e.g., skills/repomatic-topics) select a single file within a component. The same syntax is used by the exclude config option in [tool.repomatic].

name: str = 'selector'

the descriptive name of this type

get_metavar(param, ctx=None)[source]

Returns the metavar default for this param if it provides one.

convert(value, param, ctx)[source]

Convert the value to the correct type. This is not called if the value is None (the missing value).

This must accept string values from the command line, as well as values that are already the correct type. It may also convert other compatible types.

The param and ctx arguments may be None in certain situations, such as when converting prompt input.

If the value cannot be converted, call fail() with a descriptive message.

Parameters:
  • value – The value to convert.

  • param – The parameter that is using this type to convert its value. May be None.

  • ctx – The current context that arrived at this value. May be None.

shell_complete(ctx, param, incomplete)[source]

Return a list of CompletionItem objects for the incomplete value. Most types do not provide completions, but some do, and this allows custom types to provide custom completions as well.

Parameters:
  • ctx – Invocation context for this command.

  • param – The parameter that is requesting completion.

  • incomplete – Value being completed. May be empty.

Added in version 8.0.

repomatic.cli.TEST_MATRIX_STATE_DISPLAY = {'stable': '✅ stable', 'unstable': '⁉️ unstable'}

Emoji-decorated labels for job states in the show-test-matrix grid.

repomatic.cli.AUDIT_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Version', 'version'), ('Advisory', 'advisory'), ('Fixed', 'fixed'), ('Sources', 'sources'))

Column definitions for the repomatic audit table.

repomatic.cli.TOOL_LIST_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Tool', 'tool'), ('Version', 'version'), ('Config source', 'config-source'))

Column definitions for the repomatic run --list table.

repomatic.cli.CACHE_LIST_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Type', 'type'), ('Name', 'name'), ('Detail', 'detail'), ('Size', 'size'), ('Age', 'age'))

Column definitions for the repomatic cache show table.

repomatic.config module

Configuration schema and loading for [tool.repomatic] in pyproject.toml.

Defines the Config dataclass, its TOML serialization helpers, and the load_repomatic_config function that reads, validates, and returns a typed Config instance.

class repomatic.config.CacheConfig(dir='', github_release_ttl=604800, github_releases_ttl=86400, max_age=30, npm_ttl=86400, pypi_ttl=86400)[source]

Bases: object

Nested schema for [tool.repomatic.cache].

dir: str = ''

Override the binary cache directory path.

When empty (the default), the cache uses the platform convention: ~/Library/Caches/repomatic on macOS, $XDG_CACHE_HOME/repomatic or ~/.cache/repomatic on Linux, %LOCALAPPDATA%\repomatic\Cache on Windows. The REPOMATIC_CACHE_DIR environment variable takes precedence over this setting.

github_release_ttl: int = 604800

Freshness TTL for cached single-release bodies (seconds).

GitHub release bodies are immutable once published, so a long TTL (7 days) is safe. Set to 0 to disable caching for single-release lookups.

github_releases_ttl: int = 86400

Freshness TTL for cached all-releases responses (seconds).

New releases can appear at any time, so a shorter TTL (24 hours) balances freshness with API savings.

max_age: int = 30

Auto-purge cached entries older than this many days.

Set to 0 to disable auto-purge. The REPOMATIC_CACHE_MAX_AGE environment variable takes precedence over this setting.

npm_ttl: int = 86400

Freshness TTL for cached npm registry metadata (seconds).

New npm versions can appear at any time, so a 24-hour TTL balances freshness with request savings. Set to 0 to disable caching for npm lookups.

pypi_ttl: int = 86400

Freshness TTL for cached PyPI metadata (seconds).

PyPI metadata changes when new versions are published. A 24-hour TTL avoids redundant API calls while keeping data reasonably current.

class repomatic.config.DependencyGraphConfig(all_extras=True, all_groups=True, level=None, no_extras=<factory>, no_groups=<factory>, output='./docs/assets/dependencies.mmd')[source]

Bases: object

Nested schema for [tool.repomatic.dependency-graph].

all_extras: bool = True

Whether to include all optional extras in the graph.

When True, the update-deps-graph command behaves as if --all-extras was passed.

all_groups: bool = True

Whether to include all dependency groups in the graph.

When True, the update-deps-graph command behaves as if --all-groups was passed. Projects that want to exclude development dependency groups (docs, test, typing) from their published graph can set this to false.

level: int | None = None

Maximum depth of the dependency graph.

None means unlimited. 1 = directly-declared deps only, 2 = adds their deps, etc. Equivalent to --level.

no_extras: list[str]

Optional extras to exclude from the graph.

Equivalent to passing --no-extra for each entry. Takes precedence over dependency-graph.all-extras.

no_groups: list[str]

Dependency groups to exclude from the graph.

Equivalent to passing --no-group for each entry. Takes precedence over dependency-graph.all-groups.

output: str = './docs/assets/dependencies.mmd'

Path where the dependency graph Mermaid diagram should be written.

The dependency graph visualizes the project’s dependency tree in Mermaid format.

class repomatic.config.DocsConfig(apidoc_exclude=<factory>, apidoc_extra_args=<factory>, update_script='./docs/docs_update.py')[source]

Bases: object

Nested schema for [tool.repomatic.docs].

apidoc_exclude: list[str]

Glob patterns for modules to exclude from sphinx-apidoc.

Passed as positional exclude arguments after the source directory (e.g., ["setup.py", "tests"]).

apidoc_extra_args: list[str]

Extra arguments appended to the sphinx-apidoc invocation.

The base flags --no-toc --module-first are always applied. Use this for project-specific options (e.g., ["--implicit-namespaces"]).

update_script: str = './docs/docs_update.py'

Path to a Python script run after sphinx-apidoc to generate dynamic content.

Resolved relative to the repository root. Must reside under the docs/ directory for security. Set to an empty string to disable.

class repomatic.config.GitignoreConfig(extra_categories=<factory>, extra_content=<factory>, location='./.gitignore', sync=True)[source]

Bases: object

Nested schema for [tool.repomatic.gitignore].

extra_categories: list[str]

Additional gitignore template categories to fetch from gitignore.io.

List of template names (e.g., ["Python", "Node", "Terraform"]) to combine with the generated .gitignore content.

extra_content: str

Additional content to append at the end of the generated .gitignore file.

location: str = './.gitignore'

File path of the .gitignore to update, relative to the root of the repository.

sync: bool = True

Whether .gitignore sync is enabled for this project.

Projects that manage their own .gitignore and do not want the autofix job to overwrite it can set this to false.

class repomatic.config.LabelsConfig(content_rules=<factory>, extra=<factory>, extra_files=<factory>, file_rules=<factory>, sync=True)[source]

Bases: object

Nested schema for [tool.repomatic.labels].

content_rules: list[dict[str, str | list[str]]]

Structured per-label rules for the content-based labeller.

Each [[tool.repomatic.labels.content-rules]] entry has:

  • label (required): label name to apply when any pattern matches.

  • patterns (required): list of regex patterns evaluated against the issue or PR title and body by github/issue-labeller.

Repeating the same label across entries merges their patterns. Serialized to YAML at export time and appended to the bundled labeller-content-based.yaml.

extra: list[dict[str, str]]

Inline label definitions applied at sync time under the default profile.

Each entry is a mapping with name, color, and description keys, matching labelmaker’s label specification. Entries are serialized into a temporary TOML file as [[profiles.default.labels]] blocks and applied by labelmaker apply. This avoids committing a lonely extra-labels/*.toml file when the downstream project only needs the basic three fields.

For label sets that need labelmaker’s advanced features (rename-from, multi-profile, multi-color), commit a hand-written file under extra-labels/ or download one via extra-files instead.

extra_files: list[str]

URLs of additional label definition files (JSON, JSON5, TOML, or YAML).

Each URL is downloaded into extra-labels/ and applied separately by labelmaker. For inline definitions that need no external file, use extra instead.

file_rules: list[dict[str, str | list[str]]]

Structured per-label rules for the file-based labeller.

Each [[tool.repomatic.labels.file-rules]] entry defines one match group for one label. Required key:

  • label: label name to apply when this group’s conditions match.

Optional matcher keys (all conditions in the same entry are AND’d):

  • any-glob-to-any-file: any pattern matches any changed file.

  • any-glob-to-all-files: any pattern matches every changed file.

  • all-globs-to-any-file: every pattern matches any changed file.

  • all-globs-to-all-files: every pattern matches every changed file.

  • head-branch: regex patterns matched against the PR head branch.

  • base-branch: regex patterns matched against the PR base branch.

  • any: list of nested sub-groups, OR’d together.

  • all: list of nested sub-groups, AND’d together.

Repeating the same label across entries OR’s the resulting groups, the same as listing multiple top-level groups under one label in actions/labeler. Together with any / all wrappers this covers the full actions/labeler v5+ schema.

sync: bool = True

Whether label sync is enabled for this project.

Projects that manage their own repository labels and do not want the labels workflow to overwrite them can set this to false.

class repomatic.config.TestMatrixConfig(exclude=<factory>, full_include=<factory>, include=<factory>, remove=<factory>, replace=<factory>, unstable=<factory>, variations=<factory>)[source]

Bases: object

Nested schema for [tool.repomatic.test-matrix].

Keys inside replace and variations are GitHub Actions matrix identifiers (e.g., os, python-version) and must not be normalized to snake_case. Click Extra’s click_extra.normalize_keys = False metadata on the parent field prevents this.

exclude: list[dict[str, str]]

Extra exclude rules applied to both full and PR test matrices.

Each entry is a dict of GitHub Actions matrix keys (like {"os": "windows-11-arm"}) that removes matching combinations. Additive to the upstream default excludes.

full_include: list[dict[str, str]]

Full-matrix-only job rows, added as standalone matrix combinations.

Each entry is a dict of GitHub Actions matrix keys fully describing one job (like {“os”: “ubuntu-24.04-arm”, “python-version”: “3.10”, “click-version”: “8.3.1”}`). Unlike include`, these are appended as independent rows of the full matrix, never merged into the base cross-product, so a cell can't overwrite a shipped-config job that shares its ``os and python-version. Keys left out inherit the matrix defaults (the single-key include entries, plus state: stable), so a cell lists only what differs from the shipped configuration.

Use this for heterogeneous coverage, like pinning each release of a dependency to its own runner and Python, where carving the same shape from the base cross-product with exclude would take many rules. Like variations and unstable, it touches the full matrix only; the PR matrix stays a curated reduced set. Adding any entry makes the full matrix emit as a flat job list ({"include": [...]}), which GitHub runs verbatim with no cross-product expansion.

include: list[dict[str, str]]

Extra include directives applied to both full and PR test matrices.

Each entry is a dict of GitHub Actions matrix keys that adds or augments matrix combinations. Additive to the upstream default includes.

Because includes apply to both matrices, a directive whose keys are not PR base axes is risky. In the PR matrix only os and python-version are base axes, so a key like click-version (injected by another include) has nothing to match and GitHub’s expansion adds the directive to every PR job, overwriting it. To flag a value continue-on-error, prefer unstable over an include carrying state: unstable.

remove: dict[str, list[str]]

Per-axis value removals applied to both full and PR test matrices.

Outer key is the variation/axis ID (e.g., os, python-version). Inner list contains values to drop from that axis. Applied after replacements but before excludes, includes, and variations.

replace: dict[str, dict[str, str]]

Per-axis value replacements applied to both full and PR test matrices.

Outer key is the variation/axis ID (e.g., os, python-version). Inner dict maps old values to new values. Applied before removals, excludes, includes, and variations.

unstable: list[dict[str, str]]

Full-matrix-only combinations to flag continue-on-error in CI.

Each entry is a dict of GitHub Actions matrix keys (like {"click-version": "main"}). Every full-matrix combination matching an entry gets a state: unstable value, which tests.yaml reads to set continue-on-error. Like variations, this applies to the full matrix only; the PR matrix stays a curated stable set.

Prefer this over an include entry carrying state: unstable. include applies to both matrices, and in the PR matrix a key like click-version is not a base axis (another include injects it), so GitHub’s expansion would add the directive to every PR job and overwrite it. unstable only touches the full matrix, sidestepping that hijack.

variations: dict[str, list[str]]

Extra matrix dimension values added to the full test matrix only.

Each key is a dimension ID (e.g., os, click-version) and its value is a list of additional entries. For existing dimensions, values are merged with the upstream defaults. For new dimension IDs, a new axis is created. Only affects the full matrix; the PR matrix stays a curated reduced set.

class repomatic.config.VulnerableDepsConfig(sources=<factory>, sync=True)[source]

Bases: object

Nested schema for [tool.repomatic.vulnerable-deps].

sources: list[str]

Advisory databases to consult for known vulnerabilities.

Recognized values:

  • "uv-audit": PyPA Advisory Database via uv audit (works locally and in CI without a GitHub token).

  • "github-advisories": GitHub Advisory Database via the repository’s Dependabot alerts (CI-only, requires a token with Dependabot alerts: Read-only).

Sources are unioned and deduplicated per package by advisory identity: entries sharing an advisory_id or a cross-referenced CVE/GHSA/PYSEC alias are merged. Repositories that distrust GHSA — or have no Dependabot alerts enabled — can opt out with sources = ["uv-audit"].

sync: bool = True

Whether the fix-vulnerable-deps job is enabled for this project.

Projects that manage their own vulnerability remediation flow can set this to false to skip the autofix job.

class repomatic.config.WorkflowConfig(source_paths=None, extra_paths=<factory>, ignore_paths=<factory>, paths=<factory>, sync=True)[source]

Bases: object

Nested schema for [tool.repomatic.workflow].

source_paths: list[str] | None = None

Source code directory names for workflow trigger paths: filters.

When set, thin-caller and header-only workflows include paths: filters using these directory names (as name/** globs) alongside universal paths like pyproject.toml and uv.lock.

When None (default), source paths are auto-derived from [project.name] in pyproject.toml by replacing hyphens with underscores — the universal Python convention. For example, name = "extra-platforms" automatically uses ["extra_platforms"].

extra_paths: list[str]

Literal entries to append to every workflow’s paths: filter.

Applies to thin-caller and header-only sync. Useful for repo-specific files that should re-trigger CI but are not detected by the canonical paths: filter (e.g., install.sh, dotfiles/**).

Per-workflow overrides in paths ignore this list: when an entry exists for a given filename, that entry is treated as the complete list.

ignore_paths: list[str]

Literal entries to strip from every workflow’s paths: filter.

Useful for canonical entries that don’t exist downstream (e.g., tests/**, uv.lock in repos with no Python tests or lockfile). Match is by exact string equality. Applies before extra_paths.

Per-workflow overrides in paths ignore this list.

paths: dict[str, list[str]]

Per-workflow override of the paths: filter, keyed by filename.

When a workflow filename appears here, its paths: blocks (in push, pull_request, etc.) are replaced wholesale with the listed entries. source_paths, extra_paths, and ignore_paths do not apply when a per-workflow override is set: the list is treated as authoritative.

Override only takes effect on triggers that already have a paths: filter in the canonical workflow. Workflows without paths: upstream keep their unrestricted trigger semantics.

Example:

[tool.repomatic.workflow.paths]
"tests.yaml" = ["install.sh", "packages.toml", ".github/workflows/tests.yaml"]
sync: bool = True

Whether workflow sync is enabled for this project.

Projects that manage their own workflow files and do not want the autofix job to sync thin callers or headers can set this to false.

class repomatic.config.Config(abandoned_versions=<factory>, action_pins_sync=True, agents_location='./.claude/agents/', awesome_template_sync=True, binaries_sync=True, bumpversion_sync=True, cache=<factory>, changelog_archive_location='', changelog_bullet_word_threshold=40, changelog_location='./changelog.md', dep_sources_sync=True, dependency_graph=<factory>, dev_release_sync=True, docs=<factory>, exclude=<factory>, gitignore=<factory>, include=<factory>, labels=<factory>, mailmap_sync=True, manpages_asset_name='', manpages_script='', minimum_release_age='8 days', notification_unsubscribe=False, nuitka_enabled=True, nuitka_entry_points=<factory>, nuitka_extras=<factory>, nuitka_unstable_targets=<factory>, pypi_package_history=<factory>, setup_guide=True, skills_location='./.claude/skills/', test_matrix=<factory>, tool_versions_sync=True, uv_lock_sync=True, vulnerable_deps=<factory>, workflow=<factory>, workflow_pins_sync=True)[source]

Bases: object

Configuration schema for [tool.repomatic] in pyproject.toml.

This dataclass defines the structure and default values for repomatic configuration. Each field has a docstring explaining its purpose.

abandoned_versions: list[str]

Versions documented in the changelog but never published.

A version reached only its [changelog] Release vX.Y.Z freeze and was then skipped per CLAUDE.md § Skip and move forward (botched build, broken artifact, bad metadata) without rewriting history. List those versions here so lint-changelog reports them as skipped (an info log line) instead of flagging them every run as X.Y.Z: not found on PyPI. Applies to both PyPI lookups and the git-tag fallback.

action_pins_sync: bool = True

Whether the sync-action-pins job is enabled for this project.

Bumps SHA-pinned GitHub Actions (uses: owner/repo@<sha> # vX.Y.Z) to the latest release passing the minimum-release-age cooldown. Projects that pin actions by hand can set this to false.

agents_location: str = './.claude/agents/'

Directory prefix for Claude Code agent files, relative to the repository root.

Agent files are written as {agents_location}/{agent-id}.md. Useful for repositories where .claude/ is not at the root (like dotfiles repos that store configs under a subdirectory).

awesome_template_sync: bool = True

Whether awesome-template sync is enabled for this project.

Repositories whose name starts with awesome- get their boilerplate synced from files bundled in repomatic. Set to false to opt out.

binaries_sync: bool = True

Whether the release pipeline records released binaries into the repository.

When enabled, the scan-virustotal release job regenerates the binaries catalog (docs/binaries.md and docs/assets/binaries.csv) and pushes it, along with the scan history (docs/assets/virustotal-scans.json), straight to the default branch without a pull request: the release-lane exception documented in docs/operation-contracts.md. Set to false to keep the repository untouched: binaries are still scanned on VirusTotal (seeding AV vendor databases), but no catalog page, CSV, or scan record is committed.

bumpversion_sync: bool = True

Whether bumpversion config sync is enabled for this project.

Projects that manage their own [tool.bumpversion] section and do not want the autofix job to overwrite it can set this to false.

cache: CacheConfig

Binary cache configuration.

changelog_archive_location: str = ''

File path of the changelog archive, relative to the root of the repository.

The archive holds older release sections split out of the live changelog to keep it small. Empty (the default) disables archive handling.

When set, lint-changelog treats versions documented in the archive as present, so they are neither reported nor re-inserted as orphans (versions found on PyPI, GitHub, or git tags but missing from the changelog). The archive is frozen: its released entries are immutable and are not re-validated against their canonical release dates.

changelog_bullet_word_threshold: int = 40

Word count above which lint-changelog warns about a changelog bullet.

A changelog entry is a release note, not a commit message: ideally one short sentence stating what changed (see CLAUDE.md § Changelog entry length). lint-changelog emits a non-fatal warning for every bullet in the unreleased section longer than this many words, nudging verbose, implementation-heavy entries back toward a user-facing summary. Released sections are immutable and never flagged. Set to 0 to disable the check.

changelog_location: str = './changelog.md'

File path of the changelog, relative to the root of the repository.

dep_sources_sync: bool = True

Whether the sync-dep-sources updater is enabled for this project.

Swaps a dependency tracked from a git branch back to its released version once the release named by its .dev version floor ships on PyPI (see repomatic.dep_sources for the managed idiom). Projects that manage [tool.uv.sources] overrides by hand can set this to false.

dependency_graph: DependencyGraphConfig

Dependency graph generation configuration.

dev_release_sync: bool = True

Whether dev pre-release sync is enabled for this project.

Projects that do not want a rolling draft pre-release maintained on GitHub can set this to false.

docs: DocsConfig

Sphinx documentation generation configuration.

exclude: list[str]

Additional components and files to exclude from repomatic operations.

Additive to the default exclusions (agents, labels, skills). Bare names exclude an entire component (e.g., "workflows"). Qualified component/identifier entries exclude a specific file within a component (e.g., "workflows/debug.yaml", "skills/repomatic-audit", "labels/labeller-content-based.yaml").

Affects repomatic init, workflow sync, and workflow create. Explicit CLI positional arguments override this list.

gitignore: GitignoreConfig

.gitignore sync configuration.

include: list[str]

Components and files to force-include, overriding default exclusions.

Use this to opt into components that are excluded by default (agents, labels, skills). Each entry is subtracted from the effective exclude set (defaults + user exclude) and bypasses RepoScope filtering, so scope-restricted components (like awesome-only skills or Python-only publish-pypi-action) are included regardless of repository type. Qualified entries (component/file) implicitly select the parent component. Same syntax as exclude.

labels: LabelsConfig

Repository label sync configuration.

mailmap_sync: bool = True

Whether .mailmap sync is enabled for this project.

Projects that manage their own .mailmap and do not want the autofix job to overwrite it can set this to false.

manpages_asset_name: str = ''

Filename stem (without the .tar.gz extension) for the man-page tarball uploaded to the GitHub release.

Defaults to <package-name>-manpages when left empty and manpages.script is set. Has no effect when manpages.script is empty.

manpages_script: str = ''

Click command target whose tree gets rendered as roff .1 files and attached as a tarball asset on every GitHub release.

Same shape the click-extra wrap --man CLI accepts: a module:function path (preferred for projects whose console-script entry point dispatches through a wrapper), an entry-point name, a .py file path, or a plain importable module name. Leave empty to disable release-attached man pages.

minimum_release_age: str = '8 days'

Stabilization window before a new upstream release is adopted.

Shared cooldown for the sync-tool-versions, sync-action-pins, and sync-workflow-pins jobs: a release is only proposed once it has been public for at least this long, giving upstream time to yank a bad cut. It also gates repomatic run’s ad-hoc installs at run time, so their transitive trees honor the same window: uvx tools via uv’s --exclude-newer, npm tools via npm’s min-release-age. The GitHub/PyPI/npm counterpart to uv’s exclude-newer (which guards sync-uv-lock). Accepts the same friendly durations (8 days, 2 weeks, 36 hours). Set to 0 days to adopt releases immediately.

notification_unsubscribe: bool = False

Whether the unsubscribe-threads workflow is enabled.

Notifications are per-user across all repos. Enable on the single repo where you want scheduled cleanup of closed notification threads. Requires a classic PAT with notifications scope stored as REPOMATIC_NOTIFICATIONS_PAT.

nuitka_enabled: bool = True

Whether Nuitka binary compilation is enabled for this project.

Projects with [project.scripts] entries that are not intended to produce standalone binaries (e.g., libraries with convenience CLI wrappers) can set this to false to opt out of Nuitka compilation.

nuitka_entry_points: list[str]

Which [project.scripts] entry points produce Nuitka binaries.

List of CLI IDs (e.g., ["mpm"]) to compile. When empty (the default), deduplicates by callable target: keeps the first entry point for each unique module:callable pair. This avoids building duplicate binaries when a project declares alias entry points (like both mpm and meta-package-manager pointing to the same function).

nuitka_extras: list[str]

[project.optional-dependencies] extras to install before the Nuitka build.

List of extra names (like ["sbom"]) to sync into the build venv before invoking Nuitka. By default the binary build only sees the project’s base dependencies, which matches a bare pip install <package> and excludes optional features. Listing an extra here calls uv sync –frozen –extra <name> before the Nuitka build so the binary can bundle the optional feature’s third-party packages (paired with --include-package in [tool.nuitka] for imports guarded behind try/except).

nuitka_unstable_targets: list[str]

Nuitka build targets allowed to fail without blocking the release.

List of target names (e.g., ["linux-arm64", "windows-x64"]) that are marked as unstable. Jobs for these targets will be allowed to fail without preventing the release workflow from succeeding.

pypi_package_history: list[str]

Former PyPI package names for projects that were renamed.

When a project changes its PyPI name, older versions remain published under the previous name. List former names here so lint-changelog can fetch release metadata from all names and generate correct PyPI URLs.

setup_guide: bool = True

Whether the setup guide issue is enabled for this project.

Projects that do not need REPOMATIC_PAT or manage their own PAT setup can set this to false to suppress the setup guide issue.

skills_location: str = './.claude/skills/'

Directory prefix for Claude Code skill files, relative to the repository root.

Skill files are written as {skills_location}/{skill-id}/SKILL.md. Useful for repositories where .claude/ is not at the root (like dotfiles repos that store configs under a subdirectory).

test_matrix: TestMatrixConfig

Per-project customizations for the GitHub Actions CI test matrix.

Keys inside this section are GitHub Actions matrix identifiers (e.g., os, python-version) and must not be normalized to snake_case.

tool_versions_sync: bool = True

Whether the sync-tool-versions job is enabled for this project.

Bumps every tool in the repomatic run registry to the latest release passing the minimum-release-age cooldown (GitHub releases for binary tools, PyPI for the rest), recomputing binary checksums in the same pass. Projects that pin tool versions by hand can set this to false.

uv_lock_sync: bool = True

Whether uv.lock sync is enabled for this project.

Projects that manage their own lock file strategy and do not want the sync-uv-lock job to run uv lock --upgrade can set this to false.

vulnerable_deps: VulnerableDepsConfig

Vulnerable dependency detection and remediation configuration.

workflow: WorkflowConfig

Workflow sync configuration.

workflow_pins_sync: bool = True

Whether the sync-workflow-pins job is enabled for this project.

Bumps version literals embedded in workflow YAML (npm pkg@x installs and uvx '<pkg>==x' PyPI pins) to the latest release passing the minimum-release-age cooldown. Projects that pin these by hand can set this to false.

repomatic.config.SUBCOMMAND_CONFIG_FIELDS: Final[frozenset[str]] = frozenset({'abandoned_versions', 'action_pins_sync', 'agents_location', 'awesome_template_sync', 'bumpversion_sync', 'cache', 'changelog_archive_location', 'changelog_location', 'dep_sources_sync', 'dependency_graph', 'dev_release_sync', 'docs', 'exclude', 'gitignore', 'include', 'labels', 'mailmap_sync', 'minimum_release_age', 'notification_unsubscribe', 'pypi_package_history', 'setup_guide', 'skills_location', 'test_matrix', 'tool_versions_sync', 'uv_lock_sync', 'vulnerable_deps', 'workflow', 'workflow_pins_sync'})

Config fields consumed directly by subcommands, not needed as metadata outputs.

These fields are read directly from [tool.repomatic] in pyproject.toml by their respective subcommands (e.g. deps-graph), so they no longer need to be passed through workflow metadata outputs.

repomatic.config.escape_type_for_gfm_table(ftype)[source]

Escape outer brackets of nested generics for raw GFM table cells.

Nested generics like list[dict[str, str]] would otherwise be interpreted by mdformat as a markdown link reference and re-escaped on every reformat. Escaping the outermost brackets up front keeps the cell stable under mdformat. Simple generics like list[str] have no nested brackets and stay unescaped.

Apply this only when the value lands directly in a raw GFM table cell (e.g. CLI show-config output). Do not apply when wrapping the value in inline code backticks: inside a code span, backslashes are literal characters in CommonMark and would render visibly as \[.

Return type:

str

repomatic.config.CONFIG_REFERENCE_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Option', 'option'), ('Type', 'type'), ('Default', 'default'), ('Description', 'description'))

Column definitions for the [tool.repomatic] configuration reference table.

repomatic.config.config_reference()[source]

Build the [tool.repomatic] configuration reference as table rows.

Introspection comes from click-extra’s schema_field_infos() (dotted kebab-case keys, type annotations, defaults, attribute-docstring summaries); this wrapper only applies the Markdown presentation of the show-config table. Returns a list of (option, type, default, description) tuples suitable for click_extra.table.print_table.

Return type:

list[tuple[str, str, str, str]]

repomatic.config.load_repomatic_config(pyproject_data=None)[source]

Load [tool.repomatic] config merged with Config defaults.

Delegates to click-extra’s schema-aware dataclass instantiation, which handles normalization, flattening, nested dataclasses, and opaque field extraction automatically based on field metadata and type hints.

Parameters:

pyproject_data (dict[str, Any] | None) – Pre-parsed pyproject.toml dict. If None, reads and parses pyproject.toml from the current working directory.

Return type:

Config

repomatic.dep_sources module

Swap git-tracked dependencies back to their released versions.

The sync-dep-sources updater manages one precise idiom: a dependency temporarily consumed from a git branch while its next release is awaited. The idiom is machine-recognizable because it pairs two declarations in pyproject.toml:

  • a [tool.uv.sources] entry tracking a branch (not a rev or tag pin), and

  • a dev-version floor on the same package (like mango>=2.1.0.dev0), whose base version names the awaited release.

Once the awaited release ships on the index, the swap rewrites the project back to released artifacts: the source override is dropped, the .dev floor is tightened to its base release, and a cooldown-bypass freeze adopts the release through the exclude-newer window (the same deliberate-bypass mechanism audit --fix uses for security fixes). The freeze then ages out and is pruned by the ordinary sync-uv-lock lifecycle.

Note

The dev floor is authoritative, deliberately: the project declares that anything from the awaited release onward satisfies it. If the project quietly grew a dependency on branch commits newer than the release, the swap PR’s CI run exposes the stale declaration, and the correction (bumping the floor to the next .dev version, which retracts the swap on the next run) is exactly the fix the project needed anyway. Overrides outside the idiom (path or workspace sources, rev/tag pins, floor-less branch tracks) are never touched.

repomatic.dep_sources.DEV_BOUND_PATTERN = re.compile('(?P<op>>=?)\\s*(?P<version>[0-9][A-Za-z0-9.!+]*)')

Lower-bound clauses in a PEP 508 requirement string.

Captures the operator and the version literal so strip_dev_bounds() can rewrite >=2.1.0.dev0 into >=2.1.0 in place, leaving extras, markers, and every other clause byte-for-byte untouched.

class repomatic.dep_sources.ReleaseSwap(name, source_key, branch, floor, release, released)[source]

Bases: object

A git-tracked dependency whose awaited release has shipped.

Built by find_ready_swaps(); consumed by apply_release_swaps() (the pyproject.toml rewrite) and format_swap_section() (the PR report).

name: str

Normalized package name, as it appears on PyPI and in uv.lock.

source_key: str

The entry key as written in [tool.uv.sources] (may differ from name in case or separators).

branch: str

The git branch the override tracks.

floor: str

The .dev version floor that named the awaited release.

release: str

The adopted release version, the newest stable satisfying the floor.

released: str

Upload date of release (YYYY-MM-DD), from the index.

property freeze_cutoff: str

The exclude-newer-package cutoff adopting release.

One day of margin past the release’s earliest upload date, rendered as an explicit UTC timestamp, mirroring _freeze_cutoff (see there for the margin and timezone rationale): every distribution file of the adopted release sits inside the window even when its uploads straddle midnight, while the global cooldown still shields anything newer.

repomatic.dep_sources.tracked_git_overrides(pyproject_path)[source]

Read the [tool.uv.sources] entries tracking a git branch.

Only single-source entries carrying both a git URL and a branch are returned: a rev or tag pin is a deliberate point-in-time choice, a path or workspace source is a local development arrangement, and a multi-source list (per-platform markers) is too bespoke to rewrite. None of those encode “waiting for the next release”.

Parameters:

pyproject_path (Path) – Path to the pyproject.toml file.

Return type:

dict[str, str]

Returns:

Source-entry key to tracked branch name; empty when the file or the table is absent.

repomatic.dep_sources.dev_floor(pyproject_path, name)[source]

The highest .dev lower bound declared for name, if any.

Scans every requirement array for lower-bound clauses (>= or >) whose version is a dev release. The highest one is the project’s declared “awaited release” threshold.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • name (str) – Package name (any capitalization or separator style).

Return type:

str | None

Returns:

The floor version string, or None when the package has no dev floor (the override is then outside the managed idiom).

repomatic.dep_sources.find_ready_swaps(pyproject_path)[source]

Probe the index for git-tracked packages whose awaited release shipped.

For each branch-tracking override inside the managed idiom, the awaited release is considered shipped once PyPI carries a stable (non-prerelease, non-yanked) version satisfying the dev floor. The newest such release is adopted. Index misses (an unpublished package, a network failure) read as “not ready”: a swap needs positive confirmation, so the failure mode is always a skipped run, never a wrong rewrite.

Parameters:

pyproject_path (Path) – Path to the pyproject.toml file.

Return type:

list[ReleaseSwap]

Returns:

Ready swaps sorted by package name; empty when there is nothing to do.

repomatic.dep_sources.strip_dev_bounds(requirement, release)[source]

Tighten a requirement string’s .dev lower bounds to their release.

Rewrites only the version literal of >=/> clauses whose version is a dev release older than or equal to release, replacing it with its base version (>=2.1.0.dev0 becomes >=2.1.0). Everything else in the string (extras, markers, other clauses, spacing) is preserved byte-for-byte.

Parameters:
  • requirement (str) – The PEP 508 requirement string.

  • release (str) – The adopted release; bounds newer than it are left alone (they await a later release).

Return type:

str

Returns:

The rewritten string, or the original when nothing matched.

repomatic.dep_sources.apply_release_swaps(pyproject_path, swaps)[source]

Rewrite pyproject.toml for the given swaps, in one pass.

Two of the three swap edits happen here: the [tool.uv.sources] override is removed (and the emptied table with it), and every .dev floor on the swapped packages is tightened to its base release. The third edit, the cooldown-bypass freeze at ReleaseSwap.freeze_cutoff, goes through repomatic.uv.upsert_exclude_newer_packages() so the insertion position and inline-table formatting stay canonical.

Parameters:
Return type:

None

repomatic.dep_sources.SWAP_SECTION_NOTE = 'Dependencies tracked from a git branch while awaiting a release, swapped back to the package index: the `[tool.uv.sources]` override is dropped, the `.dev` version floor is tightened to its release form, and a cooldown bypass freezes the adoption until it ages past the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cutoff.'

Intro paragraph for the sync-dep-sources swap section.

repomatic.dep_sources.format_swap_section(swaps, *, name_urls=None, reference_date=None)[source]

Format the release swaps as a markdown section.

The sync-dep-sources report section explaining the pyproject.toml hunks: one row per swapped package, with the branch it tracked, the release it adopted, and when that release shipped.

Parameters:
  • swaps (list[ReleaseSwap]) – Ready swaps from find_ready_swaps().

  • name_urls (dict[str, str] | None) – Optional mapping of names to a URL the name links to. Names absent from the mapping render plain.

  • reference_date (date | None) – When set, the “Released” date gains a relative hint measured from this date.

Return type:

str

Returns:

A markdown string with a ## 🔀 Source swaps heading and table, or an empty string when swaps is empty.

repomatic.deps_graph module

Generate Mermaid dependency graphs from uv lockfiles.

Every box in the graph (the primary dependencies rectangle and each --group/--extra subgraph) only holds directly-declared dependencies, drawn as hexagons: the packages under the project’s control, referenced in pyproject.toml. Transitive dependencies always render outside the boxes, as plain ovals.

Note

Uses uv export --format cyclonedx1.5 which provides structured JSON with dependency relationships, replacing the need for pipdeptree.

Warning

The generated Mermaid syntax targets the version bundled with sphinxcontrib-mermaid, currently 11.12.1. See the hard-coded MERMAID_VERSION constant in sphinxcontrib-mermaid’s source. Avoid using Mermaid features introduced after that version.

repomatic.deps_graph.STYLE_PRIMARY_DEPS_SUBGRAPH: str = 'fill:#1565C020,stroke:#42A5F5'

Mermaid style for the primary dependencies subgraph box.

Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.

repomatic.deps_graph.STYLE_EXTRA_SUBGRAPH: str = 'fill:#7B1FA220,stroke:#BA68C8'

Mermaid style for extra dependency subgraph boxes.

Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.

repomatic.deps_graph.STYLE_GROUP_SUBGRAPH: str = 'fill:#546E7A20,stroke:#90A4AE'

Mermaid style for group dependency subgraph boxes.

Uses semi-transparent fill (8-digit hex) so the tint adapts to both light and dark page backgrounds.

repomatic.deps_graph.STYLE_PRIMARY_NODE: str = 'stroke-width:3px'

Mermaid style for root and primary dependency nodes (thick border).

repomatic.deps_graph.STYLE_DUPLICATE_NODE: str = 'stroke-width:3px,stroke-dasharray:5 5'

Mermaid style for duplicate headline nodes (dashed thick border).

The dashes mark the node as a display-only mirror of the real node owned by another subgraph; a dotted identity link ties the two together. Derived from STYLE_PRIMARY_NODE since duplicates are always headline (primary) dependencies of their box.

class repomatic.deps_graph.SubgraphKind(*values)[source]

Bases: Enum

Kind of dependency selector a subgraph box represents.

GROUP = 'group'
EXTRA = 'extra'
property flag: str

CLI flag selecting this kind, shown as the box title prefix.

available(project_root=None)[source]

Discover this kind’s declared names from pyproject.toml.

Groups come from the [dependency-groups] table, extras from [project.optional-dependencies].

Parameters:

project_root (Path | None) – Directory holding pyproject.toml. Defaults to the current working directory.

Return type:

tuple[str, ...]

Returns:

Sorted tuple of group or extra names.

property mermaid_prefix: str

Namespace prefix keeping subgraph IDs distinct from node IDs.

Without it, a json5 extra box would collide with a json5 package node.

property style: str

Mermaid style for boxes of this kind.

class repomatic.deps_graph.Subgraph(kind, name, owned, duplicates)[source]

Bases: object

One --group or --extra box in the rendered graph.

A box only holds the packages its group or extra declares directly: the dependencies under the project’s control, referenced in pyproject.toml. Transitive dependencies always render outside the boxes, exactly like the transitive dependencies of the primary set.

kind: SubgraphKind

Whether the box represents a dependency group or an optional extra.

name: str

Group or extra name, as declared in pyproject.toml.

owned: set[str]

Directly-declared packages this box renders as real hexagon nodes.

duplicates: set[str]

Directly-declared packages owned by a sibling box.

Rendered as display-only duplicate nodes tied to the real node by a dotted identity link. See attribute_subgraph_packages().

property mermaid_id: str

Mermaid subgraph ID, namespaced away from node IDs.

property title: str

Box title, echoing the CLI flag that pulls these packages in.

repomatic.deps_graph.MERMAID_RESERVED_KEYWORDS: frozenset[str] = frozenset({'C4Component', 'C4Container', 'C4Deployment', 'C4Dynamic', '_blank', '_parent', '_self', '_top', 'call', 'class', 'classDef', 'click', 'end', 'flowchart', 'flowchart-v2', 'graph', 'interpolate', 'linkStyle', 'style', 'subgraph'})

Mermaid keywords that cannot be used as node IDs.

repomatic.deps_graph.normalize_package_name(name)[source]

Normalize package name for use as Mermaid node ID.

Converts to lowercase and replaces non-alphanumeric characters with underscores. Appends _0 suffix to avoid conflicts with Mermaid reserved keywords.

Return type:

str

repomatic.deps_graph.resolve_subgraph_selection(kind, explicit, select_all, excluded, only, config_all, config_excluded)[source]

Resolve which groups or extras the graph should render.

Mirrors one selection axis of the update-deps-graph command: explicit CLI values win over the [tool.repomatic] dependency-graph defaults; --only-* replaces the explicit selection; --all-* expands to every name declared in pyproject.toml; --no-* prunes last.

Parameters:
  • kind (SubgraphKind) – The axis to resolve, groups or extras.

  • explicit (tuple[str, ...]) – Names selected one by one (--group/--extra).

  • select_all (bool) – Select every declared name (--all-groups/--all-extras).

  • excluded (tuple[str, ...]) – Names to prune from the selection (--no-group/--no-extra).

  • only (tuple[str, ...]) – Names selected in exclusive mode (--only-group/--only-extra).

  • config_all (bool) – Configured default for select_all, applied when no selection flag is passed.

  • config_excluded (Sequence[str]) – Configured default for excluded.

Return type:

tuple[str, ...] | None

Returns:

Selected names, or None when the axis is not requested at all.

repomatic.deps_graph.get_cyclonedx_sbom(package=None, groups=None, extras=None, frozen=True)[source]

Run uv export and return the CycloneDX SBOM as a dictionary.

Results are cached to avoid redundant subprocess calls within the same process.

Parameters:
  • package (str | None) – Optional package name to focus the export on.

  • groups (tuple[str, ...] | None) – Optional dependency groups to include (e.g., “test”, “typing”).

  • extras (tuple[str, ...] | None) – Optional extras to include (e.g., “xml”, “json5”).

  • frozen (bool) – If True, use –frozen to skip lock file updates.

Return type:

dict[str, Any]

Returns:

Parsed CycloneDX SBOM dictionary.

Raises:
repomatic.deps_graph.get_package_names_from_sbom(sbom)[source]

Extract all package names from a CycloneDX SBOM.

Parameters:

sbom (dict[str, Any]) – Parsed CycloneDX SBOM dictionary.

Return type:

set[str]

Returns:

Set of package names.

repomatic.deps_graph.build_dependency_graph(sbom)[source]

Build a dependency graph from CycloneDX SBOM data.

Parameters:

sbom (dict[str, Any]) – Parsed CycloneDX SBOM dictionary.

Return type:

tuple[str, set[str], list[tuple[str, str]]]

Returns:

Tuple of (root_name, package_names, edges_list) where: - root_name is the root package name - package_names is the set of all package names - edges_list is a list of (from_name, to_name) tuples

repomatic.deps_graph.filter_graph_to_package(packages, edges, package)[source]

Filter the graph to only include dependencies of a specific package.

Parameters:
  • packages (set[str]) – Set of all package names.

  • edges (list[tuple[str, str]]) – List of (from_name, to_name) edge tuples.

  • package (str) – Package name to filter to.

Return type:

tuple[set[str], list[tuple[str, str]]]

Returns:

Filtered (packages, edges) tuple.

repomatic.deps_graph.trim_graph_to_depth(root_name, packages, edges, depth)[source]

Trim the graph to only include nodes within a given depth from the root.

Performs a breadth-first traversal from the root, keeping only nodes reachable within depth hops and edges between those nodes.

Parameters:
  • root_name (str) – The root package name.

  • packages (set[str]) – Set of all package names.

  • edges (list[tuple[str, str]]) – List of (from_name, to_name) edge tuples.

  • depth (int) – Maximum depth from root. 0 = root only, 1 = root + primary deps, etc.

Return type:

tuple[set[str], list[tuple[str, str]]]

Returns:

Filtered (packages, edges) tuple.

repomatic.deps_graph.render_mermaid(root_name, packages, edges, subgraphs=None, lock_specs=None)[source]

Render the dependency graph as a Mermaid flowchart.

Warning

Output must stay compatible with the Mermaid version bundled in sphinxcontrib-mermaid. See module docstring for details.

Every box holds only directly-declared dependencies, drawn as hexagons with a thick border; transitive dependencies render outside the boxes as plain ovals. See the module docstring.

Parameters:
  • root_name (str) – The root package name (used to highlight it).

  • packages (set[str]) – Package names to render as nodes.

  • edges (list[tuple[str, str]]) – List of (from_name, to_name) edge tuples.

  • subgraphs (list[Subgraph] | None) – Boxes to render, in display order (extras before groups keeps them closer to the main dependencies). See Subgraph.

  • lock_specs (LockSpecifiers | None) – Optional specifiers extracted from uv.lock. Provides edge labels (by_package) and subgraph node labels (by_subgraph).

Return type:

str

Returns:

Mermaid flowchart string.

repomatic.deps_graph.attribute_subgraph_packages(subgraph_closures, base_packages, direct_packages, edges, root_name)[source]

Attribute each directly-declared package to one owning subgraph box.

Boxes only hold the packages their group/extra declares directly; transitive dependencies stay outside every box (see the module docstring). A directly-declared package can still be claimed by several boxes, but a graph node can live in only one: the declarer whose closure holds the most dependents wins the real node (declaration order breaks ties), since arrows point where the package is consumed and the busiest box is its most natural home. The root is not a dependent, as it reaches every declared package by definition.

The losing declarers list the package as a duplicate headline so every box still shows the dependency it exists to install (rendered as a display-only duplicate node by render_mermaid()). For example the carapace and yaml extras both declare only pyyaml, which no other package depends on: the dependent counts tie at zero, carapace owns the node by declaration order, and yaml carries pyyaml as a duplicate.

Parameters:
  • subgraph_closures (list[tuple[str, set[str]]]) – Ordered (name, closure_package_names) pairs. Order is the last-resort tie-break for shared packages (first wins).

  • base_packages (set[str]) – Packages in the base set, excluded from every box.

  • direct_packages (dict[str, set[str]]) – Map of subgraph name to the package names it declares directly (from uv.lock), keyed by SBOM-normalized name.

  • edges (list[tuple[str, str]]) – (from_name, to_name) dependency edges from the full SBOM, used to count each declaring subgraph’s local dependents.

  • root_name (str) – The root package name, excluded from dependent counts.

Return type:

tuple[dict[str, set[str]], dict[str, set[str]]]

Returns:

(owned, duplicates). owned maps each subgraph to the declared packages it renders as real nodes; duplicates maps it to declared packages owned by a sibling box.

repomatic.deps_graph.generate_dependency_graph(package=None, groups=None, extras=None, frozen=True, depth=None, exclude_base=False)[source]

Generate a Mermaid dependency graph.

Each requested group/extra renders as a box holding only the packages it declares directly; the transitive dependencies they pull in render outside the boxes, like the transitive dependencies of the main set.

Parameters:
  • package (str | None) – Optional package name to focus on. If None, shows the entire project dependency tree.

  • groups (tuple[str, ...] | None) – Optional dependency groups to include (e.g., “test”, “typing”).

  • extras (tuple[str, ...] | None) – Optional extras to include (e.g., “xml”, “json5”).

  • frozen (bool) – If True, use –frozen to skip lock file updates.

  • depth (int | None) – Optional maximum depth from root. If None, shows the full tree.

  • exclude_base (bool) – If True, exclude main (base) dependencies from the graph, showing only packages unique to the requested groups/extras. Used by --only-group and --only-extra.

Return type:

str

Returns:

The graph in Mermaid format.

repomatic.docs module

Regenerate Sphinx API docs and dynamic documentation content.

Backs the update-docs command: orchestrates sphinx-apidoc, the RST-to-MyST conversion, the project’s docs/docs_update.py script, and the self-updating directive-block refresh. Configuration is read from [tool.repomatic.docs].

repomatic.docs.validate_docs_script_path(script, repo_root)[source]

Validate and resolve a docs update script path.

Parameters:
  • script (str) – Configured docs.update-script path, relative to the repo.

  • repo_root (Path) – Repository root the script path resolves against.

Return type:

Path | None

Returns:

The resolved path, or None when the configured value is empty.

Raises:

ClickException – If the path escapes the repository root or is not a .py file under docs/.

repomatic.docs.update_docs(config)[source]

Regenerate Sphinx autodoc stubs and run the project’s update script.

Orchestrates four phases:

  1. Run sphinx-apidoc to generate RST stubs for all modules.

  2. If MyST-Parser is detected, convert the RST stubs to MyST markdown with {eval-rst} blocks.

  3. Run the project-specific docs/docs_update.py script (if present) to generate dynamic content.

  4. Refresh self-updating blocks (:matrix: compatibility tables and python:render :mirror: regions) found in docs/ pages and readme.md, via click-extra refresh-directives.

Parameters:

config (Config) – The resolved [tool.repomatic] configuration.

Return type:

None

repomatic.git_ops module

Git operations for GitHub Actions workflows.

This module provides utilities for common Git operations in CI/CD contexts, with idempotent behavior to allow safe re-runs of failed workflows.

All operations follow a “belt-and-suspenders” approach: combine workflow timing guarantees (e.g. workflow_run ensures tags exist) with idempotent guards (e.g. skip_existing on tag creation). This ensures correctness in the face of race conditions, API eventual consistency, and partial failures that are common in GitHub Actions.

Warning

Tag push requires REPOMATIC_PAT

Tags pushed with the default GITHUB_TOKEN do not trigger downstream on.push.tags workflows. The custom PAT is required so that tagging a release commit actually fires the publish and release creation jobs.

repomatic.git_ops.COMMIT_IDENTITY_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com'

Commit author email for automated commits: GitHub’s own Actions bot user.

The 41898282+ prefix is the bot’s stable user ID, which makes GitHub link the commit to the verified github-actions[bot] account.

repomatic.git_ops.COMMIT_IDENTITY_NAME = 'github-actions[bot]'

Commit author name for automated commits.

repomatic.git_ops.SHORT_SHA_LENGTH = 7

Default SHA length hard-coded to 7.

Caution

The default is subject to change and depends on the size of the repository.

repomatic.git_ops.GITHUB_REMOTE_PATTERN = re.compile('github\\.com[:/](?P<slug>[^/]+/[^/]+?)(?:\\.git)?$')

Extracts an owner/repo slug from a GitHub remote URL.

Handles both HTTPS (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) formats.

repomatic.git_ops.RELEASE_COMMIT_PATTERN = re.compile('^\\[changelog\\] Release v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$')

Pre-compiled regex for release commit messages.

Matches the full message and captures the version number. Use fullmatch to validate a commit is a release commit, or match/search with .group("version") to extract the version string.

A rebase merge preserves the original commit messages, so release commits match this pattern. A squash merge replaces them with the PR title (e.g. Release ``v1.2.3 (#42)``), which does not match. This mismatch is the mechanism by which squash merges are safely skipped: the create-tag job only processes commits matching this pattern, so no tag, PyPI publish, or GitHub release is created from a squash merge. The detect-squash-merge job in release.yaml detects this and opens an issue to notify the maintainer.

repomatic.git_ops.GIT_LOG_FORMAT = '%H%x00%B'

git log pretty-format placeholders for a single commit: full SHA, then a NUL, then the raw body.

Paired with git log -z (which terminates each commit’s output with a NUL), this frames the stream as alternating (hash, message) tokens. Commit messages may contain newlines but never NUL bytes, so splitting on NUL recovers the fields unambiguously even for multi-line messages.

class repomatic.git_ops.Commit(hash: str, msg: str)[source]

Bases: NamedTuple

A minimal git commit.

Only the hash and message are ever consumed downstream, so a full git library object (with diffs, modified-file analysis, and complexity metrics) is unnecessary: the git CLI feeds these two fields directly.

Create new instance of Commit(hash, msg)

hash: str

The commit’s full 40-character SHA-1 hash.

msg: str

The commit message, stripped of surrounding whitespace.

repomatic.git_ops.get_commit(ref='HEAD')[source]

Return the commit at ref.

Raises:

subprocess.CalledProcessError – if ref does not resolve to a commit present in the repository.

Return type:

Commit

repomatic.git_ops.list_commits(start, end)[source]

Return the commits in the start..end range, oldest first.

Follows git range semantics: start is excluded, end is included. Both endpoints must already exist locally, so deepen a shallow clone before calling if necessary.

Return type:

tuple[Commit, ...]

repomatic.git_ops.commit_exists(ref)[source]

Return True if ref resolves to a commit object present locally.

Return type:

bool

repomatic.git_ops.count_commits(ref='HEAD')[source]

Return the number of commits reachable from ref.

Return type:

int

repomatic.git_ops.head_sha()[source]

Return the full SHA of the current HEAD commit.

Return type:

str

repomatic.git_ops.current_branch()[source]

Return the checked-out branch name, or None when HEAD is detached.

Return type:

str | None

repomatic.git_ops.checkout(ref)[source]

Check out ref (a branch name or commit SHA).

Return type:

None

repomatic.git_ops.stash()[source]

Stash the working tree’s local changes.

Return type:

None

repomatic.git_ops.stash_pop()[source]

Restore the most recently stashed local changes.

Return type:

None

repomatic.git_ops.stash_count()[source]

Return the number of entries on the stash reflog.

Return type:

int

repomatic.git_ops.fetch_deepen(depth)[source]

Deepen a shallow clone by fetching depth more commits.

Raises:

subprocess.CalledProcessError – if the fetch fails.

Return type:

None

repomatic.git_ops.diff_names(start, end)[source]

Return the paths that differ between start and end.

Raises:

subprocess.CalledProcessError – if either ref is unknown.

Return type:

tuple[str, ...]

repomatic.git_ops.get_repo_slug_from_remote(remote='origin')[source]

Extract the owner/repo slug from a git remote URL.

Parses both HTTPS and SSH GitHub remote formats. Returns None if the remote is not set, not a GitHub URL, or git is unavailable.

Return type:

str | None

repomatic.git_ops.get_latest_tag_version()[source]

Returns the latest release version from Git tags.

Looks for tags matching the pattern vX.Y.Z and returns the highest version. Returns None if no matching tags are found.

Return type:

Version | None

repomatic.git_ops.get_release_version_from_commits(max_count=10)[source]

Extract release version from recent commit messages.

Searches recent commits for messages matching the pattern [changelog] Release vX.Y.Z and returns the version from the most recent match.

This provides a fallback when tags haven’t been pushed yet due to race conditions between workflows. The release commit message contains the version information before the tag is created.

Parameters:

max_count (int) – Maximum number of commits to search.

Return type:

Version | None

Returns:

The version from the most recent release commit, or None if not found.

repomatic.git_ops.get_tag_date(tag)[source]

Get the date of a Git tag in YYYY-MM-DD format.

Uses creatordate which resolves to the tagger date for annotated tags and the commit date for lightweight tags.

Parameters:

tag (str) – The tag name to look up.

Return type:

str | None

Returns:

Date string in YYYY-MM-DD format, or None if the tag does not exist.

repomatic.git_ops.get_all_version_tags()[source]

Get all version tags and their dates.

Runs a single git tag command to list all tags matching the vX.Y.Z pattern and extracts their dates.

Return type:

dict[str, str]

Returns:

Dict mapping version strings (without v prefix) to dates in YYYY-MM-DD format.

repomatic.git_ops.tag_exists(tag)[source]

Check if a Git tag already exists locally.

Parameters:

tag (str) – The tag name to check.

Return type:

bool

Returns:

True if the tag exists, False otherwise.

repomatic.git_ops.create_tag(tag, commit=None)[source]

Create a local Git tag.

Parameters:
  • tag (str) – The tag name to create.

  • commit (str | None) – The commit to tag. Defaults to HEAD.

Raises:

subprocess.CalledProcessError – If tag creation fails.

Return type:

None

repomatic.git_ops.push_tag(tag, remote='origin')[source]

Push a Git tag to a remote repository.

Parameters:
  • tag (str) – The tag name to push.

  • remote (str) – The remote name. Defaults to “origin”.

Raises:

subprocess.CalledProcessError – If push fails.

Return type:

None

repomatic.git_ops.commit_and_push_files(paths, message, remote='origin', branch='main', attempts=3)[source]

Commit the given files and push, rebasing and retrying on rejection.

Designed for CI jobs that append to tracked files (scan records, the binaries page) and publish the result on the default branch. The commit is authored as COMMIT_IDENTITY_NAME via per-command -c config, since CI checkouts carry no git identity.

Idempotent: when the files are unchanged, no commit is created and the function returns False. A rejected push (another job or the maintainer pushed meanwhile) is retried after fetching and rebasing onto the fresh remote tip. Works from a detached HEAD: the push targets HEAD:{branch} explicitly.

Parameters:
  • paths (Sequence[Path | str]) – Files to stage and commit.

  • message (str) – Commit message.

  • remote (str) – Remote to push to.

  • branch (str) – Remote branch to push to.

  • attempts (int) – Maximum push attempts before giving up.

Return type:

bool

Returns:

True when a commit was pushed, False when there was nothing to commit.

Raises:
repomatic.git_ops.create_and_push_tag(tag, commit=None, push=True, skip_existing=True)[source]

Create and optionally push a Git tag.

This function is idempotent: if the tag already exists and skip_existing is True, it returns False without failing. This allows safe re-runs of workflows that were interrupted after tag creation but before other steps.

Parameters:
  • tag (str) – The tag name to create.

  • commit (str | None) – The commit to tag. Defaults to HEAD.

  • push (bool) – Whether to push the tag to the remote. Defaults to True.

  • skip_existing (bool) – If True, skip silently when tag exists. If False, raise an error. Defaults to True.

Return type:

bool

Returns:

True if the tag was created, False if it already existed.

Raises:

repomatic.gitignore module

Generate .gitignore content from gitignore.io templates.

Backs the sync-gitignore command: fetches the base template categories plus any [tool.repomatic] gitignore.extra-categories from gitignore.io, then appends gitignore.extra-content.

repomatic.gitignore.GITIGNORE_BASE_CATEGORIES: tuple[str, ...] = ('certificates', 'emacs', 'git', 'gpg', 'linux', 'macos', 'node', 'nohup', 'python', 'rust', 'ssh', 'vim', 'virtualenv', 'visualstudiocode', 'windows')

Base gitignore.io template categories included in every generated .gitignore.

These cover common development environments, operating systems, and tools. Downstream projects can add more via gitignore.extra-categories in [tool.repomatic].

repomatic.gitignore.GITIGNORE_IO_URL = 'https://www.toptal.com/developers/gitignore/api'

gitignore.io API endpoint for fetching .gitignore templates.

repomatic.gitignore.build_gitignore(config)[source]

Fetch and assemble the .gitignore content for config.

Combines GITIGNORE_BASE_CATEGORIES with the configured extra categories (order-preserving, deduplicated), fetches the merged template from gitignore.io, and appends the configured extra content.

Parameters:

config (Config) – The resolved [tool.repomatic] configuration.

Return type:

str

Returns:

The full .gitignore text.

Raises:

urllib.error.URLError – When the gitignore.io fetch fails.

repomatic.http module

Shared JSON-over-HTTP fetch for the API clients.

The single implementation of the GET-and-parse-JSON loop used by the PyPI (repomatic.pypi), npm (repomatic.npm), and GitHub Releases (repomatic.github.releases) clients, so every datasource shares the same timeout and truncated-body retry semantics. Response caching stays with the callers: each client owns its cache namespace, TTL, and serialization.

exception repomatic.http.FetchError[source]

Bases: RuntimeError

Raised when a JSON fetch could not complete cleanly.

Wraps every failure mode of get_json(): HTTP 4xx/5xx, network error, timeout, truncated body (after its one retry), and JSON parse error. Callers decide whether a failure is fatal (GitHub pagination, where a missing page corrupts the result) or a soft miss (PyPI/npm lookups, logged and treated as “no data”).

repomatic.http.get_json(url, *, headers=None, timeout=10)[source]

GET url and parse the body as JSON, retrying once on truncation.

A truncated body (IncompleteRead) is transient (a flaky connection or an interfering proxy), so it earns one retry; every other failure mode fails straight away.

Parameters:
  • url (str) – The URL to fetch.

  • headers (Mapping[str, str] | None) – Extra request headers, merged over the JSON Accept default (caller wins on conflict).

  • timeout (int) – Socket timeout in seconds.

Return type:

tuple[Any, bytes]

Returns:

(parsed, raw_bytes): the decoded JSON value and the raw body (for callers that cache the verbatim response).

Raises:

FetchError – On any failure (see the class docstring).

repomatic.images module

Image optimization using external CLI tools.

Replaces the Docker-based calibreapp/image-actions GitHub Action with direct invocations of lightweight CLI tools, removing the Docker dependency and enabling ubuntu-slim runners.

Tools used per format:

  • PNG: oxipng (lossless, multithreaded Rust optimizer).

  • JPEG/JPG: jpegoptim (lossless Huffman optimization + metadata stripping).

Note

Both tools are strictly lossless: oxipng finds optimal PNG encoding parameters without altering pixel data, and jpegoptim (without -m) rewrites Huffman tables only. This means optimization is idempotent — a second run produces no further changes, so the workflow never creates noisy PRs for negligible savings.

Warning

WebP and AVIF are intentionally not optimized. The only available tools (cwebp, avifenc) work by lossy re-encoding: decode → re-compress at a target quality. This is not idempotent — each pass re-compresses the previous output, producing progressively smaller (and worse) files. The earlier calibreapp/image-actions suffered from this: it required multiple workflow runs to stabilize below the savings threshold, generating repeated PRs with diminishing returns and cumulative quality loss. Lossless WebP/AVIF modes exist but typically increase file size when applied to already lossy-encoded images, making them counterproductive. Since WebP and AVIF are modern formats chosen specifically for their compression efficiency, files in these formats are almost always already well-optimized at creation time.

class repomatic.images.OptimizationResult(path, before_bytes, after_bytes)[source]

Bases: object

Result of optimizing a single image file.

path: Path
before_bytes: int
after_bytes: int
property saved_bytes: int

Bytes saved by optimization.

property saved_pct: float

Percentage saved, as a float 0–100.

repomatic.images.format_file_size(size_bytes)[source]

Format a byte count as a human-readable string.

Uses KB/MB/GB with one decimal place, matching the format produced by calibreapp/image-actions.

Return type:

str

repomatic.images.optimize_image(path, min_savings_pct, min_savings_bytes=1024)[source]

Optimize a single image file in-place.

Parameters:
  • path (Path) – Path to the image file.

  • min_savings_pct (float) – Minimum percentage savings to keep the result. If savings are below this threshold, the original file is restored.

  • min_savings_bytes (int) – Minimum absolute byte savings to keep the result. Prevents noisy diffs for tiny files where even a high percentage represents negligible absolute savings.

Return type:

OptimizationResult | None

Returns:

An OptimizationResult if the file was optimized, or None if the format is unsupported, the required tool is missing, or savings were below the threshold.

repomatic.images.optimize_images(image_files, min_savings_pct=5, min_savings_bytes=1024)[source]

Optimize a list of image files.

Parameters:
  • image_files (Sequence[Path]) – Paths to image files.

  • min_savings_pct (float) – Minimum percentage savings to keep an optimization.

  • min_savings_bytes (int) – Minimum absolute byte savings to keep an optimization.

Return type:

list[OptimizationResult]

Returns:

List of results for files that were successfully optimized.

repomatic.images.generate_markdown_summary(results)[source]

Generate a markdown summary table of optimization results.

Produces a table similar to calibreapp/image-actions output, showing before/after sizes and percentage improvement for each optimized file.

Return type:

str

repomatic.init_project module

Bundled data files, configuration templates, and repository initialization.

Provides a unified interface for accessing bundled data files from repomatic/data/ and orchestrates repository bootstrapping via repomatic init.

Available components (repomatic init <component>):

  • workflows - Thin-caller workflow files

  • labels - Label definitions (labels.toml + labeller rules)

  • changelog - Minimal changelog.md

  • uv - Syncs the [tool.uv] resolver pins into pyproject.toml

  • ruff - Merges [tool.ruff] into pyproject.toml

  • pytest - Merges [tool.pytest] into pyproject.toml

  • mypy - Merges [tool.mypy] into pyproject.toml

  • bumpversion - Merges [tool.bumpversion] into pyproject.toml

  • agents - Claude Code agent definitions (.claude/agents/)

  • skills - Claude Code skill definitions (.claude/skills/)

  • awesome-template - Boilerplate for awesome-* repositories

Selectors use the same component[/file] syntax as the exclude config option in [tool.repomatic]. Qualified entries like skills/repomatic-topics select a single file within a component.

repomatic.init_project.RUNTIME_FRAGMENTS: tuple[str, ...] = ('release.yaml',)

Bundled YAML files loaded by repomatic at runtime, not deployed verbatim.

These files live in repomatic/data/ so they ship in the wheel and are discoverable via get_data_content(), but repomatic init never copies them as-is. release.yaml is the canonical caller repomatic.github.workflow_sync reads to assemble each downstream release.yaml, copying its jobs and rewriting the local uses:` refs (see `_generate_release_caller); the deployed release.yaml is generated, not this bundled copy. New entries must be added explicitly so the data-file registry tests stay authoritative.

repomatic.init_project.EXPORTABLE_FILES: dict[str, str | None] = {'_release-engine.yaml': '.github/workflows/release.yaml', 'action-publish-pypi.yaml': '.github/actions/publish-pypi/action.yaml', 'agent-grunt-qa.md': '.claude/agents/grunt-qa.md', 'agent-qa-engineer.md': '.claude/agents/qa-engineer.md', 'agent-sphinx-docs.md': '.claude/agents/sphinx-docs.md', 'autofix.yaml': '.github/workflows/autofix.yaml', 'autolock.yaml': '.github/workflows/autolock.yaml', 'bumpversion.toml': None, 'cancel-runs.yaml': '.github/workflows/cancel-runs.yaml', 'changelog.yaml': '.github/workflows/changelog.yaml', 'codecov.yaml': '.github/codecov.yaml', 'debug.yaml': '.github/workflows/debug.yaml', 'docs.yaml': '.github/workflows/docs.yaml', 'labeller-content-based.yaml': '.github/labeller-content-based.yaml', 'labeller-file-based.yaml': '.github/labeller-file-based.yaml', 'labels.toml': 'labels.toml', 'labels.yaml': '.github/workflows/labels.yaml', 'lint.yaml': '.github/workflows/lint.yaml', 'lychee.toml': None, 'mdformat.toml': None, 'mypy.toml': None, 'pytest.toml': None, 'release.yaml': None, 'ruff.toml': None, 'skill-av-false-positive.md': '.claude/skills/av-false-positive/SKILL.md', 'skill-awesome-triage.md': '.claude/skills/awesome-triage/SKILL.md', 'skill-babysit-ci.md': '.claude/skills/babysit-ci/SKILL.md', 'skill-benchmark-update.md': '.claude/skills/benchmark-update/SKILL.md', 'skill-brand-assets.md': '.claude/skills/brand-assets/SKILL.md', 'skill-file-bug-report.md': '.claude/skills/file-bug-report/SKILL.md', 'skill-repomatic-audit.md': '.claude/skills/repomatic-audit/SKILL.md', 'skill-repomatic-changelog.md': '.claude/skills/repomatic-changelog/SKILL.md', 'skill-repomatic-deps.md': '.claude/skills/repomatic-deps/SKILL.md', 'skill-repomatic-init.md': '.claude/skills/repomatic-init/SKILL.md', 'skill-repomatic-ship.md': '.claude/skills/repomatic-ship/SKILL.md', 'skill-repomatic-topics.md': '.claude/skills/repomatic-topics/SKILL.md', 'skill-sphinx-docs-sync.md': '.claude/skills/sphinx-docs-sync/SKILL.md', 'skill-translation-sync.md': '.claude/skills/translation-sync/SKILL.md', 'skill-upstream-audit.md': '.claude/skills/upstream-audit/SKILL.md', 'tests.yaml': '.github/workflows/tests.yaml', 'typos.toml': None, 'unsubscribe.yaml': '.github/workflows/unsubscribe.yaml', 'uv.toml': None, 'yamllint.yaml': None, 'zizmor.yaml': None}

Registry of all exportable files: maps filename to default output path.

None means the file is bundled but not directly written to a target path by repomatic init (used for pyproject.toml templates that need merging, tool-runner default configs, and runtime fragments).

repomatic.init_project.get_data_content(filename)[source]

Get the content of a bundled data file.

This is the low-level function for reading any file from repomatic/data/.

Parameters:

filename (str) – Name of the file to retrieve (e.g., “labels.toml”).

Return type:

str

Returns:

Content of the file as a string.

Raises:

FileNotFoundError – If the file doesn’t exist.

repomatic.init_project.export_content(filename)[source]

Get the content of any exportable bundled file.

Parameters:

filename (str) – The filename (e.g., “ruff.toml”, “labels.toml”, “release.yaml”).

Return type:

str

Returns:

Content of the file as a string.

Raises:
repomatic.init_project.init_config(config_type, pyproject_path=None)[source]

Initialize a configuration by merging it into pyproject.toml.

Reads the pyproject.toml file, checks if the tool section already exists, and if not, inserts the bundled template at the appropriate location.

The template is stored in native format (without [tool.X] prefix) and is parsed by tomlrt and added under the [tool] table.

Parameters:
  • config_type (str) – The configuration type (e.g., "ruff", "bumpversion").

  • pyproject_path (Path | None) – Path to pyproject.toml. Defaults to ./pyproject.toml.

Return type:

str | None

Returns:

The modified pyproject.toml content, or None if no changes needed.

Raises:

ValueError – If the config type is not supported.

repomatic.init_project.default_version_pin()[source]

Derive the default version pin from __version__.

Strips any .dev0 suffix and prefixes with v. For example, "5.10.0.dev0" becomes "v5.10.0".

Return type:

str

class repomatic.init_project.InitResult(created=<factory>, updated=<factory>, skipped=<factory>, excluded=<factory>, excluded_existing=<factory>, unmodified_configs=<factory>, removed_prunable=<factory>, removed_review=<factory>, warnings=<factory>)[source]

Bases: object

Result of a repository initialization run.

created: list[str]

Relative paths of newly created files.

updated: list[str]

Relative paths of existing files overwritten with new content.

skipped: list[str]

Relative paths of skipped (already existing) files.

excluded: list[str]

Exclude entries that were applied.

excluded_existing: list[str]

Relative paths of excluded files that still exist on disk.

unmodified_configs: list[str]

Relative paths of config files identical to bundled defaults.

removed_prunable: list[tuple[str, str]]

(relative_path, successor) for on-disk orphans of dropped assets whose content matches the last-shipped version (safe to auto-delete).

removed_review: list[tuple[str, str]]

(relative_path, successor) for on-disk orphans of dropped assets that differ from the last-shipped version (locally modified: reported for manual review, never auto-deleted).

warnings: list[str]

Warning messages emitted during initialization.

repomatic.init_project.run_init(output_dir, components=(), version=None, repo='kdeldycke/repomatic', repo_slug=None, config=None)[source]

Bootstrap a repository for use with kdeldycke/repomatic.

Creates thin-caller workflow files, exports configuration files, and generates a minimal changelog.md if missing. Managed files (workflows, configs, skills) are always overwritten. User-owned files (changelog.md, zizmor.yaml) are created once and never overwritten.

For awesome-* repositories, the awesome-template component is auto-included when no explicit component selection is made.

Note

Scope exclusions (RepoScope.AWESOME_ONLY, PYTHON_ONLY) and user-config exclusions ([tool.repomatic] exclude) only apply during bare repomatic init. When components are explicitly named on the CLI, scope is bypassed: the caller knows what they asked for. This allows workflows to materialize out-of-scope configs at runtime (like repomatic init publish-pypi-action in a non-Python repo).

Parameters:
  • output_dir (Path) – Root directory of the target repository.

  • components (Sequence[str]) – Components to initialize. Empty means all defaults. When non-empty, scope and user-config exclusions are bypassed.

  • version (str | None) – Version pin for upstream workflows (e.g., v5.10.0).

  • repo (str) – Upstream repository containing reusable workflows.

  • repo_slug (str | None) – Repository owner/name slug for awesome-template URL rewriting. Auto-detected via Metadata if not provided.

Return type:

InitResult

Returns:

Summary of created, updated, skipped, and warned items.

repomatic.init_project.is_source_repo(output_dir)[source]

Detect whether output_dir is the repomatic source repository root.

Returns True when output_dir contains the repomatic Python package source tree (repomatic/__init__.py and repomatic/data/). Only the upstream source repo has these. This prevents auto-exclusion from deleting files that are the source of truth (skills, opt-in workflows, bundled configs).

Note

Detection is based on output_dir contents, not on __file__, because uvx --from .` installs the package into a temp venv where `__file__ no longer points to the source checkout.

Return type:

bool

repomatic.init_project.FILE_RULE_CHANGED_FILES_MATCHERS: tuple[str, ...] = ('any-glob-to-any-file', 'any-glob-to-all-files', 'all-globs-to-any-file', 'all-globs-to-all-files')

actions/labeler matchers nested under changed-files in the rendered YAML.

repomatic.init_project.FILE_RULE_BRANCH_MATCHERS: tuple[str, ...] = ('head-branch', 'base-branch')

actions/labeler matchers that sit at the same level as changed-files.

repomatic.init_project.FILE_RULE_GROUP_WRAPPERS: tuple[str, ...] = ('any', 'all')

actions/labeler group wrappers whose value is a list of nested sub-groups.

repomatic.init_project.FILE_RULE_MATCHER_KEYS: frozenset[str] = frozenset({'all', 'all-globs-to-all-files', 'all-globs-to-any-file', 'any', 'any-glob-to-all-files', 'any-glob-to-any-file', 'base-branch', 'head-branch'})

Keys valid inside a match group (top-level entry minus label, or nested).

repomatic.init_project.CONTENT_RULE_KNOWN_KEYS: frozenset[str] = frozenset({'label', 'patterns'})

All keys recognized on a single [[labels.content-rules]] TOML entry.

repomatic.init_project.AWESOME_TEMPLATE_SLUG = 'kdeldycke/awesome-template'

Source slug embedded in bundled awesome-template files, rewritten at sync time.

repomatic.init_project.init_awesome_template(output_dir, repo_slug, result)[source]

Copy bundled awesome-template files and rewrite URLs.

Copies all files from the repomatic/data/awesome_template/ bundle into output_dir and rewrites kdeldycke/awesome-template URLs in .github/ markdown and YAML files to match repo_slug.

Parameters:
  • output_dir (Path) – Root directory of the target repository.

  • repo_slug (str) – Target owner/name slug for URL rewriting.

  • result (InitResult) – InitResult accumulator for created/updated files.

Return type:

None

repomatic.init_project.find_unmodified_init_files()[source]

Find init-managed config files identical to their bundled defaults.

Checks bundled components without keep_unmodified for files on disk whose content matches the bundled template (via export_content()) after trailing-whitespace normalization (.rstrip() + "\n").

Mirrors the API of tool_runner.find_unmodified_configs(), returning (component_name, relative_path) tuples.

Return type:

list[tuple[str, str]]

Returns:

List of (component_name, relative_path) tuples for each unmodified file found.

repomatic.init_project.find_all_unmodified_configs()[source]

Find all config files identical to their bundled defaults.

Combines tool configs (yamllint, zizmor, etc.) from tool_runner.find_unmodified_configs() and init-managed configs (like labels) from find_unmodified_init_files().

Return type:

list[tuple[str, str]]

Returns:

List of (label, relative_path) tuples for each unmodified file found.

repomatic.lint_repo module

Repository linting for GitHub Actions workflows.

This module provides consistency checks for repository metadata, including package names, website fields, descriptions, and funding configuration.

repomatic.lint_repo.get_repo_metadata(repo)[source]

Fetch repository metadata from GitHub API.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

dict[str, str | None]

Returns:

Dictionary with ‘homepageUrl’ and ‘description’ keys.

repomatic.lint_repo.check_package_name_vs_repo(package_name, repo_name)[source]

Check if package name matches repository name.

Parameters:
  • package_name (str | None) – The Python package name.

  • repo_name (str) – The repository name.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_website_for_sphinx(repo, is_sphinx, homepage_url=None)[source]

Check that Sphinx projects have a website set.

Parameters:
  • repo (str) – Repository in ‘owner/repo’ format.

  • is_sphinx (bool) – Whether the project uses Sphinx documentation.

  • homepage_url (str | None) – The homepage URL from API (to avoid duplicate calls).

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_description_matches(repo, project_description, repo_description=None)[source]

Check that repository description matches project description.

Parameters:
  • repo (str) – Repository in ‘owner/repo’ format.

  • project_description (str | None) – Description from pyproject.toml.

  • repo_description (str | None) – Description from API (to avoid duplicate calls).

Return type:

tuple[str | None, str]

Returns:

Tuple of (error_message or None, info_message).

repomatic.lint_repo.check_funding_file(repo)[source]

Check that repos with GitHub Sponsors have a FUNDING.yml.

Skips forks (they inherit the parent’s sponsor button) and owners without a Sponsors listing. Uses the GraphQL API because the REST API does not expose hasSponsorsListing.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_stale_draft_releases(repo)[source]

Check for draft releases that are not dev pre-releases.

Draft releases whose tag does not end with .dev0 are likely leftovers from abandoned or failed release attempts. The only expected drafts are the rolling dev pre-releases managed by sync-dev-release.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_topics_subset_of_keywords(repo, keywords=None)[source]

Check that GitHub repo topics are a subset of pyproject.toml keywords.

Parameters:
  • repo (str) – Repository in ‘owner/repo’ format.

  • keywords (list[str] | None) – Keywords from pyproject.toml. If None, check is skipped.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_pat_repository_scope(repo)[source]

Check that the PAT is scoped to only the current repository.

Fine-grained PATs should use Only select repositories to follow the principle of least privilege. This check detects tokens configured with All repositories access.

Two strategies are tried in order:

  1. GET /installation/repositories — returns the repos the token can access, including a repository_selection field.

  2. Cross-repo probe — check permissions.push on another repo owned by the same user. If the token can push to a repo it should not have access to, it is over-scoped.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_pat_stale_statuses_permission(repo)[source]

Detect a PAT that still grants the dropped Commit statuses permission.

REPOMATIC_PAT stopped needing statuses:write once the Renovate integration (and its stability-days status checks) was removed. A fine-grained PAT cannot report its own granted permissions, so this probes behaviorally: it attempts to create a commit status on NULL_SHA, a SHA that never resolves to a commit. GitHub authorizes the request before validating the resource, which splits the outcomes cleanly:

  • HTTP 403: the token lacks statuses:write (correctly scoped).

  • HTTP 422 (No commit found for SHA): authorization passed and only the SHA was rejected, so the token still grants the permission. Warn.

  • Anything else (404, 5xx, network): indeterminate, stay silent.

Note

Because NULL_SHA never resolves, no commit status is ever created: the probe mutates nothing. Only an unambiguous 422 raises the warning, so a future change to GitHub’s authorize-before-validate ordering degrades to under-reporting rather than a false warning.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_fork_pr_approval_policy(repo)[source]

Check that fork PR workflows require approval for first-time contributors.

GitHub Actions has a per-repository policy that controls when workflows from fork pull requests must be approved by a maintainer before they run. The three values, from weakest to strongest, are first_time_contributors_new_to_github, first_time_contributors, and all_external_contributors.

The default (first_time_contributors_new_to_github) only catches brand-new GitHub accounts, which is trivial to bypass with a slightly aged account. The minimum acceptable setting is first_time_contributors, which requires approval for any first-time contributor to this repository. This is one of the mitigations recommended in Astral’s open-source security post: see https://astral.sh/blog/open-source-security-at-astral.

Queries GET /repos/{repo}/actions/permissions/fork-pr-contributor-approval and returns False when the policy is weaker than first_time_contributors.

Note

This endpoint requires the Actions: read permission. When the REPOMATIC_PAT lacks it (or the API call fails for any other reason), the check returns None to signal that the result is indeterminate rather than negative.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[bool | None, str]

Returns:

Tuple of (passed_or_None, message). None means the check could not run (API inaccessible, unparsable, or unknown policy).

repomatic.lint_repo.check_tag_protection_rules(repo)[source]

Check that no tag rulesets could block the create-tag workflow job.

Tag rulesets that restrict creation or require status checks can prevent REPOMATIC_PAT (or GITHUB_TOKEN) from pushing release tags. This check queries the repository rulesets API and warns when any ruleset targets tags.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[str | None, str]

Returns:

Tuple of (warning_message or None, info_message).

repomatic.lint_repo.check_branch_ruleset_on_default(repo)[source]

Check that at least one active branch ruleset exists.

Queries the same GET /repos/{repo}/rulesets endpoint as check_tag_protection_rules() and looks for active rulesets with target == "branch". The presence of any such ruleset is taken as evidence that the default branch is protected (restrict deletions and block force pushes).

Note

This is a heuristic: it does not verify the ruleset targets the default branch specifically, nor that it enables the exact rules recommended by the setup guide. A deeper check would require fetching each ruleset’s conditions via GET /repos/{repo}/rulesets/{id}, adding N+1 API calls.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[bool, str]

Returns:

Tuple of (passed, message).

repomatic.lint_repo.check_immutable_releases(repo)[source]

Check that immutable releases are enabled for the repository.

Queries GET /repos/{repo}/immutable-releases and inspects the enabled field in the response.

Note

This endpoint requires the “Administration: Read-only” permission on fine-grained PATs. The REPOMATIC_PAT does not include this scope (too broad), so the check returns None when the API call fails, signaling that the result is indeterminate rather than negative.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[bool | None, str]

Returns:

Tuple of (passed_or_None, message). None means the check could not run (API inaccessible or unparsable).

repomatic.lint_repo.check_pages_deployment_source(repo)[source]

Check that GitHub Pages is deployed via GitHub Actions, not a branch.

The docs.yaml workflow uses actions/upload-pages-artifact and actions/deploy-pages, which require the Pages source to be set to GitHub Actions in the repository settings. Branch-based deployment (legacy) is incompatible.

Queries GET /repos/{repo}/pages and inspects the build_type field in the response.

Note

A 404 means Pages is not configured at all. This is treated as indeterminate (None) rather than a failure, because the repo may not have deployed docs yet.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[bool | None, str]

Returns:

Tuple of (passed_or_None, message). None means the check could not run (Pages not configured, or API inaccessible).

repomatic.lint_repo.check_pypi_trusted_publisher(repo, package_name)[source]

Check that the PyPI Trusted Publisher entry is registered for this repo.

PyPI’s Trusted Publisher settings are owner-only at /manage/project/<name>/settings/publishing/ and not exposed through any public API. The only public surface where the OIDC publisher is observable is the PEP 740 provenance attached to releases uploaded via OIDC: see repomatic.pypi.get_trusted_publishers(). This check probes the latest release’s provenance and looks for a bundle whose repository matches repo and whose workflow is PYPI_TRUSTED_PUBLISHER_WORKFLOW. A match means the publisher is wired up and a previous release uploaded successfully through it. A mismatch (provenance exists but names a different repo or workflow) is a misconfiguration: typical cause is registering the upstream reusable workflow instead of the downstream caller’s release.yaml, which fails on the first upload after migration. Indeterminate (None) covers two cases that look identical from the outside: no published release yet, and provenance missing because past releases were uploaded via API token. In both cases the setup guide nags until the next OIDC-attested upload appears.

Parameters:
  • repo (str) – Repository in "owner/repo" format.

  • package_name (str | None) – PyPI package name. The check is skipped when not provided.

Return type:

tuple[bool | None, str]

Returns:

Tuple of (passed_or_None, message).

repomatic.lint_repo.check_stale_gh_pages_branch(repo)[source]

Check for a leftover gh-pages branch after switching to GitHub Actions.

When Pages is deployed via GitHub Actions, the gh-pages branch is no longer needed and should be deleted to avoid confusion.

Parameters:

repo (str) – Repository in ‘owner/repo’ format.

Return type:

tuple[bool | None, str]

Returns:

Tuple of (passed_or_None, message).

repomatic.lint_repo.check_workflow_permissions()[source]

Check that workflows with custom jobs declare permissions: {}.

Thin-caller workflows (all jobs use uses: to call a reusable workflow) inherit permissions from the called workflow and do not need a top-level permissions key. Workflows that define their own steps: should declare permissions: {} to follow the principle of least privilege.

Return type:

list[tuple[str | None, str]]

Returns:

List of (warning_message or None, info_message) tuples.

repomatic.lint_repo.check_test_matrix_excludes()[source]

Flag [tool.repomatic.test-matrix] exclude entries that match no axis.

An exclude naming a value absent from every matrix axis (like a renamed runner) can never match a combination, so Matrix.prune() drops it silently and its exclusion intent is lost. Reporting it as a warning makes the drift visible in CI instead of silently weakening the matrix.

Return type:

list[tuple[str | None, str]]

Returns:

List of (warning_message or None, info_message) tuples.

repomatic.lint_repo.check_inline_pins_match_upstream(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic')[source]

Check inline upstream pins match the workflow uses: ref version.

A workflow that pins the upstream toolkit in a run: shell command (like uvx 'repomatic==1.2.3' metadata) must keep that version in lockstep with the SHA-pinned uses: refs. A manual workflow sync bumps the refs but not the inline pin, and sync-workflow-pins only realigns it on its next scheduled run, so the pin can lag in between. When the stale version drops a symbol the newer refs rely on, the metadata job fails and a release can publish to PyPI yet never tag (the toolkit chicken-and-egg). Flag the drift so the lint fails before a release does.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files.

  • upstream_repo (str) – Upstream owner/repo; its name is the inline package to match (e.g. repomatic).

Return type:

tuple[str | None, str]

Returns:

(error_message_or_None, info_message).

repomatic.lint_repo.run_repo_lint(package_name=None, repo_name=None, is_sphinx=False, project_description=None, keywords=None, repo=None, has_pat=False, has_virustotal_key=False, nuitka_active=False, has_notifications_pat=False, unsubscribe_active=False)[source]

Run all repository lint checks.

Emits GitHub Actions annotations for each check result.

Parameters:
  • package_name (str | None) – The Python package name.

  • repo_name (str | None) – The repository name.

  • is_sphinx (bool) – Whether the project uses Sphinx documentation.

  • project_description (str | None) – Description from pyproject.toml.

  • keywords (list[str] | None) – Keywords list from pyproject.toml.

  • repo (str | None) – Repository in ‘owner/repo’ format.

  • has_pat (bool) – Whether GH_TOKEN contains REPOMATIC_PAT.

  • has_virustotal_key (bool) – Whether VIRUSTOTAL_API_KEY is configured.

  • has_notifications_pat (bool) – Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

  • unsubscribe_active (bool) – Whether the unsubscribe workflow is opted in via notification.unsubscribe.

Return type:

int

Returns:

Exit code (0 for success, 1 for errors).

repomatic.mailmap module

repomatic.mailmap.MAILMAP_PATH = PosixPath('.mailmap')

Canonical path to the .mailmap file in the repository root.

class repomatic.mailmap.Record(canonical='', aliases=<factory>, pre_comment='')[source]

Bases: object

A mailmap identity mapping entry.

canonical: str = ''
aliases: set[str]
pre_comment: str = ''
class repomatic.mailmap.Mailmap[source]

Bases: object

Helpers to manipulate .mailmap files.

.mailmap file format is documented on Git website.

Initialize the mailmap with an empty list of records.

records: list[Record]
static split_identities(mapping)[source]

Split a mapping of identities and normalize them.

Return type:

tuple[str, set[str]]

parse(content)[source]

Parse mailmap content and add it to the current list of records.

Each non-empty, non-comment line is considered a mapping entry.

The preceding lines of a mapping entry are kept attached to it as pre-comments, so the layout will be preserved on rendering, during which records are sorted.

Return type:

None

find(identity)[source]

Returns True if the provided identity matched any record.

Return type:

bool

property git_contributors: set[str][source]

Returns the set of all contributors found in the Git commit history.

No normalization happens: all variations of authors and committers strings attached to all commits are considered.

For format output syntax, see: https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-aN

update_from_git()[source]

Add to internal records all missing contributors found in commit history.

This method will refrain from adding contributors already registered as aliases.

Return type:

None

render()[source]

Render internal records in Mailmap format.

Return type:

str

repomatic.metadata module

Extract metadata from repository and Python projects to be used by GitHub workflows.

This module solves a fundamental limitation of GitHub Actions: a workflow run is triggered by a singular event, which might encapsulate multiple commits. GitHub only exposes github.event.head_commit (the most recent commit), but workflows often need to process all commits in the push event.

This is critical for releases, where two commits are pushed together:

  1. [changelog] Release vX.Y.Z — the release commit to be tagged and published

  2. [changelog] Post-release bump vX.Y.Z vX.Y.Z — bumps version for the next dev cycle

Since github.event.head_commit only sees the post-release bump, this module extracts the full commit range from the push event and identifies release commits that need special handling (tagging, PyPI publishing, GitHub release creation).

The following variables are printed to the environment file:

is_bot=false
new_commits=346ce664f055fbd042a25ee0b7e96702e95 6f27db47612aaee06fdf08744b09a9f5f6c2
release_commits=6f27db47612aaee06fdf08744b09a9f5f6c2
mailmap_exists=true
gitignore_exists=true
python_files=".github/update_mailmap.py" ".github/metadata.py" "setup.py"
json_files=
yaml_files="config.yaml" ".github/workflows/lint.yaml" ".github/workflows/test.yaml"
workflow_files=".github/workflows/lint.yaml" ".github/workflows/test.yaml"
doc_files="changelog.md" "readme.md" "docs/license.md"
markdown_files="changelog.md" "readme.md" "docs/license.md"
image_files=
zsh_files=
is_python_project=true
package_name=click-extra
project_description=📦 Extra colorful clickable helpers for the CLI.
mypy_params=--python-version 3.7
current_version=2.0.1
released_version=2.0.0
is_sphinx=true
active_autodoc=true
release_notes=`🐍 Available on PyPI <https://pypi.org/project/click-extra/2.21.3>`_.
new_commits_matrix={
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2"
    ],
    "include": [
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.1"
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0"
        }
    ]
}
release_commits_matrix={
    "commit": ["6f27db47612aaee06fdf08744b09a9f5f6c2"],
    "include": [
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0"
        }
    ]
}
build_targets=[
    {
        "target": "linux-arm64",
        "os": "ubuntu-24.04-arm",
        "platform_id": "linux",
        "arch": "arm64",
        "extension": "bin"
    },
    {
        "target": "linux-x64",
        "os": "ubuntu-24.04",
        "platform_id": "linux",
        "arch": "x64",
        "extension": "bin"
    },
    {
        "target": "macos-arm64",
        "os": "macos-26",
        "platform_id": "macos",
        "arch": "arm64",
        "extension": "bin"
    },
    {
        "target": "macos-x64",
        "os": "macos-26-intel",
        "platform_id": "macos",
        "arch": "x64",
        "extension": "bin"
    },
    {
        "target": "windows-arm64",
        "os": "windows-11-arm",
        "platform_id": "windows",
        "arch": "arm64",
        "extension": "exe"
    },
    {
        "target": "windows-x64",
        "os": "windows-2025",
        "platform_id": "windows",
        "arch": "x64",
        "extension": "exe"
    }
]
nuitka_matrix={
    "os": [
        "ubuntu-24.04-arm",
        "ubuntu-24.04",
        "macos-26",
        "macos-26-intel",
        "windows-11-arm",
        "windows-2025"
    ],
    "entry_point": ["mpm"],
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2"
    ],
    "include": [
        {
            "target": "linux-arm64",
            "os": "ubuntu-24.04-arm",
            "platform_id": "linux",
            "arch": "arm64",
            "extension": "bin"
        },
        {
            "target": "linux-x64",
            "os": "ubuntu-24.04",
            "platform_id": "linux",
            "arch": "x64",
            "extension": "bin"
        },
        {
            "target": "macos-arm64",
            "os": "macos-26",
            "platform_id": "macos",
            "arch": "arm64",
            "extension": "bin"
        },
        {
            "target": "macos-x64",
            "os": "macos-26-intel",
            "platform_id": "macos",
            "arch": "x64",
            "extension": "bin"
        },
        {
            "target": "windows-arm64",
            "os": "windows-11-arm",
            "platform_id": "windows",
            "arch": "arm64",
            "extension": "exe"
        },
        {
            "target": "windows-x64",
            "os": "windows-2025",
            "platform_id": "windows",
            "arch": "x64",
            "extension": "exe"
        },
        {
            "entry_point": "mpm",
            "cli_id": "mpm",
            "module_id": "meta_package_manager.__main__",
            "callable_id": "main",
            "module_path": "meta_package_manager"
        },
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.0"
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "1.9.1"
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-linux-arm64.bin"
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-linux-arm64.bin"
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-linux-x64.bin"
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-linux-x64.bin"
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-macos-arm64.bin"
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-macos-arm64.bin"
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-macos-x64.bin"
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-macos-x64.bin"
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-windows-arm64.bin"
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-windows-arm64.bin"
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-windows-x64.exe"
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-windows-x64.exe"
        },
        {"state": "stable"}
    ]
}

Warning

Fields with serialized lists and dictionaries, like new_commits_matrix, build_targets or nuitka_matrix, are pretty-printed in the example above for readability. They are inlined in the actual output and not formatted this way.

class repomatic.metadata.Dialect(*values)[source]

Bases: StrEnum

Output dialect for metadata serialization.

github = 'github'
github_json = 'github-json'
json = 'json'
serialize(metadata)[source]

Render metadata in this dialect.

Parameters:

metadata (dict[str, Any]) – Raw key-to-value mapping from Metadata.dump().

Return type:

str

Returns:

The serialized payload.

repomatic.metadata.METADATA_KEYS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Key', 'key'), ('Description', 'description'))

Column definitions for the metadata keys reference table.

repomatic.metadata.metadata_keys_reference()[source]

Build the metadata keys reference as table rows.

Returns a list of (key, description) tuples for all keys produced by Metadata.dump(), including [tool.repomatic] config fields that are exposed as metadata outputs. Rows are unsorted: sorting is handled by the CLI’s SortByOption.

Return type:

list[tuple[str, str]]

repomatic.metadata.all_metadata_keys()[source]

Returns the set of all valid metadata key names.

Return type:

frozenset[str]

repomatic.metadata.HEREDOC_FIELDS: Final[frozenset[str]] = frozenset({'release_notes', 'release_notes_with_admonition'})

Metadata fields that should always use heredoc format in GitHub Actions output.

Some fields may contain special characters (brackets, parentheses, emojis, or potential newlines) that can break GitHub Actions parsing when using simple key=value format. These fields will use the heredoc delimiter format regardless of whether they currently contain multiple lines.

repomatic.metadata.is_version_bump_allowed(part)[source]

Check if a version bump of the specified part is allowed.

This prevents double version increments within a development cycle. A bump is blocked if the version has already been bumped (but not released) since the last tagged release.

For example: - Last release: v5.0.1, current: 5.0.2 → minor bump allowed - Last release: v5.0.1, current: 5.1.0 → minor bump NOT allowed (bumped) - Last release: v5.0.1, current: 6.0.0 → major bump NOT allowed (bumped)

Note

When tags are not available (e.g., due to race conditions between workflows), this function falls back to parsing version from recent commit messages.

Parameters:

part (Literal['minor', 'major']) – The version part to check (minor or major).

Return type:

bool

Returns:

True if the bump should proceed, False if it should be skipped.

repomatic.metadata.stale_axis_values(entry, axes)[source]

Return the entry key/value pairs absent from the matrix axes.

A non-empty result means a test-matrix.exclude entry can never match a combination: one of its keys is not a live axis, or its value is absent from that axis. See Metadata.stale_test_matrix_excludes().

Return type:

dict[str, str]

class repomatic.metadata.JSONMetadata(*, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, indent=None, separators=None, default=None)[source]

Bases: JSONEncoder

Custom JSON encoder for metadata serialization.

Constructor for JSONEncoder, with sensible defaults.

If skipkeys is false, then it is a TypeError to attempt encoding of keys that are not str, int, float, bool or None. If skipkeys is True, such items are simply skipped.

If ensure_ascii is true, the output is guaranteed to be str objects with all incoming non-ASCII and non-printable characters escaped. If ensure_ascii is false, the output can contain non-ASCII and non-printable characters.

If check_circular is true, then lists, dicts, and custom encoded objects will be checked for circular references during encoding to prevent an infinite recursion (which would cause an RecursionError). Otherwise, no such check takes place.

If allow_nan is true, then NaN, Infinity, and -Infinity will be encoded as such. This behavior is not JSON specification compliant, but is consistent with most JavaScript based encoders and decoders. Otherwise, it will be a ValueError to encode such floats.

If sort_keys is true, then the output of dictionaries will be sorted by key; this is useful for regression tests to ensure that JSON serializations can be compared on a day-to-day basis.

If indent is a non-negative integer, then JSON array elements and object members will be pretty-printed with that indent level. An indent level of 0 will only insert newlines. None is the most compact representation.

If specified, separators should be an (item_separator, key_separator) tuple. The default is (’, ‘, ‘: ‘) if indent is None and (‘,’, ‘: ‘) otherwise. To get the most compact JSON representation, you should specify (‘,’, ‘:’) to eliminate whitespace.

If specified, default is a function that gets called for objects that can’t otherwise be serialized. It should return a JSON encodable version of the object or raise a TypeError.

default(o)[source]

Implement this method in a subclass such that it returns a serializable object for o, or calls the base implementation (to raise a TypeError).

For example, to support arbitrary iterators, you could implement default like this:

def default(self, o):
    try:
        iterable = iter(o)
    except TypeError:
        pass
    else:
        return list(iterable)
    # Let the base class default method raise the TypeError
    return super().default(o)
Return type:

Any

class repomatic.metadata.Metadata[source]

Bases: object

Metadata class.

Implemented as a singleton: every Metadata() call returns the same instance within a process. This is safe because env vars and project files do not change during a single CLI invocation. Use reset() in test teardown to discard the cached instance between tests.

Initialize internal variables.

classmethod reset()[source]

Discard the singleton so the next call creates a fresh instance.

Intended for test teardown only. Production code should never call this.

Return type:

None

pyproject_path = PosixPath('pyproject.toml')
sphinx_conf_path = PosixPath('docs/conf.py')
property github_event: dict[str, Any][source]

Load the GitHub event payload from GITHUB_EVENT_PATH.

GitHub Actions automatically sets GITHUB_EVENT_PATH to a JSON file containing the complete webhook event payload.

git_stash_count()[source]

Returns the number of stashes.

Return type:

int

git_deepen(commit_hash, max_attempts=10, deepen_increment=50)[source]

Deepen a shallow clone until the provided commit_hash is found.

Progressively fetches more commits from the current repository until the specified commit is found or max attempts is reached.

Returns True if the commit was found, False otherwise.

Return type:

bool

commit_matrix(commits)[source]

Pre-compute a matrix of commits.

Danger

This method temporarily modify the state of the repository to compute version metadata from the past.

To prevent any loss of uncommitted data, it stashes and unstash the local changes between checkouts.

The list of commits is augmented with long and short SHA values, as well as current version. Most recent commit is first, oldest is last.

Returns a ready-to-use matrix structure:

{
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2",
    ],
    "include": [
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.1",
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "2.0.0",
        },
    ],
}
Return type:

Matrix | None

property event_type: WorkflowEvent | None[source]

Returns the type of event that triggered the workflow run.

Caution

This property is based on a crude heuristics as it only looks at the value of the GITHUB_BASE_REF environment variable. Which is only set when the event that triggers a workflow run is either pull_request or pull_request_target.

Todo

Add detection of all workflow trigger events.

property event_actor: str | None[source]

Returns the GitHub login of the user that triggered the workflow run.

property event_sender_type: str | None[source]

Returns the type of the user that triggered the workflow run.

property is_bot: bool[source]

Returns True if the workflow was triggered by a bot or automated process.

This is useful to only run some jobs on human-triggered events. Or skip jobs triggered by bots to avoid infinite loops.

property head_branch: str | None[source]

Returns the head branch name for pull request events.

For pull request events, this is the source branch name (e.g., update-mailmap). For push events, returns None since there’s no head branch concept.

The branch name is extracted from the GITHUB_HEAD_REF environment variable, which is only set for pull request events.

property event_name: str | None[source]

Returns the name of the event that triggered the workflow.

Reads GITHUB_EVENT_NAME. This is the raw event name (e.g., "push", "pull_request", "workflow_run"), as opposed to event_type which returns a WorkflowEvent enum based on heuristics.

property job_name: str | None[source]

Returns the ID of the current job in the workflow.

Reads GITHUB_JOB.

property ref_name: str | None[source]

Returns the short ref name of the branch or tag.

Reads GITHUB_REF_NAME.

property repo_name: str | None[source]

Returns the repository name without owner prefix.

Derived from repo_slug by splitting on /.

property is_awesome: bool[source]

Whether this is an awesome-list repository.

Detected by the awesome- prefix on the repository name.

property repo_owner: str | None[source]

Returns the repository owner.

Reads GITHUB_REPOSITORY_OWNER, falling back to the owner component of repo_slug.

property repo_slug: str | None[source]

Returns the owner/name slug for the current repository.

Resolution order: GITHUB_REPOSITORY env var (CI), gh repo view (authenticated local), git remote URL parsing (offline fallback).

property repo_url: str | None[source]

Returns the full URL to the repository.

Derived from server_url and repo_slug.

property run_attempt: str | None[source]

Returns the run attempt number.

Reads GITHUB_RUN_ATTEMPT.

property run_id: str | None[source]

Returns the unique ID of the current workflow run.

Reads GITHUB_RUN_ID.

property run_number: str | None[source]

Returns the run number for the current workflow.

Reads GITHUB_RUN_NUMBER.

property server_url: str[source]

Returns the GitHub server URL.

Reads GITHUB_SERVER_URL, defaulting to https://github.com.

property sha: str | None[source]

Returns the commit SHA that triggered the workflow.

Reads GITHUB_SHA.

property triggering_actor: str | None[source]

Returns the login of the user that initiated the workflow run.

Reads GITHUB_TRIGGERING_ACTOR. This differs from event_actor (GITHUB_ACTOR) when a workflow is re-run by a different user.

property workflow_ref: str | None[source]

Returns the full workflow reference.

Reads GITHUB_WORKFLOW_REF. The format is owner/repo/.github/workflows/name.yaml@refs/heads/branch.

property changed_files: tuple[str, ...] | None[source]

Returns the list of files changed in the current event’s commit range.

Uses git diff --name-only between the start and end of the commit range. Returns None if no commit range is available (e.g., outside CI).

property binary_affecting_paths: tuple[str, ...][source]

Path prefixes that affect compiled binaries for this project.

Combines the static BINARY_AFFECTING_PATHS (common files like pyproject.toml, uv.lock, tests/) with project-specific source directories derived from [project.scripts] in pyproject.toml.

For example, a project with mpm = "meta_package_manager.__main__:main" adds meta_package_manager/ as an affecting path. This makes the check reusable across downstream repositories without hardcoding source directories.

property head_commit_message: str[source]

Returns github.event.head_commit.message from the event payload.

Set for push events. Empty string for events that do not carry a head commit (pull_request, schedule, workflow_dispatch).

property yaml_changed: bool[source]

Returns True when the current event’s commit range touches at least one YAML file.

Lets per-job lint gates short-circuit on pushes / PRs that don’t touch YAML. Falls back to “repo contains any YAML file” when the commit range is unavailable (workflow_dispatch), preserving the existing behavior of those manual runs.

property zsh_changed: bool[source]

Returns True when the current event’s commit range touches at least one Zsh file.

Falls back to “repo contains any Zsh file” when the commit range is unavailable.

property workflows_changed: bool[source]

Returns True when the current event’s commit range touches at least one GitHub workflow file.

Falls back to “repo contains any workflow file” when the commit range is unavailable.

property skip_binary_build: bool[source]

Returns True if binary builds should be skipped for this event.

Binary builds are expensive and time-consuming. This property identifies contexts where the changes cannot possibly affect compiled binaries, allowing workflows to skip Nuitka compilation jobs.

Three mechanisms are checked:

  1. Branch name — PRs from known non-code branches (documentation, .mailmap, .gitignore, etc.) are skipped.

  2. Version-bump commit — Push events whose head commit is a user-initiated version bump (Bump (major|minor) version to ``) are skipped: the bump merge changes only version strings and ``uv.lock, so the new binary differs from the previous one only in the baked-in version string. The [changelog] Post-release bump `` prefix is deliberately *not* checked here: the ``prepare-release merge bundles the release commit with the post-release-bump commit, and the release commit must still produce its binary.

  3. Changed files — Push events where all changed files fall outside binary_affecting_paths are skipped. This avoids ~2h of Nuitka builds for documentation-only commits to main.

property commit_range: tuple[str | None, str] | None[source]

Range of commits bundled within the triggering event.

A workflow run is triggered by a singular event, which might encapsulate one or more commits. This means the workflow will only run once on the last commit, even if multiple new commits were pushed.

This is critical for releases where two commits are pushed together:

  1. [changelog] Release vX.Y.Z — the release commit

  2. [changelog] Post-release bump vX.Y.Z vX.Y.Z — the post-release bump

Without extracting the full commit range, the release commit would be missed since github.event.head_commit only exposes the post-release bump.

This property also enables processing each commit individually when we want to keep a carefully constructed commit history. The typical example is a pull request that is merged upstream but we’d like to produce artifacts (builds, packages, etc.) for each individual commit.

The default GITHUB_SHA environment variable is not enough as it only points to the last commit. We need to inspect the commit history to find all new ones. New commits need to be fetched differently in push and pull_request events.

See also

Pull request events on GitHub are a bit complex, see: The Many SHAs of a GitHub Pull Request.

property current_commit: Commit[source]

Returns the current Commit object.

Raises if HEAD cannot be resolved (an empty repository), mirroring the previous behavior where traversing an empty history raised too.

property current_commit_matrix: Matrix | None[source]

Pre-computed matrix with long and short SHA values of the current commit.

property new_commits: tuple[Commit, ...] | None[source]

Returns list of all Commit objects bundled within the triggering event.

This extracts all commits from the push event, not just head_commit. For releases, this typically includes both the release commit and the post-release bump commit, allowing downstream jobs to process each one.

Commits are returned in chronological order (oldest first, most recent last).

property new_commits_matrix: Matrix | None[source]

Pre-computed matrix with long and short SHA values of new commits.

property new_commits_hash: tuple[str, ...] | None[source]

List all hashes of new commits.

property release_commits: tuple[Commit, ...] | None[source]

Returns list of Commit objects to be tagged within the triggering event.

This filters new_commits to find release commits that need special handling: tagging, PyPI publishing, and GitHub release creation.

This is essential because when a release is pushed, github.event.head_commit only exposes the post-release bump commit, not the release commit. By extracting all commits from the event (via new_commits) and filtering for release commits here, we ensure the release workflow can properly identify and process the [changelog] Release vX.Y.Z commit.

We cannot identify a release commit based on the presence of a vX.Y.Z tag alone. That’s because the tag is not present in the prepare-release pull request produced by the changelog.yaml workflow. The tag is created later by the release.yaml workflow, when the pull request is merged to main.

Our best option is to identify a release based on the full commit message, using the template from the changelog.yaml workflow.

property release_commits_matrix: Matrix | None[source]

Pre-computed matrix with long and short SHA values of release commits.

property release_commits_hash: tuple[str, ...] | None[source]

List all hashes of release commits.

property mailmap_exists: bool[source]
property gitignore_exists: bool[source]
property gitignore_parser: Parser | None[source]

Returns a parser for the .gitignore file, if it exists.

gitignore_match(file_path)[source]
Return type:

bool

glob_files(*patterns)[source]

Return all file path matching the patterns.

Patterns are glob patterns supporting ** for recursive search, and ! for negation.

All directories are traversed, whether they are hidden (i.e. starting with a dot .) or not, including symlinks.

Skips:

  • files which does not exists

  • directories

  • broken symlinks

  • files matching patterns specified by .gitignore file

Returns both hidden and non-hidden files.

All files are normalized to their absolute path, so that duplicates produced by symlinks are ignored.

File path are returned as relative to the current working directory if possible, or as absolute path otherwise.

The resulting list of file paths is sorted.

Return type:

list[Path]

property python_files: list[Path][source]

Returns a list of python files.

property json_files: list[Path][source]

Returns a list of JSON files.

Note

JSON5 files are excluded because Biome doesn’t support them.

property yaml_files: list[Path][source]

Returns a list of YAML files.

property toml_files: list[Path][source]

Returns a list of TOML files.

property pyproject_files: list[Path][source]

Returns a list of pyproject.toml files.

property workflow_files: list[Path][source]

Returns a list of GitHub workflow files.

property doc_files: list[Path][source]

Returns a list of doc files.

property markdown_files: list[Path][source]

Returns a list of Markdown files.

property image_files: list[Path][source]

Returns a list of image files.

Covers the formats handled by repomatic format-images: JPEG, PNG, WebP, and AVIF. See repomatic.images for the optimization tools.

property shfmt_files: list[Path][source]

Returns a list of shell files that shfmt can reliably format.

shfmt supports the following dialects (-ln flag):

  • bash: GNU Bourne Again Shell.

  • posix: POSIX Shell (/bin/sh).

  • mksh: MirBSD Korn Shell.

  • bats: Bash Automated Testing System.

Zsh is excluded. shfmt added experimental Zsh support in v3.13.0 but it fails on common constructs: for var (list) short-form loops and for ... { } brace-delimited loops. See mvdan/sh#1203 for upstream tracking.

Files are excluded by extension (.zsh, .zshrc, etc.) and by shebang (any .sh file whose first line references zsh).

property zsh_files: list[Path][source]

Returns a list of Zsh files.

property is_python_project: bool[source]

Returns True if repository is a Python project.

Presence of a pyproject.toml file that respects the standards is enough to consider the project as a Python one. Delegates to repomatic.pyproject.is_python_project() so the detection rule has a single source of truth.

property pyproject_toml: dict[str, Any][source]

Returns the raw parsed content of pyproject.toml.

Returns an empty dict if the file does not exist.

property pyproject: StandardMetadata | None[source]

Returns metadata stored in the pyproject.toml file.

Returns None if the pyproject.toml does not exists or does not respects the PEP standards.

Warning

Some third-party apps have their configuration saved into pyproject.toml file, but that does not means the project is a Python one. For that, the pyproject.toml needs to respect the PEPs.

property config: Config[source]

Returns the [tool.repomatic] section from pyproject.toml.

Merges user configuration with defaults from Config.

property nuitka_entry_points: list[str][source]

Entry points selected for Nuitka binary compilation.

Reads [tool.repomatic].nuitka.entry-points from pyproject.toml. When empty (the default), deduplicates by callable target: keeps the first entry point for each unique module:callable pair, so alias entry points (like both mpm and meta-package-manager pointing to the same function) don’t produce duplicate binaries. Unrecognized CLI IDs are logged as warnings and discarded.

property unstable_targets: set[str][source]

Nuitka build targets allowed to fail without blocking the release.

Reads [tool.repomatic].nuitka.unstable-targets from pyproject.toml. Defaults to an empty set.

Unrecognized target names are logged as warnings and discarded.

property package_name: str | None[source]

Returns package name as published on PyPI.

property project_description: str | None[source]

Returns project description from pyproject.toml.

property script_entries: list[tuple[str, str, str]][source]

Returns a list of tuples containing the script name, its module and callable.

Results are derived from the script entries of pyproject.toml. So that:

[project.scripts]
mdedup = "mail_deduplicate.cli:mdedup"
mpm = "meta_package_manager.__main__:main"

Will yields the following list:

(
    ("mdedup", "mail_deduplicate.cli", "mdedup"),
    ("mpm", "meta_package_manager.__main__", "main"),
    ...,
)

Each entry is validated against PEP 621 and PyPI conventions:

  • The script name (the dict key) must be non-empty, contain at least one non-dot character, and match [A-Za-z0-9._-]+. This mirrors the rule PyPI enforces on uploaded wheels and the check uv-build performs; rejecting names like ../escape, nested/script or . here keeps them from flowing into the binary file path template {{cli_id}}-{{current_version}}-{{target}}.{{extension}} and from there into shell-quoted artifact names, chmod, and attestation commands in the release workflow.

  • The script value must split on : into exactly two non-empty parts (module:object). Malformed values raise a descriptive ValueError instead of crashing with an unpacking error.

property mypy_params: list[str] | None[source]

Generates mypy parameters.

Mypy needs to be fed with this parameter: --python-version 3.x.

Extracts the minimum Python version from the project’s requires-python specifier. Only takes major.minor into account.

static get_current_version()[source]

Returns the current version as managed by bump-my-version.

Same as calling the CLI:

$ bump-my-version show current_version

Reads current_version from the first TOML file found in the current working directory: .bumpversion.toml (top-level table) or pyproject.toml ([tool.bumpversion]).

Return type:

str | None

property current_version: str | None[source]

Returns the current version.

Current version is fetched from the bump-my-version configuration file.

During a release, two commits are bundled into a single push event:

  1. [changelog] Release vX.Y.Z — freezes the version to the release number

  2. [changelog] Post-release bump vX.Y.Z vX.Y.Z — bumps to the next dev version

In this situation, the current version returned is the one from the most recent commit (the post-release bump), which represents the next development version. Use released_version to get the version from the release commit.

property released_version: str | None[source]

Returns the version of the release commit.

During a release push event, this extracts the version from the [changelog] Release vX.Y.Z commit, which is distinct from current_version (the post-release bump version). This is used for tagging, PyPI publishing, and GitHub release creation.

Returns None if no release commit is found in the current event.

property is_sphinx: bool[source]

Returns True if the Sphinx config file is present.

property minor_bump_allowed: bool[source]

Check if a minor version bump is allowed.

This prevents double version increments within a development cycle.

property major_bump_allowed: bool[source]

Check if a major version bump is allowed.

This prevents double version increments within a development cycle.

property active_autodoc: bool[source]

Returns True if Sphinx autodoc is active.

property uses_myst: bool[source]

Returns True if MyST-Parser is active in Sphinx.

property nuitka_matrix: Matrix | None[source]

Pre-compute a matrix for Nuitka compilation workflows.

Combine the variations of: - release commits only (during releases) or all new commits (otherwise) - all entry points - for the 3 main OSes - for a set of architectures

Returns a ready-to-use matrix structure, where each variation is augmented with specific extra parameters by the way of matching parameters in the include directive.

{
    "os": [
        "ubuntu-24.04-arm",
        "ubuntu-24.04",
        "macos-26",
        "macos-26-intel",
        "windows-11-arm",
        "windows-2025",
    ],
    "entry_point": [
        "mpm",
    ],
    "commit": [
        "346ce664f055fbd042a25ee0b7e96702e95",
        "6f27db47612aaee06fdf08744b09a9f5f6c2",
    ],
    "include": [
        {
            "target": "linux-arm64",
            "os": "ubuntu-24.04-arm",
            "platform_id": "linux",
            "arch": "arm64",
            "extension": "bin",
        },
        {
            "target": "linux-x64",
            "os": "ubuntu-24.04",
            "platform_id": "linux",
            "arch": "x64",
            "extension": "bin",
        },
        {
            "target": "macos-arm64",
            "os": "macos-26",
            "platform_id": "macos",
            "arch": "arm64",
            "extension": "bin",
        },
        {
            "target": "macos-x64",
            "os": "macos-26-intel",
            "platform_id": "macos",
            "arch": "x64",
            "extension": "bin",
        },
        {
            "target": "windows-arm64",
            "os": "windows-11-arm",
            "platform_id": "windows",
            "arch": "arm64",
            "extension": "exe",
        },
        {
            "target": "windows-x64",
            "os": "windows-2025",
            "platform_id": "windows",
            "arch": "x64",
            "extension": "exe",
        },
        {
            "entry_point": "mpm",
            "cli_id": "mpm",
            "module_id": "meta_package_manager.__main__",
            "callable_id": "main",
            "module_path": "meta_package_manager",
        },
        {
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "short_sha": "346ce66",
            "current_version": "2.0.0",
        },
        {
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "short_sha": "6f27db4",
            "current_version": "1.9.1",
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-linux-arm64.bin",
        },
        {
            "os": "ubuntu-24.04-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-linux-arm64.bin",
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-linux-x64.bin",
        },
        {
            "os": "ubuntu-24.04",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-linux-x64.bin",
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-macos-arm64.bin",
        },
        {
            "os": "macos-26",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-macos-arm64.bin",
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-macos-x64.bin",
        },
        {
            "os": "macos-26-intel",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-macos-x64.bin",
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-windows-arm64.exe",
        },
        {
            "os": "windows-11-arm",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-windows-arm64.exe",
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "346ce664f055fbd042a25ee0b7e96702e95",
            "bin_name": "mpm-2.0.0-windows-x64.exe",
        },
        {
            "os": "windows-2025",
            "entry_point": "mpm",
            "commit": "6f27db47612aaee06fdf08744b09a9f5f6c2",
            "bin_name": "mpm-1.9.1-windows-x64.exe",
        },
        {
            "state": "stable",
        },
    ],
}
property test_matrix: Matrix[source]

Full test matrix for non-PR events.

Combines all runner OS images and Python versions, excluding known incompatible combinations. Marks development Python versions as unstable so CI can use continue-on-error, and adds released build flavors (free-threaded) as stable single-runner smoke tests. Per-project config from [tool.repomatic.test-matrix] is applied last.

When [tool.repomatic.test-matrix] full-include rows are configured, the matrix is emitted as a flat job list ({"include": [...]}) so each row is a standalone combination GitHub runs verbatim, rather than one that augments a base combo sharing its os and python-version.

property test_matrix_pr: Matrix[source]

Reduced test matrix for pull requests.

Skips experimental Python versions and redundant architecture variants to reduce CI load on PRs. Per-project config excludes and includes from [tool.repomatic.test-matrix] are applied, but variations are not (to keep the PR matrix small).

property coverage_cells: list[str][source]

Matrix cells eligible for Codecov coverage upload, as os|python-version.

Coverage is a function of OS family and Python version, never CPU architecture, so uploading from every cell of test_matrix just merges redundant reports (architecture twins cover identical lines). This returns the test_matrix_pr cell set (one runner per OS at released Python). On a push event the workflow uses it to select the coverage-distinct subset of the full matrix; on a pull request it matches every running cell. Tests still execute on every cell: only the upload is gated.

Emitted as os|python-version tokens so the workflow can test membership with contains(coverage_cells, format('{0}|{1}', ...)).

property stale_test_matrix_excludes: list[dict[str, str]][source]

User test-matrix.exclude entries matching no full-matrix axis value.

An exclude naming a value absent from every axis (like a renamed runner) can never match a combination, so Matrix.prune() drops it silently and its exclusion intent is lost. This drift is common after an upstream runner rename (such as macos-15-intel becoming macos-26-intel). The lint-repo check surfaces these so the drift fails loudly instead of silently.

Returns:

The offending exclude entries, in config order.

property release_notes: str | None[source]

Generate notes to be attached to the GitHub release.

Renders the github-releases template with changelog content for the version. The template is the single place that defines the release body layout.

property release_notes_with_admonition: str | None[source]

Generate release notes with a pre-computed availability admonition.

Builds the same body as release_notes, but injects a > [!NOTE] admonition linking to PyPI and GitHub even before fix-changelog has a chance to update changelog.md.

The engine’s create-release job bakes this body into the GitHub release at draft-creation time, so the admonition is present from the start. Doing it there (rather than editing the release from the caller’s fast publish-pypi lane) removes the cross-lane race where the edit ran before create-release had created the release, and so silently dropped the admonition under continue-on-error. The bake is optimistic: it assumes the parallel PyPI upload succeeds, which it does on the normal path; a failed upload surfaces as a red publish-pypi job, not as a wrong admonition the user must catch.

Returns None when the project is not on PyPI, has no changelog, or has no version to release, in which case create-release falls back to the plain release_notes.

static format_github_value(value)[source]

Transform Python value to GitHub-friendly, JSON-like, console string.

Renders:

  • str as-is

  • None into empty string

  • bool into lower-cased string

  • Matrix into JSON string

  • Iterable of mixed strings and Path into a serialized space-separated string, where Path items are double-quoted

  • other Iterable into a JSON string

Return type:

str

dump(dialect=Dialect.github, keys=())[source]

Returns metadata in the specified format.

Defaults to GitHub dialect. When keys is non-empty, only the requested keys are computed and included in the output. Filtered-out keys are never accessed, so callers requesting a small subset avoid triggering expensive dependent computations (git history walks, file system scans, build matrix expansion).

Return type:

str

repomatic.npm module

npm registry API integration.

The npm counterpart to repomatic.pypi, used by sync-workflow-pins to resolve the npm version literals embedded in workflow YAML (like npm install awesome-lint@2.3.0).

repomatic.npm.NPM_REGISTRY_URL = 'https://registry.npmjs.org/{package}'

npm registry metadata URL for a package.

repomatic.npm.get_release_dates(package)[source]

Get publication dates for all versions of an npm package.

Parameters:

package (str) – The npm package name (e.g. awesome-lint).

Return type:

dict[str, str]

Returns:

Dict mapping version strings to YYYY-MM-DD publication dates. Empty if the package is not found or the request fails.

repomatic.prepare_release module

Prepare a release by updating changelog, citation, install guide, and workflow files.

A release cycle produces exactly two commits that must be merged via “Rebase and merge” (never squash):

  1. Freeze commit ([changelog] Release vX.Y.Z):

    • Strips the .dev0 suffix from the version.

    • Finalizes the changelog date and comparison URL.

    • Freezes workflow action references: @main@vX.Y.Z.

    • Freezes CLI invocations: --from . repomatic'repomatic==X.Y.Z'.

    • Freezes the install guide’s binary download URLs to versioned release paths.

    • Sets the release date in citation.cff.

  2. Unfreeze commit ([changelog] Post-release bump vX.Y.Z vX.Y.(Z+1)):

    • Reverts action references: @vX.Y.Z@main.

    • Reverts CLI invocations back to local source for dogfooding.

    • Bumps the version with a .dev0 suffix.

    • Adds a new unreleased changelog section.

The auto-tagging job in release.yaml depends on these being separate commits — it uses release_commits_matrix to identify and tag only the freeze commit. Squash-merging would collapse both into one, breaking the tagging logic. See the detect-squash-merge job for the safeguard.

Both operations are idempotent: re-running on an already-frozen or already-unfrozen tree is a no-op.

class repomatic.prepare_release.PrepareRelease(changelog_path=None, citation_path=None, workflow_dir=None, install_path=None, default_branch='main')[source]

Bases: object

Prepare files for a release by updating dates, URLs, and removing warnings.

property current_version: str[source]

Extract current version from the bump-my-version config.

Delegates discovery to Metadata.get_current_version(), which searches .bumpversion.toml then pyproject.toml.

property release_date: str[source]

Return today’s date in UTC as YYYY-MM-DD.

set_citation_release_date()[source]

Update the date-released field in citation.cff.

Return type:

bool

Returns:

True if the file was modified.

property composite_action_names: list[str][source]

Discover composite action directories under .github/actions/.

Enumerates every .github/actions/*/action.yaml (or .yml) and returns the directory names. New composite actions automatically participate in freeze/unfreeze without requiring code changes here.

Returns:

Sorted list of composite action directory names.

freeze_workflow_urls()[source]

Replace workflow URLs from default branch to versioned tag.

This is part of the freeze step: it freezes workflow references to the release tag so released versions reference immutable URLs.

Replaces /repomatic/{default_branch}/ with /repomatic/v{version}/ and every /repomatic/.github/actions/{name}@{default_branch} with /repomatic/.github/actions/{name}@v{version} across every workflow file under workflow_dir. Composite action names are discovered from composite_action_names.

Return type:

int

Returns:

Number of files modified.

freeze_install_download_urls(version)[source]

Replace binary download URLs in the install guide with versioned paths.

This is part of the freeze step: it freezes the install guide’s download links to a specific GitHub release so users get explicit, versioned URLs instead of the /releases/latest/download/ redirect. The versionless redirect cannot resolve because release assets are version-stamped (repomatic-{version}-linux-arm64.bin).

Handles two input forms:

  • Initial (never frozen): /releases/latest/download/repomatic-linux-arm64.bin

  • Previously frozen: /releases/download/v6.0.0/repomatic-6.0.0-linux-arm64.bin

Both are transformed to: /releases/download/v{version}/repomatic-{version}-linux-arm64.bin

Note

No unfreeze method is needed. Unlike workflow URLs (which toggle @main@vX.Y.Z), download URLs ratchet forward — they always point to a specific release. After unfreeze, the install guide still shows the last release’s URLs, which is correct for users wanting stable binaries.

Parameters:

version (str) – The release version to freeze to.

Return type:

bool

Returns:

True if the file was modified.

freeze_cli_version(version)[source]

Replace local source CLI invocations with a frozen PyPI version.

This is part of the freeze step: it freezes repomatic invocations to a specific PyPI version so the released workflow files reference a published package. Downstream repos that check out a tagged release will install from PyPI rather than expecting a local source tree.

Replaces --from . repomatic with 'repomatic=={version}' in all workflow YAML files. Comment lines (starting with #) are skipped to avoid corrupting explanatory comments.

Parameters:

version (str) – The PyPI version to freeze to.

Return type:

int

Returns:

Number of files modified.

unfreeze_cli_version()[source]

Replace frozen PyPI CLI invocations with local source.

This is part of the unfreeze step: it reverts repomatic invocations back to local source (--from . repomatic) for the next development cycle on main.

Replaces 'repomatic==X.Y.Z' (quoted, in YAML) with --from . repomatic. Comment lines are skipped (see freeze_cli_version()).

Return type:

int

Returns:

Number of files modified.

unfreeze_workflow_urls()[source]

Replace workflow URLs from versioned tag back to default branch.

This is part of the unfreeze step: it reverts workflow references back to the default branch for the next development cycle.

Replaces /repomatic/v{version}/ with /repomatic/{default_branch}/ and every /repomatic/.github/actions/{name}@v{version} with /repomatic/.github/actions/{name}@{default_branch} across every workflow file under workflow_dir (the same set as freeze_workflow_urls()). Composite action names are discovered from composite_action_names.

Return type:

int

Returns:

Number of files modified.

prepare_release(update_workflows=False)[source]

Run all freeze steps to prepare the release commit.

Parameters:

update_workflows (bool) – If True, also freeze workflow URLs to versioned tag and freeze CLI invocations to the current version.

Return type:

list[Path]

Returns:

List of modified files.

post_release(update_workflows=False)[source]

Run all unfreeze steps to prepare the post-release commit.

Parameters:

update_workflows (bool) – If True, unfreeze workflow URLs back to default branch and unfreeze CLI invocations back to local source.

Return type:

list[Path]

Returns:

List of modified files.

repomatic.pypi module

PyPI API client for package metadata lookups.

Provides a shared HTTP client and domain-specific query functions used by repomatic.changelog (release dates, yanked status) and repomatic.uv (source repository discovery for release notes).

repomatic.pypi.PYPI_API_URL = 'https://pypi.org/pypi/{package}/json'

PyPI JSON API URL for fetching all release metadata for a package.

repomatic.pypi.PYPI_PROJECT_URL = 'https://pypi.org/project/{package}/{version}/'

PyPI project page URL for a specific version.

repomatic.pypi.PYPI_PROVENANCE_URL = 'https://pypi.org/integrity/{package}/{version}/{filename}/provenance'

PyPI integrity API endpoint exposing PEP 740 attestation bundles for a file.

The response includes a publisher object per bundle that names the OIDC identity used to upload (kind, repository, workflow filename, environment). This is the only public surface where the OIDC job_workflow_ref claim is observable: project-level Trusted Publisher settings live behind the owner-only /manage/project/<name>/settings/publishing/ page.

repomatic.pypi.PYPI_TRUSTED_PUBLISHER_SETTINGS_URL = 'https://pypi.org/manage/project/{package}/settings/publishing/'

Owner-only page where Trusted Publisher entries are registered.

repomatic.pypi.PYPI_TRUSTED_PUBLISHER_WORKFLOW = 'release.yaml'

Workflow filename each downstream registers as the Trusted Publisher.

The caller-side publish-pypi job is appended to release.yaml in every downstream repo (reshaped from the canonical entry by repomatic.github.workflow_sync._render_publish_pypi_job), and the composite action it invokes inherits the calling job’s OIDC context. The OIDC job_workflow_ref claim therefore names this file: that is what the PyPI Trusted Publisher entry must match.

repomatic.pypi.pypi_trusted_publisher_settings_url(package, *, owner=None, repository=None, workflow_filename=None, environment=None)[source]

Build the PyPI Trusted Publisher settings page URL for a project.

Without keyword arguments, returns the bare settings URL. When any GitHub publisher field is provided, appends the query string PyPI’s settings page consumes to activate the GitHub tab and pre-populate the form: see the manage_project_oidc_publishers_prefill view in pypi/warehouse.

Parameters:
  • package (str) – PyPI project name.

  • owner (str | None) – GitHub owner (user or org) prefilled in the form.

  • repository (str | None) – GitHub repository name prefilled in the form.

  • workflow_filename (str | None) – Workflow filename prefilled in the form (e.g., PYPI_TRUSTED_PUBLISHER_WORKFLOW).

  • environment (str | None) – GitHub Actions environment name prefilled in the form.

Return type:

str

Returns:

The settings URL, optionally with a ?provider=github&… suffix.

repomatic.pypi.PYPI_LABEL = '🐍 PyPI'

Display label for PyPI releases in admonitions.

class repomatic.pypi.PyPIRelease(date: str, yanked: bool, package: str)[source]

Bases: NamedTuple

Release metadata for a single version from PyPI.

Create new instance of PyPIRelease(date, yanked, package)

date: str

Earliest upload date across all files in YYYY-MM-DD format.

yanked: bool

Whether all files for this version are yanked.

package: str

PyPI package name this release was fetched from.

Needed for projects that were renamed: older versions live under a former package name and their PyPI URLs must point to that name, not the current one.

repomatic.pypi.get_release_dates(package)[source]

Get upload dates and yanked status for all versions from PyPI.

Fetches the package metadata in a single API call. For each version, selects the earliest upload time across all distribution files as the canonical release date. A version is considered yanked only if all of its files are yanked.

Parameters:

package (str) – The PyPI package name.

Return type:

dict[str, PyPIRelease]

Returns:

Dict mapping version strings to PyPIRelease tuples. Empty dict if the package is not found or the request fails.

repomatic.pypi.get_source_url(package)[source]

Discover the GitHub repository URL for a PyPI package.

Queries the PyPI JSON API and scans project_urls for keys that typically point to a source repository on GitHub.

Parameters:

package (str) – The PyPI package name.

Return type:

str | None

Returns:

The GitHub repository URL, or None if not found.

class repomatic.pypi.TrustedPublisher(kind: str, repository: str, workflow: str, environment: str | None)[source]

Bases: NamedTuple

OIDC publisher metadata extracted from a PyPI provenance bundle.

Create new instance of TrustedPublisher(kind, repository, workflow, environment)

kind: str

Publisher kind, e.g., "GitHub" or "GitLab".

repository: str

Repository slug ("owner/name" for GitHub publishers).

workflow: str

Workflow filename within .github/workflows/ (e.g., "release.yaml").

environment: str | None

GitHub Actions environment name, when the publisher was scoped to one.

repomatic.pypi.get_latest_release_file(package)[source]

Return (version, filename) for the latest non-yanked release on PyPI.

Picks the version with the most recent earliest-upload time and returns a representative distribution file from that version. Wheels are preferred over sdists since wheels are guaranteed to exist for any package built with modern tooling.

Parameters:

package (str) – The PyPI package name.

Return type:

tuple[str, str] | None

Returns:

Tuple of (version, filename), or None if the package has no published releases or the request fails.

repomatic.pypi.get_trusted_publishers(package, version, filename)[source]

Fetch PEP 740 provenance for a file and extract publisher entries.

Calls PYPI_PROVENANCE_URL and parses the attestation_bundles array. Each bundle’s publisher object names the OIDC identity that uploaded the file.

Parameters:
  • package (str) – The PyPI package name.

  • version (str) – The release version (e.g., "1.2.3").

  • filename (str) – The distribution filename (e.g., "my_pkg-1.2.3-py3-none-any.whl").

Return type:

list[TrustedPublisher] | None

Returns:

List of TrustedPublisher entries (possibly empty when provenance exists but no bundles are present), or None when the endpoint returns 404 or any network/parse error occurs (signal that no provenance is available rather than that none was registered).

repomatic.pypi.get_changelog_url(package)[source]

Discover the changelog URL for a PyPI package.

Queries the PyPI JSON API and scans project_urls for keys that typically point to a changelog or release notes page.

Parameters:

package (str) – The PyPI package name.

Return type:

str | None

Returns:

The changelog URL, or None if not found.

repomatic.pyproject module

Utilities for reading and interpreting pyproject.toml metadata.

Provides standalone functions for extracting project name and source paths from pyproject.toml. These functions have no dependency on the Metadata singleton and can be used independently.

repomatic.pyproject.read_pyproject_toml(project_root=None)[source]

Parse pyproject.toml from project_root.

Parameters:

project_root (Path | None) – Directory holding pyproject.toml. Defaults to the current working directory.

Return type:

dict[str, Any]

Returns:

Parsed contents, or an empty dict when the file is missing or cannot be decoded.

repomatic.pyproject.derive_source_paths(pyproject_data=None)[source]

Derive source code directory name from [project.name].

Converts the project name to its importable form by replacing hyphens with underscores — the universal Python convention that all build backends (setuptools, hatchling, flit, uv) follow by default. For example, name = "extra-platforms" yields ["extra_platforms"].

Parameters:

pyproject_data (dict[str, Any] | None) – Pre-parsed pyproject.toml dict. If None, reads from the current working directory.

Return type:

list[str]

Returns:

Single-element list with the source directory name, or an empty list if no project name is defined.

repomatic.pyproject.resolve_source_paths(config, pyproject_data=None)[source]

Resolve workflow source paths from config or auto-derivation.

Parameters:
  • config (Config) – Loaded Config instance from [tool.repomatic].

  • pyproject_data (dict[str, Any] | None) – Pre-parsed pyproject.toml dict for derivation.

Return type:

list[str] | None

Returns:

List of source directory names, or None when no source paths can be determined (paths should be stripped entirely).

repomatic.pyproject.get_project_name(pyproject_data=None)[source]

Read the project name from pyproject.toml.

Parameters:

pyproject_data (dict[str, Any] | None) – Pre-parsed dict. If None, reads from CWD.

Return type:

str | None

repomatic.pyproject.is_python_project(project_root=None, pyproject_data=None)[source]

Detect whether project_root hosts a Python project.

Returns True when the pyproject.toml parses cleanly through pyproject_metadata.StandardMetadata.from_pyproject: it must declare a PEP 621 [project] table that respects the standard. A pyproject.toml that only carries third-party [tool.*] sections does not qualify, so repositories that merely lean on the file for tool configuration (linters, formatters, [tool.repomatic] itself) are correctly classified as non-Python.

Parameters:
  • project_root (Path | None) – Directory to probe. Ignored when pyproject_data is supplied; otherwise defaults to the current working directory.

  • pyproject_data (dict[str, Any] | None) – Pre-parsed pyproject.toml. Pass this when the caller has already parsed the file (e.g., the Metadata singleton).

Return type:

bool

Returns:

True when the [project] table satisfies PEP 621.

repomatic.registry module

Declarative registry of all components managed by the init subcommand.

Every resource the init subcommand can create, sync, or merge is declared here as a Component subclass instance in the COMPONENTS tuple. Each component carries all its metadata: what kind it is, whether it is selected by default, which files it manages, and any per-file properties like repo-scope gating or config keys.

All derived constants (ALL_COMPONENTS, COMPONENT_FILES, REUSABLE_WORKFLOWS, SKILL_PHASES, etc.) are computed from this single registry in repomatic.init_project.

class repomatic.registry.InitDefault(*values)[source]

Bases: Enum

How init treats the component when no explicit CLI args are given.

INCLUDE = 1

Included by default (like changelog or workflows).

EXCLUDE = 2

In default set but excluded unless explicitly included (e.g., labels, skills).

AUTO = 3

Auto-included only for matching repos (e.g., awesome-template).

EXPLICIT = 4

Only included when explicitly requested (e.g., tool configs).

class repomatic.registry.SyncMode(*values)[source]

Bases: Enum

How a ToolConfigComponent behaves when the section already exists.

BOOTSTRAP = 1

Insert once, skip if section already exists (e.g., ruff, pytest).

ONGOING = 2

Replace template content on every sync, preserving local additions (e.g., bumpversion).

class repomatic.registry.RepoScope(*values)[source]

Bases: Enum

Which repository types a component or file entry applies to.

The classification has two axes: whether the repo is an awesome-* list and whether it carries a PEP 621 pyproject.toml. In practice these are mutually exclusive (awesome repos are content lists, not Python packages), so a single scope value suffices.

Scope restrictions are defaults: they apply during bare repomatic init but are bypassed when components are explicitly named on the CLI or covered by [tool.repomatic] include.

ALL = 1

Included in every repository type.

AWESOME_ONLY = 2

Only for awesome-* repositories.

PYTHON_ONLY = 3

Only for Python projects (PEP 621 [project].name present).

matches(is_awesome, is_python)[source]

Whether this scope applies to the given repository traits.

Parameters:
  • is_awesome (bool) – True for awesome-* repositories.

  • is_python (bool) – True for repositories whose pyproject.toml declares a PEP 621 [project].name.

Return type:

bool

class repomatic.registry.FileEntry(source, target='', file_id='', scope=RepoScope.ALL, config_key='', config_default=False, reusable=True, phase='')[source]

Bases: object

A single file managed within a component.

source: str

Filename in repomatic/data/.

target: str = ''

Relative output path in the target repository. Defaults to source (root-level file).

file_id: str = ''

Identifier for file-level --include/--exclude. Defaults to the filename portion of target.

scope: RepoScope = 1

Which repository types get this file.

config_key: str = ''

[tool.repomatic] key that gates this entry.

config_default: bool = False

Value assumed when config_key is absent from config. False means opt-in (excluded unless enabled), True means opt-out (included unless disabled).

reusable: bool = True

Workflow-specific: supports workflow_call trigger.

phase: str = ''

Skill-specific: lifecycle phase for list-skills display.

is_enabled(config)[source]

Whether this entry is enabled by the given Config object.

See _config_enabled() for the resolution rule.

Parameters:

config (object) – A Config instance.

Return type:

bool

class repomatic.registry.Component(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False)[source]

Bases: object

Base class for all init components.

name: str

Component name used on the CLI (e.g., "skills").

description: str

Human-readable description for help text.

init_default: InitDefault = 1

How init treats this component when no explicit CLI selection is made.

scope: RepoScope = 1

Which repository types get this component. Checked at the component level during auto-exclusion, complementing the file-level FileEntry.scope.

files: tuple[FileEntry, ...] = ()

File entries this component manages.

config_key: str = ''

[tool.repomatic] key that gates this component.

config_default: bool = True

Value assumed when config_key is absent from config. True means opt-out (included unless disabled).

keep_unmodified: bool = False

Preserve files on disk even when identical to the bundled default. When False, unmodified copies are flagged for cleanup by --delete-unmodified.

is_enabled(config)[source]

Whether this component is enabled by the given Config object.

See _config_enabled() for the resolution rule.

Parameters:

config (object) – A Config instance.

Return type:

bool

class repomatic.registry.BundledComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False)[source]

Bases: Component

Files copied from repomatic/data/ to a target path.

class repomatic.registry.WorkflowComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False)[source]

Bases: Component

Thin-caller generation and header sync.

class repomatic.registry.ToolConfigComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='', tool_section='', insert_after=(), insert_before=(), sync_mode=SyncMode.BOOTSTRAP, preserved_keys=(), graft_identity_keys=(), overlay=False)[source]

Bases: Component

Merged into pyproject.toml.

source_file: str = ''

Filename in repomatic/data/.

tool_section: str = ''

The [tool.X] section name to check for existence.

insert_after: tuple[str, ...] = ()

Sections to insert after in pyproject.toml (in priority order).

insert_before: tuple[str, ...] = ()

Sections to insert before in pyproject.toml (if insert_after not found).

sync_mode: SyncMode = 1

How this config behaves when the section already exists.

BOOTSTRAP: insert once, skip if the section is present. ONGOING: re-derive the section from the template on every sync while preserving local additions: keys the template omits, extra items in shared arrays, and extra keys in shared nested tables. The template wins on shared scalars; preserved_keys flips that for named top-level keys.

preserved_keys: tuple[str, ...] = ()

Top-level keys whose existing values survive an ongoing sync.

Only meaningful when sync_mode is ONGOING. During replacement, these keys keep their value from the existing config rather than being overwritten by the template placeholder.

graft_identity_keys: tuple[str, ...] = ()

Keys that identify the “slot” of an array-of-tables entry during a graft.

Only meaningful when sync_mode is ONGOING. When set, a local array-of-tables entry that shares its identity tuple (the values of these keys) with a template entry is treated as a stale copy of that canonical entry: the template wins and the local entry is dropped rather than appended as a duplicate. Local entries whose identity matches no template entry are genuinely local and survive. Leave empty to fall back to a plain union-by-value, which cannot tell an evolved canonical entry apart from a new local one.

For bumpversion, the slot is (filename | glob | key_path, replace): filename/glob/key_path name the target file and replace names what the entry writes there, so a stale entry whose search pattern evolved (e.g. gaining a regex anchor) still maps to the same slot.

overlay: bool = False

Treat the template as a partial section owning only its own keys.

Only meaningful when sync_mode is ONGOING. The default rebuild-and-graft sync rebuilds the whole section from the template and grafts local additions after it, so template keys always land first. That is wrong for a section the project mostly owns and a formatter reorders: [tool.uv], whose keys pyproject-fmt sorts into a fixed schema order. Emitting the owned keys as a leading block would lose to pyproject-fmt on the next format pass and churn an endless sync PR.

With overlay set, an ongoing sync instead updates only the template’s top-level keys in place within the existing section (the template value wins), preserving the existing key order and leaving every other key untouched. The merged section is therefore already a pyproject-fmt fixpoint. A repo missing an owned key has it appended; pyproject-fmt canonicalizes that one position once, after which steady-state syncs are no-ops.

class repomatic.registry.TemplateComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False)[source]

Bases: Component

Directory tree (awesome-template).

class repomatic.registry.GeneratedComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False, target='')[source]

Bases: Component

Produced from code (changelog).

Unlike bundled components, generated components have no files tuple. The target field records the output path so the auto-exclusion logic can detect stale copies on disk.

target: str = ''

Relative output path in the target repository.

class repomatic.registry.RemovedAsset(component, target, removed_in, hashes=(), successor='')[source]

Bases: object

An asset repomatic once shipped and has since dropped.

Note

Stale-file detection in init only inspects files still listed in COMPONENTS. An asset removed from the registry (a renamed or consolidated skill, a retired workflow) becomes invisible to it, so downstream repos accumulate one orphan per upstream removal. Each RemovedAsset is a tombstone that lets init find and prune those orphans.

init finds an on-disk orphan and decides whether to prune it with one of two gates, depending on the component:

  • Content-gated (skills, agents): the file is deleted only when its normalized content matches one of hashes (a version repomatic shipped), proving it is an untouched copy.

  • Fingerprint-gated (workflows): thin-callers are parameterized per repo (version pin, paths: filters), so they carry no fixed content. The file is deleted only when it is a repomatic-lineage thin-caller for this workflow (its uses: line references an upstream slug, see UPSTREAM_REPO_SLUGS) with no extra downstream jobs.

Either way, a locally modified orphan is reported for manual review, never deleted.

component: str

Component the asset belonged to (like "skills" or "workflows").

target: str

Relative output path the asset occupied, in default-location form (like .claude/skills/repomatic-release/SKILL.md or .github/workflows/label-sponsors.yaml).

Build skill and agent targets with _skill_target / _agent_target so they match the live registry: the skills.location and agents.location overrides are re-applied at detection time. Workflow targets are literal (.github/workflows/ is fixed by GitHub).

removed_in: str

Bare package version that first stopped shipping the asset (like 6.21.0). Surfaced in the prune report.

hashes: tuple[str, ...] = ()

Content gate for skills and agents: the hex SHA-256 of every distinct normalized content repomatic shipped for this asset (content.rstrip() + “n”`, exactly as ``init` writes it to disk). An on-disk file whose content hashes to any of these is an untouched copy of some released version and is safe to delete. Listing one hash per distinct released revision (not just the last) means a downstream repo that synced an older version is still recognized and pruned rather than flagged for review.

Empty for workflows, which are fingerprint-gated by their uses: line instead (see the class docstring).

successor: str = ''

Optional human note describing what replaced the asset, shown in the report (like replaced by repomatic-ship).

repomatic.registry.COMPONENTS: tuple[Component, ...] = (BundledComponent(name='labels', description='Label config files (labels.toml + labeller rules)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='labeller-content-based.yaml', target='.github/labeller-content-based.yaml', file_id='labeller-content-based.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labeller-file-based.yaml', target='.github/labeller-file-based.yaml', file_id='labeller-file-based.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labels.toml', target='labels.toml', file_id='labels.toml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=False), BundledComponent(name='codecov', description='Codecov PR comment config (.github/codecov.yaml)', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(FileEntry(source='codecov.yaml', target='.github/codecov.yaml', file_id='codecov.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''),), config_key='', config_default=True, keep_unmodified=True), BundledComponent(name='publish-pypi-action', description='Composite action that publishes to PyPI via Trusted Publishing (.github/actions/publish-pypi/)', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(FileEntry(source='action-publish-pypi.yaml', target='.github/actions/publish-pypi/action.yaml', file_id='action.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''),), config_key='', config_default=True, keep_unmodified=True), BundledComponent(name='agents', description='Claude Code agent definitions (.claude/agents/)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='agent-grunt-qa.md', target='.claude/agents/grunt-qa.md', file_id='grunt-qa', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='agent-qa-engineer.md', target='.claude/agents/qa-engineer.md', file_id='qa-engineer', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='agent-sphinx-docs.md', target='.claude/agents/sphinx-docs.md', file_id='sphinx-docs', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=True), BundledComponent(name='skills', description='Claude Code skill definitions (.claude/skills/)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='skill-av-false-positive.md', target='.claude/skills/av-false-positive/SKILL.md', file_id='av-false-positive', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-awesome-triage.md', target='.claude/skills/awesome-triage/SKILL.md', file_id='awesome-triage', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-babysit-ci.md', target='.claude/skills/babysit-ci/SKILL.md', file_id='babysit-ci', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality'), FileEntry(source='skill-benchmark-update.md', target='.claude/skills/benchmark-update/SKILL.md', file_id='benchmark-update', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-brand-assets.md', target='.claude/skills/brand-assets/SKILL.md', file_id='brand-assets', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-file-bug-report.md', target='.claude/skills/file-bug-report/SKILL.md', file_id='file-bug-report', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-repomatic-audit.md', target='.claude/skills/repomatic-audit/SKILL.md', file_id='repomatic-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-repomatic-changelog.md', target='.claude/skills/repomatic-changelog/SKILL.md', file_id='repomatic-changelog', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-repomatic-deps.md', target='.claude/skills/repomatic-deps/SKILL.md', file_id='repomatic-deps', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-repomatic-init.md', target='.claude/skills/repomatic-init/SKILL.md', file_id='repomatic-init', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Setup'), FileEntry(source='skill-repomatic-ship.md', target='.claude/skills/repomatic-ship/SKILL.md', file_id='repomatic-ship', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-repomatic-topics.md', target='.claude/skills/repomatic-topics/SKILL.md', file_id='repomatic-topics', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-sphinx-docs-sync.md', target='.claude/skills/sphinx-docs-sync/SKILL.md', file_id='sphinx-docs-sync', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-translation-sync.md', target='.claude/skills/translation-sync/SKILL.md', file_id='translation-sync', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-upstream-audit.md', target='.claude/skills/upstream-audit/SKILL.md', file_id='upstream-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance')), config_key='', config_default=True, keep_unmodified=True), WorkflowComponent(name='workflows', description='Thin-caller workflow files', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='autofix.yaml', target='.github/workflows/autofix.yaml', file_id='autofix.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='autolock.yaml', target='.github/workflows/autolock.yaml', file_id='autolock.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='cancel-runs.yaml', target='.github/workflows/cancel-runs.yaml', file_id='cancel-runs.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='changelog.yaml', target='.github/workflows/changelog.yaml', file_id='changelog.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='debug.yaml', target='.github/workflows/debug.yaml', file_id='debug.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='docs.yaml', target='.github/workflows/docs.yaml', file_id='docs.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labels.yaml', target='.github/workflows/labels.yaml', file_id='labels.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='lint.yaml', target='.github/workflows/lint.yaml', file_id='lint.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='_release-engine.yaml', target='.github/workflows/release.yaml', file_id='release.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='tests.yaml', target='.github/workflows/tests.yaml', file_id='tests.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=False, phase=''), FileEntry(source='unsubscribe.yaml', target='.github/workflows/unsubscribe.yaml', file_id='unsubscribe.yaml', scope=<RepoScope.ALL: 1>, config_key='notification.unsubscribe', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=False), TemplateComponent(name='awesome-template', description='Boilerplate for awesome-* repositories', init_default=<InitDefault.AUTO: 3>, scope=<RepoScope.ALL: 1>, files=(), config_key='awesome-template.sync', config_default=True, keep_unmodified=False), GeneratedComponent(name='changelog', description='Minimal changelog.md', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(), config_key='', config_default=True, keep_unmodified=False, target='changelog.md'), ToolConfigComponent(name='uv', description='uv resolver pin and dependency cooldown policy', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='uv.toml', tool_section='tool.uv', insert_after=(), insert_before=('tool.ruff', 'tool.pytest', 'tool.mypy'), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=True), ToolConfigComponent(name='lychee', description='Lychee link checker configuration', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.AWESOME_ONLY: 2>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='lychee.toml', tool_section='tool.lychee', insert_after=(), insert_before=(), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='ruff', description='Ruff linter/formatter configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='ruff.toml', tool_section='tool.ruff', insert_after=('tool.uv', 'tool.uv.build-backend'), insert_before=('tool.pytest',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='pytest', description='Pytest test configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='pytest.toml', tool_section='tool.pytest', insert_after=('tool.ruff', 'tool.ruff.format'), insert_before=('tool.mypy',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='mypy', description='Mypy type checking configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='mypy.toml', tool_section='tool.mypy', insert_after=('tool.pytest',), insert_before=('tool.nuitka', 'tool.bumpversion'), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='mdformat', description='mdformat Markdown formatter configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='mdformat.toml', tool_section='tool.mdformat', insert_after=('tool.coverage',), insert_before=('tool.bumpversion',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='bumpversion', description='bump-my-version configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='bumpversion.toml', tool_section='tool.bumpversion', insert_after=('tool.mdformat', 'tool.nuitka', 'tool.mypy'), insert_before=('tool.typos',), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=('current_version',), graft_identity_keys=('filename', 'glob', 'key_path', 'replace'), overlay=False), ToolConfigComponent(name='typos', description='Typos spell checker configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='typos.toml', tool_section='tool.typos', insert_after=('tool.bumpversion',), insert_before=('tool.pytest',), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=False))

The component registry.

Single source of truth for all resources managed by the init subcommand. Every component declares its kind, selection default, file entries, and behavioral flags. All derived constants are computed from this tuple.

repomatic.registry.COMPONENTS_BY_NAME: dict[str, Component] = {'agents': BundledComponent(name='agents', description='Claude Code agent definitions (.claude/agents/)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='agent-grunt-qa.md', target='.claude/agents/grunt-qa.md', file_id='grunt-qa', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='agent-qa-engineer.md', target='.claude/agents/qa-engineer.md', file_id='qa-engineer', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='agent-sphinx-docs.md', target='.claude/agents/sphinx-docs.md', file_id='sphinx-docs', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=True), 'awesome-template': TemplateComponent(name='awesome-template', description='Boilerplate for awesome-* repositories', init_default=<InitDefault.AUTO: 3>, scope=<RepoScope.ALL: 1>, files=(), config_key='awesome-template.sync', config_default=True, keep_unmodified=False), 'bumpversion': ToolConfigComponent(name='bumpversion', description='bump-my-version configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='bumpversion.toml', tool_section='tool.bumpversion', insert_after=('tool.mdformat', 'tool.nuitka', 'tool.mypy'), insert_before=('tool.typos',), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=('current_version',), graft_identity_keys=('filename', 'glob', 'key_path', 'replace'), overlay=False), 'changelog': GeneratedComponent(name='changelog', description='Minimal changelog.md', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(), config_key='', config_default=True, keep_unmodified=False, target='changelog.md'), 'codecov': BundledComponent(name='codecov', description='Codecov PR comment config (.github/codecov.yaml)', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(FileEntry(source='codecov.yaml', target='.github/codecov.yaml', file_id='codecov.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''),), config_key='', config_default=True, keep_unmodified=True), 'labels': BundledComponent(name='labels', description='Label config files (labels.toml + labeller rules)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='labeller-content-based.yaml', target='.github/labeller-content-based.yaml', file_id='labeller-content-based.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labeller-file-based.yaml', target='.github/labeller-file-based.yaml', file_id='labeller-file-based.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labels.toml', target='labels.toml', file_id='labels.toml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=False), 'lychee': ToolConfigComponent(name='lychee', description='Lychee link checker configuration', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.AWESOME_ONLY: 2>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='lychee.toml', tool_section='tool.lychee', insert_after=(), insert_before=(), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'mdformat': ToolConfigComponent(name='mdformat', description='mdformat Markdown formatter configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='mdformat.toml', tool_section='tool.mdformat', insert_after=('tool.coverage',), insert_before=('tool.bumpversion',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'mypy': ToolConfigComponent(name='mypy', description='Mypy type checking configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='mypy.toml', tool_section='tool.mypy', insert_after=('tool.pytest',), insert_before=('tool.nuitka', 'tool.bumpversion'), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'publish-pypi-action': BundledComponent(name='publish-pypi-action', description='Composite action that publishes to PyPI via Trusted Publishing (.github/actions/publish-pypi/)', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PYTHON_ONLY: 3>, files=(FileEntry(source='action-publish-pypi.yaml', target='.github/actions/publish-pypi/action.yaml', file_id='action.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''),), config_key='', config_default=True, keep_unmodified=True), 'pytest': ToolConfigComponent(name='pytest', description='Pytest test configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='pytest.toml', tool_section='tool.pytest', insert_after=('tool.ruff', 'tool.ruff.format'), insert_before=('tool.mypy',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'ruff': ToolConfigComponent(name='ruff', description='Ruff linter/formatter configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='ruff.toml', tool_section='tool.ruff', insert_after=('tool.uv', 'tool.uv.build-backend'), insert_before=('tool.pytest',), sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'skills': BundledComponent(name='skills', description='Claude Code skill definitions (.claude/skills/)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='skill-av-false-positive.md', target='.claude/skills/av-false-positive/SKILL.md', file_id='av-false-positive', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-awesome-triage.md', target='.claude/skills/awesome-triage/SKILL.md', file_id='awesome-triage', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-babysit-ci.md', target='.claude/skills/babysit-ci/SKILL.md', file_id='babysit-ci', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality'), FileEntry(source='skill-benchmark-update.md', target='.claude/skills/benchmark-update/SKILL.md', file_id='benchmark-update', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-brand-assets.md', target='.claude/skills/brand-assets/SKILL.md', file_id='brand-assets', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-file-bug-report.md', target='.claude/skills/file-bug-report/SKILL.md', file_id='file-bug-report', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-repomatic-audit.md', target='.claude/skills/repomatic-audit/SKILL.md', file_id='repomatic-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-repomatic-changelog.md', target='.claude/skills/repomatic-changelog/SKILL.md', file_id='repomatic-changelog', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-repomatic-deps.md', target='.claude/skills/repomatic-deps/SKILL.md', file_id='repomatic-deps', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-repomatic-init.md', target='.claude/skills/repomatic-init/SKILL.md', file_id='repomatic-init', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Setup'), FileEntry(source='skill-repomatic-ship.md', target='.claude/skills/repomatic-ship/SKILL.md', file_id='repomatic-ship', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release'), FileEntry(source='skill-repomatic-topics.md', target='.claude/skills/repomatic-topics/SKILL.md', file_id='repomatic-topics', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development'), FileEntry(source='skill-sphinx-docs-sync.md', target='.claude/skills/sphinx-docs-sync/SKILL.md', file_id='sphinx-docs-sync', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-translation-sync.md', target='.claude/skills/translation-sync/SKILL.md', file_id='translation-sync', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance'), FileEntry(source='skill-upstream-audit.md', target='.claude/skills/upstream-audit/SKILL.md', file_id='upstream-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance')), config_key='', config_default=True, keep_unmodified=True), 'typos': ToolConfigComponent(name='typos', description='Typos spell checker configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='typos.toml', tool_section='tool.typos', insert_after=('tool.bumpversion',), insert_before=('tool.pytest',), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'uv': ToolConfigComponent(name='uv', description='uv resolver pin and dependency cooldown policy', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, source_file='uv.toml', tool_section='tool.uv', insert_after=(), insert_before=('tool.ruff', 'tool.pytest', 'tool.mypy'), sync_mode=<SyncMode.ONGOING: 2>, preserved_keys=(), graft_identity_keys=(), overlay=True), 'workflows': WorkflowComponent(name='workflows', description='Thin-caller workflow files', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='autofix.yaml', target='.github/workflows/autofix.yaml', file_id='autofix.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='autolock.yaml', target='.github/workflows/autolock.yaml', file_id='autolock.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='cancel-runs.yaml', target='.github/workflows/cancel-runs.yaml', file_id='cancel-runs.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='changelog.yaml', target='.github/workflows/changelog.yaml', file_id='changelog.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='debug.yaml', target='.github/workflows/debug.yaml', file_id='debug.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='docs.yaml', target='.github/workflows/docs.yaml', file_id='docs.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='labels.yaml', target='.github/workflows/labels.yaml', file_id='labels.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='lint.yaml', target='.github/workflows/lint.yaml', file_id='lint.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='_release-engine.yaml', target='.github/workflows/release.yaml', file_id='release.yaml', scope=<RepoScope.PYTHON_ONLY: 3>, config_key='', config_default=False, reusable=True, phase=''), FileEntry(source='tests.yaml', target='.github/workflows/tests.yaml', file_id='tests.yaml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=False, phase=''), FileEntry(source='unsubscribe.yaml', target='.github/workflows/unsubscribe.yaml', file_id='unsubscribe.yaml', scope=<RepoScope.ALL: 1>, config_key='notification.unsubscribe', config_default=False, reusable=True, phase='')), config_key='', config_default=True, keep_unmodified=False)}

Index for O(1) component lookup by name.

repomatic.registry.REMOVED_ASSETS: tuple[RemovedAsset, ...] = (RemovedAsset(component='skills', target='.claude/skills/gha-changelog/SKILL.md', removed_in='6.0.0', hashes=('2c178a58e1106f08aa6e540cd022eff12c4e954942ec5d794282c7b640adf768',), successor='renamed to repomatic-changelog'), RemovedAsset(component='skills', target='.claude/skills/gha-deps/SKILL.md', removed_in='6.0.0', hashes=('d0bcb44f81335f4aabcadb82085f5048be12db252fc0a1f8c6bda8d9e5292efd',), successor='renamed to repomatic-deps'), RemovedAsset(component='skills', target='.claude/skills/gha-init/SKILL.md', removed_in='6.0.0', hashes=('0f4f23f424c73774dd6253d9cb547e7a1d52ed64266c93b5b7271f4bee492a25',), successor='renamed to repomatic-init'), RemovedAsset(component='skills', target='.claude/skills/gha-lint/SKILL.md', removed_in='6.0.0', hashes=('7079f4d79c6347b03b4788de97db2e1839006b606e9dbacbfeb51e9cca04db20',), successor='now handled by lint.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/gha-metadata/SKILL.md', removed_in='6.0.0', hashes=('74c6f7d3574236d20aa7011b92f174abd2f8fdda162131e7f61851dfee7145fa',), successor='now handled by the repomatic metadata CLI command'), RemovedAsset(component='skills', target='.claude/skills/gha-release/SKILL.md', removed_in='6.0.0', hashes=('99a466bc4d377bb056c5696de8f0eae2b025b34505ac951d504bee55a42bdd1c',), successor='replaced by repomatic-ship'), RemovedAsset(component='skills', target='.claude/skills/gha-sync/SKILL.md', removed_in='6.0.0', hashes=('f856f143db3f0ad37adb6c80b89c33efa5112e1307927ff3331f82857a71fef4',), successor='now handled by autofix.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/gha-test/SKILL.md', removed_in='6.0.0', hashes=('4a00dac78e0ca3c598c2a3ae6e649f354f73e754c5aaea531d8409f1eff23434',), successor='now handled by tests.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repokit-changelog/SKILL.md', removed_in='6.0.1', hashes=('6e176d9d0090afb9d9a10035e4c6721fff8fac4a1c313010fc04a7ab631be399',), successor='renamed to repomatic-changelog'), RemovedAsset(component='skills', target='.claude/skills/repokit-deps/SKILL.md', removed_in='6.0.1', hashes=('577687ae8481cc67b992497ee0de9fb38c0f26cd20a9b907a4bf78f834803cc0',), successor='renamed to repomatic-deps'), RemovedAsset(component='skills', target='.claude/skills/repokit-init/SKILL.md', removed_in='6.0.1', hashes=('c68a9108ead81c4bb5b33912770155f6a587188ca72c8ba8d08f7283fdcad281',), successor='renamed to repomatic-init'), RemovedAsset(component='skills', target='.claude/skills/repokit-lint/SKILL.md', removed_in='6.0.1', hashes=('1c05f0fb8c5ff8eed38ac02af2fff016e931fdf8866fd93a3fc6c61f84d4df52',), successor='now handled by lint.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repokit-metadata/SKILL.md', removed_in='6.0.1', hashes=('0322f70cdd8e53d03fce2befbf904be1f0dc5596b79e41557ce8ec788a202cff',), successor='now handled by the repomatic metadata CLI command'), RemovedAsset(component='skills', target='.claude/skills/repokit-release/SKILL.md', removed_in='6.0.1', hashes=('a6ceb0394f084f481765bb834f275af0cb1cf58a9383059358ceec50ea87b93a',), successor='replaced by repomatic-ship'), RemovedAsset(component='skills', target='.claude/skills/repokit-sync/SKILL.md', removed_in='6.0.1', hashes=('412811337a541b6c4518e588240ce2cb13f3f476bcd311f32edcf04394e17ade',), successor='now handled by autofix.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repokit-test/SKILL.md', removed_in='6.0.1', hashes=('63f0b532f379aa4400eea5a6284c3004ddc09749c8f476f4ea5a5e8ce3c4716f',), successor='now handled by tests.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repomatic-lint/SKILL.md', removed_in='6.21.0', hashes=('11131553c99adb7daf880b6b19b84e4d4573eedbe7b951092aa7d4a1f9357aab', 'd72cada008b46db93eff0b7a167f1f57346c528ec317fca73857205895fb1395', '058b9cc3248cd1d537d8fbf7a0c1133e3107c6ed405859457e88625b9301d3d8', '7ec6520cba0a14af07ed1bb4e2f0388109ac8db0509ca92ffa0829cf2967bd11'), successor='now handled by lint.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repomatic-metadata/SKILL.md', removed_in='6.3.0', hashes=('e94ba4246c0bf56b8dfb6a7e4d3ea2e9521c000e8322130b1746e7a54d3f260b', '58c6eec756177f445893366960464c2d5872de994a692399440df0eb30b11e35'), successor='now handled by the repomatic metadata CLI command'), RemovedAsset(component='skills', target='.claude/skills/repomatic-release/SKILL.md', removed_in='6.21.0', hashes=('0ecfa8ff5d55b33394d83bce76d39015450403124ff63e131fea14adf685c00b', '8546a42c1ea44b2a4fa0ed1bc49f71eaf8be3b5656a323ee93957ea1fdb0bb38', '778783f3ef6093d9892a4772fc312747155b399e18ba33f416fa9b138897b43d', 'b076cae374b3104f50996cf8b92eae6f53ec9546d3b0fab2c033c90cb1e8a107', '8e93d723827042e90acbe22d038516400bcd743bf39f3fb45a65c115008a97d0'), successor='replaced by repomatic-ship'), RemovedAsset(component='skills', target='.claude/skills/repomatic-sync/SKILL.md', removed_in='6.21.0', hashes=('3b36a8b4fc76282c280f6cc19fdc24aa826db8a81ee91a66737b24cb921c84d9', '1460738708f7e878c17ef578a7fad14710962a5fd7e7789f3bc08ae6bc49247b', '54a2b2aa40799c05d666295ee0a1f4d65946605c5397a006185123e4c2e9f1d0', '771d4e15efab4739fb00a7c1ba20495e063025842beb2e54d84207e1410f40a1', '687c7f9cae7271ee56f4d35b754325ba7a2c3b13537eee057679cc160e39471e', 'ceaf3141599850847ee51b2e4f85c76a4cae130a01b2a4fd820dd3b5c0dd0dc0', '91add2c0b7686f64f810bb86fa70c3ac99d3940b37ba6fbe57c01a4d427cc902'), successor='now handled by autofix.yaml on every push'), RemovedAsset(component='skills', target='.claude/skills/repomatic-test/SKILL.md', removed_in='6.21.0', hashes=('8bc5f054507b369f9be34dd4a34183e00b6a8e0186c34d4deb385032e6682e1a', 'cb987bfe342c2d00ea1a6226585238f19bc5a351a678124f7e6225d5c6122c2c', '17bae80a4b98518b6037518ad340a60d117d35a4fa26725fa2ab685ebd23e8dd'), successor='now handled by tests.yaml on every push'), RemovedAsset(component='workflows', target='.github/workflows/label-sponsors.yaml', removed_in='4.25.0', hashes=(), successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/labeller-content-based.yaml', removed_in='4.25.0', hashes=(), successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/labeller-file-based.yaml', removed_in='4.25.0', hashes=(), successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/renovate.yaml', removed_in='7.0.0.dev0', hashes=(), successor='replaced by self-hosted sync-tool-versions, sync-action-pins, and sync-workflow-pins'))

Tombstones for assets repomatic has dropped (see RemovedAsset).

init prunes orphaned copies of these from downstream repos. Ordered by (component, target).

When you drop a bundled asset from COMPONENTS, add an entry here so the removal propagates downstream on the next init instead of leaving an orphan. List one hash per distinct content the asset shipped across its released lifetime, collected from the release tags where its data file existed:

import hashlib, subprocess

src = "repomatic/data/skill-repomatic-release.md"  # the dropped data file
tags = subprocess.run(
    ["git", "tag", "--list", "v*"], capture_output=True, text=True, check=True
).stdout.split()
hashes = {}
for tag in tags:
    blob = subprocess.run(
        ["git", "show", f"{tag}:{src}"], capture_output=True, text=True, encoding="UTF-8"
    )
    if blob.returncode == 0:
        normalized = blob.stdout.rstrip() + "\n"
        hashes.setdefault(hashlib.sha256(normalized.encode("UTF-8")).hexdigest(), tag)
print(tuple(hashes))  # distinct contents, in first-shipped order

Removed workflows are fingerprint-gated, not hashed: omit hashes and give the workflow’s downstream path as target (.github/workflows/{name}).

repomatic.registry.DEFAULT_REPO: str = 'kdeldycke/repomatic'

Default upstream repository for reusable workflows.

repomatic.registry.UPSTREAM_REPO_SLUGS: tuple[str, ...] = ('kdeldycke/repomatic', 'kdeldycke/repokit', 'kdeldycke/workflows')

Upstream repository slugs across the project’s renames, current first.

A downstream thin-caller’s uses: line references whichever slug was current when it was generated. Workflow-tombstone detection matches against all of them (current first, since most callers are recent) so an orphaned thin-caller is recognized regardless of which era set it up.

repomatic.registry.UPSTREAM_SOURCE_GLOB: str = 'repomatic/**'

Path glob for the upstream source directory in canonical workflows.

Canonical workflow paths: filters use this glob to match source code changes. In downstream repos, this is replaced with the project’s own source directory.

repomatic.registry.UPSTREAM_SOURCE_PREFIX: str = 'repomatic/'

Path prefix for upstream-specific files in canonical workflows.

Paths starting with this prefix (but not matching UPSTREAM_SOURCE_GLOB) are dropped in downstream thin callers because they reference files that only exist in the upstream repository (like repomatic/data/labels.toml).

repomatic.registry.SKILL_PHASE_ORDER: tuple[str, ...] = ('Setup', 'Development', 'Quality', 'Maintenance', 'Release')

Canonical display order for lifecycle phases in list-skills output.

repomatic.registry.ALL_COMPONENTS: dict[str, str] = {'agents': 'Claude Code agent definitions (.claude/agents/)', 'awesome-template': 'Boilerplate for awesome-* repositories', 'bumpversion': 'bump-my-version configuration', 'changelog': 'Minimal changelog.md', 'codecov': 'Codecov PR comment config (.github/codecov.yaml)', 'labels': 'Label config files (labels.toml + labeller rules)', 'lychee': 'Lychee link checker configuration', 'mdformat': 'mdformat Markdown formatter configuration', 'mypy': 'Mypy type checking configuration', 'publish-pypi-action': 'Composite action that publishes to PyPI via Trusted Publishing (.github/actions/publish-pypi/)', 'pytest': 'Pytest test configuration', 'ruff': 'Ruff linter/formatter configuration', 'skills': 'Claude Code skill definitions (.claude/skills/)', 'typos': 'Typos spell checker configuration', 'uv': 'uv resolver pin and dependency cooldown policy', 'workflows': 'Thin-caller workflow files'}

All available init components.

repomatic.registry.BUNDLED_VERBATIM_TARGETS: frozenset[str] = frozenset({'.claude/agents/grunt-qa.md', '.claude/agents/qa-engineer.md', '.claude/agents/sphinx-docs.md', '.claude/skills/av-false-positive/SKILL.md', '.claude/skills/awesome-triage/SKILL.md', '.claude/skills/babysit-ci/SKILL.md', '.claude/skills/benchmark-update/SKILL.md', '.claude/skills/brand-assets/SKILL.md', '.claude/skills/file-bug-report/SKILL.md', '.claude/skills/repomatic-audit/SKILL.md', '.claude/skills/repomatic-changelog/SKILL.md', '.claude/skills/repomatic-deps/SKILL.md', '.claude/skills/repomatic-init/SKILL.md', '.claude/skills/repomatic-ship/SKILL.md', '.claude/skills/repomatic-topics/SKILL.md', '.claude/skills/sphinx-docs-sync/SKILL.md', '.claude/skills/translation-sync/SKILL.md', '.claude/skills/upstream-audit/SKILL.md', '.github/actions/publish-pypi/action.yaml', '.github/codecov.yaml', '.github/labeller-content-based.yaml', '.github/labeller-file-based.yaml', 'labels.toml'})

Target paths repomatic init writes verbatim from a repomatic/data/ template.

Every BundledComponent copies its bundled source byte-for-byte to the target, so downstream the file’s content (including any SHA-pinned uses: ref) is owned by repomatic init. sync-action-pins and sync-workflow-pins skip these paths for the same reason they skip UPSTREAM_REPO_SLUGS: a pin the next sync-repomatic overwrites turns the two pull requests into a ping-pong, the bump PR and the init-revert PR chasing each other. The skip lifts inside the source repo, where each bundled source is a symlink to its in-tree target and the pin is a normal source-of-truth ref (see repomatic.sync_ops._pinnable_files). Generated workflows (WorkflowComponent) are deliberately absent: they carry only upstream-slug refs (already skipped) and may host downstream-authored extra jobs whose third-party pins the bumpers should keep current.

repomatic.registry.REUSABLE_WORKFLOWS: tuple[str, ...] = ('autofix.yaml', 'autolock.yaml', 'cancel-runs.yaml', 'changelog.yaml', 'debug.yaml', 'docs.yaml', 'labels.yaml', 'lint.yaml', 'release.yaml', 'unsubscribe.yaml')

Workflow filenames that support workflow_call triggers.

repomatic.registry.NON_REUSABLE_WORKFLOWS: frozenset[str] = frozenset({'tests.yaml'})

Workflows without workflow_call that cannot be used as thin callers.

repomatic.registry.ALL_WORKFLOW_FILES: tuple[str, ...] = ('autofix.yaml', 'autolock.yaml', 'cancel-runs.yaml', 'changelog.yaml', 'debug.yaml', 'docs.yaml', 'labels.yaml', 'lint.yaml', 'release.yaml', 'tests.yaml', 'unsubscribe.yaml')

All workflow filenames (reusable and non-reusable).

repomatic.registry.WORKFLOW_SOURCES: dict[str, str] = {'autofix.yaml': 'autofix.yaml', 'autolock.yaml': 'autolock.yaml', 'cancel-runs.yaml': 'cancel-runs.yaml', 'changelog.yaml': 'changelog.yaml', 'debug.yaml': 'debug.yaml', 'docs.yaml': 'docs.yaml', 'labels.yaml': 'labels.yaml', 'lint.yaml': 'lint.yaml', 'release.yaml': '_release-engine.yaml', 'tests.yaml': 'tests.yaml', 'unsubscribe.yaml': 'unsubscribe.yaml'}

Maps each workflow’s downstream file_id to its bundled source filename.

For most workflows source == file_id. The release entry is the exception: its downstream artifact is release.yaml, whose backing reusable engine is _release-engine.yaml (the lane the generic “is this a reusable workflow” tests inspect). The full set of reusable lanes the generated release.yaml calls is RELEASE_ENGINE_WORKFLOWS.

repomatic.registry.RELEASE_ENGINE_WORKFLOWS: tuple[str, ...] = ('_release-build.yaml', '_release-engine.yaml')

Reusable workflows the generated release.yaml references but that repomatic init never materializes downstream.

The workflows component deploys a generated release.yaml (not a thin delegation): its build job calls _release-build.yaml and its release job calls _release-engine.yaml, each via {repo}/.github/workflows/<lane>@<tag> resolved from this repo at the release tag rather than copied into the downstream tree. These lanes live in .github/workflows/ here (and at every release tag) but are not FileEntry targets and never appear in ALL_WORKFLOW_FILES.

The release entry’s FileEntry still records _release-engine.yaml as its source (see WORKFLOW_SOURCES) so the generic backing-reusable tests and a downstream repomatic lint can read it via get_data_content to check the engine lane forwards its secrets; _release-build.yaml is not bundled because nothing reads it at runtime (the build lane declares no secrets). Naming both lanes here lets stale-file detection and the data-symlink rules treat them as a group instead of special-casing each by hand.

repomatic.registry.SKILL_PHASES: dict[str, str] = {'av-false-positive': 'Release', 'awesome-triage': 'Maintenance', 'babysit-ci': 'Quality', 'benchmark-update': 'Development', 'brand-assets': 'Development', 'file-bug-report': 'Maintenance', 'repomatic-audit': 'Maintenance', 'repomatic-changelog': 'Release', 'repomatic-deps': 'Development', 'repomatic-init': 'Setup', 'repomatic-ship': 'Release', 'repomatic-topics': 'Development', 'sphinx-docs-sync': 'Maintenance', 'translation-sync': 'Maintenance', 'upstream-audit': 'Maintenance'}

Maps skill names to lifecycle phases for display grouping.

repomatic.registry.FILE_SELECTOR_COMPONENTS: tuple[str, ...] = ('labels', 'codecov', 'publish-pypi-action', 'agents', 'skills', 'workflows')

Components that support file-level component/file selectors.

repomatic.registry.COMPONENT_HELP_TABLE: str = '    labels                 Label config files (labels.toml + labeller rules)\n    codecov                Codecov PR comment config (.github/codecov.yaml)\n    publish-pypi-action    Composite action that publishes to PyPI via Trusted Publishing (.github/actions/publish-pypi/)\n    agents                 Claude Code agent definitions (.claude/agents/)\n    skills                 Claude Code skill definitions (.claude/skills/)\n    workflows              Thin-caller workflow files\n    awesome-template       Boilerplate for awesome-* repositories\n    changelog              Minimal changelog.md\n    uv                     uv resolver pin and dependency cooldown policy\n    lychee                 Lychee link checker configuration\n    ruff                   Ruff linter/formatter configuration\n    pytest                 Pytest test configuration\n    mypy                   Mypy type checking configuration\n    mdformat               mdformat Markdown formatter configuration\n    bumpversion            bump-my-version configuration\n    typos                  Typos spell checker configuration'

Formatted component table for CLI help text.

repomatic.registry.valid_file_ids(component)[source]

Return valid file identifiers for a component.

Components with file entries report their declared file_id values. Returns an empty set for components without file-level selection (e.g., changelog, tool configs).

Return type:

frozenset[str]

repomatic.registry.excluded_rel_path(component, file_id)[source]

Map a component and file identifier to its relative output path.

Returns None when the identifier cannot be resolved (e.g., for tool config components that have no file-level exclusion support).

Return type:

str | None

repomatic.registry.parse_component_entries(entries, *, context='entry')[source]

Parse component entries into full-component and file-level sets.

Bare names (no /) must be component names from ALL_COMPONENTS. Qualified component/identifier entries target individual files. Raises ValueError on unknown entries.

Used by both the exclude config path and the CLI positional selection, with context controlling error message wording.

Parameters:

context (str) – Label for error messages (e.g., "exclude", "selection").

Return type:

tuple[set[str], dict[str, set[str]]]

Returns:

(full_components, file_selections) where file_selections maps component names to sets of file identifiers.

repomatic.setup_guide module

Build and manage the setup guide issue.

Backs the setup-guide command: composes the repository-settings checks from repomatic.lint_repo and the PAT permission probes from repomatic.github.token with the setup-guide-* templates into a single issue body, then drives the issue lifecycle. Each setup step renders as a collapsible section whose open/closed state and emoji reflect the check outcome, and the issue closes only once every verifiable step passes.

repomatic.setup_guide.manage_setup_guide(config, *, has_pat, has_notifications_pat, has_virustotal_key, repo)[source]

Render the setup guide issue body and drive the issue lifecycle.

Runs the per-step checks (PAT permissions, branch ruleset, immutable releases, fork PR approval, PyPI Trusted Publisher, Pages source), renders each as a collapsible section, and opens, updates, or closes the setup issue accordingly. The issue closes only when all verifiable steps pass.

Parameters:
  • config (Config) – The resolved [tool.repomatic] configuration.

  • has_pat (bool) – Whether REPOMATIC_PAT is configured.

  • has_notifications_pat (bool) – Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

  • has_virustotal_key (bool) – Whether VIRUSTOTAL_API_KEY is configured.

  • repo (str | None) – Repository in owner/repo format; permission and settings checks are skipped when None.

Return type:

None

repomatic.sync_ops module

Registry of the cooldown-respecting dependency updaters, and their driver.

The five sync-* dependency bumpers (sync-dep-sources, sync-uv-lock, sync-tool-versions, sync-action-pins, sync-workflow-pins) share a shape: discover the latest eligible upstream version, gated by the [tool.repomatic] minimum-release-age cooldown (or uv’s exclude-newer for the lock), then rewrite the pin. This module turns that shape into data: one SyncOperation per bumper, in SYNC_OPERATIONS.

The registry is the single source of truth consumed three ways: the thin sync-* commands and the aggregate sync-deps command in repomatic.cli, and the consolidated CI job emitted by repomatic.github.workflow_sync.

Resolve then apply

Each operation splits into a read phase and a write phase so sync-deps can run the slow, network-bound discovery for every operation concurrently, then write serially:

  • SyncOperation.resolve performs the network discovery and computes the new file contents in memory, returning a SyncPlan. It does not touch the repository, so the resolves are safe to run in parallel.

  • SyncOperation.apply writes the planned contents. Three of the five operations rewrite .github/workflows/*.yaml (action pins, workflow literals, and the actionlint matcher URL all live there), so applies must run serially.

sync-uv-lock and sync-dep-sources are the documented exceptions: their discovery is a mutation (uv lock rewrites uv.lock), so their SyncOperation.resolve writes during the parallel phase and their SyncOperation.apply is a no-op. Their shared write domain (uv.lock, pyproject.toml) is disjoint from every other operation’s, and the two are serialized against each other through _UV_PROJECT_MUTEX. A --dry-run resolve snapshots and restores the mutated files so the preview leaves no trace.

The datasource adapters, version selection, and pure string rewriters live in repomatic.version_sync and repomatic.uv; this module composes them with the file I/O and checksum recompute. Terminal and PR-body rendering stay in repomatic.cli, fed from the SyncPlan.

repomatic.sync_ops.DEPENDENCY_LABEL = '🔗 dependencies'

GitHub label applied to every dependency-update PR.

Shared by all five bumpers so a single label filters the whole family.

repomatic.sync_ops.workflow_and_action_files()[source]

Collect workflow and composite-action YAML files under .github/.

Return type:

list[Path]

class repomatic.sync_ops.ResolveContext(config, today, release_notes=False, held_back=True, dry_run=False, lockfile=<factory>)[source]

Bases: object

Inputs shared by every SyncOperation.resolve.

Each operation reads the subset it needs. The cooldown is derived from config (minimum-release-age for the version-sync trio, exclude-newer from the lock for sync-uv-lock).

config: Config

The resolved [tool.repomatic] configuration.

today: date

Reference date for the cooldown computation, fixed once per run.

release_notes: bool = False

Fetch upstream release notes and append them to the report.

held_back: bool = True

Report newer releases withheld only by the cooldown.

dry_run: bool = False

Plan without persisting: restore any files the resolve had to mutate.

lockfile: Path

Path to uv.lock for sync-uv-lock.

class repomatic.sync_ops.SyncPlan(operation, subject, heading, changes=<factory>, dates=<factory>, released_overrides=<factory>, name_urls=<factory>, comparison_urls=<factory>, held_back=<factory>, held_back_name_urls=<factory>, held_back_note='Newer releases already published but withheld because they are still inside the [`minimum-release-age`](https://kdeldycke.github.io/repomatic/configuration.html#minimum-release-age) cooldown window.', notes_section='', cooldown_note='', cutoff=None, reference_date=None, file_writes=<factory>, binary_overrides=<factory>, actionlint_version=None, checksums_path=None, exclude_newer='', reverted=False, pins_synced=False, pruned_bypasses=<factory>, frozen_bypasses=<factory>, bypass_forecasts=<factory>, source_swaps=<factory>)[source]

Bases: object

The resolved, not-yet-written outcome of one operation’s read phase.

Carries everything SyncOperation.apply needs to write the changes and everything repomatic.cli needs to render the terminal table and the markdown PR body, so the write and the rendering never re-resolve.

operation: str

The operation name (sync-uv-lock, …).

subject: str

Header for the first table column (Package, Tool, Action).

heading: str

Noun after ## 🆙 `` in the diff table (``Updated tools, …).

changes: list[tuple[str, str, str]]

Applied (name, old, new) triples, in the order the report renders.

dates: dict[str, str]

Name to release/upload date (YYYY-MM-DD or ISO 8601) for the table.

released_overrides: dict[str, str]

Name to literal markdown replacing its “Released” table cell.

Marks rows whose version was decided outside the cooldown-checked release listing (the upstream toolkit’s lockstep-aligned pin), so the table shows the exemption instead of a blank cell.

name_urls: dict[str, str]

Name to the URL its table cell links to (PyPI, GitHub, npm).

comparison_urls: dict[str, str]

Name to a compare URL linked on the change cell.

held_back: list[HeldBackPackage]

Newer releases withheld only by the cooldown.

held_back_name_urls: dict[str, str]

Name to URL for the held-back section.

held_back_note: str = 'Newer releases already published but withheld because they are still inside the [`minimum-release-age`](https://kdeldycke.github.io/repomatic/configuration.html#minimum-release-age) cooldown window.'

Intro paragraph for the held-back section (cooldown wording).

notes_section: str = ''

Pre-rendered release-notes markdown, or empty.

cooldown_note: str = ''

Pre-rendered cooldown-cutoff sentence shown above the diff table.

cutoff: date | None = None

Effective minimum-release-age cutoff, for the terminal report.

reference_date: date | None = None

Reference date for the table’s relative “Released” hints (the run date).

file_writes: dict[Path, str]

Path to its new full text, written verbatim by SyncOperation.apply.

binary_overrides: dict[str, str]

Binary tool name to new version, for the checksum recompute.

actionlint_version: str | None = None

New actionlint version, for the matcher-URL realignment.

checksums_path: Path | None = None

The tool_runner.py path the checksum recompute rewrites.

exclude_newer: str = ''

The exclude-newer cutoff from the lock, or empty.

reverted: bool = False

Whether a cosmetic-only re-lock was discarded.

pins_synced: bool = False

Whether the [tool.uv] policy pins were refreshed from the template.

pruned_bypasses: list[BypassForecast]

Expired exclude-newer-package entries removed from pyproject.toml, snapshot with the version and expiry each freeze had.

frozen_bypasses: list[str]

exclude-newer-package entries rewritten into freeze cutoffs.

bypass_forecasts: list[BypassForecast]

Active cooldown-bypass freezes with their expiry forecasts.

source_swaps: list[ReleaseSwap]

Git-tracked dependencies swapped to their released versions.

property has_changes: bool

Whether the operation found anything to update.

Cooldown-bypass edits count: a run that only prunes or freezes exclude-newer-package entries still rewrites pyproject.toml and must produce a report explaining that hunk.

note_cooldown(age_label, min_age, today)[source]

Record the cooldown cutoff and its rendered diff-table note.

No-op fields (a None cutoff, an empty note) when the cooldown is disabled (0 days or unparsable).

Return type:

None

repomatic.sync_ops.render_plan_markdown(plan)[source]

Render a plan as the markdown PR-body section every updater shares.

Concatenates the source-swap section (when the plan carries one), the diff table, any release notes, the held-back section, and the uv cooldown-bypass section exactly as the individual sync-* commands do, so sync-deps and the thin commands produce identical output for the same plan.

Return type:

str

class repomatic.sync_ops.SyncOperation(name, config_flag, job_name, job_if, resolve, apply, applies_here, write_domain, editable=False, needs_gh_token=False, ci_flags=())[source]

Bases: object

One cooldown-respecting dependency updater, as data.

Naming rule 3 (claude.md): the CLI command, workflow job ID, PR branch, and PR-body template all share name. The CI-only metadata (job_name, job_if, editable, needs_gh_token, ci_flags) lets repomatic.github.workflow_sync emit the consolidated job without a hand-maintained YAML twin.

name: str

Command, job ID, branch, and template name (all identical).

config_flag: str

The Config boolean gating this operation.

job_name: str

Human-facing CI job step name, with its emoji (⛓️ Sync uv.lock).

job_if: str

The workflow if: expression gating the CI steps (empty for none).

resolve: Callable[[ResolveContext], SyncPlan]

Read phase: network discovery, returns a SyncPlan.

apply: Callable[[SyncPlan], None]

Write phase: persist the plan’s file writes.

applies_here: Callable[[], bool]

Whether the operation is meaningful in the current working tree.

write_domain: tuple[str, ...]

Human-readable globs the operation mutates (for conflict awareness).

editable: bool = False

CI install mode: uv run --frozen (rewrites source) vs uvx --from ..

needs_gh_token: bool = False

Whether the CI step needs GH_TOKEN for the GitHub releases API.

ci_flags: tuple[str, ...] = ()

Extra CLI flags the consolidated CI job passes to the command.

property branch: str

The PR branch name (identical to name).

property template: str

The PR-body template name (identical to name).

is_enabled(config)[source]

Whether this operation is enabled in config.

Return type:

bool

repomatic.sync_ops.SYNC_OPERATIONS: tuple[SyncOperation, ...] = (SyncOperation(name='sync-dep-sources', config_flag='dep_sources_sync', job_name='🔀 Sync dependency sources', job_if='fromJSON(needs.metadata.outputs.metadata).is_python_project', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>, write_domain=('uv.lock', 'pyproject.toml'), editable=False, needs_gh_token=False, ci_flags=('--no-table', '--release-notes')), SyncOperation(name='sync-uv-lock', config_flag='uv_lock_sync', job_name='⛓️ Sync uv.lock', job_if='fromJSON(needs.metadata.outputs.metadata).is_python_project', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>, write_domain=('uv.lock', 'pyproject.toml [tool.uv]'), editable=False, needs_gh_token=False, ci_flags=('--no-table', '--release-notes')), SyncOperation(name='sync-action-pins', config_flag='action_pins_sync', job_name='📌 Sync action pins', job_if='fromJSON(needs.metadata.outputs.metadata).workflow_files', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), editable=False, needs_gh_token=True, ci_flags=('--release-notes',)), SyncOperation(name='sync-workflow-pins', config_flag='workflow_pins_sync', job_name='🔖 Sync workflow pins', job_if='fromJSON(needs.metadata.outputs.metadata).workflow_files', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), editable=False, needs_gh_token=True, ci_flags=('--release-notes',)), SyncOperation(name='sync-tool-versions', config_flag='tool_versions_sync', job_name='🔼 Sync tool versions', job_if="github.repository == 'kdeldycke/repomatic' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')", resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tool_runner.py', '.github/workflows/lint.yaml'), editable=True, needs_gh_token=True, ci_flags=('--release-notes',)))

The cooldown-respecting dependency updaters, in CI execution order.

sync-dep-sources first (adopting a release changes what the routine re-lock even does), then sync-uv-lock (its lock churn gates other Python work), then the two workflow-file rewriters, then the upstream-only tool bump last.

repomatic.sync_ops.OPERATIONS_BY_NAME: dict[str, SyncOperation] = {'sync-action-pins': SyncOperation(name='sync-action-pins', config_flag='action_pins_sync', job_name='📌 Sync action pins', job_if='fromJSON(needs.metadata.outputs.metadata).workflow_files', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), editable=False, needs_gh_token=True, ci_flags=('--release-notes',)), 'sync-dep-sources': SyncOperation(name='sync-dep-sources', config_flag='dep_sources_sync', job_name='🔀 Sync dependency sources', job_if='fromJSON(needs.metadata.outputs.metadata).is_python_project', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>, write_domain=('uv.lock', 'pyproject.toml'), editable=False, needs_gh_token=False, ci_flags=('--no-table', '--release-notes')), 'sync-tool-versions': SyncOperation(name='sync-tool-versions', config_flag='tool_versions_sync', job_name='🔼 Sync tool versions', job_if="github.repository == 'kdeldycke/repomatic' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')", resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tool_runner.py', '.github/workflows/lint.yaml'), editable=True, needs_gh_token=True, ci_flags=('--release-notes',)), 'sync-uv-lock': SyncOperation(name='sync-uv-lock', config_flag='uv_lock_sync', job_name='⛓️ Sync uv.lock', job_if='fromJSON(needs.metadata.outputs.metadata).is_python_project', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>, write_domain=('uv.lock', 'pyproject.toml [tool.uv]'), editable=False, needs_gh_token=False, ci_flags=('--no-table', '--release-notes')), 'sync-workflow-pins': SyncOperation(name='sync-workflow-pins', config_flag='workflow_pins_sync', job_name='🔖 Sync workflow pins', job_if='fromJSON(needs.metadata.outputs.metadata).workflow_files', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), editable=False, needs_gh_token=True, ci_flags=('--release-notes',))}

SYNC_OPERATIONS keyed by SyncOperation.name.

repomatic.sync_ops.selected_operations(config, *, here_only=True, names=None)[source]

Return the operations to run, in SYNC_OPERATIONS order.

The config feature flags are always authoritative: a disabled operation is dropped whether or not it was named (mirrors each standalone sync-* command, which exits when its flag is off).

Parameters:
  • config (Config) – The resolved configuration; disabled operations are dropped.

  • here_only (bool) – Drop operations whose SyncOperation.applies_here is false (no uv.lock, no workflow files, not the repomatic checkout). Ignored when names is given: naming an operation is an explicit opt-in that bypasses the working-tree probe (the “scope exclusions are defaults, not absolutes” rule in claude.md).

  • names (Sequence[str] | None) – When given, restrict to these operation names. Unknown names are ignored (the CLI validates them upstream).

Return type:

list[SyncOperation]

repomatic.sync_ops.run_sync_operations(operations, rc, *, spinner_label=None)[source]

Resolve operations concurrently, then apply them serially.

The resolve phase fans out through click_extra.run_jobs() (the work is network-bound and disjoint per operation), sized by the global --jobs option and sequential when no CLI context is active (as in tests). At DEBUG verbosity the fan-out also collapses to sequential so per-operation log narration stays coherent, and a Ctrl+C drops queued resolves instead of waiting for them. The apply phase runs in SYNC_OPERATIONS order because three of the five rewrite the same workflow files. In --dry-run no apply runs. An operation whose resolve raises is logged and reported with a None plan so one failure never blocks the others.

Parameters:
  • operations (Sequence[SyncOperation]) – The operations to run (already filtered by the caller).

  • rc (ResolveContext) – Shared resolve inputs.

  • spinner_label (str | None) – When set and attached to a TTY, animate a spinner for the resolve phase (silent off a TTY, so CI and tests show nothing).

Return type:

list[tuple[SyncOperation, SyncPlan | None]]

Returns:

Each operation paired with its plan (or None if its resolve failed), in SYNC_OPERATIONS order.

repomatic.sync_ops.operation_order(operations)[source]

Sort operations into SYNC_OPERATIONS order.

Return type:

list[SyncOperation]

repomatic.test_matrix module

Test matrix constants for CI workflows.

Defines the GitHub-hosted runner images and Python versions used to build test matrices. Separating these from repomatic.metadata makes the CI matrix configuration self-contained and easier to update when runner images or Python releases change.

repomatic.test_matrix.TEST_RUNNERS_FULL = ('ubuntu-24.04-arm', 'ubuntu-slim', 'macos-26', 'macos-26-intel', 'windows-11-arm', 'windows-2025')

GitHub-hosted runners for the full test matrix.

Two variants per platform (one per architecture). See available images.

Note

Architecture speed is not uniform across platforms

When reducing to one runner per OS, choose by measured speed, not architecture (see Test matrix). Tendencies from repomatic’s own full test suite: ARM Linux (ubuntu-24.04-arm) runs two to three times as fast as the lean x86 ubuntu-slim, the slowest tier overall; Apple-silicon macos-26 beats macos-26-intel by ~2x; the two Windows images tie on compute (windows-2025 is the PR pick). Per-job wall-clock folds in setup and upload, so isolate the test steps before blaming the image. These figures drift as images are re-provisioned, so re-confirm against your own job timings.

repomatic.test_matrix.TEST_RUNNERS_PR = ('ubuntu-24.04-arm', 'macos-26', 'windows-2025')

Reduced runner set for pull request test matrices.

One runner per platform: ARM Linux (ubuntu-24.04-arm) and Apple-silicon macOS (macos-26) are the fastest of their platform on the test workload, plus x86 Windows (windows-2025, where the two Windows images tie on compute). x86 Linux stays covered by the full matrix (TEST_RUNNERS_FULL).

Note

Why ARM Linux for the PR slot

The suite runs pytest --numprocesses=auto, so it scales with cores and favors ARM: ubuntu-24.04-arm runs it two to three times faster than the lean ubuntu-slim, for quicker PR feedback. That ratio is the heavy test suite’s, not a portable property: setup-bound light jobs (which run prebuilt single-threaded binaries) barely move between runners, so they keep the lean ubuntu-slim default. See Test matrix for the measurements.

repomatic.test_matrix.TEST_PYTHON_FULL = ('3.10', '3.14', '3.15')

Python versions tested across every runner in the full matrix.

Spans the supported range: the floor (3.10), the latest stable release (3.14), and the in-development version (3.15, flagged continue-on-error via UNSTABLE_PYTHON_VERSIONS). Intermediate releases (3.11, 3.12, 3.13) are skipped to reduce CI load. Released build flavors (free-threaded) are not full-spread; they get a single-runner smoke test instead, see SINGLE_RUNNER_PYTHON_VERSIONS.

repomatic.test_matrix.TEST_PYTHON_PR = ('3.10', '3.14')

Reduced Python version set for pull request test matrices.

Just the floor and the latest stable release, for fast PR feedback. The in-development version and released build flavors (free-threaded) are left to the full matrix.

repomatic.test_matrix.UNSTABLE_PYTHON_VERSIONS: Final[frozenset[str]] = frozenset({'3.15'})

Python versions still in development.

Jobs using these versions run with continue-on-error in CI. Contrast with SINGLE_RUNNER_PYTHON_VERSIONS, which are released and run stable.

repomatic.test_matrix.SINGLE_RUNNER_PYTHON_VERSIONS: Final[dict[str, str]] = {'3.14t': 'ubuntu-24.04-arm'}

Released Python build flavors smoke-tested on a single runner, mapped to it.

A free-threaded build (the t suffix, made officially supported in 3.14 by PEP 779) runs the same released interpreter as its base version, just without the GIL. The base version already gets the full cross-platform spread (TEST_PYTHON_FULL), so the library logic is covered everywhere; the flavor only needs one runner to catch a free-threading-specific break. These run stable (expected to pass), unlike the unreleased UNSTABLE_PYTHON_VERSIONS. The runner is ubuntu-24.04-arm, the default single-runner pick: the fastest measured on compute-bound parallel work and the cheapest tier, and free-threading targets server workloads where Linux/ARM is the norm (see Test matrix).

repomatic.test_matrix.MYPY_VERSION_MIN: Final = (3, 8)

Earliest version supported by Mypy’s --python-version 3.x parameter.

Sourced from Mypy original implementation.

repomatic.tool_runner module

Unified tool runner with managed config resolution.

Provides repomatic run <tool> — a single entry point that installs an external tool at a pinned version, resolves its configuration through a strict 4-level precedence chain, translates [tool.X] sections from pyproject.toml into the tool’s native format, and invokes the tool with the resolved config.

Important

Config resolution precedence (first match wins, no merging):

  1. Native config file — tool’s own config file in the repo.

  2. ``[tool.X]`` in ``pyproject.toml`` — translated to native format.

  3. Bundled default — from repomatic/data/.

  4. Bare invocation — no config at all.

repomatic.tool_runner.GENERATED_HEADER_TEMPLATE = 'Generated by {command} v{version} - https://github.com/kdeldycke/repomatic'

Template for the first line of generated-file headers.

Used by both CLI commands (e.g. sync-mailmap) and the tool runner (e.g. run shfmt) to stamp files with provenance. Format fields: command (full command path) and version (package version).

repomatic.tool_runner.generated_header(command, comment_prefix='# ')[source]

Return a generated-by header block with timestamp.

Parameters:
  • command (str) – Full command path (e.g. repomatic sync-mailmap).

  • comment_prefix (str) – Comment prefix for the target format.

Return type:

str

class repomatic.tool_runner.ArchiveFormat(*values)[source]

Bases: Enum

Archive format for binary tool downloads.

RAW = 'raw'
TAR_GZ = 'tar.gz'
TAR_XZ = 'tar.xz'
ZIP = 'zip'
tarfile_mode()[source]

Return the tarfile.open mode string for this format.

Raises:

ValueError – If called on a non-tar format.

Return type:

Literal['r:gz', 'r:xz']

extract(archive_path, spec, dest_dir, executable)[source]

Extract executable from archive_path in this format.

A RAW download is the executable itself and is renamed into place; the archive formats delegate to their extractors.

Parameters:
  • archive_path (Path) – Path to the downloaded archive file.

  • spec (BinarySpec) – Binary specification with executable path info.

  • dest_dir (Path) – Directory to extract into.

  • executable (str) – Executable filename to extract.

Return type:

Path

Returns:

Path to the extracted executable.

Raises:

FileNotFoundError – If the executable is not in the archive.

class repomatic.tool_runner.NativeFormat(*values)[source]

Bases: Enum

Target format for [tool.X] translation.

YAML = 'yaml'
TOML = 'toml'
JSON = 'json'
EDITORCONFIG = 'editorconfig'
FLAGS = 'flags'
serialize(data, tool_name='')[source]

Serialize a config dict to this format’s string representation.

When data is a live [tool.X] table parsed from pyproject.toml (a tomlrt.Table), the TOML branch keeps the user’s comments by reparenting the section to the document root; see _reroot_section. A plain dict carries no trivia, so it is rendered as-is. The other formats (YAML, JSON, editorconfig) cannot carry TOML comments across the format boundary, so they serialize the values only.

Parameters:
  • data (dict) – Configuration dictionary to serialize.

  • tool_name (str) – Tool name for the generated-by header comment.

Raises:

ValueError – For FLAGS, which is not a file format.

Return type:

str

repomatic.tool_runner.PlatformKey

A (platform_or_group, architecture) pair used as binary lookup key.

The platform element can be a single Platform (like MACOS) or a Group (like LINUX, which matches any Linux distribution). The architecture is always a concrete Architecture.

Resolution order in BinarySpec.resolve_platform():

  1. Exact Platform match (current_platform() == key_platform).

  2. Group membership (current_platform() in key_group), preferring the group with fewest members (most specific).

alias of tuple[Platform | Group, Architecture]

class repomatic.tool_runner.BinarySpec(urls, checksums, archive_format, archive_executable=None, strip_components=0)[source]

Bases: object

Platform-specific binary download specification.

Keys are PlatformKey tuples pairing an extra-platforms Platform or Group with an Architecture. This lets callers use broad groups (LINUX matches any distro) or specific platforms (DEBIAN) with full detection heuristics from extra-platforms.

Hint

Structural integrity checks (key types, checksum format, URL placeholders, strip_components consistency) are enforced in test_tool_spec_integrity. If the registry becomes user-configurable in the future, move these checks to __post_init__.

urls: dict[tuple[Platform | Group, Architecture], str]

Platform key to URL template mapping. URLs use {version} placeholders.

checksums: dict[tuple[Platform | Group, Architecture], str]

Platform key to SHA-256 hex digest mapping.

archive_format: ArchiveFormat | dict[tuple[Platform | Group, Architecture] | Platform | Group, ArchiveFormat]

Archive format of the downloaded file.

A single ArchiveFormat applies to every platform. A dict maps platform specifiers to formats, allowing mixed archives in one spec:

archive_format={ALL_PLATFORMS: ArchiveFormat.TAR_GZ, WINDOWS: ArchiveFormat.ZIP}

Dict keys follow the same resolution as resolve_platform(): exact PlatformKey tuple first, then bare Platform equality, then Group membership (smallest group wins).

archive_executable: str | None = None

Path of the executable inside the archive. None defaults to the tool name. For RAW format, used as the final filename.

strip_components: int = 0

Number of leading path components to strip when extracting.

resolve_platform()[source]

Match the current environment against registered platform keys.

Uses current_platform() and current_architecture() from extra-platforms, inheriting its full detection heuristics.

Return type:

tuple[Platform | Group, Architecture]

Returns:

The matching PlatformKey.

Raises:

RuntimeError – If no key matches the current environment.

get_archive_format(key)[source]

Return the archive format for the given platform key.

When archive_format is a single ArchiveFormat, returns it directly. When it is a dict, resolves in order: exact PlatformKey tuple, bare Platform equality, then Group membership (smallest group wins).

Return type:

ArchiveFormat

static platform_cache_key(key)[source]

Derive a filesystem-safe cache path segment from a platform key.

Return type:

str

Returns:

A string like linux-aarch64 or macos-x86_64.

repomatic.tool_runner.NPM_MIN_VERSION_FOR_COOLDOWN = '11.10.0'

First npm release honoring min-release-age, the cooldown gate for npm tools.

Older npm silently ignores the --min-release-age flag, so _install_npm() warns when it cannot enforce the cooldown. This is a fixed floor (the release that introduced the option), distinct from the auto-bumped npm@X bootstrap pin in lint.yaml, which tracks the latest npm.

class repomatic.tool_runner.NpmSpec[source]

Bases: object

npm-registry backend marker for a ToolSpec.

Presence (ToolSpec.npm is not None) selects the npm backend, the way a BinarySpec selects the download backend. The package name, executable, and version all derive from the ToolSpec fields, so no per-tool npm config is needed today; the class exists as a typed discriminator and a home for future npm-specific options.

Note

npm tools need Node.js and npm on PATH at run time: the one backend that depends on a runtime repomatic neither bundles nor provisions (binary tools are self-contained; the uv backends use uv). Integrity is npm’s own per-tarball verification on install, so unlike BinarySpec there is no repomatic-pinned checksum; the minimum-release-age cooldown (npm’s min-release-age, npm 11.10.0+) gates the transitive tree instead. Older npm ignores the gate, so the runner warns rather than silently skipping it.

class repomatic.tool_runner.ToolSpec(name, display_name=None, version='', package=None, executable=None, module=None, native_config_files=(), config_flag=None, native_format=NativeFormat.YAML, default_config=None, reads_pyproject=False, default_flags=(), ci_flags=(), with_packages=(), needs_venv=False, computed_params=None, config_after_subcommand=False, post_process=None, check_flags=(), binary=None, npm=None, source_url=None, tag_pattern=None, config_docs_url=None, cli_docs_url=None, docs_notes='')[source]

Bases: object

Specification for an external tool managed by repomatic.

Hint

Structural integrity checks (name format, version format, flag conventions, field consistency) are enforced in test_tool_spec_integrity. If the registry becomes user-configurable in the future, move these checks to __post_init__.

Hint

CLI parser quirks for config_after_subcommand

Tools that use subcommands (tool <subcmd> [flags] [files]) may require config_flag to appear after the subcommand name, depending on the CLI parser framework:

  • clap (Rust): global flags accepted before or after the subcommand. No special handling needed. Used by: ruff, labelmaker.

  • cobra (Go): root-level flags inherited by all subcommands, accepted in both positions. No special handling needed. Used by: gitleaks.

  • click (Python): global flags accepted before or after the subcommand. No special handling needed. Used by: bump-my-version.

  • bpaf (Rust): #[bpaf(external)] fields are scoped inside the subcommand variant, so tool <subcmd> --flag works but tool --flag <subcmd> does not. Set config_after_subcommand=True. Used by: biome.

name: str

Tool identity: CLI name for repomatic run <name>, default PyPI package name, and default executable name.

display_name: str | None = None

Human-readable name with proper casing for documentation (like 'Biome', 'Gitleaks'). None defaults to name.

version: str = ''

Pinned version (e.g., '1.38.0').

package: str | None = None

Install target passed to uvx/uv run (and pip). None defaults to name. Only set when it differs from the tool name, and may carry an install extra (Nuitka’s nuitka[onefile]); for PyPI lookups query the bare project name through pypi_name, which strips the extra.

executable: str | None = None

Executable name if different from the tool name. None defaults to the registry key.

module: str | None = None

Python module name for -m module invocation, e.g. 'nuitka'.

When set, the tool is invoked as python -m <module> instead of the console script. Requires needs_venv=True. Use when the tool’s script entry point is not reliably found across platforms (for example, Nuitka installs only a .cmd wrapper on Windows, which uv run -- nuitka cannot locate).

native_config_files: tuple[str, ...] = ()

Config filenames the tool auto-discovers, checked in order.

Paths relative to repo root (e.g., 'zizmor.yaml', '.github/actionlint.yaml'). Empty for tools with no config file.

config_flag: str | None = None

CLI flag to pass a config file path (e.g., '--config', '--config-file'). None if the tool only reads from fixed paths.

native_format: NativeFormat = 'yaml'

Target format for [tool.X] translation.

NativeFormat.FLAGS translates the table to CLI flags (via pyproject_table_to_flags) instead of a config file, for tools that expose their config keys as long options but read no config file themselves. It is mutually exclusive with reads_pyproject, config_flag, and native_config_files.

default_config: str | None = None

Filename in repomatic/data/ for bundled defaults, stored in native_format. None if no bundled default exists.

reads_pyproject: bool = False

Whether the tool natively reads [tool.X] from pyproject.toml.

When True and [tool.X] exists in pyproject.toml, repomatic skips Level 2 translation (the tool reads it directly). Resolution still falls through to Level 3 (bundled default) and Level 4 (bare) when no config is found.

default_flags: tuple[str, ...] = ()

Flags always passed to the tool (e.g., ('--strict',)).

ci_flags: tuple[str, ...] = ()

Flags added only when $GITHUB_ACTIONS is set (e.g., output format).

with_packages: tuple[str, ...] = ()

Extra packages installed alongside the tool (e.g., mdformat plugins).

Passed as --with <pkg> to uvx.

needs_venv: bool = False

If True, use uv run (project venv) instead of uvx (isolated).

Required when the tool imports project code (mypy, pytest).

computed_params: Callable[[Metadata], list[str]] | None = None

Callable that receives a Metadata instance and returns extra CLI args derived from project metadata (e.g., mypy’s --python-version from requires-python). None if no computed params.

config_after_subcommand: bool = False

Insert config_flag after the first token of extra_args.

Needed for tools whose CLI parser (e.g., bpaf) scopes global options inside the subcommand, so tool subcommand --config-path X is valid but tool --config-path X subcommand is not. When True, config_args are spliced after the first element of extra_args (the subcommand name).

post_process: Callable[[Sequence[str]], None] | None = None

Callback invoked on extra_args after the tool exits successfully.

Intended for temporary workarounds that fix known upstream formatting bugs in-place. Remove the callback once upstream ships the fix.

Note

The callback runs only after a successful write-mode exit (return code 0) and rewrites files on disk, so it cannot apply in check/dry-run mode, which writes nothing. Pair it with check_flags so run_tool warns when a check invocation would silently bypass it. See check_bypasses_post_process().

check_flags: tuple[str, ...] = ()

Flags that put the tool in check/dry-run mode, writing no files.

Warning

Check mode bypasses post_process: that fixup rewrites files on disk, but check mode writes nothing. So when a tool defines both a post_process and check_flags, its check-mode exit status is unreliable. run_tool detects the pairing via check_bypasses_post_process() and warns. Verify formatting by running the write path, not the check flag.

binary: BinarySpec | None = None

Platform-specific binary download spec. When set, the tool is downloaded as a binary instead of installed via uvx or uv run.

npm: NpmSpec | None = None

npm-registry backend marker. When set, the tool is installed from npm and run via its node_modules/.bin executable, instead of a binary download or a uv install. Mutually exclusive with binary and needs_venv.

source_url: str | None = None

GitHub repository or project homepage URL.

tag_pattern: str | None = None

Regex extracting the version from a GitHub release tag.

Used by sync-tool-versions for binary tools whose tags do not follow the common vX.Y.Z scheme. The pattern must define a version named group (e.g. r"^lychee-v(?P<version>.+)$" for lychee, r”^@biomejs/biome@ (?P<version>.+)$”` for biome). When None`, the version is the tag with a leading ``v stripped.

config_docs_url: str | None = None

URL to the tool’s configuration reference.

cli_docs_url: str | None = None

URL to the tool’s CLI usage documentation.

docs_notes: str = ''

Hand-written Markdown appended to the tool’s section in tool-runner.md.

Free-form usage notes the registry cannot derive: a **Try it:** shell session, a minimal [tool.X] example, caveats. Rendered live by repomatic.tool_runner_page.tool_reference after the generated metadata lines, so the prose stays next to the spec it documents.

property pypi_name: str

Bare PyPI project name for version and metadata lookups.

package doubles as the install target, so it may carry an install extra (Nuitka’s nuitka[onefile]`) that `_build_install_args needs at install time. The PyPI JSON API is keyed by the bare project name, though, and 404s on a bracketed extra, so sync-tool-versions and the held-back PR links query this stripped name (nuitka) instead.

property datasource_url: str

Human-facing URL for the tool’s version datasource.

npmjs for npm tools, the GitHub source_url when set, else the PyPI project page. Used by sync-tool-versions for the diff-table and held-back links.

check_bypasses_post_process(extra_args)[source]

Return True when a check-mode flag will skip post_process.

Check/dry-run flags (check_flags) make the tool exit without writing files, so the post_process fixup never runs and the exit status cannot be trusted: it may flag drift the write path would reconcile, or miss drift the write path would introduce. run_tool warns on this. Returns False for tools with no post_process, where check mode is authoritative.

Return type:

bool

repomatic.tool_runner.CHECKSUMS: dict[str, dict[tuple[Platform | Group, Architecture], str]] = {'actionlint': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644', (Platform(id='windows', name='Windows'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'cadcf7ea4efe3a68728893813643cebe1185e5b1d4be5b96245f65c9a4d5ea41', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '6e7241b51e6817ea6a047693d8e6fed13b31819c9a0dd6c5a726e1592d22f6e9'}, 'biome': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '698199017fc0b0c865d9a0ca2074102eb1fd0b358fd164595f3960b004fe4b90', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '4cd66b4a2953197e0e169f24b8a6e5f0fc42b3e852c3f58e562866244f3e11db', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '1250bb41a0409cf6c3133fc47819237eb61251624297f87158d2bed3ec123c3c', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'b3dfae5422dbd86272bb8ed40afec66670ea7754531d8fbcbae7e445e5430387', (Platform(id='windows', name='Windows'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '95bf56ccd99e090adf43127328e456b63422eb201471746b1d21c7aa33349005', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '49d1ef3925d31b2f08c00b76786621bf4603fdb4c3bfeff1aa2cd4d98de3a27c'}, 'gitleaks': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709', (Platform(id='windows', name='Windows'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'b95f5e4f5c425cedca7ee203d9afd29597e692c4924a12ed42f970537c72cc0f', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'd29144deff3a68aa93ced33dddf84b7fdc26070add4aa0f4513094c8332afc4e'}, 'labelmaker': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '4685e142da55150904d16624fe1052161de5dba1a859cddef19ab41833c37728', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'd76f8e64f9671884dac1758fe54a28a6680c5d9bf0ffd593a2c68ba558cc49a2', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'a52a4e102f0760ce1632da5fdaee2b0debe0e6ddea577b88a94a60172fe85751', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'dc8374d6a9bec4ebf143fb42e3024aeffabe8585bb9bd6f134cfaf0693be7688', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '939195930f9d5fd2b15a5cf43497019a52083e6c6713807d3379de49395c2e10'}, 'lychee': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '91a7bd65685da41b90ccb9bc867a3d649a7818042dae04ff405e55a25bddee4c', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '32975d1493ee1a975d6bb41e4fb56fe419cb442ded628bb772ba2e614acfacad'}, 'shfmt': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '32d92acaa5cd8abb29fc49dac123dc412442d5713967819d8af2c29f1b3857c7', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'fb096c5d1ac6beabbdbaa2874d025badb03ee07929f0c9ff67563ce8c75398b1', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '9680526be4a66ea1ffe988ed08af58e1400fe1e4f4aef5bd88b20bb9b3da33f8', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '6feedafc72915794163114f512348e2437d080d0047ef8b8fa2ec63b575f12af', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '60cd368533d0ad73fa86d93d5bbf95ef40587245ce684ed138c1b31557b5fe97'}, 'typos': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '2960ae07bc1ffe19e4895e4359394dd349c9c31de78aac3a124b6e4aeb206698', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '72a930c9a94fc3914aa56835c5b859c892a797d40c1c42638b98d93f16ff519c', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '7dcaf386ec255995dcbaf629641f961574b7e8785203921115eab75cbf1ca107', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'f4335c255db3d57374484e0e96505c8910c0e2fa6d8813b15de529c98f93b1a9', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'ce018a2352da7c1b23bd2684019ee279d2080dc063087020e80c1247d11b0743'}}

Tool name to platform-keyed SHA-256 hex digest mapping.

Recomputed in place by repomatic update-checksums and sync-tool-versions. Kept as a flat sidecar dict (rather than inline in each BinarySpec) so the checksum recompute can replace a hash by exact string match without re-parsing the registry, and so VERSIONS can anchor the offline staleness test.

repomatic.tool_runner.VERSIONS: dict[str, str] = {'actionlint': '1.7.12', 'biome': '2.5.4', 'gitleaks': '8.30.1', 'labelmaker': '0.6.4', 'lychee': '0.24.2', 'shfmt': '3.13.1', 'typos': '1.48.0'}

Tool name to the version each checksum set was computed for.

test_tool_spec_integrity asserts this equals the matching ToolSpec.version, so a bump whose checksums were never refreshed (a stale CHECKSUMS entry) fails CI offline, without downloading anything.

repomatic.tool_runner.get_data_file_path(filename)[source]

Yield the filesystem path of a bundled data file.

Unlike init_project.get_data_content() which returns string content, this yields a Path suitable for passing to external tools via --config <path>. The path is valid only within the context manager.

Return type:

Iterator[Path]

repomatic.tool_runner.load_pyproject_tool_section(tool_name)[source]

Load [tool.<tool_name>] from pyproject.toml in the current directory.

Returns the live tomlrt.Table (a dict subclass) rather than a plain-dict copy, so the section keeps its comment trivia for formats that can preserve it on materialization (see NativeFormat.serialize()). Callers that only read values or test truthiness are unaffected.

Return type:

dict[str, Any]

Returns:

The tool’s config table, or empty dict if not found.

repomatic.tool_runner.pyproject_table_to_flags(table)[source]

Translate a [tool.X] table into long-form CLI flags.

For tools whose command-line options mirror their config keys but which cannot read [tool.X] from pyproject.toml themselves and accept no config file. Follows the conventional mapping:

  • key = true--key

  • key = "value" (or a number) → --key=value

  • key = ["a", "b"]--key=a --key=b (one flag per item)

  • key = false is skipped: there is no universal --no-<key> form.

Keys keep their hyphenated spelling so they map straight onto long options, and flags follow their declaration order in pyproject.toml.

Return type:

list[str]

repomatic.tool_runner.resolve_config(spec, tool_config=None)[source]

Resolve config for a tool using the 4-level precedence chain.

Parameters:
  • spec (ToolSpec) – Tool specification.

  • tool_config (dict[str, Any] | None) – Pre-loaded [tool.X] config dict. If None, reads from pyproject.toml in the current directory.

Return type:

tuple[list[str], Path | None]

Returns:

Tuple of (extra CLI args for config, path to clean up). The path is None when no cleanup is needed (cache-based configs persist across runs). Non-None paths are CWD files written for tools that have no --config flag.

repomatic.tool_runner.binary_tool_context(name, no_cache=False)[source]

Download a binary tool and yield its executable path.

For tools invoked indirectly by repomatic commands (e.g., labelmaker called by sync-labels) rather than via run_tool(). Downloads once; the binary stays valid for the context’s duration. On a cache hit the yielded path points to the cache and the staging directory is empty.

Parameters:
  • name (str) – Tool name (must be in TOOL_REGISTRY with binary set).

  • no_cache (bool) – Bypass the binary cache when True.

Yields:

Path to the ready-to-run executable.

repomatic.tool_runner.run_tool(name, extra_args=(), version=None, checksum=None, skip_checksum=False, no_cache=False)[source]

Run an external tool with managed config resolution.

Parameters:
  • name (str) – Tool name (must be in TOOL_REGISTRY).

  • extra_args (Sequence[str]) – Extra arguments passed through to the tool.

  • version (str | None) – Override the pinned version.

  • checksum (str | None) – Override the SHA-256 checksum for the current platform.

  • skip_checksum (bool) – Skip SHA-256 verification entirely.

  • no_cache (bool) – Bypass the binary cache when True.

Return type:

int

Returns:

The tool’s exit code.

repomatic.tool_runner.resolve_config_source(spec)[source]

Return a human-readable description of the active config source.

Used by repomatic run --list to show which precedence level is active for each tool in the current repo.

Return type:

str

repomatic.tool_runner.find_unmodified_configs()[source]

Find native config files identical to their bundled defaults.

Iterates over every tool in TOOL_REGISTRY that has a default_config. For each, checks whether any of its native_config_files exists on disk and is content-identical to the bundled default after trailing-whitespace normalization.

The normalization (rstrip() + "\n"`) matches the convention used by `_init_config_files when writing files during init.

Return type:

list[tuple[str, str]]

Returns:

List of (tool_name, relative_path) tuples for each unmodified file found.

repomatic.tool_runner_page module

Markdown renderers behind the tool-runner.md documentation page.

Each renderer turns TOOL_REGISTRY into a Markdown fragment consumed by a {python:render} block in docs/tool-runner.md, so the page documents the live registry on every Sphinx build (the sibling of binaries_page, which renders binaries.md from release data). Nothing here is checked in: adding a tool to the registry is all it takes for the page to cover it.

repomatic.tool_runner_page.tool_summary()[source]

Render the summary table of all managed tools.

Return type:

str

repomatic.tool_runner_page.tool_reference()[source]

Render the per-tool detail sections.

The metadata of each section (version, install, config, flags, links) is generated from the registry; the trailing free-form prose comes from the spec’s own docs_notes field, so hand-written examples and caveats live next to the spec they document.

Return type:

str

repomatic.uv module

uv lock file operations.

This module provides utilities for managing uv.lock files: parsing versions, computing diff tables, managing exclude-newer-package cooldown overrides, and fetching release notes from GitHub.

repomatic.uv.uv_cmd(subcommand, *, frozen=False)[source]

Build a uv <subcommand> command prefix with standard flags.

Always includes --no-progress. Adds --frozen when requested (appropriate for run, export, sync — not for lock).

Return type:

list[str]

repomatic.uv.uvx_cmd(exclude_newer=None)[source]

Build a uvx command prefix with standard flags.

When exclude_newer is set (a YYYY-MM-DD date), adds --exclude-newer so the isolated resolution honors the minimum-release-age cooldown, gating the tool’s transitive dependencies by upload date.

Return type:

list[str]

repomatic.uv.RELEASE_NOTES_MAX_LENGTH = 2000

Maximum characters per package release body before truncation.

repomatic.uv.LOCK_TIMESTAMP_SENTINEL = '0001-01-01T00:00:00Z'

Placeholder uv writes to options.exclude-newer in uv.lock when the user-configured value is a relative span. The real cutoff is in options.exclude-newer-span as an ISO 8601 duration.

repomatic.uv.packages_outside_cooldown(pyproject_path, lock_path, packages)[source]

Return the subset of packages whose upload time exceeds the cooldown.

A package needs an exclude-newer-package exemption only when its locked version was uploaded after the exclude-newer cutoff, meaning a regular uv lock --upgrade would not resolve it.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • lock_path (Path) – Path to the uv.lock file.

  • packages (set[str]) – Candidate package names.

Return type:

set[str]

Returns:

The subset that actually requires a "0 day" override.

repomatic.uv.date_to_utc_cutoff(day)[source]

Render an exclude-newer-package cutoff date as an explicit UTC instant.

Warning

uv reads a bare YYYY-MM-DD in exclude-newer-package as the start of the following day in the locking machine’s local timezone, then writes that absolute instant into uv.lock’s [options.exclude-newer-package] block. The same date therefore lands as a different timestamp depending on where uv lock ran: 2026-06-13 becomes 2026-06-14T00:00:00Z on a UTC CI runner but 2026-06-13T20:00:00Z on a UTC+4 laptop. Every local lock then flips the value one way and every CI lock flips it back: an endless sync-uv-lock ping-pong.

Pinning the cutoff to that same next-day-midnight boundary expressed in UTC removes the ambiguity: uv stores a full RFC 3339 timestamp verbatim, identically on every machine.

Parameters:

day (date) – The cutoff date (the bare date uv would otherwise expand).

Return type:

str

Returns:

A YYYY-MM-DDT00:00:00Z timestamp at the start of the day after day, matching uv’s exclusive end-of-day expansion pinned to UTC.

repomatic.uv.upsert_exclude_newer_packages(pyproject_path, entries)[source]

Insert or replace [tool.uv].exclude-newer-package entries.

The write primitive shared by add_exclude_newer_packages() (which computes freeze cutoffs from the lock and never overwrites) and sync-dep-sources (which supplies exact cutoffs and must replace the stale value a git-tracking era left behind).

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • entries (dict[str, str]) – Package name to cutoff value (a freeze timestamp or a relative span). Existing entries for the same names are overwritten.

Return type:

bool

Returns:

True if the file was updated, False if no changes were needed.

repomatic.uv.add_exclude_newer_packages(pyproject_path, packages, lock_path)[source]

Add packages to [tool.uv].exclude-newer-package in pyproject.toml.

Persists for each package the _freeze_cutoff of its currently-locked version (a whole-day boundary just past that version’s upload) so that subsequent uv lock --upgrade runs (the sync-uv-lock job) hold the package within that freeze window instead of tracking the latest release, until it ages past the exclude-newer cooldown and prune_stale_exclude_newer_packages() drops the entry. See _freeze_cutoff for the window’s width and its same-day-patch caveat. Packages with no upload time in the lock (git or path sources) fall back to a permanent "0 day" span.

Skips packages that already have an entry. Returns True if the file was modified.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • packages (set[str]) – Package names to add.

  • lock_path (Path) – Path to the uv.lock file, read to resolve each package’s locked-version upload time.

Return type:

bool

Returns:

True if the file was updated, False if no changes were needed.

repomatic.uv.freeze_exclude_newer_packages(pyproject_path, lock_path)[source]

Convert relative-span cooldown bypasses into fixed freeze cutoffs.

A "0 day" (or any relative-span) exclude-newer-package entry tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving and prune_stale_exclude_newer_packages() never sees its locked version age out. Rewriting the span as the _freeze_cutoff of the locked version instead holds the package: releases past the freeze window are excluded until the held version ages past the global cooldown, at which point the entry is pruned and the package rejoins normal resolution.

Also migrates any legacy bare YYYY-MM-DD fixed entry to the equivalent explicit UTC timestamp (see date_to_utc_cutoff()), so uv stops re-expanding it per locking-machine timezone. Entries already carrying a full timestamp are left untouched (idempotent). Packages with no upload time in the lock (git or path sources) keep their span: they have no PyPI release to freeze against.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • lock_path (Path) – Path to the uv.lock file.

Return type:

set[str]

Returns:

The names of the packages whose entry was rewritten (span frozen or bare date pinned); empty when no entry needed rewriting (the file is then left untouched).

repomatic.uv.prune_stale_exclude_newer_packages(pyproject_path, lock_path)[source]

Remove stale entries from [tool.uv].exclude-newer-package.

Note

This is a workaround until uv supports native pruning. See uv#18792.

An entry is stale when its locked version’s upload time falls before the exclude-newer cutoff, meaning uv lock --upgrade would resolve to the same (or newer) version without the "0 day" override.

Packages without an upload time in the lock file (git or path sources) are treated as permanent exemptions and never pruned.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • lock_path (Path) – Path to the uv.lock file.

Return type:

set[str]

Returns:

The names of the pruned packages; empty when nothing was stale (the file is then left untouched).

repomatic.uv.parse_lock_versions(lock_path)[source]

Parse a uv.lock file and return a mapping of package names to versions.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

dict[str, str]

Returns:

A dict mapping normalized package names to their version strings.

repomatic.uv.parse_lock_upload_times(lock_path)[source]

Parse a uv.lock file and return a mapping of package names to upload times.

Extracts the upload-time field from each package’s sdist entry.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

dict[str, str]

Returns:

A dict mapping normalized package names to ISO 8601 upload-time strings. Packages without an sdist or upload-time are omitted.

repomatic.uv.parse_lock_exclude_newer(lock_path)[source]

Parse the effective exclude-newer cutoff from a uv.lock file.

When the user configures a relative span (exclude-newer = "1 week" in pyproject.toml), uv writes the LOCK_TIMESTAMP_SENTINEL into options.exclude-newer and the real value into options.exclude-newer-span as an ISO 8601 duration. In that case the effective cutoff is computed as now - span.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

str

Returns:

An ISO 8601 datetime string for the effective cutoff, or an empty string if neither field is present (or the sentinel is present without a parseable span).

repomatic.uv.load_lock_data(lock_path=None)[source]

Load and parse a uv.lock file.

Parameters:

lock_path (Path | None) – Path to uv.lock file. If None, looks in current directory.

Return type:

dict[str, Any]

Returns:

Parsed TOML data as a dict, or empty dict if the file does not exist.

class repomatic.uv.LockSpecifiers(by_package, by_subgraph)[source]

Bases: object

Dependency specifiers extracted from a uv.lock file.

Two views of the same data, built in a single pass over the lock packages:

by_package

{package_name: {dep_name: specifier}}. Every dependency declared by a package (main and dev) keyed by the declaring package name. Used for edge labels in dependency graphs.

by_subgraph

{subgraph_name: {dep_name: specifier}}. Primary dependencies keyed by dev-group name or extra name. Used for node labels inside subgraphs.

by_package: dict[str, dict[str, str]]
by_subgraph: dict[str, dict[str, str]]
repomatic.uv.parse_lock_specifiers(lock_path=None, *, lock_data=None)[source]

Parse uv.lock and extract dependency specifiers.

A single pass builds two complementary indexes from [package.metadata].requires-dist and [package.metadata.requires-dev]. See LockSpecifiers for the two views returned.

Parameters:
  • lock_path (Path | None) – Path to uv.lock file. If None, looks in current directory. Ignored when lock_data is provided.

  • lock_data (dict[str, Any] | None) – Pre-loaded lock data from load_lock_data(). When provided, skips file I/O.

Return type:

LockSpecifiers

repomatic.uv.format_upload_date(iso_datetime)[source]

Format an ISO 8601 datetime as a human-readable date string.

Parameters:

iso_datetime (str) – An ISO 8601 datetime string (e.g., "2026-03-13T12:00:00Z").

Return type:

str

Returns:

A formatted date like 2026-03-13, or the raw string if parsing fails.

repomatic.uv.format_released(raw_upload, reference)[source]

Format an upload time as a date, optionally with a relative hint.

Parameters:
  • raw_upload (str) – ISO 8601 upload-time string, or empty.

  • reference (date | None) – Date to measure the relative offset from. When None, only the absolute date is returned.

Return type:

str

Returns:

A string like 2026-06-24 (2 days ago), the bare date when reference is None, or empty when raw_upload is empty.

repomatic.uv.diff_lock_versions(before, after)[source]

Compare two version mappings and return the list of changes.

Parameters:
  • before (dict[str, str]) – Package versions before the upgrade.

  • after (dict[str, str]) – Package versions after the upgrade.

Return type:

list[tuple[str, str, str]]

Returns:

A sorted list of (name, old_version, new_version) tuples. old_version is empty for added packages; new_version is empty for removed packages.

repomatic.uv.pypi_name_urls(changes)[source]

Map each changed package name to its PyPI project URL.

Convenience for format_diff_table()’s name_urls when the changes come from a PyPI-resolved source (sync-uv-lock, fix-vulnerable-deps).

Return type:

dict[str, str]

repomatic.uv.format_exclude_newer_note(exclude_newer)[source]

Render the uv exclude-newer cutoff sentence for a diff table.

The format_diff_table() counterpart for sync-uv-lock and fix-vulnerable-deps, which gate on uv’s absolute exclude-newer timestamp. The relative-cooldown updaters (repomatic.version_sync) render their own minimum-release-age note instead.

Parameters:

exclude_newer (str) – ISO 8601 datetime from the lock’s [options].exclude-newer, as returned by parse_lock_exclude_newer(), or empty.

Return type:

str

Returns:

A one-line markdown note, or empty when exclude_newer is empty.

repomatic.uv.format_diff_table(changes, upload_times=None, cooldown_note='', comparison_urls=None, reference_date=None, name_urls=None, heading='Updated packages', subject='Package', released_overrides=None)[source]

Format version changes as a markdown table with heading.

The shared PR-body table for every dependency updater (sync-uv-lock, fix-vulnerable-deps, sync-tool-versions, sync-action-pins, sync-workflow-pins) so they all render identically.

When upload_times is provided, a “Released” column is added so reviewers can visually verify that all updated packages respect the cooldown. A row whose version was decided outside that cooldown check (the upstream toolkit’s lockstep-aligned pin) marks itself through released_overrides instead of showing a date, so the exemption reads as deliberate rather than as missing data. When cooldown_note is provided, that pre-rendered sentence (the absolute exclude-newer cutoff for uv, or the relative minimum-release-age cutoff for the version-sync updaters) is shown above the table.

Parameters:
  • changes (list[tuple[str, str, str]]) – List of (name, old_version, new_version) tuples as returned by diff_lock_versions().

  • upload_times (dict[str, str] | None) – Optional mapping of package names to ISO 8601 upload-time strings, as returned by parse_lock_upload_times().

  • cooldown_note (str) – Optional pre-rendered markdown sentence describing the cooldown cutoff, shown above the table. Build it with format_exclude_newer_note() (uv) or repomatic.version_sync.format_cooldown_note() (version-sync).

  • comparison_urls (dict[str, str] | None) – Optional mapping of names to comparison URLs, linked on the change cell (see build_comparison_urls()).

  • reference_date (date | None) – When set, each “Released” date gains a relative hint (2026-06-24 (2 days ago)) measured from this date.

  • name_urls (dict[str, str] | None) – Optional mapping of names to a URL the name links to (PyPI, GitHub, npm). Names absent from the mapping render plain. Pass pypi_name_urls() for PyPI-sourced changes.

  • heading (str) – Noun after ## 🆙 `` (e.g. ``Updated tools).

  • subject (str) – Header for the first (name) column (e.g. Tool, Action).

  • released_overrides (dict[str, str] | None) – Optional mapping of names to literal markdown replacing their “Released” cell. An override on a changed name also forces the column on, even without upload_times; entries for unchanged names are ignored.

Return type:

str

Returns:

A markdown string with a ## 🆙 {heading} heading and table, or an empty string if there are no changes.

class repomatic.uv.HeldBackPackage(name, locked_version, available_version, released, eligible)[source]

Bases: object

A newer release withheld from the lock by the exclude-newer cooldown.

Built by compute_held_back_packages() for the ## Held back by cooldown report section: a package has already published a newer version, but it is still inside the cooldown window, so uv lock --upgrade keeps the older locked_version.

name: str

Package name, as it appears on PyPI.

locked_version: str

Version held in the lock: the newest release outside the cooldown.

available_version: str

Newer version already on the index, still inside the cooldown window.

released: str

Upload date of available_version (YYYY-MM-DD), or empty when the lock records no upload time (a git or path source).

eligible: str

Date available_version leaves the cooldown and becomes lockable, with a human-readable countdown (2026-06-25 (in 4 days)), or empty when it cannot be computed.

repomatic.uv.compute_held_back_packages(lock_path)[source]

Find releases withheld from the lock only by the cooldown.

Re-resolves the lock with the cooldown lifted and diffs the result against the in-cooldown lock. Both the global exclude-newer cutoff and every per-package exclude-newer-package freeze are raised to the current instant, so a release blocked by a cooldown-bypass freeze is reported like any cooldown-blocked one. That keeps the section’s wording and “Eligible” math honest: prune_stale_exclude_newer_packages() drops a freeze as soon as its held version exits the window, so any release a freeze still blocks is necessarily inside the global window too, and becomes lockable on its own cooldown-exit date. Versions pinned by a specifier or capped by a requires-python bound resolve identically with and without the lift, so they are excluded.

The probe writes uv.lock and restores it byte-for-byte in a finally, so the canonical in-cooldown lock is left untouched even when resolution or parsing fails.

Note

This runs a second uv lock resolution. It is the report’s only cost and is skipped by sync-uv-lock --no-held-back.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

list[HeldBackPackage]

Returns:

Held-back packages sorted by name. Empty when the probe fails or nothing is withheld.

repomatic.uv.EXCLUDE_NEWER_HELD_BACK_NOTE = 'Newer releases already published but withheld because they are still inside the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cooldown window.'

Intro paragraph for the sync-uv-lock held-back section.

The repomatic.version_sync updaters pass their own minimum-release-age wording to format_held_back_table() instead.

repomatic.uv.build_held_back(name, pinned, available, available_date, min_age, today)[source]

Assemble a HeldBackPackage row from raw selection data.

The formatting half of the version-sync held-back report: repomatic.version_sync.select_held_back() picks the withheld candidate, and this turns its raw version and upload date into the same released/eligible strings compute_held_back_packages() produces for uv, so format_held_back_table() renders both identically. Unlike the uv path, no second resolution is needed: the candidates are already in hand from the datasource sweep.

Parameters:
  • name (str) – Display name (package, action slug, or tool).

  • pinned (str) – Version this run settled on (held in place by the cooldown).

  • available (str) – The newer version still inside the cooldown window.

  • available_date (str) – Upload date of available (YYYY-MM-DD), or empty.

  • min_age (timedelta) – The minimum-release-age cooldown width.

  • today (date) – Reference date for the relative countdown.

Return type:

HeldBackPackage

Returns:

A populated HeldBackPackage.

repomatic.uv.format_held_back_table(held_back, note='Newer releases already published but withheld because they are still inside the [`exclude-newer`](https://docs.astral.sh/uv/reference/settings/#exclude-newer) cooldown window.', *, name_urls=None, subject='Package')[source]

Format cooldown-withheld releases as a markdown section.

Shared by every cooldown-gated updater: sync-uv-lock (rows from compute_held_back_packages()) and the version-sync commands (rows from build_held_back()), so the section renders identically.

Parameters:
  • held_back (list[HeldBackPackage]) – Withheld releases as HeldBackPackage rows.

  • note (str) – Intro paragraph describing the cooldown. Defaults to the uv exclude-newer wording; version-sync passes its minimum-release-age wording.

  • name_urls (dict[str, str] | None) – Optional mapping of names to a URL the name links to (PyPI, GitHub, npm). Names absent from the mapping render plain.

  • subject (str) – Header for the first column (e.g. Action, Tool).

Return type:

str

Returns:

A markdown string with a ## ⏸️ Held back by cooldown heading and table, or an empty string when held_back is empty.

repomatic.uv.BYPASS_NEEDS_RELEASE = 'needs release'

Expiry placeholder for a freeze holding an unreleased version.

A fixed-timestamp exclude-newer-package entry whose held version has no upload time in the lock (a git, path, or otherwise unpublished source) can never age past the rolling exclude-newer cutoff on its own: the freeze only ends once the package ships a release the lock can adopt. The markdown report renders the marker in italics to set it apart from real dates.

class repomatic.uv.BypassForecast(name, held_version, expires)[source]

Bases: object

A cooldown-bypass freeze and the date it self-clears.

Built by compute_bypass_forecasts() (freezes still active) and compute_pruned_forecasts() (freezes the run just cleared) for the ## ❄️ Cooldown bypasses report section: a fixed-timestamp exclude-newer-package entry holds name at held_version until that version ages past the exclude-newer cutoff, at which point sync-uv-lock prunes the entry and the package resumes normal cooldown resolution.

name: str

Package name, as it appears on PyPI.

held_version: str

Version the freeze holds in the lock.

expires: str

Date the freeze expires and the entry is pruned, with a human-readable countdown (2026-07-08 (in 2 days), in the past for an already-cleared freeze), BYPASS_NEEDS_RELEASE when the held version has no upload time in the lock, or empty when there is no rolling exclude-newer span to forecast against.

repomatic.uv.compute_bypass_forecasts(pyproject_path, lock_path)[source]

Forecast when each active cooldown-bypass freeze self-clears.

Covers only the fixed-timestamp exclude-newer-package entries. Relative spans ("0 day") are permanent exemptions for packages with no PyPI release to age against (git or path sources), so they never expire and would repeat a static row in every report; auditing them is left to the dependency review (see docs/dependencies.md). Entries for packages absent from the lock (dropped dependencies) are skipped for the same reason.

The expiry mirrors the prune_stale_exclude_newer_packages() condition: the held version’s upload time plus the rolling exclude-newer span, which is the day the next sync-uv-lock run prunes the entry.

Parameters:
  • pyproject_path (Path) – Path to the pyproject.toml file.

  • lock_path (Path) – Path to the uv.lock file.

Return type:

list[BypassForecast]

Returns:

Forecasts sorted by package name; empty when there is no freeze.

repomatic.uv.compute_pruned_forecasts(names, lock_path)[source]

Snapshot the freezes a prune just cleared, for their (cleared) rows.

Must run against the pre-upgrade uv.lock: once the entry is pruned the package rejoins normal resolution, so the post-upgrade lock may hold a newer version whose upload time would misstate what the freeze held and when it aged out.

Parameters:
Return type:

list[BypassForecast]

Returns:

One record per pruned entry, sorted by package name, with the version the freeze held and the (past) date it expired.

repomatic.uv.BYPASS_SECTION_NOTE = 'Packages pulled in ahead of the cooldown by an [`exclude-newer-package`](https://docs.astral.sh/uv/reference/settings/#exclude-newer-package) freeze. Each entry is cleared from `pyproject.toml` automatically once its held version ages past the `exclude-newer` cutoff.'

Intro paragraph for the sync-uv-lock cooldown-bypasses section.

repomatic.uv.format_bypass_section(forecasts, pruned=None, frozen=None, *, name_urls=None)[source]

Format the cooldown-bypass lifecycle as a single markdown table.

The sync-uv-lock report section covering exclude-newer-package freezes. Every lifecycle state is a row in one table so the section scans like the ## 🆙 Updated packages one: freezes still active render plain, entries this run rewrote into freeze cutoffs are labelled 📌 frozen:, and expired entries this run removed from pyproject.toml are labelled 🧹 cleared:, keeping the version and expiry data the freeze had. A freeze holding an unreleased version is labelled 🚧 unreleased: and its BYPASS_NEEDS_RELEASE expiry renders in italics.

Parameters:
Return type:

str

Returns:

A markdown string with a ## ❄️ Cooldown bypasses heading and table, or an empty string when there is no row to report.

repomatic.uv.fetch_release_notes(changes)[source]

Fetch release notes for all updated packages.

For each package with a new version, discovers the GitHub repository via PyPI and fetches the release notes from GitHub Releases for all versions in the range (old, new]. Falls back to a changelog link from PyPI project_urls when no GitHub Release exists.

Parameters:

changes (list[tuple[str, str, str]]) – List of (name, old_version, new_version) tuples.

Return type:

dict[str, tuple[str, list[tuple[str, str]]]]

Returns:

A dict mapping package names to (repo_url, versions) tuples where versions is a list of (tag, body) pairs sorted ascending. Only packages with at least one non-empty body are included. When a changelog URL is used as fallback, tag is empty and body contains a markdown link.

repomatic.uv.format_release_notes(notes)[source]

Render release notes as collapsible <details> blocks.

A ### Release notes heading (an h3, nesting the section under the PR body’s h2 update table) with one collapsible section per package, each version introduced by an h4 tag heading. Long release bodies are truncated to RELEASE_NOTES_MAX_LENGTH characters with a link to the full release.

Parameters:

notes (dict[str, tuple[str, list[tuple[str, str]]]]) – A dict mapping package names to (repo_url, versions) tuples where versions is a list of (tag, body) pairs, as returned by fetch_release_notes().

Return type:

str

Returns:

A markdown string with the release notes section, or an empty string if no notes are available.

repomatic.uv.build_comparison_urls(changes, notes)[source]

Build GitHub comparison URLs from version changes and release notes.

Uses the tag format discovered by fetch_release_notes() to construct comparison URLs. Only packages with both old and new versions and a known GitHub repository are included.

Parameters:
Return type:

dict[str, str]

Returns:

Dict mapping package names to GitHub comparison URLs.

class repomatic.uv.SyncResult(changes, upload_times, exclude_newer, reverted=False, pruned_bypasses=<factory>, frozen_bypasses=<factory>, bypass_forecasts=<factory>)[source]

Bases: object

Result of a sync-uv-lock operation.

changes: list[tuple[str, str, str]]

Version changes as (name, old_version, new_version) tuples.

upload_times: dict[str, str]

Package name to ISO 8601 upload-time mapping from the lock file.

exclude_newer: str

The exclude-newer cutoff from the lock file, or empty string.

reverted: bool = False

Whether a cosmetic-only re-lock was discarded.

True when uv lock --upgrade changed no package versions and was not driven by a pyproject.toml cooldown edit, so sync_uv_lock() restored the pre-upgrade lock verbatim. See that function for why such a run is dropped.

pruned_bypasses: list[BypassForecast]

Expired exclude-newer-package entries removed from pyproject.toml, each with the version and (past) expiry the freeze had, snapshot against the pre-upgrade lock by compute_pruned_forecasts().

frozen_bypasses: list[str]

exclude-newer-package entries rewritten into freeze cutoffs.

bypass_forecasts: list[BypassForecast]

Active cooldown-bypass freezes with their expiry forecasts (post-run state).

repomatic.uv.sync_uv_lock(lock_path)[source]

Re-lock with --upgrade and report version changes.

First prunes stale exclude-newer-package entries from pyproject.toml (entries whose locked version was uploaded before the exclude-newer cutoff), then runs uv lock --upgrade to update transitive dependencies.

Note

When the upgrade changes no package versions and was not driven by a pyproject.toml cooldown edit, the pre-upgrade lock is restored byte-for-byte. uv lock --upgrade otherwise rewrites semantically equivalent environment markers in a form that varies by uv version and by whether the resolution ran fresh or incrementally: a transitive dependency reachable only below Python 3.11 has its python_full_version < '3.13' marker flipped to the equivalent < '3.11', or back, with no change to the resolved package set. Committed by one machine and re-flipped by the next, that cosmetic churn drives an endless sync-uv-lock ping-pong of empty PRs. Since the job exists only to move dependency versions forward, a run that moves none has nothing to contribute and is discarded. This mirrors the timezone-pinning fix in date_to_utc_cutoff().

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

SyncResult

Returns:

A SyncResult with structured version change data and the cooldown-bypass lifecycle (entries pruned, frozen, and still active with their expiry forecasts).

repomatic.version_sync module

Self-hosted dependency-version updaters: the replacement for Renovate.

Backs the sync-tool-versions, sync-action-pins, and sync-workflow-pins commands. Each discovers the latest eligible upstream version from a datasource (GitHub releases, PyPI, or npm), gated by the shared [tool.repomatic] minimum-release-age cooldown (the GitHub/PyPI/npm counterpart to uv’s exclude-newer, which guards sync-uv-lock), then rewrites the pinned version in place.

The datasource adapters and version selection live here; the file I/O and checksum recompute that the commands drive stay in repomatic.cli. The string-level helpers (set_tool_version, find_action_pins, find_workflow_literals, and the apply_* rewriters) are pure so they can be unit-tested without network access.

repomatic.version_sync.MINIMUM_RELEASE_AGE_URL = 'https://kdeldycke.github.io/repomatic/configuration.html#minimum-release-age'

Docs anchor for the minimum-release-age cooldown, linked from PR bodies.

repomatic.version_sync.MIN_AGE_HELD_BACK_NOTE = 'Newer releases already published but withheld because they are still inside the [`minimum-release-age`](https://kdeldycke.github.io/repomatic/configuration.html#minimum-release-age) cooldown window.'

Intro paragraph for the version-sync held-back section.

The GitHub/PyPI/npm counterpart to repomatic.uv.EXCLUDE_NEWER_HELD_BACK_NOTE.

repomatic.version_sync.ACTION_PIN_RE = re.compile('(?P<prefix>uses:\\s*)(?P<slug>[\\w.-]+/[\\w.-]+)@(?P<sha>[0-9a-f]{40})(?P<gap>\\s*#\\s*)(?P<ref>v?\\d[\\w.-]*)')

Match a SHA-pinned GitHub Action uses: reference with its version comment.

The slug/slug@<40-hex> shape only matches owner/repo actions, so local ./… refs and reusable-workflow refs carrying a subpath (owner/repo/.github/workflows/x.yaml@…) are skipped automatically.

class repomatic.version_sync.Candidate(version: str, date: str, ref: str)[source]

Bases: NamedTuple

A single release version offered by a datasource.

Create new instance of Candidate(version, date, ref)

version: str

Comparable, display version (e.g. 1.7.12).

date: str

Publication date in YYYY-MM-DD format.

ref: str

Upstream reference to pin downstream.

The raw git tag for GitHub releases (needed to resolve the commit SHA and write the pin comment); identical to version for PyPI and npm.

class repomatic.version_sync.ActionPin(slug: str, sha: str, ref: str)[source]

Bases: NamedTuple

A SHA-pinned GitHub Action reference found in a workflow file.

Create new instance of ActionPin(slug, sha, ref)

slug: str

The owner/repo action slug.

sha: str

The currently pinned 40-character commit SHA.

ref: str

The version in the trailing # vX.Y.Z comment.

class repomatic.version_sync.WorkflowLiteral(ecosystem: str, package: str, version: str)[source]

Bases: NamedTuple

A version literal embedded in a workflow command.

Create new instance of WorkflowLiteral(ecosystem, package, version)

ecosystem: str

Datasource: npm or pypi.

package: str

The package name.

version: str

The currently pinned version.

repomatic.version_sync.parse_min_age(value)[source]

Parse a minimum-release-age value into a timedelta.

Accepts the friendly relative durations uv allows for exclude-newer (8 days, 2 weeks, 36 hours). An unrecognized value logs a warning and yields no cooldown.

Parameters:

value (str) – The configured minimum-release-age string.

Return type:

timedelta

Returns:

The cooldown duration, or timedelta(0) when value does not parse.

repomatic.version_sync.min_release_age_days(value)[source]

Convert a minimum-release-age value to whole days for npm’s cooldown.

npm’s min-release-age resolver option (npm 11.10.0+) refuses any package version younger than the given number of days, across the whole resolved tree, transitive dependencies included. It is the runtime, transitive-tree counterpart to the pin cooldown parse_min_age() feeds sync-workflow-pins: the same minimum-release-age window, enforced by npm at install time.

Sub-day remainders round up, so a cooldown always over-protects rather than collapsing to 0, npm’s “no cooldown” sentinel.

Parameters:

value (str) – The configured minimum-release-age string (e.g. 8 days).

Return type:

int

Returns:

The cooldown as a whole number of days (0 when disabled).

repomatic.version_sync.exclude_newer_cutoff(value, today)[source]

uv --exclude-newer cutoff date for a minimum-release-age value.

uv’s cooldown knob is an absolute date, so the relative window is resolved live against today: packages uploaded on or after the returned date drop out of resolution. This gates ad-hoc uvx tool installs (via repomatic.tool_runner.run_tool()) by the same window sync-workflow-pins applies to pins. The uv counterpart to min_release_age_days() (npm).

Parameters:
  • value (str) – The configured minimum-release-age string (e.g. 8 days).

  • today (date) – Reference date, resolved once per run.

Return type:

str | None

Returns:

The cutoff as YYYY-MM-DD, or None when the cooldown is disabled (0 days or an unrecognized value), so callers omit the flag.

repomatic.version_sync.format_cooldown_note(age_label, cutoff)[source]

Render the minimum-release-age cutoff sentence for a diff table.

The version-sync counterpart to repomatic.uv.format_exclude_newer_note(). uv records an absolute exclude-newer timestamp; here the cooldown is a relative span, so the effective cutoff is today - min_age, recomputed each run rather than stored.

Parameters:
  • age_label (str) – The configured minimum-release-age value (e.g. 8 days).

  • cutoff (date) – The effective cutoff date (today - min_age); releases published after it are held back.

Return type:

str

Returns:

A one-line markdown note for repomatic.uv.format_diff_table().

repomatic.version_sync.select_latest(candidates, min_age, today, *, allow_prerelease=False)[source]

Return the highest version old enough to clear the cooldown.

Candidates published more recently than min_age are held back, then the highest remaining PEP 440 version wins. Prereleases and versions that do not parse are skipped.

Parameters:
  • candidates (list[Candidate]) – Versions offered by a datasource.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

  • allow_prerelease (bool) – Keep prerelease versions when True.

Return type:

Candidate | None

Returns:

The winning Candidate, or None when none qualify.

repomatic.version_sync.select_held_back(candidates, pinned, min_age, today, *, allow_prerelease=False)[source]

Return the highest release withheld from pinned only by the cooldown.

The counterpart to select_latest(): among candidates strictly newer than pinned, keep those still inside the cooldown window (published more recently than min_age) and return the highest. These are the releases a later run adopts once they age out, surfaced in the ## ⏸️ Held back by cooldown PR section. No extra network call is needed: the candidates are already in hand from the select_latest() sweep.

Parameters:
  • candidates (list[Candidate]) – Versions offered by a datasource.

  • pinned (str) – The version this run settled on; only strictly newer candidates can be held back.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

  • allow_prerelease (bool) – Keep prerelease versions when True.

Return type:

Candidate | None

Returns:

The withheld Candidate, or None when nothing newer is inside the cooldown.

repomatic.version_sync.github_candidates(repo_url, tag_pattern=None)[source]

Collect release candidates from a GitHub repository.

Parameters:
Return type:

list[Candidate]

Returns:

One Candidate per release whose tag yields a version. Empty when the API is unavailable (logged, never raised).

repomatic.version_sync.pypi_candidates(package)[source]

Collect non-yanked release candidates from PyPI.

Parameters:

package (str) – The PyPI package name.

Return type:

list[Candidate]

Returns:

One Candidate per non-yanked version.

repomatic.version_sync.npm_candidates(package)[source]

Collect release candidates from the npm registry.

Parameters:

package (str) – The npm package name.

Return type:

list[Candidate]

Returns:

One Candidate per published version.

repomatic.version_sync.is_newer(new, old)[source]

Return True when new is a strictly higher version than old.

Unparsable versions compare as not-newer, so a malformed candidate never triggers a bump.

Return type:

bool

repomatic.version_sync.set_tool_version(content, name, new_version)[source]

Rewrite a tool’s version= field in the tool_runner.py source.

Targets the first version="…" inside the named ToolSpec( entry, stopping at the next entry so a later tool is never touched.

Parameters:
  • content (str) – The tool_runner.py source text.

  • name (str) – The TOOL_REGISTRY key (e.g. "gitleaks").

  • new_version (str) – The version to write.

Return type:

str

Returns:

The updated source text.

repomatic.version_sync.find_action_pins(content)[source]

Find every SHA-pinned GitHub Action reference in a workflow file.

Return type:

list[ActionPin]

repomatic.version_sync.apply_action_pins(content, resolved)[source]

Rewrite SHA-pinned actions to their resolved SHA and version comment.

Parameters:
  • content (str) – The workflow file text.

  • resolved (dict[str, tuple[str, str]]) – Mapping of owner/repo slug to (new_sha, new_ref).

Return type:

tuple[str, list[tuple[str, str, str]]]

Returns:

The updated text and a list of (slug, old_ref, new_ref) changes actually applied (entries whose SHA already matched are skipped).

repomatic.version_sync.find_workflow_literals(content)[source]

Find npm and PyPI version literals embedded in a workflow file.

Return type:

list[WorkflowLiteral]

repomatic.version_sync.apply_workflow_literals(content, resolved)[source]

Rewrite npm/PyPI version literals to their resolved version.

Parameters:
  • content (str) – The workflow file text.

  • resolved (dict[tuple[str, str], str]) – Mapping of (ecosystem, package) to the new version.

Return type:

tuple[str, list[tuple[str, str, str]]]

Returns:

The updated text and a list of (package, old_version, new_version) changes actually applied.

repomatic.version_sync.find_upstream_ref_versions(content, upstream_repo)[source]

Extract the uses: ref versions of the upstream repo’s workflows.

Matches reusable-workflow and composite-action refs of upstream_repo, both SHA-pinned with a trailing version comment (owner/repo/.github/workflows/lint.yaml@abc123 # v1.2.3) and directly tag-pinned (...@v1.2.3), and returns the bare version strings.

Shared by lint-repo’s inline-pin lockstep check and sync-workflow-pins’ upstream-pin alignment, so both read the refs the same way.

Return type:

set[str]

repomatic.virustotal module

Upload release binaries to VirusTotal and record detection snapshots.

Submits compiled binaries (.bin, .exe) to the VirusTotal API for malware scanning. This seeds antivirus vendor databases with the signatures of freshly built binaries, which keeps false-positive rates in check for downstream distributors.

Detection statistics polled after an upload are appended to a JSON history file, one record per binary per scan date. The sync-binaries command renders that history into the binaries catalog page (docs/binaries.md).

Note

Scan results are deliberately kept out of GitHub release notes: a raw flagged / total count next to a download link reads as a malware verdict to visitors, when it is almost always Nuitka onefile false positives. See kdeldycke/meta-package-manager#1911 for the confusion this caused. The catalog page provides the context release notes cannot.

Note

The free-tier API allows 4 requests per minute. All API calls (uploads and polls) are rate-limited with a sleep between each request.

repomatic.virustotal.VIRUSTOTAL_GUI_URL = 'https://www.virustotal.com/gui/file/{sha256}'

URL template for the VirusTotal file analysis page.

class repomatic.virustotal.DetectionStats(malicious, suspicious, undetected, harmless)[source]

Bases: object

Detection statistics from a completed VirusTotal analysis.

Stores only the four categories that constitute a definitive verdict. type-unsupported, timeout, and failure from the API response are excluded from the total.

malicious: int

Number of engines that flagged the file as malicious.

suspicious: int

Number of engines that flagged the file as suspicious.

undetected: int

Number of engines that found no threat.

harmless: int

Number of engines that classified the file as harmless.

property flagged: int

Total engines that flagged the file (malicious + suspicious).

property total: int

Total engines that produced a definitive verdict.

class repomatic.virustotal.ScanResult(filename, sha256, analysis_url, detection_stats=None)[source]

Bases: object

Result of uploading a single file to VirusTotal.

filename: str

Original filename of the uploaded binary.

sha256: str

SHA-256 hash of the file content.

analysis_url: str

VirusTotal web GUI URL for the file analysis.

detection_stats: DetectionStats | None = None

Detection statistics, or None if analysis is still pending.

class repomatic.virustotal.ScanRecord(tag, filename, sha256, scanned, stats)[source]

Bases: object

A detection snapshot for one binary, taken on a given date.

Records accumulate in a JSON history file (see upsert_scan_records()) committed to the repository. Each record freezes the flagged / total verdict counts at scan time, so the history supports trend analysis across releases even after VirusTotal re-analyzes the files or vendors process false-positive reports.

tag: str

Git tag of the release the binary belongs to (e.g. v1.2.3).

filename: str

Filename of the scanned binary.

sha256: str

SHA-256 hash of the file content.

scanned: str

Scan date in YYYY-MM-DD format.

stats: DetectionStats

Detection statistics at scan time.

property key: tuple[str, str]

Deduplication identity: the same file scanned on the same day.

to_dict()[source]

Flatten to a JSON-ready mapping, detection stats inlined.

Return type:

dict[str, int | str]

classmethod from_dict(data)[source]

Rebuild a record from its flattened JSON mapping.

Return type:

ScanRecord

repomatic.virustotal.scan_files(api_key, file_paths, rate_limit=4)[source]

Upload files to VirusTotal and return scan results.

Uses the synchronous vt.Client API. Sleeps between uploads to respect the free-tier rate limit.

Parameters:
  • api_key (str) – VirusTotal API key.

  • file_paths (list[Path]) – Paths to binary files to upload.

  • rate_limit (int) – Maximum requests per minute (free tier: 4).

Return type:

list[ScanResult]

Returns:

List of scan results with analysis URLs.

repomatic.virustotal.poll_detection_stats(api_key, results, rate_limit=4, timeout=600)[source]

Poll VirusTotal for detection statistics of previously uploaded files.

Queries GET /files/{sha256} for each file until analysis completes or the timeout is reached. Respects the free-tier rate limit for all API calls.

Parameters:
  • api_key (str) – VirusTotal API key.

  • results (list[ScanResult]) – Scan results from a previous upload.

  • rate_limit (int) – Maximum API requests per minute (shared with uploads).

  • timeout (int) – Maximum seconds to wait for all analyses to complete.

Return type:

list[ScanResult]

Returns:

Results with detection_stats populated (or None for files whose analysis did not complete before the timeout).

repomatic.virustotal.records_from_results(results, tag, scanned=None)[source]

Build history records from scan results whose analysis completed.

Results still pending (no detection statistics) are skipped: a record without verdict counts carries no information the release assets don’t already provide.

Parameters:
  • results (list[ScanResult]) – Scan results, typically from poll_detection_stats().

  • tag (str) – Git tag of the release the binaries belong to.

  • scanned (str | None) – Snapshot date in YYYY-MM-DD format. Today (UTC) when None.

Return type:

list[ScanRecord]

Returns:

One record per result with detection statistics.

repomatic.virustotal.records_from_release_notes(body, tag, scanned)[source]

Recover detection snapshots from a legacy release-notes table.

Before the scan history file existed, the release pipeline appended a VirusTotal table to GitHub release notes, with a flagged / total Detections cell frozen minutes after publication. Those cells are genuine at-release snapshots, so sync-binaries --backfill-records harvests them to seed the history for releases that predate the file.

Note

The legacy table only recorded the flagged and total aggregates, not the malicious/suspicious/undetected/harmless split. The split is rebuilt as flagged = malicious and the remainder = undetected, which is lossless for everything the catalog consumes (flagged and total).

Parameters:
  • body (str) – Release notes markdown.

  • tag (str) – Git tag of the release.

  • scanned (str) – Snapshot date, normally the release publication date.

Return type:

list[ScanRecord]

Returns:

One record per table row carrying a numeric Detections cell.

repomatic.virustotal.load_scan_records(path)[source]

Load scan records from a JSON history file.

Parameters:

path (Path) – Path to the JSON file.

Return type:

list[ScanRecord]

Returns:

The records, or an empty list when the file does not exist.

Raises:

ValueError – When the file exists but cannot be parsed. Loud on purpose: a corrupt history must never be silently clobbered by the next upsert_scan_records() write.

repomatic.virustotal.upsert_scan_records(path, new_records)[source]

Merge new records into the JSON history file at path.

Records sharing the same (sha256, scanned) identity are replaced, so re-running a scan the same day is idempotent. The file is created (with its parent directories) when missing, and always rewritten in normalized form: sorted by version, filename, and scan date, serialized with the same layout Biome’s JSON formatter produces so the format-json autofix job never rewrites it.

Parameters:
  • path (Path) – Path to the JSON history file.

  • new_records (list[ScanRecord]) – Records to merge in.

Return type:

bool

Returns:

True when the file content changed.

repomatic.vulnerable_deps module

Vulnerability audit and remediation for locked dependencies.

Backs the audit command and the fix-vulnerable-deps job: queries the advisory sources enabled in [tool.repomatic] vulnerable-deps.sources, unions and deduplicates their findings into VulnerablePackage records, and (--fix) upgrades each fixable package through uv.

Two advisory sources are consulted:

Coverage diverges in practice: GHSA frequently lists a CVE before the PyPA database mirrors it, and transitive lockfile vulnerabilities sometimes only surface in GHSA. By unioning both sources, audit catches CVEs that either database alone would miss.

repomatic.vulnerable_deps.MIN_UV_AUDIT_JSON_VERSION = <Version('0.11.15')>

Minimum uv version exposing uv audit --output-format json.

The structured JSON output landed in uv 0.11.15 as a preview feature. Below this, uv audit emits only human-readable text, so _run_uv_audit refuses to run rather than silently scanning nothing.

class repomatic.vulnerable_deps.AdvisorySource(*values)[source]

Bases: StrEnum

Where a vulnerability advisory was detected.

Each source has a distinct upstream database and ingestion pipeline, so coverage diverges in practice (e.g., GHSA frequently lists a CVE before the PyPA Advisory Database mirrors it). Tracking the source per VulnerablePackage lets the union deduplicate by advisory ID while still attributing each entry to the database that produced it.

UV_AUDIT = 'uv-audit'

Detected by uv audit (PyPA Advisory Database, OSV-backed).

GITHUB_ADVISORIES = 'github-advisories'

Detected via the repository’s Dependabot alerts (GitHub Advisory Database).

class repomatic.vulnerable_deps.VulnerablePackage(name, current_version, advisory_id, advisory_title, fixed_version, advisory_url, aliases=<factory>, sources=<factory>, source_urls=<factory>)[source]

Bases: object

A single vulnerability advisory for a Python package.

name: str

Package name.

current_version: str

Currently resolved version.

advisory_id: str

Advisory identifier (e.g., GHSA-xxxx-xxxx-xxxx).

advisory_title: str

Short description of the vulnerability.

fixed_version: str

Version that contains the fix, or empty string if unknown.

advisory_url: str

URL to the advisory details.

aliases: set[str]

Alternate identifiers for the same advisory (CVE, GHSA, PYSEC, OSV).

Advisory databases cross-reference each other: the PyPA database (via uv audit) keys records by OSV/PYSEC IDs while listing the matching GHSA/CVE IDs as aliases, and Dependabot keys by GHSA while listing the CVE. collect_vulnerable_packages() unions entries whose identifier sets overlap, so a shared alias deduplicates the same advisory reported under different primary IDs by different sources.

sources: set[AdvisorySource]

Advisory databases that surfaced this entry.

A set rather than a single value because the same advisory can be reported by multiple sources after deduplication. Empty only for entries built without source attribution (test fixtures); every production code path records at least one source.

source_urls: dict[AdvisorySource, str]

Per-source URL pointing to the advisory page in each database.

Each source has its own canonical URL even when reporting the same advisory ID (PyPA’s osv.dev page vs. GitHub’s /advisories/ page), so the rendered table can link the source name to the database that actually surfaced it.

repomatic.vulnerable_deps.parse_uv_audit_json(output)[source]

Parse uv audit --output-format json output into vulnerability records.

The structured contract avoids the regex fragility of scraping human-readable lines, and exposes the advisory aliases (cross-referenced CVE/GHSA/PYSEC IDs) that let collect_vulnerable_packages() deduplicate the same advisory across sources.

Parameters:

output (str) – stdout from uv audit --output-format json.

Return type:

list[VulnerablePackage]

Returns:

A list of VulnerablePackage entries (empty when the audit found nothing).

Raises:

RuntimeError – when the output is unusable as JSON (empty, malformed, or carrying an unrecognized schema.version). Raising rather than returning an empty list keeps the scanner from silently passing when the preview schema changes under it.

repomatic.vulnerable_deps.format_vulnerability_table(vulns)[source]

Format vulnerability data as a markdown table.

Includes a Sources column listing the advisory databases that surfaced each entry, so reviewers can see which database (PyPA Advisory DB, GitHub Advisory DB, or both) detected the vulnerability.

Parameters:

vulns (list[VulnerablePackage]) – List of VulnerablePackage entries.

Return type:

str

Returns:

A markdown string with a ## Vulnerabilities heading and table, or an empty string if no vulnerabilities are provided.

repomatic.vulnerable_deps.collect_vulnerable_packages(lock_path, repo=None, sources=None)[source]

Collect vulnerability advisories from all configured sources.

Queries each enabled advisory database, then deduplicates entries per package by advisory identity: two entries merge when their identifier sets (advisory_id plus aliases) overlap, so the same advisory reported under a PYSEC/OSV ID by uv audit and a GHSA ID by Dependabot collapses into one. Merging preserves the union of sources so the rendered table credits both databases when they agree.

Current versions reported by uv audit take precedence over the empty placeholder produced by the GHSA path, since uv audit reads the actual locked version while Dependabot alerts only carry the vulnerable range. When the GHSA path encounters a package that uv audit did not surface, the current version is filled in from the lock file.

Parameters:
  • lock_path (Path) – Path to the uv.lock file.

  • repo (str | None) – Repository in owner/repo format. Required for the AdvisorySource.GITHUB_ADVISORIES source; pass None to skip it (the result then reflects uv audit only).

  • sources (list[AdvisorySource] | None) – Advisory databases to consult. Defaults to all known sources.

Return type:

list[VulnerablePackage]

Returns:

Deduplicated list of VulnerablePackage entries.

repomatic.vulnerable_deps.fix_vulnerable_deps(lock_path, repo=None, sources=None)[source]

Detect vulnerable packages and upgrade them in the lock file.

Queries every advisory source enabled by sources (defaults to all), then upgrades each fixable package with uv lock --upgrade-package using --exclude-newer-package to bypass the exclude-newer cooldown for security fixes. Also persists the exemptions in pyproject.toml so that subsequent uv lock --upgrade runs (e.g. from the sync-uv-lock job) do not downgrade the fixed packages back within the cooldown window.

Parameters:
Return type:

tuple[bool, str]

Returns:

A tuple of (has_fixes, diff_table). has_fixes is True when at least one vulnerable package was upgraded. diff_table is a markdown-formatted string with vulnerability details and version changes, or an empty string if no fixable vulnerabilities were found.

repomatic.vulnerable_deps.fetch_dependabot_alerts(repo)[source]

Fetch open pip-ecosystem Dependabot alerts for a repository.

Calls GET /repos/{repo}/dependabot/alerts?state=open&ecosystem=pip via the gh CLI, then maps each alert into a VulnerablePackage tagged with AdvisorySource.GITHUB_ADVISORIES.

Returns an empty list when the API is unreachable, the token lacks the Dependabot alerts permission, or the repository has no open alerts. A network or auth failure must not break the autofix workflow: the uv audit source is still consulted independently.

Parameters:

repo (str) – Repository in owner/repo format.

Return type:

list[VulnerablePackage]

Returns:

List of VulnerablePackage entries with a known fixed version. Alerts without first_patched_version are skipped (no upgrade target).