Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ jobs:
[[ "$count" -gt 0 ]] || { printf 'markdown-link-files: no files checked\n' >&2; exit 1; }

- name: Lint GitHub issue templates
run: yamllint .github/ISSUE_TEMPLATE/*.yml .github/workflows/*.yml
run: bash scripts/lint-github-yaml.sh

frontend:
runs-on: ubuntu-latest
Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ installed by yamllint). Both `docs-and-hygiene` and `scripts/ci-local.sh` requir
this check, including a working GitHub API connection. Offline regression tests
run with `bash tests/release/action-pin-comments.test.sh`.

Workflow checks also discover nested `.github/actions/**/action.yml` and
`action.yaml` metadata. Composite `runs.steps[*].uses` references require the
same SHA pins and version comments as workflow steps. Local and CI YAML linting
use `bash scripts/lint-github-yaml.sh`; Node EOL checks include both setup-node
versions and local JavaScript actions' `runs.using` runtime. An absent actions
directory is allowed; an existing empty or unreadable directory fails discovery.
Run `python3 tests/test_github_yaml.py` for the cross-gate fixtures (requires
PyYAML and yamllint). The full `bash tests/release/release-rehearsal.test.sh`
gate invokes these fixtures too, so install both before running it locally:
`python3 -m pip install PyYAML==6.0.2 yamllint==1.38.0`.

```sh
git checkout -b feat/<short-name>
# … implement …
Expand Down
14 changes: 10 additions & 4 deletions scripts/action-pin-comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import yaml
from yaml.nodes import MappingNode, ScalarNode, SequenceNode
from github_yaml import discover


def entries(node, key):
Expand All @@ -20,6 +21,14 @@ def entries(node, key):


def references(document):
for runs in entries(document, "runs"):
if not isinstance(runs, MappingNode):
raise ValueError("runs must be a mapping")
for steps in entries(runs, "steps"):
if not isinstance(steps, SequenceNode):
raise ValueError("steps must be a sequence")
for step in steps.value:
yield from entries(step, "uses")
for jobs in entries(document, "jobs"):
if not isinstance(jobs, MappingNode):
raise ValueError("jobs must be a mapping")
Expand All @@ -33,10 +42,7 @@ def references(document):


def extract(root):
directory = root / ".github/workflows"
files = sorted(path for path in directory.iterdir() if path.suffix in (".yml", ".yaml"))
if not files:
raise ValueError("no workflow files")
files = discover(root / ".github/workflows", root / ".github/actions")
rows = []
for path in files:
try:
Expand Down
2 changes: 1 addition & 1 deletion scripts/ci-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ hygiene_markdown_link_check() (

hygiene_yamllint() (
cd "$repo_root" || exit 1
yamllint .github/ISSUE_TEMPLATE/*.yml .github/workflows/*.yml
bash scripts/lint-github-yaml.sh
)

# Same scan as e2e.yml's "ShellCheck maintained scripts" step -- kept here too
Expand Down
51 changes: 51 additions & 0 deletions scripts/github_yaml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Discover workflow and local action metadata for the GitHub YAML gates."""

import argparse
import os
from pathlib import Path
import sys


def discover(workflows, actions=None, templates=None):
files = sorted(p for p in workflows.iterdir() if p.suffix in (".yml", ".yaml"))
if not files:
raise ValueError(f"no workflow files matched under {workflows}")
if actions is not None and actions.exists():
found = []

def failed(error):
raise error

for directory, directories, names in os.walk(actions, onerror=failed):
for name in directories:
path = Path(directory) / name
if path.is_symlink():
raise ValueError(f"cannot scan symlinked action directory {path}")
found.extend(Path(directory) / name for name in names + directories
if name in ("action.yml", "action.yaml"))
if not found:
raise ValueError(f"no action metadata files matched under {actions}")
files.extend(sorted(found))
if templates is not None:
found = sorted(p for p in templates.iterdir() if p.suffix in (".yml", ".yaml"))
if not found:
raise ValueError(f"no issue templates matched under {templates}")
files.extend(found)
return files


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("workflows", type=Path)
parser.add_argument("actions", nargs="?", type=Path)
parser.add_argument("--templates", type=Path)
args = parser.parse_args()
try:
paths = discover(args.workflows, args.actions, args.templates)
# Check before emitting so an unreadable input cannot yield a partial list.
for path in paths:
path.read_text(encoding="utf-8")
sys.stdout.buffer.write(b"".join(os.fsencode(path) + b"\0" for path in paths))
except (OSError, UnicodeError, ValueError) as error:
sys.exit(f"FAIL: {error}")
10 changes: 10 additions & 0 deletions scripts/lint-github-yaml.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail
root="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
manifest="$(mktemp)"
trap 'rm -f "$manifest"' EXIT
python3 "$(dirname "${BASH_SOURCE[0]}")/github_yaml.py" \
"$root/.github/workflows" "$root/.github/actions" \
--templates "$root/.github/ISSUE_TEMPLATE" > "$manifest"
mapfile -d '' -t files < "$manifest"
yamllint "${files[@]}"
15 changes: 10 additions & 5 deletions tests/release/node-eol.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@
# Host-side only: no network, no VM.
set -euo pipefail

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
script_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
repo_root="${1:-$script_root}"
workflows="$repo_root/.github/workflows"

[ -d "$workflows" ] || { printf 'missing %s\n' "$workflows" >&2; exit 1; }
manifest="$(mktemp)"
trap 'rm -f "$manifest"' EXIT
python3 "$script_root/scripts/github_yaml.py" "$workflows" "$repo_root/.github/actions" > "$manifest"
mapfile -d '' -t files < "$manifest"

# major:end-of-life
NODE_SCHEDULE="18:2025-04-30 20:2026-04-30 22:2027-04-30 24:2028-04-30 26:2029-04-30"
Expand Down Expand Up @@ -49,7 +54,7 @@ while IFS= read -r hit; do
line="${rest%%:*}"
# `node-version: "20"`, `node-version: '20'` and `node-version: 20` all
# reach here; keep only the leading integer.
version="$(printf '%s' "${rest#*:}" | tr -d "\"' " | sed 's/^node-version://')"
version="$(printf '%s' "${rest#*:}" | tr -d "\"' " | sed -e 's/^node-version://' -e 's/^using:node//')"
major="${version%%.*}"
pins=$((pins + 1))

Expand All @@ -68,7 +73,7 @@ while IFS= read -r hit; do
if [ "$eol" \< "$today" ]; then
report "$(basename "$file"):$line pins Node $major, end-of-life since $eol"
fi
done < <(grep -rn '^[[:space:]]*node-version:' "$workflows" || true)
done < <(grep -nHE '^[[:space:]]*(node-version:|using:.*node[0-9])' "${files[@]}" || true)

# The extraction above recognises the block-style `node-version:` key only.
# Two other spellings pin a Node major without matching it, and neither would
Expand All @@ -81,10 +86,10 @@ while IFS= read -r hit; do
file="${hit%%:*}"
rest="${hit#*:}"
report "$(basename "$file"):${rest%%:*} pins Node through a form this check cannot read; use a literal \`node-version:\` line"
done < <(grep -rnE '^[[:space:]]*node-version-file:|\{[^}]*node-version[[:space:]]*:' "$workflows" || true)
done < <(grep -nHE '^[[:space:]]*node-version-file:|\{[^}]*node-version[[:space:]]*:|\{[^}]*using[[:space:]]*:.*node[0-9]' "${files[@]}" || true)

if [ "$pins" -eq 0 ]; then
printf 'no node-version pin found under .github/workflows; the extraction is broken, not the workflows\n' >&2
printf 'no Node pin found in workflows or actions; the extraction is broken, not the inputs\n' >&2
exit 1
fi

Expand Down
27 changes: 25 additions & 2 deletions tests/release/release-rehearsal.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ assert_action_pins() {
local -a workflows=("$workflows_dir"/*.yml "$workflows_dir"/*.yaml)
eval "$old_nullglob"

# An absent actions root is normal; an existing but empty/unreadable root
# must fail discovery. Keep the workflow-only fixture interface unchanged.
if [[ $# -ge 3 ]]; then
local manifest
manifest="$(mktemp)"
if ! python3 "$repo_root/scripts/github_yaml.py" "$workflows_dir" "$3" > "$manifest"; then
rm -f "$manifest"
return 1
fi
mapfile -d '' -t workflows < "$manifest"
rm -f "$manifest"
fi

((${#workflows[@]})) || {
printf 'FAIL: no workflow files matched under %s\n' "$workflows_dir" >&2
return 1
Expand Down Expand Up @@ -170,7 +183,15 @@ for path in paths:
fail(f"cannot parse {workflow}")

document = mapping(document, path)
jobs = mapping(document.get("jobs"), f"{path}: jobs")
if workflow.name in ("action.yml", "action.yaml") and "runs" in document:
runs = mapping(document["runs"], f"{path}: runs")
if runs.get("using") != "composite":
continue # JavaScript/Docker action metadata has no nested uses.
if "steps" not in runs:
fail(f"{path}: composite action is missing steps")
jobs = {"composite": runs}
else:
jobs = mapping(document.get("jobs"), f"{path}: jobs")
for name, job in jobs.items():
location = f"{path}: job {name}"
job = mapping(job, location)
Expand All @@ -192,7 +213,7 @@ print(f"Checked {uses_count} uses: entries.")
PYTHON
}

assert_action_pins "${repo_root}/.github/workflows" 20
assert_action_pins "${repo_root}/.github/workflows" 20 "${repo_root}/.github/actions"

# Exercise the shipped checker against both YAML spellings and both locations
# GitHub accepts: step actions and job-level reusable workflows.
Expand Down Expand Up @@ -321,4 +342,6 @@ if grep -Eiq '(^|[[:space:]])(cargo|npm)[[:space:]]+publish|gh[[:space:]]+releas
exit 1
fi

python3 "$repo_root/tests/test_github_yaml.py"

printf 'Release rehearsal contract passed.\n'
128 changes: 128 additions & 0 deletions tests/test_github_yaml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Exercise the shipped gates with local action metadata in a fixture tree."""

from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from github_yaml import discover # noqa: E402

SHA = "3d3c42e5aac5ba805825da76410c181273ba90b1"


class ActionMetadataTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.workflows = self.root / ".github/workflows"
self.actions = self.root / ".github/actions"
self.workflows.mkdir(parents=True)
self.templates = self.root / ".github/ISSUE_TEMPLATE"
self.templates.mkdir()
(self.templates / "bug.yml").write_text(
"---\nname: Bug report\ndescription: Report a bug\nbody: []\n", encoding="utf-8")
(self.workflows / "ci.yml").write_text(
"---\njobs:\n build:\n steps:\n"
f" - uses: actions/checkout@{SHA} # v7.0.1\n"
' with:\n node-version: "24"\n', encoding="utf-8")

def action(self, text, suffix="yml"):
path = self.actions / "nested/setup" / f"action.{suffix}"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
return path

def run_command(self, command, expected=None):
result = subprocess.run(command, capture_output=True, text=True)
output = result.stdout + result.stderr
if expected is None:
self.assertEqual(result.returncode, 0, output)
else:
self.assertNotEqual(result.returncode, 0, output)
self.assertIn(expected, output)
return output

def pins(self, expected=None):
source = (ROOT / "tests/release/release-rehearsal.test.sh").read_text()
function = source.split("assert_action_pins() {", 1)[1].split(
'\nassert_action_pins "${repo_root}', 1)[0]
script = 'repo_root="$1"\nassert_action_pins() {' + function
script += '\nassert_action_pins "$2" 1 "$3"\n'
return self.run_command(["bash", "-c", script, "fixture", str(ROOT),
str(self.workflows), str(self.actions)], expected)

def test_absent_actions_and_pinned_nested_yaml(self):
self.assertEqual(len(discover(self.workflows, self.actions)), 1)
self.pins()
for suffix in ("yml", "yaml"):
path = self.action("---\nname: setup\nruns:\n using: composite\n steps:\n"
f" - uses: actions/checkout@{SHA} # v7.0.1\n", suffix)
self.assertIn(path, discover(self.workflows, self.actions))
self.assertIn("Checked 3 uses:", self.pins())
output = self.run_command([sys.executable, str(ROOT / "scripts/action-pin-comments.py"),
str(self.root)])
self.assertIn(".github/actions/nested/setup/action.yaml", output)
self.assertIn(".github/actions/nested/setup/action.yml", output)

def test_unpinned_composite_cannot_hide_behind_workflow_floor(self):
for body in ("runs: {using: composite, steps: [{uses: attacker/exfil@main}]}\n",
"runs:\n using: composite\n steps:\n - uses: attacker/exfil@main\n"):
self.action(body)
self.pins("action is not pinned")
self.run_command([sys.executable, str(ROOT / "scripts/action-pin-comments.py"),
str(self.root)], "cannot check non-SHA reference")

def test_empty_actions_is_not_a_successful_scan(self):
self.actions.mkdir()
self.pins("no action metadata files matched")

def test_empty_workflows_is_not_rescued_by_action(self):
(self.workflows / "ci.yml").unlink()
self.action("runs: {using: composite, steps: []}\n")
self.pins("no workflow files matched")

def test_empty_templates_is_not_rescued_by_workflow(self):
(self.templates / "bug.yml").unlink()
command = ["bash", str(ROOT / "scripts/lint-github-yaml.sh"), str(self.root)]
for unrelated in (False, True):
with self.subTest(unrelated_file=unrelated):
if unrelated:
(self.templates / "README.md").write_text("Not a YAML template.\n")
with self.assertRaisesRegex(ValueError, "no issue templates matched"):
discover(self.workflows, self.actions, self.templates)
self.run_command(command, "no issue templates matched")

def test_malformed_and_directory_metadata_fail(self):
path = self.action("runs: [\n")
self.pins("cannot parse")
path.unlink()
path.mkdir()
self.pins("Is a directory")

def test_bad_composite_shape_fails(self):
self.action("runs: {using: composite, steps: invalid}\n")
self.pins("steps must be a sequence")

def test_node_eol_follows_actions(self):
node_gate = str(ROOT / "tests/release/node-eol.test.sh")
self.action('runs:\n using: composite\n steps:\n - with:\n node-version: "18"\n')
self.run_command(["bash", node_gate, str(self.root)], "pins Node 18")
self.action("runs:\n using: node18\n main: index.js\n")
self.run_command(["bash", node_gate, str(self.root)], "pins Node 18")
self.action("runs:\n using: node24\n main: index.js\n")
self.run_command(["bash", node_gate, str(self.root)])

def test_yaml_lint_follows_actions(self):
self.action("---\nname: setup\nruns:\n using: composite\n steps: []\n")
command = ["bash", str(ROOT / "scripts/lint-github-yaml.sh"), str(self.root)]
self.run_command(command)
self.action("---\nruns: [\n")
self.run_command(command, "syntax error")


if __name__ == "__main__":
unittest.main()
Loading