Skip to content

Commit b84c2db

Browse files
committed
lint-repo now fails when a workflow asks repomatic metadata for a key that no longer exists
1 parent e64399b commit b84c2db

5 files changed

Lines changed: 296 additions & 1 deletion

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- `lint-repo` now fails when a workflow asks `repomatic metadata` for a key that no longer exists. `init` syncs a header-only workflow's header and pins but never its job bodies, so a retired key sits in a `run:` line nothing sweeps until the command rejects it and every job gated on it through `needs:` dies with it. `coverage_cells` took a downstream test workflow down this way.
89
- `lint-repo` now warns when a Sphinx project's GitHub website field differs from the documentation URL declared in `[project.urls]`.
910
- New `ci-status` command reporting each workflow's latest run and which of its failing jobs actually gate a merge. Reads jobs rather than runs, so an allowed-failure probe cannot hide inside a green run conclusion.
1011
- New `repomatic run <tool> --verify` reporting which files a formatter would rewrite, without touching the working tree.

docs/workflows.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ None of these jobs read a label config committed to the repository. `labels.toml
418418
- Validates repository metadata (package name, Sphinx docs, project description) and Dependabot configuration using [`repomatic lint-repo`](https://github.com/kdeldycke/repomatic/blob/main/repomatic/cli.py). Reads `pyproject.toml` directly. When `REPOMATIC_PAT` is configured, also validates PAT capabilities (contents, issues, pull requests, Dependabot alerts, workflows permissions). Warns when the fork PR workflow approval policy is weaker than `first_time_contributors`. Warns about missing `VIRUSTOTAL_API_KEY` when Nuitka binary compilation is active. Warns about missing `REPOMATIC_NOTIFICATIONS_PAT` when the unsubscribe workflow is enabled.
419419
- 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
420420
- 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
421+
- Fails when a workflow's `run:` line asks `repomatic 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 check beside it: both describe a workflow that is already broken rather than one that might age badly
421422
- **Requires**:
422423
- Python package (with a `pyproject.toml` file)
423424

repomatic/lint_repo.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import json
2929
import logging
3030
import re
31+
import shlex
3132
from dataclasses import dataclass
3233
from functools import cached_property
3334
from pathlib import Path
@@ -48,7 +49,12 @@
4849
TEST_RUNNERS_PR,
4950
UNSTABLE_PYTHON_VERSIONS,
5051
)
51-
from .metadata import Dialect, Metadata
52+
from .metadata import (
53+
METADATA_VALUE_OPTIONS,
54+
Dialect,
55+
Metadata,
56+
all_metadata_keys,
57+
)
5258
from .pypi import (
5359
PYPI_TRUSTED_PUBLISHER_WORKFLOW,
5460
get_latest_release_file,
@@ -1775,6 +1781,151 @@ def check_inline_pins_match_upstream(
17751781
)
17761782

17771783

1784+
_METADATA_KEY_TOKEN = re.compile(r"^[a-z][a-z0-9_]*$")
1785+
"""Shape of a metadata key, used to tell one from a neighbouring shell token.
1786+
1787+
Every key is a Python identifier, so a token carrying a hyphen (`github-json`),
1788+
a dollar (`$GITHUB_OUTPUT`) or a dot is something else on the command line and
1789+
is passed over rather than reported as unknown.
1790+
"""
1791+
1792+
_SHELL_OPERATORS = frozenset(("&&", "||", ";", "|", ">", ">>", "<", "&"))
1793+
"""Tokens that end the invocation and start unrelated words.
1794+
1795+
`repomatic metadata a b && echo done` must not report `echo` and `done` as
1796+
metadata keys.
1797+
"""
1798+
1799+
1800+
def _requested_metadata_keys(command: str, package: str) -> list[str]:
1801+
"""Positional keys a shell command passes to `<package> metadata`.
1802+
1803+
Reads the tail of the invocation the way Click would: options are dropped
1804+
along with the value each one consumes, and what remains are the positional
1805+
key arguments. Handles both spellings in use, the upstream
1806+
`uv run -- repomatic metadata …` and the downstream
1807+
`uvx 'repomatic==1.2.3' metadata …`, by looking for the subcommand after
1808+
any token naming the package.
1809+
1810+
:param command: The step's `run:` script, folded or literal.
1811+
:param package: Upstream package name (like `repomatic`).
1812+
:return: The key names requested, in the order written.
1813+
"""
1814+
keys: list[str] = []
1815+
# A literal block scalar holds a whole script: read it a line at a time so
1816+
# a later command's words cannot be mistaken for this one's arguments.
1817+
# Backslash continuations are rejoined first, being one command still.
1818+
for line in command.replace("\\\n", " ").splitlines():
1819+
try:
1820+
tokens = shlex.split(line, comments=True)
1821+
except ValueError:
1822+
# An unbalanced quote is a line this cannot read. A shell would
1823+
# reject it too, so leave it to the shell to complain.
1824+
continue
1825+
seen_package = False
1826+
tail: list[str] | None = None
1827+
for token in tokens:
1828+
if tail is not None:
1829+
if token in _SHELL_OPERATORS:
1830+
break
1831+
tail.append(token)
1832+
elif token == "metadata" and seen_package:
1833+
tail = []
1834+
elif token == package or token.startswith(f"{package}=="):
1835+
seen_package = True
1836+
if not tail:
1837+
continue
1838+
1839+
skip_next = False
1840+
for token in tail:
1841+
if skip_next:
1842+
skip_next = False
1843+
continue
1844+
if token.startswith("-"):
1845+
# `--format=json` carries its value inline, consuming nothing.
1846+
skip_next = "=" not in token and token in METADATA_VALUE_OPTIONS
1847+
continue
1848+
if _METADATA_KEY_TOKEN.match(token):
1849+
keys.append(token)
1850+
return keys
1851+
1852+
1853+
def check_metadata_keys(
1854+
workflow_dir: Path = WORKFLOW_DIR,
1855+
upstream_repo: str = DEFAULT_REPO,
1856+
) -> list[CheckResult]:
1857+
"""Check the metadata keys workflows request still exist.
1858+
1859+
A downstream repository owns the job bodies of its header-only workflows:
1860+
`repomatic init` syncs their `name`, `on` and `concurrency` blocks and the
1861+
`uses:` pins, and never touches the steps below. So a key retired upstream
1862+
keeps being asked for by a `run:` line nothing sweeps, and the `metadata`
1863+
command answers a retired key with a `UsageError`. Since every other job in
1864+
a test workflow reaches it through `needs:`, the whole run dies at the first
1865+
job, on the next push, from a workflow file that looks freshly synced.
1866+
1867+
That is not hypothetical: `coverage_cells` went away with the Codecov
1868+
integration and took a downstream test workflow down with it. Failing here
1869+
instead moves the report to lint time, where it names the file and the job.
1870+
1871+
:param workflow_dir: Directory holding the workflow YAML files.
1872+
:param upstream_repo: Upstream `owner/repo`; its name is the package whose
1873+
`metadata` invocations are read (like `repomatic`).
1874+
:return: A list of `CheckResult`.
1875+
"""
1876+
package = upstream_repo.rsplit("/", 1)[-1]
1877+
workflows = _load_workflows(workflow_dir)
1878+
if not workflows:
1879+
return [CheckResult(None, "Metadata keys check: skipped (no workflows).")]
1880+
1881+
valid = all_metadata_keys()
1882+
results: list[CheckResult] = []
1883+
for path, data in workflows.items():
1884+
for job_id, job in data["jobs"].items():
1885+
if not isinstance(job, dict):
1886+
continue
1887+
for step in job.get("steps", []) or ():
1888+
if not isinstance(step, dict):
1889+
continue
1890+
command = step.get("run")
1891+
if not isinstance(command, str):
1892+
continue
1893+
requested = _requested_metadata_keys(command, package)
1894+
if not requested:
1895+
continue
1896+
where = f"{path.name}:{job_id}"
1897+
unknown = sorted(set(requested) - valid)
1898+
if unknown:
1899+
names = ", ".join(f"`{key}`" for key in unknown)
1900+
results.append(
1901+
CheckResult(
1902+
False,
1903+
f"{where} asks `{package} metadata` for {names}, which"
1904+
f" no longer exists. The command rejects an unknown"
1905+
f" key outright, so this job fails on its next run,"
1906+
f" and every job gated on it through `needs:` with"
1907+
f" it. Run `{package} metadata --list-keys` for the"
1908+
f" current set.",
1909+
)
1910+
)
1911+
else:
1912+
count = len(requested)
1913+
plural = "" if count == 1 else "s"
1914+
results.append(
1915+
CheckResult(
1916+
True,
1917+
f"{where}: {count} requested metadata key{plural}"
1918+
f" exist{'s' if count == 1 else ''}.",
1919+
)
1920+
)
1921+
1922+
if not results:
1923+
return [
1924+
CheckResult(None, f"Metadata keys check: no `{package} metadata` calls.")
1925+
]
1926+
return results
1927+
1928+
17781929
def check_pr_templates(
17791930
workflow_dir: Path = WORKFLOW_DIR,
17801931
template_dir: Path = PR_TEMPLATE_DIR,
@@ -2148,6 +2299,9 @@ def _pat_permissions(ctx: LintContext) -> Iterator[CheckResult]:
21482299
lambda ctx: check_inline_pins_match_upstream(),
21492300
fatal=True,
21502301
),
2302+
# Fatal for the same reason as the pin check above: both describe a
2303+
# workflow that is already broken, not one that might age badly.
2304+
RepoCheck("metadata-keys", lambda ctx: check_metadata_keys(), fatal=True),
21512305
RepoCheck("pr-templates", lambda ctx: check_pr_templates()),
21522306
RepoCheck(
21532307
"virustotal-secret",

repomatic/metadata.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,25 @@ def all_metadata_keys() -> frozenset[str]:
357357
return frozenset(_METADATA_KEY_DESCRIPTIONS) | frozenset(_metadata_config_fields())
358358

359359

360+
METADATA_VALUE_OPTIONS: frozenset[str] = frozenset((
361+
"--format",
362+
"-o",
363+
"--output",
364+
"--sort-by",
365+
))
366+
"""Options on the `metadata` command consuming the token that follows them.
367+
368+
Needed by {func}`repomatic.lint_repo.check_metadata_keys` to tell a positional
369+
key from an option's value while reading a workflow's `run:` line. The command
370+
itself is not importable from there: {mod}`repomatic.cli` reads `sys.stdout.name`
371+
at import time, so importing it under a test that has replaced stdout raises.
372+
373+
Listed here rather than derived, and pinned against the real command by
374+
repomatic's own test suite, so an option added later cannot quietly turn its
375+
value into a token the lint reports as an unknown key.
376+
"""
377+
378+
360379
# Silence overly verbose debug messages from py-walk logger.
361380
logging.getLogger("py_walk").setLevel(logging.WARNING)
362381

tests/test_lint_repo.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import pytest
2626

2727
from repomatic import lint_repo
28+
from repomatic.cli import metadata as metadata_command
2829
from repomatic.github.token import PAT_PERMISSION_PROBES, probe_pat_permission
2930
from repomatic.lint_repo import (
3031
REPO_CHECKS,
@@ -34,6 +35,7 @@
3435
check_immutable_releases,
3536
check_inline_pins_match_upstream,
3637
check_install_guide_downloads,
38+
check_metadata_keys,
3739
check_package_name_vs_repo,
3840
check_pat_stale_statuses_permission,
3941
check_pr_templates,
@@ -51,6 +53,7 @@
5153
run_repo_lint,
5254
)
5355
from repomatic.matrix_axes import UNSTABLE_PYTHON_VERSIONS
56+
from repomatic.metadata import METADATA_VALUE_OPTIONS
5457
from repomatic.pypi import TrustedPublisher
5558
from repomatic.registry import INSTALL_GUIDE_PATH
5659
from tests.conftest import metadata_from_pyproject, pat_results
@@ -1478,6 +1481,123 @@ def test_inline_pins_match_upstream_skips_without_refs(tmp_path):
14781481
assert "nothing to compare" in msg
14791482

14801483

1484+
# ---------------------------------------------------------------------------
1485+
# Metadata key check tests
1486+
# ---------------------------------------------------------------------------
1487+
1488+
1489+
def _metadata_step(command):
1490+
"""A one-job workflow whose single step runs *command*."""
1491+
return f"on: push\njobs:\n metadata:\n steps:\n - run: {command}\n"
1492+
1493+
1494+
@pytest.mark.parametrize(
1495+
("command", "expected"),
1496+
[
1497+
# Upstream spelling: the CLI comes from the project's own lockfile.
1498+
pytest.param(
1499+
"uv --no-progress run --frozen -- repomatic metadata --format github-json"
1500+
' --output "$GITHUB_OUTPUT" cli_scripts package_name',
1501+
["cli_scripts", "package_name"],
1502+
id="uv-run",
1503+
),
1504+
# Downstream spelling: the CLI comes from a pinned uvx environment.
1505+
pytest.param(
1506+
"uvx --no-progress 'repomatic==7.11.0' metadata --format github-json"
1507+
' --output "$GITHUB_OUTPUT" cli_scripts package_name',
1508+
["cli_scripts", "package_name"],
1509+
id="uvx-pinned",
1510+
),
1511+
# An option's value is never a key, whether attached or separate.
1512+
pytest.param(
1513+
"repomatic metadata --format=json current_version",
1514+
["current_version"],
1515+
id="inline-option-value",
1516+
),
1517+
pytest.param(
1518+
"repomatic metadata -o out current_version",
1519+
["current_version"],
1520+
id="short-option-value",
1521+
),
1522+
# Words belonging to another command are not arguments to this one.
1523+
pytest.param(
1524+
"repomatic metadata current_version && echo done",
1525+
["current_version"],
1526+
id="shell-operator",
1527+
),
1528+
pytest.param(
1529+
"repomatic metadata current_version\necho done",
1530+
["current_version"],
1531+
id="second-line",
1532+
),
1533+
pytest.param("uv run pytest -m once", [], id="unrelated-command"),
1534+
# `metadata` also names a step id, an output and a job. Only the token
1535+
# following the package invocation is the subcommand.
1536+
pytest.param("echo metadata cli_scripts", [], id="bare-word"),
1537+
],
1538+
)
1539+
def test_requested_metadata_keys(command, expected):
1540+
"""Positional keys are read off the command line the way Click reads them."""
1541+
assert lint_repo._requested_metadata_keys(command, "repomatic") == expected
1542+
1543+
1544+
def test_metadata_keys_flags_a_retired_key(tmp_path, monkeypatch):
1545+
"""A key removed upstream fails the lint instead of the next workflow run."""
1546+
monkeypatch.chdir(tmp_path)
1547+
_write_ci_workflow(
1548+
tmp_path,
1549+
_metadata_step(
1550+
"uvx --no-progress 'repomatic==7.11.0' metadata cli_scripts coverage_cells"
1551+
),
1552+
)
1553+
failures = [r for r in check_metadata_keys() if r.passed is False]
1554+
assert failures
1555+
assert "coverage_cells" in failures[0].message
1556+
assert "--list-keys" in failures[0].message
1557+
1558+
1559+
def test_metadata_keys_accepts_current_keys(tmp_path, monkeypatch):
1560+
"""Keys the command still answers raise nothing."""
1561+
monkeypatch.chdir(tmp_path)
1562+
_write_ci_workflow(
1563+
tmp_path, _metadata_step("repomatic metadata cli_scripts package_name")
1564+
)
1565+
results = check_metadata_keys()
1566+
assert all(r.passed is not False for r in results)
1567+
1568+
1569+
def test_metadata_keys_skips_a_repo_that_never_calls_it(tmp_path, monkeypatch):
1570+
"""No invocation to read is a skip, not a pass."""
1571+
monkeypatch.chdir(tmp_path)
1572+
_write_ci_workflow(tmp_path, _metadata_step("echo apricot"))
1573+
results = check_metadata_keys()
1574+
assert [r.passed for r in results] == [None]
1575+
1576+
1577+
def test_metadata_value_options_match_the_command():
1578+
"""The hand-listed value options are the ones the command really declares.
1579+
1580+
`check_metadata_keys` cannot import the CLI to ask (see
1581+
`METADATA_VALUE_OPTIONS`), so the list is pinned here instead: an option
1582+
gaining a value would otherwise make the lint read that value as a key.
1583+
"""
1584+
declared = {
1585+
opt
1586+
for param in metadata_command.params
1587+
if not getattr(param, "is_flag", False)
1588+
for opt in (*param.opts, *param.secondary_opts)
1589+
if opt.startswith("-")
1590+
}
1591+
assert declared == set(METADATA_VALUE_OPTIONS)
1592+
1593+
1594+
def test_metadata_keys_covers_every_workflow_of_this_repo():
1595+
"""This repository's own workflows only ask for keys that exist."""
1596+
results = check_metadata_keys(Path(".github/workflows"))
1597+
assert results
1598+
assert all(r.passed is not False for r in results)
1599+
1600+
14811601
# ---------------------------------------------------------------------------
14821602
# Branch ruleset check tests
14831603
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)