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
34 changes: 34 additions & 0 deletions .github/actionlint.yaml
Original file line number Diff line number Diff line change
@@ -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'
14 changes: 10 additions & 4 deletions .github/workflows/claude-blocking-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/self-standards-check.yml
Original file line number Diff line number Diff line change
@@ -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
178 changes: 178 additions & 0 deletions .github/workflows/standards-check.yml
Original file line number Diff line number Diff line change
@@ -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}"
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <dir>` 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.
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>
```

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
Expand Down
Loading
Loading