Skip to content

Commit e64399b

Browse files
committed
Improve stiff from downstream repo
1 parent ca1249a commit e64399b

7 files changed

Lines changed: 443 additions & 10 deletions

File tree

.github/workflows/autofix.yaml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,12 @@ jobs:
187187
version: "0.12.2"
188188
- name: Run pyproject-fmt
189189
# pyproject-fmt returns exit code 1 when it reformats the file, which is
190-
# the expected outcome in an autofix workflow. No file list: the tool
191-
# runner resolves it, which also drops the xargs that used to translate
192-
# that 1 into a 123. See `ToolSpec.default_paths` in
193-
# repomatic/tool_registry.py.
190+
# the expected outcome in an autofix workflow. Tolerating it is only safe
191+
# because the tool runner checks the files too: a crash that exits 1
192+
# without rewriting anything arrives here as 70 and fails the job. See
193+
# `ToolSpec.rewrite_exit_code`. No file list: the tool runner resolves it,
194+
# which also drops the xargs that used to translate that 1 into a 123.
195+
# See `ToolSpec.default_paths` in repomatic/tool_registry.py.
194196
run: |
195197
rc=0
196198
uv --no-progress run --frozen -- repomatic run pyproject-fmt || rc=$?

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
- Fix `gh` re-downloading on every single command instead of once per version: a registry binary whose archive nests its executable in a subdirectory was stored under one cache key and looked up under another, so the cache never hit. `7.11.0` routed every GitHub call through the pinned binary, making it a 13 MB fetch per invocation.
1616
- Fix `repomatic init` realigning a workflow's inline `repomatic==X.Y.Z` pin without the cooldown exemption beside it, leaving a command that cannot resolve the version just written. `--no-cooldown` hit this on every run.
1717
- `repomatic run actionlint` now ships a bundled config declaring the `ubuntu-26.04` runner labels actionlint `1.7.12` predates, so the Lint job no longer fails on the `runs-on:` values repomatic itself generates.
18+
- `repomatic init` now warns when a workflow asks `metadata` for a key the adopted version no longer emits, instead of leaving the mismatch to fail the job every other job hangs off.
19+
- New `ToolSpec.rewrite_exit_code` naming the status a formatter returns after rewriting a file. A run exiting with it while leaving every target unchanged is reported as a crash (exit code `70`) instead of passing for a successful reformat: pyproject-fmt uses `1` for both, so its panics reached the autofix job as green runs that formatted nothing.
20+
- `repomatic run --verify` now surfaces a tool that failed on the throwaway copies, instead of reading the unformatted result as an absence of drift.
1821
- Fix the `[tool.repomatic.workflow]` key names the `repomatic-audit` skill recommends: they are `extra-paths` and `ignore-paths`, not the snake_case attribute names.
1922
- Every job now caps its runtime with `timeout-minutes`, so a hung job frees its runner in minutes instead of holding it for the platform's 6-hour ceiling. Downstream callers inherit the caps.
2023
- The bundled `lychee.toml` now excludes `bitdefender.com`, `npmjs.com`, `star-history.com` and `githubstatus.com`, which answer bots with 403, 405 or JavaScript rather than a link.

repomatic/init_project.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import hashlib
3636
import logging
3737
import re
38+
import shlex
3839
import sys
3940
from dataclasses import dataclass, field
4041
from datetime import date, datetime, timedelta, timezone
@@ -44,6 +45,8 @@
4445
from urllib.request import Request, urlopen
4546

4647
import tomlrt
48+
import yaml
49+
from packaging.version import InvalidVersion, Version
4750

4851
from . import __git_tag_sha__, __version__
4952
from .bundle import get_data_content
@@ -57,7 +60,7 @@
5760
render_thin_caller_for_target,
5861
)
5962
from .http import DEFAULT_TIMEOUT
60-
from .metadata import Metadata
63+
from .metadata import Metadata, all_metadata_keys
6164
from .plugin import merge_plugin_settings
6265
from .prepare_release import SELF_PIN_COOLDOWN_EXEMPTION
6366
from .pyproject import is_python_package, is_python_project, resolve_source_paths
@@ -1320,6 +1323,8 @@ def _init_workflows(
13201323
)
13211324

13221325
_realign_inline_pins(workflows_dir, version, result, output_dir, repo=repo)
1326+
# Runs after the realignment, so it reads the pin the workflows now carry.
1327+
_check_metadata_keys(workflows_dir, result, output_dir, version, repo=repo)
13231328

13241329

13251330
def _realign_inline_pins(
@@ -1396,6 +1401,154 @@ def _realign_inline_pins(
13961401
)
13971402

13981403

1404+
def _run_commands(workflow: Path) -> list[str]:
1405+
"""Collect every `run:` script in a workflow file.
1406+
1407+
:param workflow: Path to a workflow YAML file.
1408+
:return: One entry per step carrying a `run:`. Empty when the file does not
1409+
parse as a workflow, which is not this function's problem to report.
1410+
"""
1411+
try:
1412+
data = yaml.safe_load(workflow.read_text(encoding="UTF-8"))
1413+
except (yaml.YAMLError, OSError):
1414+
return []
1415+
if not isinstance(data, dict):
1416+
return []
1417+
commands = []
1418+
for job in data.get("jobs", {}).values():
1419+
if not isinstance(job, dict):
1420+
continue
1421+
for step in job.get("steps") or []:
1422+
if isinstance(step, dict) and isinstance(step.get("run"), str):
1423+
commands.append(step["run"])
1424+
return commands
1425+
1426+
1427+
def _requested_metadata_keys(command: str, package: str) -> list[str]:
1428+
"""Extract the metadata keys a shell command asks for.
1429+
1430+
```{note}
1431+
Deliberately conservative: a token is only read as a key when it is
1432+
lowercase snake_case and does not follow a bare option, which could be that
1433+
option's value. A boolean option ahead of the first key therefore hides it.
1434+
Under-reporting costs a missed warning; over-reporting would blame a
1435+
perfectly good workflow, and this runs on files the user did not write.
1436+
```
1437+
1438+
:param command: One step's `run:` script.
1439+
:param package: Upstream package name, as it appears in the invocation.
1440+
:return: The key arguments, in the order they appear.
1441+
"""
1442+
try:
1443+
tokens = shlex.split(command)
1444+
except ValueError:
1445+
# Unbalanced quotes: a shell script this function has no business
1446+
# second-guessing.
1447+
return []
1448+
1449+
keys = []
1450+
seen_package = False
1451+
after_subcommand = False
1452+
for index, token in enumerate(tokens):
1453+
previous = tokens[index - 1] if index else ""
1454+
if package in token:
1455+
seen_package = True
1456+
elif token == "metadata" and seen_package:
1457+
after_subcommand = True
1458+
# Outside the subcommand's arguments, or looking at an option or at the
1459+
# value of one written apart from it: none of these can be a key.
1460+
elif (
1461+
not after_subcommand
1462+
or token.startswith("-")
1463+
or (previous.startswith("-") and "=" not in previous)
1464+
):
1465+
continue
1466+
elif re.fullmatch(r"[a-z][a-z0-9_]*", token):
1467+
keys.append(token)
1468+
else:
1469+
# A value that cannot be a key: the invocation moved on to
1470+
# something else (a shell operator, a path, another command).
1471+
after_subcommand = False
1472+
return keys
1473+
1474+
1475+
def _check_metadata_keys(
1476+
workflows_dir: Path,
1477+
result: InitResult,
1478+
output_dir: Path,
1479+
version: str,
1480+
*,
1481+
repo: str = DEFAULT_REPO,
1482+
) -> None:
1483+
"""Warn when a workflow asks `metadata` for a key this version dropped.
1484+
1485+
A workflow reaches the toolkit's metadata through a `run:` command naming
1486+
the keys it wants, and an unknown key is a hard `UsageError`. So retiring a
1487+
key breaks every downstream workflow still asking for it, at the first push
1488+
after the adoption, in the job every other job hangs off: click-extra's
1489+
whole test workflow went dark that way when `coverage_cells` left with the
1490+
Codecov integration.
1491+
1492+
`init` is where the version changes, and the job bodies holding these
1493+
commands belong to the downstream repository, so header-only sync never
1494+
reads them. That makes this the one moment the mismatch is both introduced
1495+
and fixable, ahead of the commit rather than after the red run.
1496+
1497+
Scope is every workflow file, matching {func}`_realign_inline_pins`: the
1498+
invocation hides in a job body wherever the repository chose to put it.
1499+
1500+
```{note}
1501+
The key set is the running version's, read in-process, so the check applies
1502+
only when the workflows end up pinned to that same version. A cooldown
1503+
holding the pin back leaves the answer unknowable from here: the older
1504+
release's key set is not importable, and judging it by this one's would
1505+
blame a workflow that works.
1506+
```
1507+
1508+
:param workflows_dir: The repository's `.github/workflows/` directory.
1509+
:param result: {class}`InitResult` accumulator, mutated in place.
1510+
:param output_dir: Repository root, for the reported relative path.
1511+
:param version: Version just pinned into the workflows, `vX.Y.Z` spelling.
1512+
:param repo: Upstream `owner/repo`; its name is the package to match.
1513+
"""
1514+
# Compared on the release tuple, so a development build checks the release
1515+
# it is heading for: the pin drops the `.devN` suffix the running version
1516+
# carries, and string equality would skip the check on every dev checkout.
1517+
try:
1518+
same_release = (
1519+
Version(version.removeprefix("v")).release == Version(__version__).release
1520+
)
1521+
except InvalidVersion:
1522+
same_release = False
1523+
if not same_release:
1524+
logging.debug(
1525+
"Workflows pin %s, not the running %s: skipping the metadata-key "
1526+
"check, whose key set is this version's.",
1527+
version,
1528+
__version__,
1529+
)
1530+
return
1531+
1532+
package = repo.rsplit("/", 1)[-1]
1533+
valid_keys = all_metadata_keys()
1534+
for target in sorted(workflows_dir.glob("*.y*ml")):
1535+
unknown = sorted({
1536+
key
1537+
for command in _run_commands(target)
1538+
for key in _requested_metadata_keys(command, package)
1539+
if key not in valid_keys
1540+
})
1541+
if not unknown:
1542+
continue
1543+
result.warnings.append(
1544+
f"{_relative_label(target, output_dir)} asks `{package} metadata` "
1545+
f"for {', '.join(repr(key) for key in unknown)}, which "
1546+
f"{package} {__version__} does not emit. The job will fail; drop "
1547+
"the key or pin an older version."
1548+
)
1549+
logging.warning(result.warnings[-1])
1550+
1551+
13991552
def is_source_repo(output_dir: Path) -> bool:
14001553
"""Detect whether `output_dir` is the repomatic source repository root.
14011554

repomatic/tool_registry.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,26 @@ class ToolSpec:
781781
```
782782
"""
783783

784+
rewrite_exit_code: int | None = None
785+
"""Exit code the tool returns when it rewrote at least one file.
786+
787+
Formatters that signal "I reformatted something" with a non-zero status
788+
force every caller to tolerate that code, which is what lets a crash pass
789+
for a success: pyproject-fmt exits `1` both when it reformats a file and
790+
when it dies on a `PanicException`, and the autofix job cannot tell the two
791+
apart from the status alone.
792+
793+
Declaring the code here gives `run_tool` the second signal it needs: the
794+
files themselves. A run exiting with this code and leaving every target
795+
byte-identical contradicts what the code claims, so it is reported as a
796+
failure instead of being waved through. See
797+
{data}`repomatic.tool_runner.TOOL_CRASH_EXIT_CODE`.
798+
799+
`None` for tools with no such convention, which is most of them: a
800+
formatter that exits `0` whether or not it wrote anything needs no
801+
disambiguation.
802+
"""
803+
784804
binary: BinarySpec | None = None
785805
"""Platform-specific binary download spec. When set, the tool is downloaded
786806
as a binary instead of installed via `uvx` or `uv run`.
@@ -1822,6 +1842,7 @@ def _fix_myst_directives(extra_args: Sequence[str]) -> None:
18221842
config_flag="--config",
18231843
native_format=NativeFormat.TOML,
18241844
reads_pyproject=True,
1845+
rewrite_exit_code=1,
18251846
docs_notes=cleandoc(r"""
18261847
**Try it:**
18271848

repomatic/tool_runner.py

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,6 +1077,41 @@ def resolve_default_args(spec: ToolSpec) -> list[list[str]] | None:
10771077
return [[*base, *paths]]
10781078

10791079

1080+
TOOL_CRASH_EXIT_CODE = 70
1081+
"""Exit code reported when a tool contradicts its own rewrite status.
1082+
1083+
`EX_SOFTWARE` from `sysexits.h`: an internal error in the tool being run.
1084+
Deliberately outside the set a formatter's caller tolerates, so a crash cannot
1085+
land on the code that means "I reformatted a file". See
1086+
{attr}`~repomatic.tool_registry.ToolSpec.rewrite_exit_code`.
1087+
"""
1088+
1089+
1090+
def _digest_targets(args: Sequence[str]) -> dict[str, str]:
1091+
"""Digest every existing file among a batch's arguments.
1092+
1093+
Directories are walked, so a tool pointed at a tree is covered too.
1094+
Non-path arguments (flags, their values) simply do not exist on disk and
1095+
are skipped, which needs no flag parsing: a flag that happens to name a
1096+
real file is digested harmlessly.
1097+
1098+
:param args: One invocation's arguments.
1099+
:return: Path string mapped to the SHA-256 of its content.
1100+
"""
1101+
digests: dict[str, str] = {}
1102+
for arg in args:
1103+
path = Path(arg)
1104+
if path.is_file():
1105+
candidates = [path]
1106+
elif path.is_dir():
1107+
candidates = [child for child in path.rglob("*") if child.is_file()]
1108+
else:
1109+
continue
1110+
for candidate in candidates:
1111+
digests[str(candidate)] = compute_file_sha256(candidate)
1112+
return digests
1113+
1114+
10801115
def run_tool(
10811116
name: str,
10821117
extra_args: Sequence[str] = (),
@@ -1101,7 +1136,10 @@ def run_tool(
11011136
:param skip_checksum: Skip SHA-256 verification entirely.
11021137
:param no_cache: Bypass the binary cache when `True`.
11031138
:return: The tool's exit code; the first non-zero one when the defaults
1104-
resolved to several invocations.
1139+
resolved to several invocations, or {data}`TOOL_CRASH_EXIT_CODE` when a
1140+
tool declaring
1141+
{attr}`~repomatic.tool_registry.ToolSpec.rewrite_exit_code` reports a
1142+
rewrite it did not perform.
11051143
"""
11061144
if name not in TOOL_REGISTRY:
11071145
msg = (
@@ -1209,11 +1247,36 @@ def run_tool(
12091247
continue
12101248
destination.parent.mkdir(parents=True, exist_ok=True)
12111249

1250+
# Snapshot the targets when the tool reports rewrites through its
1251+
# exit code, so the claim can be checked against the files below.
1252+
before = (
1253+
_digest_targets(batch) if spec.rewrite_exit_code is not None else {}
1254+
)
1255+
12121256
logging.info("Running: %s", " ".join(cmd))
12131257
result = subprocess.run(cmd, check=False, env=env)
12141258

12151259
logging.info("%s exited with code %d.", spec.name, result.returncode)
12161260

1261+
# A rewrite status that rewrote nothing is a contradiction, and the
1262+
# shape a crash takes in a tool whose caller has to tolerate that
1263+
# status: pyproject-fmt exits 1 on a PanicException exactly as it
1264+
# does on a successful reformat. Trust the files over the code.
1265+
crashed = (
1266+
spec.rewrite_exit_code is not None
1267+
and result.returncode == spec.rewrite_exit_code
1268+
and _digest_targets(batch) == before
1269+
)
1270+
if crashed:
1271+
logging.error(
1272+
"%s exited with code %d, which it uses to report rewritten "
1273+
"files, but left every target unchanged. Treating it as a "
1274+
"crash and reporting %d; re-run it directly to see why.",
1275+
spec.name,
1276+
result.returncode,
1277+
TOOL_CRASH_EXIT_CODE,
1278+
)
1279+
12171280
# post_process is a write-mode-only fixup: it rewrites files on
12181281
# disk, so it runs only after a successful write (return code 0),
12191282
# never in check/dry-run mode, which writes nothing. The warning
@@ -1240,8 +1303,9 @@ def run_tool(
12401303

12411304
# Keep going after a failure, the way `xargs` does, but never let a
12421305
# later success overwrite an earlier failure.
1243-
if result.returncode != 0 and exit_code == 0:
1244-
exit_code = result.returncode
1306+
batch_code = TOOL_CRASH_EXIT_CODE if crashed else result.returncode
1307+
if batch_code != 0 and exit_code == 0:
1308+
exit_code = batch_code
12451309

12461310
return exit_code
12471311

@@ -1290,7 +1354,8 @@ def verify_via_write_path(
12901354
:param run_kwargs: Forwarded verbatim to {func}`run_tool`.
12911355
:return: `(exit_code, drifted)`, where `exit_code` is `0` when every target
12921356
is already formatted and `1` otherwise, and `drifted` names the paths
1293-
the write path would have changed.
1357+
the write path would have changed. A tool that fails on the copies
1358+
yields its own exit code and no drift, since it measured nothing.
12941359
"""
12951360
spec = TOOL_REGISTRY[name]
12961361
if not extra_args:
@@ -1346,7 +1411,20 @@ def verify_via_write_path(
13461411
)
13471412
return 0, []
13481413

1349-
run_tool(name, extra_args=rewritten, **run_kwargs)
1414+
# A tool that died on the copies formatted nothing, so the comparison
1415+
# below would find no difference and report the tree as clean. Surface
1416+
# the failure instead of laundering it into a passing verdict. The
1417+
# rewrite status is the one non-zero code that means success here: it
1418+
# is what a formatter returns after reformatting a copy.
1419+
run_code = run_tool(name, extra_args=rewritten, **run_kwargs)
1420+
if run_code not in {0, spec.rewrite_exit_code}:
1421+
logging.error(
1422+
"%s failed with code %d on the throwaway copies; its formatting "
1423+
"cannot be verified.",
1424+
name,
1425+
run_code,
1426+
)
1427+
return run_code, []
13501428

13511429
drifted = sorted(
13521430
str(source_file)

0 commit comments

Comments
 (0)