|
28 | 28 | import json |
29 | 29 | import logging |
30 | 30 | import re |
| 31 | +import shlex |
31 | 32 | from dataclasses import dataclass |
32 | 33 | from functools import cached_property |
33 | 34 | from pathlib import Path |
|
48 | 49 | TEST_RUNNERS_PR, |
49 | 50 | UNSTABLE_PYTHON_VERSIONS, |
50 | 51 | ) |
51 | | -from .metadata import Dialect, Metadata |
| 52 | +from .metadata import ( |
| 53 | + METADATA_VALUE_OPTIONS, |
| 54 | + Dialect, |
| 55 | + Metadata, |
| 56 | + all_metadata_keys, |
| 57 | +) |
52 | 58 | from .pypi import ( |
53 | 59 | PYPI_TRUSTED_PUBLISHER_WORKFLOW, |
54 | 60 | get_latest_release_file, |
@@ -1775,6 +1781,151 @@ def check_inline_pins_match_upstream( |
1775 | 1781 | ) |
1776 | 1782 |
|
1777 | 1783 |
|
| 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 | + |
1778 | 1929 | def check_pr_templates( |
1779 | 1930 | workflow_dir: Path = WORKFLOW_DIR, |
1780 | 1931 | template_dir: Path = PR_TEMPLATE_DIR, |
@@ -2148,6 +2299,9 @@ def _pat_permissions(ctx: LintContext) -> Iterator[CheckResult]: |
2148 | 2299 | lambda ctx: check_inline_pins_match_upstream(), |
2149 | 2300 | fatal=True, |
2150 | 2301 | ), |
| 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), |
2151 | 2305 | RepoCheck("pr-templates", lambda ctx: check_pr_templates()), |
2152 | 2306 | RepoCheck( |
2153 | 2307 | "virustotal-secret", |
|
0 commit comments