repomatic.lint_repo module

Repository linting for GitHub Actions workflows.

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

Every check returns a CheckResult, whose tri-state passed flag distinguishes success, failure, and skipped/indeterminate outcomes uniformly.

repomatic.lint_repo.DOCS_URL_KEYS = ('documentation', 'docs')

Keys in [project.urls] naming the published documentation site.

Checked in priority order, and looked up in a lowercased index of the project’s own keys: PEP 621 leaves the spelling to the project, so Documentation, documentation and Docs all occur in the wild. Mirrors the same convention _SOURCE_URL_KEYS in repomatic.pypi applies to the PyPI copy of the same mapping.

repomatic.lint_repo.WORKFLOW_DIR = PosixPath('.github/workflows')

Directory every workflow check walks.

Derived from the registry constant repomatic init deploys against, so the checks and the generator can never disagree about where a workflow lives.

repomatic.lint_repo.RELEASE_DOWNLOAD_RE = re.compile('/releases/(?:download/(?P<tag>[^/\\s\\")]+)|latest/download)/(?P<filename>[^/\\s\\")]+)')

A GitHub release asset URL, capturing its tag and filename.

Matches both spellings a guide can use: the tag-pinned /releases/download/<tag>/<file> the release freeze writes, and the versionless /releases/latest/download/<file> alias the binary aliases exist to serve, which names no tag and so leaves tag unset.

Covering only the first made the check a no-op on a guide written entirely against the alias, which is precisely where a filename rots unnoticed: the freeze rewrites a pinned tag every release and would surface a bad name, while an alias URL is never touched again after it is written. meta-package-manager renamed its binaries from mpm-* to meta-package-manager-* in 7.0.0 and its install guide kept advertising the old name for six weeks.

Matches any release download link, not only a binary one, so the install guide’s whole download surface is verified with a single pattern. Both groups stop at a quote, whitespace or a closing parenthesis, covering an HTML src, a Markdown link target and a bare URL in prose alike.

repomatic.lint_repo.MAX_REPORTED_DEAD_URLS = 5

How many abandoned redirect sources a failing _redirects check names.

Enough to recognize which part of the file fell off the end, short of dumping a tail that can run to hundreds of rules into one lint message. The remainder is counted rather than listed, and the fix is the same reorder either way.

repomatic.lint_repo.PR_TEMPLATE_DIR = PosixPath('.github/pr-templates')

Canonical home for a repository’s own pr-body --template-file templates.

.github/ already namespaces by subdirectory (ISSUE_TEMPLATE/, workflows/, actions/), and a dedicated one leaves each template’s basename free to carry the operation name, so it can match its job ID and PR branch. Templates sitting flat in .github/ need a pr- prefix purely to disambiguate, which breaks that identity and puts them next to GitHub’s own pull_request_template.md, an unrelated human-facing file.

repomatic.lint_repo.PYTHON_CLASSIFIER_PREFIX = 'Programming Language :: Python :: '

Prefix of the classifiers naming a supported interpreter version.

Only the dotted ones carry a version: the bare 3 and 3 :: Only state the major series, and Implementation :: CPython the interpreter.

repomatic.lint_repo.KNOWN_RUNNERS = frozenset({'macos-26', 'macos-26-intel', 'ubuntu-26.04', 'ubuntu-26.04-arm', 'windows-11-arm', 'windows-2025'})

Every runner image this project has deliberately chosen.

The closest thing to a curated list of images a project should be running on, and the one place carrying measured guidance on their relative speed and cost. A job naming something outside it has been picked without that guidance.

The test axes are the whole list: every job runs on an image the suite is also validated against, so “where is the suite exercised” and “what may a job run on” are one question. That is deliberate, since each extra image is one more to track, pin and migrate. A job needing something else is a decision to make explicitly, by widening the axes rather than by naming an image here.

repomatic.lint_repo.TEMPLATE_FILE_ARG_RE = re.compile('--template-file[=\\s]+(?P<path>\\S+)')

A repomatic pr-body --template-file argument inside a workflow run: block.

Matched against the raw YAML text rather than the parsed document: the argument sits inside a folded scalar, so the surrounding run: value is one opaque string whichever way the file is parsed.

repomatic.lint_repo.FUNDING_SPONSORS_QUERY = '\nquery($owner: String!, $name: String!) {\n  repository(owner: $owner, name: $name) { isFork }\n  repositoryOwner(login: $owner) {\n    ... on Sponsorable { hasSponsorsListing }\n  }\n}'

Reads whether a repository is a fork and whether its owner runs Sponsors.

One query for both facts the funding check gates on. GraphQL because the REST API does not expose hasSponsorsListing.

repomatic.lint_repo.BRANCH_PROTECTION_RULES_QUERY = '\nquery($owner: String!, $name: String!) {\n  repository(owner: $owner, name: $name) {\n    branchProtectionRules(first: 100) {\n      nodes { pattern }\n    }\n  }\n}\n'

Lists every branch protection rule a repository declares, by branch pattern.

GraphQL because REST cannot answer the question. GET /repos/{repo}/branches/{branch}/protection reads one concrete branch, so finding a rule needs the branch name up front and still misses any pattern targeting a branch that does not exist yet. This field enumerates the rules themselves.

The field also draws the line the check depends on: a ruleset never appears here, and a branch protection rule never appears under rulesets. Verified on 2026-08-27 against a repository holding both at once, which listed the branch protection rule here and the ruleset only there.

class repomatic.lint_repo.CheckResult(passed: bool | None, message: str)[source]

Bases: NamedTuple

Outcome of one repository check.

passed is tri-state: True on success, False on failure, None when the check could not run or does not apply (skipped). message is the human-readable line for both terminal output and annotations.

Create new instance of CheckResult(passed, message)

passed: bool | None

Alias for field number 0

message: str

Alias for field number 1

repomatic.lint_repo.get_repo_metadata(repo)[source]

Fetch repository metadata from GitHub API.

Parameters:

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

Return type:

dict[str, str | None]

Returns:

Dictionary with ‘homepageUrl’ and ‘description’ keys. Both are None when the repository could not be read.

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

Check if package name matches repository name.

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

  • repo_name (str) – The repository name.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.documentation_url(project_urls)[source]

The documentation site a project declares in [project.urls].

Parameters:

project_urls (Mapping[str, str] | None) – The [project.urls] mapping, keys untouched.

Return type:

str | None

Returns:

The first URL found per DOCS_URL_KEYS, or None when the project declares none.

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

Check that a Sphinx project’s website field names its documentation.

GitHub renders the website field in the repository sidebar, and for a project publishing Sphinx documentation that is where a visitor expects to land. So the check has two halves: the field is set at all, and it names the site the project itself declares under DOCS_URL_KEYS.

The second half is what a documentation move leaves behind. Sphinx emits <link rel="canonical"> from html_baseurl, and a conf.py commonly derives that from the same [project.urls] entry, so a project that moves to a new domain has every published page naming the new origin as canonical while the sidebar keeps sending visitors to the one it replaced. Nothing but a reader noticing connects the two.

Note

A project declaring no documentation URL gets the presence half only. The comparison needs the project to have named an expected answer, and nothing here invents one from the repository slug.

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

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

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

  • docs_url (str | None) – Documentation URL declared in [project.urls].

Return type:

CheckResult

Returns:

A CheckResult.

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

Check that repository description matches project description.

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

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

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_funding_file(repo)[source]

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

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_stale_draft_releases(repo)[source]

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

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_install_guide_downloads(repo)[source]

Check the install guide’s release download URLs still resolve.

The release freeze pins those URLs to the version being released, but it runs before the binaries exist: the freeze commit is what triggers the build. So the pin is optimistic, and a release whose binary lane fails leaves the guide advertising files that 404 until the next release ratchets past it. 7.7.0 shipped that way, with all six links dead.

A versionless latest/download alias fails a different way, and stays broken longer: nothing rewrites it at release time, so it silently outlives a renamed asset instead of being re-pinned every cycle. Both forms are checked, see RELEASE_DOWNLOAD_RE.

Nothing static can catch either: the URLs are well-formed and correct on disk, and only the release’s actual asset list settles whether they resolve. Hence a lint check against the API rather than a conformance test.

Reports rather than repairs, per claude.md § Skip and move forward: the fix is a one-liner (freeze_install_download_urls() re-pointed at the last release that carries binaries), while an automated rewrite driven by one API read could downgrade a healthy install page on a flaky response.

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

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

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

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

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_pat_repository_scope(repo)[source]

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

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

Two strategies are tried in order:

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

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_pat_stale_statuses_permission(repo)[source]

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

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

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

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

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

Note

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

class repomatic.lint_repo.RepoSettingProbe(label, endpoint, field, pass_message, fail_message, passing=None, failing=frozenset({}), unknown='unknown value {value!r}', unavailable='could not query API')[source]

Bases: object

One repository setting to read, and how its values map to verdicts.

The repo-setting checks share one shape: read one API endpoint through gh, inspect one field, and map its value onto a pass, a failure naming the settings page, or an indeterminate skip. What varies is declared here; _check_repo_setting() runs it. A probe with no passing set reads its field as a boolean toggle and never skips on an unknown value.

label: str

Check name opening every skip message.

endpoint: str

API path under repos/{repo}/ to read.

field: str

Response field carrying the setting.

pass_message: str

Success message; {value} interpolates the field’s value.

fail_message: str

Failure message; {repo} interpolates the repository slug.

passing: frozenset[str] | None = None

Values that pass; None reads the field as a boolean toggle.

failing: frozenset[str] = frozenset({})

Values that fail explicitly; anything in neither set skips.

unknown: str = 'unknown value {value!r}'

Skip reason for a value in neither set; {value} interpolates it.

unavailable: str = 'could not query API'

Skip reason when the endpoint cannot be read.

repomatic.lint_repo.check_fork_pr_approval_policy(repo)[source]

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

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

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

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

Note

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (API inaccessible, unparsable, or unknown policy).

repomatic.lint_repo.check_sha_pinning_required(repo)[source]

Check that GitHub Actions must be pinned to a full-length commit SHA.

GitHub has a per-repository policy, sha_pinning_required, that makes the platform itself refuse to run any workflow referencing an action by a mutable tag or branch instead of a commit SHA. repomatic already pins every action it generates and checks unpinned refs with zizmor (check_inline_pins_match_upstream and the lint-zizmor job), but a zizmor finding can be silenced inline (# zizmor: ignore[...]), so a hand-edited workflow could still slip a mutable tag past review. This repo-level setting is the platform-enforced backstop.

Queries GET /repos/{repo}/actions/permissions and returns False when sha_pinning_required is absent or false.

Note

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (API inaccessible or unparsable).

repomatic.lint_repo.check_tag_protection_rules(repo)[source]

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

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_branch_ruleset_on_default(repo)[source]

Check that at least one active branch ruleset exists.

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

Note

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the rulesets API could not be read, matching check_tag_protection_rules(), which reads the same payload.

repomatic.lint_repo.check_classic_branch_protection(repo)[source]

Check that no branch protection rule survives beside the rulesets.

Branch protection rules predate rulesets and GitHub still supports both, with no deprecation notice and no removal date. They are not alternatives a repository picks between: both apply at once, and where the two carry the same rule, the stricter version wins. A rule left behind after a migration therefore changes nothing on the day it is left, which is what makes it worth reporting: the branch policy is now split across two settings pages, and an edit to one page does not show on the other.

check_branch_ruleset_on_default() states the other half of the same policy, that a ruleset must exist. Together they say the protection is a ruleset and only a ruleset.

Note

Advisory, not fatal. A leftover rule protects the branch rather than exposing it, so the finding is a cleanup, per claude.md § Defensive workflow design.

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the API could not be read, matching the ruleset checks. Reading the rules needs admin on the repository, so a token without it skips rather than passes.

repomatic.lint_repo.check_immutable_releases(repo)[source]

Check that immutable releases are enabled for the repository.

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

Note

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

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (API inaccessible or unparsable).

repomatic.lint_repo.check_pages_deployment_source(repo)[source]

Check that GitHub Pages is deployed via GitHub Actions, not a branch.

The docs.yaml workflow uses actions/upload-pages-artifact and actions/deploy-pages, which require the Pages source to be set to GitHub Actions in the repository settings. Branch-based deployment (legacy) is incompatible.

Queries GET /repos/{repo}/pages and inspects the build_type field in the response.

Note

A 404 means Pages is not configured at all. This is treated as indeterminate (None) rather than a failure, because the repo may not have deployed docs yet.

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the check could not run (Pages not configured, or API inaccessible).

repomatic.lint_repo.check_pages_redirect_preserved(repo, docs_url)[source]

Check that the old github.io URLs still redirect to the live site.

A repository whose site moved to Cloudflare Pages keeps its <owner>.github.io/<repo>/… URLs answering through a single field: the GitHub Pages custom domain. Set it, and GitHub redirects that whole space with a path-preserving 301, for free, covering paths the site never even had. What it rescues is precisely the set of URLs nobody can rewrite: search indexes, other projects’ readmes, and the [project.urls] metadata frozen into every release already published.

Two ways to lose it, both invisible from inside the repository. Disabling Pages deletes the redirect along with the site, and every historical link starts answering 404 with nothing to show a maintainer why. Leaving the custom domain unset is quieter still: the old host keeps serving a copy of the documentation, which stops being rebuilt the moment the deploy job is gated off, so the two hosts disagree more with every release.

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

  • docs_url (str | None) – Documentation URL declared in [project.urls], whose host is what the custom domain must name.

Return type:

CheckResult

Returns:

A CheckResult. passed is None when the repository has no legacy Pages URLs to preserve, or the declared URL is unreadable.

repomatic.lint_repo.check_pypi_trusted_publisher(repo, package_name)[source]

Check that the PyPI Trusted Publisher entry is registered for this repo.

PyPI’s Trusted Publisher settings are owner-only at /manage/project/<name>/settings/publishing/ and not exposed through any public API. The only public surface where the OIDC publisher is observable is the PEP 740 provenance attached to releases uploaded via OIDC: see repomatic.pypi.get_trusted_publishers(). This check probes the latest release’s provenance and looks for a bundle whose repository matches repo and whose workflow is PYPI_TRUSTED_PUBLISHER_WORKFLOW. A match means the publisher is wired up and a previous release uploaded successfully through it. A mismatch (provenance exists but names a different repo or workflow) is a misconfiguration: typical cause is registering the upstream reusable workflow instead of the downstream caller’s release.yaml, which fails on the first upload after migration. Indeterminate (None) covers two cases that look identical from the outside: no published release yet, and provenance missing because past releases were uploaded via API token. In both cases the setup guide nags until the next OIDC-attested upload appears.

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

  • package_name (str | None) – PyPI package name. The check is skipped when not provided.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_stale_gh_pages_branch(repo)[source]

Check for a leftover gh-pages branch after switching to GitHub Actions.

When Pages is deployed via GitHub Actions, the gh-pages branch is no longer needed and should be deleted to avoid confusion.

Parameters:

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

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_workflow_permissions(workflows=None)[source]

Check workflow permissions declarations for least privilege.

Two failure modes are flagged:

  1. A workflow that defines its own steps: should carry a top-level permissions key (permissions: {} for least privilege) so its jobs default to no scopes rather than the repository default.

  2. A job that calls a reusable workflow (a job-level uses:) hands its own permissions down, and the reusable workflow’s jobs are capped by them: they cannot escalate beyond what the caller grants. So under a top-level permissions: {}, a reusable-call job with no permissions: block of its own passes {} to the called workflow, and GitHub aborts the run at startup the moment a nested job requests a scope the caller never granted. Such a job must name the union of the scopes its reusable workflow needs (mirror the reusable workflow’s own top-level {} plus per-job grants).

A thin caller with no top-level permissions key is fine: its jobs inherit the repository default, which the reusable workflow’s own permissions: blocks then cap. The failure is specifically an empty top-level permissions: {} starving an unqualified reusable call.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_test_matrix_excludes()[source]

Flag [tool.repomatic.test-matrix] exclude entries that match no axis.

An exclude naming a value absent from every matrix axis (like a renamed runner) can never match a combination, so Matrix.prune() drops it silently and its exclusion intent is lost. Reporting it as a warning makes the drift visible in CI instead of silently weakening the matrix.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.iter_jobs(workflows)[source]

Yield (path, job_id, job) for every job across workflows.

Parameters:

workflows (Mapping[Path, dict]) – Parsed workflow documents, keyed by path.

Return type:

Iterator[tuple[Path, str, dict]]

repomatic.lint_repo.iter_steps(workflows)[source]

Yield (path, job_id, step) for every step across workflows.

Parameters:

workflows (Mapping[Path, dict]) – Parsed workflow documents, keyed by path.

Return type:

Iterator[tuple[Path, str, dict]]

repomatic.lint_repo.check_python_version_consistency(workflows=None)[source]

Reconcile the Python versions a project requires, advertises and tests.

The same fact is stated in up to three places, and nothing else holds them together: the requires-python lower bound, the Programming Language :: Python :: X.Y classifiers PyPI renders, and any test matrix naming its versions literally.

Two failure modes are flagged:

  1. The lowest classifier disagrees with the requires-python floor. One of the two is then lying to resolvers about what installs.

  2. A literal test matrix does not reach both ends of the advertised range, or names a released version the classifiers never claim. Coverage of the ends is the invariant rather than of every version in between, so that a matrix testing the floor, the latest release and the development version stays conformant: skipping intermediate releases is a deliberate way to cut CI load, advertising an untested boundary is not.

Versions in UNSTABLE_PYTHON_VERSIONS are exempt from the second rule, being tested precisely because they are not released yet and so cannot be advertised. Build flavors carrying a suffix (the free-threaded 3.14t) count as their base version.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.literal_runners(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]

Every runner image this repository names outright, and where.

Only literals: a value built from an expression (${{ matrix.os }}) names no image here, and the axis it draws from is checked at its definition. A thin caller declares no steps: and runs on whatever the reusable workflow chose, which is that workflow’s business rather than this repository’s.

Separate from KNOWN_RUNNERS, and deliberately so. That set is what this project has chosen; this function reports what it is running, and the two diverge exactly when something has been left behind. Callers wanting “an image this repository has a stake in” need the union of both.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow files. Ignored when workflows is supplied.

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

dict[str, list[str]]

Returns:

A mapping of runner label to the file.yaml:job-id locations naming it, empty when no job names one literally.

repomatic.lint_repo.check_runner_images(workflows=None)[source]

Flag runner images that move on their own, or that no axis knows about.

Neither Dependabot nor sync-workflow-pins touches a runs-on: value: the first only rewrites uses: references, the second only the uvx '<pkg>==X.Y.Z' and npm install pkg@X.Y.Z literals. So a runner is the one dependency in a workflow that nothing bumps, and the only defence is keeping the set small and named.

Two failure modes are flagged:

  1. A -latest alias. GitHub repoints those to a new image on its own schedule, so the build changes underneath the repository with no commit to review, and a breakage arrives unattached to any change.

  2. An image outside the curated axes in repomatic.matrix_axes. Those carry measured guidance on speed and cost; an image picked outside them is one nobody has weighed, and is usually a leftover.

Values built from an expression (${{ matrix.os }}) name no image here and are left alone: the axis they draw from is checked at its definition.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

class repomatic.lint_repo.ReleaseGate(name: str, workflow: str, metadata_key: str | None, needs: str)[source]

Bases: NamedTuple

A release-only step or job, and the project capability it needs.

metadata_key names the Metadata field that both decides whether the step runs and supplies what it consumes. None marks a step that needs nothing beyond being on a release commit.

Create new instance of ReleaseGate(name, workflow, metadata_key, needs)

name: str

Alias for field number 0

workflow: str

Alias for field number 1

metadata_key: str | None

Alias for field number 2

needs: str

Alias for field number 3

repomatic.lint_repo.RELEASE_ONLY_GATES: tuple[ReleaseGate, ...] = (('Pre-bake tag SHA', '_release-build.yaml', 'cli_scripts', "a `[project.scripts]` entry, since click-extra's prebake finds the module to stamp through it"), ('📌 Tag release', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('🐍 Publish to PyPI', 'release.yaml', None, 'a wheel from the build lane'), ('🐙 Create GitHub release draft', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('📖 Man pages', '_release-engine.yaml', 'manpages_script', 'a configured man-page script'), ('📎 Extra release assets', '_release-engine.yaml', 'release_assets', 'at least one configured extra asset'), ('🎉 Publish GitHub release', '_release-engine.yaml', None, 'nothing beyond the release commit'))

Every step and job that runs only on a release commit.

These are invisible on an ordinary push: each is gated behind a condition that holds open only for the one commit that tags, publishes and releases. A project can therefore build green for its entire life and meet them for the first time on release day, where a failure costs a reverted release rather than a red push.

VirusTotal scan is deliberately absent: it gates on a repository secret rather than a project capability, so there is nothing in the tree to check it against.

repomatic.lint_repo.check_release_path(workflows=None)[source]

Resolve the release path against this project, on an ordinary push.

Two arms, because the two failure modes live in different repositories.

The first runs everywhere, including downstream. It resolves each entry of RELEASE_ONLY_GATES against the local project and reports which release-only steps a release commit would actually run. That turns a surface nothing exercises until release day into a line of output on every push, so the answer is known long before it is expensive.

The second runs only where the reusable workflows live, since a downstream repository holds a thin caller and not the steps themselves. It asserts each gate’s if: really does test the metadata key its step depends on. That invariant is what a release-only step gets wrong: the condition looks complete because it correctly waits for a release, while saying nothing about the capability the step consumes. Pre-bake tag SHA shipped that way, gated on the version alone, and every project with no [project.scripts] built green until the release commit ran prebake against a module that was not there.

Parameters:

workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_inline_pins_match_upstream(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', texts=None)[source]

Check inline upstream pins match the workflow uses: ref version.

A workflow that pins the upstream toolkit in a run: shell command (like uvx 'repomatic==1.2.3' metadata) must keep that version in lockstep with the SHA-pinned uses: refs. A manual workflow sync bumps the refs but not the inline pin, and sync-workflow-pins only realigns it on its next scheduled run, so the pin can lag in between. When the stale version drops a symbol the newer refs rely on, the metadata job fails and a release can publish to PyPI yet never tag (the toolkit chicken-and-egg). Flag the drift so the lint fails before a release does.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when texts is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the inline package to match (like repomatic).

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_self_pin_cooldown_exemption(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', texts=None)[source]

Check every inline upstream pin carries its cooldown exemption.

A workflow pinning the upstream toolkit in a run: command (uvx 'repomatic==1.2.3' metadata) resolves under the workflow-wide UV_EXCLUDE_NEWER, and that pin moves in lockstep with the uses: refs, so it routinely names a release published hours ago. Without SELF_PIN_COOLDOWN_EXEMPTION on the command line, uvx cannot resolve it at all. uvx reads no project configuration, so there is nowhere else the bypass could live.

The failure is total rather than partial, which is why this is worth a dedicated check: the pin usually sits in the metadata job, every other job is needs: metadata, and the whole workflow reports failure while executing nothing. Downstream repos are the exposed ones. tests/test_workflows.py pins the canonical workflows, sync-workflow-pins splices a missing flag in on any run that also moves the version, and a repo already pinned at the newest release falls through both.

Only flags a pin under a workflow that actually sets a cooldown: a repo without one has nothing to exempt.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when texts is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the inline package to match (like repomatic).

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_setup_uv_version_pin(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]

Check every astral-sh/setup-uv step pins the uv version it installs.

[tool.uv] required-version is a floor for everyone; what a runner downloads is a separate question, and left to setup-uv the answer is “the newest release satisfying the floor”, installed seconds after it lands. That makes the tool enforcing every cooldown the one tool without one, so each step carries with: version: "X.Y.Z" and sync-workflow-pins walks it forward once a uv release clears minimum-release-age.

Steps naming two different versions in one repository are flagged too: the pin exists so every job resolves through the same uv, and a split fleet silently tests two.

Reads the parsed workflow rather than its text: a with: input is a plain mapping, so the step a pin belongs to is a fact the parser already knows. Matching the raw text instead means bounding a step’s block by hand, and a body running past its own step lets one pinned step vouch for every unpinned one above it.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when workflows is supplied.

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.check_setup_uv_checksum_coverage(workflow_dir=PosixPath('.github/workflows'), workflows=None)[source]

Check the pinned uv is one the pinned setup-uv can checksum-verify.

The pin check above settles that CI resolves through a uv somebody chose. Whether the bytes it downloads are the bytes that version shipped is a second question, and setup-uv answers it only for the versions listed in the checksum table its own release bundles: anything else installs with no verification and no warning. So a repository can hold two perfectly good pins that together verify nothing, which is what this reports.

Advisory, and not fatal for the same reason the pin check is not: the download succeeds and the job runs, it just carries a cooldown where it could have carried a cooldown and a hash. The repair is a sync-action-pins bump, and sync-workflow-pins stops widening the gap on its own (see repomatic.sync_ops._gate_uv_on_checksums()).

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when workflows is supplied.

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

CheckResult

Returns:

A CheckResult, indeterminate when the table cannot be read.

repomatic.lint_repo.requested_metadata_keys(command, package)[source]

Positional keys a shell command passes to <package> metadata.

Reads the tail of the invocation the way Click would: options are dropped along with the value each one consumes, and what remains are the positional key arguments. Handles both spellings in use, the upstream uv run -- repomatic metadata and the downstream uvx 'repomatic==1.2.3' metadata , by looking for the subcommand after any token naming the package.

Shared with repomatic.init_project, which asks the same question of a downstream checkout at sync time rather than of this repository at lint time. One parser, so the two verdicts cannot disagree about what a run: line requests.

Parameters:
  • command (str) – The step’s run: script, folded or literal.

  • package (str) – Upstream package name (like repomatic).

Return type:

list[str]

Returns:

The key names requested, in the order written.

repomatic.lint_repo.check_metadata_keys(workflow_dir=PosixPath('.github/workflows'), upstream_repo='kdeldycke/repomatic', workflows=None)[source]

Check the metadata keys workflows request still exist.

A downstream repository owns the job bodies of its header-only workflows: repomatic init syncs their name, on and concurrency blocks and the uses: pins, and never touches the steps below. So a key retired upstream keeps being asked for by a run: line nothing sweeps, and the metadata command answers a retired key with a UsageError. Since every other job in a test workflow reaches it through needs:, the whole run dies at the first job, on the next push, from a workflow file that looks freshly synced.

That is not hypothetical: coverage_cells went away with the Codecov integration and took a downstream test workflow down with it. Failing here instead moves the report to lint time, where it names the file and the job.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when workflows is supplied.

  • upstream_repo (str) – Upstream owner/repo; its name is the package whose metadata invocations are read (like repomatic).

  • workflows (Mapping[Path, dict] | None) – Pre-parsed workflows, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

repomatic.lint_repo.check_pr_templates(workflow_dir=PosixPath('.github/workflows'), template_dir=PosixPath('.github/pr-templates'), texts=None)[source]

Check a repository’s own pr-body --template-file templates.

A repo with a custom PR-opening job ships the body as a file of its own rather than adding a template upstream. Three failure modes are flagged:

  1. The file sits outside template_dir. See PR_TEMPLATE_DIR.

  2. A workflow references a path that does not exist, which the job only discovers when it runs and pr-body rejects the missing file.

  3. The frontmatter lacks a title, or does not set footer to the bare boolean false. Both false and the quoted 'false' opt out, but an absent field, 'False', and every other value do not, and the failure is silent: the rendered body carries the attribution footer twice.

A docs field is not required. It deep-links the hosted workflows reference, which documents upstream jobs only.

Parameters:
  • workflow_dir (Path) – Directory holding the workflow YAML files. Ignored when texts is supplied.

  • template_dir (Path) – Directory the templates are expected to live in.

  • texts (Mapping[Path, str] | None) – Pre-read workflow texts, read from disk when None.

Return type:

list[CheckResult]

Returns:

A list of CheckResult.

class repomatic.lint_repo.LintContext(package_name=None, repo_name=None, is_package=False, is_sphinx=False, site_deploy='github-pages', site_cloudflare_project='', site_cloudflare_compatibility_date='', project_description=None, docs_url=None, keywords=None, repo=None, has_pat=False, has_virustotal_key=False, has_cloudflare_api_token=False, nuitka_active=False, has_notifications_pat=False, unsubscribe_active=False)[source]

Bases: object

Everything the checks read, resolved once per lint-repo run.

package_name: str | None = None

The Python package name.

repo_name: str | None = None

The repository name.

is_package: bool = False

Whether the project builds a distributable package.

Per repomatic.pyproject.is_python_package(). Gates the checks that only make sense for something actually published to PyPI.

is_sphinx: bool = False

Whether the project uses Sphinx documentation.

site_deploy: str = 'github-pages'

Where the repository’s built site publishes, per site.deploy.

Each host has its own prerequisite, and exactly one of them applies: the GitHub Pages source check reads a 404 forever on a Cloudflare-hosted project, and the Cloudflare credential check has nothing to say about a project deploying with the repository’s own OIDC identity. The credential check follows the declared target alone, Sphinx or not: a site built by the repository’s own workflow needs the same secrets the Docs workflow would.

site_cloudflare_project: str = ''

Cloudflare Pages project name override, per site.cloudflare-project.

Empty means the project is named after the repository, the deploy job’s own fallback.

site_cloudflare_compatibility_date: str = ''

Declared Workers runtime date, per site.cloudflare-compatibility-date.

project_description: str | None = None

Description from pyproject.toml.

docs_url: str | None = None

Documentation site declared in [project.urls], per DOCS_URL_KEYS.

keywords: list[str] | None = None

Keywords list from pyproject.toml.

repo: str | None = None

Repository in owner/repo format.

has_pat: bool = False

Whether GH_TOKEN contains REPOMATIC_PAT.

has_virustotal_key: bool = False

Whether VIRUSTOTAL_API_KEY is configured.

has_cloudflare_api_token: bool = False

Whether CLOUDFLARE_API_TOKEN is configured.

nuitka_active: bool = False

Whether this project compiles binaries with Nuitka.

has_notifications_pat: bool = False

Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

unsubscribe_active: bool = False

Whether the unsubscribe workflow is opted in.

property repo_metadata: dict[str, str | None][source]

The repository’s GitHub-side description and homepage.

Fetched once and shared by the checks that compare a pyproject.toml field against it. An absent repository answers empty rather than failing, so those checks report a miss instead of the run dying.

property redirects_files: list[Path][source]

Committed Cloudflare Pages _redirects files, .gitignore honoured.

The gitignore filter is what keeps a generated site tree (an output/ or docs/_build/ copy of the same file) out of the audit: the engine replica must read the source of truth, not a build artifact of it.

property has_wrangler_toml: bool[source]

Whether the repository commits a root-level wrangler.toml.

property workflow_texts: dict[Path, str][source]

Every workflow file’s raw text, read once for the whole run.

Half the roster walks .github/workflows/: three checks match the files as written and six parse them. Reading once here spares each its own directory walk, the same way repo_metadata pools the GitHub lookup.

property workflows: dict[Path, dict][source]

The parsed jobs-bearing workflows, from workflow_texts.

deploys_to(target)[source]

Whether this repository publishes its site to target.

Routes through repomatic.config.deploys_to(), the same predicate the setup guide reads, so the audit and the guide cannot disagree on which host a repository is on.

Return type:

bool

classmethod from_project(config, *, repo=None, repo_name=None, has_pat=False, has_virustotal_key=False, has_cloudflare_api_token=False, has_notifications_pat=False)[source]

Resolve the project-shaped fields from the current checkout.

The one derivation the CLI runs: everything pyproject.toml, the Metadata singleton and the [tool.repomatic] config can answer is read here, so the command hands over only what it alone knows (which secrets exist, which repository was named). The 17-field transcription this replaces lived in the CLI and cost four edits per new check-relevant fact.

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

  • repo (str | None) – Repository in owner/repo format, or None.

  • repo_name (str | None) – Repository name; derived from repo when omitted.

  • has_pat (bool) – Whether REPOMATIC_PAT is configured.

  • has_virustotal_key (bool) – Whether VIRUSTOTAL_API_KEY is configured.

  • has_cloudflare_api_token (bool) – Whether CLOUDFLARE_API_TOKEN is configured.

  • has_notifications_pat (bool) – Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

Return type:

LintContext

class repomatic.lint_repo.RepoCheck(name, run, applies=<function RepoCheck.<lambda>>, fatal=False)[source]

Bases: object

One entry of the lint-repo check sequence.

The sequence used to be twenty-five hand-numbered if blocks whose comment numbering had degraded to Check 10b-quater, and two checks this module defines were never reached by it at all. Declaring each check once makes the roster the thing tests and readers walk.

name: str

Stable identity, for tests and for grepping the roster.

run: Callable[[LintContext], CheckResult | Iterable[CheckResult]]

Perform the check. May answer one result or a stream of them.

applies()

Whether this repository has anything for the check to look at.

fatal: bool = False

Whether a failure fails the command.

A fatal check reports at ERROR and sets the non-zero exit code; every other check is advisory, per claude.md § Defensive workflow design.

results(ctx)[source]

Run the check, normalizing one-or-many into a tuple.

Return type:

tuple[CheckResult, ...]

repomatic.lint_repo.REPO_CHECKS: tuple[RepoCheck, ...] = (RepoCheck(name='package-name-vs-repo', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='website-for-sphinx', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-deployment-source', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='cloudflare-pages-secrets', run=<function _cloudflare_secrets>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirect-preserved', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirects', run=<function _pages_redirects>, applies=<function <lambda>>, fatal=True), RepoCheck(name='wrangler-toml', run=<function _wrangler_config>, applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-gh-pages-branch', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='description-matches', run=<function <lambda>>, applies=<function <lambda>>, fatal=True), RepoCheck(name='topics-subset-of-keywords', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='funding-file', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-draft-releases', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='install-guide-downloads', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='tag-protection-rules', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='branch-ruleset-on-default', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='classic-branch-protection', 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='setup-uv-checksum-coverage', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='metadata-keys', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pr-templates', run=<function <lambda>>, applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='virustotal-secret', run=<function _virustotal_secret>, applies=<function <lambda>>, fatal=False), RepoCheck(name='notifications-pat-secret', run=<function _notifications_secret>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-permissions', run=<function _pat_permissions>, applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pat-repository-scope', run=<function <lambda>>, applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-stale-statuses-permission', run=<function <lambda>>, applies=<function <lambda>>, fatal=False))

Every check lint-repo runs, in report order.

Two of these (branch-ruleset-on-default, immutable-releases) were defined in this module but reached only from repomatic.setup_guide, so lint-repo silently skipped them until the roster made the omission visible.

repomatic.lint_repo.run_repo_lint(ctx)[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:

ctx (LintContext) – Everything the checks read. Build it with LintContext.from_project() to derive the project-shaped fields the way the CLI does, or directly for a hand-assembled probe.

Return type:

int

Returns:

Exit code (0 for success, 1 for errors).