feat: standards-check.yml, the deterministic required check (W1) - #162
Merged
Conversation
added 6 commits
September 8, 2026 11:43
Adds the canonical markdownlint and yamllint configs (mirroring the
dotfiles copies at ~/.config), a check-node-floor.sh that rejects Node
pins below a given major, and a tests/ harness to run known-bad fixtures.
check-node-floor.sh scans .nvmrc/.node-version, package.json
engines.node, and workflow node-version / node-version-file pins. Named
aliases (lts/*, node, latest) float and are accepted; ${{ }} expressions
cannot be resolved statically and get a notice instead of an error.
Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
Adds the deterministic standards runner that drives shellcheck, yamllint, actionlint, zizmor, markdownlint and the Node-floor check over a repo's tracked files, plus a fixture suite where each linter has a known-bad case that must be rejected and a clean case that must pass. A linter with nothing to lint passes with a notice; --skip genuinely disables one. Dogfooding the runner on this repo surfaced two real findings, fixed here: - claude-blocking-review.yml embedded a secrets expression inside a `run:` comment. GitHub interpolates expressions in run blocks even in shell comments, and this workflow declares the secret as claude_oauth_token, so actionlint failed on the undefined property. Reworded to describe the secret without expression syntax. - zizmor reported dependabot-cooldown and self-repository. Both are now ignored with rationale in zizmor.yml: a cooldown would re-introduce exactly the update lag behind #123, and zizmor's suggested same-repo call form is rejected by both GitHub and actionlint, so `./...` is the only syntax that runs. Also hardens check-node-floor.sh after review of the previous commit: package.json with no jq now fails loudly (exit 2) instead of skipping the engines.node source in silence, and inline-comment stripping follows YAML's rule that a comment needs preceding whitespace, so "20#c" is reported as an unrecognisable value rather than misread as a Node 20 pin. Both are covered by new fixtures. Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
Two findings from the Task 1-2 review.
run-standards.sh reported a clean pass over any directory git could not
read. Every file-based linter enumerates through `_tracked || true`, so a
non-repo --repo (or a repo git refuses, e.g. dubious ownership) produced an
empty file list and each linter passed over nothing. Guard immediately after
argument parsing: if `git rev-parse --git-dir` fails, error and exit 2.
The fixtures in test-run-standards.sh used a plain `git init`, which fires
the user's global init.templateDir and scaffolds .editorconfig, .gitignore,
and .claude/ into every fixture — so a fixture meant to be empty was not.
An explicit empty --template overrides it; `git ls-files` on a bare fixture
now lists nothing. (test-check-node-floor.sh never calls git init; its
fixtures are plain directories.)
The new exit-2 case was observed failing against the pre-fix script
("exited 0, expected 2") before the guard was added.
Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
The deterministic, secret-free required check that replaces the CI judgment reviewer (dev-env#60, #154). Installs pinned, checksum-verified linters and runs standards/run-standards.sh from this repo at the SHA of the workflow file itself, so the script, the configs, and the workflow always agree. self-standards-check.yml calls it through a local path, so a PR that changes the check dogfoods itself and produces the `standards-check / run-standards-check` status check. Deviations from the drafted design, each verified rather than assumed: - The draft used `github.job_workflow_sha`. That property is absent from the current contexts reference and evaluates to an empty string (actions/runner#2417); the documented property is `job.workflow_sha`. Empty is the dangerous value, because actions/checkout treats an empty `ref` as the default branch — the check would have gone green having linted a different commit than the one under review. A separate step now resolves the SHA and exits 2 when it is empty, so a runner-side regression fails loudly instead of silently linting the wrong tree. - actionlint 1.7.12 has not added `job.workflow_sha` to its context schema, so it reports the property as undefined. .github/actionlint.yaml adds a documented ignore scoped to that one message in that one file. The scoping was validated against a known-bad case: an unrelated bogus property in the same file is still reported. - The single concatenated SKIP_LIST expression was 346 columns, over the shared 120-column yamllint limit. It is now one env var per linter joined in the shell, which keeps the fleet-wide limit intact. The join was exercised for the all-empty, partial, all-set, and trailing-element cases. - zizmor.yml gains the standards-check.yml excessive-permissions entry, plus self-repository for the new caller and adhoc-packages for the two pinned CLI installs that have no lockfile. Advances #154 Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
Adds the ## standards-check.yml README section (purpose, caller stub, inputs table, check name, config precedence, pinned tool versions, local command, validation note for PR #163/run 34267112924, and the standards-check-v1/vX.Y.Z versioning namespace), and fills in the previously-empty CLAUDE.md with the tests/run-tests.sh and standards/run-standards.sh commands. Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
…notes zizmor scanned the whole repo tree, so a tracked example workflow living outside .github/workflows/ (e.g. docs that show a caller stub) could fail the gate even though it isn't a real workflow. Scope the zizmor pass to tracked .github/workflows/*.yml|*.yaml, the same way the other linters already use the tracked-file list, with the existing "no workflows" notice when that list is empty. Also note in zizmor.yml and README.md that zizmor.yml doubles as the fleet-wide CI fallback policy (standards-check.yml uses it for any consuming repo with no zizmor.yml of its own), and add a re-check pointer in .github/actionlint.yaml's workflow_sha ignore for whoever next bumps actionlint. Claude-Session: https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The deterministic, secret-free standards check that becomes the fleet's required check, replacing the CI judgment reviewer outright (dev-env#60, #154; decided 2026-09-08).
It is a reusable workflow. It installs pinned, checksum-verified linters, then runs
standards/run-standards.shfrom this repo at the SHA of the workflow file itself — so the script, the configs, and the workflow always agree, and a consumer can never drift onto a mismatched pair.Unlike the judgment reviewer it replaces, it consumes no secrets, calls no model, and returns the same verdict for the same tree every time.
The caller stub
Consumers add one file. Name the job
standards-check, and the required check isstandards-check / run-standards-check:self-standards-check.ymlin this PR is that stub pointed at a local path, so a PR that changes the check dogfoods itself.Inputs
shellchecktrueshellcheck -S infoover shell filesyamllinttrueactionlinttrue.github/workflowszizmortrue.github/workflowsmarkdownlinttruenode_floor_checktruenode_floornode_floor"22"Setting a linter's input to
falseskips both its install and its run.Pinned tools
Every version is pinned here and bumped by PR. The three binaries are additionally SHA256-verified before install.
Running it locally
The same script CI runs:
Design decisions worth review
Three deviations from the drafted design, each verified rather than assumed:
job.workflow_sha, notgithub.job_workflow_sha. The draft used the latter. It is absent from the current contexts reference and evaluates to an empty string (Property job_workflow_sha in context github is not available actions/runner#2417). Empty is the dangerous value:actions/checkouttreats an emptyrefas the default branch, so the check would have gone green having linted a different commit than the one under review. A separate step now resolves the SHA and exits 2 when it is empty, so a runner-side regression fails loudly instead of silently linting the wrong tree.A scoped
actionlint.yamlignore. actionlint 1.7.12 has not addedjob.workflow_shato its context schema, so it reports the property as undefined. The ignore is scoped to that one message in that one file, and the scoping was validated against a known-bad case — an unrelated bogus property in the same file is still reported.SKIP_LISTsplit per linter. The single concatenated expression was 346 columns, over the shared 120-column yamllint limit. Widening that limit would have weakened it fleet-wide, so the list is now one env var per linter joined in the shell. The join was exercised for the all-empty, partial, all-set, and trailing-element cases.Also included: a fix for two review findings from the earlier commits —
run-standards.shreported a clean pass over any directory git could not read (it now exits 2), and the test fixtures inherited the user's global git template instead of being hermetic.Validation
Read from the run log, not inferred from the green check.
standards-check / run-standards-checkpassed in 16s.All three checksums verified:
All six linters ran (
skip list: ''— nothing skipped, every input defaulted true):The standards checkout resolved the right commit. The log reports:
That commit is
Merge 535b88f into 39d1b15— the PR head merged into main, confirmed by its two parents and by the PR's ownpotentialMergeCommit. For apull_requesttrigger that is the correct ref: it carries this PR's content tested against the merge target. It is notorigin/main(39d1b15), which rules out the silent default-branch fallback that the empty-SHA guard exists to prevent.This is the end-to-end confirmation that
job.workflow_shapopulates at runtime wheregithub.job_workflow_shawould have been empty.Advances #154
https://claude.ai/code/session_019HDRKLQNv82SEBd4zGpcXf