From 198e545998b97b8d4d66086d3864d7262b1af6a0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sat, 15 Aug 2026 20:52:48 -0700 Subject: [PATCH 01/14] Host the Validate Task and Reshape the Test Pull Request Stub Hosts the fleet's validation gate once, as validate-task.yml: a lint job (the doc-lint block, language lint by tree detection, the prose gate, and the repo gate), a generic unit-test job, and a validate job resolving a validate hook for a repo's own domain checks. This is the contract spec/fidelity-model.md could not express for a copied file, per docs/reusable-workflows.md "Why". There is no test-pull-request-task.yml: the ruleset-bound aggregator stays in the caller stub by design, and a second hub task wrapping one line that calls validate-task.yml hosts nothing generic. The two stub shapes, operational and release, live in docs/reusable-workflows.md "Adopting the Gates" instead, closing #585 by construction. The hub carries its own validate hook (.github/actions/validate), moving its registry and spec check, its script self-tests, its fleet-skills check, and its unclassified-character report out of the old validate-task.yml body. This makes the hub exercise the hook's override path on every hub pull request, and a repo with no hook of its own exercises the new no-op default at .github/actions/validate-default. Closes #729 by pinning uvx tools at @latest in the one place they now live, Dependabot-tracked through the action SHA that installs them. validate-task.yml stops being a manifest entry in spec/files.json, since the hub now hosts it rather than every repo carrying a copy. spec/divergences.json gets a retire disposition naming the thirteen current carriers. test-pull-request.yml's interface contract gains a requireTokensInJob check so a stub still carrying an inline lint job is caught rather than passed. scripts/prose_lint.py's HUB_HOSTED literal and its own self-test both cover the new retired path. --- .github/actionlint.yaml | 6 + .github/actions/validate-default/action.yml | 12 + .github/actions/validate/action.yml | 50 ++++ .github/workflows/validate-task.yml | 273 ++++++++++++++------ GOVERNANCE.md | 2 +- TODO.md | 9 +- docs/reusable-workflows.md | 143 +++++++++- scripts/prose_lint.py | 1 + spec/audit.py | 30 +++ spec/divergences.json | 3 +- spec/files.json | 3 +- 11 files changed, 445 insertions(+), 87 deletions(-) create mode 100644 .github/actionlint.yaml create mode 100644 .github/actions/validate-default/action.yml create mode 100644 .github/actions/validate/action.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000..7a74aa3f --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,6 @@ +# The bundled context schema predates the job context's workflow_sha, workflow_ref, workflow_repository, and workflow_file_path fields, so a valid, documented reference to job.workflow_sha reads as an unknown property. +# Scoped to the one file that reads it today, so a genuine unknown-property regression elsewhere still fails. +paths: + .github/workflows/validate-task.yml: + ignore: + - 'property "workflow_sha" is not defined in object type' diff --git a/.github/actions/validate-default/action.yml b/.github/actions/validate-default/action.yml new file mode 100644 index 00000000..7e455945 --- /dev/null +++ b/.github/actions/validate-default/action.yml @@ -0,0 +1,12 @@ +# The hub default for the validate hook, used when a caller carries no .github/actions/validate/action.yml of its own. +# A repo with no domain checks of its own (no ESPHome compile, no Hugo build, no KiCad ERC, no codegen drift, no PowerShell tests) has nothing for this hook to run. +# The fleet doc-lint block plus the generic unit-test job already cover everything else validate-task.yml checks. +name: Validate repository (default) +description: No-op default for the validate hook. + +runs: + using: composite + steps: + - name: No repository-specific validation step + shell: bash + run: echo "no repository-specific validate hook, nothing to run" diff --git a/.github/actions/validate/action.yml b/.github/actions/validate/action.yml new file mode 100644 index 00000000..cee7a16b --- /dev/null +++ b/.github/actions/validate/action.yml @@ -0,0 +1,50 @@ +# The hub's own validate hook, run by validate-task.yml's validate job for this repo. +# The fleet doc-lint block and the generic unit-test job in validate-task.yml do not cover the hub's own machinery. +# Carrying this hook makes the hub exercise the override path on every hub pull request, where a repo with no hook of its own exercises the hub default at .github/actions/validate-default. +name: Validate repository (hub) +description: Registry and spec validation, the script self-test suite, the fleet-skills freshness check, and the unclassified-character report. + +runs: + using: composite + steps: + + - name: Validate registry and spec step + shell: bash + run: | + set -Eeuo pipefail + for f in registry/*.json spec/*.json repo-config/*.json; do + jq empty "$f" + done + python3 spec/validate.py + + - name: Setup uv step + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.13" + + # Each gate here is proven by a case that reintroduces the fault it catches, wherever it lives. + # A test sits with the layer that owns it rather than beside the file it names, so a read-only gate over the spec and the host tooling lives in scripts/tests/ even where its subject is shell. + # The agent-safety kit carries its own tests because it ships as a unit. + # Every run goes through coverage with --append, so one report covers the whole self-test surface, informational with no threshold adopted. + - name: Run script self-tests step + shell: bash + run: | + set -Eeuo pipefail + uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests + uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest + uvx coverage@latest run --source=scripts,spec,host-setup --append spec/workflow_reuse.py --selftest + uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest + uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py + uvx coverage@latest report + + # Read-only: fails if .claude-plugin/fleet-skills/ was not regenerated from .agents/skills/. + - name: Check fleet skills are current step + shell: bash + run: python3 scripts/build_dist.py --check + + # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. + # A finding here names a character no tier covers, and classifying it is a fleet-law edit rather than a prose fix. + - name: Report unclassified characters step + shell: bash + continue-on-error: true + run: python3 scripts/prose_lint.py . --check charset-unknown --summary diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 7a416c61..99ea4152 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -1,13 +1,18 @@ name: Validate task -# The single validation gate - reused by test-pull-request (feeding the required check) and publish-release. - +# 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 uv run 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). +# No permissions beyond contents: read where a job needs one, since every job here only checks out and reads. +# No required inputs, and CODECOV_TOKEN is the one optional secret, since coverage upload is best-effort. +# The hook resolution in the validate job checks out the hub at job.workflow_sha under .hub, the exact commit the caller pinned, so a hub default or a hub script is reproducible against a released pin. on: workflow_call: + secrets: + CODECOV_TOKEN: + required: false jobs: - # Source-only repo: lint-only validation, using the same configs the editor and CLI use (linter parity); no build or tests. lint: name: Lint sources job runs-on: ubuntu-latest @@ -16,24 +21,28 @@ jobs: steps: - # Full history, since the dead-path prose rule keys on deletion history. - # A shallow clone holds none, and the rule stands down there rather than pass blind. + # Full history, since the dead-path prose rule keys on deletion history and the prose gate diffs against a base branch. + # A shallow clone holds neither, and both stand down there rather than pass blind. - name: Checkout code step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - # Doc linters run as pinned action wrappers. - # The editorconfig-checker action is install-only, so it runs via Docker instead. + # The hub at the exact commit the caller pinned, for the prose gate's bundled script and the repo gate. + - name: Checkout hub step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ptr727/ProjectTemplate + ref: ${{ job.workflow_sha }} + path: .hub + + # The fleet doc-lint block, hosted once rather than carried by every repo of every type. - name: Lint Markdown step uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 with: globs: '**/*.md' - # The cspell gate covers README + HISTORY only. - # Gating all *.md would mean endlessly padding cspell.json for technical terms. - # Broad live spell-check is the editor extension's job. - # See CODESTYLE.md "Markdown and Spelling". + # The spell check covers README + HISTORY only, per CODESTYLE.md "Markdown and Spelling". - name: Spell check step uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0 with: @@ -48,93 +57,213 @@ jobs: - name: Check EditorConfig step run: docker run --rm --pull=always -v "$PWD":/check --workdir /check mstruebing/editorconfig-checker:latest - # The file list comes from git rather than a glob, so a script added later is gated without editing this step. - # A finding that is correct for the shell and wrong for the program it quotes carries an inline disable naming the reason, as repo-config/configure.sh does for its jq filter. + # The file list comes from git rather than a glob, and the docker run is skipped rather than invoked on an empty argument list. + # That is how a repo with no shell scripts stays clean instead of failing on shellcheck's own no-file usage error. - name: Check shell scripts step run: | set -Eeuo pipefail mapfile -t scripts < <(git ls-files '*.sh') - docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" - - # The peer of the step above, built the same way: the file list comes from git, and the checker runs as a container rather than an install. - # The module version is pinned beside the image because the image alone does not fix it, and a floating install would make this a different check here than the one a maintainer runs locally. - # 1.23.0 rather than the newest: 1.24.0 needs a newer System.Management.Automation than this image carries, so it installs and then fails to import, which reads as a broken gate rather than a version mismatch. - # The count is printed because a checker that read no files reports the same clean as one that read them all, which is how this step first passed having read nothing. - # The list splits on whitespace rather than on a newline, because the same command is documented for a local run and a PowerShell caller joins the file list with spaces where a shell joins it with newlines. - # Splitting on the newline alone hands the analyzer one path holding every file, which it reports as one file it cannot find and then a count of one and no findings, having analyzed nothing at all. + if [ "${#scripts[@]}" -gt 0 ]; then + docker run --rm --pull=always -v "$PWD":/mnt --workdir /mnt koalaman/shellcheck:stable "${scripts[@]}" + else + echo "no shell scripts are tracked" + fi + + # The peer of the step above: the file list comes from git, and the container only starts when there is something for it to check. + # A repo with no .ps1 files pays nothing for the pull. + # 1.23.0 rather than the newest, since 1.24.0 needs a newer System.Management.Automation than this image carries, which installs and then fails to import. - name: Check PowerShell scripts step run: | set -Eeuo pipefail - PS_SCRIPTS="$(git ls-files '*.ps1')" - docker run --rm --pull=always -e PS_SCRIPTS="$PS_SCRIPTS" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ - pwsh -NoProfile -Command ' - Set-PSRepository PSGallery -InstallationPolicy Trusted - Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers - Import-Module PSScriptAnalyzer - $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } - if (-not $files) { Write-Host "no PowerShell scripts are tracked"; exit 0 } - $found = @() - foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } - Write-Host "Checked $($files.Count) file(s)" - if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } - Write-Host "no findings" - ' - - # The Python lint and type gates for the Scripts profile, CODESTYLE.md "Python". - # @latest rather than a version pin, since a `uvx @` pin is nothing Dependabot tracks and would silently go stale, where the action SHA below is tracked. + mapfile -t ps_scripts < <(git ls-files '*.ps1') + if [ "${#ps_scripts[@]}" -gt 0 ]; then + docker run --rm --pull=always -e PS_SCRIPTS="$(git ls-files '*.ps1')" -v "$PWD":/mnt --workdir /mnt mcr.microsoft.com/powershell:latest \ + pwsh -NoProfile -Command ' + Set-PSRepository PSGallery -InstallationPolicy Trusted + Install-Module PSScriptAnalyzer -RequiredVersion 1.23.0 -Force -Scope AllUsers + Import-Module PSScriptAnalyzer + $files = $env:PS_SCRIPTS -split "\s+" | Where-Object { $_ } + $found = @() + foreach ($file in $files) { $found += Invoke-ScriptAnalyzer -Path $file -Settings ./PSScriptAnalyzerSettings.psd1 } + Write-Host "Checked $($files.Count) file(s)" + if ($found) { $found | Format-Table RuleName,Severity,ScriptName,Line,Message -AutoSize | Out-String -Width 200 | Write-Host; exit 1 } + Write-Host "no findings" + ' + else + echo "no PowerShell scripts are tracked" + fi + + # Language lint by tree detection: a caller carries a language marker file or it does not. + # No per-repo input and no registry read decide it, only what the checkout actually holds. + - name: Setup .NET SDK step + if: hashFiles('**/*.csproj') != '' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.x + + - name: Restore dotnet tools step + if: >- + hashFiles('**/*.csproj') != '' && hashFiles('.config/dotnet-tools.json') != '' + run: dotnet tool restore + + - name: Check C# formatting step + if: hashFiles('**/*.csproj') != '' + run: dotnet csharpier check . + + - name: Check C# style step + if: hashFiles('**/*.csproj') != '' + run: dotnet format style --verify-no-changes + + # @latest rather than a version pin, since a `uvx @` pin is nothing Dependabot tracks and would silently go stale, where the action SHA above is Dependabot-tracked. + # This is #729, decided here, the one place the fleet's uvx tools are pinned or floated. - name: Setup uv step + if: hashFiles('pyproject.toml') != '' uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: - # The interpreter for the uv-run steps matches the pyproject py313 target, bumped in lockstep with it. python-version: "3.13" - name: Lint Python step + if: hashFiles('pyproject.toml') != '' run: uvx ruff@latest check . - name: Check Python formatting step + if: hashFiles('pyproject.toml') != '' run: uvx ruff@latest format --check . - - name: Type check Python step - run: uvx mypy@latest + # Mypy and pyright are independent: a pyproject declaring both sections runs both, and a pyproject declaring only one runs that one. + # Neither runs unless its own section exists. + - name: Type check Python (mypy) step + if: hashFiles('pyproject.toml') != '' + run: | + set -Eeuo pipefail + if grep -q '^\[tool\.mypy\]' pyproject.toml; then + uvx mypy@latest + else + echo "no [tool.mypy] section, skipping" + fi - - name: Validate registry and spec step + - name: Type check Python (pyright) step + if: hashFiles('pyproject.toml') != '' run: | set -Eeuo pipefail - for f in registry/*.json spec/*.json repo-config/*.json; do - jq empty "$f" - done - python3 spec/validate.py - - # Each gate here is proven by a case that reintroduces the fault it catches, wherever it lives. - # A test sits with the layer that owns it rather than beside the file it names, so a read-only gate over the spec and the host tooling lives in scripts/tests/ even where its subject is shell, and the agent-safety kit carries its own because it ships as a unit. - # The tests are standard library only, so uvx coverage at its unpinned latest is the one tool the step fetches. - # The audit engine self-test and the workflow-reuse measurement self-test are offline, so they run here rather than only on an owner sweep. - # The write-guard self-test is offline too, and it otherwise runs only when a host installs the hook, which is where a regression in it would surface as a broken machine. - # Every run goes through coverage with --append, so one report covers the whole self-test surface, informational with no threshold adopted. - - name: Run script self-tests step + if grep -q '^\[tool\.pyright\]' pyproject.toml; then + uvx pyright@latest + else + echo "no [tool.pyright] section, skipping" + fi + + # The fleet prose rules, scoped to a pull request's own diff so the existing backlog blocks nothing. + # This mirrors .github/actions/prose-gate/action.yml's branch rule. + # A main-target run reads the bundled script at the exact commit the caller pinned, the .hub checkout above. + # Every other target reads the live hub develop copy, so an unpromoted rule change is exercised fleet-wide before it reaches main. + # Skipped outside a pull request, since there is no base to diff and a publish run's content was already gated when it was pushed. + - name: Check prose step + if: github.event_name == 'pull_request' run: | set -Eeuo pipefail - uvx coverage@latest run --source=scripts,spec,host-setup -m unittest discover -s scripts/tests - uvx coverage@latest run --source=scripts,spec,host-setup --append spec/audit.py --selftest - uvx coverage@latest run --source=scripts,spec,host-setup --append spec/workflow_reuse.py --selftest - uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/gh-write-guard.py --selftest - uvx coverage@latest run --source=scripts,spec,host-setup --append host-setup/agent-safety/test_install.py - uvx coverage@latest report + if [ "$BASE" = "main" ]; then + script=.hub/scripts/prose_lint.py + else + script="$RUNNER_TEMP/prose_lint.py" + curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ + "https://raw.githubusercontent.com/ptr727/ProjectTemplate/develop/scripts/prose_lint.py" -o "$script" + fi + if ! git rev-parse --verify --quiet "origin/$BASE^{commit}" >/dev/null; then + echo "::error::Diff base 'origin/$BASE' does not resolve in this checkout." >&2 + exit 1 + fi + python3 "$script" --diff "origin/$BASE" --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path . + env: + BASE: ${{ github.base_ref }} - name: Check repo gates step - run: python3 scripts/repo_gate.py + run: python3 .hub/scripts/repo_gate.py - # Read-only: fails if .claude-plugin/fleet-skills/ was not regenerated from .agents/skills/. - - name: Check fleet skills are current step - run: python3 scripts/build_dist.py --check + # No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one. + # Every step below carries its own tree-detection guard instead. + # A caller with neither a *Tests*.csproj nor a tests/ directory beside a pyproject.toml runs every step's guard false, and the job reports success having done nothing, which is the clean skip this job promises. + unit-test: + name: Unit test job + runs-on: ubuntu-latest + permissions: + contents: read - # Every default prose rule is clean tree-wide, so all but one block a change that adds a finding. - # `charset-unknown` is the exception and reports in the step below, for the reason given there. - - name: Check prose step - run: python3 scripts/prose_lint.py . --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path + steps: + + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET SDK step + if: hashFiles('**/*Tests*.csproj') != '' + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.x - # Warn-only, and visible rather than absent: an unrun check is one nobody acts on. - # A finding here names a character no tier covers, and classifying it is a fleet-law edit rather than a prose fix. - - name: Report unclassified characters step + # --collect drives coverlet.collector to emit Cobertura XML into ./coverage//. + - name: Run unit tests step + if: hashFiles('**/*Tests*.csproj') != '' + run: dotnet test --collect:"XPlat Code Coverage" --results-directory ./coverage + + # Report-only: fail_ci_if_error is false so a Codecov hiccup or an absent token never fails the gate. + - name: Upload coverage to Codecov step + if: hashFiles('**/*Tests*.csproj') != '' + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + directory: ./coverage + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Setup uv step + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + python-version: "3.13" + + - name: Sync dependencies step + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + run: uv sync --all-groups --frozen + + - name: Run pytest step + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + run: uv run pytest --cov-report=xml + + # Best-effort: continue-on-error plus fail_ci_if_error false, so a missing token never reds the gate. + - name: Upload coverage to Codecov step (Python) + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' continue-on-error: true - run: python3 scripts/prose_lint.py . --check charset-unknown --summary + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + fail_ci_if_error: false + + validate: + name: Validate repository job + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # The hub default runs from this checkout, so it is fetched only when the caller carries no hook of its own. + - name: Checkout hub step + if: hashFiles('.github/actions/validate/action.yml') == '' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ptr727/ProjectTemplate + ref: ${{ job.workflow_sha }} + path: .hub + + # A local composite action resolves at step time from the workspace, which is what makes this fallback expressible at all. + # It runs the caller's own hook when it carries one, else the hub's no-op default. + - name: Run repository validate hook step + if: hashFiles('.github/actions/validate/action.yml') != '' + uses: ./.github/actions/validate + + - name: Run default validate hook step + if: hashFiles('.github/actions/validate/action.yml') == '' + uses: ./.hub/.github/actions/validate-default diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c04304df..152b88a6 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -216,7 +216,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (it has no action either). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (it has no action either). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. diff --git a/TODO.md b/TODO.md index ae804272..cc1012e7 100644 --- a/TODO.md +++ b/TODO.md @@ -239,14 +239,15 @@ One pull request per stage moving a standard workflow out of every repo and into **State** `ready` for the gates, `blocked` on the gates for everything after. **Touches** the hub's `.github/workflows/`, [`spec/files.json`][files], [`catalog/snippets/workflows/`][workflows], and [`WORKFLOW.md`][workflow] where a guarantee names a copied job. **Cost** one hub edit per stage plus an adoption per repo on its next visit, and no re-vendor beyond the stub each stage introduces. -- **Host the gates: `validate-task.yml` with a `validate` hook, and `test-pull-request-task.yml` with the fixed aggregator.** The hub owns the per-type doc-lint block once, the hook carries a repo's own tests, and the stub carries the trigger shape, operational or release. This stage is where the hook fallback is first proven live, on the hub for the default and on a pilot for the override. +- **Host the gates: `validate-task.yml` with a `validate` hook.** The hub owns the fleet doc-lint block, the language lint by tree detection, the prose gate and the repo gate in a lint job, a generic unit-test job, and the validate hook for a repo's own domain checks. There is no `test-pull-request-task.yml`: the stub shapes carrying the trigger, operational or release, live in [`docs/reusable-workflows.md`][reusable-workflows-doc] "Adopting the Gates" instead. - **Blocked by** - Nothing. - - **Issue** - None filed. [#585][issue-585] and [#729][issue-729] are settled inside this stage, the first by the operational stub's trigger and the second by the one place the hub validate task pins or floats its `uvx` tools. + - **Issue** - None filed. [#585][issue-585] and [#729][issue-729] are settled by design in this stage, the first by the stub's trigger shape and the second by the one place the hub validate task pins or floats its `uvx` tools. - **Checked** - `develop` at `7c67328` on 2026-08-15, where the report counts 20 copies of `test-pull-request.yml` in 13 variants and 13 copies of `validate-task.yml` in 11, and the doc-lint block (markdownlint, cspell, actionlint, editorconfig-checker) repeats in every one. - - **Open** - Whether the per-type lint steps are selected by an input the stub sets or read from the repo's registry entry through a hub checkout at `github.job_workflow_sha`, since the second needs no per-repo input and the first needs no network read. - - **Open** - Whether a `validate` hook that runs a domain compile (an ESPHome build, a KiCad ERC) is one hook or several, given the two repos carrying such a step run it as a separate job today. - **Settled** - Pilots are PhotoCleaner, which piloted the merge-bot stub in ptr727/PhotoCleaner#53 on 2026-08-15 as a release-model repo with Dependabot, C#, executable and Docker targets, then HomeAutomation-Config for the operational trigger shape, so both shapes are exercised before the sweep. - **Settled** - The step gated on `hashFiles('.github/actions/validate/action.yml') != ''` runs the caller's hook from its own checkout, else the default from a hub checkout under `.hub/`, and a local composite action resolves at step time from the workspace, which is what makes the fallback expressible at all. + - **Settled** - The per-type lint steps are selected by tree detection (`hashFiles` against a `*.csproj` or a `pyproject.toml`), a third option needing neither a per-repo input nor a network read. + - **Settled** - The `validate` hook is one hook, not several. A domain compile, a Hugo build, a KiCad ERC, a codegen-drift check, and PowerShell tests are each a repo's own business behind the same hook, and a repo needing more than one check composes them inside its own composite action. + - **Settled** - `test-pull-request-task.yml` hosts nothing generic. The ruleset-bound aggregator stays in the caller stub by design (a called job's check context would read ` / ` and break the ruleset binding), so the only job left for a second hub task to wrap is one line calling `validate-task.yml`, which a caller stub already writes for itself. - **Host the pure functions: `get-version-task.yml` and `publish-plan-task.yml`.** Neither has a repo-specific line, and the plan job is missing where D4.1 needs it. - **Blocked by** - The gates, only for sequencing, since a repo adopts one stub per visit and the gates come first. diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 97fe7c10..a145f7b8 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -22,6 +22,7 @@ The design for moving the fleet's standard GitHub Actions workflows out of every - [Stage 4: The Release Chain and the Docker Core](#stage-4-the-release-chain-and-the-docker-core) - [Stage 5: The Type-Specific Tasks](#stage-5-the-type-specific-tasks) - [Adopting the Merge-Bot](#adopting-the-merge-bot) +- [Adopting the Gates](#adopting-the-gates) - [Adopting the Pure Functions](#adopting-the-pure-functions) - [What a Pilot Proves](#what-a-pilot-proves) - [Open Decisions](#open-decisions) @@ -46,7 +47,7 @@ A workflow whose job graph is identical across repos of a type is reached, not c ### Layers -1. **The hub reusable workflow**, at `.github/workflows/-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ github.job_workflow_sha }}` under `.hub/`, which is the commit the caller pinned. +1. **The hub reusable workflow**, at `.github/workflows/-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ job.workflow_sha }}` under `.hub/`, which is the commit the caller pinned. 2. **The hook**, a composite action at `.github/actions//action.yml` in the caller's repo. A hub job resolves it in one order: the caller's path when `hashFiles('.github/actions//action.yml')` is non-empty, else the hub default at the same name under `.hub/`. A required hook with no default fails its job with `::error::` naming the missing path. 3. **The caller stub**, downstream, under thirty lines. The audit grades it at `interface` fidelity: the caller job key, the hub task the `uses:` names, and the secrets it maps are the contract, and the `with:` block is the repo's own. 4. **The hub's own use.** The hub calls its own task files by `./` path, so every hub pull request exercises the reusable file at least at parse level, and fully for the workflows the hub itself runs. @@ -80,8 +81,7 @@ The target set. A row exists once its hub task ships, and until then the row is | Hub task | Hooks, at `.github/actions/` in the caller | Hub default | | --- | --- | --- | | `merge-bot-task.yml` | none, extra bot rules are a `with:` input | not applicable | -| `validate-task.yml` | `validate` (repo tests and lint beyond the fleet doc-lint block) | no-op | -| `test-pull-request-task.yml` | none, wires validate, smoke and the aggregator, `smoke` is a boolean input | not applicable | +| `validate-task.yml` | `validate` (a repo's own domain checks, beyond the fleet doc-lint block and the generic unit-test job) | no-op | | `get-version-task.yml`, `publish-plan-task.yml` | none | not applicable | | `build-release-task.yml` | `build-executable`, `build-nuget`, `build-pypi`, `release-assets` (extra files) | executable, nuget and pypi defaults from today's snippets | | `build-docker-task.yml` | `docker-prepare` (extra tags, build-args, matrix), `docker-build-base` | vanilla single-target from `image`, base build required when `build-base` | @@ -147,12 +147,12 @@ Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflow ### Stage 2: The Gates -Hub: `validate-task.yml` hosts the per-type doc-lint block once and calls the `validate` hook for a repo's own tests, deciding #729 in the one place the `uvx` tools are pinned or floated. `test-pull-request-task.yml` wires validate, smoke and the fixed aggregator name, and the stub carries the trigger shape, release or operational, which settles #585. This stage is where the hook fallback is first proven live: the hub carries no hook, so the default runs on every hub pull request, and the pilot's hook proves the override. +Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language lint by tree detection, the prose gate, and the repo gate), a generic `unit-test` job (a `dotnet test` or a `uv run pytest`, skipped cleanly where the caller carries no test project), and a `validate` job resolving the `validate` hook for a repo's own domain checks, which decides #729 in the one place the `uvx` tools are pinned or floated. There is no `test-pull-request-task.yml`: the ruleset-bound aggregator stays in the caller stub by design, and a task wrapping one line that calls `validate-task.yml` hosts nothing generic, so the stub shapes live in [Adopting the Gates](#adopting-the-gates) instead, with the trigger shape, operational or release, settling #585. This stage is where the hook fallback is first proven live: the hub carries its own `validate` hook (its registry and spec check, its script self-tests, its fleet-skills check, and its unclassified-character report), so a hub pull request exercises the override path, and a repo with no hook of its own exercises the default. -- [ ] Hub pull request on `develop` with both tasks, the hub's own stubs, the manifest contracts, and the catalog snippets left for the release that follows. +- [x] Hub pull request on `develop` with the task, the hub's own hook and default, the manifest contracts, and the catalog snippets left for the release that follows, [#760][pr-760]. - [ ] Promoted and released, tag recorded here. -- [ ] Catalog snippets for both stubs pinned to that release. -- [ ] Hook fallback observed on a hub pull request run (default path) and on the pilot (override path), run URLs recorded here. +- [ ] Catalog snippets for both stub shapes in [Adopting the Gates][adopting-the-gates] pinned to that release. +- [ ] Hook override path observed on a hub pull request run and default path observed on a repo with no `validate` hook of its own, run URLs recorded here. - [ ] PhotoCleaner (pilot, release trigger shape with smoke, the same repo that piloted stage 1) - [ ] 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. @@ -246,6 +246,133 @@ The task's inputs are `app-login` (default `ptr727-codegen[bot]`), `rules` (a JS Two copies today filter Dependabot by ecosystem and semver tier before merging. [WORKFLOW.md D8.1][workflow-d8] says every Dependabot tier auto-merges and the required checks are the gate, so those two repos drop the filter on adoption unless the [Open Decisions][open-decisions] below settle otherwise. +## Adopting the Gates + +Adoptable once `validate-task.yml` is released. A downstream repo replaces its own `validate-task.yml` job bodies and its `test-pull-request.yml`'s inline lint job with one of the two stub shapes below, and deletes the copy of `validate-task.yml` per the `retire` disposition in `spec/divergences.json`. The pin is the release that first carries the task, shown here as a placeholder since no release exists yet: `@ # `. `publish-release.yml`'s own `validate` job takes the same `uses:` line. + +**No-build repos** carry the operational trigger shape [WORKFLOW.md "Branch Model"][workflow] states and [#585][issue-585] settles: a direct push to `develop` runs CI advisory (no required check binds the direct-commit allowance), and a `pull_request` to `main` or `develop` runs it pre-merge and actionable. A release-model repo with no build target takes the same stub with a `pull_request: branches: [main, develop]` trigger instead, since it has no direct-commit allowance to keep advisory. + +```yaml +name: Test pull request action + +# Thin caller: the gate is the hub's reusable validate-task.yml, which every fleet repo reaches rather than carries. +# Operational trigger shape (WORKFLOW.md "Branch Model"): a push to develop runs CI advisory, and a pull_request +# to main or develop runs it pre-merge and actionable, which is what makes D1.2 hold on this model too (#585). +on: + push: + branches: [develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + + validate: + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # + permissions: + contents: read + + # GitHub Actions does not support required status checks on conditional jobs, so a single always-run aggregator gates the merge. + # Its name is the ruleset-bound required status-check context: rename it and the ruleset context together. + check-workflow-status: + name: Check pull request workflow status job + runs-on: ubuntu-latest + needs: [validate] + if: always() + steps: + - name: Check workflow results step + run: | + set -Eeuo pipefail + if [[ "${{ needs.validate.result }}" != "success" ]]; then + echo "Job 'validate' did not succeed (${{ needs.validate.result }}); refusing to pass." + exit 1 + fi +``` + +**Release repos with a smoke build** carry the standard `pull_request` trigger, a `changes` paths-filter job (WORKFLOW.md D1.1: each of the repo's own targets gets a filter entry, and `.github/workflows/**` is excluded per D1.4), and a `smoke-build` job. The smoke build calls the repo's own `./.github/workflows/build-release-task.yml` by local path rather than a hub task, since that orchestrator is not hosted until [Stage 4](#stage-4-the-release-chain-and-the-docker-core). + +```yaml +name: Test pull request action + +on: + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + + # Add one filter entry per target this repo builds; a touched target must never fall through unfiltered (D1.1). + changes: + name: Detect changed targets job + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + release: ${{ steps.filter.outputs.release }} + steps: + - name: Checkout code step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Filter changed paths step + id: filter + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + with: + filters: | + release: + - '!.github/workflows/**' + + validate: + name: Validate sources job + uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@ # + permissions: + contents: read + + # Never publishes and never uploads (D1.3): smoke: true disables every publish path in build-release-task. + smoke-build: + name: Smoke build job + needs: [changes] + if: needs.changes.outputs.release == 'true' + uses: ./.github/workflows/build-release-task.yml + secrets: inherit + with: + smoke: true + github: false + dockerhub: false + branch: ${{ github.ref_name }} + + # Treats a skipped smoke-build (an unchanged target) as pass, and blocks on failure or cancelled (D1.5, D7.4). + check-workflow-status: + name: Check pull request workflow status job + runs-on: ubuntu-latest + needs: [changes, validate, smoke-build] + if: always() + steps: + - name: Check workflow results step + run: | + set -Eeuo pipefail + for result in "changes:${{ needs.changes.result }}" "validate:${{ needs.validate.result }}" "smoke-build:${{ needs.smoke-build.result }}"; do + name="${result%%:*}" + value="${result#*:}" + if [[ "$value" != "success" && "$value" != "skipped" ]]; then + echo "::error::Job '$name' did not succeed ($value)." + exit 1 + fi + done +``` + ## 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: @@ -297,6 +424,8 @@ Four things the hub cannot prove fall to the first downstream adopter. They are [governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling [governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions +[issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 +[pr-760]: https://github.com/ptr727/ProjectTemplate/pull/760 [secrets]: ../spec/secrets.json [todo]: ../TODO.md [workflow]: ../WORKFLOW.md diff --git a/scripts/prose_lint.py b/scripts/prose_lint.py index 948a23fa..c5b7acf1 100755 --- a/scripts/prose_lint.py +++ b/scripts/prose_lint.py @@ -347,6 +347,7 @@ def path_candidate(token: str, in_span: bool = True) -> str | None: "repo-config/configure.sh", ".github/workflows/get-version-task.yml", ".github/workflows/publish-plan-task.yml", + ".github/workflows/validate-task.yml", } ) diff --git a/spec/audit.py b/spec/audit.py index 2b36b2d9..9d1ff316 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2068,6 +2068,24 @@ def _selftest(): "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job", } + # The validate-task.yml stub contract: a caller's validate job must reach the hub task by name. + # A stub still carrying an inline lint job, the shape adoption replaces, is caught rather than passed as interface. + pr_stub_contract = dict(pr_contract, requireTokensInJob={"validate": ["validate-task.yml"]}) + pr_validate_head = ( + "name: Test\non: pull_request\njobs:\n" + " validate:\n" + " name: Validate sources job\n" + " uses: ptr727/ProjectTemplate/.github/workflows/validate-task.yml@" + + "a" * 40 + + " # 2.0.1\n" + ) + pr_validate_inline = ( + "name: Test\non: pull_request\njobs:\n" + " validate:\n" + " name: Validate sources job\n" + " runs-on: ubuntu-latest\n" + " steps:\n - run: echo inline lint\n" + ) gh_rel = ( " github-release:\n needs: [get-version, build-widget]\n runs-on: ubuntu-latest\n steps:\n" " - uses: actions/download-artifact@v4\n with:\n pattern: release-asset-${{ inputs.branch }}-*\n merge-multiple: true\n" @@ -2123,6 +2141,18 @@ def _selftest(): pr_contract, 1, ), + ( + "PR stub validate job reaching the hub validate-task", + pr_validate_head + pr_check, + pr_stub_contract, + 0, + ), + ( + "PR stub validate job still carrying an inline lint job", + pr_validate_inline + pr_check, + pr_stub_contract, + 1, + ), ("conformant release task", rel_ok, rel_contract, 0), ( "release task with an artifact-ids fork in github-release", diff --git a/spec/divergences.json b/spec/divergences.json index 89a3fa5f..4b899a50 100644 --- a/spec/divergences.json +++ b/spec/divergences.json @@ -13,6 +13,7 @@ { "path": "scripts/README.md", "disposition": "accepted", "reason": "A path collision rather than a carry. KiCadLibrary's copy documents its own KiCad tooling (common.py, verify_library.py, build_library.py) beside the scripts it describes, and shares nothing with the hub's fleet-gate documentation. Verified by reading it on 2026-08-10. scripts/ is a generic path, so a repo with its own tooling directory matches this check without carrying anything of the hub's.", "tracking": null }, { "path": "pyproject.toml", "disposition": "investigate", "reason": "The hub gained a config-only Scripts-profile pyproject.toml in #388. Decide whether to track it (intent, appliesTo python) after confirming the python repos carry an equivalent.", "tracking": null }, { "path": ".github/workflows/get-version-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. Every copy is the hub's own NBGV logic with nothing per-repo in it beyond the action pins Dependabot already owns. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, PhotoCleaner, PlexCleaner, VSCode-Server-DotNetCore, KiCadLibrary, aiopurpleair, and homeassistant-purpleair. Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, - { "path": ".github/workflows/publish-plan-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, and Utilities, and all three carry a strict subset of the canonical, missing the -E in set -Eeuo pipefail and the ::warning:: branch for an unrecognized actor pushing to main (WORKFLOW.md D8.4). Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null } + { "path": ".github/workflows/publish-plan-task.yml", "disposition": "retire", "reason": "The task is hub-hosted rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\", so it is no manifest entry and a downstream copy is retired rather than re-vendored. The carriers, read from the fleet on 2026-08-15, are ESPHome-NonRoot, NxWitness, and Utilities, and all three carry a strict subset of the canonical, missing the -E in set -Eeuo pipefail and the ::warning:: branch for an unrecognized actor pushing to main (WORKFLOW.md D8.4). Delete the copy and reach the hub task by pin as each repo is next visited, per docs/reusable-workflows.md \"Adopting the Pure Functions\".", "tracking": null }, + { "path": ".github/workflows/validate-task.yml", "disposition": "retire", "reason": "The file is hub-hosted as a workflow_call task rather than carried, per GOVERNANCE.md \"Hub-Hosted Tooling\" and docs/reusable-workflows.md \"Stage 2: The Gates\". The fleet doc-lint block, the language lint, the prose gate, and the repo gate move into the hub task, and a repo's own domain checks move into its own .github/actions/validate/action.yml hook instead, so a downstream copy is retired rather than re-vendored. The thirteen repos carrying a copy today are PhotoCleaner, PlexCleaner, LanguageTags, Utilities, MediaTools, AudioCleaner, aiopurpleair, Financial-Modeling, Blog, ESPHome-NonRoot, NxWitness, VSCode-Server-DotNetCore, and HomeAutomation-Config. Delete the copy and adopt the caller stub in docs/reusable-workflows.md \"Adopting the Gates\" as each repo is next visited.", "tracking": null } ] } diff --git a/spec/files.json b/spec/files.json index 7815b6dc..020d9a8e 100644 --- a/spec/files.json +++ b/spec/files.json @@ -27,8 +27,7 @@ { "path": "AUDIT.md", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, - { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job" }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, - { "path": ".github/workflows/validate-task.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d1---pr-fast-feedback-smoke", "appliesTo": "*" }, + { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job", "requireTokensInJob": { "validate": ["validate-task.yml"] } }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, { "path": ".github/workflows/publish-release.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] }, { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, From 7ae3bae177183dc4a6e2ce4f693a7d1eea7db747 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 06:25:08 -0700 Subject: [PATCH 02/14] Require the Validate Job and Use Reference-Style Links Adds `validate` to the test-pull-request.yml interface contract's requiredJobKeys, in spec/files.json and the mirrored spec/audit.py self-test fixture, so a stub that drops the validate job entirely is caught rather than passing because requireTokensInJob only evaluates a job that is present. Adds a self-test case for that dropped-job shape. Converts the "Adopting the Gates" section's and Stage 2's new inline anchor links in docs/reusable-workflows.md to reference-style links, per the repo's own link-style rule, which only exempts the Table of Contents. --- docs/reusable-workflows.md | 6 ++++-- spec/audit.py | 17 ++++++++++++++--- spec/files.json | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index a145f7b8..af0ecef4 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -147,7 +147,7 @@ Adoptable since `2.0.338`. Each repo replaces the whole of its `.github/workflow ### Stage 2: The Gates -Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language lint by tree detection, the prose gate, and the repo gate), a generic `unit-test` job (a `dotnet test` or a `uv run pytest`, skipped cleanly where the caller carries no test project), and a `validate` job resolving the `validate` hook for a repo's own domain checks, which decides #729 in the one place the `uvx` tools are pinned or floated. There is no `test-pull-request-task.yml`: the ruleset-bound aggregator stays in the caller stub by design, and a task wrapping one line that calls `validate-task.yml` hosts nothing generic, so the stub shapes live in [Adopting the Gates](#adopting-the-gates) instead, with the trigger shape, operational or release, settling #585. This stage is where the hook fallback is first proven live: the hub carries its own `validate` hook (its registry and spec check, its script self-tests, its fleet-skills check, and its unclassified-character report), so a hub pull request exercises the override path, and a repo with no hook of its own exercises the default. +Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language lint by tree detection, the prose gate, and the repo gate), a generic `unit-test` job (a `dotnet test` or a `uv run pytest`, skipped cleanly where the caller carries no test project), and a `validate` job resolving the `validate` hook for a repo's own domain checks, which decides #729 in the one place the `uvx` tools are pinned or floated. There is no `test-pull-request-task.yml`: the ruleset-bound aggregator stays in the caller stub by design, and a task wrapping one line that calls `validate-task.yml` hosts nothing generic, so the stub shapes live in [Adopting the Gates][adopting-the-gates] instead, with the trigger shape, operational or release, settling #585. This stage is where the hook fallback is first proven live: the hub carries its own `validate` hook (its registry and spec check, its script self-tests, its fleet-skills check, and its unclassified-character report), so a hub pull request exercises the override path, and a repo with no hook of its own exercises the default. - [x] Hub pull request on `develop` with the task, the hub's own hook and default, the manifest contracts, and the catalog snippets left for the release that follows, [#760][pr-760]. - [ ] Promoted and released, tag recorded here. @@ -296,7 +296,7 @@ jobs: fi ``` -**Release repos with a smoke build** carry the standard `pull_request` trigger, a `changes` paths-filter job (WORKFLOW.md D1.1: each of the repo's own targets gets a filter entry, and `.github/workflows/**` is excluded per D1.4), and a `smoke-build` job. The smoke build calls the repo's own `./.github/workflows/build-release-task.yml` by local path rather than a hub task, since that orchestrator is not hosted until [Stage 4](#stage-4-the-release-chain-and-the-docker-core). +**Release repos with a smoke build** carry the standard `pull_request` trigger, a `changes` paths-filter job (WORKFLOW.md D1.1: each of the repo's own targets gets a filter entry, and `.github/workflows/**` is excluded per D1.4), and a `smoke-build` job. The smoke build calls the repo's own `./.github/workflows/build-release-task.yml` by local path rather than a hub task, since that orchestrator is not hosted until [Stage 4][stage-4]. ```yaml name: Test pull request action @@ -415,9 +415,11 @@ Four things the hub cannot prove fall to the first downstream adopter. They are +[adopting-the-gates]: #adopting-the-gates [adopting-the-merge-bot]: #adopting-the-merge-bot [open-decisions]: #open-decisions [rollout]: #rollout +[stage-4]: #stage-4-the-release-chain-and-the-docker-core [the-docker-family]: #the-docker-family diff --git a/spec/audit.py b/spec/audit.py index 9d1ff316..0e6172ba 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2068,9 +2068,14 @@ def _selftest(): "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job", } - # The validate-task.yml stub contract: a caller's validate job must reach the hub task by name. - # A stub still carrying an inline lint job, the shape adoption replaces, is caught rather than passed as interface. - pr_stub_contract = dict(pr_contract, requireTokensInJob={"validate": ["validate-task.yml"]}) + # The validate-task.yml stub contract: a caller's validate job must exist and reach the hub task by name. + # A token check alone would pass a stub that drops the validate job entirely, since it only runs for a job that is present. + # Naming the validate job in requiredJobKeys too catches a dropped job on its own. + pr_stub_contract = dict( + pr_contract, + requiredJobKeys=["check-workflow-status", "validate"], + requireTokensInJob={"validate": ["validate-task.yml"]}, + ) pr_validate_head = ( "name: Test\non: pull_request\njobs:\n" " validate:\n" @@ -2153,6 +2158,12 @@ def _selftest(): pr_stub_contract, 1, ), + ( + "PR stub dropping the validate job entirely", + pr_head + pr_check, + pr_stub_contract, + 1, + ), ("conformant release task", rel_ok, rel_contract, 0), ( "release task with an artifact-ids fork in github-release", diff --git a/spec/files.json b/spec/files.json index 020d9a8e..6d94a874 100644 --- a/spec/files.json +++ b/spec/files.json @@ -27,7 +27,7 @@ { "path": "AUDIT.md", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": "spec/secrets.json", "fidelity": "intent", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": ".github/dependabot.yml", "appliesTo": "*" }, - { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status"], "requiredCheckName": "Check pull request workflow status job", "requireTokensInJob": { "validate": ["validate-task.yml"] } }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, + { "path": ".github/workflows/test-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["check-workflow-status", "validate"], "requiredCheckName": "Check pull request workflow status job", "requireTokensInJob": { "validate": ["validate-task.yml"] } }, "intentRef": "GOVERNANCE.md#workflow-yaml-conventions", "appliesTo": "*" }, { "path": ".github/workflows/publish-release.yml", "fidelity": "intent", "intentRef": "WORKFLOW.md#d4---release--publish", "appliesTo": ["two-phase", "dispatch-only", "publish-on-merge"] }, { "path": ".github/workflows/merge-bot-pull-request.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["merge-bot"], "requireTokensInJob": { "merge-bot": ["merge-bot-task.yml", "CODEGEN_APP_CLIENT_ID", "CODEGEN_APP_PRIVATE_KEY"] } }, "intentRef": "WORKFLOW.md#d8---bots--automation", "appliesTo": "*" }, { "path": ".github/workflows/build-release-task.yml", "fidelity": "interface", "contract": { "requiredJobKeys": ["get-version", "validate-release", "github-release"], "artifactNameToken": "release-asset-", "requireTokensInJob": { "github-release": ["pattern:", "merge-multiple:"] }, "forbidTokensInJob": { "github-release": ["artifact-ids:"] }, "verbatimJobs": ["github-release"] }, "reference": "catalog/snippets/workflows/build-release-task.yml", "intentRef": "GOVERNANCE.md#release-model", "appliesTo": ["csharp", "console", "docker", "nuget", "pypi", "eda"] }, From 285b8352d8681bed0de40b630f30b945866e1a60 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 06:35:45 -0700 Subject: [PATCH 03/14] Prefer Mypy Over Pyright When a Pyproject Declares Both The live run on this PR's own head proved the design: the hub's lint job ran both type checkers, since its pyproject.toml declares both sections, and pyright surfaced 32 real findings across host-setup and scripts/tests that mypy has never checked, breaking the hub's own currently-green gate. Mypy now takes precedence where both sections exist, matching every repo (including the hub) that already runs mypy in CI, and pyright runs only as a fallback when a pyproject declares it with no [tool.mypy] section. A repo that wants both tools enforced can still add the second as a step in its own validate hook. --- .github/workflows/validate-task.yml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 99ea4152..e37b4ffa 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -130,26 +130,19 @@ jobs: if: hashFiles('pyproject.toml') != '' run: uvx ruff@latest format --check . - # Mypy and pyright are independent: a pyproject declaring both sections runs both, and a pyproject declaring only one runs that one. - # Neither runs unless its own section exists. - - name: Type check Python (mypy) step + # Mypy takes precedence where a pyproject declares both sections, matching every repo that runs mypy in CI today. + # A pyright-only pyproject, with no [tool.mypy] section, falls back to pyright instead. + # A repo that genuinely wants both enforced adds the second as its own validate hook step. + - name: Type check Python step if: hashFiles('pyproject.toml') != '' run: | set -Eeuo pipefail if grep -q '^\[tool\.mypy\]' pyproject.toml; then uvx mypy@latest - else - echo "no [tool.mypy] section, skipping" - fi - - - name: Type check Python (pyright) step - if: hashFiles('pyproject.toml') != '' - run: | - set -Eeuo pipefail - if grep -q '^\[tool\.pyright\]' pyproject.toml; then + elif grep -q '^\[tool\.pyright\]' pyproject.toml; then uvx pyright@latest else - echo "no [tool.pyright] section, skipping" + echo "no [tool.mypy] or [tool.pyright] section, skipping" fi # The fleet prose rules, scoped to a pull request's own diff so the existing backlog blocks nothing. From 0954648c16987d5eefb72de377a096ab87e667e8 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 06:41:36 -0700 Subject: [PATCH 04/14] Record the Hook Override Path Proof Run Ticks the override half of the hook-fallback proof item with the run that showed it: the hub's own validate job runs ./.github/actions/validate directly, with no hub checkout, once the mypy-versus-pyright fix landed. The default half stays open until a repo with no validate hook of its own adopts the stub. --- docs/reusable-workflows.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index af0ecef4..7272f516 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -152,7 +152,7 @@ Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language - [x] Hub pull request on `develop` with the task, the hub's own hook and default, the manifest contracts, and the catalog snippets left for the release that follows, [#760][pr-760]. - [ ] Promoted and released, tag recorded here. - [ ] Catalog snippets for both stub shapes in [Adopting the Gates][adopting-the-gates] pinned to that release. -- [ ] Hook override path observed on a hub pull request run and default path observed on a repo with no `validate` hook of its own, run URLs recorded here. +- [x] Hook override path observed on a hub pull request run, (runs `./.github/actions/validate`, no hub checkout). Default path awaits a repo with no `validate` hook of its own. - [ ] PhotoCleaner (pilot, release trigger shape with smoke, the same repo that piloted stage 1) - [ ] 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. From 14401f88e88e77d094fc5d70af395eff3e89422c Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 06:50:05 -0700 Subject: [PATCH 05/14] Guard the Python Unit-Test Path on uv.lock and Fix a Link Adds hashFiles('uv.lock') to the Python unit-test steps' guard in validate-task.yml. A pyproject.toml plus a tests/ directory is not enough to prove a build-profile project: the lint-only Python profile (spec/project-types.json python profileNote) carries no uv.lock by design, so uv sync --all-groups --frozen would fail it with nothing to sync from. The comment above the job now names the exclusion. Converts the hook-override-path proof item's autolink URL in docs/reusable-workflows.md to a reference-style link, per the same rule the prior round's findings already fixed elsewhere in this file. --- .github/workflows/validate-task.yml | 11 ++++++----- docs/reusable-workflows.md | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index e37b4ffa..fd09dd3e 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -174,7 +174,8 @@ jobs: # No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one. # Every step below carries its own tree-detection guard instead. - # A caller with neither a *Tests*.csproj nor a tests/ directory beside a pyproject.toml runs every step's guard false, and the job reports success having done nothing, which is the clean skip this job promises. + # A caller with neither a *Tests*.csproj nor a uv.lock-backed tests/ directory beside a pyproject.toml runs every step's guard false, and the job reports success having done nothing, which is the clean skip this job promises. + # The uv.lock guard excludes the lint-only Python profile (spec/project-types.json python profileNote), which is stdlib-only, uvx-run, and carries no lockfile to sync from. unit-test: name: Unit test job runs-on: ubuntu-latest @@ -208,22 +209,22 @@ jobs: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Setup uv step - if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' && hashFiles('uv.lock') != '' uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: python-version: "3.13" - name: Sync dependencies step - if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' && hashFiles('uv.lock') != '' run: uv sync --all-groups --frozen - name: Run pytest step - if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' && hashFiles('uv.lock') != '' run: uv run pytest --cov-report=xml # Best-effort: continue-on-error plus fail_ci_if_error false, so a missing token never reds the gate. - name: Upload coverage to Codecov step (Python) - if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' + if: hashFiles('pyproject.toml') != '' && hashFiles('tests/**') != '' && hashFiles('uv.lock') != '' continue-on-error: true uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 7272f516..a2c5bbd1 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -152,7 +152,7 @@ Hub: `validate-task.yml` hosts a `lint` job (the fleet doc-lint block, language - [x] Hub pull request on `develop` with the task, the hub's own hook and default, the manifest contracts, and the catalog snippets left for the release that follows, [#760][pr-760]. - [ ] Promoted and released, tag recorded here. - [ ] Catalog snippets for both stub shapes in [Adopting the Gates][adopting-the-gates] pinned to that release. -- [x] Hook override path observed on a hub pull request run, (runs `./.github/actions/validate`, no hub checkout). Default path awaits a repo with no `validate` hook of its own. +- [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 awaits a repo with no `validate` hook of its own. - [ ] PhotoCleaner (pilot, release trigger shape with smoke, the same repo that piloted stage 1) - [ ] 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. @@ -427,6 +427,7 @@ Four things the hub cannot prove fall to the first downstream adopter. They are [governance-hub-hosted-tooling]: ../GOVERNANCE.md#hub-hosted-tooling [governance-workflow-yaml-conventions]: ../GOVERNANCE.md#workflow-yaml-conventions [issue-585]: https://github.com/ptr727/ProjectTemplate/issues/585 +[override-path-run]: https://github.com/ptr727/ProjectTemplate/actions/runs/31950332387/job/95172710046 [pr-760]: https://github.com/ptr727/ProjectTemplate/pull/760 [secrets]: ../spec/secrets.json [todo]: ../TODO.md From ceab2b5153f93cc0ccd9ee36cc058b313802abf0 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:00:10 -0700 Subject: [PATCH 06/14] Detect a Pull Request From the Inherited Event Payload github.event_name inside a workflow_call callee is always "workflow_call" (GitHub Actions docs, "workflow_call event": "the event payload in the called workflow is the same event payload from the calling workflow", meaning the payload copies through but the event name itself does not), so the prose step's `if: github.event_name == 'pull_request'` never ran, silently skipping the gate on every pull request. github.base_ref is unset here for the same reason. Both now read github.event.pull_request, which does carry the caller's own event object: the if: condition checks it is not null, and BASE reads github.event.pull_request.base.ref. --- .github/workflows/validate-task.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index fd09dd3e..70278b8e 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -150,8 +150,9 @@ jobs: # A main-target run reads the bundled script at the exact commit the caller pinned, the .hub checkout above. # Every other target reads the live hub develop copy, so an unpromoted rule change is exercised fleet-wide before it reaches main. # Skipped outside a pull request, since there is no base to diff and a publish run's content was already gated when it was pushed. + # The event name here is always workflow_call, since that is what triggers this callee, so pull-request detection reads the inherited event payload's pull_request key instead. - name: Check prose step - if: github.event_name == 'pull_request' + if: github.event.pull_request != null run: | set -Eeuo pipefail if [ "$BASE" = "main" ]; then @@ -167,7 +168,8 @@ jobs: fi python3 "$script" --diff "origin/$BASE" --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path . env: - BASE: ${{ github.base_ref }} + # The base ref context field is unset here for the same reason, so it reads from the inherited event payload instead. + BASE: ${{ github.event.pull_request.base.ref }} - name: Check repo gates step run: python3 .hub/scripts/repo_gate.py From 047010270bea9c396851e7d7dbb6b97f6b235542 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:06:50 -0700 Subject: [PATCH 07/14] Skip CSharpier Without a Tool Manifest and Match the Canonical Flags The Check C# formatting step ran dotnet csharpier unconditionally whenever a csproj existed, even where the caller carries no .config/dotnet-tools.json, which the Restore dotnet tools step already treats as optional. Without a restored manifest, csharpier is not installed and the step fails on a generic unknown-command error rather than skipping cleanly, the same shape the shell and PowerShell steps already handle. Gates the csharpier step on the same two-part condition the restore step already uses. dotnet format needs no such guard, since the SDK ships it directly, but its invocation was missing the fleet's canonical --severity=info --verbosity=detailed flags (CODESTYLE.md, PhotoCleaner's own gate), so it enforced a narrower severity than every other C# repo's CI. --- .github/workflows/validate-task.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 70278b8e..9b39b802 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -106,13 +106,18 @@ jobs: hashFiles('**/*.csproj') != '' && hashFiles('.config/dotnet-tools.json') != '' run: dotnet tool restore + # CSharpier is a local dotnet tool, so it needs the manifest restored above. + # A caller with no manifest skips rather than failing on a generic unknown-command error against a tool the runner never installed. - name: Check C# formatting step - if: hashFiles('**/*.csproj') != '' + if: >- + hashFiles('**/*.csproj') != '' && hashFiles('.config/dotnet-tools.json') != '' run: dotnet csharpier check . + # The dotnet SDK ships dotnet format, so it needs no tool restore. + # The severity and verbosity flags match the fleet's canonical invocation, per CODESTYLE.md and PhotoCleaner's own gate. - name: Check C# style step if: hashFiles('**/*.csproj') != '' - run: dotnet format style --verify-no-changes + run: dotnet format style --verify-no-changes --severity=info --verbosity=detailed # @latest rather than a version pin, since a `uvx @` pin is nothing Dependabot tracks and would silently go stale, where the action SHA above is Dependabot-tracked. # This is #729, decided here, the one place the fleet's uvx tools are pinned or floated. From 8a820e90f84a345c14b4419158eb581b1cdf377a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:15:50 -0700 Subject: [PATCH 08/14] Resolve the Hub Checkout to job.workflow_repository Too job.workflow_repository names the repository containing the workflow file that defines the current job, the sibling field to job.workflow_sha. Both checkout-hub steps now use it in place of the hard-coded ptr727/ProjectTemplate, so a fork of the hub checks out itself rather than always reaching upstream. .github/actionlint.yaml gains the matching ignore pattern for workflow_repository, in the shape the stage-5 PR (#761) already carries for the same actionlint gap, so the two files converge on one header comment. --- .github/actionlint.yaml | 12 +++++++++--- .github/workflows/validate-task.yml | 4 ++-- docs/reusable-workflows.md | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index 7a74aa3f..9134cd61 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -1,6 +1,12 @@ -# The bundled context schema predates the job context's workflow_sha, workflow_ref, workflow_repository, and workflow_file_path fields, so a valid, documented reference to job.workflow_sha reads as an unknown property. -# Scoped to the one file that reads it today, so a genuine unknown-property regression elsewhere still fails. +# This is the actionlint config. +# The paths..ignore key filters specific error messages by regex, scoped to the file that needs it, rather than disabling the rule fleet-wide. +# +# The job.workflow_sha and job.workflow_repository properties are documented GitHub Actions context fields (GitHub Docs, "Contexts", the job context). +# They exist specifically so a reusable workflow can check out its own repository at the exact commit its caller pinned. +# The actionlint context schema has not caught up to them yet, so it reports a false property-not-defined finding here. +# Drop this entry once a released actionlint recognizes both properties. paths: - .github/workflows/validate-task.yml: + '.github/workflows/validate-task.yml': ignore: + - 'property "workflow_repository" is not defined in object type' - 'property "workflow_sha" is not defined in object type' diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 9b39b802..4cf493a2 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -32,7 +32,7 @@ jobs: - name: Checkout hub step uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: ptr727/ProjectTemplate + repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .hub @@ -255,7 +255,7 @@ jobs: if: hashFiles('.github/actions/validate/action.yml') == '' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - repository: ptr727/ProjectTemplate + repository: ${{ job.workflow_repository }} ref: ${{ job.workflow_sha }} path: .hub diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index a2c5bbd1..75b077e2 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -47,7 +47,7 @@ A workflow whose job graph is identical across repos of a type is reached, not c ### Layers -1. **The hub reusable workflow**, at `.github/workflows/-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ job.workflow_sha }}` under `.hub/`, which is the commit the caller pinned. +1. **The hub reusable workflow**, at `.github/workflows/-task.yml` in the hub. It follows [GOVERNANCE.md "Workflow YAML Conventions"][governance-workflow-yaml-conventions], so the file ends `-task.yml` and its `name:` ends "task". It owns the job graph, the permissions each job needs, the validate-at-entry step, the artifact seam, retention, and the ruleset-bound aggregator name. It checks out the caller's repo by default. When it needs its own defaults or scripts, it checks out the hub at `${{ job.workflow_repository }}`, `${{ job.workflow_sha }}` under `.hub/`, the repository and exact commit the caller pinned, so a fork of the hub checks out itself rather than a hard-coded upstream. 2. **The hook**, a composite action at `.github/actions//action.yml` in the caller's repo. A hub job resolves it in one order: the caller's path when `hashFiles('.github/actions//action.yml')` is non-empty, else the hub default at the same name under `.hub/`. A required hook with no default fails its job with `::error::` naming the missing path. 3. **The caller stub**, downstream, under thirty lines. The audit grades it at `interface` fidelity: the caller job key, the hub task the `uses:` names, and the secrets it maps are the contract, and the `with:` block is the repo's own. 4. **The hub's own use.** The hub calls its own task files by `./` path, so every hub pull request exercises the reusable file at least at parse level, and fully for the workflows the hub itself runs. From 7eda4e2ff2dd018a7ca2ed08b381bba7b52b3f32 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:23:05 -0700 Subject: [PATCH 09/14] Diff the Prose Gate Against the Exact Base Commit The PR event payload already carries the exact base commit (github.event.pull_request.base.sha), which needs no remote-tracking branch ref to be present in the checkout, unlike a branch name would. The diff base now reads that SHA directly. The branch name (base.ref) still decides which rules source to read (bundled versus hub develop), since that choice is about the target branch, not the commit. --- .github/workflows/validate-task.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 4cf493a2..8f463ed8 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -160,21 +160,23 @@ jobs: if: github.event.pull_request != null run: | set -Eeuo pipefail - if [ "$BASE" = "main" ]; then + if [ "$BASE_REF" = "main" ]; then script=.hub/scripts/prose_lint.py else script="$RUNNER_TEMP/prose_lint.py" curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ "https://raw.githubusercontent.com/ptr727/ProjectTemplate/develop/scripts/prose_lint.py" -o "$script" fi - if ! git rev-parse --verify --quiet "origin/$BASE^{commit}" >/dev/null; then - echo "::error::Diff base 'origin/$BASE' does not resolve in this checkout." >&2 + if ! git rev-parse --verify --quiet "$BASE_SHA^{commit}" >/dev/null; then + echo "::error::Diff base '$BASE_SHA' does not resolve in this checkout." >&2 exit 1 fi - python3 "$script" --diff "origin/$BASE" --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path . + python3 "$script" --diff "$BASE_SHA" --check charset --check semicolon --check dash --check dupword --check spelling --check comment-wrap --check comment-case --check home-path --check dead-path . env: - # The base ref context field is unset here for the same reason, so it reads from the inherited event payload instead. - BASE: ${{ github.event.pull_request.base.ref }} + # The base ref context field is unset here for the same reason, so both read from the inherited event payload instead. + # The exact base commit needs no remote-tracking branch ref to be present in the checkout, unlike a branch name would. + BASE_REF: ${{ github.event.pull_request.base.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} - name: Check repo gates step run: python3 .hub/scripts/repo_gate.py From 7628c34537627b29945e059bb70e7940bb540810 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:30:23 -0700 Subject: [PATCH 10/14] Fetch the Develop-Tracked Prose Rules From the Hub That Hosts Them Reconsiders the prior round's decline: job.workflow_repository names wherever this reusable workflow itself lives, so it is correct for every real caller today (it resolves to ptr727/ProjectTemplate, since that is where the task is hosted) and it also makes a full fork or mirror of the hub test its own develop rules rather than always reaching upstream, which is what the checkout-hub steps already do for the ref. The prose step's curl fallback now reads the script from $HUB_REPO instead of the hard-coded owner, closing the one place that still hard-coded it. --- .github/workflows/validate-task.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 8f463ed8..ebbd937d 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -165,7 +165,7 @@ jobs: else script="$RUNNER_TEMP/prose_lint.py" curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ - "https://raw.githubusercontent.com/ptr727/ProjectTemplate/develop/scripts/prose_lint.py" -o "$script" + "https://raw.githubusercontent.com/$HUB_REPO/develop/scripts/prose_lint.py" -o "$script" fi if ! git rev-parse --verify --quiet "$BASE_SHA^{commit}" >/dev/null; then echo "::error::Diff base '$BASE_SHA' does not resolve in this checkout." >&2 @@ -177,6 +177,8 @@ jobs: # The exact base commit needs no remote-tracking branch ref to be present in the checkout, unlike a branch name would. BASE_REF: ${{ github.event.pull_request.base.ref }} BASE_SHA: ${{ github.event.pull_request.base.sha }} + # The repository hosting this reusable workflow, so a fork of the hub reads its own develop rather than upstream's. + HUB_REPO: ${{ job.workflow_repository }} - name: Check repo gates step run: python3 .hub/scripts/repo_gate.py From b6c03c55b1feda0225910c12bf9ad72d0f1fa7c9 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:38:48 -0700 Subject: [PATCH 11/14] Authenticate gh for the Repo Gate's Live Sha-Pin Resolution repo_gate.py's sha-pin check shells out to gh api to resolve a same-owner action pin against GitHub. A runner's gh has no credentials of its own, so every call degraded to "GitHub did not answer" in this PR's own CI run (confirmed in the Lint sources job log: resolved 0 pin(s), 1 GitHub did not answer for), silently losing same-owner pin coverage rather than failing loud. Exports GH_TOKEN from the job's own token, which the lint job's existing contents: read grant already covers for a read-only commit lookup. --- .github/workflows/validate-task.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index ebbd937d..954d0f20 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -180,8 +180,12 @@ jobs: # The repository hosting this reusable workflow, so a fork of the hub reads its own develop rather than upstream's. HUB_REPO: ${{ job.workflow_repository }} + # A runner's gh is unauthenticated on its own, and gh api needs one. + # Without this the sha-pin check silently degrades to "GitHub did not answer" for every same-owner pin. - name: Check repo gates step run: python3 .hub/scripts/repo_gate.py + env: + GH_TOKEN: ${{ github.token }} # No job-level if: here, since GitHub Actions does not evaluate hashFiles in a job condition, only a step one. # Every step below carries its own tree-detection guard instead. From 2294ff8c8d29a826e16067abfe153acaa2730a28 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:54:02 -0700 Subject: [PATCH 12/14] Check Out the Hub After the Doc-Lint Block, Not Before The hub checkout landed in the workspace before markdownlint, editorconfig-checker, and the shell and PowerShell checks ran, and each of those scans the whole tree by glob rather than by git ls-files, so a caller's lint result could pick up files from the checked-out hub commit under .hub/ rather than only the caller's own tree. Moves the checkout to immediately before the two steps that actually read it, the prose gate and the repo gate. GOVERNANCE.md's "Running the Linters Locally" list named every doc linter this block runs except shellcheck, which the block does run. The claim that the whole list is exactly the hub's validate-task.yml block, added earlier in this PR, made that omission an inaccuracy rather than a pre-existing gap, so the bullet now names shellcheck alongside PSScriptAnalyzer. --- .github/workflows/validate-task.yml | 17 +++++++++-------- GOVERNANCE.md | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 954d0f20..74eec7f4 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -28,14 +28,6 @@ jobs: with: fetch-depth: 0 - # The hub at the exact commit the caller pinned, for the prose gate's bundled script and the repo gate. - - name: Checkout hub step - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: ${{ job.workflow_repository }} - ref: ${{ job.workflow_sha }} - path: .hub - # The fleet doc-lint block, hosted once rather than carried by every repo of every type. - name: Lint Markdown step uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0 @@ -150,6 +142,15 @@ jobs: echo "no [tool.mypy] or [tool.pyright] section, skipping" fi + # Checked out here, right before the two steps that read it, so the doc-lint block above scans only the caller's own tree and never the hub commit copied in for the prose gate and the repo gate. + # The hub at the exact commit the caller pinned, for the prose gate's bundled script and the repo gate. + - name: Checkout hub step + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ job.workflow_repository }} + ref: ${{ job.workflow_sha }} + path: .hub + # The fleet prose rules, scoped to a pull request's own diff so the existing backlog blocks nothing. # This mirrors .github/actions/prose-gate/action.yml's branch rule. # A main-target run reads the bundled script at the exact commit the caller pinned, the .hub checkout above. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 152b88a6..8e89cb36 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -216,7 +216,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (it has no action either). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither has an action either). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. From f51e04fd556ed62bf0a8ebbee6cd528efa7916d1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 07:59:54 -0700 Subject: [PATCH 13/14] Authenticate the Prose-Rules Fetch and Fix a Double Negative The curl fallback fetching prose_lint.py from raw.githubusercontent.com ran unauthenticated, which raw.githubusercontent.com refuses outright for a private repository regardless of visibility elsewhere, so a private hub fork's downstream callers could never reach it. Adds an Authorization header from the job's own token, harmless for the public case since the request still succeeds either way. GOVERNANCE.md's "neither has an action either" read as an accidental double negative. Reworded to "neither one has an action." --- .github/workflows/validate-task.yml | 3 +++ GOVERNANCE.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 74eec7f4..02c98eef 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -166,6 +166,7 @@ jobs: else script="$RUNNER_TEMP/prose_lint.py" curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ "https://raw.githubusercontent.com/$HUB_REPO/develop/scripts/prose_lint.py" -o "$script" fi if ! git rev-parse --verify --quiet "$BASE_SHA^{commit}" >/dev/null; then @@ -180,6 +181,8 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} # The repository hosting this reusable workflow, so a fork of the hub reads its own develop rather than upstream's. HUB_REPO: ${{ job.workflow_repository }} + # A private hub fork needs this to read raw content; a public one ignores it, since the request still succeeds. + GITHUB_TOKEN: ${{ github.token }} # A runner's gh is unauthenticated on its own, and gh api needs one. # Without this the sha-pin check silently degrades to "GitHub did not answer" for every same-owner pin. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 8e89cb36..4be4c232 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -216,7 +216,7 @@ CI runs the full lint set, but run the linters locally before pushing to catch i **Each surface runs the lint with the tool that fits it, all from the same config files** (`.markdownlint-cli2.jsonc`, `cspell.json`, `.editorconfig`): -- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither has an action either). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. +- **CI (authoritative)** runs **markdownlint-cli2**, **cspell**, and **actionlint** as pinned action wrappers (Dependabot bumps them), plus **editorconfig-checker** via Docker `:latest` (its action only installs the CLI, so the Docker one-liner is what actually runs the check), **shellcheck** the same way for a repo that carries `.sh` files, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (neither one has an action). markdownlint covers all `**/*.md`, and **cspell is scoped to `README.md` + `HISTORY.md`** (see [CODESTYLE.md](./CODESTYLE.md) "Markdown and Spelling" for why), matching the cspell one-liner below. This whole block is the hub's `validate-task.yml` reusable workflow, per [`docs/reusable-workflows.md`](./docs/reusable-workflows.md), so a fleet repo reaches it rather than carrying a copy of these steps. - **The `.husky/pre-commit` hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker and never a network call, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff for Python, via native tooling. A repo adds each half once its tree passes that half, since a gate that fails on the corpus it guards blocks every commit from the moment it lands, so a hook running one half is a repo mid-convergence rather than a repo out of conformance. The doc half runs each gate at the scope that fits it. The prose gate is scoped to what the commit changes rather than swept over the tree, which is the difference between about 2.2 seconds and about 0.13 and is what makes it affordable in a hook at all. A whole-repo check belongs there too when it is already fast and takes no file list, which the line-ending consistency check is, so scope is a property of the gate rather than a rule the hook applies to all of them. `repo_gate.py --check sha-pin` stays out, since it resolves a same-owner pin against the GitHub API and a hook that needs a network fails offline. A repo enables the hook per clone with `git config core.hooksPath .husky`, and CI remains the authoritative run either way. - **The VS Code Lint tasks** run the full doc-lint set via Docker `:latest` on demand, the local surface for Markdown, spelling, workflow, and EditorConfig checks. From d0b4cee20b6b46f8d6a8c22f69c2be62a51b9f69 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Sun, 16 Aug 2026 08:09:24 -0700 Subject: [PATCH 14/14] Resolve the Base Branch for Smoke Builds and Tolerate Indented TOML The Adopting the Gates smoke-build example passed github.ref_name as the logical branch, which on a pull_request event is the PR ref (for example 123/merge), not the target branch, so a smoke build's branch-derived config (cache tags, buildcache selection) would key on the wrong value. It now reads github.base_ref first and falls back to ref_name only on a non-PR trigger, matching the same pattern already used elsewhere in the fleet. The pyproject section grep for mypy and pyright was anchored to column 0, which a TOML section header need not be (the grammar allows leading whitespace, however rare in practice). Both patterns now tolerate it. spec/audit.py's two validate-stub selftest fixtures paired a validate job with an aggregator needing changes, a job neither fixture defines. They now carry a needs: [validate] variant instead, matching the job they actually define. --- .github/workflows/validate-task.yml | 4 ++-- docs/reusable-workflows.md | 4 +++- spec/audit.py | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 02c98eef..ff58dc71 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -134,9 +134,9 @@ jobs: if: hashFiles('pyproject.toml') != '' run: | set -Eeuo pipefail - if grep -q '^\[tool\.mypy\]' pyproject.toml; then + if grep -Eq '^[[:space:]]*\[tool\.mypy\]' pyproject.toml; then uvx mypy@latest - elif grep -q '^\[tool\.pyright\]' pyproject.toml; then + elif grep -Eq '^[[:space:]]*\[tool\.pyright\]' pyproject.toml; then uvx pyright@latest else echo "no [tool.mypy] or [tool.pyright] section, skipping" diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 75b077e2..902801fa 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -351,7 +351,9 @@ jobs: smoke: true github: false dockerhub: false - branch: ${{ github.ref_name }} + # On a pull_request event github.ref_name is the PR ref (for example 123/merge), never the target branch, + # so the logical branch reads base_ref first and only falls back to ref_name on a non-PR trigger. + branch: ${{ github.base_ref || github.ref_name }} # Treats a skipped smoke-build (an unchanged target) as pass, and blocks on failure or cancelled (D1.5, D7.4). check-workflow-status: diff --git a/spec/audit.py b/spec/audit.py index 0e6172ba..3b29f137 100755 --- a/spec/audit.py +++ b/spec/audit.py @@ -2091,6 +2091,9 @@ def _selftest(): " runs-on: ubuntu-latest\n" " steps:\n - run: echo inline lint\n" ) + # A validate-task.yml stub's own aggregator needs the validate job, never the release-shape changes job. + # The shared pr_check fixture needs a changes job these two fixtures do not define, so this one needs validate instead. + pr_check_validate = " check-workflow-status:\n name: Check pull request workflow status job\n needs: [validate]\n runs-on: ubuntu-latest\n" gh_rel = ( " github-release:\n needs: [get-version, build-widget]\n runs-on: ubuntu-latest\n steps:\n" " - uses: actions/download-artifact@v4\n with:\n pattern: release-asset-${{ inputs.branch }}-*\n merge-multiple: true\n" @@ -2148,13 +2151,13 @@ def _selftest(): ), ( "PR stub validate job reaching the hub validate-task", - pr_validate_head + pr_check, + pr_validate_head + pr_check_validate, pr_stub_contract, 0, ), ( "PR stub validate job still carrying an inline lint job", - pr_validate_inline + pr_check, + pr_validate_inline + pr_check_validate, pr_stub_contract, 1, ),