|
35 | 35 | import hashlib |
36 | 36 | import logging |
37 | 37 | import re |
| 38 | +import shlex |
38 | 39 | import sys |
39 | 40 | from dataclasses import dataclass, field |
40 | 41 | from datetime import date, datetime, timedelta, timezone |
|
44 | 45 | from urllib.request import Request, urlopen |
45 | 46 |
|
46 | 47 | import tomlrt |
| 48 | +import yaml |
| 49 | +from packaging.version import InvalidVersion, Version |
47 | 50 |
|
48 | 51 | from . import __git_tag_sha__, __version__ |
49 | 52 | from .bundle import get_data_content |
|
57 | 60 | render_thin_caller_for_target, |
58 | 61 | ) |
59 | 62 | from .http import DEFAULT_TIMEOUT |
60 | | -from .metadata import Metadata |
| 63 | +from .metadata import Metadata, all_metadata_keys |
61 | 64 | from .plugin import merge_plugin_settings |
62 | 65 | from .prepare_release import SELF_PIN_COOLDOWN_EXEMPTION |
63 | 66 | from .pyproject import is_python_package, is_python_project, resolve_source_paths |
@@ -1320,6 +1323,8 @@ def _init_workflows( |
1320 | 1323 | ) |
1321 | 1324 |
|
1322 | 1325 | _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) |
1323 | 1328 |
|
1324 | 1329 |
|
1325 | 1330 | def _realign_inline_pins( |
@@ -1396,6 +1401,154 @@ def _realign_inline_pins( |
1396 | 1401 | ) |
1397 | 1402 |
|
1398 | 1403 |
|
| 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 | + |
1399 | 1552 | def is_source_repo(output_dir: Path) -> bool: |
1400 | 1553 | """Detect whether `output_dir` is the repomatic source repository root. |
1401 | 1554 |
|
|
0 commit comments