From ef7712ff371b8627e2e336a918b2ff821c19c2b7 Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:54:07 +0800 Subject: [PATCH 1/2] fix(ci): include local action metadata in workflow checks --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 9 ++ scripts/action-pin-comments.py | 14 ++- scripts/ci-local.sh | 2 +- scripts/github_yaml.py | 48 ++++++++++ scripts/lint-github-yaml.sh | 10 +++ tests/release/node-eol.test.sh | 15 ++-- tests/release/release-rehearsal.test.sh | 27 +++++- tests/test_github_yaml.py | 114 ++++++++++++++++++++++++ 9 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 scripts/github_yaml.py create mode 100644 scripts/lint-github-yaml.sh create mode 100644 tests/test_github_yaml.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4c1dc40..e115f34d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ff60006..f9a43ecd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -111,6 +111,15 @@ 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). + ```sh git checkout -b feat/ # … implement … diff --git a/scripts/action-pin-comments.py b/scripts/action-pin-comments.py index 0ad0b4e0..7d172817 100644 --- a/scripts/action-pin-comments.py +++ b/scripts/action-pin-comments.py @@ -11,6 +11,7 @@ import yaml from yaml.nodes import MappingNode, ScalarNode, SequenceNode +from github_yaml import discover def entries(node, key): @@ -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") @@ -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: diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index c627d81b..fde7f719 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -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 diff --git a/scripts/github_yaml.py b/scripts/github_yaml.py new file mode 100644 index 00000000..a42445be --- /dev/null +++ b/scripts/github_yaml.py @@ -0,0 +1,48 @@ +#!/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: + files.extend(sorted(p for p in templates.iterdir() if p.suffix in (".yml", ".yaml"))) + 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}") diff --git a/scripts/lint-github-yaml.sh b/scripts/lint-github-yaml.sh new file mode 100644 index 00000000..6c539e7e --- /dev/null +++ b/scripts/lint-github-yaml.sh @@ -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[@]}" diff --git a/tests/release/node-eol.test.sh b/tests/release/node-eol.test.sh index 7a3e778d..7f5d69bb 100755 --- a/tests/release/node-eol.test.sh +++ b/tests/release/node-eol.test.sh @@ -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" @@ -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)) @@ -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 @@ -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 diff --git a/tests/release/release-rehearsal.test.sh b/tests/release/release-rehearsal.test.sh index e5a115d0..8790d6ae 100755 --- a/tests/release/release-rehearsal.test.sh +++ b/tests/release/release-rehearsal.test.sh @@ -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 @@ -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) @@ -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. @@ -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' diff --git a/tests/test_github_yaml.py b/tests/test_github_yaml.py new file mode 100644 index 00000000..8be20bb8 --- /dev/null +++ b/tests/test_github_yaml.py @@ -0,0 +1,114 @@ +"""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.root / ".github/ISSUE_TEMPLATE").mkdir() + (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_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() From ae9f992435b5ee146f75d7740eae7b1be763601d Mon Sep 17 00:00:00 2001 From: hushen <190065939+918154429@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:18:34 +0800 Subject: [PATCH 2/2] fix(ci): reject an empty issue template scan --- CONTRIBUTING.md | 4 +++- scripts/github_yaml.py | 5 ++++- tests/test_github_yaml.py | 16 +++++++++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9a43ecd..16ddf464 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,9 @@ 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). +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/ diff --git a/scripts/github_yaml.py b/scripts/github_yaml.py index a42445be..8d0f20cb 100644 --- a/scripts/github_yaml.py +++ b/scripts/github_yaml.py @@ -28,7 +28,10 @@ def failed(error): raise ValueError(f"no action metadata files matched under {actions}") files.extend(sorted(found)) if templates is not None: - files.extend(sorted(p for p in templates.iterdir() if p.suffix in (".yml", ".yaml"))) + 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 diff --git a/tests/test_github_yaml.py b/tests/test_github_yaml.py index 8be20bb8..d3c70fdf 100644 --- a/tests/test_github_yaml.py +++ b/tests/test_github_yaml.py @@ -21,7 +21,10 @@ def setUp(self): self.workflows = self.root / ".github/workflows" self.actions = self.root / ".github/actions" self.workflows.mkdir(parents=True) - (self.root / ".github/ISSUE_TEMPLATE").mkdir() + 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" @@ -82,6 +85,17 @@ def test_empty_workflows_is_not_rescued_by_action(self): 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")