diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 405f8a0..192cd8f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -14,9 +14,10 @@ bats tests/extract-deps.bats # run one test file bats tests/extract-deps.bats --filter "name" # run tests whose name matches a substring ./scripts/check-inline-sync.sh # verify embedded inline bash == scripts/*.sh ./scripts/lint-workflow-call.sh # verify workflow_call files use no caller-context refs +./scripts/lint-workflows.sh # actionlint structural lint (non-hanging mode) ``` -All three checks above run in `ci-scripts.yml` on every PR touching `scripts/`, `tests/`, or `.github/workflows/`. Tests are [bats](https://github.com/bats-core/bats-core); fixtures live under `tests/fixtures//`. +The bats, inline-sync, and workflow-call checks run in `ci-scripts.yml` on every PR touching `scripts/`, `tests/`, or `.github/workflows/`. `lint-workflows.sh` is **local-only**: plain `actionlint` hangs on `dependency-safety.yml` (its large inlined `Scan and report` block × actionlint's ShellCheck orchestration), so the wrapper runs `actionlint -shellcheck= -pyflakes=`. Its bats contract test runs in CI, but actionlint itself is not installed there. ShellCheck is a **separate, optional** signal (`shellcheck scripts/*.sh`) with known info-level findings — not part of this gate. Tests are [bats](https://github.com/bats-core/bats-core); fixtures live under `tests/fixtures//`. ## The inline-sync invariant (most important architectural constraint) diff --git a/README.md b/README.md index 3ea3ff5..6a3d3b2 100644 --- a/README.md +++ b/README.md @@ -370,6 +370,33 @@ Before opening a PR that adds or modifies a `workflow_call` file: 2. **The lint rule enforces this in CI** — `scripts/lint-workflow-call.sh` runs as the `lint-workflow-call` job in `ci-scripts.yml` and will fail your PR if it detects a forbidden pattern 3. **Cross-repo smoke testing is planned** ([#30](https://github.com/j7an/shared-workflows/issues/30)) — a companion repo will exercise reusable workflows from a genuinely external caller context to catch bugs that the self-consumption harness cannot detect +## Validating workflow changes + +Run these locally before opening a PR that touches `.github/workflows/` or `scripts/`: + +```bash +./scripts/lint-workflows.sh # workflow/YAML structure (actionlint, non-hanging mode) +bats tests/ # script / runtime behavior +./scripts/check-inline-sync.sh # inline copies match scripts/*.sh +./scripts/lint-workflow-call.sh # no caller-context refs in workflow_call files +``` + +Optional, advisory shell analysis: + +```bash +shellcheck scripts/*.sh # completes, but has known info-level findings; not a gate +``` + +**Why `lint-workflows.sh` instead of plain `actionlint`?** Default `actionlint` +(with its ShellCheck integration enabled) **hangs** on +`.github/workflows/dependency-safety.yml`: that file carries a large inlined +`Scan and report` Bash block (required by the [inline-sync architecture](#known-caller-side-constraints)), +which interacts badly with actionlint's ShellCheck orchestration. The hang is a +tool limitation, **not** a workflow syntax error, and it is pre-existing on +`main`. The wrapper disables that integration (`actionlint -shellcheck= +-pyflakes=`) so structural linting completes deterministically. ShellCheck still +runs as a **separate, optional** signal against the source scripts. + ## Release Bot App setup `tag-release.yml` needs a non-`GITHUB_TOKEN` identity to push new tags, otherwise GitHub's recursion guard silently suppresses the downstream `release.yml` run. We use a GitHub App for this. diff --git a/scripts/lint-workflows.sh b/scripts/lint-workflows.sh new file mode 100755 index 0000000..bf6de17 --- /dev/null +++ b/scripts/lint-workflows.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# lint-workflows.sh — structural workflow linting in the known non-hanging mode. +# +# Default `actionlint` (ShellCheck integration on) HANGS on +# .github/workflows/dependency-safety.yml: its large inlined `Scan and report` +# block interacts badly with actionlint's ShellCheck orchestration. This is a +# tool limitation, not a workflow syntax error, and is pre-existing on main +# (see issue #81). Disabling the ShellCheck and Pyflakes integrations makes +# structural linting complete deterministically: +# +# actionlint -shellcheck= -pyflakes= .github/workflows/*.yml +# +# ShellCheck is a SEPARATE, optional signal — run `shellcheck scripts/*.sh` +# when useful (it has known info-level findings; not this command's concern). +# +# Usage: ./scripts/lint-workflows.sh [workflow-file ...] +# No args -> lints .github/workflows/*.yml +# Args -> workflow FILE PATHS only (e.g. lint a single file) +# The -shellcheck= / -pyflakes= flags are fixed and cannot be overridden; +# any flag-like argument is rejected so this stays the reliable command. +# +# Exit: actionlint's exit code (0 = clean, non-zero = findings); +# 2 if a flag-like argument is passed; +# 127 if actionlint is not installed. + +set -euo pipefail + +# Reject flag-like arguments: callers may pass workflow file paths only. +for arg in "$@"; do + case "$arg" in + -*) + echo "lint-workflows.sh: arguments must be workflow file paths, not flags: '$arg'" >&2 + echo "The -shellcheck= and -pyflakes= flags are fixed and cannot be overridden." >&2 + exit 2 + ;; + esac +done + +if ! command -v actionlint >/dev/null 2>&1; then + echo "lint-workflows.sh: actionlint not found on PATH." >&2 + echo "Install it (e.g. 'brew install actionlint') or see https://github.com/rhysd/actionlint" >&2 + exit 127 +fi + +if [ "$#" -gt 0 ]; then + exec actionlint -shellcheck= -pyflakes= "$@" +else + exec actionlint -shellcheck= -pyflakes= .github/workflows/*.yml +fi diff --git a/tests/lint-workflows.bats b/tests/lint-workflows.bats new file mode 100644 index 0000000..c7cf2bf --- /dev/null +++ b/tests/lint-workflows.bats @@ -0,0 +1,79 @@ +#!/usr/bin/env bats + +# lint-workflows.bats — tests for scripts/lint-workflows.sh +# +# The wrapper runs actionlint in the known non-hanging mode +# (-shellcheck= -pyflakes=). Tests stub `actionlint` on PATH so no real +# actionlint is needed and there is zero hang risk; the stub records the +# arguments it was invoked with. + +setup() { + LINT="$BATS_TEST_DIRNAME/../scripts/lint-workflows.sh" + REPO_ROOT="$BATS_TEST_DIRNAME/.." + STUBDIR=$(mktemp -d) + ARGS_FILE="$STUBDIR/args" +} + +teardown() { + rm -rf "$STUBDIR" +} + +# Write a fake `actionlint` that records its args (one per line) and exits $1. +make_stub() { + local exit_code="$1" + cat > "$STUBDIR/actionlint" < "$ARGS_FILE" +exit $exit_code +EOF + chmod +x "$STUBDIR/actionlint" +} + +@test "invokes actionlint with shellcheck and pyflakes integrations disabled" { + make_stub 0 + cd "$REPO_ROOT" + run env PATH="$STUBDIR:$PATH" "$LINT" + [ "$status" -eq 0 ] + grep -qx -- '-shellcheck=' "$ARGS_FILE" + grep -qx -- '-pyflakes=' "$ARGS_FILE" + grep -q '\.github/workflows/' "$ARGS_FILE" +} + +@test "propagates actionlint success exit 0" { + make_stub 0 + cd "$REPO_ROOT" + run env PATH="$STUBDIR:$PATH" "$LINT" + [ "$status" -eq 0 ] +} + +@test "propagates actionlint failure exit" { + make_stub 1 + cd "$REPO_ROOT" + run env PATH="$STUBDIR:$PATH" "$LINT" + [ "$status" -eq 1 ] +} + +@test "passes through an explicit workflow file path" { + make_stub 0 + cd "$REPO_ROOT" + run env PATH="$STUBDIR:$PATH" "$LINT" .github/workflows/security.yml + [ "$status" -eq 0 ] + grep -qx -- '-shellcheck=' "$ARGS_FILE" + grep -qx -- '.github/workflows/security.yml' "$ARGS_FILE" +} + +@test "rejects flag-like positional arguments (fixed flags not overrideable)" { + make_stub 0 + cd "$REPO_ROOT" + run env PATH="$STUBDIR:$PATH" "$LINT" -shellcheck= + [ "$status" -eq 2 ] + [[ "$output" =~ "file paths" ]] + [ ! -f "$ARGS_FILE" ] +} + +@test "fails clearly when actionlint is not installed" { + cd "$REPO_ROOT" + run env PATH="/usr/bin:/bin" "$LINT" + [ "$status" -eq 127 ] + [[ "$output" =~ actionlint ]] +}