mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
A reusable workflow is named by its file, not by a directory holding an action.yml, so every `uses: ./.github/workflows/*.yml` was reported missing. Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
147 lines
5.7 KiB
Bash
Executable File
147 lines
5.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Checks that everything the workflow names actually exists: composite actions,
|
|
# reusable workflows, make targets, and the scripts a run: block invokes. Then
|
|
# checks that no run: block interpolates a `${{ }}`.
|
|
#
|
|
# This is the class actionlint does not cover. `uses: ./.github/actions/typo`
|
|
# lints clean and fails only when the job runs, and the folio jobs and the
|
|
# release job never run on a pull request, so that first run is after merge.
|
|
set -euo pipefail
|
|
|
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
ROOT="$root" python3 - <<'PY'
|
|
import glob
|
|
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 and reusable workflows --------------------------------
|
|
print("local action and reusable workflow 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:
|
|
# A reusable workflow is named by its own file. A composite action is
|
|
# named by the directory holding it, and the file inside is action.yml.
|
|
target = os.path.join(root, ref[2:])
|
|
if not target.endswith((".yml", ".yaml")):
|
|
target = os.path.join(target, "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|workflows)/", body))
|
|
if mentions > len(found):
|
|
sys.exit("workflow-refs: %s mentions ./.github/actions/ or ./.github/workflows/ "
|
|
"%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)
|
|
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)
|
|
|
|
# --- expressions in a run: block ---------------------------------------------
|
|
# A `${{ }}` is substituted into the script text before bash reads the line, so
|
|
# an expression carrying text someone else wrote runs as a command. Values reach
|
|
# a run: block through env instead. actionlint flags only the contexts it knows
|
|
# are attacker-controlled, and a matrix value or a dispatch input is not on that
|
|
# list.
|
|
print("\nrun: blocks free of ${{ }}:")
|
|
|
|
|
|
def run_blocks(text):
|
|
lines = text.split("\n")
|
|
i = 0
|
|
while i < len(lines):
|
|
head = re.match(r"^(\s*(?:-\s+)?)run:(.*)$", lines[i])
|
|
if head is None:
|
|
i += 1
|
|
continue
|
|
column, rest = len(head.group(1)), head.group(2).strip()
|
|
start, body = i + 1, []
|
|
if rest in ("|", "|-", "|+", ">", ">-", ">+", ""):
|
|
i += 1
|
|
while i < len(lines) and (not lines[i].strip()
|
|
or len(lines[i]) - len(lines[i].lstrip()) > column):
|
|
body.append(lines[i])
|
|
i += 1
|
|
else:
|
|
body.append(rest)
|
|
i += 1
|
|
yield start, "\n".join(body)
|
|
|
|
|
|
blocks = 0
|
|
for path in workflow_files():
|
|
hits = []
|
|
for line, body in run_blocks(open(path).read()):
|
|
blocks += 1
|
|
hits += ["line %d: %s" % (line, hit) for hit in re.findall(r"\$\{\{.*?\}\}", body, re.S)]
|
|
report(not hits, rel(path), " (%s)" % ("; ".join(hits) if hits else "clean"))
|
|
|
|
# Same reason as the local action count above: a scanner that reads no run:
|
|
# block at all would pass every file it never looked at.
|
|
if blocks == 0:
|
|
sys.exit("workflow-refs: found no run: block at all, so this check is not "
|
|
"reading the workflows it claims to read")
|
|
|
|
print("\n%d references checked" % checked)
|
|
if problems:
|
|
sys.exit("workflow-refs: unresolved: %s" % ", ".join(problems))
|
|
print("workflow-refs: ok")
|
|
PY
|