Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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/**') != ''
Expand All @@ -362,6 +399,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
flags: python-${{ matrix.python-version }}
fail_ci_if_error: false

validate:
Expand Down
22 changes: 21 additions & 1 deletion docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<package>` 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=<package>` 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
Expand Down Expand Up @@ -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@<hub-main-commit-sha> # <release-tag>
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-<version>` 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:
Expand Down
Loading