diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000..eb984cd --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,34 @@ +# actionlint configuration. +# +# Like zizmor.yml, every entry here is a documented policy decision, not a +# convenience mute. Findings that reflect a real gap stay visible. + +paths: + .github/workflows/standards-check.yml: + ignore: + # `job.workflow_sha` is the documented GitHub context property for "the + # commit SHA of the workflow file that defines the current job" — which + # is exactly what a reusable workflow needs in order to check out its + # OWN standards/ and configs at the same commit as the workflow being + # run. actionlint 1.7.12 has not added it to the `job` context schema + # (it is absent from the schema and from docs/checks.md), so it reports + # the property as undefined. GitHub populates it at runtime. + # + # Last verified against actionlint 1.7.12 and the upstream context + # schema reference at + # https://github.com/rhysd/actionlint/blob/main/docs/checks.md — whoever + # bumps actionlint should re-check that reference and remove this ignore + # once `workflow_sha` is recognized. + # + # The older spelling `github.job_workflow_sha` is NOT the fix: it is + # absent from the current contexts reference and evaluates to an empty + # string (actions/runner#2417). Empty is the dangerous value here, + # because actions/checkout treats an empty `ref` as the default branch + # — the check would go green having linted a different commit than the + # one under review. standards-check.yml therefore resolves the SHA in + # its own step and exits 2 when it is empty, so a future runner-side + # regression fails loudly instead of silently linting the wrong tree. + # + # Scoped to this one message in this one file: any other expression + # error, in this or any other workflow, still fires. + - 'property "workflow_sha" is not defined in object type' diff --git a/.github/workflows/claude-blocking-review.yml b/.github/workflows/claude-blocking-review.yml index efdaec7..0afcdde 100644 --- a/.github/workflows/claude-blocking-review.yml +++ b/.github/workflows/claude-blocking-review.yml @@ -568,10 +568,16 @@ jobs: # `secrets: claude_oauth_token: required: true` on this # workflow_call only guarantees the caller supplied a `secrets:` # block — it does NOT guarantee the underlying repo secret it - # references is actually set. A caller passing - # `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` from a repo where that - # secret was never added still satisfies the `required: true` - # check while passing an empty string through. + # references is actually set. A caller passing its + # CLAUDE_CODE_OAUTH_TOKEN secret from a repo where that secret + # was never added still satisfies the `required: true` check + # while passing an empty string through. + # + # (Written without the ${_{ }} expression syntax on purpose: + # GitHub interpolates expressions inside `run:` blocks even in + # shell comments, and actionlint resolves them against this + # workflow's own `secrets:` — which declares claude_oauth_token, + # not CLAUDE_CODE_OAUTH_TOKEN.) # # Gated behind the same doc-only/Dependabot skip outputs as the # review step itself (rather than running unconditionally right diff --git a/.github/workflows/self-standards-check.yml b/.github/workflows/self-standards-check.yml new file mode 100644 index 0000000..a4b47a2 --- /dev/null +++ b/.github/workflows/self-standards-check.yml @@ -0,0 +1,16 @@ +name: Self Standards Check + +# Self-applying caller: runs this repo's reusable standards-check.yml on its +# own PRs via a local path, so a PR that changes the check dogfoods itself. +# Produces the `standards-check / run-standards-check` status check. + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + +permissions: + contents: read + +jobs: + standards-check: + uses: ./.github/workflows/standards-check.yml diff --git a/.github/workflows/standards-check.yml b/.github/workflows/standards-check.yml new file mode 100644 index 0000000..ed71281 --- /dev/null +++ b/.github/workflows/standards-check.yml @@ -0,0 +1,178 @@ +name: Standards Check + +# Reusable, deterministic, secret-free standards check. Replaces the CI +# judgment reviewer as the fleet's required check (dev-env#60, +# github-workflows#154). Installs pinned linters and runs +# standards/run-standards.sh from THIS repo at the SHA of this workflow file +# (job.workflow_sha), so the script, the configs, and the workflow always +# agree. +# +# Caller stub (name the job `standards-check`; the required check is then +# `standards-check / run-standards-check`): +# +# name: Standards Check +# on: +# pull_request: +# types: [opened, synchronize, ready_for_review, reopened] +# permissions: +# contents: read +# jobs: +# standards-check: +# uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1 +# +# Tool versions are pinned here and verified by checksum. Bump them by PR. + +on: + workflow_call: + inputs: + shellcheck: + description: Run shellcheck -S info over shell files + type: boolean + default: true + yamllint: + description: Run yamllint over YAML files + type: boolean + default: true + actionlint: + description: Run actionlint over .github/workflows + type: boolean + default: true + zizmor: + description: Run zizmor over .github/workflows + type: boolean + default: true + markdownlint: + description: Run markdownlint-cli2 over Markdown files + type: boolean + default: true + node_floor_check: + description: Fail on Node.js pins below node_floor + type: boolean + default: true + node_floor: + description: Lowest supported Node.js major + type: string + default: "22" + +permissions: {} + +env: + SHELLCHECK_VERSION: "0.11.0" + SHELLCHECK_SHA256: "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198" + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + ZIZMOR_VERSION: "1.30.0" + ZIZMOR_SHA256: "ec8c95cd800845abb9bbc5f377ec7c57d2eb8e2386a00a201d3a74ee4092e5ed" + YAMLLINT_VERSION: "1.38.0" + MARKDOWNLINT_CLI2_VERSION: "0.23.2" + +jobs: + run-standards-check: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout caller repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 1 + path: repo + + # Resolve the SHA of THIS reusable workflow file, and fail if it is + # empty. `job.workflow_sha` is the documented property (the older + # `github.job_workflow_sha` spelling evaluates empty — actions/runner#2417). + # An empty ref is the dangerous case: actions/checkout would silently + # take the default branch, so the check would go green having linted a + # different commit than the one under review. Fail loudly instead. + - name: Resolve the standards SHA + id: standards_sha + env: + JOB_WORKFLOW_SHA: ${{ job.workflow_sha }} + run: | + set -euo pipefail + if [ -z "${JOB_WORKFLOW_SHA}" ]; then + echo "::error::job.workflow_sha is empty; refusing to check out an ambiguous ref" + exit 2 + fi + echo "sha=${JOB_WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}" + echo "standards SHA: ${JOB_WORKFLOW_SHA}" + + - name: Checkout standards at the called-workflow SHA + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: smartwatermelon/github-workflows + ref: ${{ steps.standards_sha.outputs.sha }} + persist-credentials: false + fetch-depth: 1 + path: standards-src + sparse-checkout: | + standards + zizmor.yml + + - name: Install pinned linters + env: + SKIP_SHELLCHECK: ${{ inputs.shellcheck == false }} + SKIP_ACTIONLINT: ${{ inputs.actionlint == false }} + SKIP_ZIZMOR: ${{ inputs.zizmor == false }} + SKIP_YAMLLINT: ${{ inputs.yamllint == false }} + SKIP_MARKDOWNLINT: ${{ inputs.markdownlint == false }} + run: | + set -euo pipefail + mkdir -p "${HOME}/.local/bin"; echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + if [ "${SKIP_SHELLCHECK}" != "true" ]; then + curl -Lfso /tmp/shellcheck.tar.xz \ + "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + echo "${SHELLCHECK_SHA256} /tmp/shellcheck.tar.xz" | sha256sum -c - + tar -xJf /tmp/shellcheck.tar.xz -C /tmp + install -m 0755 "/tmp/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "${HOME}/.local/bin/shellcheck" + fi + if [ "${SKIP_ACTIONLINT}" != "true" ]; then + curl -Lfso /tmp/actionlint.tar.gz \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + echo "${ACTIONLINT_SHA256} /tmp/actionlint.tar.gz" | sha256sum -c - + tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint + install -m 0755 /tmp/actionlint "${HOME}/.local/bin/actionlint" + fi + if [ "${SKIP_ZIZMOR}" != "true" ]; then + curl -Lfso /tmp/zizmor.tar.gz \ + "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-x86_64-unknown-linux-gnu.tar.gz" + echo "${ZIZMOR_SHA256} /tmp/zizmor.tar.gz" | sha256sum -c - + mkdir -p /tmp/zizmor && tar -xzf /tmp/zizmor.tar.gz -C /tmp/zizmor + install -m 0755 "$(find /tmp/zizmor -type f -name zizmor | head -1)" "${HOME}/.local/bin/zizmor" + fi + if [ "${SKIP_YAMLLINT}" != "true" ]; then + pipx install "yamllint==${YAMLLINT_VERSION}" + fi + if [ "${SKIP_MARKDOWNLINT}" != "true" ]; then + npm install -g "markdownlint-cli2@${MARKDOWNLINT_CLI2_VERSION}" + fi + + # The skip list is assembled from one env var per linter rather than one + # long concatenated expression, so every line stays under the shared + # 120-column yamllint limit. Each var holds the linter's name when the + # input is false and an empty string otherwise; the shell joins them. + - name: Run standards + env: + SKIP_SHELLCHECK: ${{ inputs.shellcheck == false && 'shellcheck' || '' }} + SKIP_YAMLLINT: ${{ inputs.yamllint == false && 'yamllint' || '' }} + SKIP_ACTIONLINT: ${{ inputs.actionlint == false && 'actionlint' || '' }} + SKIP_ZIZMOR: ${{ inputs.zizmor == false && 'zizmor' || '' }} + SKIP_MARKDOWNLINT: ${{ inputs.markdownlint == false && 'markdownlint' || '' }} + SKIP_NODE_FLOOR: ${{ inputs.node_floor_check == false && 'node-floor' || '' }} + NODE_FLOOR: ${{ inputs.node_floor }} + run: | + set -euo pipefail + [[ "${NODE_FLOOR}" =~ ^[0-9]+$ ]] || { echo "::error::node_floor must be an integer major"; exit 2; } + skips=() + for s in "${SKIP_SHELLCHECK}" "${SKIP_YAMLLINT}" "${SKIP_ACTIONLINT}" \ + "${SKIP_ZIZMOR}" "${SKIP_MARKDOWNLINT}" "${SKIP_NODE_FLOOR}"; do + [ -n "${s}" ] && skips+=("${s}") + done + skip_list="$(IFS=,; echo "${skips[*]-}")" + echo "skip list: '${skip_list}'" + bash standards-src/standards/run-standards.sh \ + --repo repo \ + --config-dir standards-src/standards \ + --skip "${skip_list}" \ + --node-floor "${NODE_FLOOR}" diff --git a/CLAUDE.md b/CLAUDE.md index e69de29..a5696c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Testing and linting + +- `bash tests/run-tests.sh` runs the standards suites (`test-run-standards.sh`, `test-check-node-floor.sh`). + Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and `markdownlint-cli2` (all via Homebrew), plus `jq`. +- `bash standards/run-standards.sh --repo ` runs the exact same standards + check CI runs, against a local checkout — this is the command + `standards-check.yml` calls in CI, so a clean local run predicts a clean + self-check. diff --git a/README.md b/README.md index 0aecd51..653843b 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,131 @@ work. --- +## `standards-check.yml` + +Reusable, deterministic, secret-free standards check. Per +[dev-env#60](https://github.com/smartwatermelon/dev-env/issues/60) and +[github-workflows#154](https://github.com/smartwatermelon/github-workflows/issues/154), +decided 2026-09-08, this check replaces `claude-blocking-review.yml` as the +fleet's required check, with no judgment reviewer kept in CI. Rollout (W2) +and retirement of the old required check (W3) follow as separate phases; see +[`smartwatermelon/dev-env` `docs/superpowers/plans/2026-09-08-w1-standards-check.md`](https://github.com/smartwatermelon/dev-env/blob/main/docs/superpowers/plans/2026-09-08-w1-standards-check.md) +for the full plan. + +It installs pinned linters and runs `standards/run-standards.sh` from this +repo, checked out at the SHA of the workflow file itself (`job.workflow_sha`), +so the script, the configs, and the running workflow always agree. + +### Setup + +`.github/workflows/standards-check.yml` in your repo: + +```yaml +name: Standards Check +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] +permissions: + contents: read +jobs: + standards-check: + uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1 +``` + +Name the caller job `standards-check`; the required check is then +`standards-check / run-standards-check` (caller job `standards-check`, inner +job `run-standards-check`). With reusable workflows, GitHub reports the +**inner job** as the status check, so the check name follows the caller job +name regardless of which repo you're in. + +### Inputs + +| Input | Type | Default | Description | +| ----- | ---- | ------- | ----------- | +| `shellcheck` | boolean | `true` | Run `shellcheck -S info` over shell files | +| `yamllint` | boolean | `true` | Run yamllint over YAML files | +| `actionlint` | boolean | `true` | Run actionlint over `.github/workflows` | +| `zizmor` | boolean | `true` | Run zizmor over `.github/workflows` | +| `markdownlint` | boolean | `true` | Run markdownlint-cli2 over Markdown files | +| `node_floor_check` | boolean | `true` | Fail on Node.js pins below `node_floor` | +| `node_floor` | string | `"22"` | Lowest supported Node.js major | + +### Config precedence + +Per linter, a config at the caller repo's root wins. Otherwise the canonical +file from **this** repo is used — `standards/markdownlint.json`, +`standards/yamllint.yml`, or root `zizmor.yml` — fetched at +`job.workflow_sha` (the SHA of the called workflow file), so the configs +always match the workflow version that's running. A dedicated step resolves +that SHA and fails loudly if it is empty, rather than letting +`actions/checkout` silently fall back to the default branch and lint the +wrong commit. This makes root [`zizmor.yml`](./zizmor.yml) the fleet-wide CI +fallback policy for any consuming repo that has no `zizmor.yml` of its own — +its `dependabot-cooldown` ignore, for example, applies fleet-wide by design. + +`.github/actionlint.yaml` in this repo carries a scoped ignore for +`job.workflow_sha` on `standards-check.yml`, because actionlint 1.7.12's +context schema hasn't caught up to that (documented, GitHub-populated) +property yet. Remove the ignore once actionlint recognizes it. + +### Tool versions + +Pinned and checksum-verified in the workflow's `env:` block: + +| Tool | Version | Verification | +| ---- | ------- | ------------- | +| shellcheck | 0.11.0 | SHA-256 | +| actionlint | 1.7.12 | SHA-256 | +| zizmor | 1.30.0 | SHA-256 | +| yamllint | 1.38.0 | pinned via `pipx install` | +| markdownlint-cli2 | 0.23.2 | pinned via `npm install -g` | + +Bumps are manual PRs to this repo — Dependabot does not track these +versions (they're plain strings in a workflow `env:` block, not a manifest +Dependabot understands). + +### Local command + +```bash +bash standards/run-standards.sh --repo +``` + +Runs the same checks CI runs, against a local checkout. + +### Tests + +```bash +bash tests/run-tests.sh +``` + +Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and +`markdownlint-cli2` (all via Homebrew), plus `jq`. + +### Validation + +On 2026-09-08, a throwaway PR +([#163](https://github.com/smartwatermelon/github-workflows/pull/163)) with +a real `SC2086` was pushed against the caller workflow to confirm the gate +actually fails on a known-bad case. It did: +`standards-check / run-standards-check` failed in +[run 34267112924](https://github.com/smartwatermelon/github-workflows/actions/runs/34267112924), +with shellcheck reporting the injected problem. PR #163 was then closed +without merging. + +### Versioning + +| Tag | Meaning | +| ----- | --------- | +| `standards-check-v1` | **Recommended for callers.** Floating major — moved manually and human-authorized, exactly like `@v3` above. | +| `standards-check-vX.Y.Z` | Exact release. Pin only with a specific reason to freeze; see the "Prefer floating `@v3`" discussion above — the same trade-off applies here. | + +This workflow uses its own prefixed tag namespace for the same reason +`dependabot-auto-merge` does: git tags in this repo are repo-scoped, not +per-file, so a fourth workflow starting a bare `v1` would collide with the +existing `claude-blocking-review`/`claude-assistant` `v1` line. + +--- + ## `claude-assistant` Reusable workflow that invokes Claude Code Action. The caller handles triggers diff --git a/standards/check-node-floor.sh b/standards/check-node-floor.sh new file mode 100755 index 0000000..7552376 --- /dev/null +++ b/standards/check-node-floor.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# check-node-floor.sh +# +# Fails (exit 1) if any Node.js version pin in the repo names a major below +# . Sources scanned: +# - .github/workflows/*.yml|*.yaml : `node-version:` literals and +# `node-version-file:` indirections +# - .nvmrc, .node-version : bare versions ("20", "20.19.4", "v18") +# - package.json : engines.node lower bound (">=14.0.0") +# Named aliases (lts/*, node, latest, current) are accepted: they float and +# are never below the floor. Expressions (${{ ... }}) are skipped with a +# notice because they cannot be resolved statically. +set -euo pipefail + +repo="${1:?usage: check-node-floor.sh }" +floor="${2:?usage: check-node-floor.sh }" +[[ "${floor}" =~ ^[0-9]+$ ]] || { echo "::error::floor must be an integer major, got '${floor}'"; exit 2; } + +errors=0 + +# _major -> prints the major, or nothing for aliases/unparseable. +# Always returns 0: "no major here" is a valid answer (lts/*, node, latest), +# not an error, and a nonzero status would be masked inside "$(_major ...)". +_major() { + local v="${1}" + v="${v#v}"; v="${v%%.*}" + if [[ "${v}" =~ ^[0-9]+$ ]]; then printf '%s' "${v}"; fi + return 0 +} + +# _check +_check() { + local major="${1}" where="${2}" + [[ -z "${major}" ]] && return 0 + if (( major < floor )); then + echo "::error::${where}: Node ${major} is below the supported floor (${floor})" + errors=$((errors + 1)) + fi +} + +# _version_from_file -> first non-empty, non-comment line, trimmed +_version_from_file() { + grep -vE '^\s*(#|$)' "${1}" | head -1 | tr -d '[:space:]' +} + +for f in "${repo}/.nvmrc" "${repo}/.node-version"; do + [[ -f "${f}" ]] || continue + raw="$(_version_from_file "${f}")" + major="$(_major "${raw}")" + _check "${major}" "${f#"${repo}"/}: ${raw}" +done + +if [[ -f "${repo}/package.json" ]] && ! command -v jq >/dev/null; then + # Never skip a documented source silently: without jq an engines.node pin + # below the floor would pass unnoticed, which is worse than a hard failure. + echo "::error::package.json is present but jq is not installed; engines.node cannot be checked" + exit 2 +fi + +if [[ -f "${repo}/package.json" ]]; then + eng="$(jq -r '.engines.node // empty' "${repo}/package.json" 2>/dev/null || true)" + if [[ -n "${eng}" ]]; then + # Lower bound: first numeric token after an optional >= / ^ / ~ prefix. + low="$(printf '%s' "${eng}" | grep -oE '[0-9]+(\.[0-9]+)*' | head -1 || true)" + major="$(_major "${low}")" + _check "${major}" "package.json engines.node: ${eng}" + fi +fi + +# Literal opening delimiter of a GitHub Actions expression, built by +# concatenation so no single-quoted "${{" appears in the source (SC2016). +expr_open='$'"{{" + +shopt -s nullglob +for wf in "${repo}"/.github/workflows/*.yml "${repo}"/.github/workflows/*.yaml; do + rel="${wf#"${repo}"/}" + while IFS= read -r line; do + val="$(printf '%s' "${line}" | sed -E 's/.*node-version:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^["'"'"']//; s/["'"'"']$//')" + if [[ "${val}" == *"${expr_open}"* ]]; then + echo "::notice::${rel}: node-version is an expression (${val}); not checked" + continue + fi + major="$(_major "${val}")" + # A value that is neither a number nor a known floating alias is not + # something this checker can vouch for; say so rather than pass silently. + if [[ -z "${major}" && ! "${val}" =~ ^(lts/.*|node|latest|current|\*)$ ]]; then + echo "::notice::${rel}: node-version '${val}' is not a recognisable version or alias; not checked" + fi + _check "${major}" "${rel}: node-version: ${val}" + done < <(grep -E '^\s*node-version:' "${wf}" || true) + while IFS= read -r line; do + vf="$(printf '%s' "${line}" | sed -E 's/.*node-version-file:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^["'"'"']//; s/["'"'"']$//')" + if [[ -f "${repo}/${vf}" ]]; then + raw="$(_version_from_file "${repo}/${vf}")" + major="$(_major "${raw}")" + _check "${major}" "${rel}: node-version-file ${vf} -> ${raw}" + fi + done < <(grep -E '^\s*node-version-file:' "${wf}" || true) +done + +if (( errors > 0 )); then + echo "::error::${errors} Node pin(s) below floor ${floor}. Node 20 reached EOL 2026-04-30." + exit 1 +fi +echo "Node floor ${floor}: OK" diff --git a/standards/markdownlint.json b/standards/markdownlint.json new file mode 100644 index 0000000..8a1e9bf --- /dev/null +++ b/standards/markdownlint.json @@ -0,0 +1,13 @@ +{ + "default": true, + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD029": false, + "MD033": false, + "MD036": false, + "MD040": false, + "MD046": false, + "MD060": false +} diff --git a/standards/run-standards.sh b/standards/run-standards.sh new file mode 100755 index 0000000..72b5c56 --- /dev/null +++ b/standards/run-standards.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# run-standards.sh — the deterministic standards check. +# +# run-standards.sh [--repo DIR] [--config-dir DIR] [--skip a,b,c] [--node-floor N] +# +# Runs shellcheck, yamllint, actionlint, zizmor, markdownlint, and the +# Node-floor check over the tracked files of DIR (default: cwd). Exit 0 only +# when every enabled linter is clean. A linter with nothing to lint passes +# with a notice — absence of files is not a failure. +# +# Config precedence, per linter: a config at the repo root wins; otherwise +# the canonical file under --config-dir (github-workflows/standards/) is +# used. zizmor's canonical config is ../zizmor.yml relative to --config-dir. +# +# Same script runs in CI (standards-check.yml) and locally. +set -euo pipefail + +repo="$(pwd)" +config_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +skip="" +node_floor="22" +while (($# > 0)); do + case "$1" in + --repo) repo="$2"; shift 2 ;; + --config-dir) config_dir="$2"; shift 2 ;; + --skip) skip="$2"; shift 2 ;; + --node-floor) node_floor="$2"; shift 2 ;; + *) echo "::error::unknown argument: $1"; exit 2 ;; + esac +done +repo="$(cd "${repo}" && pwd)" +config_dir="$(cd "${config_dir}" && pwd)" + +# Fail loudly on a directory git cannot read. Every file-based linter +# enumerates through `_tracked || true`, so without this guard a non-repo (or +# a repo git refuses, e.g. dubious ownership) yields an empty file list and +# each linter reports a clean pass over nothing. +if ! git -C "${repo}" rev-parse --git-dir >/dev/null 2>&1; then + echo "::error::--repo ${repo} is not a git repository" + exit 2 +fi + +failures=0 +_skipped() { [[ ",${skip}," == *",$1,"* ]]; } +_header() { echo; echo "== $1"; } +_fail() { echo "::error::$1 found problems"; failures=$((failures + 1)); } +_tracked() { git -C "${repo}" ls-files -z --cached --others --exclude-standard; } + +# Shell lint pass over *.sh, *.bash, and files whose shebang is a +# bourne-family shell. (Do not start this comment with the linter's name +# followed by a colon: that parses as a shellcheck directive, SC1073.) +if _skipped shellcheck; then echo "== shellcheck: skipped by input"; else + _header shellcheck + files=() + while IFS= read -r -d '' f; do + case "${f}" in + *.sh|*.bash) files+=("${f}") ;; + *) + if [[ -f "${repo}/${f}" ]] && + head -c 64 "${repo}/${f}" 2>/dev/null | head -1 | grep -qE '^#!.*\b(ba)?sh\b'; then + files+=("${f}") + fi + ;; + esac + done < <(_tracked || true) + if ((${#files[@]} == 0)); then echo "::notice::no shell files"; else + (cd "${repo}" && shellcheck -S info "${files[@]}") || _fail shellcheck + fi +fi + +# yamllint: *.yml, *.yaml +if _skipped yamllint; then echo "== yamllint: skipped by input"; else + _header yamllint + files=() + while IFS= read -r -d '' f; do + case "${f}" in + *.yml|*.yaml) files+=("${f}") ;; + *) ;; + esac + done < <(_tracked || true) + if ((${#files[@]} == 0)); then echo "::notice::no YAML files"; else + cfg="" + for c in .yamllint .yamllint.yml .yamllint.yaml; do + if [[ -f "${repo}/${c}" ]]; then cfg="${repo}/${c}"; break; fi + done + [[ -n "${cfg}" ]] || cfg="${config_dir}/yamllint.yml" + (cd "${repo}" && yamllint -c "${cfg}" -f parsable "${files[@]}") || _fail yamllint + fi +fi + +# actionlint: .github/workflows only; it finds them itself. +if _skipped actionlint; then echo "== actionlint: skipped by input"; else + _header actionlint + if compgen -G "${repo}/.github/workflows/*.y*ml" >/dev/null; then + (cd "${repo}" && actionlint -shellcheck= -pyflakes=) || _fail actionlint + else echo "::notice::no workflows"; fi +fi + +# zizmor: .github/workflows/*.yml|*.yaml only (a tracked example workflow +# living elsewhere, e.g. docs/examples/.github/workflows/, is out of scope). +# Config: repo-root zizmor.yml, else the canonical policy one level above +# config-dir. +if _skipped zizmor; then echo "== zizmor: skipped by input"; else + _header zizmor + files=() + while IFS= read -r -d '' f; do + case "${f}" in + .github/workflows/*.yml|.github/workflows/*.yaml) files+=("${f}") ;; + *) ;; + esac + done < <(_tracked || true) + if ((${#files[@]} == 0)); then echo "::notice::no workflows"; else + cfg="${repo}/zizmor.yml"; [[ -f "${cfg}" ]] || cfg="${config_dir}/../zizmor.yml" + (cd "${repo}" && zizmor --config "${cfg}" --min-severity low --no-online-audits "${files[@]}") || _fail zizmor + fi +fi + +# markdownlint: *.md via markdownlint-cli2; repo config wins, else canonical. +if _skipped markdownlint; then echo "== markdownlint: skipped by input"; else + _header markdownlint + files=() + while IFS= read -r -d '' f; do + case "${f}" in + *.md) files+=("${f}") ;; + *) ;; + esac + done < <(_tracked || true) + if ((${#files[@]} == 0)); then echo "::notice::no Markdown files"; else + cfg="" + for c in .markdownlint-cli2.jsonc .markdownlint-cli2.yaml .markdownlint-cli2.cjs .markdownlint.jsonc .markdownlint.json .markdownlint.yaml .markdownlint.yml .markdownlintrc; do + if [[ -f "${repo}/${c}" ]]; then cfg="${repo}/${c}"; break; fi + done + [[ -n "${cfg}" ]] || cfg="${config_dir}/markdownlint.json" + (cd "${repo}" && markdownlint-cli2 --config "${cfg}" "${files[@]}") || _fail markdownlint + fi +fi + +# node-floor +if _skipped node-floor; then echo "== node-floor: skipped by input"; else + _header node-floor + bash "${config_dir}/check-node-floor.sh" "${repo}" "${node_floor}" || _fail node-floor +fi + +echo +if ((failures > 0)); then + echo "::error::standards-check: ${failures} linter(s) failed" + exit 1 +fi +echo "standards-check: all enabled linters clean" diff --git a/standards/yamllint.yml b/standards/yamllint.yml new file mode 100644 index 0000000..17e1d86 --- /dev/null +++ b/standards/yamllint.yml @@ -0,0 +1,8 @@ +--- +# Canonical yamllint config for standards-check.yml. Mirrors +# ~/.config/yamllint/config in dotfiles; keep them in sync. +extends: relaxed + +rules: + line-length: + max: 120 diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..6762314 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Runs every tests/test-*.sh; exits nonzero if any fails. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +failed=0 +for t in "${here}"/test-*.sh; do + echo "== ${t##*/}" + if bash "${t}"; then + echo "PASS ${t##*/}" + else + echo "FAIL ${t##*/}" + failed=$((failed + 1)) + fi +done +echo "${failed} test file(s) failed" +[[ "${failed}" -eq 0 ]] diff --git a/tests/test-check-node-floor.sh b/tests/test-check-node-floor.sh new file mode 100755 index 0000000..b07811e --- /dev/null +++ b/tests/test-check-node-floor.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Known-bad validation for standards/check-node-floor.sh. Each fixture is a +# throwaway directory; the control case proves the checker can fail. +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +checker="${here}/../standards/check-node-floor.sh" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT +pass=0; fail=0 +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } + +# Fixture 1: workflow pins node 20 -> must fail +mkdir -p "${tmp}/f1/.github/workflows" +cat >"${tmp}/f1/.github/workflows/ci.yml" <<'EOF' +jobs: + build: + steps: + - uses: actions/setup-node@abc + with: + node-version: 20 +EOF +if bash "${checker}" "${tmp}/f1" 22 >/dev/null 2>&1; then _bad "workflow node-version 20 accepted"; else _ok "workflow node-version 20 rejected"; fi + +# Fixture 2: .nvmrc 18.20.4 -> must fail +mkdir -p "${tmp}/f2"; echo "18.20.4" >"${tmp}/f2/.nvmrc" +if bash "${checker}" "${tmp}/f2" 22 >/dev/null 2>&1; then _bad ".nvmrc 18 accepted"; else _ok ".nvmrc 18 rejected"; fi + +# Fixture 3: package.json engines floor admits 14 -> must fail +mkdir -p "${tmp}/f3"; echo '{"engines":{"node":">=14.0.0"}}' >"${tmp}/f3/package.json" +if bash "${checker}" "${tmp}/f3" 22 >/dev/null 2>&1; then _bad "engines >=14 accepted"; else _ok "engines >=14 rejected"; fi + +# Fixture 4: everything at or above the floor -> must pass +mkdir -p "${tmp}/f4/.github/workflows" +printf 'jobs:\n b:\n steps:\n - with:\n node-version: "24"\n' >"${tmp}/f4/.github/workflows/ci.yml" +echo "lts/krypton" >"${tmp}/f4/.nvmrc" +echo '{"engines":{"node":">=22"}}' >"${tmp}/f4/package.json" +if bash "${checker}" "${tmp}/f4" 22 >/dev/null 2>&1; then _ok "conformant repo passes"; else _bad "conformant repo rejected"; fi + +# Fixture 5: no Node anywhere -> must pass +mkdir -p "${tmp}/f5"; echo "hi" >"${tmp}/f5/README.md" +if bash "${checker}" "${tmp}/f5" 22 >/dev/null 2>&1; then _ok "repo without Node passes"; else _bad "repo without Node rejected"; fi + +# Fixture 6: node-version-file indirection to a bad .nvmrc -> must fail +mkdir -p "${tmp}/f6/.github/workflows"; echo "20.19.4" >"${tmp}/f6/.nvmrc" +printf 'jobs:\n b:\n steps:\n - with:\n node-version-file: .nvmrc\n' >"${tmp}/f6/.github/workflows/ci.yml" +if bash "${checker}" "${tmp}/f6" 22 >/dev/null 2>&1; then _bad "node-version-file -> 20 accepted"; else _ok "node-version-file -> 20 rejected"; fi + +# Fixture 7: a real YAML inline comment (space before #) must not hide the pin +mkdir -p "${tmp}/f7/.github/workflows" +printf 'jobs:\n b:\n steps:\n - with:\n node-version: 20 # legacy\n' >"${tmp}/f7/.github/workflows/ci.yml" +if bash "${checker}" "${tmp}/f7" 22 >/dev/null 2>&1; then _bad "node-version 20 with trailing comment accepted"; else _ok "node-version 20 with trailing comment rejected"; fi + +# Fixture 8: "20#c" is the YAML string "20#c", not version 20 (no space before +# the #, so YAML starts no comment). It must not be misread as a Node 20 pin. +mkdir -p "${tmp}/f8/.github/workflows" +printf 'jobs:\n b:\n steps:\n - with:\n node-version: 20#c\n' >"${tmp}/f8/.github/workflows/ci.yml" +if bash "${checker}" "${tmp}/f8" 22 >/dev/null 2>&1; then _ok "unparseable '20#c' not misread as Node 20"; else _bad "'20#c' misread as a Node 20 pin"; fi + +# Fixture 9: package.json present but jq unavailable must fail loudly (exit 2), +# never skip the engines.node source in silence. +mkdir -p "${tmp}/f9/bin" "${tmp}/f9/repo" +echo '{"engines":{"node":">=14"}}' >"${tmp}/f9/repo/package.json" +for c in grep head sed printf bash cat tr; do + p="$(command -v "${c}" || true)" + [[ -n "${p}" ]] && ln -sf "${p}" "${tmp}/f9/bin/${c}" +done +if env PATH="${tmp}/f9/bin" bash "${checker}" "${tmp}/f9/repo" 22 >/dev/null 2>&1; then + _bad "missing jq silently skipped engines.node" +else + _ok "missing jq fails loudly instead of skipping engines.node" +fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]] diff --git a/tests/test-run-standards.sh b/tests/test-run-standards.sh new file mode 100755 index 0000000..a6a8a3e --- /dev/null +++ b/tests/test-run-standards.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Known-bad validation for standards/run-standards.sh: one fixture per +# linter that MUST fail, one clean fixture that MUST pass, and a --skip case +# proving the toggle really disables a linter. Requires the five tools on +# PATH (brew install shellcheck yamllint actionlint zizmor markdownlint-cli2). +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +runner="${here}/../standards/run-standards.sh" +cfg="${here}/../standards" +for tool in shellcheck yamllint actionlint zizmor markdownlint-cli2 jq; do + command -v "${tool}" >/dev/null || { echo "SKIP: ${tool} not on PATH"; exit 0; } +done +tmp="$(mktemp -d)" +# Hermetic fixtures: the user's global init.templateDir scaffolds +# .editorconfig/.gitignore/.claude into every `git init`, which leaks real +# files into fixtures that are supposed to be empty. An explicit empty +# --template overrides it, so a bare fixture really is bare. +tmpl="$(mktemp -d)" +trap 'rm -rf "${tmp}" "${tmpl}"' EXIT +pass=0; fail=0 +_ok() { echo " ok $1"; pass=$((pass + 1)); } +_bad() { echo " FAIL $1"; fail=$((fail + 1)); } +_mk() { mkdir -p "${tmp}/$1"; git -C "${tmp}/$1" init -q --template="${tmpl}"; } +_expect_fail() { # name dir + if bash "${runner}" --repo "${tmp}/$2" --config-dir "${cfg}" >"${tmp}/$2.log" 2>&1; then _bad "$1 (accepted; see ${tmp}/$2.log)"; else _ok "$1"; fi +} +_expect_pass() { + if bash "${runner}" --repo "${tmp}/$2" --config-dir "${cfg}" >"${tmp}/$2.log" 2>&1; then _ok "$1"; else _bad "$1 (rejected; see ${tmp}/$2.log)"; cat "${tmp}/$2.log"; fi +} + +# Fixture literals that must contain "$1" / "${{ }}" verbatim. They are +# assembled from a lone "$" plus the rest so the source carries no +# single-quoted expression for shellcheck to warn about (SC2016). +d='$' + +_mk bad-sh; printf '#!/usr/bin/env bash\necho %s1\n' "${d}" >"${tmp}/bad-sh/x.sh"; git -C "${tmp}/bad-sh" add -A +_expect_fail "shellcheck: unquoted \$1 (SC2086) rejected" bad-sh + +_mk bad-yaml; printf 'a: 1\n b: 2\n' >"${tmp}/bad-yaml/x.yml"; git -C "${tmp}/bad-yaml" add -A +_expect_fail "yamllint: bad indentation rejected" bad-yaml + +_mk bad-action; mkdir -p "${tmp}/bad-action/.github/workflows" +printf 'on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n uses: actions/checkout@v7\n' >"${tmp}/bad-action/.github/workflows/ci.yml" +git -C "${tmp}/bad-action" add -A +_expect_fail "actionlint: run+uses in one step rejected" bad-action + +_mk bad-zizmor; mkdir -p "${tmp}/bad-zizmor/.github/workflows" +printf 'on: pull_request_target\npermissions: write-all\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n with:\n ref: %s{{ github.event.pull_request.head.ref }}\n' "${d}" >"${tmp}/bad-zizmor/.github/workflows/ci.yml" +git -C "${tmp}/bad-zizmor" add -A +_expect_fail "zizmor: pwn-request / unpinned third-party rejected" bad-zizmor + +_mk bad-md; printf '#Bad heading\n\n\n\nx\n' >"${tmp}/bad-md/README.md"; git -C "${tmp}/bad-md" add -A +_expect_fail "markdownlint: MD018/MD012 rejected" bad-md + +# A tracked example workflow that lives outside .github/workflows (e.g. docs +# that show a caller stub) must not make zizmor scan it: only the repo's real +# workflows are in scope. Only .github/workflows/ci.yml is a real workflow +# here, and it is clean, so this must PASS even though the tracked example +# file would fail zizmor if scanned. +_mk zizmor-scoped; mkdir -p "${tmp}/zizmor-scoped/.github/workflows" "${tmp}/zizmor-scoped/docs/examples/.github/workflows" +printf 'on: pull_request_target\npermissions: write-all\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n with:\n ref: %s{{ github.event.pull_request.head.ref }}\n' "${d}" >"${tmp}/zizmor-scoped/docs/examples/.github/workflows/bad.yml" +printf 'on: push\npermissions:\n contents: read\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n' >"${tmp}/zizmor-scoped/.github/workflows/ci.yml" +git -C "${tmp}/zizmor-scoped" add -A +_expect_pass "zizmor: tracked example workflow outside .github/workflows is out of scope" zizmor-scoped + +_mk bad-node; echo "20" >"${tmp}/bad-node/.nvmrc"; git -C "${tmp}/bad-node" add -A +_expect_fail "node-floor: .nvmrc 20 rejected" bad-node + +_mk clean; mkdir -p "${tmp}/clean/.github/workflows" +printf '#!/usr/bin/env bash\nset -euo pipefail\necho "%s{1:-}"\n' "${d}" >"${tmp}/clean/ok.sh" +printf '# Title\n\nBody.\n' >"${tmp}/clean/README.md" +printf 'key: value\n' >"${tmp}/clean/x.yml" +printf 'on: push\npermissions:\n contents: read\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n' >"${tmp}/clean/.github/workflows/ci.yml" +echo "lts/krypton" >"${tmp}/clean/.nvmrc" +git -C "${tmp}/clean" add -A +_expect_pass "clean repo passes every linter" clean + +if bash "${runner}" --repo "${tmp}/bad-sh" --config-dir "${cfg}" --skip shellcheck >/dev/null 2>&1; then _ok "--skip shellcheck disables the linter"; else _bad "--skip shellcheck did not disable it"; fi + +_mk empty; git -C "${tmp}/empty" add -A +_expect_pass "empty repo passes (nothing to lint is not a failure)" empty + +# A --repo that is not a git repository must fail loudly with exit 2, not +# report a clean pass. Every file-based linter enumerates via `git ls-files +# || true`, so without the guard an unreadable directory lints as empty and +# the check goes green over nothing. +mkdir -p "${tmp}/not-a-repo" +set +e +bash "${runner}" --repo "${tmp}/not-a-repo" --config-dir "${cfg}" >"${tmp}/not-a-repo.log" 2>&1 +rc=$? +set -e +if [[ "${rc}" -eq 2 ]]; then _ok "non-repo --repo exits 2"; else _bad "non-repo --repo exited ${rc}, expected 2 (see ${tmp}/not-a-repo.log)"; fi + +echo "${pass} passed, ${fail} failed" +[[ "${fail}" -eq 0 ]] diff --git a/zizmor.yml b/zizmor.yml index 9ae4f64..0bcb326 100644 --- a/zizmor.yml +++ b/zizmor.yml @@ -93,3 +93,38 @@ rules: - claude-blocking-review.yml # caller for claude-blocking-review.yml - claude.yml # caller for claude-assistant.yml - dependabot-auto-merge.yml # caller for dependabot-auto-merge.yml + - standards-check.yml # caller for standards-check.yml + + # A cooldown deliberately DELAYS applying action updates. This repo was + # burned by exactly that delay: claude-code-action sat at v1.0.70 for 123 + # releases and stayed vulnerable to GHSA-8q5r-mmjf-575q with no PR opened + # (see #123 and the note at the top of .github/dependabot.yml). Adding a + # cooldown here would re-introduce the lag that incident was about, so the + # absence of one is a decision, not an oversight. + # + # This file also serves as the fleet-wide CI fallback policy: + # standards-check.yml uses it for any consuming repo that has no zizmor.yml + # of its own, so this ignore applies fleet-wide by design, not only here. + dependabot-cooldown: + ignore: + - dependabot.yml + + # zizmor advises `$/...` for a same-repo reusable workflow call. GitHub and + # actionlint both reject that form — actionlint reports "not following the + # format owner/repo/path@ref nor ./path/to/workflow.yml" — so `./...` is the + # only syntax that actually runs. Verified against zizmor 1.30 / actionlint + # on 2026-09-08. + self-repository: + ignore: + - self-review.yml + - self-standards-check.yml + + # markdownlint-cli2 and yamllint have no lockfile to install from — they are + # single pinned CLI tools, not project dependencies. standards-check.yml + # pins an exact version of each (MARKDOWNLINT_CLI2_VERSION, + # YAMLLINT_VERSION), and the three binary downloads next to them are + # additionally SHA256-verified. A lockfile would add a second place for the + # version to drift without removing the install. + adhoc-packages: + ignore: + - standards-check.yml