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
3 changes: 2 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<script-name>/`.
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/<script-name>/`.

## The inline-sync invariant (most important architectural constraint)

Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
49 changes: 49 additions & 0 deletions scripts/lint-workflows.sh
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions tests/lint-workflows.bats
Original file line number Diff line number Diff line change
@@ -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" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$@" > "$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 ]]
}