repomatic package¶
Expose package-wide elements.
Subpackages¶
repomatic.datapackagerepomatic.githubpackage- Submodules
repomatic.github.actionsmoduleNULL_SHAMAX_STEP_OUTPUT_BYTESWorkflowEventWorkflowEvent.branch_protection_ruleWorkflowEvent.check_runWorkflowEvent.check_suiteWorkflowEvent.createWorkflowEvent.deleteWorkflowEvent.deploymentWorkflowEvent.deployment_statusWorkflowEvent.discussionWorkflowEvent.discussion_commentWorkflowEvent.forkWorkflowEvent.gollumWorkflowEvent.issue_commentWorkflowEvent.issuesWorkflowEvent.labelWorkflowEvent.merge_groupWorkflowEvent.milestoneWorkflowEvent.page_buildWorkflowEvent.projectWorkflowEvent.project_cardWorkflowEvent.project_columnWorkflowEvent.publicWorkflowEvent.pull_requestWorkflowEvent.pull_request_commentWorkflowEvent.pull_request_reviewWorkflowEvent.pull_request_review_commentWorkflowEvent.pull_request_targetWorkflowEvent.pushWorkflowEvent.registry_packageWorkflowEvent.releaseWorkflowEvent.repository_dispatchWorkflowEvent.scheduleWorkflowEvent.statusWorkflowEvent.watchWorkflowEvent.workflow_callWorkflowEvent.workflow_dispatchWorkflowEvent.workflow_run
AnnotationLevelReportActionextract_workflow_filename()generate_delimiter()trim_to_budget()trim_to_byte_budget()format_multiline_output()write_output_file()format_file_output()emit_report()read_file_output()emit_annotation()get_github_event()get_event_pull_request()get_event_subject()get_default_author()get_default_number()is_pull_request()cancel_superseded_runs()
repomatic.github.dev_releasemodulerepomatic.github.ci_statusmodulerepomatic.github.ghmodulerepomatic.github.issuemoduleBOT_ISSUE_LABELLOCKED_CONVERSATION_MARKERLOCK_INACTIVE_DAYSLOCK_ISSUE_COMMENTLOCK_PR_COMMENTLOCK_REASONLOCK_THREADS_HEADER_DEFSLOCK_SEARCH_LIMITadd_labels()search_stale_threads()lock_thread()lock_stale_threads()list_issues()unlock_thread()run_unlocking()close_issue()reopen_issue()create_issue()update_issue()triage_issues()manage_issue_lifecycle()
repomatic.github.job_timingsmodulerepomatic.github.matrixmodulerepomatic.github.prmodulerepomatic.github.pr_bodymoduleGITHUB_BODY_MAX_CHARSsanitize_markdown_mentions()demote_markdown_headings()load_template()render_template()render_title()render_commit_message()template_args()template_labels()template_draft()template_docs_url()template_stem()get_template_names()generate_pr_metadata_block()generate_refresh_tip()build_release_review_steps()build_pr_body()fit_github_body()temp_body_file()
repomatic.github.release_syncmodulerepomatic.github.releasesmoduleGITHUB_API_RELEASES_URLGITHUB_API_TAG_REF_URLGITHUB_API_TAG_OBJECT_URLGITHUB_API_RELEASE_BY_TAG_URLowner_repo()GitHubReleasesUnavailableGitHubReleaseReleaseAssetReleaseWithAssetsget_github_releases()get_release_tags()get_releases_with_assets()parse_release_version()dev_release_url_and_previous_version()resolve_tag_to_sha()extract_version()edit_release_notes()get_github_release_body()fetch_github_release_notes()
repomatic.github.sponsormodulerepomatic.github.statusmodulerepomatic.github.tokenmodulerepomatic.github.unsubscribemoduleGRAPHQL_PAGE_SIZENOTIFICATION_PAGE_SIZENOTIFICATION_SUBJECT_TYPESDetailRowPhase1ResultPhase1Result.batch_sizePhase1Result.cutoffPhase1Result.newest_updatedPhase1Result.oldest_updatedPhase1Result.rowsPhase1Result.threads_failedPhase1Result.threads_inspectedPhase1Result.threads_skipped_openPhase1Result.threads_skipped_recentPhase1Result.threads_skipped_unknownPhase1Result.threads_totalPhase1Result.threads_unsubscribed
Phase2ResultUnsubscribeResultrender_report()unsubscribe_threads()
repomatic.github.workflow_syncmodulecooldown_env_block()PERMISSION_RANKDEFAULT_VERSIONWorkflowTriggerInfoLintResultworkflow_triggers()canonical_caller_permissions()extract_trigger_info()PathsSpecgenerate_thin_caller()EXTRA_JOBS_SEPARATORrender_thin_caller_for_target()GENERATED_CALLER_JOBSidentify_canonical_workflow()extract_extra_jobs()extras_define_jobs()check_has_workflow_dispatch()check_version_pinned()check_triggers_match()check_secrets_passed()generate_workflow_header()run_workflow_lint()
repomatic.templatespackage
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.pycheck.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.
allis upstream plus every consumer,upstreamnever leaveskdeldycke/repomatic, anddownstreamis 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.
upstreamis the complement and never leaveskdeldycke/repomatic, which is whymerge_agent_md()is skipped there outright rather than filtered: the source repository would otherwise receive thedownstreamsections 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-labelsruns for a renamed label, andclaude.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:
objectOne heading of an instructions file, with its tag and body as written.
- 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-markdownsettled on.
- supersedes: tuple[str, ...] = ()¶
Heading titles this section replaces downstream, from
SUPERSEDES_RE.
- reaches(is_awesome, is_python, is_package)[source]¶
Whether a repository with these traits receives this section.
- Parameters:
- Return type:
- 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_SCOPESbytests/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.
- 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 initreport it as untouched and keeps the unattendedsync-repomaticjob from opening a pull request every run.- Parameters:
- Return type:
- Returns:
The merged document, always newline-terminated.
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
subjectarray names every file signed in the same call. One entry for a singlesubject-path, several whenactions/attestwas 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 bundleactions/attestwrote.- Return type:
- 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, thenpapaya.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/attesta glob.- Parameters:
subjects (
Sequence[str]) – Subject filenames, frombundle_subjects().set_name (
str|None) – Stem to use when subjects holds more than one entry.
- Return type:
- 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 bundleactions/attestwrote.asset_dir (
Path) – Directory holding the attested artifacts.set_name (
str|None) – Stem for the multi-subject case, seebundle_filename().
- Return type:
- 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 tripsremark-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.mdand everyreadme.{lang}.mdtranslation beside it.Only the repository root is scanned. The
find ./this replaced walked the whole tree, which on a checkout carrying anode_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-toclist 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-tocblock.
- repomatic.awesome_toc.TOC_END_RE = re.compile('^<!--\\s*mdformat-toc\\s+end\\s*-->$', re.IGNORECASE)¶
Match the closing marker of an
mdformat-tocblock.
- 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.
- 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_HEADINGSthat 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
贡献translatesContributing, 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.
- repomatic.awesome_toc.strip_toc_entries(content, forbidden)[source]¶
Delete the ToC entries of content whose link text is forbidden.
Only the
mdformat-tocblock is touched: a list item elsewhere in the document that happens to link the same heading is left alone.
- repomatic.awesome_toc.fix_awesome_toc(root=None)[source]¶
Strip the forbidden ToC entries from every readme under root.
Reads
REFERENCE_READMEfirst 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.
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-positiveskill).
- repomatic.binaries_page.LEGACY_PAGE_END_MARKER = '<!-- binaries-end -->'¶
Oldest closing marker, migrated to
PAGE_END_MARKERon first touch.
- repomatic.binaries_page.LEGACY_PAGE_START_MARKERS = ('<!-- binaries-start -->', '<!-- binaries-chart-start -->')¶
Superseded opening markers, migrated to
PAGE_START_MARKERon first touch.Two generations precede the current bare open: the original
<!-- binaries-start -->, then the<!-- binaries-chart-start -->of the short-lived-start/-endpair. Both collapse toPAGE_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 thatPAGE_START_MARKERandPAGE_END_MARKERspell out, following click-extra’s<!-- name --> / <!-- name-end -->marker grammar withname= 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 withstr.replace(notstr.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:
- Returns:
A
## VirusTotal detectionssection with arawHTML 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-tabledirective): 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 byFLAGGED_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-virustotaland 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
octiconrole, so the rendering repository needssphinx-designin its documentation build (already true across this ecosystem’s docs stacks).- Parameters:
repo_slug (
str) – Repository inowner/repoform.releases (
Sequence[ReleaseWithAssets]) – Releases fromrepomatic.github.releases.get_releases_with_assets().records (
Sequence[ScanRecord]) – Detection snapshots from the scan history file.
- Return type:
- 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:
csv_path (
Path) – Path to the CSV file.content (
str) – Rendered CSV fromrender_binaries_csv().
- Return type:
- Returns:
Truewhen 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 betweenPAGE_START_MARKERandPAGE_END_MARKERis replaced byclick_extra.blocks.replace_region(), leaving all surrounding prose untouched. Pages carrying anyLEGACY_PAGE_START_MARKERSopen or theLEGACY_PAGE_END_MARKERclose are migrated to the current markers in the same pass.- Parameters:
page_path (
Path) – Path to the Markdown page.chart_section (
str) – Rendered chart fromrender_chart_section(), or an empty string to leave the region empty.repo_slug (
str) – Repository inowner/repoform, interpolated into the template on first creation.
- Return type:
- Returns:
Truewhen 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-virustotaluploads this set, the release workflow downloads it (--patternflags in_release-engine.yaml),docs/binaries.mdlists 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-releasealready attached those), and the dev-release asset globs add it on top of the compiled binaries.
- 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.mdmatch 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
Architecture IDs are inspired from those specified for self-hosted runners
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 thecontainer:key of the release workflow. Compiling insidemanylinux_2_28caps 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 byverify_binary_floor()and documented indocs/binaries.md.min_os: minimum OS version the binary runs on. On macOS the release workflow exports it asMACOSX_DEPLOYMENT_TARGETat compile time (without it, compiled objects and processed dylibs inherit the build runner’s macOS version) andverify_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 thereleases/latest/downloadURLs. The extension comes fromNUITKA_BUILD_TARGETS.- Return type:
- 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.binbecomespapaya-linux-arm64.bin). ReturnsNonefor filenames that carry no such segment or are not compiled binaries, so callers can filter and map in one pass.
- repomatic.binary.binary_filename_re(package)[source]¶
Match a package binary filename, versioned or versionless.
Captures
targetandext, both alternations derived fromNUITKA_BUILD_TARGETSso 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.pypins the pattern against every target.
- 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-releasealready uploaded those), plus a byte-identical versionless alias copied beside each versioned binary so the stablereleases/latest/downloadURLs always resolve. Aliases share their sibling’s digest, which is what lets artifact attestations verify them unchanged and the binaries catalog collapse them (seebinaries_page._binary_assets).Idempotent: re-running overwrites the same aliases with the same bytes.
- 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]inpyproject.toml) are added dynamically bybinary_affecting_paths.The release workflow entries cover both layouts: upstream keeps the
_release-engine.yamllane (which defines the Nuitka compile and binary self-test jobs) in-repo, while downstream repos call the engine cross-repo from their generatedrelease.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, soskip_binary_buildreturnsTruewhen 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_machinevalue (as decoded by pyelftools) per architecture.
- repomatic.binary.MACHO_CPU_TYPES: Final[dict[str, int]] = {'arm64': 16777228, 'x64': 16777223}¶
Expected Mach-O header
cputypeper architecture.
- repomatic.binary.PE_MACHINES: Final[dict[str, int]] = {'arm64': 43620, 'x64': 34404}¶
Expected PE COFF
Machinefield 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¶
platformfield value naming macOS inside anLC_BUILD_VERSIONcommand.
- 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:
- Raises:
ValueError – If target is unknown.
AssertionError – If binary format or architecture does not match.
- Return type:
- 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.yversion requirement of each ELF againstglibc_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
minosof each Mach-O againstmin_os, the deployment target the build exports asMACOSX_DEPLOYMENT_TARGET.Windows: nothing. PE version headers are nominal; the floor is CPython’s own Windows support policy, tracked in the docs.
- Parameters:
- Raises:
ValueError – If target is unknown.
AssertionError – If any scanned file exceeds the declared floor.
- Return type:
repomatic.broken_links module¶
Broken links detection and reporting.
Combines Lychee and Sphinx linkcheck results into a single “Broken links” GitHub issue. Sphinx linkcheck parsing detects broken auto-generated links (intersphinx, autodoc, type annotations) that Lychee cannot see because they only exist in the rendered HTML output.
Issue lifecycle management is delegated to issue.
- repomatic.broken_links.ISSUE_TITLE = 'Broken links'¶
Issue title used for the combined broken links report.
- repomatic.broken_links.LYCHEE_BROKEN_LINKS_EXIT = 2¶
The one lychee exit code that reports on the links rather than on the run.
Lychee exits 0 on success, 1 on an unexpected failure, 2 when it found broken links, and 3 on a config error. Only 2 is a verdict about the links; 1 and 3 say the run itself did not complete, so neither “broken links found” nor “no broken links” can be claimed from them.
- repomatic.broken_links.LYCHEE_DEFAULT_BODY = PosixPath('lychee/out.md')¶
Default output path used by the lychee-action GitHub Action.
- repomatic.broken_links.SPHINX_DEFAULT_OUTPUT = PosixPath('docs/_linkcheck/output.json')¶
Default Sphinx linkcheck output path produced by the
docs.yamlworkflow.
- class repomatic.broken_links.LinkcheckResult(filename, lineno, status, code, uri, info)[source]¶
Bases:
objectA single result entry from Sphinx linkcheck
output.json.Each line in the JSON-lines file corresponds to one checked URI.
- repomatic.broken_links.parse_output_json(output_json)[source]¶
Parse the Sphinx linkcheck
output.jsonfile.The file uses JSON-lines format: one JSON object per line. Blank lines are skipped.
- Parameters:
output_json (
Path) – Path to theoutput.jsonfile.- Return type:
- Returns:
List of parsed linkcheck results.
- repomatic.broken_links.filter_broken(results)[source]¶
Filter results to only broken and timed-out links.
- Parameters:
results (
Iterable[LinkcheckResult]) – Iterable of linkcheck results.- Return type:
- Returns:
List of results with
statusof"broken"or"timeout".
- repomatic.broken_links.generate_markdown_report(broken, source_url=None)[source]¶
Generate a Markdown report of broken links grouped by source file.
The report starts with H2 file headings, suitable for embedding as a section in the combined broken links issue body.
- Parameters:
broken (
list[LinkcheckResult]) – List of broken linkcheck results.source_url (
str|None) – Base URL for linking filenames and line numbers. When provided, file headers become clickable links and line numbers deep-link to the specific line.
- Return type:
- Returns:
Markdown-formatted report string.
- repomatic.broken_links.get_label(repo_name)[source]¶
Return the appropriate label based on repository name.
- repomatic.broken_links.manage_combined_broken_links_issue(repo_name=None, lychee_exit_code=None, lychee_body_file=None, sphinx_output_json=None, sphinx_source_url=None)[source]¶
Manage the combined broken links issue lifecycle.
Combines results from Lychee and Sphinx linkcheck into a single “Broken links” issue. Each tool’s results appear under its own heading. Tools that were not run are omitted from the report. Tools that found no broken links show a “No broken links found.” message.
When running in GitHub Actions, most parameters are auto-detected from
Metadataand well-known file paths:repo_namedefaults toMetadata.repo_name.lychee_body_filedefaults to./lychee/out.mdwhenlychee_exit_codeis provided and the file exists.sphinx_output_jsondefaults to./docs/_linkcheck/output.jsonwhen the file exists.sphinx_source_urlis composed fromMetadata.repo_urlandMetadata.sha.
- Parameters:
repo_name (
str|None) – Repository name (for label selection). Defaults toMetadata.repo_name.lychee_exit_code (
int|None) – Exit code from lychee (0=no broken links, 2=broken links found).Noneif lychee was not run.lychee_body_file (
Path|None) – Path to the lychee output file. Defaults to./lychee/out.mdwhenlychee_exit_codeis provided and the file exists.sphinx_output_json (
Path|None) – Path to Sphinx linkcheckoutput.json. Defaults to./docs/_linkcheck/output.jsonwhen the file exists.sphinx_source_url (
str|None) – Base URL for linking filenames and line numbers in the Sphinx report. Auto-composed fromMetadata.repo_urlandMetadata.sha.
- Raises:
ValueError – If
repo_namecannot be determined.- Return type:
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:
- 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 aPathsuitable for passing to external tools via--config <path>. The path is valid only within the context manager.
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 showtable.Lives beside the entry dataclasses it renders; the CLI derives its
--sort-bychoices from it.
- class repomatic.cache.CachedFile(size, path, mtime)[source]¶
Bases:
objectThe 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
CachedFileand never care which subtree it came from.Subclasses supply their own identity fields plus
kindandscope.- kind: ClassVar[str] = ''¶
The cache this entry belongs to, as the
repomatic cache showtable spells it.
- property scope: str¶
The name a
cache cleanfilter 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.
- class repomatic.cache.CacheEntry(size, path, mtime, tool='', version='', platform='', executable='')[source]¶
Bases:
CachedFileA single cached binary with its metadata.
- kind: ClassVar[str] = 'binary'¶
The cache this entry belongs to, as the
repomatic cache showtable spells it.
- class repomatic.cache.HttpCacheEntry(size, path, mtime, namespace='', key='')[source]¶
Bases:
CachedFileA single cached HTTP response with its metadata.
- kind: ClassVar[str] = 'http'¶
The cache this entry belongs to, as the
repomatic cache showtable spells it.
- class repomatic.cache.ConfigCacheEntry(size, path, mtime, tool='', filename='')[source]¶
Bases:
CachedFileA single cached tool configuration file with its metadata.
- kind: ClassVar[str] = 'config'¶
The cache this entry belongs to, as the
repomatic cache showtable spells it.
- repomatic.cache.cache_dir()[source]¶
Resolve the cache root directory.
Precedence (highest to lowest):
REPOMATIC_CACHE_DIRenvironment variable.cache.dirin[tool.repomatic].Platform-specific default.
- Return type:
- 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).
- 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.
- 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_checksumflag.
- 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.replaceoverwrites atomically).Triggers
auto_purge()after a successful store.- Parameters:
- Return type:
- Returns:
Path to the cached binary, or
Nonewhen the cache is unwritable (a read-only cache root, a restricted CI mount): callers fall back to their staging copy, matchingstore_response()andstore_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:
- Returns:
List of
CacheEntryinstances, sorted by tool name then version.
- repomatic.cache.clear_cache(tool=None, max_age_days=None)[source]¶
Remove cached binaries.
- Parameters:
- Return type:
- 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:
- Return type:
- Returns:
Raw cached response bytes, or
Noneif 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(). Triggersauto_purge()after a successful store.- Parameters:
- Return type:
- Returns:
Path to the cached response file, or
Noneif the write failed (permissions, read-only filesystem, sandbox restrictions).
- repomatic.cache.http_cache_info()[source]¶
List all cached HTTP responses.
- Return type:
- Returns:
List of
HttpCacheEntryinstances, sorted by namespace then key.
- repomatic.cache.clear_http_cache(namespace=None, max_age_days=None)[source]¶
Remove cached HTTP responses.
- Parameters:
- Return type:
- 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 triggerauto_purge(): config files are tiny and overwritten on every invocation, so age-based pruning is unnecessary.- Parameters:
- Return type:
- Returns:
Path to the cached config file, or
Noneif the write failed (permissions, read-only filesystem, sandbox restrictions).
- repomatic.cache.config_cache_info()[source]¶
List all cached tool configurations.
- Return type:
- Returns:
List of
ConfigCacheEntryinstances, 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, matchingclear_cache()andclear_http_cache().
- Return type:
- 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.
- repomatic.cache.auto_purge()[source]¶
Remove cached entries older than the configured TTL.
Called automatically after
store_binary()andstore_response(), and runs at most once per cache root per process (see_PURGED_ROOTS). Purges both binary and HTTP cache entries. Resolves the TTL fromREPOMATIC_CACHE_MAX_AGEenv var, thencache.max-agein[tool.repomatic], then theCacheConfig.max_agefield default. Set to0to disable.- Return type:
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.0diff links, or update them from...mainto...vX.Y.Zat 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.Zreferences 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 thatchangelog.yamluses.No citation file integration. None update
citation.cffrelease 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.locationinto 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:
- 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.
- 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:
repomatic.pypi._fetch_json()returnsNoneon 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.Even when the HTTP status is preserved, a
404from/pypi/<name>/jsonis not authoritative: Warehouse 404s registered projects that have no published releases, and registered packages can appear in thesimple/list_packagesindexes 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 --fixagainst 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
.devNsuffixVERSION_TOKENaccepts, 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_TOKENwith the trailing.devNa 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 ofVersionElements.
- class repomatic.changelog.VersionElements(compare_url='', date='', version='', availability_admonition='', changes='', development_warning='', editorial_admonition='', yanked_admonition='')[source]¶
Bases:
objectDiscrete 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 byChangelog.decompose_version()and used by therelease-notestemplate to render the##heading line. Body fields are unchanged.
- class repomatic.changelog.Changelog(initial_changelog=None, current_version=None)[source]¶
Bases:
objectHelpers 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 whatfreeze()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:
- 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-notestemplate.Returns
Falsefor 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.
- 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.
- 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:
- 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.
- 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.
- 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.
- update_comparison_base(version, new_base)[source]¶
Replace the base version in a version heading’s comparison URL.
Changes
compare/vOLD...vX.Y.Ztocompare/vNEW...vX.Y.Zin the heading for the given version.
- 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 aschanges.- Parameters:
version (
str) – Version string (e.g.1.2.3).- Return type:
- Returns:
A
VersionElementswith each field populated.
- 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. WhenTrue, uses “is the first version available on” wording.
- Return type:
- Returns:
A
> [!NOTE]admonition block, or empty string if neither URL is provided.
Build a GFM warning admonition for platforms missing a version.
- 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 inVersionElements.changes.- Return type:
- 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:
- 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
thresholdwords emits alogging.WARNINGand 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.
- 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
VersionElementsfields, 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.
- class repomatic.changelog.ReleaseSources(package, pypi_data, repo_url, github_releases, github_fetch_failed=False)[source]¶
Bases:
objectThe 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.
- pypi_data: dict[str, PyPIRelease]¶
Released versions on PyPI, keyed by version string.
- 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 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.
- class repomatic.changelog.DateCheck(corrections: dict[str, str], mismatched: bool, unfixed: bool)[source]¶
Bases:
NamedTupleWhat comparing every changelog date against its reference source found.
Create new instance of DateCheck(corrections, mismatched, unfixed)
- class repomatic.changelog.OrphanReconciliation(releases: list[tuple[str, str]], found: bool, modified: bool, unfixed: bool)[source]¶
Bases:
NamedTupleWhat reconciling versions missing from the changelog produced.
Create new instance of OrphanReconciliation(releases, found, modified, unfixed)
- 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, andwarn_on_empty_sections()over the released ones.When
fixis 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-changelogworkflow job skips this function during the release cycle (whenrelease_commits_matrixis 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 underfix) for entries split out of the live changelog. Archived dates are not re-validated.package (
str|None) – PyPI package name. IfNone, auto-detected frompyproject.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 thenot found on PyPIwarning, 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 (seewarn_on_long_bullets()).0disables the check. Never affects the exit code.
- Return type:
- Returns:
0if all dates match or references were corrected in-place,1if any date mismatch or orphan is found without a fix being applied,2if 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_REGISTRYentry with abinaryspec, downloads each platform URL (concurrently, sized by the global--jobsoption and sequential atDEBUGverbosity or without an active CLI context), computes its SHA-256, and replaces stale hashes in-place. Also reconciles each tool’sVERSIONSstamp with the version the checksums were computed for, the basis of the offline staleness test.- Parameters:
registry_path (
Path) – Path totool_registry.py.version_overrides (
dict[str,str] |None) – Optional mapping of tool name to a version to download instead of the in-memoryToolSpec.version.sync-tool-versionspasses 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:
- 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
0instead of failing the workflow that invoked it.
- repomatic.cli.log_output_target(subject, output)[source]¶
Log where a command is about to write subject.
Every command that honors an
--outputpath narrates the destination the same way, distinguishing the stdout case (-) so the log names the stream instead of a literal dash.
- class repomatic.cli.ComponentSelector[source]¶
Bases:
ParamTypeAccepts bare component names or qualified
component/fileselectors.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 toparse_component_entries(), the same code path theexcludeandincludeconfig options go through, so the CLI and config agree on syntax and error messages.- get_metavar(param, ctx)[source]¶
Returns the metavar default for this param if it provides one.
- Return type:
- 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
paramandctxarguments may beNonein certain situations, such as when converting prompt input.If the value cannot be converted, call
fail()with a descriptive message.
- shell_complete(ctx, param, incomplete)[source]¶
Return a list of
CompletionItemobjects 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:
Added in version 8.0.
- Return type:
- repomatic.cli.TEST_MATRIX_STATE_DISPLAY = {'stable': '✅ stable', 'unstable': '⁉️ unstable'}¶
Emoji-decorated labels for job states in the
show-test-matrixgrid.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:
CLOUDFLARE_API_TOKENfrom the environment (what CI uses).The OAuth token
wrangler loginstores 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
--checkstarts 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 loginstores its OAuth token, macOS first then XDG.
- exception repomatic.cloudflare.CloudflareError[source]¶
Bases:
RuntimeErrorRaised when the Cloudflare API refuses or a credential cannot be found.
- exception repomatic.cloudflare.CloudflareHTTPError(message, status)[source]¶
Bases:
CloudflareErrorAn HTTP-level refusal from the Cloudflare API, carrying its status.
Lets a caller act on which refusal arrived (a
404meaning “create it” is a different instruction than a403meaning “stop”) without parsing the message string it was raised with.
- class repomatic.cloudflare.Setting(path, desired, default, why, verified=False, managed=True)[source]¶
Bases:
objectOne server-side setting, with enough context to justify its value.
defaultis what a stock Cloudflare Pages project reports for this key. Where that value is quoted from Cloudflare’s documentation,verifiedis 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.
- 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
3requests nothing from a current project and names the stragglers.Pages supports exactly
productionandpreview, so the two environments are enumerated rather than globbed, and every managed setting applies identically to both.
- 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,mainas 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’s409.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:
- Returns:
Exit code:
0clean,1drift found (or left, for the read-only settings--applycannot 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.
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:
objectNested 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/repomaticon macOS,$XDG_CACHE_HOME/repomaticor~/.cache/repomaticon Linux,%LOCALAPPDATA%\repomatic\Cacheon Windows. TheREPOMATIC_CACHE_DIRenvironment 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
0to 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
0to disable auto-purge. TheREPOMATIC_CACHE_MAX_AGEenvironment variable takes precedence over this setting.
- 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:
objectNested schema for
[tool.repomatic.dependency-graph].- all_extras: bool = True¶
Whether to include all optional extras in the graph.
When
True, theupdate-dep-graphcommand behaves as if--all-extraswas passed.
- all_groups: bool = True¶
Whether to include all dependency groups in the graph.
When
True, theupdate-dep-graphcommand behaves as if--all-groupswas passed. Projects that want to exclude development dependency groups (docs, test, typing) from their published graph can set this tofalse.
- level: int | None = None¶
Maximum depth of the dependency graph.
Nonemeans 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-extrafor each entry. Takes precedence overdependency-graph.all-extras.
- class repomatic.config.DocsConfig(apidoc_exclude=<factory>, apidoc_extra_args=<factory>, update_script='./docs/docs_update.py')[source]¶
Bases:
objectNested 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"]).
- class repomatic.config.AgentLayout(skills, subagents, instructions, settings)[source]¶
Bases:
objectWhere one AI coding agent expects its assets to live.
- subagents: str¶
Directory holding subagent definitions.
Named for what it holds, not for the agent reading it:
agentsinvited a one-character confusion withinstructions, whose component and config key areagent, 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.mdfor Claude Code,AGENTS.mdfor the cross-agent convention), which is the whole reason this is a layout field rather than the constant it started as.
- 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_AGENTStrait ID.Only agents repomatic can actually lay out appear here.
clineandcursorare 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] agentis unset.
- repomatic.config.DEFAULT_CI: Final[str] = 'github_ci'¶
CI system assumed when
[tool.repomatic.flavor] ciis unset.
- repomatic.config.CLOUDFLARE_PLACEMENT_MODES: Final[frozenset[str]] = frozenset({'', 'off', 'smart'})¶
Values
site.cloudflare-placementaccepts, empty meaning unmanaged.The vocabulary of the Pages project’s
placement.modefield, which is whatrepomatic cloudflare-pageswrites the setting through. Anything else would be PATCHed to the live project verbatim and rejected there, far from thepyproject.tomlline 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
*.locationconfig 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 anoutput_dir / pathjoin. One normalizer keeps every consumer spelling the same value the same way.
- class repomatic.config.FlavorConfig(agent='claude_code', ci='github_ci')[source]¶
Bases:
objectNested 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 invokerepomaticlast, sorepomatic metadatawould stop being reproducible.- agent: str = 'claude_code'¶
AI coding agent whose asset layout the bundled skills and agents target.
Accepts a
extra_platforms.ALL_AGENTStrait ID present inAGENT_LAYOUTS. Hyphens are normalized, soclaude-codeworks too.
- ci: str = 'github_ci'¶
CI system the bundled workflows target.
Accepts a
extra_platforms.ALL_CItrait ID. Onlygithub_ciis 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:
objectNested 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.gitignorecontent.
- extra_content: str¶
Content appended at the end of the generated
.gitignorefile.“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 forextra_categoriesinstead when adding whole gitignore.io templates: that one is additive.The
.cc-writesentry 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 singlecdinto a subtree leaves one there instead. Anchoring it would miss every copy but the root’s.
- class repomatic.config.LabelsConfig(content_rules=<factory>, extra=<factory>, extra_files=<factory>, file_rules=<factory>, sync=True)[source]¶
Bases:
objectNested 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 byapply-labelsagainst 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/flagsform passes a regex through instead, withi,mandshonored. An entry for a label the bundled defaults also carry replaces the default entry, and an empty list disables it (seerepomatic.labels.DEFAULT_CONTENT_RULES).
- extra: list[dict[str, str | bool | list[str]]]¶
Inline label definitions applied at sync time under the
defaultprofile.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-fromandon-rename-clash. Arename-fromlist 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 bylabelmaker apply, so noextra-labels/*.tomlfile needs committing.For label sets that need multiple profiles, commit a hand-written file under
extra-labels/or download one viaextra-filesinstead.
- 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 bylabelmaker. For inline definitions that need no external file, useextrainstead.
- 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 byapply-labelsagainst 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
minimatchdialect (**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.gitignoreline would. An entry for a label the bundled defaults also carry replaces the default entry, and an empty list disables it (seerepomatic.labels.DEFAULT_FILE_RULES).
- class repomatic.config.LintDepsConfig(allow=<factory>, comment_word_threshold=40)[source]¶
Bases:
objectNested schema for
[tool.repomatic.lint-deps].- allow: dict[str, str]¶
Packages that may ship from somewhere other than PyPI, and why.
lint-depsblocks 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-depswarns 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-depsemits a non-fatal warning for every comment longer than this many words. Set to0to 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:
objectNested 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
outputpath, an optionalmetric(starsby default, and only a metric the store accrues can be charted), an optionalmode(absolute, the default, orrelative) measuring the horizontal axis, an optionalscale(linear, the default, orlogarithmic) measuring the vertical one, an optionalonlylist naming the subjects to plot in draw order, and an optionaltitleused 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, andscale = "logarithmic"keeps the smallest of them off the axis.An entry omitting
onlyplots 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_PALETTEin 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,githuborgitlab; Gitea instances read asforgejo, 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/nameis 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.compages.
- class repomatic.config.SyncRunnerImagesConfig(ignore=<factory>)[source]¶
Bases:
objectNested 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:
objectNested schema for
[tool.repomatic.test-matrix].Keys inside
replaceandvariationsare GitHub Actions matrix identifiers (e.g.,os,python-version) and must not be normalized to snake_case. Click Extra’sclick_extra.normalize_keys = Falsemetadata 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
osandpython-version. Keys left out inherit the matrix defaults (the single-keyincludeentries, plusstate: 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
excludewould take many rules. Likevariationsandunstable, 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
osandpython-versionare base axes, so a key likeclick-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, preferunstableover anincludecarryingstate: 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 astate: unstablevalue, whichtests.yamlreads to setcontinue-on-error. Likevariations, this applies to the full matrix only; the PR matrix stays a curated stable set.Prefer this over an
includeentry carryingstate: unstable.includeapplies to both matrices, and in the PR matrix a key likeclick-versionis not a base axis (anotherincludeinjects it), so GitHub’s expansion would add the directive to every PR job and overwrite it.unstableonly 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:
objectNested schema for
[tool.repomatic.vulnerable-deps].- sources: list[str]¶
Advisory databases to consult for known vulnerabilities.
Recognized values:
"uv-audit": PyPA Advisory Database viauv 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 withDependabot alerts: Read-only).
Sources are unioned and deduplicated per package by advisory identity: entries sharing an
advisory_idor a cross-referenced CVE/GHSA/PYSEC alias are merged. Repositories that distrust GHSA, or have no Dependabot alerts enabled, can opt out withsources = ["uv-audit"].
- class repomatic.config.WorkflowConfig(source_paths=None, extra_paths=<factory>, ignore_paths=<factory>, paths=<factory>, sync=True)[source]¶
Bases:
objectNested 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 (asname/**globs) alongside universal paths likepyproject.tomlanduv.lock.When
None(default), source paths are auto-derived from[project.name]inpyproject.tomlby 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
pathsignore 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.lockin repos with no Python tests or lockfile). Match is by exact string equality. Applies beforeextra_paths.Per-workflow overrides in
pathsignore 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 (inpush,pull_request, etc.) are replaced wholesale with the listed entries.source_paths,extra_paths, andignore_pathsdo 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 withoutpaths:upstream keep their unrestricted trigger semantics.Example:
[tool.repomatic.workflow.paths] "tests.yaml" = ["install.sh", "packages.toml", ".github/workflows/tests.yaml"]
- 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:
objectConfiguration schema for
[tool.repomatic]inpyproject.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.Zfreeze and was then skipped perCLAUDE.md§ Skip and move forward (botched build, broken artifact, bad metadata) without rewriting history. List those versions here solint-changelogreports 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-pinsjob is enabled for this project.Bumps SHA-pinned GitHub Actions (
uses: owner/repo@<sha> # vX.Y.Z) to the latest release passing theminimum-release-agecooldown. Projects that pin actions by hand can set this tofalse.
- 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
agentcomponent writes here, merging the audience-tagged sections it owns into whatever the file already holds. Point it atAGENTS.mdfor 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 areagentandsubagentsfor 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 inrepomatic. Set tofalseto opt out.
- binaries_sync: bool = True¶
Whether the release pipeline records released binaries into the repository.
When enabled, the
scan-virustotalrelease job regenerates the binaries catalog (docs/binaries.mdanddocs/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 tofalseto 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 tofalse.
- 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-changelogtreats 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-changelogwarns 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-changelogemits 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 to0to 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-sourcesupdater is enabled for this project.Swaps a dependency tracked from a git branch back to its released version once the release named by its
.devversion floor ships on PyPI (seerepomatic.dep_sourcesfor the managed idiom). Projects that manage[tool.uv.sources]overrides by hand can set this tofalse.
- 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"). Qualifiedcomponent/identifierentries exclude a specific file within a component (e.g.,"workflows/debug.yaml","skills/repomatic-audit","labels/labels.toml").Affects
repomatic init,workflow sync, andworkflow create. Explicit CLI positional arguments override this list.
- flavor: FlavorConfig¶
Which agent and CI ecosystem this repository targets.
- gitignore: GitignoreConfig¶
.gitignoresync 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 + userexclude) and bypassesRepoScopefiltering, so scope-restricted components (like awesome-only skills or Python-onlypublish-pypi-action) are included regardless of repository type. Qualified entries (component/file) implicitly select the parent component. Same syntax asexclude.
- labels: LabelsConfig¶
Repository label sync configuration.
- lint_deps: LintDepsConfig¶
Dependency shippability gate configuration.
- mailmap_sync: bool = True¶
Whether
.mailmapsync is enabled for this project.Projects that manage their own
.mailmapand do not want the autofix job to overwrite it can set this tofalse.
- manpages_asset_name: str = ''¶
Filename stem (without the
.tar.gzextension) for the man-page tarball uploaded to the GitHub release.Defaults to
<package-name>-manpageswhen left empty andmanpages.scriptis set. Has no effect whenmanpages.scriptis empty.
- manpages_script: str = ''¶
Click command target whose tree gets rendered as roff
.1files and attached as a tarball asset on every GitHub release.Same shape the
click-extra wrap --manCLI accepts: amodule:functionpath (preferred for projects whose console-script entry point dispatches through a wrapper), an entry-point name, a.pyfile 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, andsync-workflow-pinsjobs: 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 gatesrepomatic run’s ad-hoc installs at run time, so their transitive trees honor the same window:uvxtools via uv’s--exclude-newer, npm tools via npm’smin-release-age.repomatic inithonors 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’sexclude-newer(which guardssync-uv-lock). Accepts the same friendly durations (8 days,2 weeks,36 hours). Set to0 daysto 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
notificationsscope stored asREPOMATIC_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
scheduletrigger, and onworkflow_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 tofalseto 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 uniquemodule:callablepair. This avoids building duplicate binaries when a project declares alias entry points (like bothmpmandmeta-package-managerpointing 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 barepip 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-packagein[tool.nuitka]for imports guarded behindtry/except).
- nuitka_nofollow_imports: list[str]¶
Module names Nuitka must not follow into the compiled binary.
Each name is forwarded as a
--nofollow-import-toflag by repomatic run nuitka`. Defaults to``[“tkinter”]``:boltons.ecoutils` (in the dependency tree of every click-extra CLI) probes tkinter inside a guarded ``try/exceptimport, which otherwise drags the whole Tcl/Tk stack into every binary. Excluded modules raiseImportErrorwhen 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-changelogcan 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
buildlane the engine call already gates on) and uploaded as a run artifact namedrelease-asset-<filename>. The engine’sextra-assetsjob 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
plugincomponent 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_PATor manage their own PAT setup can set this tofalseto suppress the setup guide issue.
- site_cloudflare_compatibility_date: str = ''¶
Workers runtime date the Cloudflare Pages project is pinned to.
A
YYYY-MM-DDdate, compared and enforced byrepomatic cloudflare-pagesagainst the live project’sdeployment_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.tomlkey 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-repowarns when a committedwrangler.tomldisagrees, so the repository states one value rather than two.
- site_cloudflare_placement: str = ''¶
Smart Placement mode declared for the Cloudflare Pages project.
smartoroff, compared and enforced byrepomatic cloudflare-pageson 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.devhostname 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-pagesuploads it to a Cloudflare Pages project instead, named persite.cloudflare-project, throughwrangler 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
_redirectsfile, a real404.htmland 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
htmlwritespage.html, so the site serves/page.html. Setting it todirhtmlwritespage/index.htmlinstead, 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, anddocs.yamlis 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-versionsjob is enabled for this project.Bumps every tool in the
repomatic runregistry to the latest release passing theminimum-release-agecooldown (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 tofalse.
- uv_lock_sync: bool = True¶
Whether
uv.locksync is enabled for this project.Projects that manage their own lock file strategy and do not want the
sync-uv-lockjob to runuv lock --upgradecan set this tofalse.
- vulnerable_deps: VulnerableDepsConfig¶
Vulnerable dependency detection and remediation configuration.
- workflow: WorkflowConfig¶
Workflow sync configuration.
- workflow_pins_sync: bool = True¶
Whether the
sync-workflow-pinsjob is enabled for this project.Bumps version literals embedded in workflow YAML (npm
pkg@xinstalls anduvx '<pkg>==x'PyPI pins) to the latest release passing theminimum-release-agecooldown. Projects that pin these by hand can set this tofalse.
- 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]inpyproject.tomlby 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 likelist[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-configoutput). 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:
- 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 theshow-configtable. Returns a list of(option, type, default, description)tuples suitable forclick_extra.table.print_table.
- repomatic.config.load_repomatic_config(pyproject_data=None)[source]¶
Load
[tool.repomatic]config merged withConfigdefaults.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.
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_NODEsince duplicates are always headline (primary) dependencies of their box.
- class repomatic.dep_graph.SubgraphKind(*values)[source]¶
Bases:
EnumKind of dependency selector a subgraph box represents.
- GROUP = 'group'¶
- EXTRA = 'extra'¶
- 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].
- class repomatic.dep_graph.Subgraph(kind, name, owned, duplicates)[source]¶
Bases:
objectOne
--groupor--extrabox 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.
- 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().
- 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
_0suffix to avoid conflicts with Mermaid reserved keywords.- Return type:
- 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-graphcommand: explicit CLI values win over the[tool.repomatic] dependency-graphdefaults;--only-*replaces the explicit selection;--all-*expands to every name declared inpyproject.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:
- Returns:
Selected names, or
Nonewhen 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:
- Returns:
Parsed CycloneDX SBOM dictionary.
- Raises:
subprocess.CalledProcessError – If uv command fails.
json.JSONDecodeError – If output is not valid JSON.
- repomatic.dep_graph.get_package_names_from_sbom(sbom)[source]¶
Extract all package names from a CycloneDX SBOM.
- repomatic.dep_graph.build_dependency_graph(sbom)[source]¶
Build a dependency graph from CycloneDX SBOM data.
- Parameters:
- Return type:
- 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.tomldeclaration 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 sphinxand no--groupis enough forrequeststo come back as a direct dependency of the project: Sphinx pulls it in, thetestgroup happens to declare it too, and the export conflates the two. Neither omitting--groupnor passing--no-default-groupssuppresses 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 whatpyproject.tomldeclares 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, fromby_main.Nonewhen 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:
- 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.
- 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
depthhops and edges between those nodes.- Parameters:
- Return type:
- 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).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). SeeSubgraph.lock_specs (
LockSpecifiers|None) – Optional specifiers extracted fromuv.lock. Provides edge labels (by_package) and subgraph node labels (by_subgraph).
- Return type:
- 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 thecarapaceandyamlextras both declare onlypyyaml, which no other package depends on: the dependent counts tie at zero,carapaceowns the node by declaration order, andyamlcarriespyyamlas 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 (fromuv.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:
- 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-groupand--only-extra.
- Return type:
- 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.2is>=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:
objectOne declaration that departs from the project’s version policy.
Deliberately not a
DepFinding: that type carries aSourceKindbecause every one of its findings is about where a package resolves from, and a style finding has no answer to give there.
- 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:
- 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:
- Return type:
- 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.
- repomatic.dep_report.link_name(name, name_urls)[source]¶
Render a table’s subject cell, linked when a URL is known for it.
- 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.rows (
list[tuple[str,...]]) – One tuple of pre-rendered cells per row, each as long as headers.
- Return type:
- 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.uvreads lock timestamps,repomatic.cloudflaretoken expiries andrepomatic.github.job_timingsjob clocks through it, so every consumer tolerates the same shapes.Uses arrow, so a nanosecond fractional second and a
Zsuffix (both of which Python 3.10’s stdlibdatetime.fromisoformatrejects) parse cleanly; sub-microsecond precision is truncated to fitdatetime.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
- repomatic.dep_report.format_upload_date(iso_datetime)[source]¶
Format an ISO 8601 datetime as a human-readable date string.
- repomatic.dep_report.format_released(raw_upload, reference)[source]¶
Format an upload time as a date, optionally with a relative hint.
- Parameters:
- Return type:
- Returns:
A string like
2026-06-24 (2 days ago), the bare date when reference isNone, or empty when raw_upload is empty.
- repomatic.dep_report.format_eligible(eligible, today)[source]¶
Render an eligibility date with a human-readable countdown.
- repomatic.dep_report.pypi_name_urls(changes)[source]¶
Map each changed package name to its PyPI project URL.
Convenience for
format_diff_table()’sname_urlswhen the changes come from a PyPI-resolved source (sync-uv-lock,fix-vulnerable-deps).
- repomatic.dep_report.format_exclude_newer_note(exclude_newer)[source]¶
Render the uv
exclude-newercutoff sentence for a diff table.The
format_diff_table()counterpart forsync-uv-lockandfix-vulnerable-deps, which gate on uv’s absoluteexclude-newertimestamp. The relative-cooldown updaters (repomatic.version_sync) render their ownminimum-release-agenote instead.- Parameters:
exclude_newer (
str) – ISO 8601 datetime from the lock’s[options].exclude-newer, as returned byrepomatic.uv.parse_lock_exclude_newer(), or empty.- Return type:
- 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_timesis 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 throughreleased_overridesinstead of showing a date, so the exemption reads as deliberate rather than as missing data. Whencooldown_noteis provided, that pre-rendered sentence (the absoluteexclude-newercutoff for uv, or the relativeminimum-release-agecutoff 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 byrepomatic.uv.diff_lock_versions().upload_times (
dict[str,str] |None) – Optional mapping of package names to ISO 8601 upload-time strings, as returned byrepomatic.uv.parse_lock_upload_times().cooldown_note (
str) – Optional pre-rendered markdown sentence describing the cooldown cutoff, shown above the table. Build it withformat_exclude_newer_note()(uv) orrepomatic.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 (seebuild_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. Passpypi_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 withoutupload_times; entries for unchanged names are ignored.
- Return type:
- 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:
objectA newer release withheld from the lock by the
exclude-newercooldown.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, souv lock --upgradekeeps the olderlocked_version.- 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_versionleaves 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-lockheld-back section.The
repomatic.version_syncupdaters pass their ownminimum-release-agewording toformat_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
HeldBackPackagerow 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 samereleased/eligiblestringsrepomatic.uv.compute_held_back_packages()produces for uv, soformat_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) – Theminimum-release-agecooldown width.today (
date) – Reference date for the relative countdown.
- Return type:
- 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 fromrepomatic.uv.compute_held_back_packages()) and the version-sync commands (rows frombuild_held_back()), so the section renders identically.- Parameters:
held_back (
list[HeldBackPackage]) – Withheld releases asHeldBackPackagerows.note (
str) – Intro paragraph describing the cooldown. Defaults to the uvexclude-newerwording; version-sync passes itsminimum-release-agewording.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:
- Returns:
A markdown string with a
## ⏸️ Held back by cooldownheading 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-packageentry whose held version has no upload time in the lock (a git, path, or otherwise unpublished source) can never age past the rollingexclude-newercutoff 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:
objectA cooldown-bypass freeze and the date it self-clears.
Built by
repomatic.uv.compute_bypass_forecasts()(freezes still active) andrepomatic.uv.compute_pruned_forecasts()(freezes the run just cleared) for the## ❄️ Cooldown bypassesreport section: a fixed-timestampexclude-newer-packageentry holdsnameatheld_versionuntil that version ages past theexclude-newercutoff, at which pointsync-uv-lockprunes the entry and the package resumes normal cooldown resolution.- 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_RELEASEwhen the held version has no upload time in the lock, or empty when there is no rollingexclude-newerspan 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-lockcooldown-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 reasonHELD_BACK_COLUMNSis.
- 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-lockreport section coveringexclude-newer-packagefreezes. Every lifecycle state is a row in one table so the section scans like the## 🆙 Updated packagesone: freezes still active render plain, entries this run rewrote into freeze cutoffs are labelled📌 frozen:, and expired entries this run removed frompyproject.tomlare labelled🧹 cleared:, keeping the version and expiry data the freeze had. A freeze holding an unreleased version is labelled🚧 unreleased:and itsBYPASS_NEEDS_RELEASEexpiry renders in italics.- Parameters:
forecasts (
list[BypassForecast]) – Active freezes fromrepomatic.uv.compute_bypass_forecasts().pruned (
list[BypassForecast] |None) – Expired entries the run removed, snapshot byrepomatic.uv.compute_pruned_forecasts()before the prune.frozen (
list[str] |None) – Names of the entries the run rewrote into freeze cutoffs; their forecasts rows get the📌 frozen:label.name_urls (
dict[str,str] |None) – Optional mapping of names to a URL the name links to. Names absent from the mapping render plain.
- Return type:
- Returns:
A markdown string with a
## ❄️ Cooldown bypassesheading 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 PyPIproject_urlswhen no GitHub Release exists.- Parameters:
changes (
list[tuple[str,str,str]]) – List of(name, old_version, new_version)tuples.- Return type:
- Returns:
A dict mapping package names to
(repo_url, versions)tuples whereversionsis 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,tagis empty andbodycontains a markdown link.
- repomatic.dep_report.format_release_notes(notes)[source]¶
Render release notes as collapsible
<details>blocks.A
### Release notesheading (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 toRELEASE_NOTES_MAX_LENGTHcharacters 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 whereversionsis a list of(tag, body)pairs, as returned byfetch_release_notes().- Return type:
- 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 avprefix there yields a 404 in the PR body.- Parameters:
- Return type:
- 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 arevortagpin), anda 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-depstable.Lives beside
DepFindingso the columns and the fields they render cannot drift apart; the CLI derives its--sort-bychoices from it.DepFinding.consequenceandDepFinding.remedyare 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 runningpip installoruvxagainst 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.dev0into>=2.1.0in 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:tomllibandtomlkitboth 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:
objectA git-tracked dependency whose awaited release has shipped.
Built by
find_ready_swaps(); consumed byapply_release_swaps()(thepyproject.tomlrewrite) andformat_swap_section()(the PR report).- source_key: str¶
The entry key as written in
[tool.uv.sources](may differ fromnamein case or separators).
- property freeze_cutoff: str¶
The
exclude-newer-packagecutoff adoptingrelease.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
gitURL and abranchare returned: arevortagpin 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”.
- 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-depsreports it, and it is also what separates the tables that reach the published wheel’sRequires-Distfrom the ones that never leave the repository.
- repomatic.dep_sources.parse_requirement(item)[source]¶
Parse a requirement array item, returning
Nonefor anything else.
- repomatic.dep_sources.dev_floor(pyproject_path, name)[source]¶
The highest
.devlower 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.
- 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 runninguvx repomatic) gets a tool environment, which reads neitheruv.locknor[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(seerepomatic.prepare_release.LOCAL_CLI_INVOCATION), which resolves through the localexclude-newer-packageexemption 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.lockrecords, 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:
- Return type:
- 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 thepyproject.tomlfile.- Return type:
- 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
.devlower 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.dev0becomes>=2.1.0). Everything else in the string (extras, markers, other clauses, spacing) is preserved byte-for-byte.
- repomatic.dep_sources.apply_release_swaps(pyproject_path, swaps)[source]¶
Rewrite
pyproject.tomlfor 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.devfloor on the swapped packages is tightened to its base release. The third edit, the cooldown-bypass freeze atReleaseSwap.freeze_cutoff, goes throughrepomatic.uv.upsert_exclude_newer_packages()so the insertion position and inline-table formatting stay canonical.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.swaps (
list[ReleaseSwap]) – Ready swaps fromfind_ready_swaps().
- Return type:
- 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-sourcesswap 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-sourcesreport section explaining thepyproject.tomlhunks: 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 fromfind_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:
- Returns:
A markdown string with a
## 🔀 Source swapsheading and table, or an empty string when swaps is empty.
- class repomatic.dep_sources.SourceKind(*values)[source]¶
Bases:
StrEnumWhere a dependency is resolved from.
The vocabulary is shared by
[tool.uv.sources]anduv.lock, which name the same concepts with the same keys, soclassify_source()reads both. OnlyREGISTRYdescribes something an installer of the published artifact can reach on its own.- DIRECT_REFERENCE = 'direct reference'¶
A PEP 508
name @ urlclause 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
directoryentry.
- 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:
objectOne 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.- kind: SourceKind¶
How that package is resolved.
- level: AnnotationLevel = 'error'¶
Severity. Only
ERRORblocks a release.
- 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:
- Returns:
Truewhen its host is one ofPYPI_INDEX_HOSTS.
- repomatic.dep_sources.classify_source(value)[source]¶
Read a
[tool.uv.sources]entry or auv.locksource 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:
- Returns:
The kind, or
Nonefor 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.
- 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].requiresincluded,a
[[tool.uv.index]]markeddefaultthat is not PyPI,a non-empty
override-dependenciesorconstraint-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:
- Return type:
- Returns:
Findings, unsorted;
scan_project()orders them.
- repomatic.dep_sources.scan_lock(lock_path, allow=None, doc=None)[source]¶
Report every package
uv.lockresolves 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 inpyproject.tomlnames 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 theuv.lockfile.allow (
dict[str,str] |None) – Package name to the reason it may ship from a non-index source.doc (
dict|None) – Parsedpyproject.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:
- 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.tomldeclares (scan_pyproject()), whatuv.lockresolved (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.tomlfinding: that is where the reader has something to edit, the lock being a derived file.- Parameters:
- Return type:
- 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-depsblocker 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-depsreport: a reader who opened that report came for the diagnosis, so it carries the note and the full table. The release PR getsbuild_release_readiness()instead, which is the same findings at banner length.- Parameters:
findings (
list[DepFinding]) – Findings fromscan_project().heading (
str) – Section heading, emoji included.
- Return type:
- 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.lockfor a source the resolver picked,pyproject.tomlfor everything the project wrote itself, dependency floors included.- Parameters:
finding (
DepFinding) – The finding to locate.pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.
- Return type:
- 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-depsreportformat_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-releasechecklist 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 perclaude.md§ Skip and move forward. This body is regenerated on every push tomain, 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-bodytemplate-argument builders inrepomatic.github.pr_body, which is where it would otherwise belong: that module is imported byrepomatic.dep_report, so reachingscan_project()from it closes an import cycle.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.window (
str) – Cooldown window, from[tool.repomatic] minimum-release-age.allow (
dict[str,str] |None) – Package name to itslint-deps.allowreason.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:
- 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:
- Return type:
- Returns:
The resolved path, or
Nonewhen the configured value is empty.- Raises:
ClickException – If the path escapes the repository root or is not a
.pyfile underdocs/.
- repomatic.docs.DIRECTIVE_BLOCK_MARKERS: tuple[str, ...] = ('{matrix}', '<!-- matrix', ':mirror:', '<!-- mirror')¶
Markers of a self-updating block
click-extra refresh-directivesrewrites.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 thepython: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:
- Returns:
Truewhen anyDIRECTIVE_BLOCK_MARKERSentry appears.
- repomatic.docs.update_docs(config, *, check=False)[source]¶
Regenerate Sphinx autodoc stubs and run the project’s update script.
Orchestrates four phases:
Run
sphinx-apidocto generate RST stubs for all modules.If MyST-Parser is detected, convert the RST stubs to MyST markdown with
{eval-rst}blocks.Run the project-specific
docs/docs_update.pyscript (if present) to generate dynamic content.Refresh self-updating blocks (
{matrix}compatibility tables andpython:render:mirror:regions) found indocs/pages andreadme.md, viaclick-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 --checkandrefresh-directives --check) and any drift raises aClickException. The update script must accept a--checkflag to participate: a script that ignores it will still write.
- Return type:
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
.gitignorefile whose rules filter every inventory lookup.Fixed at the repository root, unlike the configurable
[tool.repomatic.gitignore] locationthatsync-gitignorewrites: the glob filter has to match what git itself honors, and git only reads this path.
- class repomatic.file_inventory.FileInventory[source]¶
Bases:
objectThe 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_parser: Parser | None[source]¶
Returns a parser for the
.gitignorefile, if it exists.
- 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
.gitignorefile
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.
- property json_files: list[Path][source]¶
Returns a list of JSON files.
Note
JSON5 files are excluded because Biome doesn’t support them.
- 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. Seerepomatic.imagesfor the optimization tools.
- static shebang_names_zsh(path)[source]¶
Whether path opens with a shebang line naming zsh.
The
.shextension is ambiguous: it says POSIX shell while the shebang picks the actual interpreter. Reading that first line is what keepsshfmt_filesandzsh_filesdisjoint, so a bash script is never handed to the Zsh linter and a zsh script is never handed toshfmt.
- property shfmt_files: list[Path][source]¶
Returns a list of shell files that
shfmtcan reliably format.shfmtsupports the following dialects (-lnflag):bash: GNU Bourne Again Shell.
posix: POSIX Shell (
/bin/sh).mksh: MirBSD Korn Shell.
bats: Bash Automated Testing System.
Zsh is excluded.
shfmtadded experimental Zsh support in v3.13.0 but it fails on common constructs:for var (list)short-form loops andfor ... { }brace-delimited loops. See mvdan/sh#1203 for upstream tracking.Files are excluded by extension (
.zsh,.zshrc, etc.) and by shebang (any.shfile whose first line referenceszsh).
- property zsh_files: list[Path][source]¶
Returns a list of Zsh files.
The
.zshextension and the zsh dotfiles are unambiguous. A.shfile 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 runzsh --no-execover scriptsshfmtis formatting as bash. Seeshebang_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.orgruns GitLab,gitlab.archlinux.orgtoo).
- 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.compages: 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:
/tagsanswers in an order nobody should assume, so a fallback trusting it can date a live project a decade into the past. Ordering onTAG_COMMIT_DATEstates 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:
objectOne repository’s metrics, as any forge reports them.
- 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_source: str | None = None¶
Where
releasecame from: areleaseobject, or a baretag.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.
createdis deliberately absent: it is not a metric but the origin of one, recorded by the sampler as astarsreading of zero on that date.
- repomatic.forge.split_repo_url(url)[source]¶
Split a repository URL into its host and its owner/name path.
- Parameters:
url (
str) – Anhttps://host/owner/namerepository URL.- Return type:
- 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/nameis 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) – Anowner/nameslug or a full repository URL.- Return type:
- Returns:
The canonical
https://host/owner/nameURL.- 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:
- Return type:
- Returns:
One of
forgejo,githuborgitlab.- 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.
- repomatic.forge.forge_json(url)[source]¶
Read one JSON document from a forge’s public API.
Covers every forge but GitHub, whose authentication
ghalready 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.
- repomatic.forge.github_metrics(path)[source]¶
Read a GitHub repository through
GITHUB_METRICS_QUERY.- Parameters:
path (
str) – The repository’sowner/namepath.- Return type:
- Returns:
The repository’s metrics.
- Raises:
RuntimeError – When the
ghcall fails.
- repomatic.forge.gitlab_metrics(host, path)[source]¶
Read a GitLab project, on whichever instance hosts it.
- Parameters:
- Return type:
- Returns:
The project’s metrics, or
Nonewhen unreadable.
- repomatic.forge.forgejo_metrics(host, path)[source]¶
Read a Forgejo or Gitea repository, on whichever instance hosts it.
- Parameters:
- Return type:
- Returns:
The repository’s metrics, or
Nonewhen unreadable.
- repomatic.forge.repo_metrics(url, extra_forges=None)[source]¶
Read one repository, through whichever API its host speaks.
- Parameters:
- Return type:
- Returns:
The repository’s metrics, or
Nonewhen the forge could not be read.- Raises:
ValueError – When the host declares no forge.
RuntimeError – When a GitHub call fails.
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
metadatamapping, a template’sargslist) 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 anargument-hintlisting a long-form option) from truncating the block.- Parameters:
raw (
str) – Full text of the document.- Return type:
- 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 verifiedgithub-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/reposlug 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 singlestartsWith(github.event.head_commit.message, '[changelog] ')clause instead of enumerating each message shape. Conformance tests intests/test_workflows.pyhold every member ofVERSION_BUMP_COMMIT_PREFIXES,RELEASE_COMMIT_PATTERN, thebump-versiontemplate 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’scancel-in-progressgate 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
fullmatchto validate a commit is a release commit, ormatch/searchwith.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: thecreate-tagjob only processes commits matching this pattern, so no tag, PyPI publish, or GitHub release is created from a squash merge. Thedetect-squash-mergejob inrelease.yamldetects 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-versionandprepare-releasejobs inchangelog.yaml. Their working tree is byte-identical tomainexcept for the version string inpyproject.toml,**/__init__.py,changelog.md,citation.cff, anduv.lock. Heavy PR-time workflows (tests.yaml,lint.yaml,labels.yaml) list these branches underpull_request.branches-ignoreso 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 onmainare 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-versionjob’s[changelog] Bump $part version to \``v$version\commit messages (rendered from thebump-versiontemplate’s title), carryingCHANGELOG_COMMIT_PREFIXlike every other version-machinery commit. These merges land as a single commit onmainand carry no other payload, so workflows can short-circuit on them safely.The release-cycle prefix
[changelog] Post-release bumpis deliberately absent from this set because theprepare-releasemerge 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_PREFIXESwith the[changelog] Post-release bumpprefix produced byprepare-releasemerges. Every member starts withCHANGELOG_COMMIT_PREFIX, so workflows without a release-artifact dependency (lint.yaml,labels.yaml,autofix.yaml) gate theirmetadatajob 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 useMANUAL_VERSION_BUMP_COMMIT_PREFIXESinstead.
- repomatic.git_ops.GIT_LOG_FORMAT = '%H%x00%B'¶
git logpretty-format placeholders for a single commit: full SHA, then aNUL, then the raw body.Paired with
git log -z(which terminates each commit’s output with aNUL), this frames the stream as alternating(hash, message)tokens. Commit messages may contain newlines but neverNULbytes, so splitting onNULrecovers the fields unambiguously even for multi-line messages.
- class repomatic.git_ops.Commit(hash: str, msg: str)[source]¶
Bases:
NamedTupleA 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
gitCLI feeds these two fields directly.Create new instance of Commit(hash, msg)
- 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:
- repomatic.git_ops.list_commits(start, end)[source]¶
Return the commits in the
start..endrange, 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.
- repomatic.git_ops.commit_exists(ref)[source]¶
Return
Trueif ref resolves to a commit object present locally.- Return type:
- repomatic.git_ops.count_commits(ref='HEAD')[source]¶
Return the number of commits reachable from ref.
- Return type:
- repomatic.git_ops.current_branch()[source]¶
Return the checked-out branch name, or
NonewhenHEADis detached.
- repomatic.git_ops.stash_pop()[source]¶
Restore the most recently stashed local changes.
- Return type:
- repomatic.git_ops.stash_count()[source]¶
Return the number of entries on the stash reflog.
- Return type:
- 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:
- 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:
- 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:
- repomatic.git_ops.count_commits_between(start, end)[source]¶
Return the number of commits in the
start..endrange.Follows git range semantics: start is excluded, end is included.
- Return type:
- repomatic.git_ops.is_ancestor(maybe_ancestor, ref)[source]¶
Return whether maybe_ancestor is reachable from ref.
- repomatic.git_ops.merge_base(left, right)[source]¶
Return the best common ancestor of two commits, or
Noneif unrelated.Nonealso covers the shallow-clone case described inis_ancestor().
- repomatic.git_ops.rebase_onto(new_base, old_base, branch)[source]¶
Replay
old_base..branchon top of new_base, keeping the replayed side.--strategy-option=theirsresolves 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:
- Returns:
Trueon success. On conflict the rebase is aborted andFalsereturned, 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
HEADand 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:
- 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
finallythat 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:
- 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 addexits 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.
- 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. Mirrorscommit_and_push_files(), which does the same for the direct-to-default-branch case.- Return type:
- 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/checkoutconfigures a single-branch fetch refspec, under which a baregit fetch origin {branch}updatesFETCH_HEADbut leavesrefs/remotes/{remote}/{branch}absent. Writing the remote-tracking ref is what later lets a push take a--force-with-leaseon it.
- 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-leaseguard: the push is refused when the branch moved in between, rather than silently discarding the other writer’s commit. PassNoneto 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:
- repomatic.git_ops.delete_remote_branch(branch, remote='origin')[source]¶
Delete branch from remote, tolerating a branch already gone.
- Return type:
- 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:
- repomatic.git_ops.get_repo_slug_from_remote(remote='origin')[source]¶
Extract the
owner/reposlug from a git remote URL.Parses both HTTPS and SSH GitHub remote formats. Returns
Noneif the remote is not set, not a GitHub URL, or git is unavailable.
- repomatic.git_ops.get_latest_tag_version()[source]¶
Returns the latest release version from Git tags.
Looks for tags matching the pattern
vX.Y.Zand returns the highest version. ReturnsNoneif 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.Zand 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.
- repomatic.git_ops.get_all_version_tags()[source]¶
Get all version tags and their dates.
Runs a single
git tagcommand to list all tags matching thevX.Y.Zpattern and extracts their dates.
- repomatic.git_ops.create_tag(tag, commit=None)[source]¶
Create a local Git tag.
- Parameters:
- Raises:
subprocess.CalledProcessError – If tag creation fails.
- Return type:
- repomatic.git_ops.push_tag(tag, remote='origin')[source]¶
Push a Git tag to a remote repository.
- Parameters:
- Raises:
subprocess.CalledProcessError – If push fails.
- Return type:
- 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_NAMEvia per-command-cconfig, 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 detachedHEAD: the push targetsHEAD:{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:
- Returns:
Truewhen a commit was pushed,Falsewhen there was nothing to commit.- Raises:
RuntimeError – When the rebase hits a conflict (the local change overlaps a concurrent push) or every push attempt is rejected.
subprocess.CalledProcessError – When a git command fails outright.
- 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_existingis True, it returns False without failing. This allows safe re-runs of workflows that were interrupted after tag creation but before other steps.- Parameters:
- Return type:
- Returns:
True if the tag was created, False if it already existed.
- Raises:
ValueError – If tag exists and skip_existing is False.
subprocess.CalledProcessError – If Git operations fail.
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-categoriesin[tool.repomatic].
- repomatic.gitignore.GITIGNORE_IO_URL = 'https://www.toptal.com/developers/gitignore/api'¶
gitignore.io API endpoint for fetching
.gitignoretemplates.
- repomatic.gitignore.build_gitignore(config)[source]¶
Fetch and assemble the
.gitignorecontent for config.Combines
GITIGNORE_BASE_CATEGORIESwith 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:
- Returns:
The full
.gitignoretext.- Raises:
urllib.error.URLError – When the gitignore.io fetch fails.
- repomatic.gitignore.parse_rules(content)[source]¶
Extract the ignore rules from
.gitignorecontent.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.
- repomatic.gitignore.orphaned_rules(existing, generated)[source]¶
Return the rules generated would drop from existing.
sync-gitignorerebuilds the file from gitignore.io plus[tool.repomatic.gitignore] extra-contentand 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.gitignoreon disk.generated (
str) – Contentbuild_gitignore()just produced.
- Return type:
- 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:
RuntimeErrorRaised 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:
- Return type:
- 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.
- 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/keyshort-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;0disables 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:
- Returns:
The parsed JSON value, or
Noneon 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 customaryKB/MBsymbols), matching the format produced bycalibreapp/image-actions.- Return type:
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:
objectResult of optimizing a single image file.
- 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:
- Returns:
An
OptimizationResultif the file was optimized, orNoneif the format is unsupported, the required tool is missing, or savings were below the threshold.
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
repomaticat runtime, not deployed verbatim.These files live in
repomatic/data/so they ship in the wheel and are discoverable viaget_data_content(), butrepomatic initnever copies them as-is.claude.mdis the reference documentrender_agent_md()reads to project its audience-tagged sections into a downstream repository’s ownclaude.md; what lands there is a filtered overlay, never this file entire.release.yamlis the canonical callerrepomatic.github.workflow_syncreads to assemble each downstreamrelease.yaml, copying its jobs and rewriting the localuses:refs (see_generate_release_caller); the deployedrelease.yamlis generated, not this bundled copy.vt-trend-chart.jsis the detections-chart scriptrepomatic.binaries_page.render_chart_sectionsplices intodocs/binaries.mdwith 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.
Nonemeans the file is bundled but not directly written to a target path byrepomatic init(used forpyproject.tomltemplates 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:
- Returns:
Content of the file as a string.
- Raises:
ValueError – If the file is not in the registry.
FileNotFoundError – If the file doesn’t exist.
- 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:
- Return type:
- Returns:
The modified pyproject.toml content, or
Noneif 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
.dev0suffix and prefixes withv. For example,"5.10.0.dev0"becomes"v5.10.0".- Return type:
- 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)initstamps into thin-calleruses: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-agewindow 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.
initis the only writer of these refs:sync-action-pinsskips every slug inUPSTREAM_REPO_SLUGS, andACTION_PIN_REdoes 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 newerinit. Two things follow.A pin equal to the running version is not a decision to gate. The CI
sync-repomaticjob runsinitat the pinned version itself, sobaseequals 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.
initrenders 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 (seetest_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 theminimum-release-agewindow.repo (
str) – Upstreamowner/repowhose 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), sorun_initcan surface it in the finalinitsummary rather than only mid-run.floor (
UpstreamRefPin|None) – The highest upstream pin already committed downstream, from_highest_upstream_pin().Nonefor a repository carrying none, the one case the cooldown may step back freely.
- Return type:
- Returns:
(version_pin, commit_sha).commit_shaisNonewhen 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:
objectResult of a repository initialization run.
- 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).
- repomatic.init_project.prune_paths(paths, output_dir, *, prune_parents=True)[source]¶
Delete every path of an
InitResultreport section.The deleting half of
init’s delete flags (--delete-excluded,--delete-unmodified, the removed-asset pruning), kept besiderun_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:
- repomatic.init_project.adopted_ongoing_configs(output_dir)[source]¶
Return the ongoing tool configs whose section
pyproject.tomlalready carries.EXPLICITgoverns adoption, not upkeep: it keeps a bareinitfrom pushing[tool.typos]onto a repository that never asked for one. Once the section is there the repository has asked, so anONGOINGcomponent 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
initthesync-repomaticjob 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.BOOTSTRAPcomponents 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.
- 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.mdif 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, theawesome-templatecomponent 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 barerepomatic 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 (likerepomatic init publish-pypi-actionin 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 (likev5.10.0). WhenNone, derived from the running package version, gated by cooldown.cooldown (
bool) – WhenTrue(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 (seeresolve_default_pin()). Ignored when version is explicit.repo (
str) – Upstream repository containing reusable workflows.repo_slug (
str|None) – Repositoryowner/nameslug for awesome-template URL rewriting. Auto-detected viaMetadataif 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:
- Returns:
Summary of created, updated, skipped, and warned items.
- repomatic.init_project.is_source_repo(output_dir)[source]¶
Detect whether
output_diris the repomatic source repository root.Returns
Truewhenoutput_dircontains therepomaticPython package source tree (repomatic/__init__.pyandrepomatic/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_dircontents, not on__file__, becauseuvx --from .installs the package into a temp venv where__file__no longer points to the source checkout.- Return type:
- 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 rewriteskdeldycke/awesome-templateURLs 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-excludedjoins 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) – Targetowner/nameslug for URL rewriting.result (
InitResult) –InitResultaccumulator for created/updated files.
- Return type:
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()), soBugmatches andprefixdoes not tripfix. The keys must name labels thatrepomatic/data/labels.tomldefines, or the labelling call fails on a label GitHub does not have;tests/test_labels.pyenforces 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
💖 sponsordeliberately has no rule here, nor inDEFAULT_FILE_RULES. It means “a sponsor is involved”, which is a fact about the author that only the GraphQL sponsorship query can establish, andsponsor-labelapplies 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 (seeGLOB_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/flagsspelling a content pattern may take, mirroring JavaScript.Matching this shape is what makes a pattern a regex: the body is passed to
reas 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 (seecompile_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 spelliout 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:
gandygovern stateful iteration that a single membership test never reaches,uandvdescribe a Unicode mode Python’sreis always in, anddonly 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¶
wcmatchflags reproducing theminimatchdialectactions/labelerused.GLOBSTARgives**its cross-directory meaning,BRACEexpands{a,b}, andNEGATEhonours a leading!. The two worth spelling out:DOTGLOB, becauseactions/labelerpassed{dot: true}and half the globs a repository cares about start with a dot (.github/**/*). Without it a workflow change matches nothing.NEGATEALL, becauseminimatchreads a lone!**/*.mdas “everything that is not markdown”, whilewcmatchdefaults 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_labelspasses them through verbatim (colors get their leading#stripped), so declarative renames and the other per-label knobs ride the regular sync.rename-fromis 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” inclaude.md.
- repomatic.labels.resolve_content_rules(config=None)[source]¶
The content rules in force: bundled defaults overlaid with the project’s.
- repomatic.labels.resolve_file_rules(config=None)[source]¶
The file rules in force: bundled defaults overlaid with the project’s.
- repomatic.labels.compile_content_pattern(pattern: str) Pattern[str] | None[source]¶
Compile one content pattern, as a keyword or a
/body/flagsregex.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
fixdoes not fire insideprefixwhile.lockstill matches the tail ofuv.lock(a\bbefore 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/flagsform passes the body through as a regex, mirroring JavaScript because that is what the retiredgithub/issue-labeleraction read and what existing rules are written in. No flags means case-sensitive.Returns
Noneon a body theremodule 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.
- repomatic.labels.match_content_rules(rules, text)[source]¶
Return every label with a pattern matching text.
- 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.gitignorewould) rather than standing alone. A pull request that changes no files matches nothing.
- 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 thedefaultprofile, carrying every per-label field of labelmaker’s specification (INLINE_LABEL_FIELDS): arename-fromlist renames a label in place on GitHub, preserving its issue and PR associations, and thecreate,update,enforce-caseandon-rename-clashknobs 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
nameare 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:
- 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.tomlunder thedefaultprofile, theawesomeprofile forawesome-*repositories, any hand-written or downloaded files underextra-labels/, and the inline[tool.repomatic.labels.extra]definitions. The exported files are expected to exist already (written byrun_init()for thelabelscomponent).- Parameters:
config (
Config) – The resolved[tool.repomatic]configuration.repository (
str) – GitHub repository inowner/nameform.is_awesome (
bool) – Whether the repository is anawesome-*list.labels_dir (
Path|None) – Directory holding the exportedlabels.tomland theextra-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:
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,documentationandDocsall occur in the wild. Mirrors the same convention_SOURCE_URL_KEYSinrepomatic.pypiapplies 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 initdeploys 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 leavestagunset.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-managerrenamed its binaries frommpm-*tometa-package-manager-*in7.0.0and 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
_redirectscheck 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-filetemplates..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 apr-prefix purely to disambiguate, which breaks that identity and puts them next to GitHub’s ownpull_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
3and3 :: Onlystate the major series, andImplementation :: CPythonthe 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-fileargument inside a workflowrun: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:
NamedTupleOutcome of one repository check.
passedis tri-state:Trueon success,Falseon failure,Nonewhen the check could not run or does not apply (skipped).messageis the human-readable line for both terminal output and annotations.Create new instance of CheckResult(passed, message)
- repomatic.lint_repo.check_package_name_vs_repo(package_name, repo_name)[source]¶
Check if package name matches repository name.
- Parameters:
- Return type:
- Returns:
A
CheckResult.
- repomatic.lint_repo.documentation_url(project_urls)[source]¶
The documentation site a project declares in
[project.urls].
- 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">fromhtml_baseurl, and aconf.pycommonly 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:
- Return type:
- Returns:
A
CheckResult.
- repomatic.lint_repo.check_description_matches(repo, project_description, repo_description=None)[source]¶
Check that repository description matches project description.
- 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:
- 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
.dev0are likely leftovers from abandoned or failed release attempts. The only expected drafts are the rolling dev pre-releases managed bysync-dev-release.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- 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.0shipped that way, with all six links dead.A versionless
latest/downloadalias 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, seeRELEASE_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:
- 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:
- Return type:
- 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:
GET /installation/repositories— returns the repos the token can access, including arepository_selectionfield.Cross-repo probe — check
permissions.pushon 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:
- Returns:
A
CheckResult.
- repomatic.lint_repo.check_pat_stale_statuses_permission(repo)[source]¶
Detect a PAT that still grants the dropped
Commit statusespermission.REPOMATIC_PATstopped needingstatuses:writeonce the Renovate integration (and itsstability-daysstatus checks) was removed. A fine-grained PAT cannot report its own granted permissions, so this probes behaviorally: it attempts to create a commit status onNULL_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_SHAnever 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:
- 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, andall_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 isfirst_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-approvaland returnsFalsewhen the policy is weaker thanfirst_time_contributors.Note
This endpoint requires the
Actions: readpermission. When theREPOMATIC_PATlacks it (or the API call fails for any other reason), the check returnsNoneto signal that the result is indeterminate rather than negative.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- Returns:
A
CheckResult.passedisNonewhen 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 withzizmor(check_inline_pins_match_upstreamand thelint-zizmorjob), but azizmorfinding 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/permissionsand returnsFalsewhensha_pinning_requiredis absent orfalse.Note
This endpoint requires the
Actions: readpermission. When theREPOMATIC_PATlacks it (or the API call fails for any other reason), the check returnsNoneto signal that the result is indeterminate rather than negative.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- Returns:
A
CheckResult.passedisNonewhen 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-tagworkflow job.Tag rulesets that restrict creation or require status checks can prevent
REPOMATIC_PAT(orGITHUB_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:
- 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}/rulesetsendpoint ascheck_tag_protection_rules()and looks for active rulesets withtarget == "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:
- Returns:
A
CheckResult.passedisNonewhen the rulesets API could not be read, matchingcheck_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-releasesand inspects theenabledfield in the response.Note
This endpoint requires the “Administration: Read-only” permission on fine-grained PATs. The
REPOMATIC_PATdoes not include this scope (too broad), so the check returnsNonewhen the API call fails, signaling that the result is indeterminate rather than negative.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- Returns:
A
CheckResult.passedisNonewhen 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.yamlworkflow usesactions/upload-pages-artifactandactions/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}/pagesand inspects thebuild_typefield 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:
- Returns:
A
CheckResult.passedisNonewhen 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.ioURLs 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-preserving301, 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
404with 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:
- Return type:
- Returns:
A
CheckResult.passedisNonewhen 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: seerepomatic.pypi.get_trusted_publishers(). This check probes the latest release’s provenance and looks for a bundle whoserepositorymatchesrepoand whoseworkflowisPYPI_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’srelease.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:
- Return type:
- Returns:
A
CheckResult.
- repomatic.lint_repo.check_stale_gh_pages_branch(repo)[source]¶
Check for a leftover
gh-pagesbranch after switching to GitHub Actions.When Pages is deployed via GitHub Actions, the
gh-pagesbranch is no longer needed and should be deleted to avoid confusion.- Parameters:
repo (
str) – Repository in ‘owner/repo’ format.- Return type:
- Returns:
A
CheckResult.
- repomatic.lint_repo.check_workflow_permissions(workflows=None)[source]¶
Check workflow
permissionsdeclarations for least privilege.Two failure modes are flagged:
A workflow that defines its own
steps:should carry a top-levelpermissionskey (permissions: {}for least privilege) so its jobs default to no scopes rather than the repository default.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-levelpermissions: {}, a reusable-call job with nopermissions: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
permissionskey is fine: its jobs inherit the repository default, which the reusable workflow’s ownpermissions:blocks then cap. The failure is specifically an empty top-levelpermissions: {}starving an unqualified reusable call.
- repomatic.lint_repo.check_test_matrix_excludes()[source]¶
Flag
[tool.repomatic.test-matrix] excludeentries 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:
- 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-pythonlower bound, theProgramming Language :: Python :: X.Yclassifiers PyPI renders, and any test matrix naming its versions literally.Two failure modes are flagged:
The lowest classifier disagrees with the
requires-pythonfloor. One of the two is then lying to resolvers about what installs.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_VERSIONSare 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-threaded3.14t) count as their base version.
- 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 nosteps: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:
- Return type:
- Returns:
A mapping of runner label to the
file.yaml:job-idlocations 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-pinstouches aruns-on:value: the first only rewritesuses:references, the second only theuvx '<pkg>==X.Y.Z'andnpm install pkg@X.Y.Zliterals. 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:
A
-latestalias. 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.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.
- class repomatic.lint_repo.ReleaseGate(name: str, workflow: str, metadata_key: str | None, needs: str)[source]¶
Bases:
NamedTupleA release-only step or job, and the project capability it needs.
metadata_keynames theMetadatafield that both decides whether the step runs and supplies what it consumes.Nonemarks a step that needs nothing beyond being on a release commit.Create new instance of ReleaseGate(name, workflow, metadata_key, needs)
- 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 scanis 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_GATESagainst 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 SHAshipped 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.
- 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 (likeuvx 'repomatic==1.2.3' metadata) must keep that version in lockstep with the SHA-pinneduses:refs. A manual workflow sync bumps the refs but not the inline pin, andsync-workflow-pinsonly 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:
- Return type:
- 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-wideUV_EXCLUDE_NEWER, and that pin moves in lockstep with theuses:refs, so it routinely names a release published hours ago. WithoutSELF_PIN_COOLDOWN_EXEMPTIONon the command line,uvxcannot resolve it at all.uvxreads 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
metadatajob, every other job isneeds: metadata, and the whole workflow reports failure while executing nothing. Downstream repos are the exposed ones.tests/test_workflows.pypins the canonical workflows,sync-workflow-pinssplices 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:
- Return type:
- Returns:
A
CheckResult.
- repomatic.lint_repo.check_setup_uv_version_pin(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]¶
Check every
astral-sh/setup-uvstep pins the uv version it installs.[tool.uv] required-versionis a floor for everyone; what a runner downloads is a separate question, and left tosetup-uvthe 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 carrieswith: version: "X.Y.Z"andsync-workflow-pinswalks it forward once a uv release clearsminimum-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.
- 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 downstreamuvx '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 arun:line requests.
- 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 initsyncs theirname,onandconcurrencyblocks and theuses:pins, and never touches the steps below. So a key retired upstream keeps being asked for by arun:line nothing sweeps, and themetadatacommand answers a retired key with aUsageError. Since every other job in a test workflow reaches it throughneeds:, 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_cellswent 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) – Upstreamowner/repo; its name is the package whosemetadatainvocations are read (likerepomatic).workflows (
Mapping[Path,dict] |None) – Pre-parsed workflows, read from disk whenNone.
- Return type:
- 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-filetemplates.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:
The file sits outside template_dir. See
PR_TEMPLATE_DIR.A workflow references a path that does not exist, which the job only discovers when it runs and
pr-bodyrejects the missing file.The frontmatter lacks a
title, or does not setfooterto the bare booleanfalse. Bothfalseand 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
docsfield is not required. It deep-links the hosted workflows reference, which documents upstream jobs only.- Parameters:
- Return type:
- 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:
objectEverything the checks read, resolved once per
lint-reporun.- 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.
- 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
404forever 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.
- docs_url: str | None = None¶
Documentation site declared in
[project.urls], perDOCS_URL_KEYS.
- 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.tomlfield 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
_redirectsfiles,.gitignorehonoured.The gitignore filter is what keeps a generated site tree (an
output/ordocs/_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 wayrepo_metadatapools 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:
- class repomatic.lint_repo.RepoCheck(name, run, applies=<function RepoCheck.<lambda>>, fatal=False)[source]¶
Bases:
objectOne entry of the
lint-repocheck sequence.The sequence used to be twenty-five hand-numbered
ifblocks whose comment numbering had degraded toCheck 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.- 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
ERRORand sets the non-zero exit code; every other check is advisory, perclaude.md§ Defensive workflow design.
- 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-reporuns, in report order.Two of these (
branch-ruleset-on-default,immutable-releases) were defined in this module but reached only fromrepomatic.setup_guide, solint-reposilently 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:
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.has_pat (
bool) – WhetherGH_TOKENcontainsREPOMATIC_PAT.has_virustotal_key (
bool) – WhetherVIRUSTOTAL_API_KEYis configured.has_cloudflare_api_token (
bool) – WhetherCLOUDFLARE_API_TOKENis configured.nuitka_active (
bool) – Whether Nuitka binary compilation is active.has_notifications_pat (
bool) – WhetherREPOMATIC_NOTIFICATIONS_PATis configured.unsubscribe_active (
bool) – Whether the unsubscribe workflow is opted in vianotification.unsubscribe.
- Return type:
- Returns:
Exit code (0 for success, 1 for errors).
repomatic.mailmap module¶
- repomatic.mailmap.MAILMAP_PATH = PosixPath('.mailmap')¶
Canonical path to the
.mailmapfile 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-mailmapwrites at the top of the file (generated_headeroutput:# Generated by …and# Timestamp: …lines), so a re-run parses only the identity mappings.- Return type:
- class repomatic.mailmap.Record(canonical='', aliases=<factory>, pre_comment='')[source]¶
Bases:
objectA mailmap identity mapping entry.
- class repomatic.mailmap.Mailmap[source]¶
Bases:
objectHelpers to manipulate
.mailmapfiles..mailmapfile format is documented on Git website.Initialize the mailmap with an empty list of records.
- 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:
- 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.
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
-latestaliases. This project never uses those aliases (a floating alias re-points with no commit to review, whichcheck_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-armbeatubuntu-24.04-armby 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 x86ubuntu-slimthat precededubuntu-26.04on this axis; Apple-siliconmacos-26beatsmacos-26-intelby ~2x; the two Windows images tie on compute (windows-2025is 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, flaggedcontinue-on-errorviaUNSTABLE_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, seeSINGLE_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-errorin CI. Contrast withSINGLE_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_VERSIONSmember to form thepython-labelmatrix key, so acontinue-on-errorcell states why it may fail:⁉️ ubuntu-26.04 / py3.15-devrather than a barepy3.15indistinguishable from a released one. Being a plain suffix append, it composes with the free-threaded flavor the way both tools below spell it:3.15treads3.15t-dev.The spelling is borrowed, not invented. pyenv ships version definitions named
3.15-devand3.15t-devthat build from the CPython branch tip, and actions/setup-python documents anx.y-devsyntax 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.15parses 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 name3.15-dev”). The workflow handspython-versionstraight touv venv --python, so the axis value stays the bare version and this suffix reaches the jobname: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
tsuffix, 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 unreleasedUNSTABLE_PYTHON_VERSIONS. The runner isubuntu-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-versionaxis values by release.Compares on the numeric release components, then places a build flavor (the free-threaded
tsuffix ofSINGLE_RUNNER_PYTHON_VERSIONS) directly after its base version rather than after every later release:3.14sorts before3.14t, which sorts before3.15. Non-numeric components are dropped, so an axis value likepypy3.10falls back to the digits it carries.
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:
[changelog] Release vX.Y.Z— the release commit to be tagged and published[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=valueformat. These fields will use the heredoc delimiter format regardless of whether they currently contain multiple lines.
- class repomatic.metadata.Dialect(*values)[source]¶
Bases:
StrEnumOutput dialect for metadata serialization.
- github = 'github'¶
- github_json = 'github-json'¶
- json = 'json'¶
- 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 byMetadata.dump(), including[tool.repomatic]config fields that are exposed as metadata outputs. Rows are unsorted: sorting is handled by the CLI’sSortByOption.
- repomatic.metadata.METADATA_VALUE_OPTIONS: frozenset[str] = frozenset({'--format', '--output', '--sort-by', '-o'})¶
Options on the
metadatacommand 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’srun:line. The command itself is not importable from there:repomatic.clireadssys.stdout.nameat 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.
- 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:
JSONEncoderCustom 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
Noneand (‘,’, ‘: ‘) 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 aTypeError).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:
- class repomatic.metadata.Metadata[source]¶
Bases:
objectMetadata 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. Usereset()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:
- 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_PATHto a JSON file containing the complete webhook event payload.
- git_deepen(commit_hash, max_attempts=10, deepen_increment=50)[source]¶
Deepen a shallow clone until the provided
commit_hashis found.Progressively fetches more commits from the current repository until the specified commit is found or max attempts is reached.
Returns
Trueif the commit was found,Falseotherwise.- Return type:
- 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", }, ], }
- property event_type: WorkflowEvent | None[source]¶
Returns the type of event that triggered the workflow run.
Maps
event_name(theGITHUB_EVENT_NAMEvariable, set by GitHub Actions on every run) onto itsWorkflowEventmember, soscheduleandworkflow_dispatchruns resolve to their own event instead of falling in aNonehole that nulls every commit matrix.Caution
When
GITHUB_EVENT_NAMEis absent or unrecognized, falls back on the historical heuristic: a non-emptyGITHUB_BASE_REFmeans 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
Trueif 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.typeis absent from the event payload outsidepushandpull_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 anOrganizationsender 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, returnsNonesince there’s no head branch concept.The branch name is extracted from the
GITHUB_HEAD_REFenvironment 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"), whichevent_typeresolves to aWorkflowEventmember.
- 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_slugby 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 ofrepo_slug.
- property repo_slug: str | None[source]¶
Returns the
owner/nameslug for the current repository.Resolution order:
GITHUB_REPOSITORYenv 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_urlandrepo_slug.
- 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 tohttps://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 fromevent_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 isowner/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-onlybetween the start and end of the commit range. ReturnsNoneif 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 likepyproject.toml,uv.lock,tests/) with project-specific source directories derived from[project.scripts]inpyproject.toml.For example, a project with
mpm = "meta_package_manager.__main__:main"addsmeta_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.messagefrom the event payload.Set for
pushevents. Empty string for events that do not carry a head commit (pull_request,schedule,workflow_dispatch).
- property yaml_changed: bool[source]¶
Returns
Truewhen 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
Truewhen 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
Truewhen 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
Trueif 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:
Branch name — PRs from known non-code branches (documentation,
.mailmap,.gitignore, etc.) are skipped.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 anduv.lock, so the new binary differs from the previous one only in the baked-in version string. The[changelog] Post-release bumpprefix is deliberately not checked here: theprepare-releasemerge bundles the release commit with the post-release-bump commit, and the release commit must still produce its binary.Changed files — Push events where all changed files fall outside
binary_affecting_pathsare skipped. This avoids ~2h of Nuitka builds for documentation-only commits tomain.
- 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:
[changelog] Release vX.Y.Z— the release commit[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_commitonly 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_SHAenvironment 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 inpushandpull_requestevents.See also
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
Commitobject.Raises if
HEADcannot 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
Commitobjects 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 release_commits: tuple[Commit, ...] | None[source]¶
Returns list of
Commitobjects to be tagged within the triggering event.This filters
new_commitsto 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_commitonly exposes the post-release bump commit, not the release commit. By extracting all commits from the event (vianew_commits) and filtering for release commits here, we ensure the release workflow can properly identify and process the[changelog] Release vX.Y.Zcommit.We cannot identify a release commit based on the presence of a
vX.Y.Ztag alone. That’s because the tag is not present in theprepare-releasepull request produced by thechangelog.yamlworkflow. The tag is created later by therelease.yamlworkflow, when the pull request is merged tomain.Our best option is to identify a release based on the full commit message, using the template from the
changelog.yamlworkflow.
- property release_commits_matrix: Matrix | None[source]¶
Pre-computed matrix with long and short SHA values of release commits.
- property files: FileInventory[source]¶
What this repository holds on disk,
.gitignoreapplied.The inventory is its own concern (
repomatic.file_inventory): answering “which Markdown files are there” needs no CI context, no git history and nopyproject.toml. The groups below forward to it so every existing caller, and everymetadataoutput key, keeps its name.
- property is_python_project: bool[source]¶
Returns
Trueif repository is a Python project.Presence of a
pyproject.tomlfile that respects the standards is enough to consider the project as a Python one. Delegates torepomatic.pyproject.is_python_project()so the detection rule has a single source of truth.
- property is_python_package: bool[source]¶
Returns
Trueif 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 torepomatic.pyproject.is_python_package(), the same predicatePACKAGE_ONLYresolves against, so the release lane and the checks that police it agree on who publishes.Prefer this over the truthiness of
package_namewhen gating anything about publishing.package_nameonly 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.tomlfile.Returns
Noneif thepyproject.tomldoes not exists or does not respects the PEP standards.Warning
Some third-party apps have their configuration saved into
pyproject.tomlfile, but that does not means the project is a Python one. For that, thepyproject.tomlneeds to respect the PEPs.
- property config: Config[source]¶
Returns the
[tool.repomatic]section frompyproject.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-pointsfrompyproject.toml. When empty (the default), deduplicates by callable target: keeps the first entry point for each uniquemodule:callablepair, so alias entry points (like bothmpmandmeta-package-managerpointing 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-targetsfrompyproject.toml. An empty list disables dev builds entirely. Seenuitka_dev_targetsfor 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-targetsfrompyproject.toml. Defaults to an empty set.Unrecognized target names are logged as warnings and discarded.
- 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/scriptor.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 descriptiveValueErrorinstead of crashing with an unpacking error.
- property mypy_params: list[str] | None[source]¶
Generates
mypyparameters.Mypy needs to be fed with this parameter:
--python-version 3.x.Extracts the minimum Python version from the project’s
requires-pythonspecifier. Only takesmajor.minorinto 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_versionfrom the first TOML file found in the current working directory:.bumpversion.toml(top-level table) orpyproject.toml([tool.bumpversion]).
- property current_version: str | None[source]¶
Returns the current version.
Current version is fetched from the
bump-my-versionconfiguration file.During a release, two commits are bundled into a single push event:
[changelog] Release vX.Y.Z— freezes the version to the release number[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_versionto 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.Zcommit, which is distinct fromcurrent_version(the post-release bump version). This is used for tagging, PyPI publishing, and GitHub release creation.Returns
Noneif no release commit is found in the current event.
- 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 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 pointevery 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-targetscanary subset on an ordinary push (seedev_targets); release commits,scheduleandworkflow_dispatchruns keep the full roster
Each axis contributes an
includeentry 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 oneincludeentry per(os, entry_point, commit)triple naming thebin_namethe 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_TARGETSand the project’s ownpyproject.toml, so no literal is repeated here: runrepomatic metadata nuitka_matrixagainst a project to see the matrix it computes, orrepomatic show-test-matrixfor 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-includerows 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 itsosandpython-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.excludeentries 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 asmacos-15-intelbecomingmacos-26-intel). Thelint-repocheck 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-releasestemplate 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 beforefix-changeloghas a chance to updatechangelog.md.The engine’s
create-releasejob 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 fastpublish-pypilane) removes the cross-lane race where the edit ran beforecreate-releasehad created the release, and so silently dropped the admonition undercontinue-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 redpublish-pypijob, not as a wrong admonition the user must catch.Returns
Nonewhen the project is not on PyPI, has no changelog, or has no version to release, in which casecreate-releasefalls back to the plainrelease_notes.
- static format_github_value(value)[source]¶
Transform Python value to GitHub-friendly, JSON-like, console string.
Renders:
stras-isNoneinto empty stringboolinto lower-cased stringMatrixinto JSON stringIterableof mixed strings andPathinto a serialized space-separated string, wherePathitems are double-quotedother
Iterableinto a JSON string
- Return type:
- 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",)skipsnuitka_matrixand the git history walk it pulls in.Split out of
dump()so the key inventory is inspectable without computing anything:tests/test_metadata.pyasserts these names match_METADATA_KEY_DESCRIPTIONSplus_metadata_config_fields(), which is what keeps--list-keys,all_metadata_keys()and the emitted output from drifting apart.Derived from
_METADATA_KEY_DESCRIPTIONSrather 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.
- 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:
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.
absoluteshares one calendar across every curve, answering when a project gathered its following.relativestarts 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.
linearreads a difference, and is right whenever the series are the same size.logarithmicreads 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_BANDof 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-uithe 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:
objectOne chart a repository asked for.
- 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_MODESmeasures the horizontal axis.
- scale: str = 'linear'¶
Which of
CHART_SCALESmeasures the vertical axis.
- 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:
- Returns:
The corresponding spec.
- Raises:
ValueError – When
outputis 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:
objectA 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.
- repomatic.metric_chart.assign_colors(names, overrides=None)[source]¶
Give every series a light and dark hue.
Positional from
SERIES_PALETTEin 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:
- 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:
- 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. SeeCHART_SCALESfor 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’slabel.stamp (
str|None) – Sampling date shown in the caption, inYYYY-MM-DDform. Today (UTC) whenNone.
- Return type:
- 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:
grouped (
Mapping[str,list[tuple[date,int]]]) – Every recorded series, asrepomatic.metrics.series()returns them.spec (
ChartSpec) – The chart to draw.overrides (
Mapping[str,Sequence[str]] |None) – Per-name[light, dark]pairs from configuration.stamp (
str|None) – Sampling date shown in the caption. Today (UTC) whenNone.
- Return type:
- Returns:
Truewhen 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-metricstable.Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its
--sort-bychoices 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.
createdis 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
503responses 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_TRIESbuys: 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.4kshown 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:
EnumHow 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:
objectOne thing a forge can be asked about a repository.
- 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
yieldinreadings(). 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:
objectOne reading: what a forge said about one repository on one date.
- day: str¶
The reading’s date, in
YYYY-MM-DDform.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.
- property key: tuple[str, str, str]¶
Deduplication identity: one reading per subject, metric and day.
- 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.
- class repomatic.metrics.SampleOutcome(subject, repo, phase, stars=None, rows=0, note='')[source]¶
Bases:
objectWhat one subject’s sample produced, for the CLI to report.
- repomatic.metrics.collected_subjects(subjects, predecessors=None)[source]¶
Every repository a collector touches, keyed by its subject name.
- Parameters:
- Return type:
- Returns:
The subjects, plus one entry per forerunner whose key carries
PREDECESSOR_SUFFIXso 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:
- Returns:
A tally like
6x HTTP 503, 2x truncated, orno responsewhen 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:
- 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.
- 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
Retentiondecides: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:
records (
dict[tuple[str,str,str],MetricRecord]) – The in-memory store, mutated in place.record (
MetricRecord) – The reading to store.
- Return type:
- Returns:
Truewhen 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.GzipFileneeds 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.
- 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
Noneonce 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 answers503for 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:
- Return type:
- Returns:
The body, or
Noneonce every attempt failed. Consultlast_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 inYYYY-MM-DDform. Today (UTC) whenNone.
- Return type:
- 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
404rather than403on 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:
- Return type:
- 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’sowner/namepath.- Return type:
- Returns:
The capture timestamps, or
Nonewhen 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 whenNone.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 theINFOlog instead: the archive refuses far more captures than it serves, and a line each would bury the handful that landed.
- Return type:
- Returns:
What the backfill produced. When the archive refuses
WAYBACK_REFUSAL_LIMITcaptures 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.
- repomatic.metrics.parse_csv_day(stamp)[source]¶
Read the UTC calendar day out of a star-history.com CSV timestamp.
- 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:
- Return type:
- 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:
- Returns:
One sorted list of
(day, value)per subject that has any reading, forerunners under theirPREDECESSOR_SUFFIXkey.- 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.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.tsin 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:
A static rule is only free while it appears before the first dynamic rule. The parser flips
canCreateStaticRuleto false permanently at the first source containing*or:placeholder. Every later rule, however static it looks, is charged against the dynamic budget.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.
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/:bdoes 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
- class repomatic.pages_redirects.Invalid(message, line=None, line_number=None)[source]¶
Bases:
object
- class repomatic.pages_redirects.ParseResult(rules=<factory>, invalid=<factory>, aborted_at_line=None)[source]¶
Bases:
object
- repomatic.pages_redirects.parse_redirects(text)[source]¶
The exact algorithm of
parseRedirects, budget accounting included.- Return type:
- 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 asaborted_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.
- repomatic.pages_redirects.rule_pattern(source)[source]¶
Compile a rule source exactly the way
generateRuleRegExpdoes.
- repomatic.pages_redirects.apply_rule(rule, path)[source]¶
Return the destination for
path, or None if the rule does not match.
- 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
:namestands 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:
- 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:
text (
str) – The full_redirectssource.parsed (
ParseResult) – Whatparse_redirects()made of it.
- Return type:
- 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.
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@kdeldyckeand 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-jsonwrites 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
claudebecause a barerepomatic-plugin.zipreads 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 oftool_registry. The name mirrors the spec’s own.claude-plugin/directory instead.Also the default
--outputofrepomatic pack-plugin, so the release job never spells it. It still appears in[tool.repomatic] release-assetsand in therelease-asset-run-artifact name the engine matches, which TOML and YAML cannot read from here;tests/test_workflows.pyholds 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 nosha256pin 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:
- Return type:
- 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-jsonwrites 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.
- 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, sorepomatic initreports 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 andformat-jsonrewrite the file past each other on every run, each opening a pull request undoing the other’s.
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):
Freeze commit (
[changelog] Release vX.Y.Z):Strips the
.dev0suffix 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.
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
.dev0suffix.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_NEWERcovering all package resolution (seeclaude.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 theuses: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
uvxresolution reads no project configuration at all, so moving the exemption into[tool.uv]or an adjacentuv.tomlwould not work either: both are ignored. Seeclaude.md§ Per-ecosystem knobs, and uv#20995 for the upstream request that would let a workflow declare this once.Spelled as the ISO 8601
P0Drather than the"0 day"used inpyproject.toml’sexclude-newer-packagetable: the flag travels through YAML folded scalars into a shell, where the space in0 daywould need quoting that survives both.P0Dneeds none.Caution
Every character here lands on 80-odd already-long workflow lines at freeze time.
tests/test_prepare_release.pysimulates 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
mainruns the CLI, before the freeze rewrites it.Resolving from
uv.lockrather 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 neitheruv.locknor[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.--frozenuses the lockfile as-is instead of asserting it is current, which is deliberate:--lockedwould fail every job the momentpyproject.tomldrifted ahead ofuv.lock, including thesync-uv-lockjob 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:
objectPrepare files for a release by updating dates, URLs, and removing warnings.
- property current_version: str[source]¶
Extract current version from the bump-my-version config.
Delegates discovery to
Metadata.get_current_version(), which searches.bumpversion.tomlthenpyproject.toml.
- property package_name: str | None[source]¶
Canonical PyPI package name, used to spot pinned install examples.
Delegates discovery to
Metadata.package_name, which readspyproject.toml.
- set_citation_release_date()[source]¶
Update the
date-releasedfield in citation.cff.- Return type:
- 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:
- 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 (seepack_binary_assets()); the frozen URL is preferred because it names the version the reader is installing, and keeps working once a later release moveslatest.Handles two input forms:
Initial (never frozen):
/releases/latest/download/repomatic-linux-arm64.binPreviously 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.binNote
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.
- freeze_marketplace_archive_url(version)[source]¶
Pin the plugin marketplace’s archive URL to this release.
This is part of the freeze step. The
archivesource in.claude-plugin/marketplace.jsonpoints at the release asset named byARCHIVE_NAME, and pinning the tag is what makes a marketplace ref meaningful: adding the catalog atkdeldycke/repomatic@v6.0.0then installs v6.0.0’s plugin, where alatestredirect 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.zipPreviously frozen:
/releases/download/v6.0.0/repomatic-claude-plugin.zip
Note
The trailing filename is rewritten too, not just the tag, so
ARCHIVE_NAMEis 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
.devNbump leaves it alone, so the default branch keeps pointing at the newest published release rather than at avX.Y.Z.dev0tag 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.
- 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.Zrequirement), 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.
- freeze_cli_version(version)[source]¶
Replace local source CLI invocations with a frozen PyPI version.
This is part of the freeze step: it freezes
repomaticinvocations 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 -- repomaticwithuvx --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
mainthe CLI runs fromuv.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 whatuvxdoes.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, somaincarries none between releases.
- unfreeze_cli_version()[source]¶
Replace frozen PyPI CLI invocations with local source.
This is part of the unfreeze step: it reverts
repomaticinvocations back to local source (--from . repomatic) for the next development cycle onmain.Replaces
uvx --no-progress 'repomatic==X.Y.Z'withLOCAL_CLI_INVOCATION, takingSELF_PIN_COOLDOWN_EXEMPTIONwith 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 (seefreeze_cli_version()).- Return type:
- 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:
- Returns:
Number of files modified.
- prepare_release(update_workflows=False)[source]¶
Run all freeze steps to prepare the release commit.
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
publisherobject per bundle that names the OIDC identity used to upload (kind, repository, workflow filename, environment). This is the only public surface where the OIDCjob_workflow_refclaim 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-pypijob is appended torelease.yamlin every downstream repo (reshaped from the canonical entry byrepomatic.github.workflow_sync._render_publish_pypi_job), and the composite action it invokes inherits the calling job’s OIDC context. The OIDCjob_workflow_refclaim 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_prefillview 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:
- 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:
NamedTupleRelease metadata for a single version from PyPI.
Create new instance of PyPIRelease(date, yanked, package, yanked_reason)
- 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:
- Return type:
- Returns:
Dict mapping version strings to
PyPIReleasetuples. 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/reporoot.A
project_urlsentry 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 anowner/repoAPI slug from it: the releases API would be asked forrepo/issuesand answer 404.
- repomatic.pypi.get_source_url(package)[source]¶
Discover the GitHub repository URL for a PyPI package.
Queries the PyPI JSON API and scans
project_urlsfor 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.
- class repomatic.pypi.TrustedPublisher(kind: str, repository: str, workflow: str, environment: str | None)[source]¶
Bases:
NamedTupleOIDC publisher metadata extracted from a PyPI provenance bundle.
Create new instance of TrustedPublisher(kind, repository, workflow, environment)
- 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.0above1.10.0and would return the older of the two as the latest. Versions PEP 440 cannot parse are skipped, since nothing can rank them.
- repomatic.pypi.get_trusted_publishers(package, version, filename)[source]¶
Fetch PEP 740 provenance for a file and extract publisher entries.
Calls
PYPI_PROVENANCE_URLand parses theattestation_bundlesarray. Each bundle’spublisherobject names the OIDC identity that uploaded the file.- Parameters:
- Return type:
- Returns:
List of
TrustedPublisherentries (possibly empty when provenance exists but no bundles are present), orNonewhen 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_urlsfor 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, soChangelog,changelogandCHANGELOGall occur in the wild.
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.tomlfrom 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.
- 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"].
- repomatic.pyproject.resolve_source_paths(config, pyproject_data=None)[source]¶
Resolve workflow source paths from config or auto-derivation.
- Parameters:
- Return type:
- Returns:
List of source directory names, or
Nonewhen 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.
- repomatic.pyproject.is_python_project(project_root=None, pyproject_data=None)[source]¶
Detect whether project_root hosts a Python project.
Returns
Truewhen thepyproject.tomlparses cleanly throughpyproject_metadata.StandardMetadata.from_pyproject: it must declare a PEP 621[project]table that respects the standard. Apyproject.tomlthat 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:
- Return type:
- Returns:
Truewhen 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.lockto sync and tests to cover, so it wants everything scoped toPYTHON_ONLY. It has nothing to publish, tag or write release notes for, so it wants nothing scoped toPACKAGE_ONLY.Note
Only uv’s opt-out is recognized. Poetry’s
[tool.poetry] package-modeequivalent is deliberately ignored: repomatic dropped Poetry support in4.0.0and expects standardpyproject.tomlconventions.- Parameters:
- Return type:
- Returns:
Truefor 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.ymland.yamlare 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, andinit_project._highest_upstream_pin, which reads them to floor a new pin. The two must agree on which files carry a pin, orinitwould floor against a filesync-workflow-pinsnever bumps.
- class repomatic.registry.InitDefault(*values)[source]¶
Bases:
EnumHow
inittreats 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:
EnumHow a
ToolConfigComponentbehaves 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:
EnumWhich 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 621pyproject.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_ONLYcovers anything that needs Python code to be useful;PACKAGE_ONLYcovers 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 initbut 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].namepresent).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:
is_awesome (
bool) –Trueforawesome-*repositories.is_python (
bool) –Truefor repositories whosepyproject.tomldeclares a PEP 621[project].name, perrepomatic.pyproject.is_python_project().is_package (
bool) –Truewhen that project is also distributable, perrepomatic.pyproject.is_python_package(). Always implies is_python.
- Return type:
- class repomatic.registry.FileEntry(source, target='', file_id='', scope=RepoScope.ALL, config_key='', config_default=False, reusable=True, phase='', tree=False)[source]¶
Bases:
objectA single file managed within a component.
- 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 oftarget.
- config_default: bool = False¶
Value assumed when
config_keyis absent from config.Falsemeans opt-in (excluded unless enabled),Truemeans opt-out (included unless disabled).
- tree: bool = False¶
Whether
sourceandtargetname directories, not files.A tree entry is copied wholesale, so a skill can ship
scripts/,references/andassets/alongside itsSKILL.mdexactly 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_buildrefuses 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.
- 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:
objectBase class for all init components.
- init_default: InitDefault = 1¶
How
inittreats 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.
- config_default: bool = True¶
Value assumed when
config_keyis absent from config.Truemeans 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 inittherefore 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 howsync-labelsstageslabels.tomlinto a temporary directory to hand tolabelmaker, leaving the working tree untouched.
- location_field: str = ''¶
Configfield holding this component’s destination, when the user can move it.Set for every component whose destination is configurable: the directories
subagentsandskillswrite into, and the single filespluginandagentmerge 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
Configobject.See
_config_enabled()for the resolution rule.
- resolve_target(target, config)[source]¶
Rebase a declared target path onto this component’s configured location.
A no-op unless
location_fieldis 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 aFileEntry(or aRemovedAssettombstone), relative to the repository root and expressed against the default location.
- Return type:
- 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:
ComponentFiles 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:
ComponentThin-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:
ComponentMerged into
pyproject.toml.Note
Nothing here declares where the section lands.
initappends it to the[tool]table, thenformat-pyprojectmoves it:pyproject-fmtsorts[tool.*]by its own known-tool order, which no per-component hint can override. This class used to carryinsert_after/insert_beforetuples for the purpose; they were read by no code and are gone.- 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_keysflips that for named top-level keys.
- preserved_keys: tuple[str, ...] = ()¶
Top-level keys whose existing values survive an ongoing sync.
Only meaningful when
sync_modeisONGOING. 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_modeisONGOING. 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_pathname the target file andreplacenames what the entry writes there, so a stale entry whosesearchpattern 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_modeisONGOING. 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 keyspyproject-fmtsorts into a fixed schema order. Emitting the owned keys as a leading block would lose topyproject-fmton the next format pass and churn an endless sync PR.With
overlayset, 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 apyproject-fmtfixpoint. A repo missing an owned key has it appended;pyproject-fmtcanonicalizes that one position once, after which steady-state syncs are no-ops.
- class repomatic.registry.TemplateComponent(name, description, init_default=InitDefault.INCLUDE, scope=RepoScope.ALL, files=(), config_key='', config_default=True, keep_unmodified=False, ephemeral=False, location_field='')[source]¶
Bases:
ComponentDirectory 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:
ComponentProduced from code (changelog).
Unlike bundled components, generated components have no
filestuple. Thetargetfield records the output path so the auto-exclusion logic can detect stale copies on disk.
- class repomatic.registry.RemovedAsset(component, target, removed_in, hashes=(), owned_dir='', successor='')[source]¶
Bases:
objectAn asset repomatic once shipped and has since dropped.
Note
Stale-file detection in
initonly inspects files still listed inCOMPONENTS. 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. EachRemovedAssetis a tombstone that letsinitfind and prune those orphans.initfinds 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 (itsuses:line references an upstream slug, seeUPSTREAM_REPO_SLUGS) with no extra downstream jobs.
Either way, a locally modified orphan is reported for manual review, never deleted. When
targetis already gone but the asset shipped as a folder, an emptyowned_dirleft behind is pruned on its own: it carries nothing anyone could lose.- target: str¶
Relative output path the asset occupied, in default-location form (like
.claude/skills/repomatic-release/SKILL.mdor.github/workflows/label-sponsors.yaml).Build skill and subagent targets with
_skill_target/_subagent_targetso they match the live registry: theskills.locationandsubagents.locationoverrides 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.mdby any route other thaninit(a handrm, a repomatic old enough to unlink the file alone) leaves the folder behind, empty.targetno longer exists, so the tombstone never fires again and the fossil outlives every laterinit. 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.
- 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, andcheck_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
initsubcommand. 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).initprunes 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 nextinitinstead 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
hashesand give the workflow’s downstream path astarget(.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 (likerepomatic/data/labels.toml).
- repomatic.registry.SKILL_PHASE_ORDER: tuple[str, ...] = ('Setup', 'Development', 'Quality', 'Maintenance', 'Release')¶
Canonical display order for lifecycle phases in
list-skillsoutput.
- 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.ephemeralcomponents.Written only when the component is named explicitly on the CLI, and never worth committing: whatever reads them regenerates them first.
inituses 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 initwrites verbatim from arepomatic/data/template.Every
BundledComponentcopies its bundled source byte-for-byte to the target, so downstream the file’s content (including any SHA-pinneduses:ref) is owned byrepomatic init.sync-action-pinsandsync-workflow-pinsskip these paths for the same reason they skipUPSTREAM_REPO_SLUGS: a pin the nextsync-repomaticoverwrites 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 (seerepomatic.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_calltriggers.
- repomatic.registry.NON_REUSABLE_WORKFLOWS: frozenset[str] = frozenset({'tests.yaml'})¶
Workflows without
workflow_callthat 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 generatedrelease.yamlcalls isRELEASE_ENGINE_WORKFLOWS.
- repomatic.registry.RELEASE_ENGINE_WORKFLOWS: tuple[str, ...] = ('_release-build.yaml', '_release-engine.yaml')¶
Reusable workflows the generated
release.yamlreferences but thatrepomatic initnever materializes downstream.The
workflowscomponent deploys a generatedrelease.yaml(not a thin delegation): itsbuildjob calls_release-build.yamland itsreleasejob 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 notFileEntrytargets and never appear inALL_WORKFLOW_FILES.The release entry’s
FileEntrystill records_release-engine.yamlas itssource(seeWORKFLOW_SOURCES) so the generic backing-reusable tests and a downstreamrepomatic lintcan read it viaget_data_contentto check the engine lane forwards its secrets;_release-build.yamlis 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 auses:ref at a release tag, these are invisible outside this repository: they are notFileEntrytargets, carry norepomatic/data/symlink, and nothing resolves them at runtime. That is what lets their jobs drop thegithub.repository == 'kdeldycke/repomatic'guard every in-autofix.yamlupstream-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.pyand 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:
- 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 registryfile_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/fileselectors.
- 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_idvalues. Returns an empty set for components without file-level selection (e.g., changelog, tool configs).
- repomatic.registry.excluded_rel_path(component, file_id)[source]¶
Map a component and file identifier to its relative output path.
Returns
Nonewhen the identifier cannot be resolved (e.g., for tool config components that have no file-level exclusion support).
- 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 fromALL_COMPONENTS. Qualifiedcomponent/identifierentries target individual files. RaisesValueErroron unknown entries.Used by both the
excludeconfig path and the CLI positional selection, with context controlling error message wording.
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-14is the Arm64 image while its x64 twin ismacos-14-large, andmacos-26-intelbreaks 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-26ormacos-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-largebesidemacos-26-intel, and a-latest$test keeps the very labellint-reporejects. 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-26againstmacos-26-xlarge,macos-26-intelagainstmacos-26-large). The sized ones are the paid larger runners, so they are never the default. Note that-intelis 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:
objectOne 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
familyandversionboth read it rather than the labels.
- property family: str¶
Leading word of the display name:
Ubuntu,macOSorWindows.Used to keep a successor search inside one operating system, which the labels alone cannot express (
macos-26-intelandmacos-26share a family that no common label prefix captures).
- repomatic.runner_catalog.parse_catalog(readme)[source]¶
Read the Available Images table out of a readme.
- Parameters:
readme (
str) – Full Markdown source of theactions/runner-imagesreadme.- Return type:
- Returns:
One
RunnerImageper 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
ghrather 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:
- 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:
- 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:
current (
RunnerImage) – The image being moved off.catalog (
Sequence[RunnerImage]) – Parsed catalog.
- Return type:
- 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:
- Returns:
The best replacement, or
Nonewhen 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:
chosen (
RunnerImage) – Whatsuccessor_forreturned.current (
RunnerImage) – The image being moved off.catalog (
Sequence[RunnerImage]) – Parsed catalog.
- Return type:
- 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 2026sits at the same version asWindows 11 Arm64and is a different toolchain rather than a newer image, so it is not an upgrade and is not reported as one.- Parameters:
label (
str) – Label currently in use.catalog (
Sequence[RunnerImage]) – Parsed catalog.
- Return type:
- 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
RemovedAssettombstone.
- class repomatic.runner_images.RunnerChange(kind, label, successor, locations, reason, alternative)[source]¶
Bases:
objectOne runner-image edit the available-images table justifies.
- kind: str¶
retirementwhen the current image is going away,upgradewhen a strictly newer version of it exists.
- 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 literalruns-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-errorprobe 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 2026sits at the same version asWindows 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, asliteral_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. Async-*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:
- 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 fromplan_runner_changes().- Return type:
- 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:
- 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:
change (
RunnerChange) – Aretirementchange fromplan_runner_changes().workflow_dir (
Path) – Directory holding the workflow files.
- Return type:
- 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:
change (
RunnerChange) – Aretirementchange fromplan_runner_changes().axes_path (
Path) – Path tomatrix_axes.py.
- Return type:
- 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 theosaxis throughvariations, and anunstableentry marks every cell carrying itcontinue-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:
change (
RunnerChange) – Anupgradechange fromplan_runner_changes().pyproject_path (
Path) – The project file to edit.
- Return type:
- 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
403and the check lands onNone. 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:
objectEverything 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.- property pypi_package_name: str | None[source]¶
The PyPI name to register a Trusted Publisher for, if any.
Gated on
is_python_packagerather than onpackage_namebeing set: a uv virtual project declares[project] namepurely 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
Nonewhen unrunnable.
- property missing_permissions_section: str[source]¶
Warning table naming the permissions the configured PAT lacks.
- 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.deploynames: a Cloudflare-hosted project has no GitHub Pages source to set, and the probe for it answers404forever.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:
- property dependabot_ok: bool¶
Whether vulnerability alerts are confirmed enabled.
Piggybacks the Dependabot alerts permission probe, which only answers
200when the alerts themselves are on.
- 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:
objectOne 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.
- 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.
Falsefor 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.Truefor the settings read through Administration-scoped endpoints: a PAT without that permission answers403, and a reader has no way to satisfy a check that cannot run, so it must not block the issue closing. Everywhere elseNoneis treated as incomplete, keeping the step prompting.
- explains_unverifiable: bool = False¶
Append
CANNOT_VERIFYto the body when the probe answeredNone.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.
Nonesurvives only wheretolerates_unknownsays an unreadable probe is not the reader’s fault; elsewhere it collapses to incomplete so the section stays open.
- 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) – WhetherREPOMATIC_PATis configured.has_notifications_pat (
bool) – WhetherREPOMATIC_NOTIFICATIONS_PATis configured.has_virustotal_key (
bool) – WhetherVIRUSTOTAL_API_KEYis configured.has_cloudflare_api_token (
bool) – WhetherCLOUDFLARE_API_TOKENis configured.repo (
str|None) – Repository inowner/repoformat; permission and settings checks are skipped whenNone.
- Return type:
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.
- repomatic.site_anchors.FRAGMENT_LINK_RE = re.compile(']\\(#(?P<fragment>[^)\\s]+)')¶
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:
objectOne authored fragment with no anchor to land on.
- class repomatic.site_anchors.AnchorReport(missing=<factory>, unbuilt=<factory>, checked=0)[source]¶
Bases:
objectWhat 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.
- 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.
- repomatic.site_anchors.authored_fragments(text)[source]¶
Every same-page fragment a Markdown source links to.
- 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:
htmlwrites{name}.html,dirhtmlwrites{name}/index.html. Probing rather than reading[tool.repomatic] sphinx.builderkeeps the check honest about the tree in front of it, and correct for a caller pointed at a directory some other builder wrote.
- 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.
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.resolveperforms the network discovery and computes the new file contents in memory, returning aSyncPlan. It does not touch the repository, so the resolves are safe to run in parallel.SyncOperation.applywrites 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.yamlrepeats this string literally, and the labeller’s own rule tables (repomatic.labels.DEFAULT_CONTENT_RULESandDEFAULT_FILE_RULES) key their dependency rules on the same spelling.tests/test_sync_ops.pyasserts both copies match this constant, andtests/test_labels.pythat it names a labellabels.tomlactually defines: applying an unknown label fails theghcall 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:
objectInputs shared by every
SyncOperation.resolve.Each operation reads the subset it needs. The cooldown is derived from config (
minimum-release-agefor the version-sync trio,exclude-newerfrom the lock forsync-uv-lock).
- class repomatic.sync_ops.ToolVersionExtras(binary_overrides=<factory>, actionlint_version=None, checksums_path=None)[source]¶
Bases:
objectsync-tool-versionswrite extras, applied after the source rewrite.
- 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:
objectExtras 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.- pruned_bypasses: list[BypassForecast]¶
Expired
exclude-newer-packageentries removed frompyproject.toml, snapshot with the version and expiry each freeze had.
- 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:
objectThe resolved, not-yet-written outcome of one operation’s read phase.
Carries everything
SyncOperation.applyneeds to write the changes and everythingrepomatic.clineeds to render the terminal table and the markdown PR body, so the write and the rendering never re-resolve.- changes: list[tuple[str, str, str]]¶
Applied
(name, old, new)triples, in the order the report renders.
- 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.
- held_back: list[HeldBackPackage]¶
Newer releases withheld only by the cooldown.
- 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).
- 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.applyonly whenrebaseis 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 reasonUvProjectExtras.frozen_bypassesis: 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: writingfile_writesverbatim 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-versionswrite extras (checksum recompute, matcher URL).
- uv_project: UvProjectExtras¶
sync-uv-lockandsync-dep-sourcesrendering 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-packageentries still rewritespyproject.tomland 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.
- 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, sosync-depsand 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-packageentries moves no version at all, and leading with the forecast would open its PR on the releases it did not adopt instead of thepyproject.tomlhunk it asks to merge.- Return type:
- 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 bysync-uv-lockand the threesync-*commands so their terminal output matches, and respects the global--table-format. Old/New stay separate columns (not the mergedChangecell of the markdown PR body) so structured--table-format json/csvoutput stays parseable.- Return type:
- 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 bysync-uv-lockand the threesync-*commands, and respects the global--table-format.- Return type:
- 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 fromformat_bypass_section(), and respects the global--table-format.- Return type:
- 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 aggregatesync-deps.Each table respects the global
--table-format, and an empty section prints nothing.- Return type:
- 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-lockandsync-dep-sourcesshare this tail. They alone can suppress the terminal tables with--no-table(their CI jobs want only the markdown report), and they alone have anexclude-newercutoff to announce, uv’s lock-level cooldown standing in for theminimum-release-agewindow the version-sync trio reports.- Return type:
- 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 rendererssync-uv-lockandsync-depsuse (render_plan_markdown()), so every dependency updater’s report matches.- Return type:
- 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, andsync-workflow-pinsdiffer 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 with0when nothing needs updating.op_name (
str) – TheOPERATIONS_BY_NAMEkey.output_format (
str) – The--output-formatvalue.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:
- 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-lockandsync-dep-sourcesbuild 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:
- 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:
objectOne cooldown-respecting dependency updater, as data.
Naming rule 3 (
claude.md): the CLI command, workflow job ID, PR branch, and PR-body template all sharename. The CI-only metadata (job_name,job_if,editable,needs_gh_token,ci_flags) letsrepomatic.github.workflow_syncemit the consolidated job without a hand-maintained YAML twin.- resolve: Callable[[ResolveContext], SyncPlan]¶
Read phase: network discovery, returns a
SyncPlan.
- job: str = 'sync-deps'¶
Job ID inside
workflowhosting this operation’s steps.Defaults to the consolidated
sync-depsjob, 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 workflowrepomatic initnever materializes downstream (seeSELF_MAINTENANCE_WORKFLOWS).
- property consolidated: bool¶
Whether this operation shares the multi-bumper
sync-depsjob.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
jobfield default rather than against a repeated"sync-deps"literal, so renaming the shared job is a one-line change.
- 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-sourcesfirst (adopting a release changes what the routine re-lock even does), thensync-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-depsjob inautofix.yaml.sync-tool-versionsruns fromself-maintenance.yamlon its own daily schedule, since it rewrites this package’s source and has no downstream meaning; the order still applies to a localrepomatic 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_OPERATIONSkeyed bySyncOperation.name.
- repomatic.sync_ops.selected_operations(config, *, here_only=True, names=None)[source]¶
Return the operations to run, in
SYNC_OPERATIONSorder.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 whoseSyncOperation.applies_hereis false (nouv.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 inclaude.md).names (
Sequence[str] |None) – When given, restrict to these operation names. Unknown names are ignored (the CLI validates them upstream).
- Return type:
- 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--jobsoption and sequential when no CLI context is active (as in tests). AtDEBUGverbosity 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, anclick_extra.OperationTrailreports 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 inSYNC_OPERATIONSorder because three of the five rewrite the same workflow files. In--dry-runno apply runs. An operation whose resolve raises is logged and reported with aNoneplan 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
Noneif its resolve failed), inSYNC_OPERATIONSorder.
- repomatic.sync_ops.operation_order(operations)[source]¶
Sort operations into
SYNC_OPERATIONSorder.- Return type:
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-tabledirective 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-jsonrewrites 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
\non 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.
- 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:
rows (
Iterable[Sequence[object]]) – One sequence of cells per row, in the same order.align (
Sequence[str]) – Per-column alignment,left,rightorcenter; 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:
- 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:
- 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_chartroute through it too.
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:
RuntimeErrorRaised 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) andversion(package version).
- repomatic.tool_registry.generated_header(command, comment_prefix='# ')[source]¶
Return a generated-by header block with timestamp.
- class repomatic.tool_registry.ArchiveFormat(*values)[source]¶
Bases:
EnumArchive format for binary tool downloads.
- RAW = 'raw'¶
- TAR_GZ = 'tar.gz'¶
- TAR_XZ = 'tar.xz'¶
- ZIP = 'zip'¶
- tarfile_mode()[source]¶
Return the
tarfile.openmode 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:
EnumTarget 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 frompyproject.toml(atomlrt.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:
- Raises:
ValueError – For
FLAGS, which is not a file format.- Return type:
- repomatic.tool_registry.PlatformKey¶
A
(platform_or_group, architecture)pair used as binary lookup key.The platform element can be a single
Platform(likeMACOS) or aGroup(likeLINUX, which matches any Linux distribution). The architecture is always a concreteArchitecture.Resolution order in
BinarySpec.resolve_platform():Exact Platform match (
current_platform() == key_platform).Group membership (
current_platform() in key_group), preferring the group with fewest members (most specific).The
LINUXfamily, only whencurrent_platform()isUNKNOWN_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:
EnumHow 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 Nonenarrows 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:
objectPlatform-specific binary download specification.
Keys are
PlatformKeytuples pairing an extra-platformsPlatformorGroupwith anArchitecture. This lets callers use broad groups (LINUXmatches 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
ArchiveFormatapplies 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(): exactPlatformKeytuple first, then barePlatformequality, thenGroupmembership (smallest group wins).
- archive_executable: str | None = None¶
Path of the executable inside the archive.
Nonedefaults to the tool name. ForRAWformat, 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
intapplies to every platform. A dict maps platform specifiers to counts, using the same resolution asget_archive_format(), for a project whose archives are not laid out identically across platforms:strip_components={ALL_PLATFORMS: 1, WINDOWS: 0}
ghis the motivating case: its Linux and macOS archives nest everything under agh_{version}_{platform}_{arch}/directory, while the Windows zip putsbin/gh.exeat the root. The nesting cannot be absorbed byarchive_executableinstead, 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()andcurrent_architecture()from extra-platforms, inheriting its full detection heuristics, then falls back to theLINUXfamily 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_formatis a singleArchiveFormat, returns it directly. When it is a dict, resolves through_resolve_per_platform().- Return type:
- get_strip_components(key)[source]¶
Return the leading path components to strip for a platform key.
When
strip_componentsis a plainint, returns it directly. When it is a dict, resolves through_resolve_per_platform().- Return type:
- repomatic.tool_registry.MYPY_VERSION_MIN = (3, 8)¶
Earliest Python dialect Mypy’s
--python-version 3.xparameter accepts.Floors the value
repomatic.metadata.Metadata.mypy_paramsderives from the project’srequires-python, which themypyentry inTOOL_REGISTRYpasses throughcomputed_params. A project declaring an older floor would otherwise hand mypy a version it rejects outright.
- 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 --listtable.Lives beside the registry it renders; the CLI derives its
--sort-bychoices 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-ageflag, 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-bumpednpm@Xbootstrap pin inlint.yaml, which tracks the latest npm.
- class repomatic.tool_registry.NpmSpec[source]¶
Bases:
objectnpm-registry backend marker for a
ToolSpec.Presence (
ToolSpec.npm is not None) selects the npm backend, the way aBinarySpecselects the download backend. The package name, executable, and version all derive from theToolSpecfields, 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
PATHat 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 unlikeBinarySpecthere is no repomatic-pinned checksum; theminimum-release-agecooldown (npm’smin-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:
objectSpecification 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_subcommandTools that use subcommands (
tool <subcmd> [flags] [files]) may requireconfig_flagto 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, sotool <subcmd> --flagworks buttool --flag <subcmd>does not. Setconfig_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').Nonedefaults toname.
- package: str | None = None¶
Install target passed to
uvx/uv run(and pip).Nonedefaults toname. Only set when it differs from the tool name, and may carry an install extra (Nuitka’snuitka[onefile]); for PyPI lookups query the bare project name throughpypi_name, which strips the extra.
- executable: str | None = None¶
Executable name if different from the tool name.
Nonedefaults to the registry key.
- module: str | None = None¶
Python module name for
-m moduleinvocation, e.g.'nuitka'.When set, the tool is invoked as
python -m <module>instead of the console script. Requiresneeds_venv=True. Use when the tool’s script entry point is not reliably found across platforms (for example, Nuitka installs only a.cmdwrapper on Windows, whichuv run -- nuitkacannot 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').Noneif the tool only reads from fixed paths.
- native_format: NativeFormat = 'yaml'¶
Target format for
[tool.X]translation.NativeFormat.FLAGStranslates the table to CLI flags (viaconfig_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 withreads_pyproject,config_flag, andnative_config_files.
- default_config: str | None = None¶
Filename in
repomatic/data/for bundled defaults, stored innative_format.Noneif no bundled default exists.
- reads_pyproject: bool = False¶
Whether the tool natively reads
[tool.X]frompyproject.toml.When
Trueand[tool.X]exists inpyproject.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_args: tuple[str, ...] = ()¶
Arguments used when the caller passes none of their own.
Together with
default_paths, this makes a barerepomatic run <tool>the invocation CI performs, so nobody has to reconstruct it from a workflow step. It applies only whenextra_argsis 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’scheck .would buildbiome format … check .; because an explicit argument suppresses them entirely, that command cannot be built.
- default_paths: str | None = None¶
Name of the
FileInventoryattribute 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
xargspipe 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 alongsidedefault_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
PATHwhile this runs.For a plugin that shells out to a second binary rather than importing it:
mdformat-shfmtformats fenced shell blocks by invokingshfmtfromPATH, somdformatdeclarespath_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_REGISTRYand carry abinaryspec;test_tool_spec_integrityenforces both.
- needs_venv: bool = False¶
If
True, useuv run(project venv) instead ofuvx(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_argsintool_runner.py.
- computed_params: Callable[[Metadata], list[str]] | None = None¶
Callable that receives a
Metadatainstance and returns extra CLI args derived from project metadata (e.g., mypy’s--python-versionfromrequires-python).Noneif no computed params.
- config_after_subcommand: bool = False¶
Insert
config_flagafter the first token ofextra_args.Needed for tools whose CLI parser (e.g., bpaf) scopes global options inside the subcommand, so
tool subcommand --config-path Xis valid buttool --config-path X subcommandis not. WhenTrue,config_argsare spliced after the first element ofextra_args(the subcommand name).
- post_process: Callable[[Sequence[str]], None] | None = None¶
Callback invoked on
extra_argsafter 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_flagssorun_toolwarns when a check invocation would silently bypass it. Seecheck_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_toolpre-creates the parent directory of the path following this flag (both--flag pathand--flag=pathforms), so a workflow can point the tool into a scratch subdirectory without a separatemkdirstep. lychee is the motivating case:docs.yamlcollects 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 apost_processandcheck_flags, its check-mode exit status is unreliable.run_tooldetects the pairing viacheck_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
1both when it reformats a file and when it dies on aPanicException, and the autofix job cannot tell the two apart from the status alone.Declaring the code here gives
run_toolthe 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. Seerepomatic.tool_runner.TOOL_CRASH_EXIT_CODE.Nonefor tools with no such convention, which is most of them: a formatter that exits0whether 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
uvxoruv run.
- npm: NpmSpec | None = None¶
npm-registry backend marker. When set, the tool is installed from npm and run via its
node_modules/.binexecutable, instead of a binary download or a uv install. Mutually exclusive withbinaryandneeds_venv.
- tag_pattern: str | None = None¶
Regex extracting the version from a GitHub release tag.
Used by
sync-tool-versionsfor binary tools whose tags do not follow the commonvX.Y.Zscheme. The pattern must define aversionnamed 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 leadingvstripped.
- 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 bytool_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.
binaryandnpmwin overneeds_venv;test_tool_spec_integritykeeps the three mutually exclusive so the order never actually decides.
- property pypi_name: str¶
Bare PyPI project name for version and metadata lookups.
packagedoubles as the install target, so it may carry an install extra (Nuitka’snuitka[onefile]) that_build_install_argsneeds at install time. The PyPI JSON API is keyed by the bare project name, though, and 404s on a bracketed extra, sosync-tool-versionsand 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_urlwhen set, else the PyPI project page. Used bysync-tool-versionsfor the diff-table and held-back links.
- check_bypasses_post_process(extra_args)[source]¶
Return
Truewhen a check-mode flag will skippost_process.Check/dry-run flags (
check_flags) make the tool exit without writing files, so thepost_processfixup 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_toolwarns on this. ReturnsFalsefor tools with nopost_process, where check mode is authoritative.- Return type:
- 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-checksumsandsync-tool-versions. Kept as a flat sidecar dict (rather than inline in eachBinarySpec) so the checksum recompute can replace a hash by exact string match without re-parsing the registry, and soVERSIONScan 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_integrityasserts this equals the matchingToolSpec.version, so a bump whose checksums were never refreshed (a staleCHECKSUMSentry) fails CI offline, without downloading anything.
- repomatic.tool_registry.tool_summary()[source]¶
Render the summary table of all managed tools.
- Return type:
- 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_notesfield, so hand-written examples and caveats live next to the spec they document.- Return type:
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):
Native config file — tool’s own config file in the repo.
``[tool.X]`` in ``pyproject.toml`` — translated to native format.
Bundled default — from
repomatic/data/.Bare invocation — no config at all.
- repomatic.tool_runner.load_pyproject_tool_section(tool_name)[source]¶
Load
[tool.<tool_name>]frompyproject.tomlin the current directory.Returns the live
tomlrt.Table(adictsubclass) rather than a plain-dict copy, so the section keeps its comment trivia for formats that can preserve it on materialization (seeNativeFormat.serialize()). Callers that only read values or test truthiness are unaffected.
- 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 sorepomatic run --listshows the loss.- Parameters:
- Return type:
- Returns:
Tuple of (extra CLI args for config, path to clean up). The path is
Nonewhen no cleanup is needed (cache-based configs persist across runs). Non-Nonepaths are CWD files written for tools that have no--configflag.
- repomatic.tool_runner.DOWNLOAD_TIMEOUT = 30¶
Socket-level timeout for artifact downloads, in seconds.
A stall guard, not a transfer budget:
urlopenapplies 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 thanrepomatic.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-Lengthheader; passprogress=Falsefrom 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 inchecksums.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:
- Return type:
- 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 everyrepomatic runbinary 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. WhateverPATHoffers 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-imagesoptimizing 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.
- repomatic.tool_runner.resolve_default_args(spec)[source]¶
Build the argument batches for a bare
repomatic run <tool>.Combines
default_argswith the file list named bydefault_paths, splitting into one batch per file whenper_fileis set.- Parameters:
spec (
ToolSpec) – The tool to resolve defaults for.- Return type:
- 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.
Nonewhen 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_SOFTWAREfromsysexits.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”. Seerewrite_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_argsordefault_pathsruns the invocation CI performs, resolved in-process byresolve_default_args(). Any explicit argument suppresses that entirely and is passed through as before.- Parameters:
name (
str) – Tool name (must be inTOOL_REGISTRY).extra_args (
Sequence[str]) – Extra arguments passed through to the tool.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 whenTrue.
- Return type:
- Returns:
The tool’s exit code; the first non-zero one when the defaults resolved to several invocations, or
TOOL_CRASH_EXIT_CODEwhen a tool declaringrewrite_exit_codereports a rewrite it did not perform.
- repomatic.tool_runner.verify_via_write_path(name, extra_args=(), **run_kwargs)[source]¶
Check a
post_processtool’s formatting without touching the tree.A tool pairing
post_processwithcheck_flagshas 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 (seecheck_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 inrun_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 setrun_tool()would have run, flattened into one batch: the copies are per-path already, so aper_filesplit would only cost extra invocations.run_kwargs (
Any) – Forwarded verbatim torun_tool().
- Return type:
- Returns:
(exit_code, drifted), whereexit_codeis0when every target is already formatted and1otherwise, anddriftednames 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 --listto show which precedence level is active for each tool in the current repo.- Return type:
- repomatic.tool_runner.find_unmodified_configs(root=None)[source]¶
Find native config files identical to their bundled defaults.
Iterates over every tool in
TOOL_REGISTRYthat has adefault_config. For each, checks whether any of itsnative_config_filesexists 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_fileswhen writing files duringinit.- Parameters:
root (
Path|None) – Directory the relative config paths resolve against. Defaults to the working directory;run_initpasses itsoutput_dirso the scan and the deletion the CLI derives from it (--delete-unmodified) agree on one tree.- Return type:
- 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--frozenwhen requested (appropriate forrun,export,sync— not forlock). Adds--no-projectto skip project discovery entirely, and--exclude-newer(aYYYY-MM-DDdate) to gate an unlocked resolution by theminimum-release-agecooldown, mirroringuvx_cmd().
- repomatic.uv.uvx_cmd(exclude_newer=None)[source]¶
Build a
uvxcommand prefix with standard flags.When exclude_newer is set (a
YYYY-MM-DDdate), adds--exclude-newerso the isolated resolution honors theminimum-release-agecooldown, gating the tool’s transitive dependencies by upload date.
- repomatic.uv.LOCK_TIMESTAMP_SENTINEL = '0001-01-01T00:00:00Z'¶
Placeholder uv writes to
options.exclude-newerinuv.lockwhen the user-configured value is a relative span. The real cutoff is inoptions.exclude-newer-spanas an ISO 8601 duration.
- repomatic.uv.load_pyproject_doc(pyproject_path)[source]¶
Parse
pyproject.tomlinto an editable, round-trippable document.The counterpart to
repomatic.pyproject.read_pyproject_toml(), which returns plain data for reading. This one keepstomlrt’s formatting trivia, so the document can be edited and written back with the rest of the file byte-identical.
- repomatic.uv.uv_table(doc)[source]¶
Return the
[tool.uv]table of a parsedpyproject.toml.- Parameters:
doc (
Any) – Document fromload_pyproject_doc().- Return type:
- 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-newervalue 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 likeP1DT2H): 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.
- repomatic.uv.project_exclude_newer(pyproject_path)[source]¶
Read the project’s own
[tool.uv] exclude-newerwindow.Caution
Always pass this back to
uv lockanduv syncas an explicit--exclude-newerflag rather than letting uv pick the value up frompyproject.tomlon its own. CI exports aUV_EXCLUDE_NEWERcovering every ad-hoc install (seeclaude.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, andsync-uv-lockwould churn between the two. A CLI flag outranks the environment, which pins the project’s own policy.
- repomatic.uv.uv_lock_command(pyproject_path, *extra)[source]¶
Build a
uv lockargv 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-newerthat keeps CI’s ambientUV_EXCLUDE_NEWERfrom retiming the lock: seeproject_exclude_newer().- Parameters:
- Return type:
- Returns:
The argv to run, with
cwdset 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-packageexemption only when its locked version was uploaded after theexclude-newercutoff, meaning a regularuv lock --upgradewould not resolve it.
- repomatic.uv.date_to_utc_cutoff(day)[source]¶
Render an
exclude-newer-packagecutoff date as an explicit UTC instant.Warning
uv reads a bare
YYYY-MM-DDinexclude-newer-packageas the start of the following day in the locking machine’s local timezone, then writes that absolute instant intouv.lock’s[options.exclude-newer-package]block. The same date therefore lands as a different timestamp depending on whereuv lockran:2026-06-13becomes2026-06-14T00:00:00Zon a UTC CI runner but2026-06-13T20:00:00Zon a UTC+4 laptop. Every local lock then flips the value one way and every CI lock flips it back: an endlesssync-uv-lockping-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.
- repomatic.uv.freeze_cutoff_after(day)[source]¶
The
exclude-newer-packagecutoff 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 withrepomatic.dep_sources.ReleaseSwap.
- repomatic.uv.upsert_exclude_newer_packages(pyproject_path, entries)[source]¶
Insert or replace
[tool.uv].exclude-newer-packageentries.The write primitive shared by
add_exclude_newer_packages()(which computes freeze cutoffs from the lock and never overwrites) andsync-dep-sources(which supplies exact cutoffs and must replace the stale value a git-tracking era left behind).- Parameters:
- Return type:
- Returns:
Trueif the file was updated,Falseif no changes were needed.
- repomatic.uv.add_exclude_newer_packages(pyproject_path, packages, lock_path)[source]¶
Add packages to
[tool.uv].exclude-newer-packageinpyproject.toml.Persists for each package the
_freeze_cutoffof its currently-locked version (a whole-day boundary just past that version’s upload) so that subsequentuv lock --upgraderuns (thesync-uv-lockjob) hold the package within that freeze window instead of tracking the latest release, until it ages past theexclude-newercooldown andprune_stale_exclude_newer_packages()drops the entry. See_freeze_cutofffor 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
Trueif the file was modified.- Parameters:
- Return type:
- Returns:
Trueif the file was updated,Falseif 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-packageentry tells uv to ignore the cooldown and resolve to the latest release, so the package keeps moving andprune_stale_exclude_newer_packages()never sees its locked version age out. Rewriting the span as the_freeze_cutoffof 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-DDfixed entry to the equivalent explicit UTC timestamp (seedate_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:
- Return type:
- 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-newercutoff, meaninguv lock --upgradewould 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:
- Return type:
- 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:
objectEverything 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-lockrun 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.- upload_times: dict[str, str]¶
Package name to the ISO 8601
upload-timeof itssdistentry.Packages with no
sdistor no upload time are absent: a git or path source has no release to date.
- exclude_newer: str = ''¶
Effective
options.exclude-newercutoff, as an ISO 8601 instant.When the project configures a relative span, uv writes
LOCK_TIMESTAMP_SENTINELhere and the real width tooptions.exclude-newer-span; the cutoff is then resolved tonow - spanat 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.Nonewhen the lock records an absolute cutoff instead of a span, which leaves the cooldown-expiry forecasts nothing to project against.
- repomatic.uv.parse_lock_versions(lock_path)[source]¶
Parse a
uv.lockfile and return a mapping of package names to versions.
- repomatic.uv.parse_lock_upload_times(lock_path)[source]¶
Parse a
uv.lockfile and return a mapping of package names to upload times.Extracts the
upload-timefield from each package’ssdistentry.
- repomatic.uv.parse_lock_exclude_newer(lock_path)[source]¶
Parse the effective
exclude-newercutoff from auv.lockfile.See
LockFile.exclude_newerfor how a relative span is resolved.
- repomatic.uv.EXTRA_MARKER_RE = re.compile("\\bextra\\s*==\\s*'([^']+)'")¶
Match the extra a
requires-distmarker 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:
objectDependency specifiers extracted from a
uv.lockfile.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. Seefilter_root_edges(). A package with nometadatatable is absent from the mapping entirely, telling “declares nothing unconditionally” apart from “not described here”.
- repomatic.uv.parse_lock_specifiers(lock_path=None, *, lock_data=None)[source]¶
Parse
uv.lockand extract dependency specifiers.A single pass builds two complementary indexes from
[package.metadata].requires-distand[package.metadata.requires-dev]. SeeLockSpecifiersfor the two views returned.- Parameters:
- Return type:
- repomatic.uv.diff_lock_versions(before, after)[source]¶
Compare two version mappings and return the list of changes.
- Parameters:
- Return type:
- Returns:
A sorted list of
(name, old_version, new_version)tuples.old_versionis empty for added packages;new_versionis 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-newercutoff and every per-packageexclude-newer-packagefreeze 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 arequires-pythonbound resolve identically with and without the lift, so they are excluded.The probe writes
uv.lockand restores it byte-for-byte in afinally, so the canonical in-cooldown lock is left untouched even when resolution or parsing fails.Note
This runs a second
uv lockresolution. It is the report’s only cost and is skipped bysync-uv-lock --no-held-back.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.- Return type:
- 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-packageentries. 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 (seedocs/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 rollingexclude-newerspan, which is the day the nextsync-uv-lockrun prunes the entry.- Parameters:
pyproject_path (
Path) – Path to thepyproject.tomlfile.lock_path (
Path) – Path to theuv.lockfile.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:
- 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:
- Return type:
- 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:
objectResult of a
sync-uv-lockoperation.- reverted: bool = False¶
Whether a cosmetic-only re-lock was discarded.
Truewhenuv lock --upgradechanged no package versions and was not driven by apyproject.tomlcooldown edit, sosync_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-packageentries removed frompyproject.toml, each with the version and (past) expiry the freeze had, snapshot against the pre-upgrade lock bycompute_pruned_forecasts().
- 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
--upgradeand report version changes.First prunes stale
exclude-newer-packageentries frompyproject.toml(entries whose locked version was uploaded before theexclude-newercutoff), then runsuv lock --upgradeto update transitive dependencies.Note
When the upgrade changes no package versions and was not driven by a
pyproject.tomlcooldown edit, the pre-upgrade lock is restored byte-for-byte.uv lock --upgradeotherwise 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 itspython_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 endlesssync-uv-lockping-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 indate_to_utc_cutoff().- Parameters:
lock_path (
Path) – Path to theuv.lockfile.- Return type:
- Returns:
A
SyncResultwith 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-agecooldown, 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 matchesowner/repoactions, 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-uvversion pin.
- class repomatic.version_sync.Candidate(version: str, date: str, ref: str)[source]¶
Bases:
NamedTupleA single release version offered by a datasource.
Create new instance of Candidate(version, date, ref)
- class repomatic.version_sync.ActionPin(slug: str, sha: str, ref: str)[source]¶
Bases:
NamedTupleA SHA-pinned GitHub Action reference found in a workflow file.
Create new instance of ActionPin(slug, sha, ref)
- class repomatic.version_sync.UpstreamRefPin(version: str, sha: str | None)[source]¶
Bases:
NamedTupleAn upstream thin-caller
uses:ref found in a workflow file.The counterpart of
ActionPinfor the upstream repo’s own reusable workflows and composite actions. Those refs carry a subpath (owner/repo/.github/workflows/x.yaml@…), whichACTION_PIN_REdeliberately does not match, so they need their own parser.Create new instance of UpstreamRefPin(version, sha)
- class repomatic.version_sync.WorkflowLiteral(ecosystem: str, package: str, version: str)[source]¶
Bases:
NamedTupleA version literal embedded in a workflow command.
Create new instance of WorkflowLiteral(ecosystem, package, version)
- repomatic.version_sync.parse_min_age(value)[source]¶
Parse a
minimum-release-agevalue into atimedelta.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.
- repomatic.version_sync.min_release_age_days(value)[source]¶
Convert a
minimum-release-agevalue 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()feedssync-workflow-pins: the sameminimum-release-agewindow, 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.
- repomatic.version_sync.exclude_newer_cutoff(value, today)[source]¶
uv
--exclude-newercutoff date for aminimum-release-agevalue.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
uvxtool installs (viarepomatic.tool_runner.run_tool()) by the same windowsync-workflow-pinsapplies to pins. The uv counterpart tomin_release_age_days()(npm).
- repomatic.version_sync.format_cooldown_note(age_label, cutoff)[source]¶
Render the
minimum-release-agecutoff sentence for a diff table.The version-sync counterpart to
repomatic.dep_report.format_exclude_newer_note(). uv records an absoluteexclude-newertimestamp; here the cooldown is a relative span, so the effective cutoff istoday - min_age, recomputed each run rather than stored.- Parameters:
- Return type:
- 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-newercutoff isnow - 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-pinsrun at05:20UTC adopted a release published at17:07on the cutoff date, and every binary build failed onNo solution founduntil 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().
- 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:
- Return type:
- Returns:
The winning
Candidate, orNonewhen 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 theselect_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 fromminimum-release-age.today (
date) – Reference date for the cooldown computation.allow_prerelease (
bool) – Keep prerelease versions whenTrue.
- Return type:
- Returns:
The withheld
Candidate, orNonewhen 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 throughcleared_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
uvxin 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 fromminimum-release-age.today (
date) – Reference date for the cooldown computation.
- Return type:
- Returns:
The release date when pinned is still inside the window, or
Nonewhen 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:
repo_url (
str) – The repository URL.tag_pattern (
str|None) – Per-tool version-extraction regex (seerepomatic.tool_registry.ToolSpec.tag_pattern).
- Return type:
- Returns:
One
Candidateper 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.
- repomatic.version_sync.npm_candidates(package)[source]¶
Collect release candidates from the npm registry.
- repomatic.version_sync.safe_version(value)[source]¶
Parse a PEP 440 version, returning
Nonefor 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 InvalidVersioninto 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.
- repomatic.version_sync.is_newer(new, old)[source]¶
Return
Truewhen new is a strictly higher version than old.Unparsable versions compare as not-newer, so a malformed candidate never triggers a bump.
- Return type:
- repomatic.version_sync.strip_dev_suffix(version)[source]¶
Drop any PEP 440
.devNsegment 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:
- repomatic.version_sync.set_tool_version(content, name, new_version)[source]¶
Rewrite a tool’s
version=field in thetool_registry.pysource.Targets the first
version="…"inside the namedToolSpec(entry, stopping at the next entry so a later tool is never touched.
- repomatic.version_sync.set_with_package_version(content, package, new_version)[source]¶
Rewrite a
with_packagespin in thetool_registry.pysource.Targets the
"{package}=={version}"literal wherever it appears, unlikeset_tool_version(), which is scoped to oneToolSpec(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.
- repomatic.version_sync.find_action_pins(content)[source]¶
Find every SHA-pinned GitHub Action reference in a workflow file.
- repomatic.version_sync.apply_action_pins(content, resolved)[source]¶
Rewrite SHA-pinned actions to their resolved SHA and version comment.
- Parameters:
- Return type:
- 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:
- repomatic.version_sync.self_pin_exemption_re(package: str) Pattern[str][source]¶
Match a
uvxcommand pinning package, capturing the flags before it.Memoized: the splice runs once per workflow file for the same package.
- repomatic.version_sync.frozen_cli_invocation(package, version, exemption)[source]¶
Render the frozen, cooldown-exempt
uvxinvocation of package.The one spelling both writers emit: the release freeze (
PrepareRelease.freeze_cli_version) writes it wholesale, andapply_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:
- repomatic.version_sync.apply_self_pin_exemption(content, package, exemption)[source]¶
Splice a cooldown exemption into every
uvxcommand 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 aUV_EXCLUDE_NEWERcovering all resolution, anduvxreads no per-package exemption from the environment or frompyproject.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.
- 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 needsapply_self_pin_exemption()on the resulting command.
- Return type:
- 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 aNoneSHA).Shared by
lint-repo’s inline-pin lockstep check,sync-workflow-pins’ upstream-pin alignment andinit’s pin floor (_highest_upstream_pin()), so all three read the refs the same way.- Return type:
- 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().
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-virustotalCLI 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:
objectDetection statistics from a completed VirusTotal analysis.
Stores only the four categories that constitute a definitive verdict.
type-unsupported,timeout, andfailurefrom the API response are excluded from the total.
- class repomatic.virustotal.ScanResult(filename, sha256, analysis_url, detection_stats=None)[source]¶
Bases:
objectResult of uploading a single file to VirusTotal.
- detection_stats: DetectionStats | None = None¶
Detection statistics, or
Noneif analysis is still pending.
- class repomatic.virustotal.ScanRecord(tag, filename, sha256, scanned, stats)[source]¶
Bases:
objectA 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 theflagged / totalverdict 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.- stats: DetectionStats¶
Detection statistics at scan time.
- repomatic.virustotal.scan_files(api_key, file_paths, rate_limit=4)[source]¶
Upload files to VirusTotal and return scan results.
Uses the synchronous
vt.ClientAPI. Sleeps between uploads to respect the free-tier rate limit.
- 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:
- Returns:
Results with
detection_statspopulated (orNonefor 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 frompoll_detection_stats().tag (
str) – Git tag of the release the binaries belong to.scanned (
str|None) – Snapshot date inYYYY-MM-DDformat. Today (UTC) whenNone.
- Return type:
- 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 / totalDetections cell frozen minutes after publication. Those cells are genuine at-release snapshots, sosync-binaries --backfill-recordsharvests 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 (
flaggedandtotal).- Parameters:
- Return type:
- 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 nextupsert_scan_records()write lands as CSV, so a repository migrates on its first release after upgrading without anyone converting anything. The stale.jsonis then inert and can be deleted.- Parameters:
path (
Path) – Path to the CSV file.- Return type:
- 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 theformat-jsonautofix job never rewrites it.- Parameters:
path (
Path) – Path to the JSON history file.new_records (
list[ScanRecord]) – Records to merge in.
- Return type:
- Returns:
Truewhen 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:
uv auditqueries the PyPA Advisory Database (OSV-backed).GitHub’s Dependabot alerts query the GitHub Advisory Database (GHSA).
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 audittable.Lives beside the rows’ domain model so the columns and the fields they render cannot drift apart; the CLI derives its
--sort-bychoices from it.
- repomatic.vulnerable_deps.MIN_UV_AUDIT_JSON_VERSION = <Version('0.11.15')>¶
Minimum
uvversion exposinguv audit --output-format json.The structured JSON output landed in uv 0.11.15 as a preview feature. Below this,
uv auditemits only human-readable text, so_run_uv_auditrefuses to run rather than silently scanning nothing.
- class repomatic.vulnerable_deps.AdvisorySource(*values)[source]¶
Bases:
StrEnumWhere 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
VulnerablePackagelets 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:
objectA single vulnerability advisory for a Python package.
- 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/PYSECIDs while listing the matchingGHSA/CVEIDs as aliases, and Dependabot keys byGHSAwhile listing theCVE.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.devpage 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 jsonoutput 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 letcollect_vulnerable_packages()deduplicate the same advisory across sources.- Parameters:
output (
str) – stdout fromuv audit --output-format json.- Return type:
- Returns:
A list of
VulnerablePackageentries (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
Sourcescolumn 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 ofVulnerablePackageentries.- Return type:
- Returns:
A markdown string with a
## Vulnerabilitiesheading 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_idplusaliases) overlap, so the same advisory reported under a PYSEC/OSV ID byuv auditand a GHSA ID by Dependabot collapses into one. Merging preserves the union ofsourcesso the rendered table credits both databases when they agree.Current versions reported by
uv audittake precedence over the empty placeholder produced by the GHSA path, sinceuv auditreads the actual locked version while Dependabot alerts only carry the vulnerable range. When the GHSA path encounters a package thatuv auditdid not surface, the current version is filled in from the lock file.- Parameters:
lock_path (
Path) – Path to theuv.lockfile.repo (
str|None) – Repository inowner/repoformat. Required for theAdvisorySource.GITHUB_ADVISORIESsource; passNoneto skip it (the result then reflectsuv auditonly).sources (
list[AdvisorySource] |None) – Advisory databases to consult. Defaults to all known sources.
- Return type:
- Returns:
Deduplicated list of
VulnerablePackageentries.
- 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-packageusing--exclude-newer-packageto bypass theexclude-newercooldown for security fixes. Also persists the exemptions inpyproject.tomlso that subsequentuv lock --upgraderuns (e.g. from thesync-uv-lockjob) 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:
lock_path (
Path) – Path to theuv.lockfile.repo (
str|None) – Repository inowner/repoformat. Required whenAdvisorySource.GITHUB_ADVISORIESis among sources.sources (
list[AdvisorySource] |None) – Advisory databases to consult. Defaults to all known sources.
- Return type:
- Returns:
A tuple of
(has_fixes, diff_table).has_fixesisTruewhen at least one vulnerable package was upgraded.diff_tableis 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=pipvia theghCLI, then maps each alert into aVulnerablePackagetagged withAdvisorySource.GITHUB_ADVISORIES.Returns an empty list when the API is unreachable, the token lacks the
Dependabot alertspermission, or the repository has no open alerts. A network or auth failure must not break the autofix workflow: theuv auditsource is still consulted independently.- Parameters:
repo (
str) – Repository inowner/repoformat.- Return type:
- Returns:
List of
VulnerablePackageentries with a known fixed version. Alerts withoutfirst_patched_versionare skipped (no upgrade target).