From 0429b9cce032726e30dc4f97c209683f9e27cd11 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 6 Jun 2026 09:28:36 -0700 Subject: [PATCH 1/3] chore: add lint-workflows.sh actionlint wrapper (non-hanging mode) Default actionlint hangs on dependency-safety.yml's large inlined block; the wrapper runs 'actionlint -shellcheck= -pyflakes=' so structural linting completes deterministically. Stub-based bats test proves the non-hanging mode is invoked. Refs #81 --- scripts/lint-workflows.sh | 49 ++++++++++++++++++++++++ tests/lint-workflows.bats | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100755 scripts/lint-workflows.sh create mode 100644 tests/lint-workflows.bats 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 ]] +} From 61a6f52ad9759b167fc1425e2ed94f498ffa6dca Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 6 Jun 2026 09:33:22 -0700 Subject: [PATCH 2/3] docs: document the workflow validation stack and lint-workflows.sh Adds a 'Validating workflow changes' section listing the local checks and explaining why default actionlint hangs on dependency-safety.yml. Refs #81 --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) 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. From 9bb07d983911cb6e452c5722e60aa6ffb644d9f9 Mon Sep 17 00:00:00 2001 From: j7an Date: Sat, 6 Jun 2026 09:36:47 -0700 Subject: [PATCH 3/3] docs: add lint-workflows.sh to CLAUDE.md commands and note the hang Documents the reliable workflow-lint command, why default actionlint hangs on the inlined reusable workflow, and that ShellCheck is a separate layer. Refs #81 --- .claude/CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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)