# Lint GitHub Actions workflow YAML against the github-actions-architect house
# rules: actions pinned to a full commit SHA (never a tag/branch), no bare
# long-lived AWS_*/GCP_* secret usage (OIDC is the house preference), and a
# note on whether branch-protection/required-checks are declared anywhere in
# the repo. Missing `actionlint` on PATH degrades that check to a skipped
# warning rather than a hard failure — same degrade pattern as
# helm/scripts/lint_chart.sh — so the house-rule checks (which need no
# external tools beyond python3) still run in a bare CI shell.
# lint_workflows.sh [workflows-dir]
# Defaults to .github/workflows. Example:
# scripts/lint_workflows.sh .github/workflows
DIR="${1:-.github/workflows}"
warn() { printf '⚠️ %s\n' "$1"; }
error() { printf '❌ %s\n' "$1"; fail=1; }
ok() { printf '✅ %s\n' "$1"; }
if [[ ! -d "$DIR" ]]; then
echo "Usage: $0 [workflows-dir]" >&2
echo "Directory not found: $DIR" >&2
mapfile -t FILES < <(find "$DIR" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort)
if [[ ${#FILES[@]} -eq 0 ]]; then
echo "Usage: $0 [workflows-dir]" >&2
echo "No .yml/.yaml files found in $DIR" >&2
if command -v actionlint >/dev/null 2>&1; then
if actionlint "${FILES[@]}"; then
error "actionlint reported issues"
warn "actionlint not on PATH — skipped (install: brew install actionlint / go install github.com/rhysd/actionlint/cmd/actionlint@latest)"
echo "-- falling back to a python3 structural check --"
if python3 -c "import yaml" >/dev/null 2>&1; then
for f in "${FILES[@]}"; do
if python3 - "$f" <<'PYEOF'
missing = [k for k in ("on", "jobs") if not isinstance(doc, dict) or k not in doc]
# YAML parses bare `on:` as boolean True key in some PyYAML versions — check both.
if missing and not isinstance(doc, dict):
if "on" not in doc and True not in doc:
missing = [m for m in dict.fromkeys(missing)]
print("missing top-level key(s): " + ", ".join(missing))
ok "PyYAML structural check: $f has on/jobs"
error "PyYAML structural check failed for $f — see missing key(s) above"
warn "PyYAML not importable — falling back to grep-based line check"
for f in "${FILES[@]}"; do
grep -qE '^(on|"on"|'"'"'on'"'"'):' "$f" || missing+=("on")
grep -qE '^jobs:' "$f" || missing+=("jobs")
if [[ ${#missing[@]} -eq 0 ]]; then
ok "grep structural check: $f has on/jobs"
error "grep structural check failed for $f — missing top-level key(s): ${missing[*]}"
echo "== house rule: actions pinned to a full commit SHA, not a tag/branch =="
for f in "${FILES[@]}"; do
hits=$(grep -noE 'uses:[[:space:]]*[^[:space:]#]+@[^[:space:]#]+' "$f" || true)
while IFS= read -r hit; do
[[ -z "$hit" ]] && continue
if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then
bad_pins="${bad_pins}${f}:${hit}"$'\n'
if [[ -n "$bad_pins" ]]; then
error "action(s) pinned to a tag/branch instead of a full 40-hex commit SHA:"
ok "all 'uses:' references pinned to a full commit SHA"
echo "== house rule: no bare long-lived AWS_*/GCP_* secret usage (flag only — OIDC is the house preference) =="
for f in "${FILES[@]}"; do
hits=$(grep -noE '(secrets\.|env\.)?\b(AWS|GCP|GOOGLE)_[A-Z0-9_]*(KEY|SECRET|TOKEN|CREDENTIALS)[A-Z0-9_]*\b' "$f" || true)
[[ -n "$hits" ]] && secret_hits="${secret_hits}${f}: ${hits}"$'\n'
if [[ -n "$secret_hits" ]]; then
warn "found what looks like a long-lived AWS_*/GCP_* credential env var — prefer OIDC federation instead of static keys:"
ok "no bare long-lived AWS_*/GCP_* secret patterns found"
echo "== house rule: required-checks / branch protection mentioned if a ruleset file is present =="
REPO_ROOT=$(cd "$DIR/../.." 2>/dev/null && pwd || echo "")
if [[ -n "$REPO_ROOT" ]]; then
[[ -d "$REPO_ROOT/.github/rulesets" ]] && ruleset_files+=("$REPO_ROOT"/.github/rulesets/*.json)
[[ -f "$REPO_ROOT/.github/settings.yml" ]] && ruleset_files+=("$REPO_ROOT/.github/settings.yml")
for rf in "${ruleset_files[@]:-}"; do
[[ -f "$rf" ]] || continue
if grep -qiE 'required_status_checks|branch_protection|required_checks' "$rf"; then
ok "ruleset file $rf mentions required-checks / branch protection"
error "ruleset file $rf exists but does not mention required_status_checks / branch_protection"
if [[ "$found_ruleset" -eq 0 ]]; then
ok "no .github/rulesets/*.json or .github/settings.yml found — nothing to check (informational, not a failure)"
if [[ "$fail" -eq 0 ]]; then
echo "All checks passed."
echo "One or more checks failed — see ❌ lines above."