diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 4285923ed..0a1491ebc 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -1,9 +1,9 @@ name: Validate task # The fleet validation gate, hosted here once and reached by every repo's test-pull-request stub and its own publish-release stub. -# Three jobs: lint (the fleet doc-lint block plus language lint by tree detection, the prose gate, and the repo gate), unit-test (a generic dotnet test or pytest, skipped where the caller has no test project), and validate (the validate hook, a repo's own domain checks such as an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests). +# Three jobs: lint (the fleet doc-lint block plus language lint by tree detection, the prose gate, and the repo gate), unit-test (a generic dotnet test or pytest, skipped where the caller has no test project, and run once per interpreter the python-versions input names), and validate (the validate hook, a repo's own domain checks such as an ESPHome compile, a Hugo build, a KiCad ERC, a codegen-drift check, or PowerShell tests). # No permissions beyond contents: read where a job needs one, since every job here only checks out and reads. -# No required inputs, markdown-exclude-globs and repo-gate-exclude-globs are the two optional inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. +# No required inputs, markdown-exclude-globs, repo-gate-exclude-globs and python-versions are the three optional inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. # Hub-owned gates and default hooks resolve through $/ at the reusable workflow's commit, so each implementation is reproducible against the caller's released pin without a second checkout. on: workflow_call: @@ -20,6 +20,12 @@ on: required: false type: string default: '' + # Read through fromJSON into the unit-test job's matrix, which is expanded before any step runs, so the check below reaches only the values that expand at all. + python-versions: + description: JSON array of the Python versions the pytest leg runs under, one leg per entry, for example ["3.13", "3.14"]. Quote each entry, since an unquoted one is a JSON number rather than a version string. + required: false + type: string + default: '["3.13"]' secrets: CODECOV_TOKEN: required: false @@ -260,14 +266,44 @@ jobs: # A caller with neither a *Tests*.csproj nor a tests/ directory beside a pyproject.toml and a dependency manifest runs every step's guard false, and the job reports success having done nothing, which is the clean skip this job promises. # That manifest is a committed uv.lock or a root requirements*.txt, the two dependency mechanisms spec/project-types.json names, so a pip-based Python repo with tests is served here rather than skipped, per D1.6. # What excludes the lint-only Python profile here is the root tests/ guard rather than the manifest one, that profile being tooling scripts embedded in a non-Python repo rather than a tested package at the root. + # The matrix dimension is the interpreter, and a caller naming none runs the single leg this job ran before the input existed. + # The leg names an interpreter whether or not one ran, so a .NET-only caller reports a Python version for a run whose Python steps were all guarded off. + # A caller naming several also repeats this job's .NET half once per leg, which spends runner minutes and re-uploads an identical report rather than measuring anything new, and splitting the two halves into jobs of their own is what that costs, owed by the first repo to carry both a .NET test project and more than one interpreter. unit-test: - name: Unit test job + name: Unit test job (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest permissions: contents: read + strategy: + # A failing interpreter must not cancel its siblings, since which of them fail is the whole point of running more than one. + fail-fast: false + matrix: + python-version: ${{ fromJSON(inputs.python-versions) }} steps: + # An entry holding no non-whitespace character is the value that would otherwise run green on an interpreter nobody chose, since setup-uv trims what it reads and then reads an empty input as an absent one. + # A caller reaches it by interpolating an unset value of its own, and a stray space in that template reaches it just as well, which is why the entry test is for content rather than for length. + # An unquoted entry is the other half of the same slip, and what it costs depends on the number fromJSON parsed it into. + # Some of those numbers uv resolves to a real interpreter rather than refusing, so that shape is a second silent pass this check prevents rather than a cause it names. + # The rest fail in the dependency sync below rather than in setup-uv, which exports the version rather than resolving it, and there the check names a cause instead. + # The check reads the raw input rather than matrix.python-version, which by then cannot say whether its 3.2 was written 3.2 or 3.20, and it reaches only a value that expanded into a leg at all. + - name: Validate python-versions input step + env: + PYTHON_VERSIONS: ${{ inputs.python-versions }} + run: | + set -Eeuo pipefail + if ! jq -e 'type == "array" and length > 0 and all(.[]; type == "string" and test("\\S"))' <<<"$PYTHON_VERSIONS" >/dev/null 2>&1; then + # The annotation carries the rejected value, so it is escaped per the workflow command encoding rules first. + # A raw newline would end the annotation and let whatever follows it start a command of its own, and the runner reads a command off stderr as readily as stdout, so redirecting rather than escaping would close nothing. + # The percent substitution runs first, since running it after the others would re-encode the percent each of them just introduced. + escaped=${PYTHON_VERSIONS//'%'/%25} + escaped=${escaped//$'\r'/%0D} + escaped=${escaped//$'\n'/%0A} + echo "::error::The python-versions input must be a non-empty JSON array of non-empty quoted version strings, for example '[\"3.13\", \"3.14\"]', got: $escaped" + exit 1 + fi + - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -305,7 +341,7 @@ jobs: && (hashFiles('uv.lock') != '' || hashFiles('requirements*.txt') != '') uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: - python-version: "3.13" + python-version: ${{ matrix.python-version }} # One resolve over the collected requirements files, since the glob sorts the base file last and a per-file install would let its pins downgrade what the test file just resolved. # The editable install matches what uv sync gives the other branch, and its guard leaves a layout declaring no [project] alone. @@ -353,6 +389,7 @@ jobs: fi # Best-effort: continue-on-error plus fail_ci_if_error false, so a missing token never reds the gate. + # The flag names the leg, so a multi-interpreter repo reads its legs apart in Codecov rather than seeing one merged number for all of them. - name: Upload coverage to Codecov step (Python) if: >- hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' @@ -362,6 +399,7 @@ jobs: with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml + flags: python-${{ matrix.python-version }} fail_ci_if_error: false validate: diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index be83e14fd..5841e3d17 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -156,7 +156,7 @@ Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language - [x] Hook override path observed on a hub pull request run, [proof run][override-path-run] (runs `./.github/actions/validate`, no hub checkout). Default path observed on PhotoCleaner's adoption pull request, [pilot smoke run][pilot-smoke-run], where the hub's `validate-default` ran because that repo carries no `validate` hook. The follow-up self-reference pilot also runs the bundled prose and repository gates through `$/.github/actions/` without checking out the hub. - [x] PhotoCleaner (pilot, release trigger shape with smoke, the same repo that piloted stage 1): ptr727/PhotoCleaner#55 on `develop` (`c80cb29`), promoted in ptr727/PhotoCleaner#56 (`fa91db0`), both on 2026-08-16. `test-pull-request.yml` calls the hub validate task and no repo hook was needed. - [ ] HomeAutomation-Config (second pilot, operational trigger shape) -- [ ] The remaining repos, one checkbox each added when the pilots close, since the sweep list is every cataloged repo. A Python adopter owes one precondition before its bump: the `unit-test` job fails when the run wrote no root `coverage.xml`, and that job's Python leg runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest it installs from, a committed `uv.lock` or a root `requirements*.txt`, so an adopter of that shape puts `pytest-cov` among its test dependencies and a `--cov=` selector in its own `pyproject.toml` before it bumps, per D1.6, which binds that selector for every Python repo with tests, lint-only excepted. aiopurpleair and Financial-Modeling carry both. homeassistant-purpleair owes the same precondition, its `requirements*.txt` and `tests/` reaching the leg though it carries no `uv.lock`, and its adoption is a design question rather than a bump, its pytest run being a matrix over several Home Assistant versions on Python 3.14 where the hub leg pins 3.13 and expresses no matrix at all. PlexCleaner's Python is a stdlib-only tooling subtree with no tests, and ESPHome-Config's Python is lint-only. The hub cannot smoke-test this itself, having no `tests/` of its own. +- [ ] The remaining repos, one checkbox each added when the pilots close, since the sweep list is every cataloged repo. A Python adopter owes one precondition before its bump: the `unit-test` job fails when the run wrote no root `coverage.xml`, and that job's Python leg runs where the repository root carries `pyproject.toml`, `tests/`, and a dependency manifest it installs from, a committed `uv.lock` or a root `requirements*.txt`, so an adopter of that shape puts `pytest-cov` among its test dependencies and a `--cov=` selector in its own `pyproject.toml` before it bumps, per D1.6, which binds that selector for every Python repo with tests, lint-only excepted. aiopurpleair and Financial-Modeling carry both. homeassistant-purpleair owes the same precondition, its `requirements*.txt` and `tests/` reaching the leg though it carries no `uv.lock`, and its adoption is a design question rather than a bump, its pytest run being a matrix over several Home Assistant versions on Python 3.14. The `python-versions` input settles the interpreter half, a caller naming `["3.14"]` moving the leg there, and leaves the dependency-version axis open, which is a dimension the hub task does not express. PlexCleaner's Python is a stdlib-only tooling subtree with no tests, and ESPHome-Config's Python is lint-only. The hub cannot smoke-test this itself, having no `tests/` of its own. - [ ] `reports/workflow-reuse.md` regenerated with `validate-task.yml` at 0 copies (a hub-only file no repo carries) and `test-pull-request.yml` showing callers equal to copies. ### Stage 3: The Pure Functions @@ -396,6 +396,26 @@ The repo gate's `sha-pin` and `eol-coverage` checks read the same tracked-file l `validate-task.yml` passes each line straight through to the `repo-gate` composite action's own `exclude-globs` input. That input turns each line into a `--exclude` argument for `repo_gate.py`. Unlike `markdown-exclude-globs`, a line here is never negated: it is always a pathspec to drop from `git ls-files`. So `themes/PaperMod/**` excludes, rather than `!themes/PaperMod/**`. +A repo whose package claims more than one interpreter names them all in `python-versions`, a JSON array the `unit-test` job reads through `fromJSON` as its matrix, one leg per entry: + +```yaml + validate: + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # + permissions: + contents: read + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + python-versions: '["3.13", "3.14"]' +``` + +The default is `["3.13"]`, so a caller naming nothing runs the single leg this job has always run. Each leg uploads under a `python-` Codecov flag of its own, which keeps the legs apart in the dashboard rather than merging them into one number, and `fail-fast: false` reports a failing interpreter beside a passing one rather than cancelling the run at the first. Two things move on adoption. The nested check reads `Unit test job (Python 3.13)` rather than `Unit test job`, which binds no ruleset, only the caller's own aggregator being bound, and it reads that way whether or not a Python step ran, so a .NET-only caller reports a version it never used. And a repo that already uploaded Python coverage here uploaded it unflagged, so its series stops there and a `python-3.13` one starts beside it. + +Quote each entry, and give each one a version. The `unit-test` job's entry step refuses an array holding an entry with no non-whitespace character, which is the value that would otherwise run green on an interpreter nobody chose: `setup-uv` trims what it reads and then reads an empty input as an absent one, and a caller reaches that by interpolating an unset value of its own, or by leaving a stray space beside it. It refuses an unquoted entry too, since `fromJSON` admits a JSON array of numbers, and what that costs depends on the number. Some of them uv resolves to a real interpreter rather than refusing, `'[3]'` among them, so an unquoted entry is a second silent pass the check prevents. The rest fail in the `Sync dependencies step` rather than in `setup-uv`, which exports the version rather than resolving it, and there the check names a cause instead. An empty array, and anything that is not a JSON array at all, never reach the step: they fail while the matrix is expanded, loudly and with an unnamed cause. + +Two things are deliberately outside the input. A caller carrying a .NET test project as well repeats that half of the job once per leg, the matrix being on the job rather than on its Python steps, so naming a second version there buys runner minutes rather than a second measurement. And the `lint` job stays on the one interpreter the task pins, which costs a repo two different things. Its type check is performed at that interpreter, `mypy` and `pyright` each defaulting their target to the one they run under where the repo's own `[tool.mypy]` or `[tool.pyright]` section does not name a version, while `ruff` takes its target from `requires-python` where the `pyproject.toml` declares a `[project]` table and from its own default where it does not. That much a repo settles in its own configuration. What it cannot settle there is a lockfile: a repo carrying a `uv.lock` whose `requires-python` floor is above the pinned interpreter fails the `Sync Python dependencies step` outright, and no value of `python-versions` moves that job. + ## Adopting the Pure Functions Neither `get-version-task.yml` nor `publish-plan-task.yml` has a caller-stub snippet of its own, since a caller reaching either one is a job inside a repo's own `publish-release.yml` or a future `build-release-task.yml`, not a standalone top-level workflow. A repo whose publisher reads NBGV's version outputs directly, without carrying the whole release orchestrator, reaches `get-version-task.yml` by pin in place of its own copy: diff --git a/scripts/tests/test_release_guards.py b/scripts/tests/test_release_guards.py index 3e43b46fe..7f1a697d1 100755 --- a/scripts/tests/test_release_guards.py +++ b/scripts/tests/test_release_guards.py @@ -3,7 +3,10 @@ from __future__ import annotations +import json +import os import re +import shutil import unittest from pathlib import Path from subprocess import run @@ -262,6 +265,146 @@ def test_validator_python_leg_reaches_a_pip_dependency_repo(self) -> None: self.assertIn("uv pip install -e .", job) self.assertIn(r"grep -Eq '^[[:space:]]*\[project\]' pyproject.toml", job) + def test_validator_pytest_leg_fans_out_over_every_named_interpreter(self) -> None: + """A pinned interpreter drops an adopter's other legs with nothing failing or warning. + + The required check goes green on the one version the task ran while the repository goes on + claiming the rest, so the fan-out is asserted here rather than left to be read by eye. + """ + workflow = (REPO / ".github/workflows/validate-task.yml").read_text(encoding="utf-8") + job = workflow.split("\n unit-test:\n", 1)[1].split("\n validate:\n", 1)[0] + + # The default's shape is asserted and its content is not. + # Neither bumping the fleet default nor adding a second interpreter to it is a regression here. + declaration = workflow.split(" python-versions:\n", 1)[1].split("\n secrets:", 1)[0] + default = re.search(r"(?m)^ default: ['\"](.*)['\"]$", declaration) + self.assertIsNotNone(default) + assert default is not None + versions = json.loads(default.group(1)) + self.assertTrue(versions) + self.assertTrue(all(isinstance(version, str) for version in versions)) + + # An explicit name: is used verbatim rather than falling back to a matrix-suffixed default. + # Without the interpolation every leg renders one indistinguishable check name. + self.assertIn(" name: Unit test job (Python ${{ matrix.python-version }})\n", job) + + # The matrix reads the input and the uv setup reads the matrix, so no literal survives between them. + self.assertIn(" python-version: ${{ fromJSON(inputs.python-versions) }}\n", job) + self.assertIn(" python-version: ${{ matrix.python-version }}\n", job) + self.assertNotIn('python-version: "', job) + + # One interpreter failing must not cancel the others, which is what a second leg is run to learn. + self.assertIn(" fail-fast: false\n", job) + + # Without a flag naming its leg, each upload merges into one number that hides which leg it came from. + self.assertIn(" flags: python-${{ matrix.python-version }}\n", job) + + @unittest.skipUnless(shutil.which("jq"), "jq is what the step under test runs") + def test_validator_refuses_a_python_versions_value_fromjson_would_admit(self) -> None: + """fromJSON admits a JSON array of numbers, which is not a list of interpreter versions. + + An empty entry is the value that would otherwise carry a run green on an interpreter nobody + chose, since setup-uv reads an empty input as an absent one. An unquoted entry is admitted + by fromJSON as a number, and uv resolves some of those rather than refusing them, so that + shape carries a run green too. The step's whole script is run here, not just its filter, since + inverting the condition or exiting zero would leave a filter-only assertion green while + the step admitted everything. + """ + workflow = (REPO / ".github/workflows/validate-task.yml").read_text(encoding="utf-8") + job = workflow.split("\n unit-test:\n", 1)[1].split("\n validate:\n", 1)[0] + + # The guard has to precede the steps it guards, so its position is asserted, not just its presence. + # Matched on the dash rather than on a name: key, or a step leading with uses: would slip in ahead unseen. + first_step = re.search(r"(?m)^ - (.*)$", job) + self.assertIsNotNone(first_step) + assert first_step is not None + self.assertEqual("name: Validate python-versions input step", first_step.group(1)) + + marker = " - name: Validate python-versions input step\n" + self.assertIn(marker, job) + body = job.split(marker, 1)[1] + opener = re.search(r"(?m)^ run: \|-?\n", body) + self.assertIsNotNone(opener, "the guard's script must be a literal block scalar") + assert opener is not None + script = body[opener.end() :] + lines: list[str] = [] + for line in script.splitlines(): + if line and not line.startswith(" " * 10): + break + lines.append(line[10:]) + script = "\n".join(lines) + self.assertIn("jq -e", script) + + cases = { + # Reachable: a non-empty JSON array expands into legs whatever its entries hold. + '["3.13"]': 0, + '["3.13", "3.14"]': 0, + # Reached by interpolating an unset value, and green on an unpinned interpreter if admitted. + '[""]': 1, + # The same path, since setup-uv trims before it decides an input is absent. + '[" "]': 1, + # The plausible slip, the input being a quoted string already, and well-formed JSON. + "[3.13, 3.14]": 1, + # The integer form of it, which uv resolves rather than refuses. + "[3]": 1, + # Unreachable today: each of these fails while the matrix is expanded, before the step runs. + # They pin the rest of the filter's contract, which moving the check into a job of its own would ask for. + "[]": 1, + '"3.13"': 1, + "{}": 1, + "3.13": 1, + "": 1, + } + for value, expected in cases.items(): + with self.subTest(value=value): + verdict = run( + ["bash", "-c", script], + env={**os.environ, "PYTHON_VERSIONS": value}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(expected, verdict.returncode) + + # The annotation carries the rejected value back. + # The runner reads a workflow command off any line starting with one, so an unescaped newline starts a second. + injected = '["3.13"]\n::error::injected' + verdict = run( + ["bash", "-c", script], + env={**os.environ, "PYTHON_VERSIONS": injected}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(1, verdict.returncode) + emitted = (verdict.stdout + verdict.stderr).splitlines() + self.assertEqual(1, len(emitted)) + self.assertIn("%0A", emitted[0]) + + # A carriage return is the third escape, and without this nothing here would notice its removal. + verdict = run( + ["bash", "-c", script], + env={**os.environ, "PYTHON_VERSIONS": "x\ry"}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(1, verdict.returncode) + self.assertIn("got: x%0Dy", verdict.stdout + verdict.stderr) + + # A bare percent must reach the annotation encoded. + # Their order is what the %0A assertion above covers instead, not this one. + # Substituting the percent last renders a newline %250A, which fails there and passes here. + verdict = run( + ["bash", "-c", script], + env={**os.environ, "PYTHON_VERSIONS": "%"}, + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(1, verdict.returncode) + self.assertIn("got: %25", verdict.stdout + verdict.stderr) + def test_audit_bash_blocks_are_not_labeled_as_posix_shell(self) -> None: audit_lines = (REPO / "AUDIT.md").read_text(encoding="utf-8").splitlines() bash_only = ("<(", "<<<", "$'", "[[")