diff --git a/.github/scripts/workflow-refs.sh b/.github/scripts/workflow-refs.sh new file mode 100755 index 0000000..8871dcd --- /dev/null +++ b/.github/scripts/workflow-refs.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Checks that everything the workflows name actually exists: composite actions, +# make targets, and the scripts a run: block invokes. +# +# This is the class actionlint does not cover. `uses: ./.github/actions/typo` +# lints clean and fails only when the job runs, and these workflows are +# dispatch-only or push-triggered, so that first run is after merge. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +ROOT="$root" python3 - <<'PY' +import glob +import json +import os +import re +import sys + +root = os.environ["ROOT"] +problems = [] +checked = 0 + + +def report(ok, label, detail=""): + global checked + checked += 1 + if not ok: + problems.append("%s%s" % (label, detail)) + print(" %-4s %s%s" % ("ok" if ok else "MISS", label, detail)) + + +def workflow_files(): + return (sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml"))) + + sorted(glob.glob(os.path.join(root, ".github/actions/*/action.yml")))) + + +def rel(path): + return os.path.relpath(path, root) + + +# --- composite actions ------------------------------------------------------- +print("local action references:") +local_refs = 0 +for path in workflow_files(): + # Comments are not references. They mention paths as examples, and a version + # comment trails the `uses:` line of every pinned action. + body = re.sub(r"#[^\n]*", "", open(path).read()) + found = re.findall(r"^\s*-?\s*uses:\s*(\./\S+)\s*$", body, re.M) + local_refs += len(found) + for ref in found: + target = os.path.join(root, ref[2:], "action.yml") + report(os.path.isfile(target), ref, " (from %s)" % rel(path)) + # A checker that silently matches nothing reports a safety it never looked + # for. If the file names a local action in a form the pattern above does not + # read, that is a broken checker, not a clean file. + mentions = len(re.findall(r"\./\.github/actions/", body)) + if mentions > len(found): + sys.exit("workflow-refs: %s mentions ./.github/actions/ %d time(s) but this " + "check only parsed %d `uses:` reference(s) out of it, so it is not " + "reading the file it claims to read" % (rel(path), mentions, len(found))) + +if local_refs == 0: + sys.exit("workflow-refs: found no `uses: ./...` at all, so this check is not " + "reading the workflows it claims to read") + +# --- make targets ------------------------------------------------------------ +print("\nmake targets named by a run: step:") +makefile = open(os.path.join(root, "Makefile")).read() +targets = set(re.findall(r"^([A-Za-z0-9_.-]+):", makefile, re.M)) +wanted = set() +for path in sorted(glob.glob(os.path.join(root, ".github/workflows/*.yml"))): + body = open(path).read() + # Comments are not run, and prose in them says things like "make the run + # always open the same way", which is not a target. + commands = re.sub(r"#[^\n]*", "", body) + for name in re.findall(r"\bmake\s+([a-z][a-z0-9-]*)\b", commands): + wanted.add(name) + # `make "sanderling-$SANDERLING"` is resolved from the matrix that feeds it + if "sanderling-$SANDERLING" in body: + table = re.search(r"examples='(\[.*?\])'", body, re.S) + if table is None: + sys.exit("workflow-refs: %s builds a make target from $SANDERLING but its " + "examples table could not be read" % rel(path)) + for entry in json.loads(table.group(1)): + wanted.add("sanderling-%s" % entry["sanderling"]) +for name in sorted(wanted): + report(name in targets, "make %s" % name) + +# --- scripts a run: step invokes --------------------------------------------- +print("\nscripts a run: step invokes:") +scripts = set() +for path in workflow_files(): + scripts |= set(re.findall(r"\.github/scripts/[A-Za-z0-9_.-]+\.sh", open(path).read())) +for name in sorted(scripts): + full = os.path.join(root, name) + report(os.path.isfile(full), name) + if os.path.isfile(full): + report(os.access(full, os.X_OK), "%s is executable" % name) + +print("\n%d references checked" % checked) +if problems: + sys.exit("workflow-refs: unresolved: %s" % ", ".join(problems)) +print("workflow-refs: ok") +PY