repomatic.lint_repo.workflows module¶
Checks of the repository’s workflow files.
The permissions, the runner images, the Python test matrix, the release-only gates, the metadata keys a workflow asks for, and the repository-local PR templates. The thin callers of the upstream reusable workflows get four checks of their own: the pinned version, the triggers and the secrets they pass, and the manual trigger every other workflow carries.
- repomatic.lint_repo.workflows.PR_TEMPLATE_DIR = PosixPath('.github/pr-templates')¶
Canonical home for a repository’s own
pr-body --template-filetemplates..github/already namespaces by subdirectory (ISSUE_TEMPLATE/,workflows/,actions/), and a dedicated one leaves each template’s basename free to carry the operation name, so it can match its job ID and PR branch. Templates sitting flat in.github/need apr-prefix purely to disambiguate, which breaks that identity and puts them next to GitHub’s ownpull_request_template.md, an unrelated human-facing file.
- repomatic.lint_repo.workflows.PYTHON_CLASSIFIER_PREFIX = 'Programming Language :: Python :: '¶
Prefix of the classifiers naming a supported interpreter version.
Only the dotted ones carry a version: the bare
3and3 :: Onlystate the major series, andImplementation :: CPythonthe interpreter.
- repomatic.lint_repo.workflows.TEMPLATE_FILE_ARG_RE = re.compile('--template-file[=\\s]+(?P<path>\\S+)')¶
A
repomatic pr-body --template-fileargument inside a workflowrun:block.Matched against the raw YAML text rather than the parsed document: the argument sits inside a folded scalar, so the surrounding
run:value is one opaque string whichever way the file is parsed.
- repomatic.lint_repo.workflows.UPSTREAM_WORKFLOW_RE: Final = re.compile('^kdeldycke/repomatic/\\.github/workflows/(?P<workflow>[^@]+)@(?P<ref>.+)$')¶
A job-level
uses:calling an upstream reusable workflow, with its ref.The same references
canonical_workflow()reads to tell a thin caller apart.
- repomatic.lint_repo.workflows.FLOATING_UPSTREAM_REF = 'main'¶
The upstream branch a thin caller must not track instead of a release.
- repomatic.lint_repo.workflows.check_workflow_permissions(ctx)[source]¶
Check workflow
permissionsdeclarations for least privilege.Two failure modes are flagged:
A workflow that defines its own
steps:should carry a top-levelpermissionskey (permissions: {}for least privilege) so its jobs default to no scopes rather than the repository default.A job that calls a reusable workflow (a job-level
uses:) hands its own permissions down, and the reusable workflow’s jobs are capped by them: they cannot escalate beyond what the caller grants. So under a top-levelpermissions: {}, a reusable-call job with nopermissions:block of its own passes{}to the called workflow, and GitHub aborts the run at startup the moment a nested job requests a scope the caller never granted. Such a job must name the union of the scopes its reusable workflow needs (mirror the reusable workflow’s own top-level{}plus per-job grants).
A thin caller with no top-level
permissionskey is fine: its jobs inherit the repository default, which the reusable workflow’s ownpermissions:blocks then cap. The failure is specifically an empty top-levelpermissions: {}starving an unqualified reusable call.- Return type:
- class repomatic.lint_repo.workflows.UpstreamLane(path, job_id, job, workflow, ref)[source]¶
Bases:
NamedTupleOne job of a thin caller, calling an upstream reusable workflow.
Create new instance of UpstreamLane(path, job_id, job, workflow, ref)
- repomatic.lint_repo.workflows.check_workflow_dispatch(ctx)[source]¶
Check that each standalone workflow can be started by hand.
A
workflow_dispatchtrigger lets a maintainer rerun a workflow without a push. Two kinds of workflow are left out. A thin caller follows the triggers of the workflow it calls, whichcheck_thin_caller_triggers()checks instead: some upstream workflows, likecancel-runs.yaml, have no manual trigger on purpose. A reusable workflow, which declaresworkflow_call, starts the way its callers decide.- Return type:
- repomatic.lint_repo.workflows.check_thin_caller_pins(ctx)[source]¶
Check that each thin caller pins the upstream workflows to a release.
A
@mainreference runs whatever the upstream default branch holds at the moment the job starts, so a change no release has tested reaches the repository unannounced. A tag, or a commit SHA carrying its tag as a comment, moves only through a reviewed pull request.- Return type:
- repomatic.lint_repo.workflows.check_thin_caller_triggers(ctx)[source]¶
Check that each thin caller carries the triggers of the workflow it calls.
The caller must hold every trigger the upstream workflow declares besides
workflow_call, and no other. An upstream workflow that declares none, like the call-only_release-engine.yaml, leaves the caller free to pick its own: the downstreamrelease.yamlsynthesizespushandworkflow_dispatchfor that reason.- Return type:
- repomatic.lint_repo.workflows.check_thin_caller_secrets(ctx)[source]¶
Check that each thin caller passes every secret its canonical workflow declares.
The secrets are read off the job that calls the canonical workflow, and that job alone: another job of the same file, like the build lane of the downstream
release.yaml, cannot vouch for it. The job passes them as an explicitsecrets:mapping, or forwards them all withsecrets: inherit.- Return type:
- repomatic.lint_repo.workflows.check_test_matrix_excludes(ctx)[source]¶
Flag
[tool.repomatic.test-matrix] excludeentries that match no axis.An exclude naming a value absent from every matrix axis (like a renamed runner) can never match a combination, so
Matrix.prune()drops it silently and its exclusion intent is lost. Reporting it as a warning makes the drift visible in CI instead of silently weakening the matrix.- Return type:
- repomatic.lint_repo.workflows.check_python_version_consistency(ctx)[source]¶
Reconcile the Python versions a project requires, advertises and tests.
The same fact is stated in up to three places, and nothing else holds them together: the
requires-pythonlower bound, theProgramming Language :: Python :: X.Yclassifiers PyPI renders, and any test matrix naming its versions literally.Two failure modes are flagged:
The lowest classifier disagrees with the
requires-pythonfloor. One of the two is then lying to resolvers about what installs.A literal test matrix does not reach both ends of the advertised range, or names a released version the classifiers never claim. Coverage of the ends is the invariant rather than of every version in between, so that a matrix testing the floor, the latest release and the development version stays conformant: skipping intermediate releases is a deliberate way to cut CI load, advertising an untested boundary is not.
Versions in
UNSTABLE_PYTHON_VERSIONSare exempt from the second rule, being tested precisely because they are not released yet and so cannot be advertised. Build flavors carrying a suffix (the free-threaded3.14t) count as their base version.- Return type:
- repomatic.lint_repo.workflows.check_runner_images(ctx)[source]¶
Flag runner images that move on their own, or that no axis knows about.
Neither Dependabot nor
sync-workflow-pinstouches aruns-on:value: the first only rewritesuses:references, the second only theuvx '<pkg>==X.Y.Z'andnpm install pkg@X.Y.Zliterals. So onlysync-runner-imagesmoves a runner. It rewrites a literalruns-on:when GitHub retires the image or lists a newer version of it.Two failure modes are flagged:
A
-latestalias. GitHub repoints those to a new image on its own schedule, so the build changes underneath the repository with no commit to review, and a breakage arrives unattached to any change.An image outside the curated axes in
repomatic.matrix.axes. Those carry measured guidance on speed and cost; an image picked outside them is one nobody has weighed, and is usually a leftover.
Values built from an expression (
${{ matrix.os }}) name no image here and are left alone: the axis they draw from is checked at its definition.- Return type:
- class repomatic.lint_repo.workflows.ReleaseGate(name, workflow, metadata_key, needs)[source]¶
Bases:
NamedTupleA release-only step or job, and the project capability it needs.
metadata_keynames theMetadatafield that both decides whether the step runs and supplies what it consumes.Nonemarks a step that needs nothing beyond being on a release commit.Create new instance of ReleaseGate(name, workflow, metadata_key, needs)
- repomatic.lint_repo.workflows.RELEASE_ONLY_GATES: tuple[ReleaseGate, ...] = (('Pre-bake tag SHA', '_release-build.yaml', 'cli_scripts', "a `[project.scripts]` entry, since click-extra's prebake finds the module to stamp through it"), ('📌 Tag release', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('🐍 Publish to PyPI', 'release.yaml', None, 'a wheel from the build lane'), ('🐙 Create GitHub release draft', '_release-engine.yaml', None, 'nothing beyond the release commit'), ('📖 Man pages', '_release-engine.yaml', 'manpages_script', 'a configured man-page script'), ('📎 Extra release assets', '_release-engine.yaml', 'release_assets', 'at least one configured extra asset'), ('🎉 Publish GitHub release', '_release-engine.yaml', None, 'nothing beyond the release commit'))¶
Every step and job that runs only on a release commit.
These are invisible on an ordinary push: each is gated behind a condition that holds open only for the one commit that tags, publishes and releases. A project can therefore build green for its entire life and meet them for the first time on release day, where a failure costs a reverted release rather than a red push.
VirusTotal scanis deliberately absent: it gates on a repository secret rather than a project capability, so there is nothing in the tree to check it against.
- repomatic.lint_repo.workflows.check_release_path(ctx)[source]¶
Resolve the release path against this project, on an ordinary push.
Two arms, because the two failure modes live in different repositories.
The first runs everywhere, including downstream. It resolves each entry of
RELEASE_ONLY_GATESagainst the local project and reports which release-only steps a release commit would actually run. That turns a surface nothing exercises until release day into a line of output on every push, so the answer is known long before it is expensive.The second runs only where the reusable workflows live, since a downstream repository holds a thin caller and not the steps themselves. It asserts each gate’s
if:really does test the metadata key its step depends on. That invariant is what a release-only step gets wrong: the condition looks complete because it correctly waits for a release, while saying nothing about the capability the step consumes.Pre-bake tag SHAshipped that way, gated on the version alone, and every project with no[project.scripts]built green until the release commit ran prebake against a module that was not there.- Return type:
- repomatic.lint_repo.workflows.check_metadata_keys(ctx)[source]¶
Check the metadata keys workflows request still exist.
A downstream repository owns the job bodies of its header-only workflows:
repomatic initsyncs theirname,onandconcurrencyblocks and theuses:pins, and never touches the steps below. So a key retired upstream keeps being asked for by arun:line nothing sweeps, and theshow-metadatacommand answers a retired key with aUsageError. Since every other job in a test workflow reaches it throughneeds:, the whole run dies at the first job, on the next push, from a workflow file that looks freshly synced. A step still calling themetadataalias, which8.0.0removed, dies the same way.That is not hypothetical:
coverage_cellswent away with the Codecov integration and took a downstream test workflow down with it. Failing here instead moves the report to lint time, where it names the file and the job.- Return type:
- repomatic.lint_repo.workflows.check_pr_templates(ctx)[source]¶
Check a repository’s own
pr-body --template-filetemplates.A repo with a custom PR-opening job ships the body as a file of its own rather than adding a template upstream. Three failure modes are flagged:
The file sits outside
PR_TEMPLATE_DIR.A workflow references a path that does not exist, which the job only discovers when it runs and
pr-bodyrejects the missing file.The frontmatter lacks a
title, or does not opt out of the attribution footer the wayfooter_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
docsfield is not required. It deep-links the hosted workflows reference, which documents upstream jobs only.- Return type: