repomatic package

Expose package-wide elements.

Subpackages

Submodules

repomatic.agent_md module

Project the audience-tagged parts of claude.md into a downstream repo.

claude.md § Section audience tags puts an <!-- audience: ... --> comment under every heading upstream. This module reads those tags and writes the sections a given repository is entitled to into that repository’s own instructions file, leaving everything it authored for itself untouched.

Upstream’s copy is claude.md because Claude Code is what reads it here, but the destination is whatever [tool.repomatic] agent.location resolves to, defaulting through [tool.repomatic.flavor] agent to that agent’s own filename. AGENTS.md is the same document under the cross-agent convention, and a repository keeping it outside the root is the case the key exists for.

The merge is the overlay half of the pair init_project already runs against pyproject.toml: there the bundled template is the base and local keys graft on top; here the repository’s document is the base and the tagged sections overlay into it. A section is identified by its heading title, which is also its anchor, so a cross-reference written upstream keeps resolving downstream.

Three rules decide what a repository ends up with:

  • A tagged section is upstream’s. It is re-emitted from the bundled document on every sync, so a downstream edit to one is reverted. That is the point: the six repositories consuming this today have drifted on roughly four in five of the sections they nominally share, silently and in both directions.

  • An untagged section is the repository’s. It is carried through verbatim and no sync ever rewrites it, which is where repo-specific knowledge belongs.

  • A title collision resolves upstream’s way. An untagged local section whose title matches a tagged one is a hand-copied ancestor of it, and adopting it is the whole reason this exists. A repository wanting a section of its own on a neighbouring subject gives it a different title.

Caution

Ordering is not preserved across the boundary: tagged sections are emitted first in upstream order, then the repository’s own in theirs. A stable order is what keeps the sync from fighting format-markdown for the canonical layout, per claude.md § Common maintenance pitfalls, and it makes the managed block one contiguous region a reader can skip. The first sync of an existing document therefore moves its untagged sections down, once.

repomatic.agent_md.BUNDLED_INSTRUCTIONS = 'claude.md'

The reference document, bundled under repomatic/data/ as a symlink.

Kept a symlink back to the repository root rather than a copy, the way the subagent definitions are, so the file this module ships is the one the conformance tests in tests/test_agent_md.py check.

The name is upstream’s own, not the destination’s: what a consumer ends up writing is [tool.repomatic] agent.location, which every function here takes as a parameter rather than reading from this constant.

repomatic.agent_md.AUDIENCES = ('all', 'upstream', 'downstream')

Every audience a section may declare.

all is upstream plus every consumer, upstream never leaves kdeldycke/repomatic, and downstream is what a repository needs because it consumes repomatic, which by definition does not describe repomatic itself.

repomatic.agent_md.DOWNSTREAM_AUDIENCES = frozenset({'all', 'downstream'})

Audiences a repository consuming repomatic receives.

upstream is the complement and never leaves kdeldycke/repomatic, which is why merge_agent_md() is skipped there outright rather than filtered: the source repository would otherwise receive the downstream sections written for its consumers.

repomatic.agent_md.TAG_SCOPES = {'all': RepoScope.ALL, 'package': RepoScope.PACKAGE_ONLY}

Scope qualifiers a tag may carry, mapped onto the registry’s own vocabulary.

Deliberately a subset of RepoScope: a qualifier is added when a section demonstrably does not apply somewhere, not in anticipation.

repomatic.agent_md.SUPERSEDES_RE = re.compile('^<!--\\s*supersedes:\\s*(?P<title>.+?)\\s*-->$')

A heading title this section replaces, one comment per title.

Renaming a managed section otherwise strands the old one downstream: the merge keys on the title, so the repository keeps its now-stale copy sitting beside the corrected replacement, each contradicting the other. This is the same migration sync-labels runs for a renamed label, and claude.md § Retiring a label is a migration, not a deletion is the argument for why a rename beats a drop-and-add.

On its own line rather than folded into TAG_RE, because a heading title may contain the ; and : that would otherwise delimit it.

class repomatic.agent_md.Section(level, title, audience, scope, text, supersedes=())[source]

Bases: object

One heading of an instructions file, with its tag and body as written.

level: int

Heading depth, from 2 (the document title is not a section).

title: str

Heading text, which is also the anchor a cross-reference targets.

audience: str

Declared audience, or the empty string when the section carries no tag.

scope: str

Declared scope qualifier, defaulting to all when the tag omits one.

text: str

Verbatim source, from the heading line to the line before the next one.

Kept whole rather than split into heading and body so a re-emitted section is byte-identical to its upstream form, down to the trailing blank lines format-markdown settled on.

supersedes: tuple[str, ...] = ()

Heading titles this section replaces downstream, from SUPERSEDES_RE.

property is_managed: bool

Whether upstream owns this section, rather than the repository.

reaches(is_awesome, is_python, is_package)[source]

Whether a repository with these traits receives this section.

Parameters:
  • is_awesome (bool) – True for awesome-* repositories.

  • is_python (bool) – True when a PEP 621 [project].name is present.

  • is_package (bool) – True when the project also builds a distributable.

Return type:

bool

Returns:

Whether the section is both downstream-bound and in scope.

Raises:

KeyError – If the tag carries a scope this module does not know. Upstream tags are held to TAG_SCOPES by tests/test_agent_md.py, so this signals a bundled document from a newer repomatic than the code reading it.

repomatic.agent_md.parse_sections(content)[source]

Split an instructions file into its preamble and its sections.

The preamble is everything above the first section heading: the document title, and whatever one-paragraph description a repository put under it. Both belong to the repository and survive every sync.

Parameters:

content (str) – Full text of an instructions file.

Return type:

tuple[str, list[Section]]

Returns:

The preamble, and one Section per heading below level 1.

repomatic.agent_md.render_agent_md(existing, *, is_awesome=False, is_python=True, is_package=True)[source]

Overlay the sections a repository is entitled to onto its own document.

Idempotent: rendering an already-merged document returns it unchanged, which is what lets repomatic init report it as untouched and keeps the unattended sync-repomatic job from opening a pull request every run.

Parameters:
  • existing (str) – Current instructions file of the target repository, empty when it has none yet.

  • is_awesome (bool) – True for awesome-* repositories.

  • is_python (bool) – True when a PEP 621 [project].name is present.

  • is_package (bool) – True when the project also builds a distributable.

Return type:

str

Returns:

The merged document, always newline-terminated.

repomatic.agent_md.merge_agent_md(target, *, is_awesome=False, is_python=True, is_package=True)[source]

Write the entitled sections into target, creating the file if absent.

Parameters:
  • target (Path) – Path to the repository’s instructions file, from [tool.repomatic] agent.location.

  • is_awesome (bool) – True for awesome-* repositories.

  • is_python (bool) – True when a PEP 621 [project].name is present.

  • is_package (bool) – True when the project also builds a distributable.

Return type:

bool

Returns:

Whether the file was created or modified.

repomatic.attestation module

Naming and packing of the sigstore bundles attached to a release.

actions/attest writes every bundle to the same attestation.json basename, whatever it signed, so each release job has to rename its own before the files land in one directory. Three of them did, three different ways: the compiled binaries appended the suffix to the full filename, the man-page tarball dropped its .tar.gz first, and the consumer-declared extra assets were named after the job rather than any file. A release page therefore carried repomatic-manpages.attestation.json next to repomatic-manpages.tar.gz, and repomatic-extra-assets.attestation.json next to repomatic-claude-plugin.zip.

This module holds the one rule instead: a bundle is named after the artifact it attests. The subject list is read back out of the bundle rather than passed in, so the name is derived from what was actually signed and no caller can spell it differently. See bundle_filename() for the multi-subject case.

Note

The signing itself stays in actions/attest: it needs the job’s OIDC token, so it cannot move here. This module runs immediately after it, in the same job.

repomatic.attestation.ATTESTATION_SUFFIX: Final[str] = '.attestation.json'

Extension carried by every attestation bundle attached to a release.

Not .sigstore.json (the ecosystem’s own convention) because these files have been published under this name since the first attested release, and a release asset name is part of the surface users script against.

repomatic.attestation.bundle_subjects(bundle_path)[source]

Filenames of the artifacts a sigstore bundle attests.

A bundle wraps a DSSE envelope whose base64 payload is an in-toto Statement, and that statement’s subject array names every file signed in the same call. One entry for a single subject-path, several when actions/attest was handed a glob: “If multiple subjects are being attested at the same time, a single attestation will be created with references to each of the supplied subjects.”

Parameters:

bundle_path (Path) – The bundle actions/attest wrote.

Return type:

tuple[str, ...]

Returns:

Subject filenames, in the order the statement lists them.

Raises:

ValueError – If the file is not a bundle carrying a readable in-toto statement, or names a subject that is not a bare filename.

repomatic.attestation.bundle_filename(subjects, set_name=None)[source]

Name a bundle after the artifact it attests.

A single subject gives the bundle its own name, suffix appended to the whole filename so the two sort together on a release page listing assets alphabetically (papaya.tar.gz, then papaya.tar.gz.attestation.json).

Several subjects have no such name to borrow, since one bundle covers them all, so set_name is required and should describe the set rather than any member of it. That case only arises when a job hands actions/attest a glob.

Parameters:
Return type:

str

Returns:

The bundle’s filename.

Raises:

ValueError – If subjects is empty, or holds several entries with no set_name to fall back on.

repomatic.attestation.pack_attestation(bundle_path, asset_dir, set_name=None)[source]

Name a bundle after its subject and print what to upload with it.

Copies bundle_path into asset_dir under the name bundle_filename() derives, then returns that bundle alongside every artifact it attests, which is exactly the file list the release upload step has to attach for the provenance to be verifiable offline.

Every subject must already sit in asset_dir: a bundle naming a file that is not there means the job attested a different tree than the one it is about to upload, which would publish an asset whose sidecar covers something else. Immutable releases make that unfixable after the fact, so it fails here instead.

Idempotent: re-running copies the same bytes over the same name.

Parameters:
  • bundle_path (Path) – The bundle actions/attest wrote.

  • asset_dir (Path) – Directory holding the attested artifacts.

  • set_name (str | None) – Stem for the multi-subject case, see bundle_filename().

Return type:

list[Path]

Returns:

Sorted paths to upload, the renamed bundle included.

Raises:

ValueError – If a subject is missing from asset_dir.

repomatic.awesome_toc module

Remove the table-of-contents entries awesome-lint forbids.

Backs the fix-awesome-toc command, which runs on awesome-* repositories right after repomatic run mdformat regenerates the ToC of every readme.

Note

This is the one operation that has no job, PR branch or template of its own, against the rule in claude.md § Naming conventions for automated operations. It corrects what format-markdown just wrote, so it has to share that job’s working tree: given its own job, the two would land in separate PRs and undo each other on every push, format-markdown re-adding the entries this command had removed.

mdformat-toc lists every heading in range and offers no exclusion mechanism of its own, so the entries have to be deleted afterwards. Upstream tracks the feature in hukkin/mdformat-toc#17 and hukkin/mdformat-toc#20; this module can go once either lands and grows a way to express the exclusion in the ToC marker.

repomatic.awesome_toc.FORBIDDEN_HEADINGS: tuple[str, ...] = ('Contents', 'Contributing', 'Footnotes', 'Related Lists')

Headings awesome-lint refuses to see listed in the table of contents.

Mirrors the roster in awesome-lint’s toc.js, plus the heading owning the ToC itself (Contents), whose entry trips remark-lint:awesome-toc:

✖  26:1  ToC item "Contents" does not match corresponding heading "Meta"

These are the English names, the only ones awesome-lint knows. A translated readme names the same sections in its own language, which is why matching on this roster alone is not enough: see forbidden_headings_for().

repomatic.awesome_toc.README_RE = re.compile('^readme(\\.[^.]+)?\\.md$')

Match readme.md and every readme.{lang}.md translation beside it.

Only the repository root is scanned. The find ./ this replaced walked the whole tree, which on a checkout carrying a node_modules/ directory would have reached a few hundred vendored readmes.

repomatic.awesome_toc.REFERENCE_README = 'readme.md'

The English readme, whose heading positions every translation is mapped onto.

repomatic.awesome_toc.HEADING_RE = re.compile('^\\#{1,6}[ \\t]+(?P<text>.+?)[ \\t]*\\#*[ \\t]*$', re.MULTILINE)

Match an ATX heading and capture its text, closing sequence excluded.

repomatic.awesome_toc.TOC_ENTRY_RE = re.compile('^[ \\t]*- \\[(?P<text>.+)\\]\\(#[^)]*\\)$')

Match one mdformat-toc list entry and capture its link text.

repomatic.awesome_toc.TOC_START_RE = re.compile('^<!--\\s*mdformat-toc\\s+start\\b.*-->$', re.IGNORECASE)

Match the opening marker of an mdformat-toc block.

repomatic.awesome_toc.TOC_END_RE = re.compile('^<!--\\s*mdformat-toc\\s+end\\s*-->$', re.IGNORECASE)

Match the closing marker of an mdformat-toc block.

repomatic.awesome_toc.FENCE_RE = re.compile('^[ \\t]*(?P<fence>`{3,}|~{3,})')

Match the delimiter of a fenced code block.

repomatic.awesome_toc.headings(content)[source]

List the ATX heading texts of a Markdown document, in document order.

Parameters:

content (str) – The full Markdown document.

Return type:

list[str]

Returns:

Every heading text, code fences excluded.

repomatic.awesome_toc.forbidden_headings_for(content, reference_headings=None)[source]

Resolve which heading texts must not appear in content’s ToC.

Always includes the English FORBIDDEN_HEADINGS that awesome-lint knows, since a translation routinely leaves some of them untranslated.

On top of that, when reference_headings is given and the document has the same number of headings, the heading occupying each forbidden position in the reference is forbidden here too. That positional mapping is what carries the rule across languages: repomatic cannot know that 贡献 translates Contributing, but it can see that both sit at the same index of a readme and its translation. Headings survive formatting untouched, so the mapping holds even against an already-stripped reference.

Parameters:
Return type:

set[str]

Returns:

The heading texts whose ToC entry must be deleted.

repomatic.awesome_toc.strip_toc_entries(content, forbidden)[source]

Delete the ToC entries of content whose link text is forbidden.

Only the mdformat-toc block is touched: a list item elsewhere in the document that happens to link the same heading is left alone.

Parameters:
  • content (str) – The full Markdown document.

  • forbidden (set[str]) – Heading texts whose entry must go.

Return type:

tuple[str, list[str]]

Returns:

The updated document and the link texts that were deleted.

repomatic.awesome_toc.fix_awesome_toc(root=None)[source]

Strip the forbidden ToC entries from every readme under root.

Reads REFERENCE_README first so its heading positions can be mapped onto each translation, then rewrites every readme that changed. Idempotent: a second run finds nothing left to delete.

Parameters:

root (Path | None) – Directory holding the readmes. Defaults to the current one.

Return type:

dict[Path, list[str]]

Returns:

The deleted entries, keyed by the readme they came from.

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.CHART_JS_URL = 'https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js'

Pinned CDN artifact drawing the detections trend chart.

The one external artifact this module publishes into every downstream repository’s docs, so it carries a checksum beside the pin: bump the version by hand together with CHART_JS_SRI.

repomatic.binaries_page.CHART_JS_SRI = 'sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y'

Subresource Integrity digest of CHART_JS_URL.

The browser refuses the script if the CDN bytes stop matching. Recompute on every version bump as the sha384 of the exact artifact, verified against the same file inside the npm registry tarball before trusting the CDN copy: hashlib.sha384(artifact_bytes) then base64.

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.LEGACY_PAGE_END_MARKER = '<!-- binaries-end -->'

Oldest closing marker, migrated to PAGE_END_MARKER on first touch.

repomatic.binaries_page.LEGACY_PAGE_START_MARKERS = ('<!-- binaries-start -->', '<!-- binaries-chart-start -->')

Superseded opening markers, migrated to PAGE_START_MARKER on first touch.

Two generations precede the current bare open: the original <!-- binaries-start -->, then the <!-- binaries-chart-start --> of the short-lived -start/-end pair. Both collapse to PAGE_START_MARKER, so a page written by any past version refreshes cleanly.

repomatic.binaries_page.PAGE_REGION = 'binaries-chart'

Region name spliced by click_extra.blocks.replace_region().

The generated chart lives between the <!-- binaries-chart --> and <!-- binaries-chart-end --> markers that PAGE_START_MARKER and PAGE_END_MARKER spell out, following click-extra’s <!-- name --> / <!-- name-end --> marker grammar with name = this value.

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

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

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

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, and as the flagged-verdict share (tinted by FLAGGED_DANGER_PCT) otherwise.

Caution

Only a binary backed by a scan record gets a VirusTotal cell; every other row leaves it empty. A file page exists on VirusTotal solely because the file was submitted, while the catalog spans every release a repository ever published, including those predating scan-virustotal and those whose upload failed. Deriving the URL from the GitHub asset digest alone therefore sent readers to a blank page for each binary nobody ever uploaded, which was most of the catalog on older projects.

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 by click_extra.blocks.replace_region(), leaving all surrounding prose untouched. Pages carrying any LEGACY_PAGE_START_MARKERS open or the LEGACY_PAGE_END_MARKER close are migrated to the current markers in the same pass.

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 native binary verification: architecture and minimum-OS floors are parsed straight from the executables’ ELF, Mach-O and PE headers, so no external tool is needed on runners or inside build containers.

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

File extensions identifying compiled binaries among release assets.

The one definition of “a compiled release asset”: scan-virustotal uploads this set, the release workflow downloads it (--pattern flags in _release-engine.yaml), docs/binaries.md lists it, and the dev-release asset globs derive from it.

repomatic.binary.PYTHON_DIST_SUFFIXES = ('.tar.gz', '.whl')

File extensions identifying Python distributions among release assets.

The counterpart of BINARY_ASSET_SUFFIXES, and the same kind of single definition: pack_binary_assets() excludes this set from the binary upload list (create-release already attached those), and the dev-release asset globs add it on top of the compiled binaries.

repomatic.binary.compute_file_sha256(path)[source]

Compute the SHA-256 hex digest of a file.

Parameters:

path (Path) – Path to the file.

Return type:

str

Returns:

Lowercase hex digest string.

repomatic.binary.NUITKA_BUILD_TARGETS = {'linux-arm64': {'arch': 'arm64', 'container': 'quay.io/pypa/manylinux_2_28_aarch64@sha256:e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80', 'extension': 'bin', 'glibc_floor': '2.28', 'os': 'ubuntu-26.04-arm', 'platform_id': 'linux'}, 'linux-x64': {'arch': 'x64', 'container': 'quay.io/pypa/manylinux_2_28_x86_64@sha256:fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8', 'extension': 'bin', 'glibc_floor': '2.28', 'os': 'ubuntu-26.04', 'platform_id': 'linux'}, 'macos-arm64': {'arch': 'arm64', 'extension': 'bin', 'min_os': '11.0', 'os': 'macos-26', 'platform_id': 'macos'}, 'macos-x64': {'arch': 'x64', 'extension': 'bin', 'min_os': '10.15', 'os': 'macos-26-intel', 'platform_id': 'macos'}, 'windows-arm64': {'arch': 'arm64', 'extension': 'exe', 'min_os': '11', 'os': 'windows-11-arm', 'platform_id': 'windows'}, 'windows-x64': {'arch': 'x64', 'extension': 'exe', 'min_os': '10', 'os': 'windows-2025', 'platform_id': 'windows'}}

GitHub-hosted runner matrix for Nuitka builds, keyed by target name.

The key doubles as the compiled binary’s short target identifier: it names the published release asset, so it is chosen for user-friendliness and must stay stable (download URLs and docs/binaries.md match on it).

Values are dictionaries with the following keys:

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

    Hint

    One compile job per target, each on one of the six runners the test matrix already covers, so a published binary is built on an image the suite is validated against. The targets are exactly KNOWN_RUNNERS, not a separate selection: an image is added here by widening the test axes, never on its own.

  • 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.

  • container: OCI image the Linux compile and self-test jobs run in, via the container: key of the release workflow. Compiling inside manylinux_2_28 caps the toolchain at glibc 2.28, so binaries stop inheriting the floor of whatever glibc the current runner image ships. Linux targets only: GitHub Actions containers do not exist for macOS and Windows runners.

  • glibc_floor: highest glibc symbol version the compiled artifacts may require, matching the build container. Enforced by verify_binary_floor() and documented in docs/binaries.md.

  • min_os: minimum OS version the binary runs on. On macOS the release workflow exports it as MACOSX_DEPLOYMENT_TARGET at compile time (without it, compiled objects and processed dylibs inherit the build runner’s macOS version) and verify_binary_floor() enforces it. On Windows it is documentation-only: the floor is CPython’s own Windows support policy, not a linker artifact.

repomatic.binary.FLAT_BUILD_TARGETS = [{'arch': 'arm64', 'container': 'quay.io/pypa/manylinux_2_28_aarch64@sha256:e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80', 'extension': 'bin', 'glibc_floor': '2.28', 'os': 'ubuntu-26.04-arm', 'platform_id': 'linux', 'target': 'linux-arm64'}, {'arch': 'x64', 'container': 'quay.io/pypa/manylinux_2_28_x86_64@sha256:fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8', 'extension': 'bin', 'glibc_floor': '2.28', 'os': 'ubuntu-26.04', 'platform_id': 'linux', 'target': 'linux-x64'}, {'arch': 'arm64', 'extension': 'bin', 'min_os': '11.0', 'os': 'macos-26', 'platform_id': 'macos', 'target': 'macos-arm64'}, {'arch': 'x64', 'extension': 'bin', 'min_os': '10.15', 'os': 'macos-26-intel', 'platform_id': 'macos', 'target': 'macos-x64'}, {'arch': 'arm64', 'extension': 'exe', 'min_os': '11', 'os': 'windows-11-arm', 'platform_id': 'windows', 'target': 'windows-arm64'}, {'arch': 'x64', 'extension': 'exe', 'min_os': '10', 'os': 'windows-2025', 'platform_id': 'windows', 'target': 'windows-x64'}]

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

repomatic.binary.binary_name(package, target, version=None)[source]

Compose a compiled binary’s release-asset filename.

The one definition of the naming convention: {package}-{version}-{target}.{ext} for the versioned upload, and with no version the stable alias ({package}-{target}.{ext}) backing the releases/latest/download URLs. The extension comes from NUITKA_BUILD_TARGETS.

Return type:

str

repomatic.binary.versionless_alias(filename, version)[source]

Map a versioned binary filename to its stable alias, or None.

Strips the -{version}- segment (papaya-1.2.3-linux-arm64.bin becomes papaya-linux-arm64.bin). Returns None for filenames that carry no such segment or are not compiled binaries, so callers can filter and map in one pass.

Return type:

str | None

repomatic.binary.binary_filename_re(package)[source]

Match a package binary filename, versioned or versionless.

Captures target and ext, both alternations derived from NUITKA_BUILD_TARGETS so a new build target extends the pattern without anyone editing a regex. The release freeze rewrites both spellings onto the versioned form through this; tests/test_platform_keys.py pins the pattern against every target.

Return type:

Pattern[str]

repomatic.binary.pack_binary_assets(dist_dir, version)[source]

Pack a release’s upload list, materializing the versionless aliases.

Mirrors what the release engine’s upload step needs: every file in dist_dir except the Python distributions (create-release already uploaded those), plus a byte-identical versionless alias copied beside each versioned binary so the stable releases/latest/download URLs always resolve. Aliases share their sibling’s digest, which is what lets artifact attestations verify them unchanged and the binaries catalog collapse them (see binaries_page._binary_assets).

Idempotent: re-running overwrites the same aliases with the same bytes.

Parameters:
  • dist_dir (Path) – Directory holding the compiled binaries and attestation bundles downloaded from the build jobs.

  • version (str) – The release version whose binaries earn aliases.

Return type:

list[Path]

Returns:

Sorted paths to upload, aliases included.

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-dep-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 repomatic.git_ops.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.PLATFORM_FORMATS: Final[dict[str, str]] = {'linux': 'elf', 'macos': 'macho', 'windows': 'pe'}

Executable format expected for each build platform.

repomatic.binary.ELF_MACHINES: Final[dict[str, str]] = {'arm64': 'EM_AARCH64', 'x64': 'EM_X86_64'}

Expected ELF e_machine value (as decoded by pyelftools) per architecture.

repomatic.binary.MACHO_CPU_TYPES: Final[dict[str, int]] = {'arm64': 16777228, 'x64': 16777223}

Expected Mach-O header cputype per architecture.

repomatic.binary.PE_MACHINES: Final[dict[str, int]] = {'arm64': 43620, 'x64': 34404}

Expected PE COFF Machine field per architecture.

repomatic.binary.MACHO_MAGIC_64: Final[int] = 4277009103

Magic of a 64-bit Mach-O header, in the file’s own (little) endianness.

repomatic.binary.MACHO_FAT_MAGICS: Final[frozenset[int]] = frozenset({3405691582, 3405691583})

Big-endian magics of universal (fat) Mach-O containers, 32- and 64-bit.

repomatic.binary.LC_VERSION_MIN_MACOSX: Final[int] = 36

Mach-O load command carrying the minimum macOS version (pre-10.14 SDKs).

repomatic.binary.LC_BUILD_VERSION: Final[int] = 50

Mach-O load command carrying the platform and minimum OS (10.14+ SDKs).

repomatic.binary.MACHO_PLATFORM_MACOS: Final[int] = 1

platform field value naming macOS inside an LC_BUILD_VERSION command.

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

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

Parses the executable’s own headers, so it needs no external tool and behaves identically on runner VMs and inside build containers.

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

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

Raises:
Return type:

None

repomatic.binary.verify_binary_floor(target, binary_path, dist_dirs=())[source]

Verify the binary and its dist tree stay within the target’s OS floor.

Scans the onefile binary itself plus every native library of the given Nuitka dist directories (whose content the onefile payload repacks), and compares each file’s measured requirement to the target’s declared floor:

  • Linux: the highest GLIBC_x.y version requirement of each ELF against glibc_floor. A higher requirement means a compiled object picked up symbols newer than the build container provides for, and the binary would die at load time on the distributions the floor promises.

  • macOS: the minos of each Mach-O against min_os, the deployment target the build exports as MACOSX_DEPLOYMENT_TARGET.

  • Windows: nothing. PE version headers are nominal; the floor is CPython’s own Windows support policy, tracked in the docs.

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

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

  • dist_dirs (Iterable[Path]) – Nuitka dist directories to include in the scan.

Raises:
Return type:

None

repomatic.bundle module

Raw access to the data files bundled in repomatic/data/.

The lowest layer of bundled-data access, deliberately dependency-free so any module can read a data file without import cycles. Policy layers sit above: repomatic.init_project.export_content validates names against the exportable-file registry, and repomatic.tool_runner resolves tool configs.

repomatic.bundle.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.bundle.get_data_file_path(filename)[source]

Yield the filesystem path of a bundled data file.

Unlike 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.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.

repomatic.cache.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.

Lives beside the entry dataclasses it renders; the CLI derives its --sort-by choices from it.

class repomatic.cache.CachedFile(size, path, mtime)[source]

Bases: object

The filesystem facts every cached entry carries, whatever it holds.

The three caches (binaries, HTTP responses, tool configs) differ only in how they name an entry; everything the listing, the age filter and the purge loop need is here, so those all take a CachedFile and never care which subtree it came from.

Subclasses supply their own identity fields plus kind and scope.

size: int

File size in bytes.

path: Path

Absolute path to the cached file.

mtime: float

File modification time (seconds since epoch).

kind: ClassVar[str] = ''

The cache this entry belongs to, as the repomatic cache show table spells it.

property scope: str

The name a cache clean filter matches this entry on.

Doubles as the table’s subject column: the thing a reader identifies the entry by (--tool ruff, --namespace pypi) is the same thing the listing shows them, so one property serves both.

property detail: str

What distinguishes this entry from its siblings in the same scope.

is_fresh(max_age_days)[source]

Whether this entry is younger than the age cutoff.

A None cutoff keeps nothing: age-unfiltered clears delete every entry the caller’s other filters matched.

Return type:

bool

as_row()[source]

Render this entry as one repomatic cache show table row.

Return type:

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

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

Bases: CachedFile

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.

kind: ClassVar[str] = 'binary'

The cache this entry belongs to, as the repomatic cache show table spells it.

property scope: str

The name a cache clean filter matches this entry on.

Doubles as the table’s subject column: the thing a reader identifies the entry by (--tool ruff, --namespace pypi) is the same thing the listing shows them, so one property serves both.

property detail: str

What distinguishes this entry from its siblings in the same scope.

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

Bases: CachedFile

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).

kind: ClassVar[str] = 'http'

The cache this entry belongs to, as the repomatic cache show table spells it.

property scope: str

The name a cache clean filter matches this entry on.

Doubles as the table’s subject column: the thing a reader identifies the entry by (--tool ruff, --namespace pypi) is the same thing the listing shows them, so one property serves both.

property detail: str

What distinguishes this entry from its siblings in the same scope.

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

Bases: CachedFile

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).

kind: ClassVar[str] = 'config'

The cache this entry belongs to, as the repomatic cache show table spells it.

property scope: str

The name a cache clean filter matches this entry on.

Doubles as the table’s subject column: the thing a reader identifies the entry by (--tool ruff, --namespace pypi) is the same thing the listing shows them, so one property serves both.

property detail: str

What distinguishes this entry from its siblings in the same scope.

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.SIDECAR_SUFFIX = '.sha256'

Suffix of the digest sidecar stored beside each cached binary.

Part of the binary cache’s on-disk layout: the listers skip sidecars and the purger removes them along with their entry. Computing, writing, and verifying the digest itself stays with the caller (tool_runner), per the module note above.

repomatic.cache.binary_sidecar_path(binary_path)[source]

Return the digest sidecar path for a cached binary.

Parameters:

binary_path (Path) – Path to the cached binary.

Return type:

Path

Returns:

Path of the sidecar file next to it.

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 | None

Returns:

Path to the cached binary, or None when the cache is unwritable (a read-only cache root, a restricted CI mount): callers fall back to their staging copy, matching store_response() and store_config().

repomatic.cache.cache_info()[source]

List all cached binaries.

The bin/ layout is fixed at four levels ({tool}/{version}/{platform}/{executable}), so one glob walks it and the identity fields read straight off each path’s ancestry.

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, max_age_days=None)[source]

Remove cached tool configurations.

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

  • max_age_days (int | None) – If set, only remove entries older than this many days, matching clear_cache() and clear_http_cache().

Return type:

tuple[int, int]

Returns:

Tuple of (files_deleted, bytes_freed).

repomatic.cache.cache_rows()[source]

List every cached file across the three caches, as table rows.

Backs repomatic cache show: each entry renders itself (CachedFile.as_row()), so the command stays a print call and a new cache kind shows up in the listing by existing.

Return type:

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

Returns:

(rows, total_size), rows ordered binaries, then HTTP responses, then tool configs.

repomatic.cache.auto_purge()[source]

Remove cached entries older than the configured TTL.

Called automatically after store_binary() and store_response(), and runs at most once per cache root per process (see _PURGED_ROOTS). 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.resolved_changelog_path(config)[source]

Absolute path of the configured changelog.

The one derivation of [tool.repomatic] changelog.location into a filesystem path, so every command resolves the same file the same way (two call sites used to resolve the path and two did not).

Return type:

Path

repomatic.changelog.load_changelog_repo(config)[source]

Load the configured changelog and read the repository URL out of it.

The shared preamble of the two release-notes syncers, which both need the changelog’s path (to read sections from) and the repository it belongs to (to address the GitHub API with). The URL comes from the changelog’s own comparison links rather than from the git remote, so a project whose changelog points elsewhere stays authoritative.

Parameters:

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

Return type:

tuple[Path, str] | None

Returns:

(changelog_path, repo_url), or None after logging why neither could be determined (no changelog on disk, or no comparison link in it).

repomatic.changelog.AVAILABLE_VERB = 'is available on'

Verb phrase for versions present on a platform.

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

Default changelog header for empty changelogs.

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.

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.SECTION_START = '##'

Markdown heading level for changelog version sections.

repomatic.changelog.YANKED_DEDUP_MARKER = 'yanked from PyPI'

Dedup marker for the yanked admonition to prevent duplicate insertion.

repomatic.changelog.RELEASE_VERSION_TOKEN = '\\d+\\.\\d+\\.\\d+'

Regex fragment for a final release version, like 1.2.3.

The strict half of the version vocabulary: it deliberately rejects the .devN suffix VERSION_TOKEN accepts, because a changelog documents only final releases. Anything keyed off a published version (a dated heading, a comparison URL) uses this one.

repomatic.changelog.VERSION_TOKEN = '\\d+\\.\\d+\\.\\d+(?:\\.\\w+)?'

Regex fragment for any version a ## heading may carry.

Widens RELEASE_VERSION_TOKEN with the trailing .devN a development section carries between releases. Anything enumerating or locating headings uses this one, so an unreleased section is never invisible to a scan that has to account for it.

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*\\[`?(?P<version>\\d+\\.\\d+\\.\\d+)`?\\s+\\((?P<date>\\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.

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(default_branch='main')[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 the default branch, body replaced with the development warning), and prepends it to the changelog.

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

Parameters:

default_branch (str) – Branch name for the comparison URL. Must match what freeze() is later given, since the two halves of a release cycle retarget the same URL in opposite directions: a mismatch leaves the released section pointing at a branch that does not exist.

Return type:

str

Returns:

The updated changelog content.

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.

Returns False for three different situations, only one of which is benign: an already-frozen section (idempotent no-op), no version to freeze, and a version whose section is missing. The last one is what a release would otherwise ship an (unreleased) heading over, so it is logged as a warning rather than left to look like the no-op.

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

  • default_branch (str) – Branch name for comparison URL. Must match what update() used to write it, per that method’s note.

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.warn_on_empty_sections(changelog)[source]

Warn about released sections holding no entry, non-fatally.

A published heading with nothing under it reads as broken to anyone scanning release notes, and it is not merely cosmetic: the GitHub release body is rebuilt from this section, so an empty one publishes an empty release. claude.md § Changelog and docs updates gives the fix, which is to name what actually moved rather than to leave the section blank.

Only released sections are inspected. The unreleased section is legitimately empty for most of a cycle, since the post-release bump creates it with no entries, so flagging it would fire on every push in the hours after a release and train the reader to ignore the check. That is the mirror of warn_on_long_bullets(), which inspects the unreleased section alone because re-flagging immutable history is the noise there.

Availability, editorial and yanked admonitions live in their own VersionElements fields, so a section carrying nothing but a [!WARNING] about missing binaries still counts as empty: an admonition explains a caveat, it does not say what changed.

Parameters:

changelog (Changelog) – The parsed changelog to inspect.

Return type:

None

class repomatic.changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, github_fetch_failed=False)[source]

Bases: object

The external lookups a changelog’s dates and availability are checked against.

Both lookups are TTL-cached, and the boundary versions derived from them used to be recomputed by hand after a forced refresh: deriving them here means a refresh cannot leave a stale boundary behind.

package: str | None

PyPI package name, None when the project publishes nothing.

pypi_data: dict[str, PyPIRelease]

Released versions on PyPI, keyed by version string.

repo_url: str

Repository URL read out of the changelog’s own comparison links.

github_releases: dict[str, GitHubRelease]

Published GitHub releases, keyed by version string.

github_fetch_failed: bool = False

Whether the GitHub lookup errored, as opposed to answering empty.

property use_pypi: bool

Whether PyPI is the reference source, rather than git tags.

property first_pypi_version: Version | None

Oldest version on PyPI, for the predates-the-index boundary.

property first_github_version: Version | None

Oldest GitHub release, for the predates-the-releases boundary.

classmethod load(changelog, package, pypi_package_history, *, force_refresh=False)[source]

Fetch both release sources, reporting what each answered.

Parameters:
  • changelog (Changelog) – Parsed changelog, read for its repository URL.

  • package (str | None) – PyPI package name, auto-detected when None.

  • pypi_package_history (Sequence[str] | None) – Former package names to merge in.

  • force_refresh (bool) – Bypass the caches, for a live re-confirmation.

Return type:

ReleaseSources

retracted_versions(changelog, releases)[source]

Versions whose section claims availability these lookups now deny.

An available platform renders as a markdown link and a missing one as a bare label, so the [ prefix is what separates a claim of presence from one of absence.

Return type:

set[str]

class repomatic.changelog.DateCheck(corrections: dict[str, str], mismatched: bool, unfixed: bool)[source]

Bases: NamedTuple

What comparing every changelog date against its reference source found.

Create new instance of DateCheck(corrections, mismatched, unfixed)

corrections: dict[str, str]

Version to the date it should carry, for the versions --fix repairs.

mismatched: bool

Whether any version’s date disagreed with its reference source.

unfixed: bool

Whether a mismatch was left standing (no --fix to apply).

class repomatic.changelog.OrphanReconciliation(releases: list[tuple[str, str]], found: bool, modified: bool, unfixed: bool)[source]

Bases: NamedTuple

What reconciling versions missing from the changelog produced.

Create new instance of OrphanReconciliation(releases, found, modified, unfixed)

releases: list[tuple[str, str]]

Documented releases, re-read when insertions changed the file.

found: bool

Whether any orphan was detected at all.

modified: bool

Whether a section was actually inserted.

unfixed: bool

Whether an orphan was left in place, unrepaired.

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.

Two non-fatal content checks run first and never affect the exit code: warn_on_long_bullets() over the unreleased section, and warn_on_empty_sections() over the released ones.

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.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_registry.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(registry_path, version_overrides=None)[source]

Recompute binary checksums and version stamps in tool_registry.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:
  • registry_path (Path) – Path to tool_registry.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.exit_if_disabled(ctx, enabled, key)[source]

Exit successfully when a [tool.repomatic] feature flag is off.

The shared guard of every sync command: a disabled feature is a normal, configured state, so the command logs the flag and exits 0 instead of failing the workflow that invoked it.

Parameters:
  • ctx (Context) – The Click context to exit through.

  • enabled (bool) – The resolved feature flag value.

  • key (str) – The [tool.repomatic] key, in kebab-case, for the log line.

Return type:

None

repomatic.cli.log_output_target(subject, output)[source]

Log where a command is about to write subject.

Every command that honors an --output path narrates the destination the same way, distinguishing the stdout case (-) so the log names the stream instead of a literal dash.

Parameters:
  • subject (str) – What is being written, as a noun phrase ("metadata", "PR body").

  • output (Path) – The resolved --output path.

Return type:

None

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. Validation delegates to parse_component_entries(), the same code path the exclude and include config options go through, so the CLI and config agree on syntax and error messages.

name: str = 'selector'

the descriptive name of this type

get_metavar(param, ctx)[source]

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

Return type:

str

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 (Any) – The value to convert.

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

  • ctx (Context | None) – The current context that arrived at this value. May be None.

Return type:

str

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 (Context) – Invocation context for this command.

  • param (Parameter) – The parameter that is requesting completion.

  • incomplete (str) – Value being completed. May be empty.

Added in version 8.0.

Return type:

list[CompletionItem]

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

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

The same two glyphs the workflow templates stamp onto each matrix job’s name, and that repomatic.github.ci_status.JobStatus.required() reads back off it, so the grid and the CI verdict cannot come to disagree about which mark means “allowed to fail”.

repomatic.cloudflare module

Reconcile a Cloudflare Pages project against the state its repository declares.

A Direct Upload project is not reproducible from anything committed: wrangler.toml only describes what a build would need, and these projects are never built by Cloudflare. Everything that actually shapes the live site (the compatibility date, Smart Placement, the build image, whether a git source got attached) lives server-side in the project’s deployment_configs and is invisible to anyone reading the repository. One project’s compatibility date sat three years behind the live value with nothing noticing. This module makes that state explicit, diffable and re-applicable, from the [tool.repomatic] site.* keys.

Backs the cloudflare-pages command, in four modes: --check diffs live against declared and exits non-zero on drift, --apply writes the declared values back, --create creates the Pages project when missing (reusing an existing one) then applies, and --dump prints the live state with secrets redacted.

Credentials resolve in this order, so the same command works in CI and on a laptop without a token ever landing on a command line:

  1. CLOUDFLARE_API_TOKEN from the environment (what CI uses).

  2. The OAuth token wrangler login stores locally.

The account is never declared; the credential settles it. GET /accounts answers what the token sees, and one scoped to nothing but Cloudflare Pages: Edit still enumerates the account it belongs to, so CI carries the token alone. A credential seeing several accounts resolves the ambiguity by asking which one owns the project being reconciled, and fails rather than guesses when that question has no single answer. No identifier is ever hardcoded either: repositories using this are public, and account IDs do not belong in them.

Caution

Never gate anything on GET /user/tokens/verify: that endpoint is user-scoped, so an account-owned token (the recommended kind, cfat_ prefix) answers 401 there while every project call succeeds. Proving the credential against the project it is meant to touch is the only verification that means anything, which is what every mode here does implicitly.

repomatic.cloudflare.API_ROOT = 'https://api.cloudflare.com/client/v4'

Cloudflare v4 API root every call below is relative to.

repomatic.cloudflare.API_TIMEOUT = 30

Socket timeout in seconds, wider than repomatic’s JSON default: a PATCH that stalls mid-write is worth waiting out rather than retrying blind.

repomatic.cloudflare.EXPIRY_WARNING_DAYS = 30

How close a token’s expiry gets before --check starts warning.

Cloudflare notifies about neither an approaching expiry nor a passed one, so the monthly Docs run carrying this check is the only calendar the token has. A month of warnings is enough to rotate without ever reaching the red run.

repomatic.cloudflare.SECRET_KEYS = frozenset({'api_token', 'oauth_token', 'refresh_token', 'secret'})

Response keys whose values must never be printed.

repomatic.cloudflare.WRANGLER_CONFIG_PATHS: Final = (PosixPath('/home/runner/Library/Preferences/.wrangler/config/default.toml'), PosixPath('/home/runner/.config/.wrangler/config/default.toml'))

Where wrangler login stores its OAuth token, macOS first then XDG.

exception repomatic.cloudflare.CloudflareError[source]

Bases: RuntimeError

Raised when the Cloudflare API refuses or a credential cannot be found.

exception repomatic.cloudflare.CloudflareHTTPError(message, status)[source]

Bases: CloudflareError

An HTTP-level refusal from the Cloudflare API, carrying its status.

Lets a caller act on which refusal arrived (a 404 meaning “create it” is a different instruction than a 403 meaning “stop”) without parsing the message string it was raised with.

class repomatic.cloudflare.Setting(path, desired, default, why, verified=False, managed=True)[source]

Bases: object

One server-side setting, with enough context to justify its value.

default is what a stock Cloudflare Pages project reports for this key. Where that value is quoted from Cloudflare’s documentation, verified is True. Where it is inferred from how the product behaves, it is False and the diff labels it as such: an unverified default is a reasonable guess, not a fact, and this module should not launder one into the other.

path: tuple[str, ...]
desired: Any
default: Any
why: str
verified: bool = False
managed: bool = True
repomatic.cloudflare.desired_settings(compatibility_date='', placement='')[source]

The settings to enforce, from the repository’s site.* declarations.

Only what the repository declares is managed, plus one documented floor: the build image major version, which Cloudflare auto-migrates old projects onto (v1 on 2026-09-15, v2 on 2027-02-23) and then freezes, so asserting 3 requests nothing from a current project and names the stragglers.

Pages supports exactly production and preview, so the two environments are enumerated rather than globbed, and every managed setting applies identically to both.

Parameters:
  • compatibility_date (str) – site.cloudflare-compatibility-date, empty to leave the live value unmanaged.

  • placement (str) – site.cloudflare-placement, empty to leave the live value unmanaged.

Return type:

tuple[Setting, ...]

repomatic.cloudflare.run_cloudflare_pages(project, *, check=False, apply=False, dump=False, create=False, attach_domain='', compatibility_date='', placement='')[source]

Drive one mode of the Pages reconciliation against project project.

Exactly one of check, apply, dump or create must be set; the CLI enforces that before calling.

Parameters:
  • project (str) – Cloudflare Pages project name to reconcile.

  • check (bool) – Diff live against declared, exit 1 on any drift.

  • apply (bool) – PATCH every managed drifted setting back to its declared value. Read-only drift is reported and keeps the exit code non-zero.

  • dump (bool) – Print the live project state as JSON, secrets redacted.

  • create (bool) – Create the Pages project (Direct Upload, main as its production branch) when it does not exist yet, then apply the declared settings to it. An existing project is reused, so a re-run converges on the declared state instead of failing on the API’s 409.

  • attach_domain (str) – Serve the project at this hostname, creating the DNS record it needs when the credential can. See _attach_domain().

  • compatibility_date (str) – Declared Workers runtime date, empty for unmanaged.

  • placement (str) – Declared Smart Placement mode, empty for unmanaged.

Return type:

int

Returns:

Exit code: 0 clean, 1 drift found (or left, for the read-only settings --apply cannot write).

repomatic.compat module

Version-dependent standard-library imports, shared by the whole package.

One home for every sys.version_info import shim, so each consumer keeps a clean import block and dropping a Python version means deleting a branch here instead of hunting copies across modules.

class repomatic.compat.StrEnum(new_class_name, /, names, *, module=None, qualname=None, type=None, start=1, boundary=None)[source]

Bases: str, ReprEnum

Enum where members are also (and must be) strings

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-dep-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-dep-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.AgentLayout(skills, subagents, instructions, settings)[source]

Bases: object

Where one AI coding agent expects its assets to live.

skills: str

Directory holding one folder per skill.

subagents: str

Directory holding subagent definitions.

Named for what it holds, not for the agent reading it: agents invited a one-character confusion with instructions, whose component and config key are agent, and the two write entirely different things.

instructions: str

File holding the agent’s own instructions, read on every session.

A file, like settings, and merged into rather than written whole: repomatic owns the audience-tagged sections and the repository owns the rest. The name each agent expects differs (claude.md for Claude Code, AGENTS.md for the cross-agent convention), which is the whole reason this is a layout field rather than the constant it started as.

settings: str

File holding the agent’s project-scoped settings.

A file rather than a directory, unlike its siblings: it is the one asset repomatic merges into rather than writes whole, so the path has to name the document itself.

repomatic.config.AGENT_LAYOUTS: Final[dict[str, AgentLayout]] = {'claude_code': AgentLayout(skills='./.claude/skills/', subagents='./.claude/agents/', instructions='./claude.md', settings='./.claude/settings.json')}

Asset layout per agent, keyed by extra_platforms.ALL_AGENTS trait ID.

Only agents repomatic can actually lay out appear here. cline and cursor are valid trait IDs but have no Agent Skills layout to target, so selecting one is rejected rather than silently producing a Claude Code tree.

repomatic.config.DEFAULT_AGENT: Final[str] = 'claude_code'

Agent assumed when [tool.repomatic.flavor] agent is unset.

repomatic.config.DEFAULT_CI: Final[str] = 'github_ci'

CI system assumed when [tool.repomatic.flavor] ci is unset.

repomatic.config.CLOUDFLARE_PLACEMENT_MODES: Final[frozenset[str]] = frozenset({'', 'off', 'smart'})

Values site.cloudflare-placement accepts, empty meaning unmanaged.

The vocabulary of the Pages project’s placement.mode field, which is what repomatic cloudflare-pages writes the setting through. Anything else would be PATCHed to the live project verbatim and rejected there, far from the pyproject.toml line that caused it.

repomatic.config.SITE_DEPLOY_TARGETS: Final[frozenset[str]] = frozenset({'cloudflare-pages', 'github-pages'})

Hosts a repository’s built site can be published to.

One deploy job per target, each with the permissions its own host needs, so a value outside this set has no job at all behind it. Config.__post_init__ rejects one rather than letting the workflow run green and publish nothing.

repomatic.config.location_path(location)[source]

Normalize a *.location config value into a bare repo-relative path.

The location defaults carry a ./ prefix (they read as paths in the reference table) and a directory location a trailing slash; neither belongs in a registry target or an output_dir / path join. One normalizer keeps every consumer spelling the same value the same way.

Parameters:

location (str) – A Config location field value, class default or resolved instance value alike.

Return type:

str

Returns:

The path with no ./ prefix and no trailing slash.

class repomatic.config.FlavorConfig(agent='claude_code', ci='github_ci')[source]

Bases: object

Nested schema for [tool.repomatic.flavor].

Declares which ecosystem repomatic is targeting, so a future decision has one place to branch on instead of a new flag per feature.

Note

Values are trait IDs from extra-platforms, which already models both AI agents and CI systems. Borrowing its vocabulary brings its detection helpers (current_agent(), is_github_ci()) and its naming along for free, instead of repomatic maintaining a parallel enum.

Caution

Defaults are static, never detected. Deriving them from current_agent() would make a repository’s effective configuration depend on which tool happened to invoke repomatic last, so repomatic metadata would stop being reproducible.

agent: str = 'claude_code'

AI coding agent whose asset layout the bundled skills and agents target.

Accepts a extra_platforms.ALL_AGENTS trait ID present in AGENT_LAYOUTS. Hyphens are normalized, so claude-code works too.

ci: str = 'github_ci'

CI system the bundled workflows target.

Accepts a extra_platforms.ALL_CI trait ID. Only github_ci is implemented: every bundled workflow is a GitHub Actions workflow, so any other value is rejected rather than quietly emitting the wrong thing.

property layout: AgentLayout

Asset layout for the selected agent.

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

Content appended at the end of the generated .gitignore file.

“Appended” describes where the string lands, after the gitignore.io block, not how a downstream value combines with the default above: setting this key replaces that default wholesale, so the entries shown there are lost unless the override repeats them. repomatic.gitignore.orphaned_rules() catches that for any rule an earlier sync already wrote to disk, but not for one this repository never materialized, so copy the default and extend it rather than writing only the new lines. Reach for extra_categories instead when adding whole gitignore.io templates: that one is additive.

The .cc-writes entry is the one carrying a **/ prefix, because it is the one Claude Code does not place at the repository root: the directory is staged beside whichever working directory the session tracks, so a single cd into a subtree leaves one there instead. Anchoring it would miss every copy but the root’s.

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: dict[str, list[str]]

Per-label patterns matched against an issue or pull request’s text.

The [tool.repomatic.labels.content-rules] table maps each label to the patterns that apply it, evaluated by apply-labels against the title and body. Any one pattern matching applies the label:

[tool.repomatic.labels.content-rules]
"🥭 mango" = ["mango", "papaya"]
"🐛 bug" = []

A bare pattern is a literal keyword, matched case-insensitively on word boundaries; the /regex/flags form passes a regex through instead, with i, m and s honored. An entry for a label the bundled defaults also carry replaces the default entry, and an empty list disables it (see repomatic.labels.DEFAULT_CONTENT_RULES).

extra: list[dict[str, str | bool | list[str]]]

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

Each entry is a mapping carrying labelmaker’s per-label specification: name (required), color (single color or multi-color list), description, create, update, enforce-case, rename-from and on-rename-clash. A rename-from list renames an existing label in place, preserving its issue and PR associations. Entries are serialized into a temporary TOML file as [[profiles.default.labels]] blocks and applied by labelmaker apply, so no extra-labels/*.toml file needs committing.

For label sets that need multiple profiles, 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: dict[str, list[str]]

Per-label globs matched against the paths a pull request changes.

The [tool.repomatic.labels.file-rules] table maps each label to the globs that apply it, evaluated by apply-labels against the changed files. The label applies when any changed file matches the glob set:

[tool.repomatic.labels.file-rules]
"🥭 mango" = ["orchard/**", "!orchard/generated/**"]

Globs follow the minimatch dialect (** crosses directories, {a,b} expands, a leading dot needs no special casing), and a !-prefixed entry subtracts from the label’s other globs the way a .gitignore line would. An entry for a label the bundled defaults also carry replaces the default entry, and an empty list disables it (see repomatic.labels.DEFAULT_FILE_RULES).

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.LintDepsConfig(allow=<factory>, comment_word_threshold=40)[source]

Bases: object

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

allow: dict[str, str]

Packages that may ship from somewhere other than PyPI, and why.

lint-deps blocks a release whose dependencies do not all resolve from the index its users will install from. A handful of arrangements are legitimate exceptions: a member of the same monorepo published under its own name, a private mirror an internal project genuinely targets. Name each one here, mapped to the reason it is safe:

[tool.repomatic]
lint-deps.allow = { papaya = "monorepo workspace member, published separately" }

A mapping rather than a list, deliberately: the reason is the point. An exemption without one is indistinguishable from a forgotten development shortcut six months later, which is the exact thing this gate exists to catch. The reason renders in the report and in the release PR banner, so an accepted exception stays visible instead of disappearing.

Per-package only, with no global off switch, following exclude-newer-package: an exemption narrow enough to name is one somebody weighed. Listing a package does not silence its transitive dependencies, which stay gated on their own.

comment_word_threshold: int = 40

Word count above which lint-deps warns about a floor comment.

A floor comment justifies the version in force: what breaks below it, and where the project would notice. It is not a running log of every earlier floor, which is what it turns into when each bump appends a paragraph and deletes nothing. lint-deps emits a non-fatal warning for every comment longer than this many words. Set to 0 to disable the check.

It starts at the same 40 words as changelog.bullet-word-threshold, and stays an independent knob: both cap a paragraph written for a reader who came looking for one fact, but a project that wants its floors terser than its release notes says so here alone.

class repomatic.config.MetricsConfig(charts=<factory>, colors=<factory>, forges=<factory>, predecessors=<factory>, skip=<factory>, store='./docs/assets/metrics.csv', subjects=<factory>, sync=False)[source]

Bases: object

Nested schema for [tool.repomatic.metrics].

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

Charts to draw from the accumulated history, one array-of-tables entry each.

Each entry carries an output path, an optional metric (stars by default, and only a metric the store accrues can be charted), an optional mode (absolute, the default, or relative) measuring the horizontal axis, an optional scale (linear, the default, or logarithmic) measuring the vertical one, an optional only list naming the subjects to plot in draw order, and an optional title used as the chart’s accessible name:

[[tool.repomatic.metrics.charts]]
output = "./docs/assets/star-history.svg"

[[tool.repomatic.metrics.charts]]
mode = "relative"
output = "./docs/assets/star-history-by-age.svg"

[[tool.repomatic.metrics.charts]]
only = [ "apricot" ]
output = "./docs/assets/star-history-apricot.svg"

[[tool.repomatic.metrics.charts]]
scale = "logarithmic"
output = "./docs/assets/star-history-compared.svg"

The two axes are independent, and a chart comparing projects of different sizes usually wants both: mode = "relative" slides every curve onto a common origin, and scale = "logarithmic" keeps the smallest of them off the axis.

An entry omitting only plots every declared subject. Declaring none of these leaves the history accruing with nothing drawn from it, which is a valid way to collect first and decide later.

colors: dict[str, list[str]]

Per-subject [light, dark] hex pairs overriding the positional palette.

Hues are assigned from repomatic.metric_chart.SERIES_PALETTE in draw order, so a subject keeps its colour as long as the order holds. Pin one here when it must survive a reordering, or when a chart plots more curves than the palette holds:

[tool.repomatic.metrics.colors]
apricot = [ "#2a78d6", "#3987e5" ]
forges: dict[str, str]

Self-hosted forge instances, mapping each host to the software it runs.

Merged over repomatic.forge.FORGE_APIS, which only knows the three public hosts. A self-hosted instance is never guessed from its name, so an undeclared host raises rather than sampling nothing:

[tool.repomatic.metrics.forges]
"gitlab.example.org" = "gitlab"
"codeberg.example.org" = "forgejo"

Values are forgejo, github or gitlab; Gitea instances read as forgejo, whose API they share.

predecessors: dict[str, str]

Retired forerunners, mapping the subject they precede to their own repository.

A project that reopened under a new repository carries an audience it inherited rather than one it gathered, which a by-age chart would otherwise misreport as the fastest start in the field:

[tool.repomatic.metrics.predecessors]
papaya = "old-owner/papaya"

Drawn in the successor’s own hue to tie the two together, but dashed and never joined to it: the counts are independent tallies on separate repositories, so a continuous line would claim a running total no repository ever showed. The forerunner’s line stops where its successor’s begins.

skip: dict[str, str]

Subjects deliberately left unmeasured, mapped to the reason why.

A mapping rather than a list, following lint-deps.allow: the reason is the point. A project absent from both tables is an oversight a conformance test can report, while one listed here is a decision:

[tool.repomatic.metrics.skip]
papaya = "Ships in a distribution package with no public repository."

Nothing is sampled for them, and whatever renders the readings leaves their cells empty.

store: str = './docs/assets/metrics.csv'

Where the readings accumulate, one row per subject, metric and date.

subjects: dict[str, str]

Repositories to track, mapping each subject name to its repository.

The name labels the curve and keys its colour, so it is what a reader sees. A bare owner/name is GitHub; anything else is a full URL on whichever forge hosts it:

[tool.repomatic.metrics.subjects]
apricot = "apricot-org/apricot"
papaya = "https://gitlab.com/papaya/papaya"

Every subject is read for every metric its forge answers. The two deep collectors are GitHub-only and skip the rest with a note: an exact star reconstruction reads per-star timestamps, and the archive backfill mines github.com pages.

sync: bool = False

Whether sample-metrics records readings for this repository.

Opt-in, and the gate on the metrics.yaml workflow: a repository tracking nothing should not carry a weekly job, and an accumulating store is a commitment a maintainer makes deliberately.

class repomatic.config.SyncRunnerImagesConfig(ignore=<factory>)[source]

Bases: object

Nested schema for [tool.repomatic.sync-runner-images].

ignore: list[str]

Runner labels never to propose, whatever GitHub announces about them.

A sync-* job regenerates on every push, so a proposal declined by closing its pull request comes back on the next one. Without somewhere to record the decision, the only way to stop a proposal already considered and rejected is to disable the whole operation. Naming the label here is the one-line commit that makes a “no” stick:

[tool.repomatic.sync-runner-images]
# 26.04 stays out until its capacity settles: queue time matters more here
# than the compute it wins.
ignore = [ "ubuntu-26.04", "ubuntu-26.04-arm" ]

Applies to both shapes: an ignored label is neither probed when it arrives nor proposed as a successor when something retires onto it.

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-26.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, agent_location='./claude.md', 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>, flavor=<factory>, gitignore=<factory>, include=<factory>, labels=<factory>, lint_deps=<factory>, mailmap_sync=True, manpages_asset_name='', manpages_script='', metrics=<factory>, minimum_release_age='1 week', notification_unsubscribe=False, nuitka_dev_targets=<factory>, nuitka_enabled=True, nuitka_entry_points=<factory>, nuitka_extras=<factory>, nuitka_nofollow_imports=<factory>, nuitka_unstable_targets=<factory>, pypi_package_history=<factory>, release_assets=<factory>, settings_location='./.claude/settings.json', setup_guide=True, site_cloudflare_compatibility_date='', site_cloudflare_placement='', site_cloudflare_project='', site_deploy='github-pages', skills_location='./.claude/skills/', sphinx_builder='html', subagents_location='./.claude/agents/', sync_runner_images=<factory>, 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.

agent_location: str = './claude.md'

Path to the agent’s instructions file, relative to the repository root.

Left unset, it follows [tool.repomatic.flavor] agent; setting it explicitly overrides that.

Only the agent component writes here, merging the audience-tagged sections it owns into whatever the file already holds. Point it at AGENTS.md for the cross-agent convention, or anywhere else the file actually lives: a repository keeping its instructions outside the root (./dotfiles/.agents/AGENTS.md) is the case this exists for.

Caution

One character from subagents_location, and they write different things: this one an instructions document, that one a directory of subagent definitions. The components are agent and subagents for the same reason.

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.csv), 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/labels.toml").

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

flavor: FlavorConfig

Which agent and CI ecosystem this repository targets.

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.

lint_deps: LintDepsConfig

Dependency shippability gate 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.

metrics: MetricsConfig

What forges say about the repositories this project tracks, over time.

minimum_release_age: str = '1 week'

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. repomatic init honors it too: the derived upstream workflow pin steps back to the newest release past the window (override with --no-cooldown). 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_dev_targets: list[str]

Nuitka build targets compiled on ordinary pushes, as a canary.

An ordinary push to the default branch rebuilds binaries only for these targets: enough to catch a compilation break early, while freeing runner slots the full fleet would occupy on every code push just to refresh the rolling dev pre-release (a draft). The full target roster still builds on release commits, on the weekly schedule trigger, and on workflow_dispatch. Defaults to ["linux-arm64"], the fastest and cheapest builder. Set to [] to skip dev builds entirely.

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_nofollow_imports: list[str]

Module names Nuitka must not follow into the compiled binary.

Each name is forwarded as a --nofollow-import-to flag by repomatic run nuitka`. Defaults to``[“tkinter”]``:boltons.ecoutils` (in the dependency tree of every click-extra CLI) probes tkinter inside a guarded ``try/ except import, which otherwise drags the whole Tcl/Tk stack into every binary. Excluded modules raise ImportError when imported at run time, which guarded imports absorb. GUI projects that really ship tkinter can set this to [].

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.

release_assets: list[str]

Extra asset filenames attached to every GitHub release.

Each listed file must be produced by a job the consumer defines in its own release workflow (alongside the build lane the engine call already gates on) and uploaded as a run artifact named release-asset-<filename>. The engine’s extra-assets job downloads the artifacts, attests them with the same provenance chain as the compiled binaries, and attaches them to the release draft before publication locks it (GitHub immutable releases).

The build code stays in the downstream repository as regular workflow code, reviewed and linted there: the engine never executes consumer-supplied commands. Filenames must be space-free, as they travel through a space-separated job environment variable. Leave empty to disable, which keeps the job silent.

settings_location: str = './.claude/settings.json'

Path to the agent’s project settings file, relative to the repository root.

Left unset, it follows [tool.repomatic.flavor] agent; setting it explicitly overrides that.

Only the plugin component writes here, merging the marketplace and enablement keys it owns into whatever the file already holds.

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.

site_cloudflare_compatibility_date: str = ''

Workers runtime date the Cloudflare Pages project is pinned to.

A YYYY-MM-DD date, compared and enforced by repomatic cloudflare-pages against the live project’s deployment_configs, on both the production and preview environments. Inert while the project has no Pages Functions, which is exactly how it drifts unnoticed: the value only starts mattering the moment a Function is added, long after anyone last chose it. Empty (the default) leaves the live value unmanaged.

This is server-side state, not the wrangler.toml key of the same name: Cloudflare honours the project’s own configuration, and the file only matters to a build that a Direct Upload project never runs. lint-repo warns when a committed wrangler.toml disagrees, so the repository states one value rather than two.

site_cloudflare_placement: str = ''

Smart Placement mode declared for the Cloudflare Pages project.

smart or off, compared and enforced by repomatic cloudflare-pages on both environments. For a static site it changes nothing measurable and costs nothing; declaring it means the dashboard toggle stops looking like an accident. Empty (the default) leaves the live value unmanaged.

site_cloudflare_project: str = ''

Name of the Cloudflare Pages project the site deploys into.

Empty (the default) names the project after the repository, which is what the deploy job falls back to. Set it when the project predates repomatic or otherwise cannot carry the repository’s name: renaming a live Pages project would move the <project>.pages.dev hostname every custom domain CNAMEs through.

site_deploy: str = 'github-pages'

Where this repository’s built site is published.

github-pages, the default, has the Docs workflow upload the Sphinx tree as a Pages artifact and deploy it with the repository’s own OIDC identity: no stored credential, and nothing to configure beyond enabling Pages.

cloudflare-pages uploads it to a Cloudflare Pages project instead, named per site.cloudflare-project, through wrangler pages deploy. That path needs one repository secret, CLOUDFLARE_API_TOKEN, and it trades the OIDC deploy for a long-lived token: the Docs workflow’s monthly run is what surfaces its expiry, since Cloudflare warns about neither an approaching lapse nor a passed one.

A property of the site rather than of Sphinx. A repository whose site is built by its own workflow (a Pelican blog, a hand-rolled static tree) declares the target here too: that is what turns on the credential checks, the setup-guide step and the Cloudflare drift job for it, even though the Docs workflow’s own Sphinx build never runs.

Choose Cloudflare for what the edge can do rather than for speed. A custom domain on Cloudflare Pages carries its own certificate, so the zone’s apex can be proxied, which is what a _redirects file, a real 404.html and any edge rule on the apex all depend on.

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

Directory prefix for skill folders, relative to the repository root.

Left unset, it follows [tool.repomatic.flavor] agent; setting it explicitly overrides that.

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).

sphinx_builder: str = 'html'

Sphinx builder producing the deployed documentation site.

The default html writes page.html, so the site serves /page.html. Setting it to dirhtml writes page/index.html instead, so the same page serves at /page/ and the published URLs carry no extension, which is the shape search engines and most static hosts expect.

The one Sphinx setting a project cannot make in its own conf.py, hence a config key: the builder is chosen on the command line, and docs.yaml is what runs it. Switching an already-published site republishes every URL it has: the old paths stop existing, so the repository’s own absolute self-links (readme, packaging specs) move in the same commit, and whatever fronts the site redirects the old ones.

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

Directory prefix for subagent definitions, relative to the repository root.

Left unset, it follows [tool.repomatic.flavor] agent; setting it explicitly overrides that.

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

sync_runner_images: SyncRunnerImagesConfig

Runner image pull request configuration.

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', 'agent_location', 'awesome_template_sync', 'bumpversion_sync', 'cache', 'changelog_archive_location', 'changelog_bullet_word_threshold', 'changelog_location', 'dep_sources_sync', 'dependency_graph', 'dev_release_sync', 'docs', 'exclude', 'flavor', 'gitignore', 'include', 'labels', 'lint_deps', 'mailmap_sync', 'metrics', 'minimum_release_age', 'notification_unsubscribe', 'nuitka_enabled', 'nuitka_nofollow_imports', 'pypi_package_history', 'settings_location', 'setup_guide', 'site_cloudflare_compatibility_date', 'site_cloudflare_placement', 'skills_location', 'subagents_location', 'sync_runner_images', '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. dep-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_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.dep_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.dep_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.dep_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.dep_graph.STYLE_PRIMARY_NODE: str = 'stroke-width:3px'

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

repomatic.dep_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.dep_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.dep_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.dep_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.dep_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.dep_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-dep-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.dep_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.dep_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.dep_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.dep_graph.filter_root_edges(root_name, edges, main_deps, subgraphs)[source]

Drop root edges that no pyproject.toml declaration backs.

uv’s CycloneDX export hangs a dependency-group package off the root as soon as that package lands in the resolved component set, whether or not the group was requested. Exporting click-extra with --extra sphinx and no --group is enough for requests to come back as a direct dependency of the project: Sphinx pulls it in, the test group happens to declare it too, and the export conflates the two. Neither omitting --group nor passing --no-default-groups suppresses it.

Left in place, such an edge lands the package in the primary dependencies box, labelled with the specifier of a group nobody asked for, claiming the project depends on something a plain install never installs. So the root’s direct dependencies are re-derived from uv.lock, which records what pyproject.toml declares rather than what resolution happened to produce.

Edges into a box-owned package survive: render_mermaid() turns those into the box’s dashed arrow. Edges that do not start at the root are never touched, so the dropped package keeps rendering as a transitive dependency of whatever actually pulls it in.

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

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

  • main_deps (set[str] | None) – Names the root declares as main dependencies, from by_main. None when the lockfile describes no such package, in which case every edge is kept: missing data is not evidence that an edge is spurious.

  • subgraphs (Sequence[Subgraph]) – Boxes whose owned packages legitimately hang off the root.

Return type:

list[tuple[str, str]]

Returns:

The edge list, without the unbacked root edges.

repomatic.dep_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.dep_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.dep_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.dep_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.dep_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.dep_policy module

How a dependency is declared, as opposed to where it resolves from.

dep_sources answers “can this ship”: a git branch or a local path breaks the install for whoever pulls the published artifact, so those findings block a release. This module answers a narrower question that never blocks anything: is the declaration written the way the project’s own version policy says to write it.

The split is what keeps both halves honest. A style finding that could stop a release would eventually be silenced rather than fixed; a shippability finding that only warned would ship a broken wheel.

Only rules decidable from pyproject.toml alone live here. Whether a floor is justified by the APIs the code actually calls is the judgment call /repomatic-deps review exists for, and it stays there: no amount of parsing settles it, and a checker that guessed would train people to ignore it.

The rules, and what each one costs the reader when broken:

  • An upper bound on a runtime dependency caps everyone downstream, and the cap outlives whatever release prompted it. See Should You Use Upper Bound Version Constraints?

  • A bare dependency pins nothing, so the install that passed CI and the one a user gets can differ by a major version.

  • An unsorted list makes every addition a merge conflict candidate and hides duplicates.

  • A type stub outside the ``typing`` group installs at runtime for users who will never type-check.

  • A floor with no comment cannot be audited: the next reader has no way to tell a deliberate API minimum from a number a bot last touched.

  • A floor comment that runs long has stopped justifying the floor and started narrating how it got there. Each bump appends a paragraph about a version no longer in force, and the one claim that matters (what breaks below the floor that is declared) ends up buried in superseded history the git log already keeps.

repomatic.dep_policy.RUNTIME_LOCATION = '[project] dependencies'

Where a runtime dependency is declared, as the report spells it.

repomatic.dep_policy.STUB_PREFIX = 'types-'

Distribution-name prefix marking a PEP 561 stub-only package.

repomatic.dep_policy.STUB_GROUP = 'typing'

Dependency group stub-only packages belong in.

They are build-time inputs to a type checker, so installing them anywhere a user’s runtime environment reaches is pure weight.

repomatic.dep_policy.UPPER_BOUND_OPERATORS = ('<', '<=', '==', '!=', '~=')

Specifier operators that cap a runtime dependency from above.

~= is included because it implies a ceiling: ~=1.2 is >=1.2, ==1.*. Conditional markers (python_version<'3.11') are not specifiers and never reach this list.

class repomatic.dep_policy.PolicyFinding(package, location, detail, consequence, remedy)[source]

Bases: object

One declaration that departs from the project’s version policy.

Deliberately not a DepFinding: that type carries a SourceKind because every one of its findings is about where a package resolves from, and a style finding has no answer to give there.

package: str

Normalized name of the package the finding is about.

location: str

The TOML path the declaration was read from.

detail: str

The declaration as written, for the reader to go find.

consequence: str

What it costs to leave this as it is.

remedy: str

The next action.

property message: str

The finding as a single annotation line.

repomatic.dep_policy.count_comment_words(comment)[source]

Count the words of a comment run, ignoring the # markers.

Everything else counts as written, URLs and inline code included: a rationale leaning on three links is still three links the reader walks past on the way to the floor.

Return type:

int

repomatic.dep_policy.scan_policy(pyproject_path, comment_word_threshold=0)[source]

Every declaration in pyproject_path that departs from version policy.

Entirely offline, reading only pyproject.toml, so it costs nothing to run on every push.

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

  • comment_word_threshold (int) – Word ceiling per floor comment. 0 (or less) disables the check, which is what a caller with no configuration to read gets.

Return type:

list[PolicyFinding]

Returns:

Findings sorted by location, then by package.

repomatic.dep_report module

Shared rendering of dependency-update reports.

The markdown diff, held-back, and cooldown-bypass tables, the release-notes sections, and the comparison URLs that every updater’s PR body and terminal output route through: sync-uv-lock, sync-deps, sync-dep-sources, the three version-sync bumpers (sync-tool-versions, sync-action-pins, sync-workflow-pins), and audit --fix.

The computations stay with their datasources (repomatic.uv for the lock, repomatic.version_sync for GitHub/PyPI/npm); this module only renders their results.

repomatic.dep_report.RELEASE_NOTES_MAX_LENGTH = 2000

Maximum characters per package release body before truncation.

Render a table’s subject cell, linked when a URL is known for it.

Parameters:
  • name (str) – Package, action or tool name.

  • name_urls (dict[str, str] | None) – Mapping of names to their URL. Names absent from it (or a None mapping) render as plain text.

Return type:

str

Returns:

A markdown link, or the bare name.

repomatic.dep_report.markdown_section(heading, note, headers, rows)[source]

Assemble a report section: heading, optional intro, and a table.

The one place the section layout is defined, so every updater’s PR body keeps the same shape. The alignment row is derived from headers rather than written out, which is what stops a column from being added to one and not the other.

Parameters:
  • heading (str) – Full heading line, emoji included, without the ##. Omitted when empty, for a caller embedding the table under a title of its own (the release PR’s blocker banner sits inside a [!CAUTION] blockquote that already says what it is).

  • note (str) – Intro paragraph shown between heading and table. Omitted when empty.

  • headers (tuple[str, ...]) – Column titles.

  • rows (list[tuple[str, ...]]) – One tuple of pre-rendered cells per row, each as long as headers.

Return type:

str

Returns:

The rendered markdown, with no trailing newline.

repomatic.dep_report.parse_iso_datetime(value)[source]

Parse an ISO 8601 / RFC 3339 timestamp into a timezone-aware datetime.

The package-wide parser for any timestamp an external service writes: besides this module’s own upload times, repomatic.uv reads lock timestamps, repomatic.cloudflare token expiries and repomatic.github.job_timings job clocks through it, so every consumer tolerates the same shapes.

Uses arrow, so a nanosecond fractional second and a Z suffix (both of which Python 3.10’s stdlib datetime.fromisoformat rejects) parse cleanly; sub-microsecond precision is truncated to fit datetime.

arrow also supplies the .humanize() relative-time phrasing used in the sync report. whenever was the prior parser but has no humanizer; switch back once one lands: https://github.com/ariebovenberg/whenever/discussions/277

Parameters:

value (str) – An ISO 8601 / RFC 3339 instant, or empty.

Return type:

datetime | None

Returns:

A timezone-aware datetime, or None when value is empty or not a valid instant.

repomatic.dep_report.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.dep_report.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.dep_report.format_eligible(eligible, today)[source]

Render an eligibility date with a human-readable countdown.

Parameters:
  • eligible (date) – The date a release leaves the cooldown window.

  • today (date) – The current date, for the relative offset.

Return type:

str

Returns:

A string like 2026-06-25 (in 4 days), ... (today), or the bare date once the window has elapsed.

repomatic.dep_report.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.dep_report.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 repomatic.uv.parse_lock_exclude_newer(), or empty.

Return type:

str

Returns:

A one-line markdown note, or empty when exclude_newer is empty.

repomatic.dep_report.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 repomatic.uv.diff_lock_versions().

  • upload_times (dict[str, str] | None) – Optional mapping of package names to ISO 8601 upload-time strings, as returned by repomatic.uv.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.dep_report.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 repomatic.uv.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.dep_report.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.dep_report.HELD_BACK_COLUMNS = ('Locked', 'Available', 'Released', 'Eligible')

Held-back columns following the caller-supplied subject column.

Shared with repomatic.sync_ops.print_held_back_table(), so a run’s markdown PR body and its terminal table name the same columns in the same order instead of drifting apart as two hand-kept literals.

repomatic.dep_report.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 repomatic.uv.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.dep_report.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 repomatic.uv.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.dep_report.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.dep_report.BypassForecast(name, held_version, expires)[source]

Bases: object

A cooldown-bypass freeze and the date it self-clears.

Built by repomatic.uv.compute_bypass_forecasts() (freezes still active) and repomatic.uv.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.dep_report.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.dep_report.BYPASS_COLUMNS = ('Package', 'Held at', 'Held until')

Columns of the cooldown-bypass table.

Shared with repomatic.sync_ops.print_bypass_table() for the reason HELD_BACK_COLUMNS is.

repomatic.dep_report.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.dep_report.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.dep_report.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.dep_report.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.

A package whose notes carry no tag at all is skipped. That happens when fetch_release_notes() found no GitHub release for the range and fell back to a changelog link, which is positive evidence that the tags this URL would name do not exist: guessing a v prefix there yields a 404 in the PR body.

Parameters:
Return type:

dict[str, str]

Returns:

Dict mapping package names to GitHub comparison URLs.

repomatic.dep_sources module

Swap git-tracked dependencies back to their released versions, and refuse to release while one is still in place.

Two halves, both about where a dependency actually comes from.

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.

The lint-deps gate is the other half, and it covers what the swap does not. A dependency is shippable when whoever installs the published artifact from an index gets the same code the release was tested against. scan_project() reports every way that breaks, and the release lane refuses to build a package while one stands. See DepFinding for the failure classes, and docs/dependencies.md § Shippable sources for the worked example.

repomatic.dep_sources.LINT_DEPS_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Source', 'kind'), ('Declared in', 'location'), ('Verdict', 'verdict'))

Column definitions for the repomatic lint-deps table.

Lives beside DepFinding so the columns and the fields they render cannot drift apart; the CLI derives its --sort-by choices from it.

DepFinding.consequence and DepFinding.remedy are deliberately not columns: each runs to a couple of sentences, which in a fifth column pushes the other four off the side of any terminal. They are printed as the annotation line under the table instead, where the width is the screen’s rather than the widest cell’s.

repomatic.dep_sources.PYPI_INDEX_HOSTS = frozenset({'pypi.org', 'www.pypi.org'})

Hosts a package may be resolved from and still count as published.

Anything else is a private index, a staging index (TestPyPI lives on test.pypi.org, deliberately absent) or a proxy: a user running pip install or uvx against the default index reaches none of them, so a dependency pinned there is no more installable than one pinned to a git branch.

repomatic.dep_sources.WHEEL_METADATA_TABLES = ('project.dependencies', 'project.optional-dependencies')

Requirement arrays whose entries land in the published Requires-Dist.

[dependency-groups] (PEP 735) is deliberately absent: it never reaches distribution metadata. That is what separates a finding an installer trips over from one only a contributor does, which the report says out loud even though both block.

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.

repomatic.dep_sources.TOML_TABLE_HEADER = re.compile('\\s*\\[{1,2}\\s*(?P<path>[^]]+?)\\s*\\]{1,2}\\s*$')

A [table] or [[array of tables]] header, capturing its dotted path.

Enough TOML parsing for declaration_anchor() to tell which table a line sits in. The parsed document cannot answer that: tomllib and tomlkit both return values, and a line number is what a link needs.

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.

Delegates the margin policy to repomatic.uv.freeze_cutoff_after(): 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.requirement_arrays(doc)[source]

Yield every requirement array in a parsed pyproject.toml, labelled.

Covers [project.dependencies], each [project.optional-dependencies] extra, each [dependency-groups] group, and [build-system].requires. Non-list values and non-string items (like {include-group = …} entries) are the callers’ concern.

The label is the TOML path the array sits at, so a finding can name where a declaration lives rather than just which package it names. lint-deps reports it, and it is also what separates the tables that reach the published wheel’s Requires-Dist from the ones that never leave the repository.

Parameters:

doc (dict) – Parsed pyproject.toml.

Return type:

Iterator[tuple[str, list]]

Returns:

(location, array) pairs, the array being the live list object so callers can rewrite items in place.

repomatic.dep_sources.parse_requirement(item)[source]

Parse a requirement array item, returning None for anything else.

Parameters:

item (object) – One entry of a requirement array.

Return type:

Requirement | None

Returns:

The parsed requirement, or None for a non-string entry or an unparsable specifier.

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.floors_inside_cooldown(pyproject_path, lock_path, window)[source]

Dependency floors that no cooldown-gated resolution can satisfy.

A floor naming a version published inside the cooldown window makes the published package uninstallable. Anyone resolving it from an index (a downstream repo running a frozen workflow’s uvx 'repomatic==X.Y.Z', or an end user running uvx repomatic) gets a tool environment, which reads neither uv.lock nor [tool.uv] exclude-newer-package. Since uv exposes no environment variable for a per-package exemption either, there is nowhere for them to record the bypass.

Caution

This repository cannot feel the breakage it would ship. Its own workflows install from uv.lock (see repomatic.prepare_release.LOCAL_CLI_INVOCATION), which resolves through the local exclude-newer-package exemption and stays green. The failure lands only on whoever installs the release, which is why it needs a gate here rather than a red CI run to catch it.

Wait for a release to age out of the window before raising a floor onto it.

The comparison runs against the locked version’s upload time, which uv.lock records, so the check needs no network. A floor is reported when the locked version sits inside the window and the floor demands at least that version: releases reach an index in version order, so nothing satisfying such a floor can be older than what is already locked.

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

  • lock_path (Path) – Path to the uv.lock file.

  • window (str) – Cooldown window, in any form [tool.uv] exclude-newer accepts.

Return type:

dict[str, str]

Returns:

Mapping of canonical package name to the offending floor version, empty when every floor resolves without an exemption.

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.

class repomatic.dep_sources.SourceKind(*values)[source]

Bases: StrEnum

Where a dependency is resolved from.

The vocabulary is shared by [tool.uv.sources] and uv.lock, which name the same concepts with the same keys, so classify_source() reads both. Only REGISTRY describes something an installer of the published artifact can reach on its own.

DIRECT_REFERENCE = 'direct reference'

A PEP 508 name @ url clause written into the requirement itself.

GIT = 'git'

A git repository, whether tracked by branch, tag or commit.

INDEX = 'index'

A named [[tool.uv.index]] other than PyPI.

PATH = 'path'

A local directory, including an editable install or a lock directory entry.

REGISTRY = 'registry'

A package index. Shippable when the index is PyPI.

URL = 'url'

A direct artifact URL (a wheel or sdist served over HTTP).

WORKSPACE = 'workspace'

Another member of the same uv workspace.

class repomatic.dep_sources.DepFinding(package, kind, location, detail, consequence, remedy, level=AnnotationLevel.ERROR, allowed='')[source]

Bases: object

One reason the project cannot be published as it stands.

Findings are what scan_project() returns, and they carry their own explanation rather than a code the caller has to map: the CLI table, the GitHub annotation and the release PR banner all render the same three sentences, so a maintainer reads one wording wherever they meet it.

package: str

Normalized name of the offending package.

kind: SourceKind

How that package is resolved.

location: str

The TOML path (or uv.lock) the declaration was read from.

detail: str

The declaration as written, for the reader to go find.

consequence: str

What breaks, for whom, if this ships.

remedy: str

The next action, naming the automation that already covers it.

level: AnnotationLevel = 'error'

Severity. Only ERROR blocks a release.

allowed: str = ''

The [tool.repomatic] lint-deps.allow reason, when one covers this package. A non-empty reason downgrades the finding to a notice that still renders, so an accepted exception stays visible instead of disappearing.

property blocking: bool

Whether this finding stops a release.

property verdict: str

One-word outcome, for the table’s last column.

property message: str

The finding as a single annotation line.

repomatic.dep_sources.is_pypi_url(url)[source]

Whether url points at the public Python Package Index.

Parameters:

url (str) – An index or registry URL.

Return type:

bool

Returns:

True when its host is one of PYPI_INDEX_HOSTS.

repomatic.dep_sources.classify_source(value)[source]

Read a [tool.uv.sources] entry or a uv.lock source table.

Both spell the same concepts with the same keys, so one classifier serves the declaration and its resolution. Checked most-specific first: a { path = "…", editable = true } entry is a path source, not two.

Parameters:

value (object) – The mapping sitting under a source key.

Return type:

SourceKind | None

Returns:

The kind, or None for anything unrecognized (a bare marker table, a future uv key). Unknown shapes are not reported: a gate that guesses would block releases over syntax it does not understand.

repomatic.dep_sources.declared_requirements(doc, name)[source]

Every requirement string declaring name, with its TOML location.

A [tool.uv.sources] entry says where a package comes from but not whether anyone downstream will feel it. That answer lives in the requirement arrays, and it is what decides the consequence: a package named in [project.dependencies] ships a requirement the index must satisfy, one named only in [dependency-groups] ships nothing at all, and one named nowhere is a transitive dependency being swapped underneath the resolver.

Parameters:
  • doc (dict) – Parsed pyproject.toml.

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

Return type:

list[tuple[str, str]]

Returns:

(location, requirement string) pairs, empty when the package is not declared directly.

repomatic.dep_sources.scan_pyproject(pyproject_path, allow=None)[source]

Report every unshippable declaration in pyproject.toml.

Covers what the project says, which is the half a reader can act on directly:

  • a [tool.uv.sources] entry resolving from anywhere but PyPI,

  • a PEP 508 direct reference (name @ git+…) in any requirement array, [build-system].requires included,

  • a [[tool.uv.index]] marked default that is not PyPI,

  • a non-empty override-dependencies or constraint-dependencies, which is reported as a warning rather than a block: those name a version rather than an unreleased artifact, so what they change is the tested resolution, not the installability of the result.

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

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source, from [tool.repomatic] lint-deps.allow.

Return type:

list[DepFinding]

Returns:

Findings, unsorted; scan_project() orders them.

repomatic.dep_sources.scan_lock(lock_path, allow=None, doc=None)[source]

Report every package uv.lock resolves from outside PyPI.

The complement to scan_pyproject(), and the reason the gate is not a list of hand-written rules: the lock records the resolved source of every package in the tree, so a git dependency pulled in by another git dependency shows up here even though no table in pyproject.toml names it.

The project’s own entry is skipped. uv writes it as { editable = "." } for a package and { virtual = "." } for a virtual project, and neither describes a dependency. A workspace member is a different path (like { editable = "packages/mango" }) and is reported, since publishing this project does not publish that one.

Parameters:
  • lock_path (Path) – Path to the uv.lock file.

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source.

  • doc (dict | None) – Parsed pyproject.toml, when the caller has one. Supplying it lets each finding say whether the package is declared directly, which is what separates “every install fails” from “a transitive dependency was swapped underneath the resolver”.

Return type:

list[DepFinding]

Returns:

Findings, unsorted.

repomatic.dep_sources.scan_project(pyproject_path, lock_path, window, allow=None)[source]

Every reason this project cannot be released as it stands.

Folds the three checks into one ordered report: what pyproject.toml declares (scan_pyproject()), what uv.lock resolved (scan_lock()), and which floors no cooldown-gated resolution can satisfy (floors_inside_cooldown()). Entirely offline, so it costs nothing to run on every push and cannot fail on a flaky index.

A package flagged by both halves is reported once, keeping the pyproject.toml finding: that is where the reader has something to edit, the lock being a derived file.

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

  • lock_path (Path) – Path to the uv.lock file.

  • window (str) – Cooldown window, in any form [tool.uv] exclude-newer accepts.

  • allow (dict[str, str] | None) – Package name to the reason it may ship from a non-index source.

Return type:

list[DepFinding]

Returns:

Findings sorted by package, then by location.

repomatic.dep_sources.BLOCKER_SECTION_NOTE = 'A dependency is shippable when whoever installs the published artifact gets the code this release was tested against. These do not clear that bar, so the release lane refuses to build a package while they stand. See [Dependency management § Shippable sources](https://repomatic.net/dependencies#shippable-sources).'

Intro paragraph for the lint-deps blocker section.

repomatic.dep_sources.format_blocker_section(findings, *, heading='🚧 Unshippable dependencies')[source]

Format blocking findings as a markdown section.

The long form, for the lint-deps report: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR gets build_release_readiness() instead, which is the same findings at banner length.

Parameters:
Return type:

str

Returns:

A markdown string, or an empty string when nothing blocks.

repomatic.dep_sources.declaration_anchor(finding, pyproject_path, lock_path)[source]

Locate the declaration behind a finding, as a repository-relative link.

Only two files can hold one: uv.lock for a source the resolver picked, pyproject.toml for everything the project wrote itself, dependency floors included.

Parameters:
  • finding (DepFinding) – The finding to locate.

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

  • lock_path (Path) – Path to the uv.lock file.

Return type:

str

Returns:

The file name, suffixed with #L{n} once the declaring line is found. A line that cannot be found degrades to the bare file rather than to a guess: an anchor pointing at the wrong line costs the reader more than no anchor at all.

repomatic.dep_sources.RELEASE_READY_SENTENCE = 'This PR is ready to be merged. '

How the release checklist opens when nothing blocks.

Trailing space included: it runs inline into the sentence the template follows it with, where the blocked form is a standalone blockquote instead.

repomatic.dep_sources.UNSHIPPABLE_BANNER_LEAD = 'Do not merge yet. This release would ship dependencies its users cannot install:'

Opening of the blocked form of the release PR’s verdict.

The banner is a verdict, not a report: it says what is wrong and names what to open. Everything else the finding carries (why the source is unshippable, what to do about it, the general rule) reads as chatter in a pull request whose body is otherwise a five-step checklist, and it is one click away in the lint-deps report format_blocker_section() renders.

repomatic.dep_sources.build_release_readiness(pyproject_path, lock_path, window, allow=None, source_url=None)[source]

Build the release PR’s opening verdict.

The prepare-release checklist has always opened with “This PR is ready to be merged”, and that sentence is a lie while a dependency resolves from a git branch, a fork or a local path. So the opening is owned here rather than hard-coded in the template: it stays that sentence while the project is releasable, and becomes a [!CAUTION] block naming every offending dependency when it is not.

This is the layer that matters, even though the release lane carries a hard gate of its own. By the time that gate fires the freeze commit is already on main, and the recovery is to burn the version per claude.md § Skip and move forward. This body is regenerated on every push to main, so it carries the same answer days earlier, in the one place a maintainer reads before deciding to merge.

Note

Lives here rather than beside the other pr-body template-argument builders in repomatic.github.pr_body, which is where it would otherwise belong: that module is imported by repomatic.dep_report, so reaching scan_project() from it closes an import cycle.

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

  • lock_path (Path) – Path to the uv.lock file.

  • window (str) – Cooldown window, from [tool.repomatic] minimum-release-age.

  • allow (dict[str, str] | None) – Package name to its lint-deps.allow reason.

  • source_url (str | None) – Blob URL the declarations hang off, without a trailing slash (like {repo_url}/blob/{sha}). Each package links into it. A caller with no commit to point at passes nothing, and the packages render with their file and line as plain text instead.

Return type:

str

Returns:

RELEASE_READY_SENTENCE, or a one-line GitHub-flavored markdown [!CAUTION] blockquote naming what blocks the release.

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.DIRECTIVE_BLOCK_MARKERS: tuple[str, ...] = ('{matrix}', '<!-- matrix', ':mirror:', '<!-- mirror')

Markers of a self-updating block click-extra refresh-directives rewrites.

Every form the refresh recognizes: the {matrix} MyST fence (live-rendered by Sphinx), the <!-- matrix --> comment region (whose embedded table renders on GitHub too), and the python:render :mirror: region (<!-- mirror -->, whose generator Python the refresh executes).

repomatic.docs.has_directive_block(path)[source]

Whether path carries a self-updating block worth refreshing.

Parameters:

path (Path) – Markdown file to scan.

Return type:

bool

Returns:

True when any DIRECTIVE_BLOCK_MARKERS entry appears.

repomatic.docs.update_docs(config, *, check=False)[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.

  • check (bool) – Report out-of-date content without writing, for CI drift detection. Phases 1–2 regenerate files and have no dry-run mode, so they are skipped; the self-updating phases run in their own check modes (docs_update.py --check and refresh-directives --check) and any drift raises a ClickException. The update script must accept a --check flag to participate: a script that ignores it will still write.

Return type:

None

repomatic.file_inventory module

What files this repository contains, honoring .gitignore.

One question, asked in one place: every “which files are the Python sources / the workflows / the images” lookup routes through FileInventory, whose glob_files() resolves symlinks, drops broken ones and filters out anything .gitignore excludes. The results are the lists CI jobs gate on, so a job that formats Markdown and one that lints it see the same files.

Split out of repomatic.metadata.Metadata, which reaches CI context, git history and pyproject.toml: none of that is needed to answer “what is on disk here”, and Metadata keeps the family reachable under its own names for every existing caller.

repomatic.file_inventory.GITIGNORE_PATH = PosixPath('.gitignore')

Path of the .gitignore file whose rules filter every inventory lookup.

Fixed at the repository root, unlike the configurable [tool.repomatic.gitignore] location that sync-gitignore writes: the glob filter has to match what git itself honors, and git only reads this path.

class repomatic.file_inventory.FileInventory[source]

Bases: object

The repository’s files, grouped by what a job needs to act on them.

Each group is a cached property, so a command asking for the Markdown files twice walks the tree once. Instantiate per working directory: the lookups resolve against the current directory at call time.

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 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.

static shebang_names_zsh(path)[source]

Whether path opens with a shebang line naming zsh.

The .sh extension is ambiguous: it says POSIX shell while the shebang picks the actual interpreter. Reading that first line is what keeps shfmt_files and zsh_files disjoint, so a bash script is never handed to the Zsh linter and a zsh script is never handed to shfmt.

Parameters:

path (Path) – File to probe.

Return type:

bool | None

Returns:

True when the shebang names zsh, False when it does not, and None when the file cannot be read. Both callers drop an unreadable file rather than guess at its dialect.

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.

The .zsh extension and the zsh dotfiles are unambiguous. A .sh file joins the list only when its shebang names zsh: matching the extension alone would claim every bash script in the repository, and the Zsh lint job would then run zsh --no-exec over scripts shfmt is formatting as bash. See shebang_names_zsh().

repomatic.forge module

Read a repository’s metrics from whichever forge hosts it.

Answers one question for one repository: how many accounts follow it, when it was created, when it last shipped, and when it was last touched. GitHub, GitLab (on any instance) and Forgejo or Gitea (likewise) each expose that through a different API, and repo_metrics() picks the right one from the URL’s host.

One call per repository on every forge, which is what lets a single sampler collect every metric repomatic.metrics records rather than one call per metric family.

repomatic.forge.FORGE_APIS: dict[str, str] = {'codeberg.org': 'forgejo', 'github.com': 'github', 'gitlab.com': 'gitlab'}

Forge software each known host runs, which is what selects the API to call.

Never guessed from the host name: an unknown host raises instead, so a subject landing on a fourth kind of forge has to declare how to read it rather than silently sampling nothing. Extend it through [tool.repomatic.metrics] forges, which a repository uses to name the self-hosted instances it tracks (salsa.debian.org runs GitLab, gitlab.archlinux.org too).

repomatic.forge.FORGE_USER_AGENT = 'repomatic forge metrics collector'

Sent to every forge API, where a browser identity backfires.

Several self-hosted GitLab instances answer a browser user-agent with a page rather than a payload, returning kilobytes of HTML where the same URL fetched under a plain agent returns a small JSON document. Nothing errors, so the symptom is a subject quietly missing from the readings rather than a failed run.

repomatic.forge.GITHUB_HOST = 'github.com'

The one host whose deep collectors exist.

An exact star reconstruction reads per-star timestamps, and an archive backfill mines github.com pages: both are GitHub-only, so a subject elsewhere is skipped by them rather than failed.

repomatic.forge.GITHUB_METRICS_QUERY = '\nquery($owner: String!, $name: String!) {\n  repository(owner: $owner, name: $name) {\n    createdAt\n    stargazerCount\n    latestRelease { publishedAt }\n    defaultBranchRef { target { ... on Commit { committedDate } } }\n    refs(refPrefix: "refs/tags/", first: 1,\n         orderBy: {field: TAG_COMMIT_DATE, direction: DESC}) {\n      nodes {\n        target {\n          ... on Commit { committedDate }\n          ... on Tag { target { ... on Commit { committedDate } } }\n        }\n      }\n    }\n  }\n}\n'

Reads a repository’s whole metric set in one call.

One call where REST needs four, and correct where REST is not: /tags answers in an order nobody should assume, so a fallback trusting it can date a live project a decade into the past. Ordering on TAG_COMMIT_DATE states the question instead of hoping the default matches it.

The commit date is read off the default branch rather than from the repository’s pushedAt, which any push to any branch bumps.

class repomatic.forge.ForgeMetrics(stars, created=None, release=None, release_source=None, commit=None)[source]

Bases: object

One repository’s metrics, as any forge reports them.

stars: int

Count of accounts following the repository on its own forge.

created: str | None = None

ISO date the repository was opened.

The one date a star count is known to be zero, which is what gives a history an origin and a by-age chart something to align on.

release: str | None = None

ISO date of the newest release or tag, None when a project has neither.

release_source: str | None = None

Where release came from: a release object, or a bare tag.

Recorded because the two are not the same claim. A release is something the project announced; a tag is only the newest thing it labelled, which is the closest available answer for the many projects that never cut a release.

commit: str | None = None

ISO date of the newest commit on the default branch.

The half of the activity reading that stays true for a rolling repository. A widely used package archive can go a decade without tagging a release while being committed to several times a day: a release date alone would report it as long dead.

readings()[source]

Yield each metric this reading carries, as (metric id, value).

The bridge between a typed forge answer and the metric store, which holds every value as text. A metric the forge did not answer yields nothing rather than an empty string, so a project with no release adds no row instead of a blank one.

created is deliberately absent: it is not a metric but the origin of one, recorded by the sampler as a stars reading of zero on that date.

Return type:

Iterator[tuple[str, str]]

repomatic.forge.split_repo_url(url)[source]

Split a repository URL into its host and its owner/name path.

Parameters:

url (str) – An https://host/owner/name repository URL.

Return type:

tuple[str, str]

Returns:

The (host, path) pair.

Raises:

ValueError – When the URL carries no host or no owner/name path.

repomatic.forge.canonical_url(subject)[source]

Normalize a configured subject into the URL the store keys on.

A bare owner/name is GitHub, which is what a repository declaring a handful of peers writes. Anything else is already a URL and only needs its trailing decoration removed. One spelling in the store keeps a subject addressable whichever way its configuration named it.

Parameters:

subject (str) – An owner/name slug or a full repository URL.

Return type:

str

Returns:

The canonical https://host/owner/name URL.

Raises:

ValueError – When neither shape parses.

repomatic.forge.forge_of(url, extra_forges=None)[source]

Name the forge software running the host of url.

Parameters:
  • url (str) – An https://host/owner/name repository URL.

  • extra_forges (Mapping[str, str] | None) – Host-to-forge entries a repository declared for the self-hosted instances it tracks, merged over FORGE_APIS.

Return type:

str

Returns:

One of forgejo, github or gitlab.

Raises:

ValueError – When the host is not declared anywhere, which is deliberate: a silently unsampled subject is worse than a loud one.

repomatic.forge.newest_dated(release, tag)[source]

Pick whichever of a project’s newest release and newest tag is more recent.

Not a preference for releases: plenty of projects carry a tag newer than their latest release object, some by close to a year, so always reading the release would report them as idle. ISO dates compare as strings, which is the whole of the arithmetic here.

Parameters:
  • release (str | None) – ISO date of the newest release, or None.

  • tag (str | None) – ISO date of the newest tag, or None.

Return type:

tuple[str | None, str | None]

Returns:

The (date, source) pair, both None when a project has neither.

repomatic.forge.forge_json(url)[source]

Read one JSON document from a forge’s public API.

Covers every forge but GitHub, whose authentication gh already carries. The instances read here (GitLab and Forgejo) serve their project metadata to anonymous callers, so no token is involved and none is asked for.

Parameters:

url (str) – The API endpoint to read.

Return type:

Any | None

Returns:

The parsed payload, or None when the call or the parse failed.

repomatic.forge.github_metrics(path)[source]

Read a GitHub repository through GITHUB_METRICS_QUERY.

Parameters:

path (str) – The repository’s owner/name path.

Return type:

ForgeMetrics

Returns:

The repository’s metrics.

Raises:

RuntimeError – When the gh call fails.

repomatic.forge.gitlab_metrics(host, path)[source]

Read a GitLab project, on whichever instance hosts it.

Parameters:
  • host (str) – The instance’s hostname.

  • path (str) – The project’s namespace path.

Return type:

ForgeMetrics | None

Returns:

The project’s metrics, or None when unreadable.

repomatic.forge.forgejo_metrics(host, path)[source]

Read a Forgejo or Gitea repository, on whichever instance hosts it.

Parameters:
  • host (str) – The instance’s hostname.

  • path (str) – The repository’s owner/name path.

Return type:

ForgeMetrics | None

Returns:

The repository’s metrics, or None when unreadable.

repomatic.forge.repo_metrics(url, extra_forges=None)[source]

Read one repository, through whichever API its host speaks.

Parameters:
  • url (str) – An https://host/owner/name repository URL.

  • extra_forges (Mapping[str, str] | None) – Host-to-forge entries for self-hosted instances.

Return type:

ForgeMetrics | None

Returns:

The repository’s metrics, or None when the forge could not be read.

Raises:

repomatic.frontmatter module

Splitting a Markdown document into its YAML frontmatter and body.

Two unrelated families of bundled Markdown carry frontmatter: skill definitions (SKILL.md, whose fields the Agent Skills spec defines) and PR body templates in repomatic/templates/. Both need the same split, so it lives here once rather than once per consumer.

repomatic.frontmatter.DELIMITER = '---'

Line that opens and closes a frontmatter block.

repomatic.frontmatter.split_frontmatter(raw)[source]

Split a document into its parsed frontmatter mapping and its body.

Values keep their YAML types, so a nested field (the spec’s metadata mapping, a template’s args list) reads back as the structure it was written as rather than a flat string.

Note

Both delimiters must sit alone on their own line, per the frontmatter convention. Scanning for the closing line, instead of splitting the document on the first two --- runs, keeps a value that embeds --- (like an argument-hint listing a long-form option) from truncating the block.

Parameters:

raw (str) – Full text of the document.

Return type:

tuple[dict[str, Any], str]

Returns:

(frontmatter, body). The frontmatter is an empty mapping when the document opens no block, leaves one unterminated, or holds something other than a YAML mapping; in each of those cases the body is raw unchanged, so no content is ever silently dropped.

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.CHANGELOG_COMMIT_PREFIX = '[changelog] '

Marker prefix carried by every machine-authored version-machinery commit.

The one bracketed prefix commit messages may carry (see claude.md § Commit messages): release freezes, post-release bumps and manual version bumps all start with it, so a workflow can skip machinery pushes with a single startsWith(github.event.head_commit.message, '[changelog] ') clause instead of enumerating each message shape. Conformance tests in tests/test_workflows.py hold every member of VERSION_BUMP_COMMIT_PREFIXES, RELEASE_COMMIT_PATTERN, the bump-version template title, and the workflow gates to this prefix.

repomatic.git_ops.RELEASE_COMMIT_PREFIX = '[changelog] Release'

Head-commit-message prefix marking a push that carries the release commit.

The coarser sibling of RELEASE_COMMIT_PATTERN: where that one validates and extracts a version, this is the prefix test every workflow’s cancel-in-progress gate performs, so a release run is never cancelled by a later push entering its concurrency group. A prefix is deliberately weaker than the full pattern here, because the question is “does this push carry a release” rather than “which version is it”, and answering it must not depend on the version number parsing.

repomatic.github.actions.cancel_superseded_runs() applies the same test from the API side, which is the half GitHub’s own concurrency mechanism cannot cover: a manual sweep of a branch’s live runs enters no concurrency group at all.

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.VERSION_BUMP_BRANCHES: 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 repomatic.binary.SKIP_BINARY_BUILD_BRANCHES. Post-merge release artifacts on main are still produced.

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

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

Members are the bump-version job’s [changelog] Bump $part version to \``v$version\ commit messages (rendered from the bump-version template’s title), carrying CHANGELOG_COMMIT_PREFIX like every other version-machinery commit. 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.git_ops.VERSION_BUMP_COMMIT_PREFIXES: frozenset[str] = frozenset({'[changelog] Bump major version to ', '[changelog] 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. Every member starts with CHANGELOG_COMMIT_PREFIX, so workflows without a release-artifact dependency (lint.yaml, labels.yaml, autofix.yaml) gate their metadata job on that single prefix and 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.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.tree_sha(ref='HEAD')[source]

Return the SHA of the tree ref points at.

Two commits sharing a tree SHA carry byte-identical content, whatever their message, author or parent. That makes this the cheapest way to ask whether re-running a generator produced anything new.

Return type:

str

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

Return the number of commits in the start..end range.

Follows git range semantics: start is excluded, end is included.

Return type:

int

repomatic.git_ops.is_ancestor(maybe_ancestor, ref)[source]

Return whether maybe_ancestor is reachable from ref.

Return type:

bool | None

Returns:

True or False when git can relate the two commits, and None when it cannot — a shallow clone whose grafted history stops before their common ancestor answers neither yes nor no.

repomatic.git_ops.merge_base(left, right)[source]

Return the best common ancestor of two commits, or None if unrelated.

None also covers the shallow-clone case described in is_ancestor().

Return type:

str | None

repomatic.git_ops.rebase_onto(new_base, old_base, branch)[source]

Replay old_base..branch on top of new_base, keeping the replayed side.

--strategy-option=theirs resolves overlaps in favour of the commits being replayed, which for a generated branch means the freshly generated content wins over whatever the new base happens to carry.

Return type:

bool

Returns:

True on success. On conflict the rebase is aborted and False returned, leaving branch exactly as it was: a branch built on a slightly stale base still opens a usable pull request, and the next run converges it, so this is not worth failing the job over.

repomatic.git_ops.create_branch(name)[source]

Create or reset branch name at HEAD and switch to it.

The index and working tree carry over untouched, so staged changes made before the call survive into a commit made after it.

Return type:

None

repomatic.git_ops.delete_branch(name)[source]

Delete the local branch name, even when unmerged.

Tolerates a branch that is not there. Callers reach this from a finally that cleans up a scratch branch, where the failure being cleaned up after may be the very thing that stopped the branch from being created: raising here would replace the real error with a confusing one.

Return type:

None

repomatic.git_ops.stage_all(paths=())[source]

Stage working-tree changes, untracked files included.

Stages the whole tree by default. paths narrows that to a git pathspec list, for a job whose own steps leave more behind than they mean to commit: a linter installed into the checkout, a lock file a package manager rewrote on the way past. Anything outside the pathspec stays dirty and is left for the caller to restore or discard.

A pathspec matching nothing is dropped rather than fatal. git add exits 128 on the first one it cannot resolve and stages nothing at all, so a glob covering output a run happened not to produce would take the whole job down with it. Filtering first also keeps one stale entry in a list from silently costing the others their staging.

Parameters:

paths (Sequence[str]) – Git pathspecs to stage. Empty stages everything.

Return type:

bool

Returns:

True when the index ends up carrying something to commit.

repomatic.git_ops.commit_staged(message)[source]

Commit the staged tree as the CI bot and return the new commit SHA.

The identity is supplied per-command via -c, since CI checkouts carry no git identity of their own. Mirrors commit_and_push_files(), which does the same for the direct-to-default-branch case.

Return type:

str

repomatic.git_ops.fetch_remote_branch(branch, remote='origin')[source]

Fetch branch into its remote-tracking ref and return the SHA.

The refspec is explicit because actions/checkout configures a single-branch fetch refspec, under which a bare git fetch origin {branch} updates FETCH_HEAD but leaves refs/remotes/{remote}/{branch} absent. Writing the remote-tracking ref is what later lets a push take a --force-with-lease on it.

Return type:

str | None

Returns:

The remote branch tip, or None when the branch does not exist on the remote.

repomatic.git_ops.force_push_branch(local_ref, branch, expected_sha, remote='origin')[source]

Publish local_ref as branch on remote, overwriting what is there.

expected_sha is the remote tip the caller last observed, which becomes a --force-with-lease guard: the push is refused when the branch moved in between, rather than silently discarding the other writer’s commit. Pass None to create a branch that does not exist yet, where a plain push already fails if someone wins the race.

Raises:

subprocess.CalledProcessError – When the push is rejected.

Return type:

None

repomatic.git_ops.delete_remote_branch(branch, remote='origin')[source]

Delete branch from remote, tolerating a branch already gone.

Return type:

None

repomatic.git_ops.list_contributor_identities()[source]

Return every author and committer identity found in the history.

No normalization happens: all variations of author and committer strings attached to all commits are returned as-is, in Name <email> form.

For format output syntax, see: https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-aN

Raises:

RuntimeError – When git fails, carrying its stderr.

Return type:

set[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_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, all_changes=False)[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. Ignored when all_changes is set.

  • 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.

  • all_changes (bool) – Stage every change in the working tree instead of the named files. For a job whose output paths come from configuration and are therefore unknown to the workflow that runs it: the runner starts from a pristine checkout and the preceding steps are the only writers, so “everything that changed” is exactly the job’s own output. Never reach for it in a job that also runs a formatter or an installer.

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.gitignore.parse_rules(content)[source]

Extract the ignore rules from .gitignore content.

Blank lines and comments are dropped, leaving only the lines git actually matches paths against. Order is preserved and duplicates are collapsed, so the result compares two files by what they ignore rather than by how they are laid out.

Only a leading # opens a comment: git treats one anywhere else in the line as part of the pattern, so no inline-comment stripping happens here.

Parameters:

content (str) – Full text of a .gitignore file.

Return type:

list[str]

Returns:

The rules, in first-seen order.

repomatic.gitignore.orphaned_rules(existing, generated)[source]

Return the rules generated would drop from existing.

sync-gitignore rebuilds the file from gitignore.io plus [tool.repomatic.gitignore] extra-content and never reads what is already on disk, so a rule added by hand survives exactly one edit: the next sync writes over it. Comparing the two rule sets before the write is what turns that silent loss into something the caller can refuse.

Parameters:
  • existing (str) – Current content of the .gitignore on disk.

  • generated (str) – Content build_gitignore() just produced.

Return type:

list[str]

Returns:

Rules present in existing and absent from generated, in first-seen order. Empty when the sync drops nothing.

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. Caching policy stays with the callers — each client owns its cache namespace, TTL, and serialization — while get_cached_json() shares the raw-response caching mechanics for the clients that store verbatim bodies.

repomatic.http.DEFAULT_TIMEOUT = 10

Socket timeout in seconds for every HTTP fetch repomatic makes.

Shared by the JSON clients here and the plain-text gitignore.io fetch (repomatic.gitignore): a stalled connection must fail the operation, not hang it.

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 (float) – 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.http.get_json_soft(url, log_label)[source]

GET url as JSON, logging any failure as a soft miss.

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

  • log_label (str) – Human-readable label for the debug log on failure.

Return type:

tuple[Any, bytes] | None

Returns:

(parsed, raw_bytes), or None on any failure (HTTP error, network error, timeout, JSON parse error).

repomatic.http.get_cached_json(namespace, key, url, *, ttl, log_label, force_refresh=False)[source]

GET url as JSON through the raw-response cache.

A fresh cached body under namespace/key short-circuits the network; otherwise the response is fetched, cached verbatim (when ttl is positive), and returned parsed. The caller keeps the caching policy: it picks the namespace, the cache key, and the TTL.

Note

force_refresh skips the cache read but keeps the write, which is what separates it from ttl=0: the latter also skips the store, so a caller using it to bypass a stale entry would leave that entry in place for the next reader. A forced refresh replaces it.

Parameters:
  • namespace (str) – Cache namespace (like "pypi" or "npm").

  • key (str) – Cache key within the namespace, usually the package name.

  • url (str) – The URL to fetch on a cache miss.

  • ttl (int) – Freshness TTL in seconds; 0 disables caching.

  • log_label (str) – Human-readable label for the debug log on failure.

  • force_refresh (bool) – Ignore any cached body and re-fetch, then store the fresh response.

Return type:

Any | None

Returns:

The parsed JSON value, or None on any fetch failure.

repomatic.humanize module

Human-readable renderings of raw file-system quantities.

Byte counts and modification times reach the user through more than one surface (the image-optimization summary, the repomatic cache tables), and each surface should spell them the same way. One home for those conversions keeps the wording consistent and keeps the formatters out of the modules that merely happen to be the first consumer.

Dependency-free beyond click_extra, so any module can import it without risking a cycle.

repomatic.humanize.SECONDS_PER_DAY = 86400

Divisor turning an mtime delta into whole days.

repomatic.humanize.format_file_size(size_bytes)[source]

Format a byte count as a human-readable string.

A thin binding of click_extra.format_size() to the JEDEC unit style (binary powers with the customary KB/MB symbols), matching the format produced by calibreapp/image-actions.

Return type:

str

repomatic.humanize.format_age(mtime)[source]

Format a file mtime as a human-readable age string.

Rounds down to whole days, since the cache tables it feeds exist to answer “is this stale?”, not to time anything precisely.

Parameters:

mtime (float) – POSIX timestamp, as returned by Path.stat().st_mtime.

Return type:

str

Returns:

"today", "1 day", or "{n} days".

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.

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.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.

Every component repomatic init accepts is declared in COMPONENTS, which carries each one’s description, default scope and target paths; repomatic init --help lists them. That tuple is the only roster: a list repeated here would silently fall behind it.

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, ...] = ('claude.md', 'release.yaml', 'vt-trend-chart.js')

Bundled 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. claude.md is the reference document render_agent_md() reads to project its audience-tagged sections into a downstream repository’s own claude.md; what lands there is a filtered overlay, never this file entire. 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. vt-trend-chart.js is the detections-chart script repomatic.binaries_page.render_chart_section splices into docs/binaries.md with its payload placeholders filled. 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', 'actionlint.yaml': None, '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', 'claude.md': None, 'coverage.toml': None, 'debug.yaml': '.github/workflows/debug.yaml', 'docs.yaml': '.github/workflows/docs.yaml', 'labels.toml': 'labels.toml', 'labels.yaml': '.github/workflows/labels.yaml', 'lint.yaml': '.github/workflows/lint.yaml', 'lychee.toml': None, 'mdformat.toml': None, 'metrics.yaml': '.github/workflows/metrics.yaml', 'mypy.toml': None, 'pytest.toml': None, 'release.yaml': None, 'ruff.toml': None, 'tests.yaml': '.github/workflows/tests.yaml', 'typos.toml': None, 'unsubscribe.yaml': '.github/workflows/unsubscribe.yaml', 'uv.toml': None, 'vt-trend-chart.js': 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.export_content(filename)[source]

Get the content of any exportable bundled file.

Parameters:

filename (str) – The filename (like “ruff.toml” or “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 (like "ruff" or "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

repomatic.init_project.resolve_default_pin(config, *, repo='kdeldycke/repomatic', today=None, warnings=None, floor=None)[source]

Resolve the upstream pin, holding a fresh release back by cooldown.

Returns the (version, commit_sha) init stamps into thin-caller uses: refs. In the common case, and on any datasource failure, this is the running repomatic version paired with its build-time SHA. Only when adopting a release still inside the [tool.repomatic] minimum-release-age window does the pin step back to the newest cooldown-cleared release (see _select_cooldown_pin()), resolving that tag’s SHA afresh.

Important

The cooldown may hold back an adoption. It may never rewrite a pin the repository already carries, which is what floor records.

init is the only writer of these refs: sync-action-pins skips every slug in UPSTREAM_REPO_SLUGS, and ACTION_PIN_RE does not even match a subpath-carrying reusable-workflow ref. So a downstream repository adopts a new repomatic release exactly one way: a human moves the pin, by hand or by running a newer init. Two things follow.

A pin equal to the running version is not a decision to gate. The CI sync-repomatic job runs init at the pinned version itself, so base equals floor on every sync; re-judging it there downgrades the repository once a week after each hand-bump, and fights the only upgrade path there is.

A pin below the running version is a skew. init renders caller content from the running version, so a ref naming an older release ships that content against an older reusable-workflow surface, which GitHub rejects as soon as the two disagree (see test_thin_caller_workflow_call_inputs_stay_minimal). Returning such a pin is therefore only half a decision: run_init() reads it back and, when the repository already carries workflows, skips regenerating them so the tree stays coherent at the pin it keeps. A first-time adoption has no tree to keep, so there the skew stands as the only alternative to writing no workflows at all.

Parameters:
  • config (Config) – Repomatic config supplying the minimum-release-age window.

  • repo (str) – Upstream owner/repo whose releases gate the pin.

  • today (date | None) – Reference date for the cooldown; defaults to the current UTC date.

  • warnings (list[str] | None) – When provided, a cooldown note is appended here (in addition to being logged), so run_init can surface it in the final init summary rather than only mid-run.

  • floor (UpstreamRefPin | None) – The highest upstream pin already committed downstream, from _highest_upstream_pin(). None for a repository carrying none, the one case the cooldown may step back freely.

Return type:

tuple[str, str | None]

Returns:

(version_pin, commit_sha). commit_sha is None when no SHA can be resolved, leaving a bare tag pin.

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.prune_paths(paths, output_dir, *, prune_parents=True)[source]

Delete every path of an InitResult report section.

The deleting half of init’s delete flags (--delete-excluded, --delete-unmodified, the removed-asset pruning), kept beside run_init(), which produced the paths: the CLI decides which sections get deleted, this module owns the filesystem mutation.

Parameters:
  • paths (Sequence[str] | Sequence[tuple[str, str]]) – Bare relative paths, or (path, successor) pairs for the removed-asset sections.

  • output_dir (Path) – Repository root the paths are relative to.

  • prune_parents (bool) – Also remove parent directories left empty. On for the removed-asset and excluded sections, whose targets sit in directories repomatic itself created (.claude/skills/<name>/). Off for unmodified tool configs, which share .github/ and the repository root with files repomatic does not own.

Return type:

None

repomatic.init_project.adopted_ongoing_configs(output_dir)[source]

Return the ongoing tool configs whose section pyproject.toml already carries.

EXPLICIT governs adoption, not upkeep: it keeps a bare init from pushing [tool.typos] onto a repository that never asked for one. Once the section is there the repository has asked, so an ONGOING component rejoins the bare-init set and resumes tracking the bundled template.

Without this the two flags cancel out. The only sync that ever runs unattended is the bare init the sync-repomatic job calls, so an ONGOING section is otherwise re-derived only when a human types its component name, and a [tool.typos] written by hand sits indefinitely beside a bundled template it never adopts a single rule from.

BOOTSTRAP components stay out: their template is a starting point the repository owns outright after the first write, and re-selecting one would revert deliberate local edits.

Parameters:

output_dir (Path) – Repository root holding pyproject.toml.

Return type:

set[str]

Returns:

Component names to add to a bare init selection. Empty when the file is absent, unparsable, or carries no [tool] table.

repomatic.init_project.run_init(output_dir, components=(), version=None, cooldown=True, 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 (like v5.10.0). When None, derived from the running package version, gated by cooldown.

  • cooldown (bool) – When True (and version is unset), hold the derived pin back to the newest release past the [tool.repomatic] minimum-release-age window instead of pinning a fresh running version (see resolve_default_pin()). Ignored when version is explicit.

  • 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.

  • config (Config | None) – The resolved [tool.repomatic] configuration. Loaded from the current directory when omitted, so a caller working against another tree must pass the config it read from there.

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.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.

Every copied file is recorded on result by its own relative path, the way the skills and agents trees already are. The roll-up stays a log line: those lists are consumed as paths (--delete-excluded joins them against output_dir), so a "awesome-template (12 files)" summary sitting among them would be a path that resolves nowhere.

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.labels module

Repository label management.

The label domain in one place: matching an issue or pull request against the [tool.repomatic.labels] rules to decide which labels it earns, and applying label definitions to a repository through labelmaker. Backs the apply-labels and sync-labels commands.

A rule is one label mapped to a list of patterns: regexes or keywords over the thread’s text (DEFAULT_CONTENT_RULES), globs over a pull request’s changed paths (DEFAULT_FILE_RULES). Any pattern matching applies the label. A project entry for a label replaces the default entry wholesale, and an empty list disables it; see resolve_content_rules().

Note

This schema replaced the actions/labeler v5 and github/issue-labeler dialects when the matching moved in-tree. The retired shapes earned their complexity serving the actions (per-matcher quantifiers, branch regexes, any/all group nesting, per-pattern AND-joins), and none of it was used by any repository this toolkit manages: every real rule was “label X when any changed file matches any of these globs” or “when any of these words appears”, which is exactly what the schema now says and nothing more.

repomatic.labels.DEFAULT_CONTENT_RULES: dict[str, tuple[str, ...]] = {'🆙 changelog': ('change-log', 'changelog'), '🐛 bug': ('bug', 'error', 'exception', 'fix', 'traceback'), '📚 documentation': ('docstring', 'license', 'mailmap', 'markdown', 'readme', 'sphinx', 'typo'), '🔗 dependencies': ('.lock', 'pyproject.toml'), '🤖 ci': ('.github', 'actions', 'ci-cd', 'cicd', 'coverage', 'gitignore', 'workflow')}

Default content rules: keywords matched against a thread’s title and body.

Every entry is a plain keyword, compiled case-insensitively with word boundaries on its word-character edges (see compile_content_pattern()), so Bug matches and prefix does not trip fix. The keys must name labels that repomatic/data/labels.toml defines, or the labelling call fails on a label GitHub does not have; tests/test_labels.py enforces that.

Tune for precision, not recall: a missing label costs one manual click, a wrong one is noise on every issue that trips it. Never key a rule off a token the project prints in its own output, or a user pasting a trace sets every label at once.

Note

💖 sponsor deliberately has no rule here, nor in DEFAULT_FILE_RULES. It means “a sponsor is involved”, which is a fact about the author that only the GraphQL sponsorship query can establish, and sponsor-label applies it from exactly that. Matching the words “funding” or “sponsor” (or a pull request touching .github/funding.yml) labels the topic instead, so anyone opening “Add a funding.yml” read as a sponsor. Precision-first means no rule beats an ambiguous one when an authoritative source already exists.

repomatic.labels.DEFAULT_FILE_RULES: dict[str, tuple[str, ...]] = {'🆙 changelog': ('.github/workflows/changelog.yaml', '.github/workflows/release.yaml', 'changelog.md'), '📚 documentation': ('.github/code-of-conduct.md', '.github/workflows/docs.yaml', '.mailmap', 'docs/**/*', 'license', 'readme.md'), '🔗 dependencies': ('*.lock', '**/pyproject.toml'), '🤖 ci': ('.github/**/*', '.gitignore', 'pyproject.toml')}

Default file rules: globs matched against the paths a pull request changes.

The dialect is minimatch’s (see GLOB_FLAGS): ** crosses directories, {a,b} expands, a leading ! subtracts from the label’s other globs, and a leading dot is matched like any other character. Keep the globs precise: one broad enough to catch unrelated changes mislabels every pull request touching them.

repomatic.labels.CONTENT_PATTERN_RE = re.compile('^/(?P<body>.*)/(?P<flags>[a-z]*)$', re.DOTALL)

The /body/flags spelling a content pattern may take, mirroring JavaScript.

Matching this shape is what makes a pattern a regex: the body is passed to re as written, and only the flags named between the slashes apply, so a bare /foo/ is case-sensitive. A pattern not in this shape is a literal keyword instead, escaped and word-anchored and always matched case-insensitively (see compile_content_pattern()). So the slashed form is the one to reach for when a rule genuinely needs regex syntax, and the one that has to spell i out to get back the case-insensitivity the bare form gives for free.

repomatic.labels.CONTENT_PATTERN_FLAGS: dict[str, int] = {'i': re.IGNORECASE, 'm': re.MULTILINE, 's': re.DOTALL}

JavaScript regex flags with a Python equivalent, and their translation.

The rest of JavaScript’s set is accepted and ignored rather than rejected: g and y govern stateful iteration that a single membership test never reaches, u and v describe a Unicode mode Python’s re is always in, and d only adds capture-group offsets nobody here reads. Refusing them would fail a rule over a flag that changes nothing about whether it matches.

repomatic.labels.GLOB_FLAGS = 33608

wcmatch flags reproducing the minimatch dialect actions/labeler used.

GLOBSTAR gives ** its cross-directory meaning, BRACE expands {a,b}, and NEGATE honours a leading !. The two worth spelling out:

  • DOTGLOB, because actions/labeler passed {dot: true} and half the globs a repository cares about start with a dot (.github/**/*). Without it a workflow change matches nothing.

  • NEGATEALL, because minimatch reads a lone !**/*.md as “everything that is not markdown”, while wcmatch defaults to matching nothing at all when no positive pattern accompanies the exclusion.

repomatic.labels.INLINE_LABEL_FIELDS: tuple[str, ...] = ('name', 'color', 'description', 'create', 'update', 'enforce-case', 'rename-from', 'on-rename-clash')

Per-label fields of labelmaker’s specification, in its documented order.

serialize_inline_labels passes them through verbatim (colors get their leading # stripped), so declarative renames and the other per-label knobs ride the regular sync.

rename-from is the one field with a constraint worth knowing before use: it is strictly one-to-one, renaming only when the target is absent and exactly one listed source exists. It therefore cannot merge several labels into one, and is useless once a sync has already created the target. See “Retiring a label is a migration, not a deletion” in claude.md.

repomatic.labels.resolve_content_rules(config=None)[source]

The content rules in force: bundled defaults overlaid with the project’s.

Parameters:

config (Config | None) – The resolved [tool.repomatic] configuration, or None for the bundled defaults alone.

Return type:

dict[str, tuple[str, ...]]

Returns:

Patterns keyed by label.

repomatic.labels.resolve_file_rules(config=None)[source]

The file rules in force: bundled defaults overlaid with the project’s.

Parameters:

config (Config | None) – The resolved [tool.repomatic] configuration, or None for the bundled defaults alone.

Return type:

dict[str, tuple[str, ...]]

Returns:

Glob patterns keyed by label.

repomatic.labels.compile_content_pattern(pattern: str) Pattern[str] | None[source]

Compile one content pattern, as a keyword or a /body/flags regex.

Memoized: the rule tables hand the same patterns to every matching call, so each spelling compiles once per process (and a malformed one is warned about once instead of on every thread it is matched against).

A bare pattern is a literal keyword: escaped, matched case-insensitively, and word-anchored on each edge that is itself a word character, so fix does not fire inside prefix while .lock still matches the tail of uv.lock (a \b before the dot would demand a word character ahead of it). Case-insensitivity is the point of defaulting this way: users capitalize freely, and a convention every rule must remember to spell is a convention half of them forget.

The /body/flags form passes the body through as a regex, mirroring JavaScript because that is what the retired github/issue-labeler action read and what existing rules are written in. No flags means case-sensitive.

Returns None on a body the re module rejects, having logged it. A single malformed rule must not take the whole labelling run down with it: the job is a convenience that runs once per opened issue, and the other rules still have work to do.

Return type:

Pattern[str] | None

repomatic.labels.match_content_rules(rules, text)[source]

Return every label with a pattern matching text.

Parameters:
Return type:

set[str]

Returns:

The matching labels.

repomatic.labels.match_file_rules(rules, files)[source]

Return every label whose globs match a changed file.

A label’s globs are evaluated as one set, so a !-negated entry subtracts from its siblings (["docs/**", "!docs/generated/**"] reads the way a .gitignore would) rather than standing alone. A pull request that changes no files matches nothing.

Parameters:
Return type:

set[str]

Returns:

The matching labels.

repomatic.labels.serialize_inline_labels(entries)[source]

Serialize [tool.repomatic.labels.extra] entries to a labelmaker TOML config.

Each entry becomes a [[profiles.default.labels]] block under the default profile, carrying every per-label field of labelmaker’s specification (INLINE_LABEL_FIELDS): a rename-from list renames a label in place on GitHub, preserving its issue and PR associations, and the create, update, enforce-case and on-rename-clash knobs pass through alike. Leading # on hex colors is stripped, on both single colors and multi-color lists, so the output matches labelmaker’s convention.

Entries missing a name are skipped with a warning, and unknown fields are dropped with a warning: labelmaker rejects both and would abort the whole sync.

Returns an empty string when there are no valid entries, so the caller can skip writing a temp file and invoking labelmaker entirely.

Return type:

str

repomatic.labels.apply_labels(config, repository, *, is_awesome, labels_dir=None)[source]

Apply every configured label source to repository via labelmaker.

Applies, in order: the exported labels.toml under the default profile, the awesome profile for awesome-* repositories, any hand-written or downloaded files under extra-labels/, and the inline [tool.repomatic.labels.extra] definitions. The exported files are expected to exist already (written by run_init() for the labels component).

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

  • repository (str) – GitHub repository in owner/name form.

  • is_awesome (bool) – Whether the repository is an awesome-* list.

  • labels_dir (Path | None) – Directory holding the exported labels.toml and the extra-labels/ downloads. Defaults to the current directory. Point it at a scratch directory to keep the export out of the working tree.

Raises:

RuntimeError – When a labelmaker invocation fails.

Return type:

None

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.

Every check returns a CheckResult, whose tri-state passed flag distinguishes success, failure, and skipped/indeterminate outcomes uniformly.

repomatic.lint_repo.DOCS_URL_KEYS = ('documentation', 'docs')

Keys in [project.urls] naming the published documentation site.

Checked in priority order, and looked up in a lowercased index of the project’s own keys: PEP 621 leaves the spelling to the project, so Documentation, documentation and Docs all occur in the wild. Mirrors the same convention _SOURCE_URL_KEYS in repomatic.pypi applies to the PyPI copy of the same mapping.

repomatic.lint_repo.WORKFLOW_DIR = PosixPath('.github/workflows')

Directory every workflow check walks.

Derived from the registry constant repomatic init deploys against, so the checks and the generator can never disagree about where a workflow lives.

repomatic.lint_repo.RELEASE_DOWNLOAD_RE = re.compile('/releases/(?:download/(?P<tag>[^/\\s\\")]+)|latest/download)/(?P<filename>[^/\\s\\")]+)')

A GitHub release asset URL, capturing its tag and filename.

Matches both spellings a guide can use: the tag-pinned /releases/download/<tag>/<file> the release freeze writes, and the versionless /releases/latest/download/<file> alias the binary aliases exist to serve, which names no tag and so leaves tag unset.

Covering only the first made the check a no-op on a guide written entirely against the alias, which is precisely where a filename rots unnoticed: the freeze rewrites a pinned tag every release and would surface a bad name, while an alias URL is never touched again after it is written. meta-package-manager renamed its binaries from mpm-* to meta-package-manager-* in 7.0.0 and its install guide kept advertising the old name for six weeks.

Matches any release download link, not only a binary one, so the install guide’s whole download surface is verified with a single pattern. Both groups stop at a quote, whitespace or a closing parenthesis, covering an HTML src, a Markdown link target and a bare URL in prose alike.

repomatic.lint_repo.MAX_REPORTED_DEAD_URLS = 5

How many abandoned redirect sources a failing _redirects check names.

Enough to recognize which part of the file fell off the end, short of dumping a tail that can run to hundreds of rules into one lint message. The remainder is counted rather than listed, and the fix is the same reorder either way.

repomatic.lint_repo.PR_TEMPLATE_DIR = PosixPath('.github/pr-templates')

Canonical home for a repository’s own pr-body --template-file templates.

.github/ already namespaces by subdirectory (ISSUE_TEMPLATE/, workflows/, actions/), and a dedicated one leaves each template’s basename free to carry the operation name, so it can match its job ID and PR branch. Templates sitting flat in .github/ need a pr- prefix purely to disambiguate, which breaks that identity and puts them next to GitHub’s own pull_request_template.md, an unrelated human-facing file.

repomatic.lint_repo.PYTHON_CLASSIFIER_PREFIX = 'Programming Language :: Python :: '

Prefix of the classifiers naming a supported interpreter version.

Only the dotted ones carry a version: the bare 3 and 3 :: Only state the major series, and Implementation :: CPython the interpreter.

repomatic.lint_repo.KNOWN_RUNNERS = frozenset({'macos-26', 'macos-26-intel', 'ubuntu-26.04', 'ubuntu-26.04-arm', 'windows-11-arm', 'windows-2025'})

Every runner image this project has deliberately chosen.

The closest thing to a curated list of images a project should be running on, and the one place carrying measured guidance on their relative speed and cost. A job naming something outside it has been picked without that guidance.

The test axes are the whole list: every job runs on an image the suite is also validated against, so “where is the suite exercised” and “what may a job run on” are one question. That is deliberate, since each extra image is one more to track, pin and migrate. A job needing something else is a decision to make explicitly, by widening the axes rather than by naming an image here.

repomatic.lint_repo.TEMPLATE_FILE_ARG_RE = re.compile('--template-file[=\\s]+(?P<path>\\S+)')

A repomatic pr-body --template-file argument inside a workflow run: block.

Matched against the raw YAML text rather than the parsed document: the argument sits inside a folded scalar, so the surrounding run: value is one opaque string whichever way the file is parsed.

class repomatic.lint_repo.CheckResult(passed: bool | None, message: str)[source]

Bases: NamedTuple

Outcome of one repository check.

passed is tri-state: True on success, False on failure, None when the check could not run or does not apply (skipped). message is the human-readable line for both terminal output and annotations.

Create new instance of CheckResult(passed, message)

passed: bool | None

Alias for field number 0

message: str

Alias for field number 1

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. Both are None when the repository could not be read.

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:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.documentation_url(project_urls)[source]

The documentation site a project declares in [project.urls].

Parameters:

project_urls (Mapping[str, str] | None) – The [project.urls] mapping, keys untouched.

Return type:

str | None

Returns:

The first URL found per DOCS_URL_KEYS, or None when the project declares none.

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

Check that a Sphinx project’s website field names its documentation.

GitHub renders the website field in the repository sidebar, and for a project publishing Sphinx documentation that is where a visitor expects to land. So the check has two halves: the field is set at all, and it names the site the project itself declares under DOCS_URL_KEYS.

The second half is what a documentation move leaves behind. Sphinx emits <link rel="canonical"> from html_baseurl, and a conf.py commonly derives that from the same [project.urls] entry, so a project that moves to a new domain has every published page naming the new origin as canonical while the sidebar keeps sending visitors to the one it replaced. Nothing but a reader noticing connects the two.

Note

A project declaring no documentation URL gets the presence half only. The comparison needs the project to have named an expected answer, and nothing here invents one from the repository slug.

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).

  • docs_url (str | None) – Documentation URL declared in [project.urls].

Return type:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_install_guide_downloads(repo)[source]

Check the install guide’s release download URLs still resolve.

The release freeze pins those URLs to the version being released, but it runs before the binaries exist: the freeze commit is what triggers the build. So the pin is optimistic, and a release whose binary lane fails leaves the guide advertising files that 404 until the next release ratchets past it. 7.7.0 shipped that way, with all six links dead.

A versionless latest/download alias fails a different way, and stays broken longer: nothing rewrites it at release time, so it silently outlives a renamed asset instead of being re-pinned every cycle. Both forms are checked, see RELEASE_DOWNLOAD_RE.

Nothing static can catch either: the URLs are well-formed and correct on disk, and only the release’s actual asset list settles whether they resolve. Hence a lint check against the API rather than a conformance test.

Reports rather than repairs, per claude.md § Skip and move forward: the fix is a one-liner (freeze_install_download_urls() re-pointed at the last release that carries binaries), while an automated rewrite driven by one API read could downgrade a healthy install page on a flaky response.

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (API inaccessible, unparsable, or unknown policy).

repomatic.lint_repo.check_sha_pinning_required(repo)[source]

Check that GitHub Actions must be pinned to a full-length commit SHA.

GitHub has a per-repository policy, sha_pinning_required, that makes the platform itself refuse to run any workflow referencing an action by a mutable tag or branch instead of a commit SHA. repomatic already pins every action it generates and checks unpinned refs with zizmor (check_inline_pins_match_upstream and the lint-zizmor job), but a zizmor finding can be silenced inline (# zizmor: ignore[...]), so a hand-edited workflow could still slip a mutable tag past review. This repo-level setting is the platform-enforced backstop.

Queries GET /repos/{repo}/actions/permissions and returns False when sha_pinning_required is absent or false.

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:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (API inaccessible or unparsable).

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult. passed is None when the rulesets API could not be read, matching check_tag_protection_rules(), which reads the same payload.

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:

CheckResult

Returns:

A CheckResult. passed is None when 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:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (Pages not configured, or API inaccessible).

repomatic.lint_repo.check_pages_redirect_preserved(repo, docs_url)[source]

Check that the old github.io URLs still redirect to the live site.

A repository whose site moved to Cloudflare Pages keeps its <owner>.github.io/<repo>/… URLs answering through a single field: the GitHub Pages custom domain. Set it, and GitHub redirects that whole space with a path-preserving 301, for free, covering paths the site never even had. What it rescues is precisely the set of URLs nobody can rewrite: search indexes, other projects’ readmes, and the [project.urls] metadata frozen into every release already published.

Two ways to lose it, both invisible from inside the repository. Disabling Pages deletes the redirect along with the site, and every historical link starts answering 404 with nothing to show a maintainer why. Leaving the custom domain unset is quieter still: the old host keeps serving a copy of the documentation, which stops being rebuilt the moment the deploy job is gated off, so the two hosts disagree more with every release.

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

  • docs_url (str | None) – Documentation URL declared in [project.urls], whose host is what the custom domain must name.

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the repository has no legacy Pages URLs to preserve, or the declared URL is unreadable.

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:

CheckResult

Returns:

A CheckResult.

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:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_workflow_permissions(workflows=None)[source]

Check workflow permissions declarations for least privilege.

Two failure modes are flagged:

  1. A workflow that defines its own steps: should carry a top-level permissions key (permissions: {} for least privilege) so its jobs default to no scopes rather than the repository default.

  2. A job that calls a reusable workflow (a job-level uses:) hands its own permissions down, and the reusable workflow’s jobs are capped by them: they cannot escalate beyond what the caller grants. So under a top-level permissions: {}, a reusable-call job with no permissions: block of its own passes {} to the called workflow, and GitHub aborts the run at startup the moment a nested job requests a scope the caller never granted. Such a job must name the union of the scopes its reusable workflow needs (mirror the reusable workflow’s own top-level {} plus per-job grants).

A thin caller with no top-level permissions key is fine: its jobs inherit the repository default, which the reusable workflow’s own permissions: blocks then cap. The failure is specifically an empty top-level permissions: {} starving an unqualified reusable call.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

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[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_python_version_consistency(workflows=None)[source]

Reconcile the Python versions a project requires, advertises and tests.

The same fact is stated in up to three places, and nothing else holds them together: the requires-python lower bound, the Programming Language :: Python :: X.Y classifiers PyPI renders, and any test matrix naming its versions literally.

Two failure modes are flagged:

  1. The lowest classifier disagrees with the requires-python floor. One of the two is then lying to resolvers about what installs.

  2. A literal test matrix does not reach both ends of the advertised range, or names a released version the classifiers never claim. Coverage of the ends is the invariant rather than of every version in between, so that a matrix testing the floor, the latest release and the development version stays conformant: skipping intermediate releases is a deliberate way to cut CI load, advertising an untested boundary is not.

Versions in UNSTABLE_PYTHON_VERSIONS are exempt from the second rule, being tested precisely because they are not released yet and so cannot be advertised. Build flavors carrying a suffix (the free-threaded 3.14t) count as their base version.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.literal_runners(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]

Every runner image this repository names outright, and where.

Only literals: a value built from an expression (${{ matrix.os }}) names no image here, and the axis it draws from is checked at its definition. A thin caller declares no steps: and runs on whatever the reusable workflow chose, which is that workflow’s business rather than this repository’s.

Separate from KNOWN_RUNNERS, and deliberately so. That set is what this project has chosen; this function reports what it is running, and the two diverge exactly when something has been left behind. Callers wanting “an image this repository has a stake in” need the union of both.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow files. Ignored when workflows is supplied.

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

dict[str, list[str]]

Returns:

A mapping of runner label to the file.yaml:job-id locations naming it, empty when no job names one literally.

repomatic.lint_repo.check_runner_images(workflows=None)[source]

Flag runner images that move on their own, or that no axis knows about.

Neither Dependabot nor sync-workflow-pins touches a runs-on: value: the first only rewrites uses: references, the second only the uvx '<pkg>==X.Y.Z' and npm install pkg@X.Y.Z literals. So a runner is the one dependency in a workflow that nothing bumps, and the only defence is keeping the set small and named.

Two failure modes are flagged:

  1. A -latest alias. GitHub repoints those to a new image on its own schedule, so the build changes underneath the repository with no commit to review, and a breakage arrives unattached to any change.

  2. An image outside the curated axes in repomatic.matrix_axes. Those carry measured guidance on speed and cost; an image picked outside them is one nobody has weighed, and is usually a leftover.

Values built from an expression (${{ matrix.os }}) name no image here and are left alone: the axis they draw from is checked at its definition.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

class repomatic.lint_repo.ReleaseGate(name: str, workflow: str, metadata_key: str | None, needs: str)[source]

Bases: NamedTuple

A release-only step or job, and the project capability it needs.

metadata_key names the Metadata field that both decides whether the step runs and supplies what it consumes. None marks a step that needs nothing beyond being on a release commit.

Create new instance of ReleaseGate(name, workflow, metadata_key, needs)

name: str

Alias for field number 0

workflow: str

Alias for field number 1

metadata_key: str | None

Alias for field number 2

needs: str

Alias for field number 3

repomatic.lint_repo.RELEASE_ONLY_GATES: tuple[ReleaseGate, ...] = (('Pre-bake tag SHA', '_release-build.yaml', 'cli_scripts', "a `[project.scripts]` entry, since click-extra's prebake finds the module to stamp through it"), ('📌 Tag release', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('🐍 Publish to PyPI', 'release.yaml', None, 'a wheel from the build lane'), ('🐙 Create GitHub release draft', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('📖 Man pages', '_release-engine.yaml', 'manpages_script', 'a configured man-page script'), ('📎 Extra release assets', '_release-engine.yaml', 'release_assets', 'at least one configured extra asset'), ('🎉 Publish GitHub release', '_release-engine.yaml', None, 'nothing beyond the release commit'))

Every step and job that runs only on a release commit.

These are invisible on an ordinary push: each is gated behind a condition that holds open only for the one commit that tags, publishes and releases. A project can therefore build green for its entire life and meet them for the first time on release day, where a failure costs a reverted release rather than a red push.

VirusTotal scan is deliberately absent: it gates on a repository secret rather than a project capability, so there is nothing in the tree to check it against.

repomatic.lint_repo.check_release_path(workflows=None)[source]

Resolve the release path against this project, on an ordinary push.

Two arms, because the two failure modes live in different repositories.

The first runs everywhere, including downstream. It resolves each entry of RELEASE_ONLY_GATES against the local project and reports which release-only steps a release commit would actually run. That turns a surface nothing exercises until release day into a line of output on every push, so the answer is known long before it is expensive.

The second runs only where the reusable workflows live, since a downstream repository holds a thin caller and not the steps themselves. It asserts each gate’s if: really does test the metadata key its step depends on. That invariant is what a release-only step gets wrong: the condition looks complete because it correctly waits for a release, while saying nothing about the capability the step consumes. Pre-bake tag SHA shipped that way, gated on the version alone, and every project with no [project.scripts] built green until the release commit ran prebake against a module that was not there.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_inline_pins_match_upstream(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', texts=None)[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. Ignored when texts is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the inline package to match (like repomatic).

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_self_pin_cooldown_exemption(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', texts=None)[source]

Check every inline upstream pin carries its cooldown exemption.

A workflow pinning the upstream toolkit in a run: command (uvx 'repomatic==1.2.3' metadata) resolves under the workflow-wide UV_EXCLUDE_NEWER, and that pin moves in lockstep with the uses: refs, so it routinely names a release published hours ago. Without SELF_PIN_COOLDOWN_EXEMPTION on the command line, uvx cannot resolve it at all. uvx reads no project configuration, so there is nowhere else the bypass could live.

The failure is total rather than partial, which is why this is worth a dedicated check: the pin usually sits in the metadata job, every other job is needs: metadata, and the whole workflow reports failure while executing nothing. Downstream repos are the exposed ones. tests/test_workflows.py pins the canonical workflows, sync-workflow-pins splices a missing flag in on any run that also moves the version, and a repo already pinned at the newest release falls through both.

Only flags a pin under a workflow that actually sets a cooldown: a repo without one has nothing to exempt.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when texts is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the inline package to match (like repomatic).

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_setup_uv_version_pin(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]

Check every astral-sh/setup-uv step pins the uv version it installs.

[tool.uv] required-version is a floor for everyone; what a runner downloads is a separate question, and left to setup-uv the answer is “the newest release satisfying the floor”, installed seconds after it lands. That makes the tool enforcing every cooldown the one tool without one, so each step carries with: version: "X.Y.Z" and sync-workflow-pins walks it forward once a uv release clears minimum-release-age.

Steps naming two different versions in one repository are flagged too: the pin exists so every job resolves through the same uv, and a split fleet silently tests two.

Reads the parsed workflow rather than its text: a with: input is a plain mapping, so the step a pin belongs to is a fact the parser already knows. Matching the raw text instead means bounding a step’s block by hand, and a body running past its own step lets one pinned step vouch for every unpinned one above it.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when workflows is supplied.

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.requested_metadata_keys(command, package)[source]

Positional keys a shell command passes to <package> metadata.

Reads the tail of the invocation the way Click would: options are dropped along with the value each one consumes, and what remains are the positional key arguments. Handles both spellings in use, the upstream uv run -- repomatic metadata and the downstream uvx 'repomatic==1.2.3' metadata , by looking for the subcommand after any token naming the package.

Shared with repomatic.init_project, which asks the same question of a downstream checkout at sync time rather than of this repository at lint time. One parser, so the two verdicts cannot disagree about what a run: line requests.

Parameters:
  • command (str) – The step’s run: script, folded or literal.

  • package (str) – Upstream package name (like repomatic).

Return type:

list[str]

Returns:

The key names requested, in the order written.

repomatic.lint_repo.check_metadata_keys(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', workflows=None)[source]

Check the metadata keys workflows request still exist.

A downstream repository owns the job bodies of its header-only workflows: repomatic init syncs their name, on and concurrency blocks and the uses: pins, and never touches the steps below. So a key retired upstream keeps being asked for by a run: line nothing sweeps, and the metadata command answers a retired key with a UsageError. Since every other job in a test workflow reaches it through needs:, the whole run dies at the first job, on the next push, from a workflow file that looks freshly synced.

That is not hypothetical: coverage_cells went away with the Codecov integration and took a downstream test workflow down with it. Failing here instead moves the report to lint time, where it names the file and the job.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when workflows is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the package whose metadata invocations are read (like repomatic).

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_pr_templates(workflow_dir=PosixPath('.github/workflows'), template_dir=PosixPath('.github/pr-templates'), texts=None)[source]

Check a repository’s own pr-body --template-file templates.

A repo with a custom PR-opening job ships the body as a file of its own rather than adding a template upstream. Three failure modes are flagged:

  1. The file sits outside template_dir. See PR_TEMPLATE_DIR.

  2. A workflow references a path that does not exist, which the job only discovers when it runs and pr-body rejects the missing file.

  3. The frontmatter lacks a title, or does not set footer to the bare boolean false. Both false and the quoted 'false' opt out, but an absent field, 'False', and every other value do not, and the failure is silent: the rendered body carries the attribution footer twice.

A docs field is not required. It deep-links the hosted workflows reference, which documents upstream jobs only.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when texts is supplied.

  • template_dir (Path) – Directory the templates are expected to live in.

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

class repomatic.lint_repo.LintContext(package_name=None, repo_name=None, is_package=False, is_sphinx=False, site_deploy='github-pages', site_cloudflare_project='', site_cloudflare_compatibility_date='', project_description=None, docs_url=None, keywords=None, repo=None, has_pat=False, has_virustotal_key=False, has_cloudflare_api_token=False, nuitka_active=False, has_notifications_pat=False, unsubscribe_active=False)[source]

Bases: object

Everything the checks read, resolved once per lint-repo run.

package_name: str | None = None

The Python package name.

repo_name: str | None = None

The repository name.

is_package: bool = False

Whether the project builds a distributable package.

Per repomatic.pyproject.is_python_package(). Gates the checks that only make sense for something actually published to PyPI.

is_sphinx: bool = False

Whether the project uses Sphinx documentation.

site_deploy: str = 'github-pages'

Where the repository’s built site publishes, per site.deploy.

Each host has its own prerequisite, and exactly one of them applies: the GitHub Pages source check reads a 404 forever on a Cloudflare-hosted project, and the Cloudflare credential check has nothing to say about a project deploying with the repository’s own OIDC identity. The credential check follows the declared target alone, Sphinx or not: a site built by the repository’s own workflow needs the same secrets the Docs workflow would.

site_cloudflare_project: str = ''

Cloudflare Pages project name override, per site.cloudflare-project.

Empty means the project is named after the repository, the deploy job’s own fallback.

site_cloudflare_compatibility_date: str = ''

Declared Workers runtime date, per site.cloudflare-compatibility-date.

project_description: str | None = None

Description from pyproject.toml.

docs_url: str | None = None

Documentation site declared in [project.urls], per DOCS_URL_KEYS.

keywords: list[str] | None = None

Keywords list from pyproject.toml.

repo: str | None = None

Repository in owner/repo format.

has_pat: bool = False

Whether GH_TOKEN contains REPOMATIC_PAT.

has_virustotal_key: bool = False

Whether VIRUSTOTAL_API_KEY is configured.

has_cloudflare_api_token: bool = False

Whether CLOUDFLARE_API_TOKEN is configured.

nuitka_active: bool = False

Whether this project compiles binaries with Nuitka.

has_notifications_pat: bool = False

Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

unsubscribe_active: bool = False

Whether the unsubscribe workflow is opted in.

property repo_metadata: dict[str, str | None][source]

The repository’s GitHub-side description and homepage.

Fetched once and shared by the checks that compare a pyproject.toml field against it. An absent repository answers empty rather than failing, so those checks report a miss instead of the run dying.

property redirects_files: list[Path][source]

Committed Cloudflare Pages _redirects files, .gitignore honoured.

The gitignore filter is what keeps a generated site tree (an output/ or docs/_build/ copy of the same file) out of the audit: the engine replica must read the source of truth, not a build artifact of it.

property has_wrangler_toml: bool[source]

Whether the repository commits a root-level wrangler.toml.

property workflow_texts: dict[Path, str][source]

Every workflow file’s raw text, read once for the whole run.

Half the roster walks .github/workflows/: three checks match the files as written and six parse them. Reading once here spares each its own directory walk, the same way repo_metadata pools the GitHub lookup.

property workflows: dict[Path, dict][source]

The parsed jobs-bearing workflows, from workflow_texts.

deploys_to(target)[source]

Whether this repository publishes its site to target.

Mirrors repomatic.setup_guide.GuideContext.deploys_to(), so the audit and the guide agree on which host a repository is on. The GitHub Pages half stays gated on Sphinx, the only tree the Docs workflow knows how to publish there; the Cloudflare half follows the declaration alone, since a site built by the repository’s own workflow still needs the project and the credential.

Return type:

bool

class repomatic.lint_repo.RepoCheck(name, run, applies=<function RepoCheck.<lambda>>, fatal=False)[source]

Bases: object

One entry of the lint-repo check sequence.

The sequence used to be twenty-five hand-numbered if blocks whose comment numbering had degraded to Check 10b-quater, and two checks this module defines were never reached by it at all. Declaring each check once makes the roster the thing tests and readers walk.

name: str

Stable identity, for tests and for grepping the roster.

run: Callable[[LintContext], CheckResult | Iterable[CheckResult]]

Perform the check. May answer one result or a stream of them.

applies()

Whether this repository has anything for the check to look at.

fatal: bool = False

Whether a failure fails the command.

A fatal check reports at ERROR and sets the non-zero exit code; every other check is advisory, per claude.md § Defensive workflow design.

results(ctx)[source]

Run the check, normalizing one-or-many into a tuple.

Return type:

tuple[CheckResult, ...]

repomatic.lint_repo.REPO_CHECKS: tuple[RepoCheck, ...] = (RepoCheck(name='package-name-vs-repo', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='website-for-sphinx', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-deployment-source', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='cloudflare-pages-secrets', run=<function _cloudflare_secrets>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirect-preserved', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirects', run=<function _pages_redirects>, applies=<function <lambda>>, fatal=True), RepoCheck(name='wrangler-toml', run=<function _wrangler_config>, applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-gh-pages-branch', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='description-matches', run=<function <lambda>>, applies=<function <lambda>>, fatal=True), RepoCheck(name='topics-subset-of-keywords', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='funding-file', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-draft-releases', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='install-guide-downloads', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='tag-protection-rules', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='branch-ruleset-on-default', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='immutable-releases', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='fork-pr-approval-policy', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='sha-pinning-required', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pypi-trusted-publisher', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='workflow-permissions', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='test-matrix-excludes', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='python-version-consistency', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='runner-images', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='release-path', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='inline-pins-match-upstream', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='self-pin-cooldown-exemption', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='setup-uv-version-pin', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='metadata-keys', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pr-templates', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='virustotal-secret', run=<function _virustotal_secret>, applies=<function <lambda>>, fatal=False), RepoCheck(name='notifications-pat-secret', run=<function _notifications_secret>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-permissions', run=<function _pat_permissions>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pat-repository-scope', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-stale-statuses-permission', run=<function <lambda>>, applies=<function <lambda>>, fatal=False))

Every check lint-repo runs, in report order.

Two of these (branch-ruleset-on-default, immutable-releases) were defined in this module but reached only from repomatic.setup_guide, so lint-repo silently skipped them until the roster made the omission visible.

repomatic.lint_repo.run_repo_lint(package_name=None, repo_name=None, is_package=False, is_sphinx=False, site_deploy='github-pages', site_cloudflare_project='', site_cloudflare_compatibility_date='', project_description=None, docs_url=None, keywords=None, repo=None, has_pat=False, has_virustotal_key=False, has_cloudflare_api_token=False, nuitka_active=False, has_notifications_pat=False, unsubscribe_active=False)[source]

Run all repository lint checks.

Walks REPO_CHECKS, printing each result and emitting its GitHub Actions annotation. Only a check declaring itself fatal can fail the command; everything else is advisory, so a scheduled run stays green on findings a maintainer merely needs to see.

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

  • repo_name (str | None) – The repository name.

  • is_package (bool) – Whether the project builds a distributable package.

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

  • site_deploy (str) – Where the repository’s built site publishes.

  • site_cloudflare_project (str) – Cloudflare Pages project name override.

  • site_cloudflare_compatibility_date (str) – Declared Workers runtime date.

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

  • docs_url (str | None) – Documentation URL declared in [project.urls].

  • 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_cloudflare_api_token (bool) – Whether CLOUDFLARE_API_TOKEN is configured.

  • nuitka_active (bool) – Whether Nuitka binary compilation is active.

  • 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.

repomatic.mailmap.remove_header(content)[source]

Return content without the generated-by comment header and blank lines above.

Strips the metadata block sync-mailmap writes at the top of the file (generated_header output: # Generated by and # Timestamp: lines), so a re-run parses only the identity mappings.

Return type:

str

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. A failing git invocation exits the process with git’s stderr, keeping the CLI’s error output clean.

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.matrix_axes 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.matrix_axes.TEST_RUNNERS_FULL = ('ubuntu-26.04-arm', 'ubuntu-26.04', '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

Preview images are adopted on measurement, not on GitHub’s label

GitHub still marks the Ubuntu 26.04 pair preview, which gates their eligibility to sit behind the -latest aliases. This project never uses those aliases (a floating alias re-points with no commit to review, which check_runner_images() rejects outright), so that distinction does not reach it. An image is treated as stable here once it has been validated against this suite, not once a vendor relabels it. Measured over consecutive runs before the swap, ubuntu-26.04-arm beat ubuntu-24.04-arm by 16% on Python 3.10 and 28% on 3.14, tied on 3.15, and failed nothing.

The residual risk is capacity rather than correctness: GitHub warns a preview image’s capacity “will be balanced only throughout the next weeks”, so queue time may be worse than the runtimes above suggest. Release binaries are built on GA images for that reason, see NUITKA_BUILD_TARGETS.

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 runs two to three times as fast as the lean x86 ubuntu-slim that preceded ubuntu-26.04 on this axis; 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.matrix_axes.TEST_RUNNERS_PR = ('ubuntu-26.04-arm', 'macos-26', 'windows-2025')

Reduced runner set for pull request test matrices.

One runner per platform: ARM Linux (ubuntu-26.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, by two to three times over the x86 image, for quicker PR feedback. See Test matrix for the measurements.

repomatic.matrix_axes.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.matrix_axes.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.matrix_axes.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.matrix_axes.PRERELEASE_LABEL_SUFFIX: Final[str] = '-dev'

Suffix marking an unreleased Python in a CI job name.

Appended to each UNSTABLE_PYTHON_VERSIONS member to form the python-label matrix key, so a continue-on-error cell states why it may fail: ⁉️ ubuntu-26.04 / py3.15-dev rather than a bare py3.15 indistinguishable from a released one. Being a plain suffix append, it composes with the free-threaded flavor the way both tools below spell it: 3.15t reads 3.15t-dev.

The spelling is borrowed, not invented. pyenv ships version definitions named 3.15-dev and 3.15t-dev that build from the CPython branch tip, and actions/setup-python documents an x.y-dev syntax resolving to “the latest patch version of Python, alpha, beta and rc (release candidate) releases included”. Anyone reading a GitHub Actions job name has met it in one of the two.

Warning

A label, never a uv request

uv does not implement the syntax. uv python find 3.15 parses as a version request (“No interpreter found for Python 3.15”), while uv python find 3.15-dev falls through to the executable-name branch (“No interpreter found for executable name 3.15-dev”). The workflow hands python-version straight to uv venv --python, so the axis value stays the bare version and this suffix reaches the job name: alone. Writing it into a [tool.repomatic.test-matrix] directive matches no cell.

repomatic.matrix_axes.SINGLE_RUNNER_PYTHON_VERSIONS: Final[dict[str, str]] = {'3.14t': 'ubuntu-26.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-26.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.matrix_axes.python_version_sort_key(version)[source]

Sort key ordering python-version axis values by release.

Compares on the numeric release components, then places a build flavor (the free-threaded t suffix of SINGLE_RUNNER_PYTHON_VERSIONS) directly after its base version rather than after every later release: 3.14 sorts before 3.14t, which sorts before 3.15. Non-numeric components are dropped, so an axis value like pypy3.10 falls back to the digits it carries.

Parameters:

version (str) – A python-version axis value, like 3.14 or 3.14t.

Return type:

tuple[tuple[int, ...], int]

Returns:

A key tuple suitable for sorted().

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).

Output shapes

Every key is printed to the environment file as one key=value line. Values take three shapes:

is_python_project=true
doc_files="changelog.md" "readme.md" "docs/license.md"
new_commits_matrix={"commit": ["346ce66…", "6f27db4…"], "include": [{"commit": "346ce66…", "short_sha": "346ce66"}]}

A scalar prints bare. A list prints as space-joined, individually quoted items, not a JSON array: workflow if: conditions test membership with a padded contains() against that string. A matrix prints as inlined JSON for fromJSON() to parse into a job matrix. See Metadata.format_github_value() for the encoding, and Dialect for the other output formats.

The full key inventory is generated from this module rather than listed here, so it cannot go stale: run repomatic metadata --list-keys, or read the rendered table in the workflows documentation.

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.

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.METADATA_VALUE_OPTIONS: frozenset[str] = frozenset({'--format', '--output', '--sort-by', '-o'})

Options on the metadata command consuming the token that follows them.

Needed by repomatic.lint_repo.check_metadata_keys() to tell a positional key from an option’s value while reading a workflow’s run: line. The command itself is not importable from there: repomatic.cli reads sys.stdout.name at import time, so importing it under a test that has replaced stdout raises.

Listed here rather than derived, and pinned against the real command by repomatic’s own test suite, so an option added later cannot quietly turn its value into a token the lint reports as an unknown key.

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.

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.

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.

Maps event_name (the GITHUB_EVENT_NAME variable, set by GitHub Actions on every run) onto its WorkflowEvent member, so schedule and workflow_dispatch runs resolve to their own event instead of falling in a None hole that nulls every commit matrix.

Caution

When GITHUB_EVENT_NAME is absent or unrecognized, falls back on the historical heuristic: a non-empty GITHUB_BASE_REF means a pull request (only set for pull request events), a present-but-empty one means a push.

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.

The sender type covers every GitHub App, which is how Dependabot and Renovate author their pull requests today. The explicit login list is kept as a second signal for downstream repositories: sender.type is absent from the event payload outside push and pull_request (and empty when the payload cannot be read at all), and the login is then the only thing left to match on.

The test is deliberately not sender.type != "User", which would also classify an Organization sender as a bot.

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 ("push", "pull_request", "workflow_run"), which event_type resolves to a WorkflowEvent member.

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 files: FileInventory[source]

What this repository holds on disk, .gitignore applied.

The inventory is its own concern (repomatic.file_inventory): answering “which Markdown files are there” needs no CI context, no git history and no pyproject.toml. The groups below forward to it so every existing caller, and every metadata output key, keeps its name.

glob_files(*patterns)[source]

Files matching patterns, per FileInventory.glob_files().

Return type:

list[Path]

gitignore_match(file_path)[source]

Whether .gitignore excludes file_path.

Return type:

bool

property gitignore_exists: bool

Whether a .gitignore file is present.

property python_files: list[Path]

Python sources, notebooks included.

property json_files: list[Path]

JSON files Biome can format.

property yaml_files: list[Path]

YAML files.

property pyproject_files: list[Path]

Every pyproject.toml in the tree.

property workflow_files: list[Path]

GitHub workflow definitions.

property doc_files: list[Path]

Documentation sources.

property markdown_files: list[Path]

Markdown files.

property image_files: list[Path]

Images the optimizer can losslessly shrink.

property shfmt_files: list[Path]

Shell scripts shfmt formats.

property zsh_files: list[Path]

Zsh scripts, by extension or shebang.

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 is_python_package: bool[source]

Returns True if the repository builds a distributable package.

Strictly narrower than is_python_project: a uv virtual project declares a [project] table to carry its dependencies, then opts out of being built with [tool.uv] package = false. Delegates to repomatic.pyproject.is_python_package(), the same predicate PACKAGE_ONLY resolves against, so the release lane and the checks that police it agree on who publishes.

Prefer this over the truthiness of package_name when gating anything about publishing. package_name only reports what [project] name says, which a virtual project still declares.

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 dev_targets: set[str][source]

Nuitka build targets compiled on ordinary (non-release) pushes.

Reads [tool.repomatic].nuitka.dev-targets from pyproject.toml. An empty list disables dev builds entirely. See nuitka_dev_targets for the default and the canary rationale.

Unrecognized target names 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.

Crosses three axes:

  • one commit per release commit (during a release) or per new commit (otherwise)

  • every [project.scripts] entry point

  • every build target of NUITKA_BUILD_TARGETS (runner, platform, architecture, binary extension, and the glibc floor or minimum-OS version that target enforces), narrowed to the [tool.repomatic] nuitka.dev-targets canary subset on an ordinary push (see dev_targets); release commits, schedule and workflow_dispatch runs keep the full roster

Each axis contributes an include entry carrying the extra parameters the compile job needs, keyed on the axis value that selects it: the target’s runner and floors, the entry point’s module and callable, and the commit’s short SHA and version. A final pass adds one include entry per (os, entry_point, commit) triple naming the bin_name the compiled artifact takes, since that name depends on all three at once.

The matrix closes with {"state": "stable"}, which the release workflow reads to decide whether a failing job blocks the release.

Note

Every value comes from NUITKA_BUILD_TARGETS and the project’s own pyproject.toml, so no literal is repeated here: run repomatic metadata nuitka_matrix against a project to see the matrix it computes, or repomatic show-test-matrix for the test one.

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 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_factories()[source]

Lazy value factories for every metadata key, in output order.

Each value is computed only when its key is included, so keys=("is_python_project",) skips nuitka_matrix and the git history walk it pulls in.

Split out of dump() so the key inventory is inspectable without computing anything: tests/test_metadata.py asserts these names match _METADATA_KEY_DESCRIPTIONS plus _metadata_config_fields(), which is what keeps --list-keys, all_metadata_keys() and the emitted output from drifting apart.

Derived from _METADATA_KEY_DESCRIPTIONS rather than re-listing every key: most keys read the attribute of the same name, so only the handful whose value is not a plain attribute carry an explicit factory.

Return type:

dict[str, Callable[[], Any]]

Returns:

Key name to a zero-argument callable producing its value.

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). See dump_factories().

Return type:

str

repomatic.metric_chart module

Draw an accumulated metric history as a standalone, themeable SVG.

Written by hand rather than through a plotting library: the output is committed, so a docs build never needs the dependency, and the file stays a few kilobytes of readable vector.

An SVG rather than a client-side canvas: GitHub strips <script> and <canvas> from rendered Markdown, so a scripted chart is invisible to every reader of the repository, while the third-party embeds these replace were images that rendered there. Committing it also drops the pinned CDN artifact and its subresource-integrity digest, which is the point of moving off a service that died without notice.

repomatic.metric_chart.CHART_MODES = ('absolute', 'relative')

Horizontal axes a chart can measure against.

absolute shares one calendar across every curve, answering when a project gathered its following. relative starts each curve at its own repository’s creation, which is the only origin they all share, so a project that took eight years to reach a figure another hit in two is read at a glance.

Kept separate from CHART_SCALES, which measures the vertical one: a comparison chart routinely wants both, and folding them into a single setting would make each pair of choices a new name.

repomatic.metric_chart.CHART_SCALES = ('linear', 'logarithmic')

Vertical axes a chart can measure against.

linear reads a difference, and is right whenever the series are the same size. logarithmic reads a rate, and is what puts a project of 57 stars on one chart with a peer of 25,000 without flattening it onto the axis: equal slopes mean equal growth in percentage terms, whatever the counts.

A count of zero has no logarithm, and every series carries one, since the day a repository was created is the only date its count is known exactly. So the bottom LOG_ZERO_BAND of the plot is kept linear, spanning nothing but the step from zero to one. The curve then leaves the axis where the first star landed rather than beginning in mid-air or being silently dropped.

repomatic.metric_chart.LABEL_CHAR_WIDTH = 7.6

Pixels a direct label’s average character occupies, for margin arithmetic.

Measured against the 13px semibold system-ui the labels are drawn in. An SVG carries no text metrics and this generator loads no font, so the width of a label can only be estimated: erring high costs a few pixels of plot, erring low clips the name off the edge of the chart.

repomatic.metric_chart.LOG_ZERO_BAND = 0.06

Fraction of a logarithmic plot’s height reserved for the zero-to-one step.

Small enough to read as a baseline rather than as a decade of its own, and large enough that a curve sitting at zero for years is visibly on the floor instead of indistinguishable from one at a count of one.

repomatic.metric_chart.MIN_LABEL_MARGIN = 168

Floor on the right margin, in pixels, whatever the labels measure.

Holds the plot’s proportions steady across the charts a project draws: a single-series chart would otherwise stretch nearly to the edge and read as a different shape from the comparison beside it.

repomatic.metric_chart.SERIES_PALETTE: tuple[tuple[str, str], ...] = (('#2a78d6', '#3987e5'), ('#eb6834', '#d95926'), ('#1baf7a', '#199e70'), ('#eda100', '#c98500'), ('#e87ba4', '#d55181'), ('#8250df', '#a371f7'), ('#0a7c8a', '#22b8cf'), ('#cf222e', '#ff7b72'), ('#5a7f10', '#8fc832'), ('#8a6240', '#c19a6b'), ('#57606a', '#9198a1'), ('#bf3989', '#e878b8'))

Light and dark hex pair per categorical slot, in fixed order.

Assigned positionally and never cycled: a chart declaring more series than there are slots raises rather than reusing a hue, since a repeated colour on a chart whose curves are told apart by colour is a defect the reader cannot see. Override any of them by name through [tool.repomatic.metrics] colors.

A few light-mode steps sit below 3:1 against a white surface. The direct label drawn at the end of every line is what answers that: identity is never colour alone.

class repomatic.metric_chart.ChartSpec(output, metric='stars', mode='absolute', only=(), scale='linear', title='')[source]

Bases: object

One chart a repository asked for.

output: Path

Where to write the rendered SVG.

metric: str = 'stars'

Which accruing metric to plot, from METRICS.

Defaults to the one that motivated the whole collector. Only a metric the store accrues can be charted: an attribute holds a single current value, which is a table cell rather than a curve.

mode: str = 'absolute'

Which of CHART_MODES measures the horizontal axis.

only: tuple[str, ...] = ()

Series to plot, in draw order. Every declared subject when empty.

scale: str = 'linear'

Which of CHART_SCALES measures the vertical axis.

title: str = ''

Accessible name for the chart, describing what it shows.

property logarithmic: bool

Whether the vertical axis measures by powers of ten.

property relative: bool

Whether the horizontal axis measures project age.

classmethod from_mapping(entry)[source]

Build a spec from one [[tool.repomatic.metrics.charts]] entry.

Parameters:

entry (Mapping[str, object]) – The entry as configuration parsed it.

Return type:

ChartSpec

Returns:

The corresponding spec.

Raises:

ValueError – When output is missing, the mode is unknown, or the named metric has no history to chart.

class repomatic.metric_chart.ChartData(points=<factory>, colors=<factory>)[source]

Bases: object

A chart’s plotted series, already grouped and ordered.

Holds what render_chart() draws, so the renderer never touches the store and stays testable against synthetic points.

points: dict[str, list[tuple[date, int]]]

One sorted list of (day, value) per series, in draw order.

colors: dict[str, tuple[str, str]]

Light and dark hex pair per series, keyed like points.

repomatic.metric_chart.css_class(name)[source]

Fold a series name into a CSS class fragment.

Parameters:

name (str) – A series name as the repository declared it.

Return type:

str

Returns:

The lowercased name with every unsafe run replaced by a dash.

repomatic.metric_chart.assign_colors(names, overrides=None)[source]

Give every series a light and dark hue.

Positional from SERIES_PALETTE in names order, so a chart’s first curve is always the first slot, with overrides winning by name. A hue is a property of the series rather than of the chart, which is what keeps a repository plotted on two charts recognizable across both.

Parameters:
Return type:

dict[str, tuple[str, str]]

Returns:

The light and dark pair of each name.

Raises:

ValueError – When more series need a slot than the palette holds, or when an override is not a light and dark pair.

repomatic.metric_chart.build_chart_data(grouped, spec, overrides=None)[source]

Select, order and colour the series one chart plots.

A forerunner rides along with the series it precedes rather than being selected on its own, and borrows that series’ hue instead of claiming a slot of its own.

Parameters:
Return type:

ChartData

Returns:

The chart’s points and colours.

Raises:

ValueError – When the chart plots nothing, when two series fold onto one CSS class, or when the palette runs out.

repomatic.metric_chart.render_chart(data, *, relative=False, logarithmic=False, title='', label='Stars', stamp=None)[source]

Draw the line chart as a standalone, themeable SVG.

Parameters:
  • data (ChartData) – The series to plot and their hues.

  • relative (bool) – Measure the horizontal axis from each repository’s own first point rather than from the calendar.

  • logarithmic (bool) – Measure the vertical axis by powers of ten, so series orders of magnitude apart stay legible on one chart. See CHART_SCALES for how the zero every series carries is placed.

  • title (str) – Accessible name for the chart. Derived from the metric and the mode when empty.

  • label (str) – What the vertical axis counts, from the plotted metric’s label.

  • stamp (str | None) – Sampling date shown in the caption, in YYYY-MM-DD form. Today (UTC) when None.

Return type:

str

Returns:

The complete SVG document.

repomatic.metric_chart.write_chart(grouped, spec, overrides=None, stamp=None)[source]

Render one chart and write it, leaving an unchanged file alone.

Parameters:
Return type:

bool

Returns:

True when the file content changed.

Raises:

ValueError – When the chart cannot be built (see build_chart_data()).

repomatic.metrics module

Accumulate what forges say about a set of repositories, one reading at a time.

Every reading is one row of one table: which repository, which metric, on which date, what it said, and where the figure came from. A new metric is a Metric entry and one line in the forge reader, not a new file, a new schema or a new command.

Note

How long a reading is kept is a property of the metric, not of the caller.

A counter accrues: its whole point is the curve, so every dated reading is kept and charted. A star count is the one that motivated all this.

An attribute does not: the date of a project’s newest commit is a fact about today, nothing reads it chronologically, and a hundred subjects sampled weekly would pile up thousands of rows a year that no page ever opens. Only the newest reading is kept, dated when the value last moved, so a quiet week leaves the file untouched rather than restamping every row.

Retention is where that choice lives, and upsert() is the only code that has to know about it.

Note

The star history replaces the third-party charts a project used to embed. On 2026-06-30 GitHub restricted the REST stargazer endpoints to a repository’s own admins and collaborators, and closed the equivalent GraphQL field on 2026-07-17, which left every such embed on the web rendering an error card.

What survived is the aggregate count on the repository object, which stays public for everyone. Sampled on a schedule it accumulates into a history nobody can revoke.

Warning

A reconstruction and a sample do not measure the same thing, and the difference is deliberate rather than a defect.

The stargazers API lists only the accounts that still have the repository starred, so a reconstruction attributes today’s surviving stars to the dates they were given: it understates every past date by the number of stars since withdrawn, converging on the true figure at the present day. Kept on purpose, since a curve that sags where a project shed followers carries a signal a monotonic one hides. Each row therefore names its SOURCES, so a reader can always tell which question a point answers.

repomatic.metrics.GITHUB_EPOCH = datetime.date(2008, 1, 1)

No star predates GitHub, so nothing earlier can be a real reading.

The guard that tells a star-history.com calendar export from its by-age sibling: the latter measures each curve from epoch zero, so its rows land in the 1970s and would otherwise enter the store as genuine points four decades before the repository existed.

repomatic.metrics.MAX_RETRY_DELAY = 15.0

Ceiling on fetch()’s exponential backoff, in seconds.

Doubling without a bound spends the whole attempt budget waiting, which is the wrong trade against a service that fails most requests but recovers within seconds on the next one.

repomatic.metrics.METRIC_HEADERS = ('repo', 'metric', 'date', 'value', 'source')

Columns of the committed store, in file order.

The three key columns first, then the payload, so the file reads top to bottom as one repository at a time, one metric at a time, chronologically. That is also the sort order, which is what makes a scheduled commit an append per subject rather than a reshuffle.

repomatic.metrics.PREDECESSOR_SUFFIX = ':prior'

Marks a predecessor’s series key, appended to the subject it belongs to.

Keeps the configured subject list exactly the curves a chart plots, while still letting the collectors and the renderer address the extra one through the same code paths.

repomatic.metrics.SAMPLE_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Subject', 'subject'), ('Phase', 'phase'), ('Repository', 'repository'), ('Stars', 'stars'), ('Rows', 'rows'), ('Note', 'note'))

Column definitions for the repomatic sample-metrics table.

Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its --sort-by choices from it.

repomatic.metrics.SOURCE_RANK: dict[str, int] = {'created': 3, 'github': 3, 'sample': 2, 'star-history': 1, 'wayback': 1}

How authoritative each provenance is, for resolving two readings of a day.

An exact reconstruction supersedes a mined or imported count; a contemporaneous sample supersedes both, since it was taken by this collector against the live API. A backfill never overwrites something stronger, which is what lets a one-off import run against an already-populated store without degrading it.

repomatic.metrics.SOURCES: dict[str, str] = {'created': 'Repository creation, the one date a star count is known to be 0.', 'github': 'Exact per-star timestamps, surviving stars only (admin token).', 'sample': "Read from the forge's own API, contemporaneous.", 'star-history': 'Count at a date, imported from a star-history.com export.', 'wayback': 'Contemporaneous count mined from an archived GitHub page.'}

Provenance vocabulary, recorded per row.

A chart may mix methodologies it cannot reconcile, so it records which one each point came from rather than presenting a uniform curve it cannot honestly claim.

created is the outlier: not a measurement but a fact, and the only origin every series shares. A repository backfilled from the archives has no knowable first star, since its earliest capture already shows a count, so its curve would otherwise begin in mid-air. It is also what a by-age chart aligns on.

repomatic.metrics.USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'

Sent to the Wayback Machine, which serves robots a reduced index.

repomatic.metrics.WAYBACK_PAGE_TRIES = 8

Attempts per archived page.

Sized against a measurement rather than a guess: 25 requests for one capture known to exist returned 23 plain 503 responses and 2 truncated bodies, and no clean response at all. Since a truncated body still carries the counter, the per-try success rate that matters was 2 in 25, and eight tries is the point past which more attempts cost more than the captures they recover.

repomatic.metrics.WAYBACK_REFUSAL_LIMIT = 10

Consecutive refused captures tolerated before the run abandons the archive.

A served page proves the archive healthy whatever it holds, so only refusals extend the streak, and any payload resets it. Sized against the healthy success rate WAYBACK_PAGE_TRIES buys: with eight tries a capture lands about half the time, so ten misses in a row happens by luck roughly once in a thousand runs. Past it the per-IP budget is spent for a while, and every further capture only burns a full retry schedule proving it again.

repomatic.metrics.WAYBACK_REQUEST_DELAY = 3.0

Seconds to wait between two archived pages.

The backfill is a one-off that nobody watches, so trading minutes for a higher completion rate is free. Its counterpart is the retry backoff in fetch(), which handles a single hiccup; this handles the sustained budget.

repomatic.metrics.WAYBACK_STAR_PATTERNS = (re.compile('id="repo-stars-counter-star"[^>]*title="([\\d,]+)"', re.IGNORECASE), re.compile('title="([\\d,]+)"[^>]*id="repo-stars-counter-star"', re.IGNORECASE), re.compile('aria-label="([\\d,]+) users? starred', re.IGNORECASE), re.compile('href="/[^"]+/stargazers"[^>]*class="social-count[^"]*"[^>]*>\\s*([\\d,]+)', re.IGNORECASE), re.compile('class="social-count[^"]*"[^>]*href="/[^"]+/stargazers"[^>]*>\\s*([\\d,]+)', re.IGNORECASE))

Star-counter markups GitHub has shipped over the years, newest first.

An archived page states the exact figure in an attribute rather than the abbreviated 4.4k shown to readers, so a capture yields an integer, not an estimate. The layout was reworked twice in the window these mine, hence the alternatives.

class repomatic.metrics.Retention(*values)[source]

Bases: Enum

How long the store keeps a metric’s readings.

HISTORY = 1

Every dated reading, forever. For a counter, whose curve is the point.

LATEST = 2

Only the newest reading, dated when the value last moved.

For an attribute, which describes today rather than accruing. Nothing reads it chronologically, and keeping every sample would bury the file in rows restating what the previous one already said.

class repomatic.metrics.Metric(id, retention, label, description)[source]

Bases: object

One thing a forge can be asked about a repository.

id: str

Value of the store’s metric column, and the name a chart selects on.

retention: Retention

Which of Retention governs this metric’s rows.

label: str

Human-readable name, for a rendered table or a chart axis.

description: str

What the reading means, and what it deliberately does not.

property accrues: bool

Whether this metric’s past readings are kept and can be charted.

repomatic.metrics.METRICS: tuple[Metric, ...] = (Metric(id='commit', retention=<Retention.LATEST: 2>, label='Last commit', description='Date of the newest commit on the default branch, which stays true for a rolling repository that never tags a release.'), Metric(id='release', retention=<Retention.LATEST: 2>, label='Last release', description='Date of the newest release or tag, whichever is more recent.'), Metric(id='release_source', retention=<Retention.LATEST: 2>, label='Release kind', description='Whether the release date came from a release the project announced, or from the newest tag it merely labelled.'), Metric(id='stars', retention=<Retention.HISTORY: 1>, label='Stars', description='Accounts following the repository on its own forge.'))

Every metric the sampler collects, sorted by ID.

The extension point: a new counter is one entry here plus one yield in readings(). Nothing else changes, because the store, the retention rule and the chart all read this registry.

repomatic.metrics.METRICS_BY_ID: dict[str, Metric] = {'commit': Metric(id='commit', retention=<Retention.LATEST: 2>, label='Last commit', description='Date of the newest commit on the default branch, which stays true for a rolling repository that never tags a release.'), 'release': Metric(id='release', retention=<Retention.LATEST: 2>, label='Last release', description='Date of the newest release or tag, whichever is more recent.'), 'release_source': Metric(id='release_source', retention=<Retention.LATEST: 2>, label='Release kind', description='Whether the release date came from a release the project announced, or from the newest tag it merely labelled.'), 'stars': Metric(id='stars', retention=<Retention.HISTORY: 1>, label='Stars', description='Accounts following the repository on its own forge.')}

Index for O(1) metric lookup by ID.

repomatic.metrics.CHARTABLE_METRICS: tuple[str, ...] = ('stars',)

Metrics a chart can plot, since only an accruing one has a curve.

class repomatic.metrics.MetricRecord(repo, metric, day, value, source)[source]

Bases: object

One reading: what a forge said about one repository on one date.

repo: str

Canonical https://host/owner/name URL of the subject.

metric: str

Which METRICS entry this reading is of.

day: str

The reading’s date, in YYYY-MM-DD form.

For an accruing metric, when the reading was taken. For an attribute, when its value last changed.

value: str

What the forge answered, as text.

CSV carries no types, so a consumer wanting a number coerces it. The store keeps the forge’s own answer rather than a parsed one, since a metric added later may not be numeric at all.

source: str

Which key of SOURCES produced the figure.

property key: tuple[str, str, str]

Deduplication identity: one reading per subject, metric and day.

property subject_key: tuple[str, str]

What an attribute keeps only one row of.

property count: int

The reading as an integer, for a counter metric.

Raises:

ValueError – When the value is not a number, which means a chart was pointed at an attribute.

as_row()[source]

Flatten to one CSV row, in METRIC_HEADERS order.

Return type:

tuple[str, ...]

classmethod from_row(row)[source]

Rebuild a record from one parsed CSV row.

Parameters:

row (Mapping[str, str]) – The row, keyed by column name.

Return type:

MetricRecord

Returns:

The corresponding record.

Raises:

KeyError – When a column is missing.

class repomatic.metrics.SampleOutcome(subject, repo, phase, stars=None, rows=0, note='')[source]

Bases: object

What one subject’s sample produced, for the CLI to report.

subject: str

Name the repository gives this subject.

repo: str

Canonical URL it read from.

phase: str

Sampling lane that produced this outcome.

One of forward, reconstruct, import or wayback. The CLI reports one row per subject per lane, and the columns mean different things in each, so the row names the lane whose semantics it carries.

stars: int | None = None

Its current star count, when the collector read one.

rows: int = 0

How many stored rows this collector added or moved.

note: str = ''

Why nothing was collected, empty when something was.

repomatic.metrics.collected_subjects(subjects, predecessors=None)[source]

Every repository a collector touches, keyed by its subject name.

Parameters:
  • subjects (Mapping[str, str]) – Tracked subjects, mapping each name to a slug or URL.

  • predecessors (Mapping[str, str] | None) – Retired forerunners, mapping the name of the subject they belong to onto their own slug or URL.

Return type:

dict[str, str]

Returns:

The subjects, plus one entry per forerunner whose key carries PREDECESSOR_SUFFIX so a caller can tell the two apart. Every value is a canonical URL.

Raises:

ValueError – When a declared subject parses as neither a slug nor a URL.

repomatic.metrics.last_fetch_failure()[source]

Summarize why the most recent fetch() gave up.

Return type:

str

Returns:

A tally like 6x HTTP 503, 2x truncated, or no response when nothing was recorded.

repomatic.metrics.load_metrics(path)[source]

Read the committed store, keyed by subject, metric and date.

Parameters:

path (Path) – Path to the CSV store.

Return type:

dict[tuple[str, str, str], MetricRecord]

Returns:

The records, empty when the file does not exist.

Raises:

ValueError – When the file exists but cannot be parsed. Loud on purpose: a corrupt store must never be silently clobbered by the next save_metrics() write.

repomatic.metrics.save_metrics(path, records)[source]

Write the store back, sorted by subject, metric and date.

Merges whatever is on disk under the caller’s own records rather than overwriting the file wholesale. A slow backfill flushes after every point across a run lasting hours, so it holds a snapshot that goes stale the moment anything else records a reading: without the merge its next flush would silently drop those rows.

Caution

The merge is additive, so it cannot express a deletion. An attribute whose older rows upsert() just pruned would come back from disk. The prune therefore happens against a store that was loaded from that same file, which is what every collector here does; a caller assembling records from nothing must write with a store it loaded first.

Parameters:
Return type:

bool

Returns:

True when the file content changed.

repomatic.metrics.upsert(records, record)[source]

Record one reading, returning whether it changed anything.

Re-running on the same day overwrites rather than appends, which is what keeps the scheduled job idempotent. Beyond that the metric’s Retention decides:

  • An accruing metric keeps every day, and a more authoritative source wins over a weaker one for the same day, per SOURCE_RANK.

  • An attribute keeps one row. An unchanged value leaves the stored date alone, so a quiet week rewrites nothing; a moved value replaces the row and takes the new date, which is therefore when the value last changed rather than when it was last confirmed.

Parameters:
Return type:

bool

Returns:

True when the store moved.

Raises:

KeyError – When the metric is not in METRICS_BY_ID.

repomatic.metrics.gunzip(blob)[source]

Decompress a gzip payload, tolerating one cut short mid-stream.

gzip.GzipFile needs the trailer to finish, so it raises on the truncated bodies a degraded archive delivers, discarding the megabyte that did arrive. Feeding the same bytes to a raw decompressor returns everything decodable before the cut and simply never reports the end of stream.

Parameters:

blob (bytes) – The compressed payload.

Return type:

bytes

Returns:

Everything that could be decoded, empty on an undecodable blob.

repomatic.metrics.fetch(url, tries=3, timeout=45, user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36')[source]

Fetch a URL with capped backoff, returning None once every try failed.

Deliberately separate from repomatic.http, whose single-retry policy is right for an API that either answers or does not. The Wayback Machine’s replay service is frequently only partly healthy: its load balancer answers 503 for most requests while a minority succeed, with neighbouring requests for the same capture landing on different backends. A failure therefore says nothing about whether the capture exists, and repeating the request is the lever that works. Pacing is not: the whole service is degraded, not this client’s budget.

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

  • tries (int) – How many attempts to make before giving up.

  • timeout (int) – Socket timeout in seconds.

  • user_agent (str) – Identity to send, defaulting to the browser one the archive wants and every forge refuses.

Return type:

bytes | None

Returns:

The body, or None once every attempt failed. Consult last_fetch_failure() for why.

repomatic.metrics.sample_subject(records, subject, repo, extra_forges=None, day=None)[source]

Read every metric of one subject, through whichever forge hosts it.

The scheduled collector, and the only one that works for a repository the token does not administer, or that lives outside GitHub entirely.

Parameters:
  • records (dict[tuple[str, str, str], MetricRecord]) – The in-memory store, mutated in place.

  • subject (str) – Name the repository gives this subject.

  • repo (str) – Its canonical URL.

  • extra_forges (Mapping[str, str] | None) – Host-to-forge entries for self-hosted instances.

  • day (str | None) – Reading date in YYYY-MM-DD form. Today (UTC) when None.

Return type:

SampleOutcome

Returns:

What the sample produced.

repomatic.metrics.reconstruct_from_github(records, subject, repo)[source]

Reconstruct one repository’s star curve from per-star timestamps.

Only works on GitHub, and only where the token administers the repository; GitHub answers 404 rather than 403 on the restricted endpoint for every other. Collapses to one cumulative reading per day on which the count moved, rather than one per star.

Pagination is all-or-nothing on purpose. A transient failure halfway through would otherwise write a truncated cumulative curve over a correct one, and every point of it would look exactly as legitimate as the rest.

Parameters:
  • records (dict[tuple[str, str, str], MetricRecord]) – The in-memory store, mutated in place once the whole walk succeeded.

  • subject (str) – Name the repository gives this subject.

  • repo (str) – Its canonical URL.

Return type:

SampleOutcome

Returns:

What the reconstruction produced.

repomatic.metrics.wayback_captures(path)[source]

List one archived capture per month of a repository’s GitHub page.

Parameters:

path (str) – The repository’s owner/name path.

Return type:

list[str] | None

Returns:

The capture timestamps, or None when the index itself could not be read. That is not the same answer as an empty list and must not be reported as one: the archive fails this query as readily as any other, and a run treating the outage as “never archived” skips the repository silently and for good.

repomatic.metrics.backfill_wayback(records, subject, repo, store=None, on_status=None, on_row=None)[source]

Mine contemporaneous star counts from archived copies of a GitHub page.

The only route to the past of a repository the token cannot administer, and the only one reporting what the counter actually read on the day rather than what survives today.

Parameters:
  • records (dict[tuple[str, str, str], MetricRecord]) – The in-memory store, mutated in place.

  • subject (str) – Name the repository gives this subject.

  • repo (str) – Its canonical URL.

  • store (Path | None) – Store to flush to after every recovered point, since a run spans many minutes of a flaky remote. Skipped when None.

  • on_status (Callable[[str], None] | None) – Called with whatever the backfill is reaching for next, so a caller can animate a live label. One subject is a single call spanning minutes, and a watcher hears nothing at all without this.

  • on_row (Callable[[str], None] | None) – Called with each recovered point, for a caller keeping a persistent line per result. Misses stay on the INFO log instead: the archive refuses far more captures than it serves, and a line each would bury the handful that landed.

Return type:

SampleOutcome

Returns:

What the backfill produced. When the archive refuses WAYBACK_REFUSAL_LIMIT captures in a row, the run is abandoned with a retry-later note and every later subject is skipped: the budget is per IP, so no following subject stands a better chance.

repomatic.metrics.read_star_counter(html)[source]

Read the exact star count out of an archived GitHub repository page.

Parameters:

html (str) – The archived page’s markup.

Return type:

int | None

Returns:

The count, or None when no known counter markup matched.

repomatic.metrics.parse_csv_day(stamp)[source]

Read the UTC calendar day out of a star-history.com CSV timestamp.

Parameters:

stamp (str) – A JavaScript Date.toString() stamp.

Return type:

date | None

Returns:

The day in UTC, or None when the stamp does not parse.

repomatic.metrics.import_star_history_csv(records, path, repos=None)[source]

Import the calendar export a star-history.com user downloaded.

That service reconstructed its curves from the same stargazer endpoint GitHub has since closed, so an export taken while it worked is the only surviving record of the past for a repository nobody administers and the archives never captured.

Caution

A replacement export cannot be obtained today. The service now inherits the restriction it reports: asked for a repository the visitor neither owns nor collaborates on, it answers that star history is unavailable instead of exporting anything. So a file reaching this function was either downloaded before the endpoints closed, or covers a repository its downloader administers, which reconstruct_from_github() already rebuilds exactly and at finer resolution. For a competitor, backfill_wayback() and forward sampling are what is left.

Its by-age export is refused rather than imported: that variant measures every curve from epoch zero, so its rows land in the 1970s and would enter the store as readings four decades before the repository existed.

Parameters:
  • records (dict[tuple[str, str, str], MetricRecord]) – The in-memory store, mutated in place.

  • path (Path) – The exported CSV.

  • repos (Iterable[str] | None) – Only import rows whose repository canonicalizes into this set. Every row when None.

Return type:

list[SampleOutcome]

Returns:

One outcome per repository the file covered.

Raises:

ValueError – When the file carries no usable row, naming the by-age export as the likely cause.

repomatic.metrics.series(records, subjects, metric='stars', predecessors=None)[source]

Group one metric’s readings into a chronological series per subject.

Parameters:
Return type:

dict[str, list[tuple[date, int]]]

Returns:

One sorted list of (day, value) per subject that has any reading, forerunners under their PREDECESSOR_SUFFIX key.

Raises:

ValueError – When metric does not accrue, so has no curve to plot.

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_PACKAGE_URL = 'https://www.npmjs.com/package/{package}'

npm package homepage URL. The npm counterpart to repomatic.pypi.PYPI_PACKAGE_URL.

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.pages_redirects module

A faithful Python replica of the engine Cloudflare Pages runs _redirects on.

Cloudflare’s documentation describes the file format; it does not describe the accounting, and the accounting is where rules die. A site once lost the last 18 rules of its file for years this way, silently: wrangler pages deploy prints nothing when the parser discards lines, and a dead redirect looks exactly like a URL nobody visits. This module replicates the reference implementation so lint-repo can audit a committed file the way production will read it, before production reads it.

Transcribed on 2026-08-10 from the engine itself, not from the documentation:

  • Parsing: packages/workers-shared/utils/configuration/parseRedirects.ts in cloudflare/workers-sdk, as bundled in wrangler 4.118 (the same code path Miniflare uses, and the same parser family the Pages asset server feeds on).

  • Matching: packages/workers-shared/asset-worker/src/utils/rules-engine.ts.

The three rules of the engine that the documentation does not state:

  1. A static rule is only free while it appears before the first dynamic rule. The parser flips canCreateStaticRule to false permanently at the first source containing * or :placeholder. Every later rule, however static it looks, is charged against the dynamic budget.

  2. The dynamic budget is 100, and blowing it aborts the file. Rule 101 of that mixed stream does not get skipped: the parser breaks out of the loop, discarding every remaining line. Order is therefore not a style choice, it decides which rules exist.

  3. Matching is anchored and literal about trailing slashes. A placeholder compiles to [^/]+ (at least one character, never a slash, never empty), a splat to .* (may be empty), and the whole source to ^...$. /a/:b does not match /x/y/ and /a/* matches /a/ with an empty splat.

class repomatic.pages_redirects.Rule(source, destination, status, line_number)[source]

Bases: object

source: str
destination: str
status: int
line_number: int
property is_dynamic: bool
class repomatic.pages_redirects.Invalid(message, line=None, line_number=None)[source]

Bases: object

message: str
line: str | None = None
line_number: int | None = None
class repomatic.pages_redirects.ParseResult(rules=<factory>, invalid=<factory>, aborted_at_line=None)[source]

Bases: object

rules: list[Rule]
invalid: list[Invalid]
aborted_at_line: int | None = None

Line number from which the parser discarded the rest of the file, if it did.

repomatic.pages_redirects.parse_redirects(text)[source]

The exact algorithm of parseRedirects, budget accounting included.

Return type:

ParseResult

repomatic.pages_redirects.misordered_statics(rules)[source]

Exact-source rules charged against the dynamic budget by their position.

The engine’s static budget (2000) only covers exact rules appearing before the first dynamic source; every exact rule after that point burns a slot of the dynamic budget (100) instead. Such a file still works while the budget holds, so this is the early warning: each rule returned here brings the file one line closer to the silent abort parse_redirects() reports as aborted_at_line. The fix is always the same reorder, all exact rules first, all pattern rules second, which is behaviour-preserving because the asset server probes exact sources first regardless of file position.

Return type:

list[Rule]

repomatic.pages_redirects.rule_pattern(source)[source]

Compile a rule source exactly the way generateRuleRegExp does.

Return type:

Pattern[str]

repomatic.pages_redirects.apply_rule(rule, path)[source]

Return the destination for path, or None if the rule does not match.

Return type:

str | None

repomatic.pages_redirects.sample_path(source)[source]

A concrete request path a rule source would have matched.

An exact source is already one, and is returned untouched, which is the case that matters: a dropped exact rule names the very URL that stops working. A pattern has no single answer, so each :name stands in for itself and each * for one segment, yielding an illustration rather than a promise about live traffic.

Parameters:

source (str) – Rule source, exact or patterned.

Return type:

str

Returns:

A path that rule_pattern() would match.

repomatic.pages_redirects.discarded_rules(text, parsed)[source]

The rules the engine abandoned, recovered from the tail it never read.

parse_redirects() reports that it stopped and drops everything below, because that is what production does. Naming what was lost needs the tail parsed on its own, which is what this does, with the line numbers shifted back to where they sit in the real file.

Caution

The tail is parsed with fresh budgets, so one long enough to exhaust them again reports only its first batch. Reading this as an illustration of what broke rather than an exhaustive inventory is the intent either way: the fix is the same reorder however many rules are below the line.

Parameters:
Return type:

list[Rule]

Returns:

The abandoned rules, empty when the parser read the whole file.

repomatic.pages_redirects.evaluate(rules, path)[source]

First-match evaluation over the kept rules, the way the asset server runs it.

The server splits exact sources into a hash map probed first, then walks the dynamic rules in file order. The two passes below mirror that: an exact rule wins over a pattern that also matches, wherever each sits in the file, which is also what makes the statics-first reorder the lint recommends behaviour-preserving.

Return type:

tuple[Rule, str] | None

repomatic.plugin module

Distribution of the bundled skills and agents as a Claude Code plugin.

Two halves of the same story, kept together because they share the plugin’s identity constants:

  • pack_plugin() assembles the zip the release engine attaches to every GitHub release, from the manifest and asset directories already in the tree.

  • merge_plugin_settings() writes the marketplace and enablement wiring into a consumer’s Claude Code settings, so a downstream repository can install the plugin instead of carrying copied skill files.

Caution

pack_plugin() relocates each asset into the spec’s default skills/ and agents/ directories, rather than mirroring the .claude/ layout it reads them from, and the manifest therefore declares no component paths at all.

That asymmetry is not a stylistic choice. A manifest naming individual agent files ("agents": ["./.claude/agents/qa-engineer.md", ...], the only form the published schema accepts, since it constrains the field to paths ending in .md) passes claude plugin validate –strict and then loads zero agents at runtime, silently. Naming the directory instead fails validation outright. The default location is the only shape that actually works, verified against Claude Code 2.1.220 by loading the packed archive and counting components with claude plugin details. skills does honor a custom directory, but there is no reason to keep one half on the mechanism that misbehaves, so both travel to their defaults and the manifest stays metadata-only.

Note

.claude/skills/ and .claude/agents/ remain the single source of truth: the relocation happens only inside the archive, so there is no symlink anywhere and no second copy of any skill in the tree. The trade-off is that the repository root is not itself an installable plugin: test a change by packing it and pointing claude --plugin-dir at the unpacked archive.

Note

The checked-in manifest carries no version: pack_plugin() injects the running __version__ into the copy it writes to the archive. Claude Code compares that string against a user’s installed copy to decide whether an update is due, so a hand-maintained value that went stale would silently strand everyone on the plugin they already had. Deriving it at pack time makes it impossible to forget, and keeps the one repomatic-specific [[tool.bumpversion.files]] entry out of a [tool.bumpversion] block that sync-bumpversion regenerates from a bundled template shared with every downstream repository.

Note

The marketplace entry is an archive source pointing at the release asset, and its URL ratchets forward: PrepareRelease.freeze_marketplace_archive_url() rewrites it to /releases/download/v{X.Y.Z}/ on each release commit, and nothing walks it back. So the default branch always names the newest published release, and a catalog added at a tag installs that tag’s plugin. The URL is never a latest redirect except before the very first release, and never a .devN tag.

Caution

The entry still carries no sha256. The archive is byte-deterministic, so a digest could in principle be committed alongside the pin, but only if the release runner reproduces those bytes exactly: ZIP_DEFLATED output depends on the zlib build behind CPython, and a one-byte difference would fail every install with Plugin archive integrity check failed rather than degrading. Integrity comes from the attestation the engine’s extra-assets job generates instead. Switching to ZIP_STORED would make a committed digest safe, at the cost of a larger asset.

Independently of that: a release that publishes without this asset breaks /plugin install until the next one, which is why a failed extra-assets now blocks publish-release.

repomatic.plugin.MANIFEST_PATH = '.claude-plugin/plugin.json'

Location of the plugin manifest.

The same path in both places it appears: relative to the repository root, where pack_plugin() reads it, and relative to the plugin root inside the archive, where Claude Code looks for it.

repomatic.plugin.MARKETPLACE_PATH = '.claude-plugin/marketplace.json'

Location of the marketplace catalog, relative to the repository root.

repomatic.plugin.PLUGIN_NAME = 'repomatic'

The plugin’s name, which namespaces every skill and agent it ships.

Users type it as /plugin install repomatic@kdeldycke and see it in the scoped component names (repomatic:qa-engineer). Renaming it breaks every existing install, so it lives here as a constant and is asserted against the manifest rather than read from it.

repomatic.plugin.MARKETPLACE_NAME = 'kdeldycke'

The marketplace’s name, the catalog this plugin is published in.

Named after the owner rather than the project, so sibling repositories can be listed in the same catalog later. Like PLUGIN_NAME, renaming it breaks every existing install.

repomatic.plugin.MARKETPLACE_REPO = 'kdeldycke/repomatic'

Repository a consumer registers to reach MARKETPLACE_PATH.

repomatic.plugin.BIOME_DEFAULT_INDENT: Final[str] = '\t'

Indent format-json writes when no Biome configuration overrides it.

Biome’s own default, so a repository declaring nothing gets a rendered document the formatter already agrees with.

repomatic.plugin.BIOME_DEFAULT_INDENT_WIDTH: Final[int] = 2

Spaces per level Biome assumes when a config asks for spaces without a width.

repomatic.plugin.ARCHIVE_NAME = 'repomatic-claude-plugin.zip'

Filename of the release asset pack_plugin() produces.

Carries claude because a bare repomatic-plugin.zip reads backwards: packaging names an extension after its host first (pytest-cov, mdformat-gfm), so that filename announces a plugin for repomatic on a release page, which is also what “plugin” means for the mdformat entries of tool_registry. The name mirrors the spec’s own .claude-plugin/ directory instead.

Also the default --output of repomatic pack-plugin, so the release job never spells it. It still appears in [tool.repomatic] release-assets and in the release-asset- run-artifact name the engine matches, which TOML and YAML cannot read from here; tests/test_workflows.py holds all three equal. freeze_marketplace_archive_url() rewrites the marketplace URL’s trailing filename from here too, so a rename reaches every consumer through one constant.

repomatic.plugin.ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0)

Fixed modification time stamped on every archive member.

The earliest timestamp the ZIP format can represent. Together with a sorted member list and an explicit file mode, it makes pack_plugin() byte-deterministic, so re-packing an unchanged tree yields an identical archive. That matters more here than it usually would: with no sha256 pin in the marketplace entry, the archive’s own digest is what Claude Code falls back to for change detection.

repomatic.plugin.FILE_MODE = 420

Permission bits stamped on every archive member.

Stamped explicitly rather than copied from disk so the archive does not vary with the packing runner’s umask.

repomatic.plugin.AGENTS_DIR = 'agents'

Directory the plugin spec scans for agent definitions, inside the plugin root.

repomatic.plugin.SKILLS_DIR = 'skills'

Directory the plugin spec scans for skill folders, inside the plugin root.

repomatic.plugin.pack_plugin(repo_root, output, version='7.13.1.dev0')[source]

Pack the manifest and its assets into an installable plugin archive.

The archive holds a single top-level folder named after the plugin. That is one of the two layouts Claude Code accepts, and the one that makes unzip && claude –plugin-dir repomatic work on the downloaded asset. Inside it, assets sit at the spec’s default locations rather than the .claude/ paths they are read from: see the module docstring for why.

Parameters:
  • repo_root (Path) – Repository root the assets are read from.

  • output (Path) – Destination .zip path. Parent directories are created.

  • version (str) – Version stamped into the packaged manifest.

Return type:

list[str]

Returns:

Archive member names, sorted.

Raises:
  • FileNotFoundError – If the manifest, an agent file or a skill folder is missing.

  • TypeError – If the manifest is not a JSON object.

repomatic.plugin.render_plugin_settings(existing='', indent='\\t')[source]

Merge the plugin wiring into an existing settings document.

Only the two keys _plugin_settings() owns are touched, and within them only the entries this plugin and marketplace are named by: a repository’s own permissions, hooks and any unrelated marketplace survive untouched.

Sorted keys, and indent whichever way format-json writes JSON in the consuming repository, so writing the file leaves no drift for the formatter to raise a pull request about. Biome preserves key order, which is why only the indent has to be negotiated.

Parameters:
  • existing (str) – Current file content, or an empty string when absent.

  • indent (str) – One level of indentation, from _biome_json_indent().

Return type:

str

Returns:

The merged document, newline-terminated.

Raises:

TypeError – If existing is not a JSON object.

repomatic.plugin.merge_plugin_settings(target, root=None)[source]

Write the plugin wiring into target, creating the file if absent.

Idempotent: re-running against an already-wired document rewrites nothing and returns False, so repomatic init reports it as unchanged. That holds only while the rendered indent matches the repository’s own, which is why root is read rather than assumed: a repository declaring spaces would otherwise see this and format-json rewrite the file past each other on every run, each opening a pull request undoing the other’s.

Parameters:
  • target (Path) – Path to the Claude Code settings file to update.

  • root (Path | None) – Repository root whose Biome config sets the indent. Defaults to target’s own directory, which is right only for a root-level file.

Return type:

bool

Returns:

Whether the file was created or modified.

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: uv run --frozen -- repomatic (from the lockfile) → uvx 'repomatic==X.Y.Z' (from PyPI, for downstream repos).

    • Freezes the install guide’s binary download URLs to versioned release paths.

    • Pins the install guide’s versioned CLI examples to the release.

    • 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.

Caution

Rebase-merging the two commits delivers them in a single push, and GitHub Actions reads workflow files from that push’s head: the unfreeze commit. So the release lane of this repository always executes the unfrozen workflow content, running LOCAL_CLI_INVOCATION against uv.lock, even while building the freeze commit named in release_commits_matrix. Every job calling the CLI therefore needs its own checkout of matrix.commit, and a job written on the assumption that the frozen uvx 'repomatic==X.Y.Z' form is what runs will fail with Failed to spawn: repomatic.

Only downstream repositories, which call the reusable workflow at its vX.Y.Z tag, ever execute the frozen form. tests/test_workflows.py locks the checkout requirement across every workflow.

Both operations are idempotent: re-running on an already-frozen or already-unfrozen tree is a no-op.

repomatic.prepare_release.SELF_PIN_COOLDOWN_EXEMPTION = '--exclude-newer-package repomatic=P0D'

uv escape hatch letting a just-published repomatic install under the cooldown.

Every workflow exports a UV_EXCLUDE_NEWER covering all package resolution (see claude.md § Cooldown on every install), and it applies to the frozen 'repomatic==X.Y.Z' self-pin like any other requirement. That pin moves in lockstep with the uses: refs pointing at the same tag, so the version it names is always minutes old: without an exemption every downstream repo would fail to resolve it until the window elapsed. A zero-length window sets that one package’s cutoff to “now”, leaving the rest of the tree gated.

uv exposes no environment variable for --exclude-newer-package, so the exemption has to ride on the command line, which is why the freeze splices it in beside the pin instead of the workflows declaring it once.

Note

A uvx resolution reads no project configuration at all, so moving the exemption into [tool.uv] or an adjacent uv.toml would not work either: both are ignored. See claude.md § Per-ecosystem knobs, and uv#20995 for the upstream request that would let a workflow declare this once.

Spelled as the ISO 8601 P0D rather than the "0 day" used in pyproject.toml’s exclude-newer-package table: the flag travels through YAML folded scalars into a shell, where the space in 0 day would need quoting that survives both. P0D needs none.

Caution

Every character here lands on 80-odd already-long workflow lines at freeze time. tests/test_prepare_release.py simulates a freeze and fails if the result breaches yamllint’s 120-column cap, so lengthening this string means reflowing the workflows that no longer fit.

repomatic.prepare_release.LOCAL_CLI_INVOCATION = 'uv --no-progress run --frozen -- repomatic'

How every workflow on main runs the CLI, before the freeze rewrites it.

Resolving from uv.lock rather than from the index is what keeps the cooldown off the critical path here. A lockfile entry is pinned and hash-verified, so it is strictly stronger than a publication-age gate, and it cannot be made unsatisfiable by one: uvx --from . re-resolves [project.dependencies] on every call, and reads neither uv.lock nor [tool.uv] exclude-newer-package, so a floor naming a release younger than the window took every workflow down at once with nowhere to record the bypass.

--frozen uses the lockfile as-is instead of asserting it is current, which is deliberate: --locked would fail every job the moment pyproject.toml drifted ahead of uv.lock, including the sync-uv-lock job whose whole purpose is to close that gap.

class repomatic.prepare_release.PrepareRelease(changelog_path=None, citation_path=None, workflow_dir=None, install_path=None, marketplace_path=None, default_branch='main')[source]

Bases: object

Prepare files for a release by updating dates, URLs, and removing warnings.

modified_files: list[Path]
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 package_name: str | None[source]

Canonical PyPI package name, used to spot pinned install examples.

Delegates discovery to Metadata.package_name, which reads 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.

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. Both spellings resolve, since every release also carries versionless alias copies of its binaries (see pack_binary_assets()); the frozen URL is preferred because it names the version the reader is installing, and keeps working once a later release moves latest.

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 what users wanting stable binaries need.

Caution

The freeze runs before the binaries exist, since it is the freeze commit that triggers the build. So it pins the version optimistically, and a release whose binary lane fails leaves the install guide linking six URLs that 404 until the next release ratchets past it. Re-point the guide at the last release that carries binaries when that happens, by calling this method with that version.

Parameters:

version (str) – The release version to freeze to.

Return type:

bool

Returns:

True if the file was modified.

freeze_marketplace_archive_url(version)[source]

Pin the plugin marketplace’s archive URL to this release.

This is part of the freeze step. The archive source in .claude-plugin/marketplace.json points at the release asset named by ARCHIVE_NAME, and pinning the tag is what makes a marketplace ref meaningful: adding the catalog at kdeldycke/repomatic@v6.0.0 then installs v6.0.0’s plugin, where a latest redirect would hand over whatever shipped most recently regardless of the ref asked for.

Handles the same two input forms as freeze_install_download_urls():

  • Initial (never frozen): /releases/latest/download/repomatic-claude-plugin.zip

  • Previously frozen: /releases/download/v6.0.0/repomatic-claude-plugin.zip

Note

The trailing filename is rewritten too, not just the tag, so ARCHIVE_NAME is the single source of truth for the whole URL. Renaming the asset would otherwise leave the checked-in URL naming a file the next release no longer publishes, and the mismatch would only surface as a failed /plugin install. Rewriting both together also keeps the default branch installable across the rename: the URL still names the asset the last published release actually carries until this method flips tag and filename in the same commit.

Note

No unfreeze method, for the same reason download URLs have none: the URL ratchets forward. The post-release .devN bump leaves it alone, so the default branch keeps pointing at the newest published release rather than at a vX.Y.Z.dev0 tag that was never created. That is what makes every state of this file installable, which a bump-my-version entry rewriting it on both commits could not achieve.

Parameters:

version (str) – The release version to freeze to.

Return type:

bool

Returns:

True if the file was modified.

freeze_install_cli_version(version)[source]

Pin the install guide’s versioned CLI examples to the release.

This is part of the freeze step: the install guide’s Specific version``tab demonstrates a pinned invocation (``uvx {package}@X.Y.Z or a {package}==X.Y.Z requirement), which must always showcase the latest release. Without this pass the pinned example silently rots (click-extra’s install guide sat on a 14-releases-old pin).

Note

Like freeze_install_download_urls(), this ratchets forward with no unfreeze: after a release the examples keep demonstrating that release, which is what readers should copy until the next one ships.

Parameters:

version (str) – The release version to pin the examples 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 uv --no-progress run --frozen -- repomatic with uvx --no-progress 'repomatic=={version}' in all workflow YAML files. Comment lines (starting with #) are skipped to avoid corrupting explanatory comments.

The two halves are not symmetric by accident. On main the CLI runs from uv.lock, which is pinned and hash-verified, and which no cooldown can make unsatisfiable. A downstream repo has no such lockfile for this project, so its copy has to resolve the published package from the index, which is what uvx does.

The pin is spliced in behind SELF_PIN_COOLDOWN_EXEMPTION, which is what keeps a release installable the minute it is published despite the workflow-wide cooldown. Local source needs no exemption, so main carries none between releases.

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 uvx --no-progress 'repomatic==X.Y.Z' with LOCAL_CLI_INVOCATION, taking SELF_PIN_COOLDOWN_EXEMPTION with it when the freeze put one there: the lockfile resolves from the working tree, so it never needs the escape hatch. The exemption is optional in the pattern so a workflow frozen by an older release still unfreezes cleanly. 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, across the same reference set as freeze_workflow_urls().

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_PACKAGE_URL = 'https://pypi.org/project/{package}/'

PyPI project homepage URL for a package (no version pinned).

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, yanked_reason: str = '')[source]

Bases: NamedTuple

Release metadata for a single version from PyPI.

Create new instance of PyPIRelease(date, yanked, package, yanked_reason)

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.

yanked_reason: str

Why the release was yanked, empty when PyPI records no reason.

PyPI stores the reason per file and accepts a yank with none at all, so this carries the first non-empty one across the version’s files.

repomatic.pypi.get_release_dates(package, *, force_refresh=False)[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, and carries the first yank reason any of them records.

Parameters:
  • package (str) – The PyPI package name.

  • force_refresh (bool) – Ignore any cached response and re-fetch.

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.github_repo_root(url)[source]

Reduce any GitHub URL to its https://github.com/owner/repo root.

A project_urls entry often points inside a repository (/issues, /releases, /blob/main/CHANGELOG.md), which is fine for a human-facing link but not for callers that derive an owner/repo API slug from it: the releases API would be asked for repo/issues and answer 404.

Parameters:

url (str) – Any URL, GitHub or not.

Return type:

str | None

Returns:

The repository root, or None when url names no GitHub repository (a bare github.com, or an owner with no repo).

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, then reduces the winner to its repository root so an API slug can be derived from it.

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.

Two releases uploaded on the same day are ordered by PEP 440, not by the version string: a raw string comparison sorts 1.9.0 above 1.10.0 and would return the older of the two as the latest. Versions PEP 440 cannot parse are skipped, since nothing can rank them.

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. Keys are matched case-insensitively, for the reason spelled out on _SOURCE_URL_KEYS: PyPI preserves whatever spelling the project wrote, so Changelog, changelog and CHANGELOG all occur in the wild.

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.

Parses are cached per file identity (absolute path, mtime, size), since a single CLI invocation reads the same document many times over: treat the result as read-only.

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.pyproject.is_python_package(project_root=None, pyproject_data=None)[source]

Detect whether project_root builds a distributable Python package.

Strictly narrower than is_python_project(): every package is a Python project, but not every Python project is a package. A uv virtual project declares a PEP 621 [project] table purely to carry dependencies, and opts out of being built or installed with [tool.uv] package = false. Blogs, docs sites and dotfiles repos that lean on uv for dependency management all look like this.

The distinction matters because the two traits gate different things. A virtual project still has dependencies to lock, a uv.lock to sync and tests to cover, so it wants everything scoped to PYTHON_ONLY. It has nothing to publish, tag or write release notes for, so it wants nothing scoped to PACKAGE_ONLY.

Note

Only uv’s opt-out is recognized. Poetry’s [tool.poetry] package-mode equivalent is deliberately ignored: repomatic dropped Poetry support in 4.0.0 and expects standard pyproject.toml conventions.

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.

Return type:

bool

Returns:

True for a PEP 621 project that is not a uv virtual project.

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, REUSABLE_WORKFLOWS, SKILL_PHASES, etc.) are computed from this single registry at the bottom of this module.

repomatic.registry.GITHUB_YAML_PATTERNS: tuple[str, ...] = ('.github/workflows/*.yaml', '.github/workflows/*.yml', '.github/actions/**/*.yaml', '.github/actions/**/*.yml')

Globs matching every workflow and composite-action file of a repository.

Rooted at the repository root rather than at .github/, so the same patterns work against the current directory and against an arbitrary target tree. Both .yml and .yaml are listed because GitHub accepts either, whatever this project’s own long-extension convention prefers: a downstream repository is free to have picked the short one.

Shared by sync_ops._workflow_and_action_files, which reads the pins to bump, and init_project._highest_upstream_pin, which reads them to floor a new pin. The two must agree on which files carry a pin, or init would floor against a file sync-workflow-pins never bumps.

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 three axes: whether the repo is an awesome-* list, whether it carries a PEP 621 pyproject.toml, and whether that project is a distributable package. The first is mutually exclusive with the other two (awesome repos are content lists, not Python projects), so a single scope value suffices.

The Python axis is deliberately split in two. PYTHON_ONLY covers anything that needs Python code to be useful; PACKAGE_ONLY covers only what needs something to publish. A uv virtual project ([tool.uv] package = false) sits between the two: it locks dependencies and runs tests, but never ships a release. Collapsing the pair would hand every blog and docs site a PyPI publish action and a release workflow it can never run.

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).

Use for anything a uv virtual project still wants: dependency locking, coverage config, test tooling.

PACKAGE_ONLY = 4

Only for Python projects that build a distributable package.

Strictly narrower than PYTHON_ONLY, excluding uv virtual projects. Use for the release lane: publishing, tagging, changelog upkeep.

matches(is_awesome, is_python, is_package)[source]

Whether this scope applies to the given repository traits.

Parameters:
Return type:

bool

class repomatic.registry.FileEntry(source, target='', file_id='', scope=RepoScope.ALL, config_key='', config_default=False, reusable=True, phase='', tree=False)[source]

Bases: object

A single file managed within a component.

source: str

Filename in repomatic/data/, or a directory when tree is set.

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.

tree: bool = False

Whether source and target name directories, not files.

A tree entry is copied wholesale, so a skill can ship scripts/, references/ and assets/ alongside its SKILL.md exactly as the Agent Skills spec describes, with no per-file registration.

Caution

Under repomatic/data/ a tree’s directories must be real and only its leaves may be symlinks back into the authoritative tree. uv_build refuses a symlinked directory in package data (Is a directory (os error 21)) and fails the whole wheel, while symlinked files are dereferenced into it normally.

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, ephemeral=False, location_field='')[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.

ephemeral: bool = False

Whether this component’s files are inputs regenerated on demand rather than repository content.

Every consumer of an ephemeral component dumps it right before reading it, so a copy in the working tree is never the one that gets used. Bare repomatic init therefore skips these components, and [tool.repomatic] include cannot opt into materializing them: only naming the component explicitly on the CLI (repomatic init labels) writes its files out, which is how sync-labels stages labels.toml into a temporary directory to hand to labelmaker, leaving the working tree untouched.

location_field: str = ''

Config field holding this component’s destination, when the user can move it.

Set for every component whose destination is configurable: the directories subagents and skills write into, and the single files plugin and agent merge into. Declared targets are built against the default location, so a repo that overrode it needs each target rebased onto the configured one. resolve_target() performs that rebase, and leaving this empty means the targets are fixed (.github/workflows/ is GitHub’s, not ours to move).

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

resolve_target(target, config)[source]

Rebase a declared target path onto this component’s configured location.

A no-op unless location_field is set and the resolved config actually moves the destination, so every caller can route every target through this method instead of testing the component name first.

Handles both shapes a location may take. A directory location rebases the path under it; a file location (plugin, agent) is the path, so it is replaced outright. Matching only the directory shape would leave a moved file reported at its default path, and stale-file detection would then hunt for an orphan the repository never wrote there.

Parameters:
  • target (str) – A path as declared on a FileEntry (or a RemovedAsset tombstone), relative to the repository root and expressed against the default location.

  • config (object) – A Config instance, or None.

Return type:

str

Returns:

The target rebased onto the configured location, or target unchanged.

class repomatic.registry.BundledComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='')[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, ephemeral=False, location_field='')[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, ephemeral=False, location_field='', source_file='', tool_section='', sync_mode=SyncMode.BOOTSTRAP, preserved_keys=(), graft_identity_keys=(), overlay=False)[source]

Bases: Component

Merged into pyproject.toml.

Note

Nothing here declares where the section lands. init appends it to the [tool] table, then format-pyproject moves it: pyproject-fmt sorts [tool.*] by its own known-tool order, which no per-component hint can override. This class used to carry insert_after / insert_before tuples for the purpose; they were read by no code and are gone.

source_file: str = ''

Filename in repomatic/data/.

tool_section: str = ''

The [tool.X] section name to check for existence.

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.

property tool_name: str

The bare tool name, without the tool. table prefix.

The key this component’s section sits under inside a parsed [tool] table, derived once here rather than re-spelled by every consumer of tool_section.

class repomatic.registry.TemplateComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='')[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, ephemeral=False, location_field='', 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=(), owned_dir='', 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, config files): 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. When target is already gone but the asset shipped as a folder, an empty owned_dir left behind is pruned on its own: it carries nothing anyone could lose.

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 subagent targets with _skill_target / _subagent_target so they match the live registry: the skills.location and subagents.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).

owned_dir: str = ''

Directory the asset had to itself, in default-location form (like .claude/skills/repomatic-release), for an asset shipped as a folder.

A skill is a folder, so deleting its SKILL.md by any route other than init (a hand rm, a repomatic old enough to unlink the file alone) leaves the folder behind, empty. target no longer exists, so the tombstone never fires again and the fossil outlives every later init. Declaring the folder gives detection a second thing to look for. Empty for an asset that shipped as a lone file in a shared directory (a subagent, a workflow), whose parent must never be swept.

successor: str = ''

Optional human note describing what replaced the asset, shown in the report (like replaced by repomatic-ship).

repomatic.registry.WORKFLOW_TARGET_ROOT = '.github/workflows'

Directory GitHub reads workflow files from. Not configurable.

repomatic.registry.INSTALL_GUIDE_PATH = 'docs/install.md'

Install guide the release freeze pins download URLs in.

Shared by PrepareRelease, which rewrites those URLs, and check_install_guide_downloads(), which verifies the release they name actually carries the files.

repomatic.registry.SKILL_FILENAME = 'SKILL.md'

Name the Agent Skills spec reserves for a skill’s entry point.

repomatic.registry.SKILL_SOURCE_ROOT = 'skills'

Directory under repomatic/data/ holding one folder per bundled skill.

repomatic.registry.COMPONENTS: tuple[Component, ...] = (BundledComponent(name='labels', description='Label definitions for labelmaker (labels.toml)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='labels.toml', target='labels.toml', file_id='labels.toml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='', tree=False),), config_key='', config_default=True, keep_unmodified=False, ephemeral=True, location_field=''), 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.PACKAGE_ONLY: 4>, 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='', tree=False),), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field=''), BundledComponent(name='subagents', description='Agent subagent 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='', tree=False), 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='', tree=False), 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='', tree=False)), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='subagents_location'), BundledComponent(name='skills', description='Claude Code skill definitions (.claude/skills/)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='skills/av-false-positive', target='.claude/skills/av-false-positive', file_id='av-false-positive', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/awesome-triage', target='.claude/skills/awesome-triage', file_id='awesome-triage', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/babysit-ci', target='.claude/skills/babysit-ci', file_id='babysit-ci', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality', tree=True), FileEntry(source='skills/benchmark-update', target='.claude/skills/benchmark-update', file_id='benchmark-update', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/brand-assets', target='.claude/skills/brand-assets', file_id='brand-assets', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/file-bug-report', target='.claude/skills/file-bug-report', file_id='file-bug-report', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/github-housekeeping', target='.claude/skills/github-housekeeping', file_id='github-housekeeping', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/repomatic-audit', target='.claude/skills/repomatic-audit', file_id='repomatic-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/repomatic-changelog', target='.claude/skills/repomatic-changelog', file_id='repomatic-changelog', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/repomatic-deps', target='.claude/skills/repomatic-deps', file_id='repomatic-deps', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/repomatic-init', target='.claude/skills/repomatic-init', file_id='repomatic-init', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Setup', tree=True), FileEntry(source='skills/repomatic-ship', target='.claude/skills/repomatic-ship', file_id='repomatic-ship', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/repomatic-test-matrix', target='.claude/skills/repomatic-test-matrix', file_id='repomatic-test-matrix', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality', tree=True), FileEntry(source='skills/repomatic-topics', target='.claude/skills/repomatic-topics', file_id='repomatic-topics', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/sphinx-docs-sync', target='.claude/skills/sphinx-docs-sync', file_id='sphinx-docs-sync', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/translation-sync', target='.claude/skills/translation-sync', file_id='translation-sync', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/upstream-audit', target='.claude/skills/upstream-audit', file_id='upstream-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True)), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='skills_location'), 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='', tree=False), 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='', tree=False), 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='', tree=False), FileEntry(source='changelog.yaml', target='.github/workflows/changelog.yaml', file_id='changelog.yaml', scope=<RepoScope.PACKAGE_ONLY: 4>, config_key='', config_default=False, reusable=True, phase='', tree=False), 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='', tree=False), 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='', tree=False), 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='', tree=False), 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='', tree=False), FileEntry(source='metrics.yaml', target='.github/workflows/metrics.yaml', file_id='metrics.yaml', scope=<RepoScope.ALL: 1>, config_key='metrics.sync', config_default=False, reusable=True, phase='', tree=False), FileEntry(source='_release-engine.yaml', target='.github/workflows/release.yaml', file_id='release.yaml', scope=<RepoScope.PACKAGE_ONLY: 4>, config_key='', config_default=False, reusable=True, phase='', tree=False), 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='', tree=False), 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='', tree=False)), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field=''), 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, ephemeral=False, location_field=''), GeneratedComponent(name='changelog', description='Minimal changelog.md', init_default=<InitDefault.INCLUDE: 1>, scope=<RepoScope.PACKAGE_ONLY: 4>, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='', target='changelog.md'), GeneratedComponent(name='plugin', description='Claude Code plugin marketplace wiring (.claude/settings.json)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='settings_location', target='.claude/settings.json'), GeneratedComponent(name='agent', description='Audience-tagged sections of the agent instructions file', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='agent_location', target='claude.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, ephemeral=False, location_field='', source_file='uv.toml', tool_section='tool.uv', 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, ephemeral=False, location_field='', source_file='lychee.toml', tool_section='tool.lychee', 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, ephemeral=False, location_field='', source_file='ruff.toml', tool_section='tool.ruff', 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, ephemeral=False, location_field='', source_file='pytest.toml', tool_section='tool.pytest', sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), ToolConfigComponent(name='coverage', description='Coverage.py measurement and reporting configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='', source_file='coverage.toml', tool_section='tool.coverage', 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, ephemeral=False, location_field='', source_file='mypy.toml', tool_section='tool.mypy', 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, ephemeral=False, location_field='', source_file='mdformat.toml', tool_section='tool.mdformat', 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, ephemeral=False, location_field='', source_file='bumpversion.toml', tool_section='tool.bumpversion', 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, ephemeral=False, location_field='', source_file='typos.toml', tool_section='tool.typos', 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] = {'agent': GeneratedComponent(name='agent', description='Audience-tagged sections of the agent instructions file', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='agent_location', target='claude.md'), '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, ephemeral=False, location_field=''), '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, ephemeral=False, location_field='', source_file='bumpversion.toml', tool_section='tool.bumpversion', 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.PACKAGE_ONLY: 4>, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='', target='changelog.md'), 'coverage': ToolConfigComponent(name='coverage', description='Coverage.py measurement and reporting configuration', init_default=<InitDefault.EXPLICIT: 4>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='', source_file='coverage.toml', tool_section='tool.coverage', sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'labels': BundledComponent(name='labels', description='Label definitions for labelmaker (labels.toml)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(FileEntry(source='labels.toml', target='labels.toml', file_id='labels.toml', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='', tree=False),), config_key='', config_default=True, keep_unmodified=False, ephemeral=True, location_field=''), '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, ephemeral=False, location_field='', source_file='lychee.toml', tool_section='tool.lychee', 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, ephemeral=False, location_field='', source_file='mdformat.toml', tool_section='tool.mdformat', 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, ephemeral=False, location_field='', source_file='mypy.toml', tool_section='tool.mypy', sync_mode=<SyncMode.BOOTSTRAP: 1>, preserved_keys=(), graft_identity_keys=(), overlay=False), 'plugin': GeneratedComponent(name='plugin', description='Claude Code plugin marketplace wiring (.claude/settings.json)', init_default=<InitDefault.EXCLUDE: 2>, scope=<RepoScope.ALL: 1>, files=(), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='settings_location', target='.claude/settings.json'), '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.PACKAGE_ONLY: 4>, 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='', tree=False),), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field=''), '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, ephemeral=False, location_field='', source_file='pytest.toml', tool_section='tool.pytest', 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, ephemeral=False, location_field='', source_file='ruff.toml', tool_section='tool.ruff', 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='skills/av-false-positive', target='.claude/skills/av-false-positive', file_id='av-false-positive', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/awesome-triage', target='.claude/skills/awesome-triage', file_id='awesome-triage', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/babysit-ci', target='.claude/skills/babysit-ci', file_id='babysit-ci', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality', tree=True), FileEntry(source='skills/benchmark-update', target='.claude/skills/benchmark-update', file_id='benchmark-update', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/brand-assets', target='.claude/skills/brand-assets', file_id='brand-assets', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/file-bug-report', target='.claude/skills/file-bug-report', file_id='file-bug-report', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/github-housekeeping', target='.claude/skills/github-housekeeping', file_id='github-housekeeping', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/repomatic-audit', target='.claude/skills/repomatic-audit', file_id='repomatic-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/repomatic-changelog', target='.claude/skills/repomatic-changelog', file_id='repomatic-changelog', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/repomatic-deps', target='.claude/skills/repomatic-deps', file_id='repomatic-deps', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/repomatic-init', target='.claude/skills/repomatic-init', file_id='repomatic-init', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Setup', tree=True), FileEntry(source='skills/repomatic-ship', target='.claude/skills/repomatic-ship', file_id='repomatic-ship', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Release', tree=True), FileEntry(source='skills/repomatic-test-matrix', target='.claude/skills/repomatic-test-matrix', file_id='repomatic-test-matrix', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Quality', tree=True), FileEntry(source='skills/repomatic-topics', target='.claude/skills/repomatic-topics', file_id='repomatic-topics', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Development', tree=True), FileEntry(source='skills/sphinx-docs-sync', target='.claude/skills/sphinx-docs-sync', file_id='sphinx-docs-sync', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/translation-sync', target='.claude/skills/translation-sync', file_id='translation-sync', scope=<RepoScope.AWESOME_ONLY: 2>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True), FileEntry(source='skills/upstream-audit', target='.claude/skills/upstream-audit', file_id='upstream-audit', scope=<RepoScope.ALL: 1>, config_key='', config_default=False, reusable=True, phase='Maintenance', tree=True)), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='skills_location'), 'subagents': BundledComponent(name='subagents', description='Agent subagent 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='', tree=False), 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='', tree=False), 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='', tree=False)), config_key='', config_default=True, keep_unmodified=True, ephemeral=False, location_field='subagents_location'), '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, ephemeral=False, location_field='', source_file='typos.toml', tool_section='tool.typos', 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, ephemeral=False, location_field='', source_file='uv.toml', tool_section='tool.uv', 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='', tree=False), 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='', tree=False), 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='', tree=False), FileEntry(source='changelog.yaml', target='.github/workflows/changelog.yaml', file_id='changelog.yaml', scope=<RepoScope.PACKAGE_ONLY: 4>, config_key='', config_default=False, reusable=True, phase='', tree=False), 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='', tree=False), 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='', tree=False), 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='', tree=False), 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='', tree=False), FileEntry(source='metrics.yaml', target='.github/workflows/metrics.yaml', file_id='metrics.yaml', scope=<RepoScope.ALL: 1>, config_key='metrics.sync', config_default=False, reusable=True, phase='', tree=False), FileEntry(source='_release-engine.yaml', target='.github/workflows/release.yaml', file_id='release.yaml', scope=<RepoScope.PACKAGE_ONLY: 4>, config_key='', config_default=False, reusable=True, phase='', tree=False), 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='', tree=False), 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='', tree=False)), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='')}

Index for O(1) component lookup by name.

repomatic.registry.REMOVED_ASSETS: tuple[RemovedAsset, ...] = (RemovedAsset(component='codecov', target='.github/codecov.yaml', removed_in='7.8.0.dev0', hashes=('e8e96bfead62334599f4ec4c0448f2376352629789a70a76ae6fc3746ff7057b',), owned_dir='', successor='coverage is now gated by pytest --cov-fail-under'), RemovedAsset(component='labels', target='.github/labeller-content-based.yaml', removed_in='7.11.0.dev0', hashes=('1f3e670c0b4c6687a8920fb3738a15fb82b8639b7825d81f76c55bc5784cdb08', 'adf62c78c539229d34d4d2518a9af7f39df44d599c60852784b0faa47a6defa9', '5cf481b4aec2bf98a4056757f41ef5fc50f808dbd7c8a43f1dea0b224ecb7f1f', '8a047d53d5449ea0b53517e2f63e126360050127342084b7a705f34fb735d818'), owned_dir='', successor='rules now live in repomatic.labels.DEFAULT_CONTENT_RULES'), RemovedAsset(component='labels', target='.github/labeller-file-based.yaml', removed_in='7.11.0.dev0', hashes=('9dc0948e23a3a83d2cec5f11e400c75992fb1ce326eb6c5811c1fc3bfe258b31', 'b216d370e4d2c6118f46d9bb2eacaf91392e6a6267a4f4857f44a698422cc860', '9a4feeb49c37ee7eba1d13957d26aaaa867c791ec12be8cd4197e7526bfbf963'), owned_dir='', successor='rules now live in repomatic.labels.DEFAULT_FILE_RULES'), RemovedAsset(component='skills', target='.claude/skills/gha-changelog/SKILL.md', removed_in='6.0.0', hashes=('2c178a58e1106f08aa6e540cd022eff12c4e954942ec5d794282c7b640adf768',), owned_dir='.claude/skills/gha-changelog', successor='renamed to repomatic-changelog'), RemovedAsset(component='skills', target='.claude/skills/gha-deps/SKILL.md', removed_in='6.0.0', hashes=('d0bcb44f81335f4aabcadb82085f5048be12db252fc0a1f8c6bda8d9e5292efd',), owned_dir='.claude/skills/gha-deps', successor='renamed to repomatic-deps'), RemovedAsset(component='skills', target='.claude/skills/gha-init/SKILL.md', removed_in='6.0.0', hashes=('0f4f23f424c73774dd6253d9cb547e7a1d52ed64266c93b5b7271f4bee492a25',), owned_dir='.claude/skills/gha-init', successor='renamed to repomatic-init'), RemovedAsset(component='skills', target='.claude/skills/gha-lint/SKILL.md', removed_in='6.0.0', hashes=('7079f4d79c6347b03b4788de97db2e1839006b606e9dbacbfeb51e9cca04db20',), owned_dir='.claude/skills/gha-lint', 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',), owned_dir='.claude/skills/gha-metadata', 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',), owned_dir='.claude/skills/gha-release', successor='replaced by repomatic-ship'), RemovedAsset(component='skills', target='.claude/skills/gha-sync/SKILL.md', removed_in='6.0.0', hashes=('f856f143db3f0ad37adb6c80b89c33efa5112e1307927ff3331f82857a71fef4',), owned_dir='.claude/skills/gha-sync', 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',), owned_dir='.claude/skills/gha-test', 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',), owned_dir='.claude/skills/repokit-changelog', successor='renamed to repomatic-changelog'), RemovedAsset(component='skills', target='.claude/skills/repokit-deps/SKILL.md', removed_in='6.0.1', hashes=('577687ae8481cc67b992497ee0de9fb38c0f26cd20a9b907a4bf78f834803cc0',), owned_dir='.claude/skills/repokit-deps', successor='renamed to repomatic-deps'), RemovedAsset(component='skills', target='.claude/skills/repokit-init/SKILL.md', removed_in='6.0.1', hashes=('c68a9108ead81c4bb5b33912770155f6a587188ca72c8ba8d08f7283fdcad281',), owned_dir='.claude/skills/repokit-init', successor='renamed to repomatic-init'), RemovedAsset(component='skills', target='.claude/skills/repokit-lint/SKILL.md', removed_in='6.0.1', hashes=('1c05f0fb8c5ff8eed38ac02af2fff016e931fdf8866fd93a3fc6c61f84d4df52',), owned_dir='.claude/skills/repokit-lint', 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',), owned_dir='.claude/skills/repokit-metadata', 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',), owned_dir='.claude/skills/repokit-release', successor='replaced by repomatic-ship'), RemovedAsset(component='skills', target='.claude/skills/repokit-sync/SKILL.md', removed_in='6.0.1', hashes=('412811337a541b6c4518e588240ce2cb13f3f476bcd311f32edcf04394e17ade',), owned_dir='.claude/skills/repokit-sync', 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',), owned_dir='.claude/skills/repokit-test', 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'), owned_dir='.claude/skills/repomatic-lint', 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'), owned_dir='.claude/skills/repomatic-metadata', 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'), owned_dir='.claude/skills/repomatic-release', 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'), owned_dir='.claude/skills/repomatic-sync', 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'), owned_dir='.claude/skills/repomatic-test', successor='now handled by tests.yaml on every push'), RemovedAsset(component='workflows', target='.github/workflows/label-sponsors.yaml', removed_in='4.25.0', hashes=(), owned_dir='', successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/labeller-content-based.yaml', removed_in='4.25.0', hashes=(), owned_dir='', successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/labeller-file-based.yaml', removed_in='4.25.0', hashes=(), owned_dir='', successor='merged into labels.yaml'), RemovedAsset(component='workflows', target='.github/workflows/renovate.yaml', removed_in='7.0.0.dev0', hashes=(), owned_dir='', 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_PACKAGE: str = 'repomatic'

Distribution name of the upstream toolkit, derived from DEFAULT_REPO.

The freeze, cooldown-exemption, and lint code that handles the uses: refs and the inline self-pin all key on this name: deriving it here keeps the writer/checker pairs in lockstep and makes a rename a one-line change.

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] = {'agent': 'Audience-tagged sections of the agent instructions file', 'awesome-template': 'Boilerplate for awesome-* repositories', 'bumpversion': 'bump-my-version configuration', 'changelog': 'Minimal changelog.md', 'coverage': 'Coverage.py measurement and reporting configuration', 'labels': 'Label definitions for labelmaker (labels.toml)', 'lychee': 'Lychee link checker configuration', 'mdformat': 'mdformat Markdown formatter configuration', 'mypy': 'Mypy type checking configuration', 'plugin': 'Claude Code plugin marketplace wiring (.claude/settings.json)', '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/)', 'subagents': 'Agent subagent definitions (.claude/agents/)', 'typos': 'Typos spell checker configuration', 'uv': 'uv resolver pin and dependency cooldown policy', 'workflows': 'Thin-caller workflow files'}

All available init components.

repomatic.registry.EPHEMERAL_TARGETS: frozenset[str] = frozenset({'labels.toml'})

Target paths belonging to Component.ephemeral components.

Written only when the component is named explicitly on the CLI, and never worth committing: whatever reads them regenerates them first. init uses this to keep its closing “commit the generated files” advice off a run that produced nothing but scratch output.

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', '.claude/skills/awesome-triage', '.claude/skills/babysit-ci', '.claude/skills/benchmark-update', '.claude/skills/brand-assets', '.claude/skills/file-bug-report', '.claude/skills/github-housekeeping', '.claude/skills/repomatic-audit', '.claude/skills/repomatic-changelog', '.claude/skills/repomatic-deps', '.claude/skills/repomatic-init', '.claude/skills/repomatic-ship', '.claude/skills/repomatic-test-matrix', '.claude/skills/repomatic-topics', '.claude/skills/sphinx-docs-sync', '.claude/skills/translation-sync', '.claude/skills/upstream-audit', '.github/actions/publish-pypi/action.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', 'metrics.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', 'metrics.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', 'metrics.yaml': 'metrics.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.SELF_MAINTENANCE_WORKFLOWS: frozenset[str] = frozenset({'self-maintenance.yaml'})

Workflows that maintain this package’s own source and never ship downstream.

Unlike RELEASE_ENGINE_WORKFLOWS, which downstream repos still reach remotely through a uses: ref at a release tag, these are invisible outside this repository: they are not FileEntry targets, carry no repomatic/data/ symlink, and nothing resolves them at runtime. That is what lets their jobs drop the github.repository == 'kdeldycke/repomatic' guard every in-autofix.yaml upstream-only step needs, and pick a schedule without spending downstream CI.

A workflow belongs here when its write domain is a path that exists only in this repository (repomatic/tool_registry.py and friends). A workflow that merely behaves differently upstream does not: it still ships, so it still needs the runtime guard.

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', 'github-housekeeping': 'Maintenance', 'repomatic-audit': 'Maintenance', 'repomatic-changelog': 'Release', 'repomatic-deps': 'Development', 'repomatic-init': 'Setup', 'repomatic-ship': 'Release', 'repomatic-test-matrix': 'Quality', 'repomatic-topics': 'Development', 'sphinx-docs-sync': 'Maintenance', 'translation-sync': 'Maintenance', 'upstream-audit': 'Maintenance'}

Maps skill names to lifecycle phases for display grouping.

repomatic.registry.skill_catalog()[source]

Read every bundled skill’s display metadata off its frontmatter.

Return type:

list[tuple[str, str, str]]

Returns:

One (phase, name, description) tuple per bundled skill, in registry order, with the description’s trailing period stripped for table display. Phases are keyed by the registry file_id, not the frontmatter name, so a skill renamed in frontmatter still lands in its phase.

repomatic.registry.FILE_SELECTOR_COMPONENTS: tuple[str, ...] = ('labels', 'publish-pypi-action', 'subagents', 'skills', 'workflows')

Components that support file-level component/file selectors.

repomatic.registry.COMPONENT_HELP_TABLE: str = '    labels                 Label definitions for labelmaker (labels.toml)\n    publish-pypi-action    Composite action that publishes to PyPI via Trusted Publishing (.github/actions/publish-pypi/)\n    subagents              Agent subagent 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    plugin                 Claude Code plugin marketplace wiring (.claude/settings.json)\n    agent                  Audience-tagged sections of the agent instructions file\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    coverage               Coverage.py measurement and reporting 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.runner_catalog module

Which runner images exist, what they are called, and which are on the way out.

actions/runner-images publishes an Available Images table in its readme with one row per image, carrying the display name, the architecture, the runs-on: labels that reach it, and inline preview / deprecated badges. That table is the canonical dictionary for two questions nothing else answers cleanly:

  • Which generation a label belongs to. Deriving one from the other by pattern fails on macOS, where the generations disagree: macos-14 is the Arm64 image while its x64 twin is macos-14-large, and macos-26-intel breaks the pattern again. The display name states the family and the version that the labels only imply, so a successor search stays inside one operating system and orders its generations correctly.

  • Which images are current. The badges mark preview and deprecated images, which is what makes a retirement visible before a build starts failing.

This table is the only source read. repomatic.runner_images carries why it replaced the announcement feed, and what that trade costs.

Caution

Every parse here fails closed. A restyled table yields no rows, which makes the catalog unavailable rather than wrong, and every caller treats an unavailable catalog as “propose nothing”. A wrong label would rewrite a runs-on: to something GitHub does not host, which fails every job in the repository; a missing one costs a cycle of not noticing.

repomatic.runner_catalog.CATALOG_REPO = 'actions/runner-images'

Repository whose readme carries the Available Images table.

repomatic.runner_catalog.TABLE_HEADER_RE = re.compile('^\\|\\s*Image\\s*\\|\\s*Architecture\\s*\\|\\s*YAML Label\\s*\\|', re.MULTILINE)

The table’s header row, matched by column name rather than by position.

Anchoring on the names is what survives a column being added or reordered: the row is located by what it says, and the cells below it are read by the index this match establishes rather than by a hard-coded one.

repomatic.runner_catalog.BADGE_RE = re.compile('!\\[(?P<state>preview|deprecated)\\]')

A status badge, identified by its alt text rather than its image URL.

The URL carries a colour and a style that GitHub restyles freely; the alt text is the word a reader sees and has stayed put across restyles.

repomatic.runner_catalog.LABEL_RE = re.compile('`([a-z][a-z0-9.\\-]*)`')

A runs-on: label, backticked inside the YAML Label cell.

The cell separates alternatives in prose (”macos-latest, macos-26 or macos-26-xlarge”), so the backticks are what delimit a label rather than the punctuation around them.

repomatic.runner_catalog.LATEST_TOKEN = 'latest'

Hyphen-separated part marking a floating alias, dropped on sight.

Tested per part rather than as a suffix, because the alias is not always trailing: the x64 macOS row offers macos-latest-large beside macos-26-intel, and a -latest$ test keeps the very label lint-repo rejects. GitHub repoints these with no commit to review, so filtering here means no caller can propose one by accident.

repomatic.runner_catalog.SIZED_SUFFIXES = ('-large', '-xlarge')

macOS size variants, deprioritized when picking one label from a row.

A row often lists an ordinary hosted label beside sized ones (macos-26 against macos-26-xlarge, macos-26-intel against macos-26-large). The sized ones are the paid larger runners, so they are never the default. Note that -intel is not a size variant: it is the x64 half of a macOS generation, and the label this project runs.

class repomatic.runner_catalog.RunnerImage(display_name, architecture, labels, preview, deprecated)[source]

Bases: object

One row of the Available Images table.

display_name: str

Name as the table writes it, badges and endpoint markup stripped.

The only place the operating system and the generation are spelled out, so family and version both read it rather than the labels.

architecture: str

x64 or arm64, as the table’s own column spells it.

labels: tuple[str, ...]

Every runs-on: label reaching this image, -latest aliases removed.

preview: bool

Whether the row is badged as a public preview.

deprecated: bool

Whether the row is badged as deprecated.

property family: str

Leading word of the display name: Ubuntu, macOS or Windows.

Used to keep a successor search inside one operating system, which the labels alone cannot express (macos-26-intel and macos-26 share a family that no common label prefix captures).

property version: tuple[int, ...]

Numeric version read out of the display name, for ordering.

Ubuntu 26.04 Arm64 sorts above Ubuntu 24.04, and a name carrying no number at all (Ubuntu Slim) sorts below every numbered sibling rather than raising.

property preferred_label: str

The one label to write into a runs-on: for this image.

Prefers a plain label over a sized variant, so a row offering macos-26 beside macos-26-xlarge yields the ordinary hosted runner.

repomatic.runner_catalog.parse_catalog(readme)[source]

Read the Available Images table out of a readme.

Parameters:

readme (str) – Full Markdown source of the actions/runner-images readme.

Return type:

list[RunnerImage]

Returns:

One RunnerImage per table row, empty when the table cannot be located or yields no usable row.

repomatic.runner_catalog.fetch_catalog(repo='actions/runner-images')[source]

Download and parse the Available Images table.

Read through gh rather than a bare HTTP GET: the authenticated path carries a rate limit a CI job will not exhaust, where the anonymous one shares 60 requests an hour with every other job on the runner.

Parameters:

repo (str) – Repository whose readme to read.

Return type:

list[RunnerImage]

Returns:

The catalog, empty when the readme could not be read or parsed. Callers treat an empty catalog as “propose nothing” rather than as “nothing exists”.

repomatic.runner_catalog.by_label(catalog)[source]

Index a catalog by every label reaching each image.

Return type:

dict[str, RunnerImage]

repomatic.runner_catalog.live_siblings(current, catalog)[source]

Every image that could host a job currently on current.

Same operating system and architecture, not itself, and not on its way out. Version is deliberately not filtered: a dying image whose family offers only a same-version sibling still has somewhere to go, and going there beats staying on a deadline.

Parameters:
Return type:

list[RunnerImage]

Returns:

Candidates, unordered.

repomatic.runner_catalog.successor_for(label, catalog)[source]

The image a workflow on label should move to when its own is retiring.

Prefers a released image over a preview, then the highest version. The ordering matters more than it looks: a retirement is a forced move, and landing it on something GitHub is still rolling out trades a known deadline for an unknown one. So a released successor always wins, however old.

A preview is still returned when the family offers nothing else, because the alternative is proposing nothing and leaving the job on an image with an end date. newer_preview_than() surfaces the preview separately when a released successor was chosen, so a reviewer sees the fresher option without it being taken on their behalf.

Parameters:
  • label (str) – Label whose image is retiring, or has vanished.

  • catalog (Sequence[RunnerImage]) – Parsed catalog.

Return type:

RunnerImage | None

Returns:

The best replacement, or None when the family offers none.

repomatic.runner_catalog.newer_preview_than(chosen, current, catalog)[source]

A preview image newer than the one successor_for() settled on.

Reported rather than adopted. Whether a fresher preview beats a released image is a capacity-and-risk judgement the pull request exists to host, so naming the alternative in the body is the useful half; picking it is not.

Parameters:
Return type:

RunnerImage | None

Returns:

The newest preview above chosen, or None.

repomatic.runner_catalog.newer_version_than(label, catalog)[source]

A genuinely newer version of the image behind label, if one exists.

Strictly newer by version, which is what separates an upgrade from a flavour. Windows 11 Arm64 with Visual Studio 2026 sits at the same version as Windows 11 Arm64 and is a different toolchain rather than a newer image, so it is not an upgrade and is not reported as one.

Parameters:
Return type:

RunnerImage | None

Returns:

The newest strictly-higher version available, or None.

repomatic.runner_images module

Keep a repository’s runner images current against what GitHub still offers.

A runs-on: value is the one dependency in a workflow that nothing bumps: Dependabot rewrites uses: references, sync-workflow-pins rewrites version literals, and neither touches a runner image. So an image retires on GitHub’s schedule, entirely outside this repository’s view, and the first sign is a failing build.

The source is the Available Images table (repomatic.runner_catalog), compared against the labels this repository actually runs. Nothing else is read.

Note

Why the table and not the announcement feed

This module previously polled the Announcement-labelled issues of actions/runner-images, and the two questions turn out to be different ones. The feed reports what changed for anyone; the table reports what is true for me, and only the second decides anything. Polling produced an issue whose every row was an image this repository either already ran or never would.

Two things are given up, both deliberately. GitHub badges an image deprecated when deprecation begins rather than when it is announced, so a retirement surfaces here months later than the feed would have shown it: for Ubuntu 22.04, September rather than June. What remains is still ample, since the badge lands well before the image stops working. And a change to the contents of an image already in use, like a default toolchain moving, is invisible in the table; the test suite is what catches those.

Caution

An unreadable or restyled table yields an empty catalog, and every caller here reads that as “propose nothing” rather than “nothing exists”. Failing closed costs a cycle of not noticing; failing open would rewrite a runs-on: to an image GitHub does not host, taking every job with it.

repomatic.runner_images.LEGACY_ISSUE_TITLE = 'GitHub runner image announcements'

Title of the issue this module used to maintain, closed on sight.

Dropping the announcement feed stopped anything from managing that issue, and an issue nothing manages never closes: every repository that ran the old version would keep one open forever, listing announcements no longer read. Closing it from here is the issue-shaped equivalent of a RemovedAsset tombstone.

class repomatic.runner_images.RunnerChange(kind, label, successor, locations, reason, alternative)[source]

Bases: object

One runner-image edit the available-images table justifies.

kind: str

retirement when the current image is going away, upgrade when a strictly newer version of it exists.

label: str

Label this repository runs today.

successor: str

Label to move onto, or to probe.

locations: tuple[str, ...]

file.yaml:job-id entries naming label, for a retirement.

reason: str

Why the table says this change is warranted.

alternative: str

A newer preview passed over in favour of a released successor.

Reported, never taken. Whether a fresher preview beats a released image is a capacity judgement, and the pull request exists to host exactly that.

property summary: str

One line naming the change, for a commit subject or a table row.

repomatic.runner_images.plan_runner_changes(literal, tracked, catalog, ignore=())[source]

Work out which runner-image edits the table justifies.

Every label this repository runs is looked up in the table, and yields at most one change:

  • Retirement. The row is badged deprecated, or the label is absent from the table entirely, which means the image is already gone. Jobs naming it outright move to successor_for()’s pick. Only literal runs-on: values are reachable: one built from an expression draws on a matrix axis, which is the axis owner’s to move.

  • Upgrade. A strictly newer version exists. It joins the full matrix as a continue-on-error probe rather than replacing anything, so nothing is bet on it while the suite starts exercising it.

Strictly newer by version is what separates an upgrade from a flavour. Windows 11 Arm64 with Visual Studio 2026 sits at the same version as Windows 11 Arm64: a different toolchain, not a newer image, and proposing it as an upgrade would be wrong.

Parameters:
  • literal (Mapping[str, Sequence[str]]) – Labels named outright in workflows, mapped to their locations, as literal_runners() reports them.

  • tracked (Iterable[str]) – Every image this repository has a stake in.

  • catalog (Sequence[RunnerImage]) – Parsed available-images table.

  • ignore (Iterable[str]) – Labels the repository has declined. A sync-* job regenerates on every run, so without this a closed pull request comes back and the proposal becomes a nuisance rather than a service.

Return type:

list[RunnerChange]

Returns:

The changes to propose, retirements first.

repomatic.runner_images.render_change_table(changes)[source]

Render proposed changes as a Markdown table for a pull request body.

Carries the reasoning rather than just the edit: the diff shows what moved, and what a reviewer cannot see there is why the table says it had to, which jobs are affected, and what was passed over.

Parameters:

changes (Sequence[RunnerChange]) – Changes from plan_runner_changes().

Return type:

str

Returns:

A GitHub-flavored Markdown table, newline-terminated.

repomatic.runner_images.close_legacy_issue()[source]

Close the announcement issue this module no longer maintains.

Called on every run rather than once, because there is no “once” available: a downstream repository adopts a release whenever it adopts one, and the first run after that adoption is the only moment this can be noticed. The close is a no-op when no such issue is open.

Return type:

None

repomatic.runner_images.RUNS_ON_RE_TEMPLATE = '(?P<prefix>^[ \\t]*runs-on:[ \\t]*)(?P<quote>[\'\\"]?){label}(?P=quote)[ \\t]*$'

A literal runs-on: naming one label, anchored to its own line.

Rewritten as raw text rather than through a YAML round-trip, for the reason _extract_raw_job() gives: a round-trip reformats the whole file, and a runner bump should read as a one-line diff. The optional quote group is carried through so a quoted value stays quoted.

repomatic.runner_images.apply_retirement(change, workflow_dir)[source]

Rewrite every literal runs-on: naming a retiring label.

Idempotent: a file already on the successor matches nothing and is left untouched, so a re-run after a merge is a no-op rather than a second edit.

Parameters:
Return type:

list[Path]

Returns:

The files actually rewritten.

repomatic.runner_images.AXIS_LABEL_RE_TEMPLATE = '(?P<quote>["\\\']){label}(?P=quote)'

A runner label as a quoted string literal in the curated axes.

Rewritten as text for the same reason a runs-on: is: the axes are a hand-kept tuple carrying comments and an ordering that says which runner is the fast one, and rebuilding the module from an AST would discard both.

repomatic.runner_images.apply_axes_retirement(change, axes_path)[source]

Move a retiring label forward in the curated test-matrix axes.

Only meaningful inside kdeldycke/repomatic, where the axes live. A repo consuming repomatic inherits them through the pin, so its matrix moves when it adopts a release rather than when it edits anything.

This is the highest-blast-radius edit the operation makes: every downstream repository picks these axes up at the next release. That is the argument for proposing it in a pull request whose own CI runs the full matrix on the new image, rather than for not proposing it.

Parameters:
Return type:

bool

Returns:

Whether the file was modified.

repomatic.runner_images.apply_upgrade(change, pyproject_path)[source]

Add a superseding image to the full test matrix as a failing-allowed probe.

Writes two keys under [tool.repomatic.test-matrix]: the label joins the os axis through variations, and an unstable entry marks every cell carrying it continue-on-error. Both are needed and neither alone is useful: the variation without the unstable entry gates the build on an image nobody has vetted, and the unstable entry without the variation matches nothing.

Idempotent: an image already probed is detected in both keys and nothing is written.

Parameters:
Return type:

bool

Returns:

Whether the file was modified.

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.CANNOT_VERIFY = '\n\n> [!NOTE]\n> This setting could not be verified: `REPOMATIC_PAT` is missing the **Administration: Read-only** permission. Update the token with the pre-filled link in the first step. The setting may well be correct already, but nothing here can confirm it.'

Note appended to a step whose probe could not run.

Both settings it covers are read through Administration-scoped endpoints, so a PAT issued without that permission answers 403 and the check lands on None. Saying so in the step beats dropping it: dropping also hid the token gap itself, since the missing permission had no other symptom.

class repomatic.setup_guide.GuideContext(config, repo, has_pat, has_notifications_pat, has_virustotal_key, has_cloudflare_api_token)[source]

Bases: object

Everything the steps read, resolved once per run.

The expensive lookups (PAT permission probes, pyproject.toml) are cached properties, so a step that never asks never pays and two steps asking the same question share one answer.

config: Config

The resolved [tool.repomatic] configuration.

repo: str | None

Repository in owner/repo form, or None when undetectable.

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.

has_cloudflare_api_token: bool

Whether CLOUDFLARE_API_TOKEN is configured.

property md: Metadata[source]

CI and project context, for the repository identity fields.

property has_changelog: bool[source]

Whether the configured changelog exists on disk.

property nuitka_active: bool[source]

Whether this project compiles binaries with Nuitka.

property pypi_package_name: str | None[source]

The PyPI name to register a Trusted Publisher for, if any.

Gated on is_python_package rather than on package_name being set: a uv virtual project declares [project] name purely to carry dependencies, so the name alone says nothing about whether anything is ever published. Asking those projects to register a publisher points them at a PyPI name they do not own, for a workflow file they do not have.

property pat_results: PatPermissionResults | None[source]

The PAT permission probe results, or None when unrunnable.

property missing_permissions_section: str[source]

Warning table naming the permissions the configured PAT lacks.

property token_ok: bool

Whether a PAT is configured and every permission probe passed.

property cloudflare_secrets_ok: bool

Whether the Cloudflare Pages deploy can authenticate.

The token alone settles it: the account it belongs to is derived from it at run time, even when it is scoped to nothing but Cloudflare Pages: Edit, so there is no second identifier to configure and nothing else to ask for here.

property cloudflare_token_name: str

Suggested name for the deploy token, carrying the month it was made.

Cloudflare’s token list shows what a token can do and never how old it is, while the rotation procedure turns entirely on telling the incumbent from its replacement. Stamping the month into the name is what makes a token approaching its one-year expiry obvious at a glance, and what lets the two coexist unambiguously during a handover.

Recomputed per run, so the name the guide suggests stays current while the step is still open. It stops moving once the issue closes, which is the point at which the body is no longer rewritten.

deploys_to(target)[source]

Whether this repository publishes its site to target.

One host’s setup step is the other’s noise, and the guide asks about exactly the one site.deploy names: a Cloudflare-hosted project has no GitHub Pages source to set, and the probe for it answers 404 forever.

The GitHub Pages half stays gated on Sphinx, because the Docs workflow is the only publisher repomatic runs for that host and it only builds Sphinx trees. The Cloudflare half follows the declaration alone: a repository whose site is built by its own workflow still needs the project and the two credentials this guide walks through.

Return type:

bool

property dependabot_ok: bool

Whether vulnerability alerts are confirmed enabled.

Piggybacks the Dependabot alerts permission probe, which only answers 200 when the alerts themselves are on.

probe_settings(check)[source]

Run a repository-settings check, or report it as failed.

Without a PAT or a repository there is nothing to read, and the step is reported incomplete rather than indeterminate: the reader still has to perform it.

Return type:

bool | None

probe_trusted_publisher()[source]

Whether PyPI provenance confirms the Trusted Publisher entry.

Needs no PAT: the probe hits the public PyPI integrity API.

Return type:

bool | None

class repomatic.setup_guide.SetupStep(placeholder, title, template, probe=<function SetupStep.<lambda>>, applies=<function SetupStep.<lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False)[source]

Bases: object

One step of the setup guide, declared once and read by every phase.

The guide used to spell each step out four times: a probe, a render, a template keyword and a clause of the close gate. Keeping the four in sync was manual, and the applicability guards were duplicated between the probe and the render. One entry per step now drives all four.

placeholder: str

The $name this step’s rendered block fills in setup-guide.md.

title: str

Heading shown in the collapsible section’s <summary> line.

template: str

Template rendered as the section’s body.

probe()

Read the step’s completion state. Tri-state, per CheckResult.

applies()

Whether this repository needs the step at all.

A step that does not apply renders nothing and satisfies its gate, so a non-Sphinx project is never asked about Pages.

args()

Template variables, defaulting to the pair almost every step wants.

gates_closure: bool = True

Whether this step’s outcome can hold the issue open.

False for the two steps with nothing to probe (immutable releases, the final verification), which would otherwise wedge the issue open forever.

tolerates_unknown: bool = False

Whether an indeterminate probe (None) satisfies the gate.

True for the settings read through Administration-scoped endpoints: a PAT without that permission answers 403, and a reader has no way to satisfy a check that cannot run, so it must not block the issue closing. Everywhere else None is treated as incomplete, keeping the step prompting.

explains_unverifiable: bool = False

Append CANNOT_VERIFY to the body when the probe answered None.

The reader is looking at a setting they were told to configure, so the difference between “verified” and “nobody could look” belongs on screen.

outcome(ctx)[source]

The step’s state as the section renders it.

None survives only where tolerates_unknown says an unreadable probe is not the reader’s fault; elsewhere it collapses to incomplete so the section stays open.

Return type:

bool | None

render(ctx)[source]

Render this step’s collapsible section, empty when it does not apply.

Return type:

str

satisfied(ctx)[source]

Whether this step lets the issue close.

A step that does not apply, or that gates nothing, is always satisfied.

Return type:

bool

repomatic.setup_guide.SETUP_STEPS: tuple[SetupStep, ...] = (SetupStep(placeholder='step_token', title='Create and configure the token', template='setup-guide-token', probe=<function <lambda>>, applies=<function SetupStep.<lambda>>, args=<function <lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_dependabot', title='Configure Dependabot settings', template='setup-guide-dependabot', probe=<function <lambda>>, applies=<function SetupStep.<lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='immutable_releases_step', title='Enable immutable releases', template='immutable-releases', probe=<function <lambda>>, applies=<function <lambda>>, args=<function <lambda>>, gates_closure=False, tolerates_unknown=True, explains_unverifiable=False), SetupStep(placeholder='step_branch_ruleset', title='Protect the main branch', template='setup-guide-branch-ruleset', probe=<function <lambda>>, applies=<function SetupStep.<lambda>>, args=<function <lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_fork_pr_approval', title='Require approval for fork PR workflows', template='setup-guide-fork-pr-approval', probe=<function <lambda>>, applies=<function SetupStep.<lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=True, explains_unverifiable=True), SetupStep(placeholder='step_sha_pinning_required', title='Require SHA pinning for GitHub Actions', template='setup-guide-sha-pinning-required', probe=<function <lambda>>, applies=<function SetupStep.<lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=True, explains_unverifiable=True), SetupStep(placeholder='step_pypi_trusted_publisher', title='Register the PyPI Trusted Publisher entry', template='setup-guide-pypi-trusted-publisher', probe=<function <lambda>>, applies=<function <lambda>>, args=<function <lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_pages_source', title='Set GitHub Pages deployment source to GitHub Actions', template='setup-guide-pages-source', probe=<function <lambda>>, applies=<function <lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_cloudflare_pages', title='Configure the Cloudflare Pages credentials', template='setup-guide-cloudflare-pages', probe=<function <lambda>>, applies=<function <lambda>>, args=<function <lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_virustotal', title='Configure VirusTotal scanning (optional)', template='setup-guide-virustotal', probe=<function <lambda>>, applies=<function <lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_notifications_pat', title='Create and configure the notifications token', template='setup-guide-notifications-pat', probe=<function <lambda>>, applies=<function <lambda>>, args=<function SetupStep.<lambda>>, gates_closure=True, tolerates_unknown=False, explains_unverifiable=False), SetupStep(placeholder='step_verify', title='Verify the setup', template='setup-guide-verify', probe=<function SetupStep.<lambda>>, applies=<function SetupStep.<lambda>>, args=<function SetupStep.<lambda>>, gates_closure=False, tolerates_unknown=False, explains_unverifiable=False))

Every step of the setup guide, in the order the issue body lists them.

repomatic.setup_guide.manage_setup_guide(config, *, has_pat, has_notifications_pat, has_virustotal_key, has_cloudflare_api_token=False, repo)[source]

Render the setup guide issue body and drive the issue lifecycle.

Walks SETUP_STEPS: each step probes its own state, renders its collapsible section, and reports whether it lets the issue close. The issue closes only when every applicable gating step passes.

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.

  • has_cloudflare_api_token (bool) – Whether CLOUDFLARE_API_TOKEN is configured.

  • repo (str | None) – Repository in owner/repo format; permission and settings checks are skipped when None.

Return type:

None

repomatic.site_anchors module

Same-page fragment links, checked against the anchors the build produced.

A literal ](#fragment) is the one cross-reference nothing resolves. A :ref: or :doc: role goes through Sphinx, which reports a missing target under nitpicky; a raw fragment is copied into the HTML untouched, so a slug that never existed ships as a link that looks fine and lands nowhere. The build stays green because it was never asked a question.

Caution

A Markdown link checker cannot stand in for this, because it has to guess the slug. Measured against lychee 0.24.2 on the heading ## The pages.dev hostname`: myst-parser builds``the-pages-dev-hostname`, lychee’s GitHub-style slugger wants the-pagesdev-hostname, and each reports the other as broken. That disagreement is why this repository excludes intra-docs fragments from lychee altogether, which left the class with no coverage at all until a #the-pagesdev-hostname link shipped against a the-pages-dev-hostname anchor.

The built page is the only authority, so that is what this reads. Fragments come from the Markdown source rather than from the rendered HTML, which is what keeps the check to what an author actually wrote: a theme’s own footnote backrefs and header permalinks never enter, so there is no denylist to keep.

repomatic.site_anchors.ANCHOR_ATTRIBUTES = frozenset({'id', 'name'})

HTML attributes a browser will scroll a fragment to.

repomatic.site_anchors.DEFAULT_BUILD_DIR = PosixPath('docs/_build')

Where the Sphinx builders in this project’s workflows write the site.

repomatic.site_anchors.DEFAULT_DOCS_DIR = PosixPath('docs')

Conventional root of a Sphinx source tree.

repomatic.site_anchors.FENCE_RE = re.compile('^\\s*(?:`{3,}|~{3,})')

Opening or closing line of a fenced code block.

An authored same-page link, ](#fragment).

Anchored on the ](# sequence, which is what makes it same-page: a link to another document carries a path before its # and is Sphinx’s problem, not this one.

repomatic.site_anchors.INLINE_CODE_RE = re.compile('(?P<ticks>`+)(?:.|\\n)*?(?P=ticks)')

An inline code span, of any backtick width.

repomatic.site_anchors.MARKDOWN_SUFFIX = '.md'

Extension of the sources scanned for authored links.

class repomatic.site_anchors.MissingAnchor(source, fragment, page)[source]

Bases: object

One authored fragment with no anchor to land on.

source: Path

Markdown file that wrote the link.

fragment: str

The fragment as authored, without its #.

page: Path

Built page the fragment was looked for in.

property message: str

The finding as a single reportable line.

class repomatic.site_anchors.AnchorReport(missing=<factory>, unbuilt=<factory>, checked=0)[source]

Bases: object

What one sweep over a docs tree found.

missing: list[MissingAnchor]

Every authored fragment that resolves to nothing.

unbuilt: list[Path]

Sources with no built page, so with nothing to check against.

A page left out of every toctree, or a fragment file meant only to be included by another, lands here. Reported rather than failed: the build is what decides which sources become pages, and it is not this check’s place to second-guess it.

checked: int = 0

How many authored fragments were resolved against a built page.

repomatic.site_anchors.page_anchors(html)[source]

Every fragment a built page can be scrolled to.

Parameters:

html (str) – Full source of one built page.

Return type:

set[str]

Returns:

The id and name values it carries.

repomatic.site_anchors.strip_code(text)[source]

Blank out every code span and fenced block of a Markdown source.

A fence showing ](#example) documents a link rather than making one, and checking it would fail a page for its own example. Lines are replaced rather than deleted so a reported line number still points at the source.

Parameters:

text (str) – Markdown source.

Return type:

str

Returns:

The same text with code content emptied.

repomatic.site_anchors.authored_fragments(text)[source]

Every same-page fragment a Markdown source links to.

Parameters:

text (str) – Markdown source.

Return type:

list[str]

Returns:

Fragments without their #, in source order, duplicates kept out.

repomatic.site_anchors.built_page(source, docs_dir, build_dir)[source]

Locate the page a Markdown source was rendered into.

Both Sphinx HTML builders are covered by trying each layout in turn: html writes {name}.html, dirhtml writes {name}/index.html. Probing rather than reading [tool.repomatic] sphinx.builder keeps the check honest about the tree in front of it, and correct for a caller pointed at a directory some other builder wrote.

Parameters:
  • source (Path) – The Markdown file.

  • docs_dir (Path) – Root the source tree is relative to.

  • build_dir (Path) – Root of the rendered site.

Return type:

Path | None

Returns:

The built page, or None when the source produced none.

repomatic.site_anchors.markdown_sources(docs_dir, build_dir)[source]

Every authored Markdown source under a docs tree.

Skips the rendered site, which commonly sits inside the source tree, and every underscore-prefixed directory, Sphinx’s own convention for the static and template folders that hold no authored prose.

Parameters:
  • docs_dir (Path) – Root of the documentation sources.

  • build_dir (Path) – Root of the rendered site, excluded when nested.

Return type:

Iterator[Path]

Returns:

The sources, in path order.

repomatic.site_anchors.check_anchors(docs_dir, build_dir)[source]

Resolve every authored fragment against the page it was built into.

Parameters:
  • docs_dir (Path) – Root of the documentation sources.

  • build_dir (Path) – Root of the rendered site.

Return type:

AnchorReport

Returns:

What the sweep found.

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. Workflow YAML cannot import Python, so autofix.yaml repeats this string literally, and the labeller’s own rule tables (repomatic.labels.DEFAULT_CONTENT_RULES and DEFAULT_FILE_RULES) key their dependency rules on the same spelling. tests/test_sync_ops.py asserts both copies match this constant, and tests/test_labels.py that it names a label labels.toml actually defines: applying an unknown label fails the gh call outright, so a rename in the registry has to reach all of them at once.

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.ToolVersionExtras(binary_overrides=<factory>, actionlint_version=None, checksums_path=None)[source]

Bases: object

sync-tool-versions write extras, applied after the source rewrite.

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_registry.py path the checksum recompute rewrites.

class repomatic.sync_ops.UvProjectExtras(exclude_newer='', reverted=False, pins_synced=False, pruned_bypasses=<factory>, frozen_bypasses=<factory>, bypass_forecasts=<factory>, source_swaps=<factory>)[source]

Bases: object

Extras of the uv-project pair (sync-uv-lock, sync-dep-sources).

The pair shares one write domain (uv.lock, pyproject.toml) and resolves under _UV_PROJECT_MUTEX. Each resolve already wrote those files, so these fields only inform the terminal and PR-body rendering.

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.

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://repomatic.net/configuration#minimum-release-age) cooldown window.', notes_section='', cooldown_note='', cutoff=None, reference_date=None, file_writes=<factory>, self_pin_exemptions=<factory>, rebase=None, tool_versions=<factory>, uv_project=<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://repomatic.net/configuration#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, as computed at resolve time.

Written verbatim by SyncOperation.apply only when rebase is unset; otherwise it records which files the resolve touched (and what it computed) while the apply replays the rewrite on current disk state.

self_pin_exemptions: list[str]

Workflow files that gained a missing self-pin cooldown exemption.

Names only the files whose sole edit was the splice, since a file that also moved a version is already reported through changes. Kept as a separate list for the same reason UvProjectExtras.frozen_bypasses is: the rewrite has no (name, old, new) triple to render, yet it still produced a hunk the report has to explain. Without it a splice-only run reads as “nothing to update” and the write is dropped, which is what let an exemption-less downstream pin sit broken indefinitely: the backfill only ever landed on a run that happened to move the version too.

rebase: Callable[[str], tuple[str, list[tuple[str, str, str]]]] | None = None

Replay this operation’s rewriter against a file’s current text.

Set by _plan_file_rewrites(). Applies run serially after every resolve finished, and the two .github/ pin updaters routinely plan rewrites of the same workflow files from the same pre-apply snapshot: writing file_writes verbatim would silently revert whichever sibling applied first. The closure re-runs the pure rewriter on whatever is on disk at apply time instead.

tool_versions: ToolVersionExtras

sync-tool-versions write extras (checksum recompute, matcher URL).

uv_project: UvProjectExtras

sync-uv-lock and sync-dep-sources rendering extras.

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. A run that only splices a missing self-pin exemption into a workflow counts for the same reason.

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 uv cooldown-bypass section, and the held-back section exactly as the individual sync-* commands do, so sync-deps and the thin commands produce identical output for the same plan.

Every other section reports what the run did to the working tree, so the held-back one closes the body: it is the only forward-looking section, listing releases the run deliberately left alone. A run that only rewrites exclude-newer-package entries moves no version at all, and leading with the forecast would open its PR on the releases it did not adopt instead of the pyproject.toml hunk it asks to merge.

Return type:

str

repomatic.sync_ops.print_sync_table(ctx, changes, dates, *, subject, reference_date)[source]

Print the shared terminal table for the dependency updaters.

Columns are {subject} | Old | New | Released, the released date carrying a relative hint. Shared by sync-uv-lock and the three sync-* commands so their terminal output matches, and respects the global --table-format. Old/New stay separate columns (not the merged Change cell of the markdown PR body) so structured --table-format json/csv output stays parseable.

Return type:

None

repomatic.sync_ops.print_held_back_table(ctx, held_back, *, subject='Package')[source]

Print the shared held-back terminal table for the cooldown-gated updaters.

Columns are subject followed by HELD_BACK_COLUMNS. Shared by sync-uv-lock and the three sync-* commands, and respects the global --table-format.

Return type:

None

repomatic.sync_ops.print_bypass_table(ctx, forecasts)[source]

Print the active cooldown-bypass freezes with their expiry forecasts.

Columns are BYPASS_COLUMNS, mirroring the markdown section from format_bypass_section(), and respects the global --table-format.

Return type:

None

repomatic.sync_ops.print_plan_tables(ctx, plan, reference_date)[source]

Print a resolved plan’s diff, bypass and held-back tables.

The terminal counterpart of render_plan_markdown(), deliberately beside it and in the same order, so a run’s terminal output and its PR body read the same way and cannot drift apart. Every dependency updater goes through here: the two lockfile commands, the three version-sync commands, and the aggregate sync-deps.

Each table respects the global --table-format, and an empty section prints nothing.

Return type:

None

repomatic.sync_ops.emit_lockfile_sync_report(ctx, plan, *, reference_date, table, output, output_format)[source]

Emit the terminal tables and markdown report of a lockfile sync.

sync-uv-lock and sync-dep-sources share this tail. They alone can suppress the terminal tables with --no-table (their CI jobs want only the markdown report), and they alone have an exclude-newer cutoff to announce, uv’s lock-level cooldown standing in for the minimum-release-age window the version-sync trio reports.

Return type:

None

repomatic.sync_ops.emit_version_sync_report(ctx, plan, output, output_format)[source]

Print a terminal report and optionally write a markdown PR-body report.

Shared by the three sync-* version updaters. The terminal table and the markdown PR body (diff table, held-back section, release notes) route through the same shared renderers sync-uv-lock and sync-deps use (render_plan_markdown()), so every dependency updater’s report matches.

Return type:

None

repomatic.sync_ops.run_version_sync(ctx, op_name, output, output_format, release_notes, held_back, up_to_date)[source]

Shared body of the three version-sync commands.

sync-tool-versions, sync-action-pins, and sync-workflow-pins differ only in their operation, feature flag, and messages: the resolve, apply, and report sequence is identical. The feature-flag guard stays with each command, which knows its own [tool.repomatic] key.

Parameters:
  • ctx (Context) – The Click context, exited with 0 when nothing needs updating.

  • op_name (str) – The OPERATIONS_BY_NAME key.

  • output (Path | None) – The --output report path.

  • output_format (str) – The --output-format value.

  • release_notes (bool) – Whether to fetch GitHub release notes.

  • held_back (bool) – Whether to report cooldown-held releases.

  • up_to_date (str) – Message printed when nothing needs updating.

Return type:

None

repomatic.sync_ops.resolve_lockfile_plan(op_name, config, *, lockfile, table, output, release_notes, held_back)[source]

Resolve one of the two lockfile-mutating operations.

sync-uv-lock and sync-dep-sources build the same resolve context and, unlike the version-sync trio, gate the held-back probe on a consumer being present: that probe costs a second full uv resolution, so a run that prints no table and writes no report must not pay for it.

The apply and the narration stay with each command, whose “what happened” lines differ (adopted releases for one, bypass lifecycle for the other).

Return type:

tuple[SyncOperation, ResolveContext, SyncPlan]

Returns:

(operation, resolve_context, plan).

class repomatic.sync_ops.SyncOperation(name, config_flag, job_name, job_if, resolve, apply, applies_here, write_domain, workflow='autofix.yaml', job='sync-deps', 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).

workflow: str = 'autofix.yaml'

Workflow file whose job runs this operation in CI.

job: str = 'sync-deps'

Job ID inside workflow hosting this operation’s steps.

Defaults to the consolidated sync-deps job, which shares one checkout across every bumper whose write domain exists downstream. An operation that writes only to this repository’s own source belongs in a job of its own, in a workflow repomatic init never materializes downstream (see SELF_MAINTENANCE_WORKFLOWS).

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).

property consolidated: bool

Whether this operation shares the multi-bumper sync-deps job.

A consolidated operation must reset the working tree before it runs, so the previous bumper’s diff never bleeds into its PR. An operation with a job to itself starts from a clean checkout and needs no reset.

Compared against the job field default rather than against a repeated "sync-deps" literal, so renaming the shared job is a one-line change.

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'), workflow='autofix.yaml', job='sync-deps', 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]'), workflow='autofix.yaml', job='sync-deps', 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'), workflow='autofix.yaml', job='sync-deps', 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'), workflow='autofix.yaml', job='sync-deps', 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='', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tool_registry.py', '.github/workflows/lint.yaml'), workflow='self-maintenance.yaml', job='sync-tool-versions', 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.

Only the first four share the sync-deps job in autofix.yaml. sync-tool-versions runs from self-maintenance.yaml on its own daily schedule, since it rewrites this package’s source and has no downstream meaning; the order still applies to a local repomatic sync-deps, which runs every enabled operation in one pass.

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'), workflow='autofix.yaml', job='sync-deps', 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'), workflow='autofix.yaml', job='sync-deps', 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='', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tool_registry.py', '.github/workflows/lint.yaml'), workflow='self-maintenance.yaml', job='sync-tool-versions', 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]'), workflow='autofix.yaml', job='sync-deps', 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'), workflow='autofix.yaml', job='sync-deps', 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. When labelled, an click_extra.OperationTrail reports each resolve as a / line and closes with a summary, its rendering tracking the resolved worker count and its elapsed times following --time (click-extra’s own default). 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) – Present-tense label for the resolve trail (like "Resolving dependency updates"). When set and attached to a TTY, the trail shows a / line per operation and a running tally; unset (programmatic and test calls) forces it silent, 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.tabular module

Render and persist the flat tables repomatic produces.

Two surfaces, one module, because both are the same shape of data: the CSV files a repository commits (a scan verdict, a binary in a release, a metric reading) and the Markdown tables its reports embed (a PR body’s diff table, a step summary’s tally). render_markdown_table() is the one Markdown table renderer, so every report agrees on the cell and separator spelling; the CSV trio below decides how the committed datasets are stored.

Note

CSV over JSON for these, on four counts a flat table makes decisive:

  • Diff churn. A record is one line, not seven. These files are sorted, so a scheduled append lands mid-file rather than at the end: ten new readings cost ten inserted lines instead of seventy.

  • Size. Roughly half, and the gap widens as a history accrues.

  • Rendering. MyST’s csv-table directive reads one directly, and GitHub serves a committed CSV through its own searchable grid viewer where JSON is raw text.

  • No formatter contention. Nothing in the autofix lane touches CSV, where a committed JSON file has to be serialized in Biome’s exact style or format-json rewrites it right back.

JSON earns its place where a record nests. None of these do.

repomatic.tabular.render_csv(headers, rows)[source]

Render a header row and its data rows as CSV text.

Newlines are \n on every platform, since the output is committed and a platform-dependent line ending would make the file churn between a Windows and a Unix runner.

Parameters:
Return type:

str

Returns:

The complete CSV document, newline-terminated.

repomatic.tabular.render_markdown_table(headers, rows, align=())[source]

Render a GitHub-flavored Markdown table.

Cells are used as given: a caller wanting a code span, a link or an emoji renders it into the cell first. Nothing is escaped, matching what every report renderer did by hand before this existed: none of them ever feeds a cell carrying a |.

Parameters:
  • headers (Sequence[object]) – Column titles, in order.

  • rows (Iterable[Sequence[object]]) – One sequence of cells per row, in the same order.

  • align (Sequence[str]) – Per-column alignment, left, right or center; an empty entry (or a list shorter than headers) leaves that column on the parser default. Alignment only changes how a renderer justifies the column, so it is worth declaring where it carries meaning, like a numeric column read against its neighbours.

Return type:

str

Returns:

The table’s lines joined with newlines, no trailing newline.

Raises:

KeyError – On an alignment name outside the vocabulary.

repomatic.tabular.read_csv(path)[source]

Read a committed CSV into one mapping per row.

Every cell comes back as a string: CSV carries no types, so a caller wanting a number coerces it. A missing file reads as no rows, which is what a first run sees.

Parameters:

path (Path) – Path to the CSV file.

Return type:

list[dict[str, str]]

Returns:

One mapping per data row, keyed by column name.

Raises:

ValueError – When the file exists but carries no header row. Loud on purpose: a truncated or half-written file must never be silently treated as empty and clobbered by the next write_csv().

repomatic.tabular.write_if_changed(path, content)[source]

Write content to path, leaving an already-matching file alone.

Creates the parent directories when missing. Comparing before writing is what every generator in the package leans on: one that rewrote its output unconditionally would turn each scheduled run into a commit, and a sync job that opens a pull request would open one forever.

Format-neutral despite sitting beside the CSV helpers, because what it encodes is the write rather than the bytes. The SVG charts in repomatic.metric_chart route through it too.

Parameters:
  • path (Path) – File to write.

  • content (str) – The full text the file should hold.

Return type:

bool

Returns:

True when the file was created or its content changed.

repomatic.tabular.write_csv(path, content)[source]

Write rendered CSV to path, leaving an unchanged file alone.

Parameters:
Return type:

bool

Returns:

True when the file content changed.

repomatic.tool_registry module

Declarative registry of the external tools repomatic run manages.

Each ToolSpec entry pins a tool’s version and, for binary-distributed tools, its per-platform download URLs and SHA-256 digests in CHECKSUMS; the paired VERSIONS map records the version each checksum set was computed for. The ArchiveFormat, NativeFormat, BinarySpec, and NpmSpec types describe how each tool is fetched and how its [tool.X] section is translated to the tool’s native config format. The repomatic run engine in tool_runner.py consumes this data to install and invoke each tool.

Note

sync-tool-versions and update-checksums rewrite this module’s version=, VERSIONS, and CHECKSUMS literals in place by string substitution, so their formatting must stay stable. The lint, autofix, and docs workflows key their tool caches on a hash of this file, so only a genuine version or checksum bump invalidates a cached tool download.

exception repomatic.tool_registry.UnsupportedPlatformError[source]

Bases: RuntimeError

Raised when a tool publishes no binary for the running platform.

Distinguished from every other install failure (a failed download, a checksum mismatch) because it is a property of the tool’s release matrix rather than a fault: nothing about the current run can make the binary exist. Asking for such a tool directly is still fatal, but a caller provisioning it as a companion can catch this alone and carry on without it. See repomatic.tool_runner._path_tools_env().

repomatic.tool_registry.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_registry.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_registry.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']

class repomatic.tool_registry.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_registry.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).

  3. The LINUX family, only when current_platform() is UNKNOWN_PLATFORM, so a distribution extra-platforms cannot name still reaches a family-wide key.

alias of tuple[Platform | Group, Architecture]

class repomatic.tool_registry.ToolBackend(short_label, long_label)[source]

Bases: Enum

How a registry tool is delivered and executed.

Each member carries the display labels the documentation generators render, so backends and their vocabulary live in one place: adding a backend means adding a member here and a branch in ToolSpec.backend(), and every consumer (docs tables, version-sync candidate sources) follows.

Note

Code that dereferences a backend’s payload still tests the field directly (spec.binary is not None narrows the optional for mypy in a way an enum comparison cannot); this enum serves the sites that only need to know which backend, not its payload.

BINARY = ('Binary', 'Binary (downloaded from GitHub Releases)')
NPM = ('npm', 'npm registry, run via `node_modules/.bin`')
VENV = ('PyPI (venv)', 'PyPI, runs in project virtualenv via `uv run`')
UVX = ('PyPI', 'PyPI, installed via `uvx`')
short_label

Cell text for the docs summary table.

long_label

Installation-method line in the per-tool reference sections.

class repomatic.tool_registry.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 | dict[tuple[Platform | Group, Architecture] | Platform | Group, int] = 0

Number of leading path components to strip when extracting.

A single int applies to every platform. A dict maps platform specifiers to counts, using the same resolution as get_archive_format(), for a project whose archives are not laid out identically across platforms:

strip_components={ALL_PLATFORMS: 1, WINDOWS: 0}

gh is the motivating case: its Linux and macOS archives nest everything under a gh_{version}_{platform}_{arch}/ directory, while the Windows zip puts bin/gh.exe at the root. The nesting cannot be absorbed by archive_executable instead, since that is one string for all platforms and the directory name carries the version and platform.

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, then falls back to the LINUX family when those heuristics name no distribution at all.

Return type:

tuple[Platform | Group, Architecture]

Returns:

The matching PlatformKey.

Raises:

UnsupportedPlatformError – 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 through _resolve_per_platform().

Return type:

ArchiveFormat

get_strip_components(key)[source]

Return the leading path components to strip for a platform key.

When strip_components is a plain int, returns it directly. When it is a dict, resolves through _resolve_per_platform().

Return type:

int

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_registry.MYPY_VERSION_MIN = (3, 8)

Earliest Python dialect Mypy’s --python-version 3.x parameter accepts.

Floors the value repomatic.metadata.Metadata.mypy_params derives from the project’s requires-python, which the mypy entry in TOOL_REGISTRY passes through computed_params. A project declaring an older floor would otherwise hand mypy a version it rejects outright.

Sourced from Mypy’s own defaults.

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

Column definitions for the repomatic run --list table.

Lives beside the registry it renders; the CLI derives its --sort-by choices from it.

repomatic.tool_registry.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_registry.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_registry.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=(), default_args=(), default_paths=None, per_file=False, with_packages=(), path_tools=(), needs_venv=False, computed_params=None, config_after_subcommand=False, post_process=None, output_flag=None, check_flags=(), rewrite_exit_code=None, 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 config_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).

default_args: tuple[str, ...] = ()

Arguments used when the caller passes none of their own.

Together with default_paths, this makes a bare repomatic run <tool> the invocation CI performs, so nobody has to reconstruct it from a workflow step. It applies only when extra_args is empty: any explicit argument means the caller is driving, and nothing is injected on top of it.

That all-or-nothing rule is what keeps a subcommand safe to put here. biome’s defaults open with format, and splicing them into a caller’s check . would build biome format check .; because an explicit argument suppresses them entirely, that command cannot be built.

default_paths: str | None = None

Name of the FileInventory attribute supplying this tool’s targets, when the caller passes no arguments.

Caution

An empty inventory means the tool is skipped, not invoked with no path. The distinction is the whole point: a formatter handed zero paths does not no-op, it walks the entire tree in write mode. Replaying a workflow’s xargs pipe on a repo with no matching file did exactly that once, rewriting 3,000+ files, and it is the reason this resolves the list in-process rather than leaving it to a shell.

per_file: bool = False

Invoke the tool once per target rather than once for all of them.

Mirrors xargs -n1, for a tool whose per-file behaviour differs from its batch behaviour. Only meaningful alongside default_paths.

with_packages: tuple[str, ...] = ()

Extra packages installed alongside the tool (e.g., mdformat plugins).

Passed as --with <pkg> to uvx.

path_tools: tuple[str, ...] = ()

Other registry tools whose executable must be on PATH while this runs.

For a plugin that shells out to a second binary rather than importing it: mdformat-shfmt formats fenced shell blocks by invoking shfmt from PATH, so mdformat declares path_tools=("shfmt",).

Each name is installed through the same registry path as a direct repomatic run, so the companion arrives at the pinned version, checksum verified, from the shared cache. The alternative, letting the environment supply it, is what this field exists to prevent: a system package manager hands over whatever its archive holds, unpinned and outside the cooldown, and the same tool then behaves differently depending on which job invoked it.

Names must resolve in TOOL_REGISTRY and carry a binary spec; test_tool_spec_integrity enforces both.

needs_venv: bool = False

If True, use uv run (project venv) instead of uvx (isolated).

Required when the tool imports project code (mypy, pytest). The project venv materializes from the frozen uv.lock; in a repository without one the runner degrades to an isolated, cooldown-gated environment (uv run --no-project), see _build_install_args in tool_runner.py.

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().

output_flag: str | None = None

Flag whose argument names the tool’s report destination, when the tool refuses to create missing parent directories itself.

run_tool pre-creates the parent directory of the path following this flag (both --flag path and --flag=path forms), so a workflow can point the tool into a scratch subdirectory without a separate mkdir step. lychee is the motivating case: docs.yaml collects its report from a dedicated subdirectory, and lychee errors out rather than creating it.

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: repomatic.tool_runner.verify_via_write_path() does exactly that against throwaway copies, so the answer is authoritative and the working tree is still never written to.

rewrite_exit_code: int | None = None

Exit code the tool returns when it rewrote at least one file.

Formatters that signal “I reformatted something” with a non-zero status force every caller to tolerate that code, which is what lets a crash pass for a success: pyproject-fmt exits 1 both when it reformats a file and when it dies on a PanicException, and the autofix job cannot tell the two apart from the status alone.

Declaring the code here gives run_tool the second signal it needs: the files themselves. A run exiting with this code and leaving every target byte-identical contradicts what the code claims, so it is reported as a failure instead of being waved through. See repomatic.tool_runner.TOOL_CRASH_EXIT_CODE.

None for tools with no such convention, which is most of them: a formatter that exits 0 whether or not it wrote anything needs no disambiguation.

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 tool_reference() after the generated metadata lines, so the prose stays next to the spec it documents.

property backend: ToolBackend

Delivery mechanism, derived from which spec fields are set.

binary and npm win over needs_venv; test_tool_spec_integrity keeps the three mutually exclusive so the order never actually decides.

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_registry.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)')): '27490d47af66420788b634afb48db23b588f272c8a284ba3daf706a5faa640ab', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '7b5045d6d34f055df8ffe1bf3077164e6f6a24c45a41497d628a5e86d0e12fe7', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'f71fe80909d2f70f1e051320f5ba9dfd553bc5ef3bacef5cdee1b00ee96a285c', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '887431b79e45758e05d94a89111af72b28e5d6545c92480ecac9247d8bacb321', (Platform(id='windows', name='Windows'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '655cc1f2ecf3719f79c9def7f2d824bb2a451fcd1d738d43468b12dd66620fd5', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '62adea0ea523f04cc5c074b2bb00e748b97252023aede03196e1bf4aacf80a9c'}, 'gh': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '73ea440ecad9c9e284429997ee6f93577bc6f7bc6fba357ef62c53ad8fb641a5', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'a2c9b8497e1f85b1ad0dfcb78b5a622e098801b8e461e459e88e1ee12f018112', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): 'a58b8fd77b417a38f47a0b54d1370c59b0fcdb324ccc9ca002b0998f7c4c999e', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '63298c998cc2a924c9e254c6af6a1caad6ece281122687a91f079bc0a462700e', (Platform(id='windows', name='Windows'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '3e2d4a166da4ee5020c592737b65eec0e724946d5d5b962f5fe59d99116dc4bf', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '35d7fe05c4dd1411ffda1e73dfc7c6f44b75c936ca51fa6595c657fdc0350cec'}, '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'}, 'oxipng': {(Group(id='linux', name='Linux distributions'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '97d168c6c0d1dbcb36e7438eb489804748a2ba40d94fe21aa7dab7372e9efe9b', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'b33f84c73d42cb592bea5d84c431030b1e97784817693380dfcec7d9575f871e', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '9aad3927d095b6ade2aacb92b89ebaca442483c1f7cde5d7a2486b283c2ed5f9', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'c45acf40a70cc02539c55555ac240bf5ef24544b7ea9959d22da19f606cec205', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): 'a5ad52c9c288dc99c2eae90dcad73dee64e39bf3f5aa5303c0fb55ac9c5f069b'}, '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)')): '85c8b87b22a0fb1da130cd4d495e0beba7f1225eb580933184509e146ec4c509', (Group(id='linux', name='Linux distributions'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '48bd2d58e02ce713b8c0f1aa239e68ee4f7d8c551013135806e6aed3938d9e10', (Platform(id='macos', name='macOS'), Architecture(id='aarch64', name='ARM64 (AArch64)')): '8c0e7bd40b2b60c0b0cfe9f74dd814b4d4385c956ce86860f7da9e62d91fdc73', (Platform(id='macos', name='macOS'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '4cecbf653a9fc45f023abf57f4e2e2f6b138c2d2387b09289beacdd3f0ea7bfd', (Platform(id='windows', name='Windows'), Architecture(id='x86_64', name='x86-64 (AMD64)')): '06d3a1b71c282e021671070696a72696d5c60ea485b47dc4f8f1fbcf90144d02'}}

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_registry.VERSIONS: dict[str, str] = {'actionlint': '1.7.12', 'biome': '2.5.7', 'gh': '2.97.0', 'gitleaks': '8.30.1', 'labelmaker': '0.6.4', 'lychee': '0.24.2', 'oxipng': '10.2.0', 'shfmt': '3.13.1', 'typos': '1.49.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_registry.tool_summary()[source]

Render the summary table of all managed tools.

Return type:

str

repomatic.tool_registry.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.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. The tool catalog it drives (ToolSpec entries, pinned versions, checksums) lives in tool_registry.py.

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.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.resolve_config(spec, tool_config=None)[source]

Resolve config for a tool using the 4-level precedence chain.

Caution

The levels do not merge. The walk stops at its first hit, so a native config file or a [tool.X] section replaces the bundled default in full rather than layering on top of it. A downstream repo overriding one rule must restate every bundled rule it wants to keep, and gains nothing when the bundled default later grows a rule. resolve_config_source() labels a shadowing config so repomatic run --list shows the loss.

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.DOWNLOAD_TIMEOUT = 30

Socket-level timeout for artifact downloads, in seconds.

A stall guard, not a transfer budget: urlopen applies it to each blocking socket operation, so a healthy multi-minute download is unaffected while a dead connection fails in seconds instead of hanging a CI job to the runner ceiling. Deliberately larger than repomatic.http.DEFAULT_TIMEOUT, which is sized for small JSON API responses.

repomatic.tool_runner.download_to(url, dest_path, *, label=None, progress=True)[source]

Stream url into dest_path and return its SHA-256 hex digest.

Chunked download with incremental hash computation, so large binaries never load fully into memory. Shows a progress bar on interactive terminals when the server provides a Content-Length header; pass progress=False from concurrent callers whose fan-out draws its own progress display.

The single download seam for every artifact repomatic fetches by hand: whatever consumes the digest (verification in _download_and_verify(), checksum harvesting in checksums.py) builds on this so the truncation guard below applies to all of them. A short body (proxy hiccup, dropped connection) hashes to a wrong digest, so without the guard it would surface later as a checksum mismatch: that reads as a stale pin or a tampered artifact when nothing is wrong upstream. Name the real failure instead.

Parameters:
  • url (str) – URL to download.

  • dest_path (Path) – Where to write the downloaded file.

  • label (str | None) – Progress bar label. Defaults to the destination filename.

  • progress (bool) – Draw per-download feedback on interactive terminals.

Return type:

str

Returns:

Lowercase hex SHA-256 digest of the downloaded bytes.

Raises:

OSError – If the body is shorter than the advertised Content-Length.

repomatic.tool_runner.ensure_binary(name: str) Path[source]

Install a registry binary tool and return the path to its executable.

The seam for repomatic code that shells out to a third-party binary but is not itself a run_tool() invocation. It buys the same guarantees every repomatic run binary gets: the registry-pinned version, its archive verified against the recorded SHA-256, and a shared cache so repeated calls in one run download once.

Prefer this over looking the tool up on PATH. Whatever PATH offers is whichever version the machine or CI image happens to carry, unpinned and unverified, and it differs between a developer’s laptop and every runner.

Memoized per tool name: callers in a loop (format-images optimizing one PNG per call) hit the install-and-verify path once per process, not once per file. Failures are not memoized, so a transient download error can be retried.

Parameters:

name (str) – Registry key of a tool whose ToolSpec declares a binary.

Return type:

Path

Returns:

Absolute path to the ready-to-run executable.

Raises:

ClickException – If the tool is unknown, ships no binary, or cannot be downloaded and verified.

repomatic.tool_runner.resolve_default_args(spec)[source]

Build the argument batches for a bare repomatic run <tool>.

Combines default_args with the file list named by default_paths, splitting into one batch per file when per_file is set.

Parameters:

spec (ToolSpec) – The tool to resolve defaults for.

Return type:

list[list[str]] | None

Returns:

One argument list per invocation; a single empty-argument batch when the tool declares no defaults, so the caller runs it bare as before. None when the tool wants targets and the repository holds none, which means skip the tool rather than invoke it pathless.

repomatic.tool_runner.TOOL_CRASH_EXIT_CODE = 70

Exit code reported when a tool contradicts its own rewrite status.

EX_SOFTWARE from sysexits.h: an internal error in the tool being run. Deliberately outside the set a formatter’s caller tolerates, so a crash cannot land on the code that means “I reformatted a file”. See rewrite_exit_code.

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.

With no extra_args, a tool declaring default_args or default_paths runs the invocation CI performs, resolved in-process by resolve_default_args(). Any explicit argument suppresses that entirely and is passed through as before.

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; the first non-zero one when the defaults resolved to several invocations, or TOOL_CRASH_EXIT_CODE when a tool declaring rewrite_exit_code reports a rewrite it did not perform.

repomatic.tool_runner.verify_via_write_path(name, extra_args=(), **run_kwargs)[source]

Check a post_process tool’s formatting without touching the tree.

A tool pairing post_process with check_flags has no trustworthy check mode: the fixup only runs on the write path, so the check status can flag drift the write path would reconcile, or miss drift it would introduce (see check_flags). This runs the write path against throwaway copies instead, then compares, which is the only authoritative answer.

Important

The copies are made inside the working directory, not in the system temp area. Formatters discover their config by walking up from each file, so a copy parked outside the repository resolves a different config and silently reports drift that does not exist.

The working tree is never written to: only the copies are formatted, and they are removed before returning.

Parameters:
  • name (str) – Tool name, as in run_tool().

  • extra_args (Sequence[str]) – Arguments for the tool. Any existing path among them is copied and rewritten to its copy; check flags are dropped, since they would defeat the write path this relies on. Every other argument is passed through untouched. Empty resolves the tool’s registry defaults, the same set run_tool() would have run, flattened into one batch: the copies are per-path already, so a per_file split would only cost extra invocations.

  • run_kwargs (Any) – Forwarded verbatim to run_tool().

Return type:

tuple[int, list[str]]

Returns:

(exit_code, drifted), where exit_code is 0 when every target is already formatted and 1 otherwise, and drifted names the paths the write path would have changed. A tool that fails on the copies yields its own exit code and no drift, since it measured nothing.

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(root=None)[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.

Parameters:

root (Path | None) – Directory the relative config paths resolve against. Defaults to the working directory; run_init passes its output_dir so the scan and the deletion the CLI derives from it (--delete-unmodified) agree on one tree.

Return type:

list[tuple[str, str]]

Returns:

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

repomatic.uv module

uv lock file operations.

Utilities for managing uv.lock files: parsing versions, computing version diffs and cooldown forecasts (held-back releases, bypass expiries), and managing exclude-newer-package cooldown overrides. The shared markdown rendering of these results lives in repomatic.dep_report.

repomatic.uv.uv_cmd(subcommand, *, frozen=False, no_project=False, exclude_newer=None)[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). Adds --no-project to skip project discovery entirely, and --exclude-newer (a YYYY-MM-DD date) to gate an unlocked resolution by the minimum-release-age cooldown, mirroring uvx_cmd().

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.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.load_pyproject_doc(pyproject_path)[source]

Parse pyproject.toml into an editable, round-trippable document.

The counterpart to repomatic.pyproject.read_pyproject_toml(), which returns plain data for reading. This one keeps tomlrt’s formatting trivia, so the document can be edited and written back with the rest of the file byte-identical.

Parameters:

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

Return type:

Any

Returns:

The parsed document.

repomatic.uv.uv_table(doc)[source]

Return the [tool.uv] table of a parsed pyproject.toml.

Parameters:

doc (Any) – Document from load_pyproject_doc().

Return type:

Any

Returns:

The [tool.uv] table, or an empty mapping when the project declares none. Reading a key off the result is therefore always safe; writing one back requires the caller to check the table exists first, since the empty fallback is not attached to doc.

repomatic.uv.resolve_exclude_newer_cutoff(value)[source]

Resolve a [tool.uv].exclude-newer value to an absolute cutoff datetime.

uv accepts three forms in this field:

  • A “friendly” duration (24 hours, 30 minutes, 1 day, 1 week): subtracted from the current UTC time.

  • An ISO 8601 duration (PT24H, P7D, P30D, P1W, combinations like P1DT2H): subtracted from the current UTC time.

  • An RFC 3339 / ISO 8601 timestamp (2026-03-18T16:39:02Z): returned verbatim as the cutoff.

Forms are tried in the order above so a duration is never mistaken for a timestamp.

Parameters:

value (str) – The string read from [tool.uv].exclude-newer in pyproject.toml.

Return type:

datetime | None

Returns:

An absolute cutoff datetime, or None if value is empty or matches none of the recognized forms.

repomatic.uv.project_exclude_newer(pyproject_path)[source]

Read the project’s own [tool.uv] exclude-newer window.

Caution

Always pass this back to uv lock and uv sync as an explicit --exclude-newer flag rather than letting uv pick the value up from pyproject.toml on its own. CI exports a UV_EXCLUDE_NEWER covering every ad-hoc install (see claude.md § Cooldown on every install), and that environment variable outranks [tool.uv]: left implicit, a CI lock would resolve against the ambient window while a developer running the same command locally resolves against this one, and sync-uv-lock would churn between the two. A CLI flag outranks the environment, which pins the project’s own policy.

Parameters:

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

Return type:

str

Returns:

The configured window verbatim (a friendly duration, an ISO 8601 span or an absolute timestamp), or an empty string when unset.

repomatic.uv.uv_lock_command(pyproject_path, *extra)[source]

Build a uv lock argv carrying the project’s own cooldown window.

The one builder behind every re-lock this package runs (sync-uv-lock, the dep-sources swap, audit --fix), so none of them can forget the explicit --exclude-newer that keeps CI’s ambient UV_EXCLUDE_NEWER from retiming the lock: see project_exclude_newer().

Parameters:
  • pyproject_path (Path) – Path to the project’s pyproject.toml. A missing file or an unset window leaves the flag off.

  • extra (str) – Extra uv lock arguments (--upgrade, --upgrade-package, …), appended before the window flag.

Return type:

list[str]

Returns:

The argv to run, with cwd set to the project directory.

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.freeze_cutoff_after(day)[source]

The exclude-newer-package cutoff holding a version uploaded on day.

One day of margin, rounded to a whole-day UTC boundary: see _freeze_cutoff() for the full margin and timezone rationale. The single source of that policy, shared with repomatic.dep_sources.ReleaseSwap.

Parameters:

day (date) – The held version’s upload date.

Return type:

str

Returns:

A YYYY-MM-DDT00:00:00Z cutoff timestamp.

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, lock=None)[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.

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it.

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, lock=None)[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.

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it.

Return type:

set[str]

Returns:

The names of the pruned packages; empty when nothing was stale (the file is then left untouched).

class repomatic.uv.LockFile(versions=<factory>, upload_times=<factory>, exclude_newer='', cooldown_span=None)[source]

Bases: object

Everything the cooldown machinery reads out of a uv.lock, parsed once.

A lock is a large TOML document (hundreds of kilobytes on a real project) and a round-trip parse of it is not cheap. The four views below used to be four independent functions that each re-opened the file, so a single sync-uv-lock run parsed the same bytes nine to twelve times. Loading once and passing the result around keeps that to two: the pre-upgrade state and the post-upgrade one.

The parse_lock_* functions remain as thin wrappers for callers holding only a path.

versions: dict[str, str]

Package name to locked version.

upload_times: dict[str, str]

Package name to the ISO 8601 upload-time of its sdist entry.

Packages with no sdist or no upload time are absent: a git or path source has no release to date.

exclude_newer: str = ''

Effective options.exclude-newer cutoff, as an ISO 8601 instant.

When the project configures a relative span, uv writes LOCK_TIMESTAMP_SENTINEL here and the real width to options.exclude-newer-span; the cutoff is then resolved to now - span at load time. Empty when neither field is present, or when the sentinel carries no parseable span.

cooldown_span: timedelta | None = None

Width of the rolling cooldown, from options.exclude-newer-span.

None when the lock records an absolute cutoff instead of a span, which leaves the cooldown-expiry forecasts nothing to project against.

classmethod load(lock_path)[source]

Read every cooldown-relevant field out of lock_path in one parse.

Parameters:

lock_path (Path) – Path to the uv.lock file.

Return type:

LockFile

Returns:

The parsed views, or an all-empty instance when the file does not exist. A missing lock is not an error: several callers run before the first uv lock.

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.

See LockFile.exclude_newer for how a relative span is resolved.

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.

repomatic.uv.EXTRA_MARKER_RE = re.compile("\\bextra\\s*==\\s*'([^']+)'")

Match the extra a requires-dist marker gates its dependency behind.

Searched rather than anchored: uv writes the bare extra == 'x' form most of the time, but combines it with a version guard (python_full_version >= ‘3.11’ and extra == ‘x’) when the declaration carries one. Anchoring would read those as unconditional dependencies.

class repomatic.uv.LockSpecifiers(by_package, by_subgraph, by_main=<factory>)[source]

Bases: object

Dependency specifiers extracted from a uv.lock file.

Three 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_main

{package_name: {dep_name: specifier}}. Only the dependencies a package declares unconditionally, behind neither an extra nor a dev group. This is the authoritative answer to “what does installing this project pull in by default”, which a CycloneDX SBOM does not reliably give. See filter_root_edges(). A package with no metadata table is absent from the mapping entirely, telling “declares nothing unconditionally” apart from “not described here”.

by_package: dict[str, dict[str, str]]
by_subgraph: dict[str, dict[str, str]]
by_main: 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.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.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.compute_bypass_forecasts(pyproject_path, lock_path, lock=None)[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.

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it. It must describe the state the report is about: sync_uv_lock() passes the pre-upgrade lock when it discarded a cosmetic-only re-lock, and the post-upgrade one otherwise.

Return type:

list[BypassForecast]

Returns:

Forecasts sorted by package name; empty when there is no freeze.

repomatic.uv.compute_pruned_forecasts(names, lock_path, lock=None)[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:
  • names (set[str]) – Names of the pruned entries, as returned by prune_stale_exclude_newer_packages().

  • lock_path (Path) – Path to the uv.lock file, still pre-upgrade.

  • lock (LockFile | None) – Pre-parsed lock_path, to skip re-reading it. Must be the pre-upgrade state, for the reason above.

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.

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://repomatic.net/configuration#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://repomatic.net/configuration#minimum-release-age) cooldown window.'

Intro paragraph for the version-sync held-back section.

The GitHub/PyPI/npm counterpart to repomatic.dep_report.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.

repomatic.version_sync.DEV_SUFFIX_RE = re.compile('\\.dev\\d*$')

Match the trailing PEP 440 developmental-release segment of a version.

repomatic.version_sync.SETUP_UV_PACKAGE = 'uv'

PyPI project backing the astral-sh/setup-uv version pin.

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.UpstreamRefPin(version: str, sha: str | None)[source]

Bases: NamedTuple

An upstream thin-caller uses: ref found in a workflow file.

The counterpart of ActionPin for the upstream repo’s own reusable workflows and composite actions. Those refs carry a subpath (owner/repo/.github/workflows/x.yaml@…), which ACTION_PIN_RE deliberately does not match, so they need their own parser.

Create new instance of UpstreamRefPin(version, sha)

version: str

The bare version in the trailing # vX.Y.Z comment, or in the tag ref.

sha: str | None

The pinned 40-character commit SHA, or None for a bare tag pin.

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.dep_report.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.dep_report.format_diff_table().

repomatic.version_sync.cleared_cooldown(released, cutoff)[source]

Whether a release dated released is safely older than cutoff.

The comparison is strict, and that one character is load-bearing. Datasources report a release date, while the cooldown this gates is enforced downstream at instant granularity: uv’s --exclude-newer cutoff is now - min_age, carrying the run’s time of day. An inclusive <= therefore adopts a release published on the cutoff day but later in the day than the run’s own clock, which uv then refuses to resolve, pinning a version that cannot be installed until it ages out.

That is not hypothetical: a sync-workflow-pins run at 05:20 UTC adopted a release published at 17:07 on the cutoff date, and every binary build failed on No solution found until the window elapsed.

Being strict costs up to 24 hours of extra window and guarantees correctness, since a release dated before the cutoff day is older than any instant on it. Same “over-protect rather than under-protect” convention as min_release_age_days().

Parameters:
  • released (date) – Release date, as reported by the datasource.

  • cutoff (date) – The window boundary, today - min_age.

Return type:

bool

Returns:

True when the release may be adopted.

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.pin_inside_cooldown(candidates, pinned, min_age, today)[source]

The release date of pinned when it has not yet cleared the cooldown.

Audits a pin already written to disk, where select_latest() only ever judges one it is about to write. The two ask the same question through cleared_cooldown(), so an audit can never disagree with the decision that produced the pin.

Worth auditing separately because a pin can enter the tree without passing the selector at all: hand-edited, merged from a branch, restored from a revert, or written by an older release whose selector had a different boundary. Such a pin resolves through uvx in CI, where no per-package exemption is reachable, so it fails the whole job until it ages out.

Parameters:
  • candidates (list[Candidate]) – Versions offered by the datasource, as already fetched for the selection pass.

  • pinned (str) – The version currently written in the workflow.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

Return type:

date | None

Returns:

The release date when pinned is still inside the window, or None when it has cleared, is not among candidates, or carries an unparsable date.

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.safe_version(value)[source]

Parse a PEP 440 version, returning None for anything unparsable.

Every version this package reads comes from somewhere it does not control (a lock file, a package index, a git tag), so the parse has to tolerate junk. Collapsing the try/except InvalidVersion into one helper keeps the callers reading as the filters they are.

Note

The modules below this one in the import graph (pypi, git_ops, virustotal, github.releases) keep a local two-line copy of this parse: importing it from here would either close an import cycle or drag this module’s index clients into dependency-light modules.

Parameters:

value (str) – A version string.

Return type:

Version | None

Returns:

The parsed Version, or None when value is empty or not PEP 440.

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.strip_dev_suffix(version)[source]

Drop any PEP 440 .devN segment from version.

"5.10.0.dev0" becomes "5.10.0". A version carrying no developmental segment is returned unchanged, so the call is safe to apply blindly.

Return type:

str

repomatic.version_sync.set_tool_version(content, name, new_version)[source]

Rewrite a tool’s version= field in the tool_registry.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_registry.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.set_with_package_version(content, package, new_version)[source]

Rewrite a with_packages pin in the tool_registry.py source.

Targets the "{package}=={version}" literal wherever it appears, unlike set_tool_version(), which is scoped to one ToolSpec( entry. Two tools pinning the same package therefore converge on one version rather than drifting apart, matching how _widest_changes() collapses a name pinned at several versions elsewhere.

Parameters:
  • content (str) – The tool_registry.py source text.

  • package (str) – The package name as spelled in the pin ("mdformat-gfm").

  • 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.self_pin_exemption_re(package: str) Pattern[str][source]

Match a uvx command pinning package, capturing the flags before it.

Memoized: the splice runs once per workflow file for the same package.

Return type:

Pattern[str]

repomatic.version_sync.frozen_cli_invocation(package, version, exemption)[source]

Render the frozen, cooldown-exempt uvx invocation of package.

The one spelling both writers emit: the release freeze (PrepareRelease.freeze_cli_version) writes it wholesale, and apply_self_pin_exemption() converges an exemption-less command onto the same byte sequence, so the unfreeze pattern has exactly one shape to recognize.

Return type:

str

repomatic.version_sync.apply_self_pin_exemption(content, package, exemption)[source]

Splice a cooldown exemption into every uvx command pinning package.

The upstream toolkit’s inline pin moves in lockstep with the uses: refs, regardless of the cooldown, so the version it names can be minutes old. Every workflow exports a UV_EXCLUDE_NEWER covering all resolution, and uvx reads no per-package exemption from the environment or from pyproject.toml, so without the flag on the command line the freshly aligned pin fails to resolve until the window elapses.

Idempotent: a command already carrying the exemption is left untouched.

Parameters:
  • content (str) – The workflow file text.

  • package (str) – The self-pinned distribution name.

  • exemption (str) – The flag to splice in, ahead of the quoted requirement.

Return type:

str

Returns:

The updated text.

repomatic.version_sync.apply_workflow_literals(content, resolved, self_pin=None)[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.

  • self_pin (tuple[str, str] | None) – Optional (package, exemption_flag) for the upstream toolkit’s own pin, whose rewrite bypasses the cooldown and therefore needs apply_self_pin_exemption() on the resulting command.

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.

Important

The returned list covers version moves only. A self_pin splice edits the text while reporting nothing, because it names a package rather than moving a version, so a caller deciding whether to write must compare the returned text against its input rather than test the list. Gating on the list silently discards the backfill, which is what stranded downstream repos already pinned at the newest release.

repomatic.version_sync.find_upstream_ref_pins(content, upstream_repo)[source]

Extract the uses: refs of the upstream repo’s workflows, with their SHAs.

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, which yields a None SHA).

Shared by lint-repo’s inline-pin lockstep check, sync-workflow-pins’ upstream-pin alignment and init’s pin floor (_highest_upstream_pin()), so all three read the refs the same way.

Return type:

list[UpstreamRefPin]

repomatic.version_sync.find_upstream_ref_versions(content, upstream_repo)[source]

Extract the bare uses: ref versions of the upstream repo’s workflows.

The version-only view of find_upstream_ref_pins().

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.FREE_TIER_RATE_LIMIT = 4

VirusTotal free-tier request budget, in API calls per minute.

The single source for the upload and polling pace: the scan-virustotal CLI default and both client functions below derive from it.

repomatic.virustotal.SCAN_HEADERS = ('tag', 'filename', 'sha256', 'scanned', 'malicious', 'suspicious', 'undetected', 'harmless')

Columns of the committed scan history, in file order.

The release and the file it identifies first, then the four verdict counts, so the table reads left to right from what was scanned to what came back. Rows are ordered by release version rather than alphabetically, which a plain sort of the tag strings would get wrong past v9.

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.

as_row()[source]

Flatten to one CSV row, in SCAN_HEADERS order.

Return type:

tuple[str, ...]

classmethod from_row(data)[source]

Rebuild a record from one parsed CSV row, or a legacy JSON mapping.

Both shapes carry the same eight keys, so one reader covers a store mid-migration as well as one already converted.

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 the CSV history file.

Note

A repository whose history predates the CSV store carries the same records in a sibling .json, and is read from there when the CSV is absent. The next upsert_scan_records() write lands as CSV, so a repository migrates on its first release after upgrading without anyone converting anything. The stale .json is then inert and can be deleted.

Parameters:

path (Path) – Path to the CSV file.

Return type:

list[ScanRecord]

Returns:

The records, or an empty list when neither file exists.

Raises:

ValueError – When a 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.AUDIT_HEADER_DEFS: tuple[tuple[str, str], ...] = (('Package', 'package'), ('Version', 'version'), ('Advisory', 'advisory'), ('Fixed', 'fixed'), ('Sources', 'sources'))

Column definitions for the repomatic audit table.

Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its --sort-by choices from it.

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.

An upgrade that resolves to the versions already locked leaves the file byte-identical to how it was found, because uv writes the overrides it was handed into the lock’s [options] table even when they change nothing. See the restore in step 5.

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).