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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .agents/skills/workflow-ci-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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: ./<path>`, 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.<id>.outputs.<name>` 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 -- <files>`. 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.
Original file line number Diff line number Diff line change
@@ -1 +1 @@
63292972ef7f3459
8dc809890d522fde
Original file line number Diff line number Diff line change
Expand Up @@ -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 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: ./<path>`, 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.<id>.outputs.<name>` 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 -- <files>`. 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.
1 change: 1 addition & 0 deletions .github/actions/docker-prepare-default/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion .github/actions/dotnet-publish-default/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/nuget-build-default/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions .github/skills/workflow-ci-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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: ./<path>`, 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.<id>.outputs.<name>` 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 -- <files>`. 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.
13 changes: 12 additions & 1 deletion .github/workflows/validate-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ jobs:
# @latest rather than a version pin, since a `uvx <tool>@<ver>` 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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,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** 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 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.
Expand Down
4 changes: 4 additions & 0 deletions OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion docs/reusable-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading