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.PAT_SCOPE_PROBE = ('administration', 'Administration: Read-only', 'repos/{repo}/actions/permissions', 'Administration: token has access', '')

The permission probe check_pat_repository_scope() sends to other repositories.

The endpoint that reports a token’s own repository selection, GET /installation/repositories, refuses a personal access token. So the check reads what the token is allowed to do. A token gets a 2xx from this endpoint only for a repository it covers with Administration: Read-only. An answer from another repository then proves the token covers it.

No other row of PAT_PERMISSION_PROBES can do that. A public repository answers the contents, issues, pull requests and workflows endpoints to any caller. The Dependabot alerts endpoint answers 404 wherever alerts are off, which says nothing about the token. Read on 2026-10-13: this endpoint answers 401 to a caller with no token, and 403 to an account that does not administer the repository.

repomatic.lint_repo.PAT_SCOPE_PROBE_LIMIT = 10

How many other repositories check_pat_repository_scope() probes.

Each probe is one API call, made on every lint-repo and setup-guide run. The check takes the owner’s most recently pushed repositories: a token shared between repositories serves the active ones, and a token with access to all of them gets an answer from the first one probed.

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_undeclared_labels(repo, declared)[source]

Report labels the repository carries that no configured source declares.

sync-labels runs labelmaker apply, which creates, updates and renames but never deletes. So a label retired from declared_label_names()’ sources keeps existing on GitHub, and no other job will ever mention it again. This is the only reading that closes that loop.

Advisory on purpose, and it must stay that way on both counts. A repository may legitimately carry a hand-made label nobody declared, so a red run would be wrong; and deleting a label detaches every issue and pull request carrying it, which is a judgement no check should push someone into making quickly. meta-package-manager is the case in point: of nine orphans found on 2026-09-08, eight carried nothing and one carried 864 items, and only the counts told them apart.

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

  • declared (frozenset[str] | None) – Every label name the configured sources declare, or None when a label definition could not be fetched or read.

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

Note

A keyword matches the topic GitHub would store for it: lowercased, with each run of whitespace replaced by a hyphen. A topic holds only lowercase letters, numbers and hyphens, while [project] keywords carries the spelling the package publishes. A project keywording an acronym (CLI, EML, MMDF) or a phrase (Weather forecast) would otherwise be told to add topics it already declares, with no spelling of the keyword able to satisfy both sides.

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 has no access to another repository of the same owner.

A fine-grained PAT should name this repository alone under Only select repositories, to follow the principle of least privilege. This check detects a token that also covers another repository, through All repositories access or through a longer selection.

Two strategies are tried in order:

  1. GET /installation/repositories — returns the repos the token can access, including a repository_selection field. GitHub answers it to an installation token and refuses a personal one.

  2. Cross-repo probe — send PAT_SCOPE_PROBE to the owner’s PAT_SCOPE_PROBE_LIMIT most recently pushed other repositories. The first one that answers fails the check.

Each probe splits three ways:

  • 2xx: the token covers that repository too. Fail, and name it.

  • HTTP 403: the token is refused there. The check passes once a probe was refused and none answered.

  • Anything else (404, 5xx, network): indeterminate for that repository.

Note

Only a 2xx fails the check, so it never reports a token limited to this repository. A pass is narrower than it reads: it covers the probed repositories, and /users/{owner}/repos lists the public ones only. A token is also refused wherever its account has no admin role, whatever the token covers.

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.

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 only sync-runner-images moves a runner. It rewrites a literal runs-on: when GitHub retires the image or lists a newer version of it.

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.MANPAGES_RENDERER = 'click-extra'

The package whose wrap CLI the manpages release job renders through.

repomatic.lint_repo.MANPAGES_RENDERER_FLOOR = <Version('9')>

Lowest MANPAGES_RENDERER the manpages release job can render with.

9.0.0 moved the roff source from wrap --man to wrap --help-format man and gave --man the paged manual instead, which writes no file. The job runs in the consumer’s own environment, so the floor is a property of that project’s lock rather than of repomatic’s dependencies.

repomatic.lint_repo.check_manpages_toolchain(script, lock_path=None)[source]

Whether a project opting into man pages locks a click-extra that can render.

The manpages job is release-only: it first runs on the commit that tags and publishes, where a failure costs the release its man-page asset for good, since a published release locks its asset list. Reading the floor off uv.lock on every ordinary push is what moves that answer forward of the one run nothing rehearses.

uv.lock is the subject rather than the pyproject.toml floor because the job installs with uv sync --frozen: the lock is what it gets. A project with no lock, or one whose lock never mentions click-extra, is indeterminate rather than failing, the same way a PAT-gated check reports a scope it could not read.

Parameters:
  • script (str) – The [tool.repomatic] manpages.script value.

  • lock_path (Path | None) – Path to uv.lock, defaulting to the repository root’s.

Return type:

CheckResult

Returns:

A CheckResult.

repomatic.lint_repo.missing_template_entries(template, local, prefix='')[source]

Yield the dotted paths a seeded tool section does not carry.

Absence is the only thing reported. A scalar the repository tuned is its own answer, a key it added is none of the template’s business, and a comment it rewrote is invisible here: all three are what BOOTSTRAP hands over on the first write. What that contract cannot express is a knob the template gained afterwards, which reaches nobody because init never revisits the section.

A list is compared by membership, not by equality. An ignore list or a select list grows one entry at a time and its order carries no meaning, so equality would report every local addition as drift.

Parameters:
  • template (Mapping[str, Any]) – The bundled template, parsed.

  • local (Mapping[str, Any]) – The repository’s own section, parsed.

  • prefix (str) – Dotted path of the table being walked, for recursion.

Return type:

Iterator[tuple[str, str | None]]

Returns:

(path, None) per missing key, (path, member) per missing list member, named by template_member_name().

repomatic.lint_repo.template_member_name(member)[source]

Name one member of a template list the way a lint-repo line reads it.

A scalar is its own name. A table has no text of its own, so its name is the inline table that writes it, like { fruit = "apple", ripe = true }. That is the text a maintainer copies into the section, or quotes as the key of a declined entry.

Parameters:

member (object) – One item of a list held by a bundled template.

Return type:

str

Returns:

The member as text, or as inline TOML when it is a table.

repomatic.lint_repo.check_bootstrap_config_drift(tool_table, lint_repo=None)[source]

Report bundled knobs a seeded tool section never received.

ruff, pytest, coverage, mypy and mdformat are BOOTSTRAP components: init writes the template once and hands the section over, so the repository owns every value from then on. That is deliberate, and this check does not argue with it. The gap it closes is narrower: a template that gains a knob after the seeding has no route into the repository and nothing that mentions the difference, so the section silently stops being the configuration repomatic ships.

Advisory, and deliberately so. A repository may have dropped a template rule on purpose, and only its maintainer can tell that from a section left behind.

The maintainer records that answer in declined. A declined entry is reported as skipped, with its reason: the decision stays in the report and the warning stops. The record is checked like the section it speaks for. An entry that declines nothing is reported, since the next gap at its path would otherwise arrive already declined.

A section keeping its keys in the component’s legacy_table is measured there, and the finding names that sub-table. An entry is declined at its path below the tool either way, without the sub-table.

Parameters:
  • tool_table (Mapping[str, Any]) – The repository’s [tool] table, parsed.

  • lint_repo (LintRepoConfig | None) – The repository’s [tool.repomatic.lint-repo] table. Declines nothing when omitted.

Return type:

Iterator[CheckResult]

Returns:

One result per declined entry and one per adopted BOOTSTRAP section, then one per recorded entry that declines nothing.

repomatic.lint_repo.anchored_exclude_core(pattern)[source]

Return the bare host pattern an anchored exclude entry supersedes.

^https://(www\.)?x\.com(/.*)?$ answers x\.com. Anything else answers None: an entry not anchored on both ends replaced no bare form, and one carrying a path between the host and the closing anchor (^https://www\.npmjs\.com/package/.*$) has no bare form that was ever bundled on its own.

Parameters:

pattern (str) – One [tool.lychee] exclude entry.

Return type:

str | None

Returns:

The unanchored host pattern, or None when there is none.

repomatic.lint_repo.check_superseded_local_excludes(tool_table)[source]

Report a local exclude entry the bundled template has since anchored.

An ongoing sync rebuilds a section from its template and grafts local-only content back on, and for an array the graft is a union by value: an item the template does not carry is appended after the ones it does. That is what keeps a repository’s own entries alive across a sync, and it has no inverse. When the template replaces an entry instead of adding one, the old form becomes local-only by the next sync, so it is grafted back beside its own replacement and nothing ever retires it.

Anchoring a bare host pattern is exactly that replacement, and the stale copy is not inert: lychee reads these entries as substring matches, so a bare x\.com kept beside ^https://(www\.)?x\.com(/.*)?$ goes on excluding every domain ending in x.com, cloudlinux.com among them, which is what the anchored form was written to stop doing.

The fix depends on what sits beside the stale entry. Where the anchored form is present, dropping the bare one is the whole fix. A section no sync reaches holds the bare form alone: lychee is an awesome-only component, so another repository keeps a copy nothing rebuilds unless [tool.repomatic] include opts it in. Dropping the entry there would stop excluding the host itself, so the result names the anchored form to write in its place.

Advisory. Only the maintainer can separate a bare entry the template superseded from one the repository narrowed on purpose.

Note

The test is deliberately narrow: it recognises the anchored/bare pair by string equality against the head and tail above, so it catches a template that anchored a host pattern and nothing else. A local entry broadened in some other way is not reported. Widening it means inverting a regex to compare match sets, which is not worth a dependency for one signature that already reached every repository the bundled lychee.toml deploys to.

Parameters:

tool_table (Mapping[str, Any]) – The repository’s [tool] table, parsed.

Return type:

Iterator[CheckResult]

Returns:

One result per synced tool config holding exclude patterns.

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. init realigns the inline pin whenever it writes the refs, so the two drift apart only when a ref is edited by hand, until the next sync-workflow-pins run. 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 with a pinned hash only for the versions listed in the checksum table its own release bundles. Anything else installs with no verification before v10.1.0, and against a hash fetched at run time from v10.1.0 on (see setup_uv_verified_versions()). So a repository can hold two perfectly good pins that together pin no hash, 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 pinned hash. sync-workflow-pins repairs it by stepping the uv pin back onto the table, and a sync-action-pins bump lets the pin move forward again (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 the metadata subcommand.

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 show-metadata … and the downstream uvx 'repomatic==1.2.3' show-metadata …, by looking for the subcommand after any token naming the package. The deprecated metadata spelling is read too, for job bodies that still call it.

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 opt out of the attribution footer the way footer_opted_out() reads it. An absent field, 'False', and every other value keep the footer, and the failure is silent: the rendered body carries it 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='', site_cloudflare_r2_bucket='', project_description=None, docs_url=None, keywords=None, tool_table=<factory>, lint_repo=<factory>, repo=None, has_pat=False, has_virustotal_key=False, has_cloudflare_api_token=False, has_cloudflare_r2_keys=False, nuitka_active=False, has_notifications_pat=False, unsubscribe_active=False, manpages_script='', declared_labels=frozenset({}))[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.

site_cloudflare_r2_bucket: str = ''

R2 bucket serving the files over the Pages limit, per site.cloudflare-r2-bucket. Empty means the deploy drops them.

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.

tool_table: Mapping[str, Any]

The [tool] table of pyproject.toml, parsed.

lint_repo: LintRepoConfig

What the repository told the checks, per [tool.repomatic.lint-repo].

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.

has_cloudflare_r2_keys: bool = False

Whether CLOUDFLARE_R2_ACCESS_KEY_ID and CLOUDFLARE_R2_SECRET_ACCESS_KEY are both 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.

manpages_script: str = ''

Click target the manpages release job renders, per manpages.script.

Empty means the project never opted in, which is what check_manpages_toolchain() gates on.

declared_labels: frozenset[str] | None = frozenset({})

Every label name the configured sources declare, per sync-labels.

Resolved here rather than inside check_undeclared_labels() so the check reads its expectation from the same place the sync writes it. None when a label definition could not be fetched or read: the set is then unknown rather than short, and the check reports skipped.

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 oversized_files: list[Path][source]

Tracked files over the Cloudflare Pages limit, .gitignore honoured.

Built trees stay out, like the _redirects inventory above: the deploy step reads those, and it names each file it moves or drops.

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_cloudflare_r2_keys=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_cloudflare_r2_keys (bool) – Whether both R2 upload secrets are configured.

  • has_notifications_pat (bool) – Whether REPOMATIC_NOTIFICATIONS_PAT is configured.

Return type:

LintContext

class repomatic.lint_repo.RepoCheck(name, run, summary, description, 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.

summary: str

What a passing repository looks like, as one clause for lint-repo --help.

Leave out the severity: help_entry derives it from fatal.

description: str

What the check reports and why, as its bullet in the lint-repo job docs.

Markdown, rendered on docs/workflows.md by repo_check_reference() in roster order, so a link is relative to that page. It opens on Fails for a fatal check and on Warns or Reports for any other, and carries no closing period, like every job bullet on that page.

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. A red run for an advisory finding teaches its readers to ignore red runs, and that hides the failures that count.

results(ctx)[source]

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

Return type:

tuple[CheckResult, ...]

property level: AnnotationLevel

The severity a failure reports at, which fatal selects.

property help_entry: str

This check’s item in the lint-repo --help list, wrapped for a terminal.

repomatic.lint_repo.REPO_CHECKS: tuple[RepoCheck, ...] = (RepoCheck(name='package-name-vs-repo', run=<function <lambda>>, summary='Package name matches the repository name', description='Warns when the package name differs from the repository name', applies=<function <lambda>>, fatal=False), RepoCheck(name='website-for-sphinx', run=<function <lambda>>, summary="A Sphinx project's website field names the documentation URL declared in [project.urls]", description='Warns when a Sphinx project\'s GitHub website field does not name the documentation URL it declares in `[project.urls]` (`Documentation`, then `Docs`). A trailing slash and the case of the scheme and host are ignored, since GitHub stores the website field with the slash a browser appends. Moving a documentation site to a new domain is what this catches: Sphinx renders `<link rel="canonical">` from `html_baseurl`, so every published page names the new origin while the repository sidebar keeps sending visitors to the old one. A project declaring no documentation URL keeps the presence-only check', applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-deployment-source', run=<function <lambda>>, summary='GitHub Pages publishes from GitHub Actions, when site.deploy targets GitHub Pages', description='Warns when a Sphinx project deploying to GitHub Pages (the `site.deploy` default) has the Pages source set to `Deploy from a branch`. The `docs.yaml` deploy job publishes with `actions/deploy-pages`, which needs the Pages source set to `GitHub Actions`', applies=<function <lambda>>, fatal=False), RepoCheck(name='cloudflare-pages-secrets', run=<function _cloudflare_secrets>, summary='CLOUDFLARE_API_TOKEN secret set, when site.deploy targets Cloudflare Pages', description='Warns about a missing `CLOUDFLARE_API_TOKEN` when `site.deploy` targets Cloudflare Pages. The token is the whole of the credential: the account is derived from it at run time', applies=<function <lambda>>, fatal=False), RepoCheck(name='cloudflare-r2-secrets', run=<function _cloudflare_r2_secrets>, summary='CLOUDFLARE_R2_ACCESS_KEY_ID and CLOUDFLARE_R2_SECRET_ACCESS_KEY secrets set, when site.cloudflare-r2-bucket is declared', description='Warns about a missing `CLOUDFLARE_R2_ACCESS_KEY_ID` or `CLOUDFLARE_R2_SECRET_ACCESS_KEY` when `site.deploy` targets Cloudflare Pages and `site.cloudflare-r2-bucket` is declared', applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirect-preserved', run=<function <lambda>>, summary='Legacy github.io URLs still redirect, for a site moved to Cloudflare Pages', description='Warns when a site that moved to Cloudflare Pages no longer keeps its old `github.io` URLs redirecting: `[project.urls]` still naming the `github.io` host, GitHub Pages disabled, or its custom domain unset or naming another host ([details](cloudflare.md#leaving-github-pages-behind-urls-intact))', applies=<function <lambda>>, fatal=False), RepoCheck(name='pages-redirects', run=<function _pages_redirects>, summary='Committed _redirects files survive the Cloudflare Pages engine: no dropped rules, no silent budget abort', description="Fails when a committed `_redirects` file would lose rules to the Cloudflare Pages engine's undocumented budget accounting, or holds a rule that can never apply as written, since such a rule is silently dead in production ([details](cloudflare.md#the-redirects-engine-as-it-actually-is))", applies=<function <lambda>>, fatal=True), RepoCheck(name='wrangler-toml', run=<function _wrangler_config>, summary='wrangler.toml agrees with the declared Cloudflare project name and compatibility date', description='Warns when a committed `wrangler.toml` contradicts the declared Cloudflare project name or compatibility date', applies=<function <lambda>>, fatal=False), RepoCheck(name='oversized-site-files', run=<function _oversized_site_files>, summary='No tracked file over the 25 MiB Cloudflare Pages limit, unless an R2 bucket is declared to serve it', description='Warns about a tracked file over the 25 MiB Cloudflare Pages limit when `site.deploy` targets Cloudflare Pages and no R2 bucket is declared', applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-gh-pages-branch', run=<function <lambda>>, summary='No leftover gh-pages branch in a Sphinx project', description='Warns when a Sphinx project still has a `gh-pages` branch. Pages deploys through GitHub Actions, so the branch is no longer needed', applies=<function <lambda>>, fatal=False), RepoCheck(name='description-matches', run=<function <lambda>>, summary='Repository description matches the project description', description='Fails when the repository description differs from the project description', applies=<function <lambda>>, fatal=True), RepoCheck(name='topics-subset-of-keywords', run=<function <lambda>>, summary='GitHub topics are a subset of the pyproject.toml keywords', description='Warns when a GitHub topic matches no `[project] keywords` entry. A keyword matches the topic GitHub would store for it: lowercased, with each run of whitespace replaced by a hyphen, so `CLI` declares the `cli` topic and `Weather forecast` declares `weather-forecast`', applies=<function <lambda>>, fatal=False), RepoCheck(name='funding-file', run=<function <lambda>>, summary='Funding file present when the owner has GitHub Sponsors', description='Warns when the repository owner has a GitHub Sponsors listing but `.github/funding.yml` is missing, so the repository shows no Sponsor button. Forks are skipped', applies=<function <lambda>>, fatal=False), RepoCheck(name='stale-draft-releases', run=<function <lambda>>, summary='No stale draft release, whose tag is not a .dev0', description='Warns about draft releases whose tag does not end in `.dev0`. Those are leftovers from an abandoned or failed release: the only expected drafts are the rolling dev pre-releases that `sync-dev-release` manages', applies=<function <lambda>>, fatal=False), RepoCheck(name='undeclared-labels', run=<function <lambda>>, summary='Every repository label is declared by a configured label source', description="Warns when the repository carries a label that no configured source (the labeller's bundled `labels.toml` profiles, a hand-written or downloaded `extra-labels/` file, the inline `labels.extra` block) declares. `sync-labels` runs `labelmaker apply`, which creates, updates and renames labels but never deletes one, so a label dropped from a source keeps existing on GitHub with nothing left to mention it again. Advisory only: deleting a label detaches every issue and pull request carrying it, and only the count of what each orphan holds tells a hand-made label from one still in active use. Extra label files are read in every format `labelmaker` accepts (JSON, TOML, YAML, and JSON5 as far as its plain-JSON subset), and a file that cannot be fetched or read skips the check rather than reporting its labels as orphans", applies=<function <lambda>>, fatal=False), RepoCheck(name='install-guide-downloads', run=<function <lambda>>, summary='Install guide download URLs resolve to real release assets', description='Warns when a release download URL in `docs/install.md` names a file its release does not carry. The release freeze pins those URLs before the binaries exist, so a failed build lane leaves the guide advertising 404s until the next release moves past it: this is the check that surfaces the gap instead of leaving it for a user to hit. Versionless `releases/latest/download` URLs are checked against the latest published release too, and rot longer: nothing rewrites them at release time, so a renamed asset leaves one pointing at a 404 indefinitely', applies=<function <lambda>>, fatal=False), RepoCheck(name='tag-protection-rules', run=<function <lambda>>, summary='No active tag ruleset that can block a release tag push', description='Warns when an active ruleset targets tags, including one inherited from a parent level. Such a ruleset can stop the `create-tag` job from pushing release tags unless `REPOMATIC_PAT` is in its bypass list', applies=<function <lambda>>, fatal=False), RepoCheck(name='branch-ruleset-on-default', run=<function <lambda>>, summary='At least one active branch ruleset', description='Warns when the repository has no active branch ruleset, which the check reads as a default branch open to deletion and force pushes. It does not verify that a ruleset targets the default branch itself ([details](security.md#supply-chain-security))', applies=<function <lambda>>, fatal=False), RepoCheck(name='classic-branch-protection', run=<function <lambda>>, summary='No classic branch protection rule beside the rulesets', description='Warns when a classic branch protection rule survives beside the rulesets. GitHub applies both together, so the branch policy splits across two settings pages ([details](security.md#supply-chain-security))', applies=<function <lambda>>, fatal=False), RepoCheck(name='immutable-releases', run=<function <lambda>>, summary='Immutable releases enabled, for a package', description="Warns when a package's repository has immutable releases disabled. That setting locks the tag and assets of a published release (see [§ Immutable releases](#immutable-releases))", applies=<function <lambda>>, fatal=False), RepoCheck(name='fork-pr-approval-policy', run=<function <lambda>>, summary='Fork PR workflow approval policy strict enough', description='Warns when the fork PR workflow approval policy is weaker than `first_time_contributors`', applies=<function <lambda>>, fatal=False), RepoCheck(name='sha-pinning-required', run=<function <lambda>>, summary='Actions required to be pinned to a full-length commit SHA', description='Warns when the repository does not require actions to be pinned to a full-length commit SHA (`sha_pinning_required`). With the setting on, GitHub itself refuses to run a workflow that names an action by a mutable tag or branch. This backs up `zizmor`, whose findings can be silenced inline ([details](security.md#supply-chain-security))', applies=<function <lambda>>, fatal=False), RepoCheck(name='pypi-trusted-publisher', run=<function <lambda>>, summary="The latest PyPI release's provenance names this repository's release.yaml", description="Warns when the latest PyPI release of a package carries PEP 740 provenance naming a repository or workflow other than this repository's `release.yaml`, which means the Trusted Publisher entry is registered wrongly. A package with no release yet, or no provenance, is reported as skipped ([details](security.md#supply-chain-security))", applies=<function <lambda>>, fatal=False), RepoCheck(name='workflow-permissions', run=<function <lambda>>, summary='Workflows and reusable-workflow callers declare their permissions', description='Warns when a workflow with steps of its own has no top-level `permissions` key. Also warns when a job calling a reusable workflow under `permissions: {}` declares no `permissions:` of its own: GitHub then aborts the run at startup once a nested job requests a scope the caller never granted', applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='test-matrix-excludes', run=<function <lambda>>, summary='Every test-matrix exclude entry names a value on some matrix axis', description='Warns when a `[tool.repomatic.test-matrix] exclude` entry names a value found on no matrix axis, like a renamed runner. Such an entry never matches a combination, so the matrix drops it without a message ([details](test-matrix.md#guard-the-matrix-with-a-test))', applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='python-version-consistency', run=<function <lambda>>, summary='Python classifiers, requires-python and the workflow test matrices agree', description='Warns when the lowest `Programming Language :: Python :: X.Y` classifier differs from the `requires-python` floor. Also warns when a workflow test matrix listing `python-version` values literally omits either end of the advertised range, or tests a released version the classifiers do not advertise', applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='runner-images', run=<function <lambda>>, summary='No job runs on a -latest alias or on an image outside the test matrices', description='Warns when a job runs on a `-latest` alias, which GitHub repoints without a commit to review, or on an image outside the test matrix axes. A `runs-on:` built from an expression is left alone ([details](test-matrix.md#github-hosted-runner-inventory))', applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='release-path', run=<function <lambda>>, summary='Each release-only step tests the capability it needs, and runs on a release commit', description="Reports which release-only steps a release commit would run or skip for a Python project, so a missing capability shows on an ordinary push instead of on release day. Where the reusable workflows live, warns when a step's `if:` never tests the capability it needs", applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='manpages-toolchain', run=<function <lambda>>, summary='The manpages.script lockfile pins a click-extra able to render man pages', description='Warns when a project setting `[tool.repomatic] manpages.script` locks a `click-extra` older than `9`. The `manpages` release job renders with `click-extra wrap --help-format man`, an invocation `9.0.0` introduced, and the job first runs on the commit that tags and publishes. Since a published release locks its asset list, a failure there costs that version its man pages for good rather than being repairable afterwards. The subject is `uv.lock`, which is what `uv sync --frozen` installs; a project with no lockfile, or one whose lockfile never mentions `click-extra`, is reported as skipped', applies=<function <lambda>>, fatal=False), RepoCheck(name='bootstrap-config-drift', run=<function <lambda>>, summary='Seeded tool configs carry every bundled template entry, or decline it with a reason', description='Warns when a tool config seeded once (`[tool.ruff]`, `[tool.pytest]`, `[tool.coverage]`, `[tool.mypy]`, `[tool.mdformat]`) lacks an entry its bundled template carries. `init` writes those sections on the first run and hands them over, so a rule the template gains later reaches no repository and nothing names the gap. Only absence is reported, keys and list members alike: a value the repository tuned, a key it added and a comment it rewrote are all its own. A pytest section that keeps its keys under `[tool.pytest.ini_options]` is measured there, since pytest refuses a file holding both that table and the native keys. Advisory, since a template rule dropped on purpose looks identical to one never received, and only the maintainer can tell them apart. An entry named under `[tool.repomatic] lint-repo.declined` is reported with its reason in place of the warning, and an entry there that declines nothing is warned about in its turn', applies=<function <lambda>>, fatal=False), RepoCheck(name='superseded-local-excludes', run=<function <lambda>>, summary='No bare [tool.lychee] exclude entry the bundled template now anchors', description='Warns when a `[tool.lychee] exclude` entry is the bare form of one the bundled template has since anchored. An ongoing sync grafts a local-only array item back on and retires nothing, so a pattern the template *replaced* rather than added survives beside its own replacement: lychee matches these as substrings, and a bare `x\\.com` kept beside `^https://(www\\.)?x\\.com(/.*)?$` goes on excluding every domain ending in `x.com`, which is what the anchoring was written to stop. A section no sync reaches holds the bare form alone, so there the warning names the anchored form to write in its place rather than advising a drop. Advisory, since only the maintainer can tell a superseded entry from one the repository narrowed on purpose', applies=<function <lambda>>, fatal=False), RepoCheck(name='inline-pins-match-upstream', run=<function <lambda>>, summary='Inline upstream pins match the version the uses: refs name', description="Fails when a workflow's inline upstream pin (like `uvx 'repomatic==X.Y.Z'`) names a different version than its `uses:` refs. A stale pin can lack a symbol the newer refs rely on: the `metadata` job then fails, and a release can reach PyPI without a tag", applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='self-pin-cooldown-exemption', run=<function <lambda>>, summary='Inline upstream pins resolving under a cooldown carry their --exclude-newer-package exemption', description="Fails when a workflow's inline upstream pin (like `uvx 'repomatic==X.Y.Z'`) resolves under a cooldown but carries no `--exclude-newer-package` exemption beside it, checked only in a workflow that sets `UV_EXCLUDE_NEWER` at all. `uvx` reads no project configuration, so the flag on the command line is the only place the bypass can live: without it, a pin naming a release younger than the window cannot resolve, and since the pin usually sits in the `metadata` job with every other job `needs: metadata`, the whole workflow fails at its first job while executing nothing. `sync-workflow-pins` backfills the flag on its next run, but a repository already pinned at the newest release never triggers that backfill on its own, which is what this check catches. The sharper of the two fatal pin checks, since the pin it guards takes every `needs: metadata` job down with it", applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='setup-uv-version-pin', run=<function <lambda>>, summary='Every astral-sh/setup-uv step pins one uv version', description='Warns when an `astral-sh/setup-uv` step declares no `version:` input, or when steps across the repository pin more than one uv version. `[tool.uv] required-version` is only a floor; left unpinned, `setup-uv` installs whatever uv release is newest the moment the job runs, seconds after publication, making the one tool that enforces every cooldown the one tool carrying none of its own', applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='setup-uv-checksum-coverage', run=<function <lambda>>, summary='The pinned uv carries a checksum in the pinned astral-sh/setup-uv', description="Warns when the pinned uv is absent from the checksum table bundled into the pinned `astral-sh/setup-uv` release. That table holds the only pinned hashes `setup-uv` verifies a download against, and a version missing from it is not refused. Before `v10.1.0` it installs unverified, on a debug line no CI log shows by default. From `v10.1.0` on it is checked against a hash the action fetches at run time, which no pin fixes. Since uv ships weekly against the action's monthly cadence, and `sync-action-pins` and `sync-workflow-pins` walk the two pins independently, a repository drifts into holding two perfectly good pins that together pin no hash. `sync-workflow-pins` repairs it by stepping the uv pin back onto the table, and a `sync-action-pins` bump lets the pin move forward again. Reported as skipped rather than failed when the table cannot be read", applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='metadata-keys', run=<function <lambda>>, summary='Workflows only ask repomatic show-metadata for keys it still emits', description="Fails when a workflow's `run:` line asks `repomatic show-metadata` for a key that no longer exists, reading the invocation the way Click does so an option's value is never mistaken for a positional key. `repomatic init` syncs a header-only workflow's header and its `uses:` pins and leaves the job bodies to the repository, so a key retired upstream stays in a `run:` line nothing sweeps. The command answers an unknown key with a `UsageError`, and every job reaching the metadata job through `needs:` dies with it, which turns a retired key into a whole workflow failing at its first job on the next push. Fatal, like the inline-pin checks above: all three describe a workflow that is already broken rather than one that might age badly", applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pr-templates', run=<function <lambda>>, summary='Repository-local PR body templates sit in .github/pr-templates/ and carry valid frontmatter', description="Warns when a repository-local `pr-body --template-file` template sits outside `.github/pr-templates/`, does not exist, or has frontmatter without a `title` or that keeps the attribution footer. Both `footer: false` and the quoted `footer: 'false'` opt out. A template that keeps the footer renders it twice ([details](operation-contracts.md#repository-local-templates))", applies=<function RepoCheck.<lambda>>, fatal=False), RepoCheck(name='virustotal-secret', run=<function _virustotal_secret>, summary='VIRUSTOTAL_API_KEY secret set, when Nuitka is active', description='Warns about a missing `VIRUSTOTAL_API_KEY` when Nuitka binary compilation is active', applies=<function <lambda>>, fatal=False), RepoCheck(name='notifications-pat-secret', run=<function _notifications_secret>, summary='REPOMATIC_NOTIFICATIONS_PAT secret set, when the unsubscribe workflow is enabled', description='Warns about a missing `REPOMATIC_NOTIFICATIONS_PAT` when the unsubscribe workflow is enabled', applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-permissions', run=<function _pat_permissions>, summary='With a PAT, the administration, contents, issues, pull requests, Dependabot alerts and workflows permissions of REPOMATIC_PAT', description='Fails when a configured `REPOMATIC_PAT` lacks one of the permissions the automation needs: administration, contents, issues, pull requests, Dependabot alerts and workflows', applies=<function RepoCheck.<lambda>>, fatal=True), RepoCheck(name='pat-repository-scope', run=<function <lambda>>, summary='With a PAT, a REPOMATIC_PAT limited to this repository', description="Warns when a configured `REPOMATIC_PAT` also has access to another repository of the same owner. The check asks the owner's most recently pushed repositories for their Actions settings, which only a token covering them can read. The setup guide issue stays open on the same finding", applies=<function <lambda>>, fatal=False), RepoCheck(name='pat-stale-statuses-permission', run=<function <lambda>>, summary='With a PAT, a REPOMATIC_PAT without the Commit statuses permission', description='Warns when a configured `REPOMATIC_PAT` still grants the `Commit statuses` permission, which `repomatic` no longer uses. The probe posts a status to a SHA that resolves to no commit, so it writes nothing', 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.REPO_CHECK_HELP_LIST: str = "  - Package name matches the repository name (warning).\n  - A Sphinx project's website field names the documentation URL declared in\n    [project.urls] (warning).\n  - GitHub Pages publishes from GitHub Actions, when site.deploy targets\n    GitHub Pages (warning).\n  - CLOUDFLARE_API_TOKEN secret set, when site.deploy targets Cloudflare\n    Pages (warning).\n  - CLOUDFLARE_R2_ACCESS_KEY_ID and CLOUDFLARE_R2_SECRET_ACCESS_KEY secrets\n    set, when site.cloudflare-r2-bucket is declared (warning).\n  - Legacy github.io URLs still redirect, for a site moved to Cloudflare\n    Pages (warning).\n  - Committed _redirects files survive the Cloudflare Pages engine: no\n    dropped rules, no silent budget abort (error).\n  - wrangler.toml agrees with the declared Cloudflare project name and\n    compatibility date (warning).\n  - No tracked file over the 25 MiB Cloudflare Pages limit, unless an R2\n    bucket is declared to serve it (warning).\n  - No leftover gh-pages branch in a Sphinx project (warning).\n  - Repository description matches the project description (error).\n  - GitHub topics are a subset of the pyproject.toml keywords (warning).\n  - Funding file present when the owner has GitHub Sponsors (warning).\n  - No stale draft release, whose tag is not a .dev0 (warning).\n  - Every repository label is declared by a configured label source\n    (warning).\n  - Install guide download URLs resolve to real release assets (warning).\n  - No active tag ruleset that can block a release tag push (warning).\n  - At least one active branch ruleset (warning).\n  - No classic branch protection rule beside the rulesets (warning).\n  - Immutable releases enabled, for a package (warning).\n  - Fork PR workflow approval policy strict enough (warning).\n  - Actions required to be pinned to a full-length commit SHA (warning).\n  - The latest PyPI release's provenance names this repository's\n    release.yaml (warning).\n  - Workflows and reusable-workflow callers declare their permissions\n    (warning).\n  - Every test-matrix exclude entry names a value on some matrix axis\n    (warning).\n  - Python classifiers, requires-python and the workflow test matrices agree\n    (warning).\n  - No job runs on a -latest alias or on an image outside the test matrices\n    (warning).\n  - Each release-only step tests the capability it needs, and runs on a\n    release commit (warning).\n  - The manpages.script lockfile pins a click-extra able to render man pages\n    (warning).\n  - Seeded tool configs carry every bundled template entry, or decline it\n    with a reason (warning).\n  - No bare [tool.lychee] exclude entry the bundled template now anchors\n    (warning).\n  - Inline upstream pins match the version the uses: refs name (error).\n  - Inline upstream pins resolving under a cooldown carry their\n    --exclude-newer-package exemption (error).\n  - Every astral-sh/setup-uv step pins one uv version (warning).\n  - The pinned uv carries a checksum in the pinned astral-sh/setup-uv\n    (warning).\n  - Workflows only ask repomatic show-metadata for keys it still emits\n    (error).\n  - Repository-local PR body templates sit in .github/pr-templates/ and\n    carry valid frontmatter (warning).\n  - VIRUSTOTAL_API_KEY secret set, when Nuitka is active (warning).\n  - REPOMATIC_NOTIFICATIONS_PAT secret set, when the unsubscribe workflow is\n    enabled (warning).\n  - With a PAT, the administration, contents, issues, pull requests,\n    Dependabot alerts and workflows permissions of REPOMATIC_PAT (error).\n  - With a PAT, a REPOMATIC_PAT limited to this repository (warning).\n  - With a PAT, a REPOMATIC_PAT without the Commit statuses permission\n    (warning)."

The lint-repo --help check list, one item per REPO_CHECKS entry.

Rendered from the roster, so the help cannot name fewer checks than the command runs.

repomatic.lint_repo.repo_check_reference()[source]

The lint-repo job’s check list for docs/workflows.md, as Markdown.

One bullet per REPO_CHECKS entry, from its description, in report order. Rendered from the roster for the same reason as REPO_CHECK_HELP_LIST: the page cannot describe fewer checks than the command runs.

Return type:

str

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