From 3edf24dda21e3af5b791b380bd9e9046bbf71f50 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 18 Sep 2026 08:45:34 -0700 Subject: [PATCH 1/3] Check Composite Action Metadata Against the GitHub Action Schema Implements item 4 of the answers recorded on [#1155](https://github.com/ptr727/ProjectTemplate/issues/1155). Items 1 to 3 are prose and land separately. ## The gap `workflow-ci-contract` "After Any Workflow Edit" sent a reader to actionlint. actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`, and it opens a composite action's `action.yml` only through a workflow's `uses: ./.github/actions/`, and only to judge that caller's `with:` block against the action's declared inputs. Nothing read the action file as action metadata. So a reader edited an `action.yml`, ran the prescribed check, and got a clean result that had judged only the caller's contract with it. ## Why actionlint cannot be the tool, measured rather than inferred `docker run --rm -v "$PWD":/repo -w /repo rhysd/actionlint:latest .github/actions/validate/action.yml` exits 1 with six syntax-check errors, `"jobs" section is missing in workflow`, `"on" section is missing in workflow`, and `unexpected key` for each of `description`, `inputs`, `outputs` and `runs`. It parses action metadata as a workflow, so widening its globs is not available. Measured on a scratch repository, so the corrected skill text claims no more than what actionlint does: a composite action carrying a malformed `if:` expression and an unterminated shell `if` in a `run:` block, plus a second action referenced by no workflow and missing both its top-level `description` and an input's, passes actionlint at exit 0. The same two files put to `check-jsonschema` report the two missing descriptions. ## What runs instead `check-jsonschema`'s `vendor.github-actions` builtin, which is GitHub's own action-metadata schema. It runs via `uvx`, beside the ruff and mypy invocations already in the same job, because `check-jsonschema` publishes no container image: upstream `python-jsonschema` carries no Dockerfile and Docker Hub holds only unaffiliated rebuilds. That is why `scripts/docker_lint.py`, which the recorded answer named, is unchanged: it runs each linter in a container with `--network none`, so neither a pulled image nor an in-container install is available to it. - `validate-task.yml` gains a `Lint composite actions step`, gated on the metadata existing, taking its file list from git and skipping rather than invoking on an empty argument list, matching the shell and PowerShell steps above it. It sits after the Python steps rather than beside actionlint because it needs the uv they set up. - `Setup uv step` now also fires for a repo that carries composite actions and no `pyproject.toml`, which would otherwise reach the new step with no `uvx` on the runner. - Both the guard and the file list use `**`, the guard through `hashFiles` and the list through a `:(glob)` pathspec, so the two describe one set. `hashFiles`'s `*` does not cross a `/` and git's default pathspec `*` does, so the earlier `*` form would have skipped a repo whose actions nest one level deeper while still checking a nested file whenever a flat one existed. - `OPERATIONS.md` "Local Verification" runs the same command, with the same empty guard. Without it, a checkout carrying no composite action reaches `check-jsonschema` with no instance files, which exits 2 and aborts the rest of the block under its own `set -Eeuo pipefail`. - `GOVERNANCE.md` "Running the Linters Locally" names the new check, since that block states it is the whole of `validate-task.yml`, and both host READMEs name it in their own enumeration of the lint tools that run through a container or `uvx` rather than natively. That section's own lead names it too, since it tells an agent to trust that enumeration before reporting a check unavailable. ## What the corrected skill text says "After Any Workflow Edit" now says which part of a composite action each check reaches, and what neither reads: a composite action's `run:` bodies and its `if:` expressions. It also says that where no workflow in the repository names the action, actionlint reaches it not at all, which is the ordinary shape downstream: a repo's hooks are invoked from a hub reusable workflow, and no workflow in that repo carries a `uses: ./.github/actions/...` line, so its own actionlint run never opens them. `docs/reusable-workflows.md` stated the reason wrongly in a snippet comment and is corrected to that one. The schema check's scope is stated as `.github/actions/`, matching the step's own guard, since an action file outside that tree is read by neither check. ## The producer-failure shape this repository already ruled against The step's file list is written to a file rather than into a process substitution. `.github/workflows/canonical-review-sweep.yml` states the reason in this tree already: `mapfile` reports success whatever the producer did. Measured both forms against a failing `git ls-files`: the redirect aborts the step at 128, and `mapfile < <(...)` continues with an empty array and exits 0, which would have reported "no composite action metadata is tracked" and gone green with the gate never run. Dropping `mapfile` also keeps the `OPERATIONS.md` block runnable on macOS, whose `/bin/bash` is 3.2 and has no such builtin. That block opens with `set -Eeuo pipefail`, so the failure would not have skipped one check, it would have truncated every gate below it. The list is `git ls-files -z` into `xargs -0`, since a bare `xargs` splits on whitespace and honours quotes. Measured: a tracked `.github/actions/my hook/action.yml` reaches the checker as two arguments and the run exits 123, where `-0` passes it whole and exits 0. `-0` is the form both GNU and BSD carry, where `-d` is GNU-only and would break the macOS copy. The pathspecs are `:(top,glob)` so a run from a subdirectory reads the same nine files rather than reporting a silent clean over none. ## The eight findings the new gate raised on its first run A gate that fails on the corpus it guards blocks every commit from the moment it lands, per `GOVERNANCE.md`, so the tree is brought to green in the same change. Eight inputs carried no `description`, which GitHub's schema requires: `semver2` in `docker-prepare-default` and `nuget-build-default`, and `assembly-version`, `assembly-file-version` and `assembly-informational-version` in both `dotnet-publish-default` and `nuget-build-default`. Each now describes the NBGV output it threads and the MSBuild property it sets. `dotnet-publish-default` passes its `assembly-version` as both `AssemblyVersion` and `Version`, and its `semver2` reaches `PackageVersion` alone, so those two say so rather than reading like their `nuget-build-default` counterparts, where `semver2` is `Version`. ## Verification The declared local gate set in `OPERATIONS.md` "Local Verification" passes whole, and `docker_lint.py` is clean for actionlint, markdownlint and editorconfig-checker. The new command was run at the exact form the workflow and the local block use, over all nine tracked action files, and its empty-list path was run in a scratch repository carrying none. `workflow-ci-contract` regenerates into all three distributions. --- .agents/skills/workflow-ci-contract/SKILL.md | 6 ++++++ .../.source-digests/workflow-ci-contract | 2 +- .../skills/workflow-ci-contract/SKILL.md | 6 ++++++ .github/actions/docker-prepare-default/action.yml | 1 + .github/actions/dotnet-publish-default/action.yml | 5 ++++- .github/actions/nuget-build-default/action.yml | 4 ++++ .github/skills/workflow-ci-contract/SKILL.md | 6 ++++++ .github/workflows/validate-task.yml | 13 ++++++++++++- GOVERNANCE.md | 4 ++-- OPERATIONS.md | 4 ++++ docs/reusable-workflows.md | 2 +- host-setup/linux/README.md | 2 +- host-setup/windows/README.md | 2 +- 13 files changed, 49 insertions(+), 8 deletions(-) diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index a02a47d61..dcb80a6c2 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -29,3 +29,9 @@ description: >- ## After Any Workflow Edit A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. + +**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. + +**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. + +**Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract index f708b3eb1..51e3fb59e 100644 --- a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -1 +1 @@ -63292972ef7f3459 +22a47c97375eae7a diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index a02a47d61..dcb80a6c2 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -29,3 +29,9 @@ description: >- ## After Any Workflow Edit A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. + +**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. + +**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. + +**Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/actions/docker-prepare-default/action.yml b/.github/actions/docker-prepare-default/action.yml index 33c750442..7e5b2dfe8 100644 --- a/.github/actions/docker-prepare-default/action.yml +++ b/.github/actions/docker-prepare-default/action.yml @@ -12,6 +12,7 @@ inputs: description: Logical branch driving the moving tag. main gives latest, anything else develop. required: true semver2: + description: NBGV SemVer2 string, used as the immutable image tag beside the moving one. required: true outputs: diff --git a/.github/actions/dotnet-publish-default/action.yml b/.github/actions/dotnet-publish-default/action.yml index a82842c00..655f4fd6f 100644 --- a/.github/actions/dotnet-publish-default/action.yml +++ b/.github/actions/dotnet-publish-default/action.yml @@ -11,13 +11,16 @@ inputs: required: false default: 'false' semver2: - description: NBGV SemVer2 output, threaded from the caller's single get-version job. + description: NBGV SemVer2 output, threaded from the caller's single get-version job and passed as the package PackageVersion. required: true assembly-version: + description: NBGV AssemblyVersion output, threaded from the caller's single get-version job and passed as both the assembly's AssemblyVersion and its Version. required: true assembly-file-version: + description: NBGV AssemblyFileVersion output, threaded from the caller's single get-version job and passed as the assembly's FileVersion. required: true assembly-informational-version: + description: NBGV AssemblyInformationalVersion output, threaded from the caller's single get-version job and passed as the assembly's InformationalVersion. required: true project-file: description: Path to the .NET project file. diff --git a/.github/actions/nuget-build-default/action.yml b/.github/actions/nuget-build-default/action.yml index 844f7b0f5..827b712c6 100644 --- a/.github/actions/nuget-build-default/action.yml +++ b/.github/actions/nuget-build-default/action.yml @@ -10,12 +10,16 @@ inputs: required: false default: 'false' semver2: + description: NBGV SemVer2 output, threaded from the caller's single get-version job and passed as the package Version and PackageVersion. required: true assembly-version: + description: NBGV AssemblyVersion output, threaded from the caller's single get-version job and passed as the assembly's AssemblyVersion. required: true assembly-file-version: + description: NBGV AssemblyFileVersion output, threaded from the caller's single get-version job and passed as the assembly's FileVersion. required: true assembly-informational-version: + description: NBGV AssemblyInformationalVersion output, threaded from the caller's single get-version job and passed as the assembly's InformationalVersion. required: true project-file: description: Path to the .NET project file. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index a02a47d61..dcb80a6c2 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -29,3 +29,9 @@ description: >- ## After Any Workflow Edit A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. + +**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. + +**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. + +**Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/workflows/validate-task.yml b/.github/workflows/validate-task.yml index 8262bac7e..28e4aa10f 100644 --- a/.github/workflows/validate-task.yml +++ b/.github/workflows/validate-task.yml @@ -196,7 +196,7 @@ jobs: # @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') != '' + if: hashFiles('pyproject.toml') != '' || hashFiles('.github/actions/**/action.yml', '.github/actions/**/action.yaml') != '' uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 with: python-version: "3.13" @@ -248,6 +248,17 @@ jobs: echo "no [tool.mypy] or [tool.pyright] section, skipping" fi + - name: Lint composite actions step + if: hashFiles('.github/actions/**/action.yml', '.github/actions/**/action.yaml') != '' + run: | + set -Eeuo pipefail + git ls-files -z ':(top,glob).github/actions/**/action.yml' ':(top,glob).github/actions/**/action.yaml' > "$RUNNER_TEMP/action-files.txt" + if [ -s "$RUNNER_TEMP/action-files.txt" ]; then + xargs -0 uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- < "$RUNNER_TEMP/action-files.txt" + else + echo "no composite action metadata is tracked" + fi + # A publish run's content is already gated by the pull request that introduced it. - name: Check prose step if: github.event.pull_request != null diff --git a/GOVERNANCE.md b/GOVERNANCE.md index de5107b39..92325df30 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -245,11 +245,11 @@ An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket preceden ## Running the Linters Locally (Known-Working Invocations) -CI runs the full lint set, but run the linters locally before pushing to catch issues early, so an agent must know how to invoke them. Their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL, so **prefer the Docker invocations below, the known-working path that needs no local toolchain.** These tools auto-discover their targets from the working directory. None of markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, or PSScriptAnalyzer is installed natively on a fleet host, by decision, so a `command -v ` miss means exactly that and not that the check is unavailable here: read this section for the working invocation before reporting a check as skipped or unavailable. +CI runs the full lint set, but run the linters locally before pushing to catch issues early, so an agent must know how to invoke them. Their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL, so **prefer the Docker invocations below, the known-working path that needs no local toolchain.** These tools auto-discover their targets from the working directory. None of markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, PSScriptAnalyzer, or check-jsonschema is installed natively on a fleet host, by decision, so a `command -v ` miss means exactly that and not that the check is unavailable here: read this section for the working invocation before reporting a check as skipped or unavailable. **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** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three 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. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, 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** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action), and, **for a repo that carries a composite action under `.github/actions/`**, **check-jsonschema** through `uvx` against its `vendor.github-actions` builtin, that one having no container image to run from. 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. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub-fetch-run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. Neither snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the two shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. - **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker 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 the working tree diff against `HEAD` 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. That scope is the working tree rather than the staged index under a plain git hook, so a partially staged file is judged on all of its edits. Under the `pre-commit` framework it is the staged state instead, since that runner stashes unstaged changes before running a hook, which is measured rather than assumed. The pull request gate re-checks every line the branch changes against its base, which is the scope a review is judged on. A whole-repo check belongs in the hook 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 of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the doc gates make, one per fetched script. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or (`uv tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. diff --git a/OPERATIONS.md b/OPERATIONS.md index ef79e1608..7b1167cf1 100644 --- a/OPERATIONS.md +++ b/OPERATIONS.md @@ -34,6 +34,10 @@ python3 scripts/prose_lint.py . --check charset --check semicolon --check dash - python3 scripts/prose_lint.py . --check charset-unknown --summary for f in registry/*.json spec/*.json repo-config/*.json; do jq empty "$f"; done python3 spec/validate.py +git ls-files -z ':(top,glob).github/actions/**/action.yml' ':(top,glob).github/actions/**/action.yaml' > "$PROJECTTEMPLATE_TOOL_CACHE/action-files.txt" +if [ -s "$PROJECTTEMPLATE_TOOL_CACHE/action-files.txt" ]; then + xargs -0 uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- < "$PROJECTTEMPLATE_TOOL_CACHE/action-files.txt" +fi python3 scripts/docker_lint.py ``` diff --git a/docs/reusable-workflows.md b/docs/reusable-workflows.md index 796d5abbb..79b63f1b7 100644 --- a/docs/reusable-workflows.md +++ b/docs/reusable-workflows.md @@ -292,7 +292,7 @@ jobs: # One entry per target this repo builds, listing that target's own paths, so a touched target never falls through unfiltered (D1.1). # A negation such as '!.github/workflows/**' marks a docs-only pull request as a target change, which scenario S2 requires to skip. - # A repo carrying its own build hook lists .github/actions/** too, since that hook builds the target and actionlint does not reach it. + # A repo carrying its own build hook lists .github/actions/** too, since that hook builds the target. changes: name: Detect changed targets job runs-on: ubuntu-latest diff --git a/host-setup/linux/README.md b/host-setup/linux/README.md index 7ac24486f..7aa5b3467 100644 --- a/host-setup/linux/README.md +++ b/host-setup/linux/README.md @@ -123,7 +123,7 @@ The sibling scripts are independently fetchable, and this one deliberately is no ## Why There Is No Linter Category -Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. +Neither this tooling nor its Windows sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer`, `check-jsonschema` or `ruff`. That is a decision rather than a gap. Each runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check. Installing native copies would put a second, unpinned version of each on the host. The only host requirements any of it creates are `docker` and `uv`, and both are already managed here. The [Windows README][windows-readme] states the same decision from its side. ## bootstrap.sh diff --git a/host-setup/windows/README.md b/host-setup/windows/README.md index 56ead2c97..1c6a756f5 100644 --- a/host-setup/windows/README.md +++ b/host-setup/windows/README.md @@ -59,7 +59,7 @@ These are listed apart, left alone, and printed with **no command beside them**. ## Why There Is No Linter Category -Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer` or `ruff`, and that is a decision rather than a gap. +Neither this tooling nor its Linux sibling installs `markdownlint`, `cspell`, `actionlint`, `editorconfig-checker`, `shellcheck`, `shfmt`, `PSScriptAnalyzer`, `check-jsonschema` or `ruff`, and that is a decision rather than a gap. Each of those runs as a pinned container image or through `uvx`, which is what keeps a local run and CI the same check: the image tag fixes the version. Installing native copies through `winget` would put a second, unpinned version of each on the host, and a local run would then differ from CI, which is the exact property the pinned images exist to guarantee. The only host requirements any of it creates are `docker` and `uv`, and both are already in the registry. From 0ef215aeb14f9297117a85acfb6c3b78f3288c1b Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 18 Sep 2026 13:04:23 -0700 Subject: [PATCH 2/3] Correct What the Skill Claims actionlint Reads Answers the review round on [#1686](https://github.com/ptr727/ProjectTemplate/pull/1686). Every claim below was measured against `rhysd/actionlint:latest` in a scratch repository rather than reasoned about, because this paragraph has now been wrong four rounds running. ## Three claims were false, one of them as the reviewer asked for it **Discovery is recursive.** The reviewer asked for `.github/workflows/*.yml` and `.github/workflows/*.yaml`, and `*` does not cross a `/`. A file at `.github/workflows/sub/nested.yml` is linted, so writing the reviewer's form would have put a fresh false statement into carried content. The text now says actionlint discovers `.github/workflows/` recursively, both extensions. **It does validate a referenced action's metadata.** Measured against a local action reached through `uses: ./.github/actions/hook`: `description is required in metadata of "H" action`. It checks `name`, the top-level `description`, and `runs.using`, reporting each against the calling workflow. "It never validates the action file as action metadata" was false. **It reads more of the caller than `with:`.** `${{ steps.h.outputs.notdeclared }}` draws `property "notdeclared" is not defined in object type {realout: string}`, so it types the caller's `steps..outputs.` against the action's declared outputs too. What actionlint does not reach that way is a missing per-input `description` and an unexpected top-level key, which is what leaves the schema check something to do. The text says that rather than implying a clean split. ## The schema is not GitHub's `vendor.github-actions` is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge. `runs.using: node16` passes the schema and actionlint rejects it: `invalid runner name "node16" ... valid runners are "composite", "docker", "node20", and "node24"`. So the text no longer calls it GitHub's schema, since a green run there is not a statement about what GitHub accepts. ## Two `GOVERNANCE.md` corrections The CI bullet said "for a repo that carries a **composite** action". The guard is `hashFiles('.github/actions/**/action.yml', ...)` and the file list is the same two patterns, so any action metadata under that tree fires it whatever `runs.using` declares. `check-jsonschema` comes back out of the "not installed natively" sentence. That sentence sends a reader to this section for the working invocation and the section states none for it, so naming it there promised an answer that is not present. The CI bullet still names it and gives the `uvx` form. ## Why this is a second commit The previous commit is already pushed, and `GOVERNANCE.md` "Git and Commit Rules" says never force push, so refining it by amend is not available. The branch is append-only from its first push. --- .agents/skills/workflow-ci-contract/SKILL.md | 4 ++-- .../fleet-skills/.source-digests/workflow-ci-contract | 2 +- .../fleet-skills/skills/workflow-ci-contract/SKILL.md | 4 ++-- .github/skills/workflow-ci-contract/SKILL.md | 4 ++-- GOVERNANCE.md | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index dcb80a6c2..a04f77c52 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -30,8 +30,8 @@ description: >- A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. -**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. +**actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract index 51e3fb59e..43ff37b9a 100644 --- a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -1 +1 @@ -22a47c97375eae7a +d8fa3620a28ce703 diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index dcb80a6c2..a04f77c52 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -30,8 +30,8 @@ description: >- A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. -**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. +**actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index dcb80a6c2..a04f77c52 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -30,8 +30,8 @@ description: >- A workflow-only change is not smoke-built, and actionlint still runs on it in CI. `GOVERNANCE.md` "Verification Discipline" requires the repository's whole lint gate before every push, rather than actionlint alone. A workflow change is still only fully exercised by CI, per the same "Verification Discipline" section. -**actionlint's own file list is `.github/workflows/*.yml` and `*.yaml`.** It opens a composite action's `action.yml` only through a workflow in that list carrying `uses: ./`, wherever in the tree that path leads, and only to judge that caller's `with:` block against the action's declared inputs, reporting an undefined input and a missing required one against the calling workflow. It never validates the action file as action metadata, and pointing it at one directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. +**actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**The action file's own metadata is checked against GitHub's action schema instead**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. An action file outside that tree keeps actionlint's caller check and gets no metadata check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 92325df30..b74eb88bd 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -245,11 +245,11 @@ An overlap with `WORKFLOW.md` resolves **by subject**, never by blanket preceden ## Running the Linters Locally (Known-Working Invocations) -CI runs the full lint set, but run the linters locally before pushing to catch issues early, so an agent must know how to invoke them. Their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL, so **prefer the Docker invocations below, the known-working path that needs no local toolchain.** These tools auto-discover their targets from the working directory. None of markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, PSScriptAnalyzer, or check-jsonschema is installed natively on a fleet host, by decision, so a `command -v ` miss means exactly that and not that the check is unavailable here: read this section for the working invocation before reporting a check as skipped or unavailable. +CI runs the full lint set, but run the linters locally before pushing to catch issues early, so an agent must know how to invoke them. Their non-Docker install paths (curl-pipe installers, global npm) are frequently blocked in sandboxes or fail on WSL, so **prefer the Docker invocations below, the known-working path that needs no local toolchain.** These tools auto-discover their targets from the working directory. None of markdownlint, cspell, actionlint, editorconfig-checker, shellcheck, shfmt, or PSScriptAnalyzer is installed natively on a fleet host, by decision, so a `command -v ` miss means exactly that and not that the check is unavailable here: read this section for the working invocation before reporting a check as skipped or unavailable. **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** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action), and, **for a repo that carries a composite action under `.github/actions/`**, **check-jsonschema** through `uvx` against its `vendor.github-actions` builtin, that one having no container image to run from. 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. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, 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** and **shfmt** the same way for a repo that carries a shell script, and, **for a repo that carries `.ps1` files**, **PSScriptAnalyzer** the same way (none of the three has an action), and, **for a repo that carries an action under `.github/actions/`**, **check-jsonschema** through `uvx` against its `vendor.github-actions` builtin, that one having no container image to run from. 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. A "shell script" here is a tracked `.sh` file, plus a tracked, extension-less file whose shebang names bash or sh, the shape a script meant to run as a bare command takes: a bare `*.sh` glob misses that second case, so both discovery paths run before either tool does. This whole block is the hub's `validate-task.yml` reusable workflow, so a fleet repo reaches it rather than carrying a copy of these steps. - **A working local hook is strongly suggested fleet-wide, and its absence is a measured audit finding, not an invisible gap.** `spec/project-types.json`'s `parity.hooks` check reads this section for its rationale, judged by hand during an `AUDIT.md` run like every sibling check in its dimension, never mechanized by `spec/audit.py`: a repo with no local hook mechanism wired at all is a `linter-parity` defect, the same severity a missing markdownlint config already gets, while a repo mid-convergence (below) stays operational. CI remains the authoritative run regardless. Two catalog snippets carry the canonical shape, each with a copy of `catalog/snippets/hub-fetch-run.py` alongside it. `catalog/snippets/husky/` is Husky.Net, for a repo that already keeps a .NET tool manifest declaring it, since `dotnet tool restore` then `dotnet husky install` is what generates the `.husky/_/husky.sh` its hook sources, and the snippet ships no manifest of its own. `catalog/snippets/pre-commit/` is the `pre-commit` framework, for a repo without one and for any repo that prefers it. Neither snippet is scoped to a language by what it runs. Each carries the repo's own language checks plus the two shared doc gates. A repo drops the block for a language it does not have. A Docker, config, or docs repo therefore wires the doc gates alone, which is a mechanism rather than the absence of one. Which snippet a repo starts from is bounded by the toolchain it already keeps, never by which languages its checks cover. A snippet is a starting point rather than the only shape: a repo may wire an equivalent hook of its own at `.husky/pre-commit`, enabled with `core.hooksPath` and sourcing nothing, which is what this repository does. That path matters, since `parity.hooks` reads the tracked file at `.husky/pre-commit` or `.pre-commit-config.yaml` rather than a per-clone hooks path it cannot observe. - **The hook** runs **language formatting** and the **diff-scoped doc gates**, never Docker, so it stays fast. The formatting half is whatever the repo's own language needs, CSharpier and `dotnet format` for .NET or ruff and the repo's type checker 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 the working tree diff against `HEAD` 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. That scope is the working tree rather than the staged index under a plain git hook, so a partially staged file is judged on all of its edits. Under the `pre-commit` framework it is the staged state instead, since that runner stashes unstaged changes before running a hook, which is measured rather than assumed. The pull request gate re-checks every line the branch changes against its base, which is the scope a review is judged on. A whole-repo check belongs in the hook 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 of the hook regardless, since it resolves a same-owner pin against the GitHub API. - **The doc gates reach a downstream repo by fetching `.github/actions/prose-gate/prose_lint.py` and `.github/actions/repo-gate/repo_gate.py` fresh from `ptr727/ProjectTemplate`'s `main` branch, via `hub-fetch-run.py`, never vendored and never pinned.** Pinning anything Dependabot does not maintain goes stale by construction, and CI (this repo's own, and the hub's) is the backstop that catches a change landing broken on `main` before a locally fetched copy does real damage. These are the only network calls the doc gates make, one per fetched script. A Python repo's `uvx`-run ruff and type checker can also reach the network, to resolve `@latest` on a cache miss or refresh, the same category of dependency as the Docker pulls the VS Code Lint tasks already do routinely, not a new one. A fetch failure fails the commit, and it never silently skips the gate. The **hub's own** `.husky/pre-commit` is the one exception, staying local and offline, since it already carries `scripts/prose_lint.py` and `scripts/repo_gate.py` directly and has no hub to reach. A repo enables its hook per clone with `git config core.hooksPath .husky` or (`uv tool install pre-commit` once, then) `pre-commit install`. The Husky.Net snippet needs one more step per clone, `dotnet tool restore` then `dotnet husky install`, which generates `.husky/_/husky.sh`, the file the hook sources. CI remains the authoritative run either way. From fa40979f5749f74d9a685aed03d941735746ae53 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Fri, 18 Sep 2026 14:35:35 -0700 Subject: [PATCH 3/3] Drop the Schema's Provenance Claim and Keep Its Measured Divergence Answers Copilot's second round on [#1686](https://github.com/ptr727/ProjectTemplate/pull/1686). The sentence asserted where the builtin schema comes from, "SchemaStore's vendored schema rather than a GitHub-published one". The origin is not what a reader of this skill needs, and it is a claim that has to be kept true as upstream moves. What a reader needs is the behaviour, which is measured rather than asserted: the schema accepts `runs.using: node16` and actionlint rejects it with `invalid runner name "node16" ... valid runners are "composite", "docker", "node20", and "node24"`, so a green schema run is not a statement about what GitHub currently accepts. That is what the sentence now says, and nothing about provenance. The round's other finding, that this repository's actionlint invocation is non-recursive, is declined on the pull request rather than here, since it opened no thread. `scripts/docker_lint.py` passes its patterns to `git ls-files` as a pathspec rather than to a shell, and git's default pathspec magic is wildmatch without `WM_PATHNAME`, so `*` matches `/` and `git ls-files -- '.github/workflows/*.yml'` returns a nested workflow. --- .agents/skills/workflow-ci-contract/SKILL.md | 2 +- .../fleet-skills/.source-digests/workflow-ci-contract | 2 +- .../fleet-skills/skills/workflow-ci-contract/SKILL.md | 2 +- .github/skills/workflow-ci-contract/SKILL.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/skills/workflow-ci-contract/SKILL.md b/.agents/skills/workflow-ci-contract/SKILL.md index a04f77c52..970af3b28 100644 --- a/.agents/skills/workflow-ci-contract/SKILL.md +++ b/.agents/skills/workflow-ci-contract/SKILL.md @@ -32,6 +32,6 @@ A workflow-only change is not smoke-built, and actionlint still runs on it in CI **actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. The schema and actionlint do not agree on every key: the schema accepts `runs.using: node16`, which actionlint rejects as an invalid runner, so a green schema run is not a statement about what GitHub currently accepts. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract index 43ff37b9a..fad4d761d 100644 --- a/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract +++ b/.claude-plugin/fleet-skills/.source-digests/workflow-ci-contract @@ -1 +1 @@ -d8fa3620a28ce703 +8dc809890d522fde diff --git a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md index a04f77c52..970af3b28 100644 --- a/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md +++ b/.claude-plugin/fleet-skills/skills/workflow-ci-contract/SKILL.md @@ -32,6 +32,6 @@ A workflow-only change is not smoke-built, and actionlint still runs on it in CI **actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. The schema and actionlint do not agree on every key: the schema accepts `runs.using: node16`, which actionlint rejects as an invalid runner, so a green schema run is not a statement about what GitHub currently accepts. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it. diff --git a/.github/skills/workflow-ci-contract/SKILL.md b/.github/skills/workflow-ci-contract/SKILL.md index a04f77c52..970af3b28 100644 --- a/.github/skills/workflow-ci-contract/SKILL.md +++ b/.github/skills/workflow-ci-contract/SKILL.md @@ -32,6 +32,6 @@ A workflow-only change is not smoke-built, and actionlint still runs on it in CI **actionlint discovers `.github/workflows/` recursively, both extensions, and opens no action file on its own.** It reaches a local action's `action.yml` only through a workflow's `uses: ./`, wherever in the tree that path leads, and reports everything it finds there against the calling workflow: the caller's `with:` block against the action's declared inputs, the caller's `steps..outputs.` against its declared outputs, and the action's own `name`, top-level `description` and `runs.using`. A missing per-input `description` and an unexpected top-level key are the metadata it does not reach that way. Pointing it at an action file directly makes it parse the file as a workflow and report several syntax-check errors, so widening its file list is not available. **Where no workflow in the repository names the action, actionlint reaches it not at all**, which is the ordinary shape for a repository whose hooks are invoked from a hub reusable workflow rather than from a workflow of its own. -**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. It is SchemaStore's vendored schema rather than a GitHub-published one, and the two diverge: it accepts `runs.using: node16`, which actionlint rejects as an invalid runner. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. +**A schema check covers the action file itself**, `check-jsonschema`'s `vendor.github-actions` builtin, run as `uvx check-jsonschema@latest --builtin-schema vendor.github-actions -- `. That one reads every tracked `action.yml` and `action.yaml` under `.github/actions/`, whether or not a workflow references it, and reaches the structure and the keys rather than the caller's contract. The schema and actionlint do not agree on every key: the schema accepts `runs.using: node16`, which actionlint rejects as an invalid runner, so a green schema run is not a statement about what GitHub currently accepts. An action file outside `.github/actions/` keeps actionlint's caller check and gets no schema check at all. **Neither check reads a composite action's `run:` bodies or its `if:` expressions.** Measured on a referenced action carrying both a malformed `if:` and an unterminated shell `if` in a `run:` block: actionlint and the schema check each pass it. A broken expression or shell body in a composite action therefore surfaces when a run executes it.