Source code for repomatic.lint_repo.roster

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

"""The `lint-repo` check roster, in report order.

Each {class}`RepoCheck` declares one check once: its name, the function that
runs it, when it applies, and whether its failure fails the command. The
`lint-repo --help` text and the `lint-repo` job docs render from the roster, so
neither can list fewer checks than the command runs.
"""

from __future__ import annotations

import textwrap
from dataclasses import dataclass

from ..checks import CheckResult
from ..components import SyncMode
from ..github.outputs import AnnotationLevel
from ..github.token import PAT_PERMISSION_PROBES
from .pins import (
    check_inline_pins_match_upstream,
    check_manpages_toolchain,
    check_registry_pin_cooldown,
    check_self_pin_cooldown_exemption,
    check_setup_uv_checksum_coverage,
    check_setup_uv_version_pin,
)
from .repo import (
    check_description_matches,
    check_funding_file,
    check_install_guide_downloads,
    check_package_name_vs_repo,
    check_stale_draft_releases,
    check_topics_subset_of_keywords,
    check_undeclared_labels,
    check_website_for_sphinx,
)
from .settings import (
    check_branch_ruleset_on_default,
    check_classic_branch_protection,
    check_fork_pr_approval_policy,
    check_immutable_releases,
    check_notifications_secret,
    check_pat_permissions,
    check_pat_scope,
    check_pypi_trusted_publisher,
    check_sha_pinning_required,
    check_tag_protection_rules,
    check_virustotal_secret,
)
from .site import (
    PAGES_LIMIT_LABEL,
    check_cloudflare_r2_secrets,
    check_cloudflare_secrets,
    check_oversized_site_files,
    check_pages_deployment_source,
    check_pages_redirect_preserved,
    check_pages_redirects,
    check_stale_gh_pages_branch,
    check_wrangler_config,
)
from .tool_config import check_bootstrap_config_drift, check_superseded_local_excludes
from .workflows import (
    check_metadata_keys,
    check_pr_templates,
    check_python_version_consistency,
    check_release_path,
    check_runner_images,
    check_test_matrix_excludes,
    check_thin_caller_pins,
    check_thin_caller_secrets,
    check_thin_caller_triggers,
    check_workflow_dispatch,
    check_workflow_permissions,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Iterator

    from .context import LintContext


[docs] @dataclass(frozen=True) class RepoCheck: """One entry of the `lint-repo` check sequence. Declaring each check once makes the roster the thing tests and readers walk: the order, the gate and the severity of every check sit in one table. """ 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: {attr}`help_entry` derives it from {attr}`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 {func}`repo_check_reference` in roster order, so a link is relative to that page. It opens on `Fails` for a {attr}`fatal` check and on `Warns` or `Reports` for any other, and carries no closing period, like every job bullet on that page. """ applies: Callable[[LintContext], bool] = lambda ctx: True """Whether this repository has anything for the check to look at. A check reading the GitHub API applies only when the context names a repository: it reads the slug through {attr}`~repomatic.lint_repo.context.LintContext.slug`. """ fatal: bool = False """Whether a failure fails the command. A fatal check reports at {attr}`~repomatic.github.outputs.AnnotationLevel.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. """
[docs] def results(self, ctx: LintContext) -> tuple[CheckResult, ...]: """Run the check, normalizing one-or-many into a tuple.""" produced = self.run(ctx) if isinstance(produced, CheckResult): return (produced,) return tuple(produced)
[docs] def fails(self, result: CheckResult) -> bool: """Whether *result* fails the command, which only a fatal check can do.""" return self.fatal and result.passed is False
@property def level(self) -> AnnotationLevel: """The severity a failure reports at, which {attr}`fatal` selects.""" return AnnotationLevel.ERROR if self.fatal else AnnotationLevel.WARNING @property def help_entry(self) -> str: """This check's item in the `lint-repo --help` list, wrapped for a terminal.""" # Neither break may split a token: `--exclude-newer-package` and # `astral-sh/setup-uv` must stay whole to stay searchable. return textwrap.fill( f"{self.summary} ({self.level.value}).", width=76, initial_indent=" - ", subsequent_indent=" ", break_long_words=False, break_on_hyphens=False, )
def _enumerate(names: Iterable[str]) -> str: """Join *names* the way a sentence lists them: `a, b and c`.""" items = list(names) if len(items) < 2: return "".join(items) return f"{', '.join(items[:-1])} and {items[-1]}" PAT_PERMISSION_NAMES = _enumerate(probe.name for probe in PAT_PERMISSION_PROBES) """The permissions the PAT probes cover, listed for the `pat-permissions` entry.""" BOOTSTRAP_SECTIONS = ", ".join( f"`[{comp.tool_section}]`" for comp in SyncMode.BOOTSTRAP.components() ) """The tool sections `init` seeds once, for the `bootstrap-config-drift` entry.""" REPO_CHECKS: tuple[RepoCheck, ...] = ( RepoCheck( "package-name-vs-repo", check_package_name_vs_repo, summary="Package name matches the repository name", description="Warns when the package name differs from the repository name", applies=lambda ctx: bool(ctx.package_name and ctx.repo_name), ), RepoCheck( "website-for-sphinx", check_website_for_sphinx, 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=lambda ctx: bool(ctx.is_sphinx and ctx.repo), ), RepoCheck( "pages-deployment-source", check_pages_deployment_source, 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=lambda ctx: bool(ctx.repo and ctx.deploys_to("github-pages")), ), RepoCheck( # Needs no repository: both answers come from the workflow environment, # so a local run reports the same gap CI does. Gated on the declared # target alone, not on Sphinx: a repository building its site with its # own workflow needs the same credential. "cloudflare-pages-secrets", check_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=lambda ctx: ctx.deploys_to("cloudflare-pages"), ), RepoCheck( # The key pair only matters to a site that declared a bucket: without # one, the deploy drops oversized files by design and reads no key. "cloudflare-r2-secrets", check_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=lambda ctx: bool( ctx.deploys_to("cloudflare-pages") and ctx.config.site_cloudflare_r2_bucket ), ), RepoCheck( # The mirror image of `pages-deployment-source` above: that one asks # the GitHub Pages host to publish, this one asks it to redirect. Only # a repository that moved away needs the second question answered. "pages-redirect-preserved", check_pages_redirect_preserved, 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=lambda ctx: bool(ctx.repo and ctx.deploys_to("cloudflare-pages")), ), RepoCheck( # Fatal: a rule the engine drops is already dead in production, not one # that might age badly, and the deploy pipeline reports nothing about # it. Gated on the file rather than on the deploy target, since a # `_redirects` only means anything to Cloudflare Pages anyway. "pages-redirects", check_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=lambda ctx: bool(ctx.redirects_files), fatal=True, ), RepoCheck( "wrangler-toml", check_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=lambda ctx: bool( ctx.deploys_to("cloudflare-pages") and ctx.has_wrangler_toml ), ), RepoCheck( "oversized-site-files", check_oversized_site_files, summary=( f"No tracked file over the {PAGES_LIMIT_LABEL} Cloudflare Pages limit," " unless an R2 bucket is declared to serve it" ), description=( f"Warns about a tracked file over the {PAGES_LIMIT_LABEL} Cloudflare Pages" " limit when `site.deploy` targets Cloudflare Pages and no R2 bucket is" " declared" ), applies=lambda ctx: ctx.deploys_to("cloudflare-pages"), ), RepoCheck( "stale-gh-pages-branch", check_stale_gh_pages_branch, 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=lambda ctx: bool(ctx.is_sphinx and ctx.repo), ), RepoCheck( "description-matches", check_description_matches, summary="Repository description matches the project description", description=( "Fails when the repository description differs from the project description" ), applies=lambda ctx: bool(ctx.project_description and ctx.repo), fatal=True, ), RepoCheck( "topics-subset-of-keywords", check_topics_subset_of_keywords, 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=lambda ctx: bool(ctx.keywords and ctx.repo), ), RepoCheck( "funding-file", check_funding_file, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "stale-draft-releases", check_stale_draft_releases, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "undeclared-labels", check_undeclared_labels, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "install-guide-downloads", check_install_guide_downloads, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "tag-protection-rules", check_tag_protection_rules, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "branch-ruleset-on-default", check_branch_ruleset_on_default, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( "classic-branch-protection", check_classic_branch_protection, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( # Gated on the changelog the setup guide gates the same step on: the # release workflow publishes a GitHub release for any project with one, # Python package or not. "immutable-releases", check_immutable_releases, summary="Immutable releases enabled, for a repository publishing releases", description=( "Warns when a repository that publishes GitHub releases, which means one" " holding a changelog, has immutable releases disabled. That setting locks" " the tag and assets of a published release (see [§ Immutable" " releases](#immutable-releases))" ), applies=lambda ctx: bool(ctx.repo and ctx.has_changelog), ), RepoCheck( "fork-pr-approval-policy", check_fork_pr_approval_policy, summary="Fork PR workflow approval policy strict enough", description=( "Warns when the fork PR workflow approval policy is weaker than" " `first_time_contributors`" ), applies=lambda ctx: bool(ctx.repo), ), RepoCheck( "sha-pinning-required", check_sha_pinning_required, 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=lambda ctx: bool(ctx.repo), ), RepoCheck( # Gated on `is_package`, not on `package_name`: a uv virtual project # sets a `[project] name` to carry dependencies and never publishes it, # so matching that name against PyPI reports on a project someone else # owns. "pypi-trusted-publisher", check_pypi_trusted_publisher, 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=lambda ctx: bool(ctx.repo and ctx.package_name and ctx.is_package), ), RepoCheck( "workflow-permissions", check_workflow_permissions, 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" ), ), RepoCheck( "workflow-dispatch", check_workflow_dispatch, summary="Every standalone workflow can be started by hand", description=( "Warns when a workflow that neither calls an upstream reusable workflow" " nor is reusable itself has no `workflow_dispatch` trigger, so a" " maintainer cannot rerun it without a push" ), ), RepoCheck( "thin-caller-pins", check_thin_caller_pins, summary="Thin callers pin the upstream workflows to a release, not to @main", description=( "Warns when a job calling an upstream reusable workflow references it at" " `@main`, which runs whatever the upstream default branch holds when the" " job starts instead of a release" ), ), RepoCheck( "thin-caller-triggers", check_thin_caller_triggers, summary="Thin callers carry the triggers of the workflow they call", description=( "Warns when a thin caller lacks a trigger the upstream workflow it calls" " declares, or adds one it does not. An upstream workflow callable only" " through `workflow_call` leaves its caller free to pick its own" ), ), RepoCheck( "thin-caller-secrets", check_thin_caller_secrets, summary="Thin callers pass every secret the workflow they call declares", description=( "Warns when the job of a thin caller that calls its upstream workflow" " forwards neither `secrets: inherit` nor every secret that workflow" " declares. The secrets are read off that job alone, so another job of" " the same file, like the build lane of a downstream `release.yaml`," " cannot vouch for it" ), ), RepoCheck( "test-matrix-excludes", check_test_matrix_excludes, 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))" ), ), RepoCheck( "python-version-consistency", check_python_version_consistency, 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" ), ), RepoCheck( "runner-images", check_runner_images, 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))" ), ), RepoCheck( "release-path", check_release_path, 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" ), ), RepoCheck( "manpages-toolchain", check_manpages_toolchain, 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=lambda ctx: bool(ctx.config.manpages_script), ), RepoCheck( "bootstrap-config-drift", check_bootstrap_config_drift, summary=( "Seeded tool configs carry every bundled template entry, or decline it" " with a reason" ), description=( f"Warns when a tool config seeded once ({BOOTSTRAP_SECTIONS}) 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=lambda ctx: bool(ctx.tool_table), ), RepoCheck( "superseded-local-excludes", check_superseded_local_excludes, 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=lambda ctx: bool(ctx.tool_table), ), RepoCheck( "inline-pins-match-upstream", check_inline_pins_match_upstream, 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" ), fatal=True, ), # Fatal for the same reason as the pin check above: both describe a # workflow that is already broken, not one that might age badly. The # exemption check is the sharper of the two, since the pin it guards takes # every `needs: metadata` job down with it. RepoCheck( "self-pin-cooldown-exemption", check_self_pin_cooldown_exemption, 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" ), fatal=True, ), # The odd one out in this run of checks, and deliberately not fatal: an # unpinned or split `setup-uv` resolves and runs, it just resolves through # a uv nobody chose. That ages badly rather than being already broken, # which is the line the checks around it sit on the other side of. RepoCheck( "setup-uv-version-pin", check_setup_uv_version_pin, 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" ), ), # The second half of the same pin: which uv CI runs, then whether it can # tell it got that uv. Non-fatal on the same reasoning, and indeterminate # rather than red when the table cannot be read. RepoCheck( "setup-uv-checksum-coverage", check_setup_uv_checksum_coverage, 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" ), ), # Advisory like the two above: the finding clears on its own once the # release ages out of the window, and the pins ship with repomatic, so a # downstream repository can act only by adding an entry. RepoCheck( "registry-pin-cooldown", check_registry_pin_cooldown, summary=( "Every repomatic run tool pin is past the cooldown, or an" " exclude-newer-package entry admits it" ), description=( "Warns when a `repomatic run` tool is pinned at a release younger than" " [`minimum-release-age`](configuration.md#minimum-release-age), which" " `sync-tool-versions` never adopts but a hand bump can. A virtualenv" " tool like `nuitka` installs through uv commands that read the" " project's `[tool.uv] exclude-newer-package`, so the warning names the" " dated entry to add when none admits the release. A downstream" " repository that adopts such a pin before it clears the window needs" " the same entry. A `uvx` or npm tool reads no such entry, so the" " warning gives the date its install starts to work. Binary tools are" " not checked: their download applies no cooldown, so nothing holds a" " young pin back. A pin whose release date cannot be read is reported" " as skipped" ), ), # Fatal for the same reason again: a retired key fails the `metadata` job, # and every other job is `needs: metadata`. RepoCheck( "metadata-keys", check_metadata_keys, 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" ), fatal=True, ), RepoCheck( "pr-templates", check_pr_templates, 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))" ), ), RepoCheck( "virustotal-secret", check_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=lambda ctx: ctx.nuitka_active, ), RepoCheck( "notifications-pat-secret", check_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=lambda ctx: ctx.config.notification_unsubscribe, ), RepoCheck( "pat-permissions", check_pat_permissions, summary=( f"With a PAT, the {PAT_PERMISSION_NAMES} permissions of REPOMATIC_PAT" ), description=( "Fails when a configured `REPOMATIC_PAT` lacks one of the permissions the" f" automation needs: {PAT_PERMISSION_NAMES}" ), fatal=True, ), RepoCheck( "pat-repository-scope", check_pat_scope, 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=lambda ctx: bool(ctx.has_pat and ctx.repo), ), ) """Every check `lint-repo` runs, in report order.""" REPO_CHECK_HELP_LIST: str = "\n".join(check.help_entry for check in REPO_CHECKS) """The `lint-repo --help` check list, one item per {data}`REPO_CHECKS` entry. Rendered from the roster, so the help cannot name fewer checks than the command runs. """
[docs] def repo_check_reference() -> str: """The `lint-repo` job's check list for `docs/workflows.md`, as Markdown. One bullet per {data}`REPO_CHECKS` entry, from its {attr}`~RepoCheck.description`, in report order. Rendered from the roster for the same reason as {data}`REPO_CHECK_HELP_LIST`: the page cannot describe fewer checks than the command runs. """ return "\n".join(f"- {check.description}" for check in REPO_CHECKS)
[docs] def run_repo_lint(ctx: LintContext) -> Iterator[tuple[RepoCheck, CheckResult]]: """Run every check of {data}`REPO_CHECKS` that applies to the repository. Reports nothing itself: the caller renders each result at the {attr}`~RepoCheck.level` of its check, and fails when {meth}`~RepoCheck.fails` says so. Only a fatal check can fail the command; everything else is advisory, so a scheduled run stays green on findings a maintainer merely needs to see. :param ctx: Everything the checks read. Build it with {meth}`~repomatic.lint_repo.context.LintContext.from_project` to derive the project facts the way the CLI does, or directly for a hand-assembled probe. :return: Each result, with the check that produced it, in report order. """ for check in REPO_CHECKS: if not check.applies(ctx): continue for result in check.results(ctx): yield check, result