ci: prepare CI workflows for a GitHub merge queue (#2721)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-16 20:10:03 +01:00
committed by GitHub
parent 032b693eb2
commit 5dcac3f259
12 changed files with 1372 additions and 278 deletions
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Apply a GitHub `paths:` filter inside a job instead of on the trigger.
Why this exists
---------------
A workflow that is skipped by a trigger-level `paths:` filter reports **no
conclusion at all**. GitHub's own documentation is explicit that "checks
associated with that workflow will remain in a 'Pending' state", and a required
check that stays pending blocks a pull request from entering a merge queue and
blocks the merge group from ever merging.
The `merge_group` event makes this worse: it supports `types:` and `branches:`
filters only -- `paths:` is not available -- so a trigger-level filter cannot
even be mirrored onto the queue side.
The fix is to move the filter one level down. The workflow triggers
unconditionally, a cheap `changes` job runs this script to decide whether the
expensive jobs are relevant, and an always-running gate job reports the single
conclusion the merge queue waits on. Jobs skipped by an `if:` conditional are
fine here because they are never themselves the required check.
Usage
-----
git diff --name-only -z "$BASE" HEAD | python3 .github/scripts/changed-paths.py
Reads the pattern list from `$PATTERNS` (override with `--patterns-env`), one
pattern per line, in the same syntax and with the same ordering semantics as a
workflow's `paths:` list. Writes `run=true` or `run=false` to stdout and, when
`$GITHUB_OUTPUT` is set, appends it there as a step output.
Pass `-z` to the diff. Git quotes any path outside ASCII under its default
`core.quotePath=true`, so plain `--name-only` reports `apps/web/café.ts` as the
literal 24-character string `"apps/web/caf\303\251.ts"`. That matches no
pattern, so a pull request touching only such a file selects nothing, every job
is skipped, and the gate reports success having tested none of it -- the exact
silent pass this whole mechanism exists to prevent. `-z` emits raw bytes with a
NUL after each path and suppresses the quoting.
Stdin is split on NUL when it contains one and on newlines otherwise, so both
forms work. The detection is not ambiguous: `-z` terminates every path with a
NUL, so any non-empty `-z` output contains at least one.
Pattern syntax
--------------
Supported: `*` (any run of characters except `/`), `**` (any run including
`/`), `?` (one character except `/`), and a leading `!` for negation. A file's
verdict is decided by the LAST pattern that matches it, which is what GitHub
does, so `!**/*.md` after `apps/web/**` excludes Markdown under `apps/web`.
Deliberately unsupported: `+`, `[...]` character classes, and `{...}` braces.
GitHub accepts the first two; this script rejects any pattern containing them
rather than silently matching them as literals, because a filter that quietly
stops matching is exactly the failure this file exists to prevent.
"""
from __future__ import annotations
import argparse
import os
import re
import sys
# Characters GitHub's filter-pattern syntax gives a meaning that this script
# does not implement. Rejected loudly rather than treated as literals.
UNSUPPORTED_METACHARACTERS = ("+", "[", "]", "{", "}")
def compile_pattern(pattern: str) -> re.Pattern[str]:
"""Translate one GitHub filter pattern into an anchored regex.
`**` is translated before `*` so that the single-segment wildcard never
consumes half of a double one.
"""
parts: list[str] = []
index = 0
while index < len(pattern):
char = pattern[index]
if char == "*":
if pattern.startswith("**", index):
parts.append(".*")
index += 2
else:
parts.append("[^/]*")
index += 1
elif char == "?":
parts.append("[^/]")
index += 1
else:
parts.append(re.escape(char))
index += 1
return re.compile("".join(parts) + r"\Z")
class Filter:
"""An ordered `paths:` list, evaluated last-match-wins per file."""
def __init__(self, patterns: list[str]) -> None:
self.rules: list[tuple[bool, re.Pattern[str]]] = []
for raw in patterns:
pattern = raw.strip()
# Blank lines and `#` comments keep the YAML block readable.
if not pattern or pattern.startswith("#"):
continue
included = True
if pattern.startswith("!"):
included = False
pattern = pattern[1:]
# A bare `!` compiles to a regex matching only the empty string,
# which no real path is. It would append a rule that can never fire
# and read like an exclusion that works.
if not pattern:
raise ValueError(
f"pattern {raw!r} is a bare negation with no path. Use "
"'!path/pattern' to exclude specific files."
)
found = [c for c in UNSUPPORTED_METACHARACTERS if c in pattern]
if found:
raise ValueError(
f"pattern {raw!r} uses unsupported metacharacter(s) "
f"{''.join(found)!r}. changed-paths.py implements *, ** and ? "
"only; extend it (and its tests) before using more."
)
self.rules.append((included, compile_pattern(pattern)))
if not self.rules:
raise ValueError("no patterns were supplied")
def includes(self, path: str) -> bool:
"""Whether `path` survives the filter. Later patterns win."""
verdict = False
for included, regex in self.rules:
if regex.match(path):
verdict = included
return verdict
def matches_any(self, paths: list[str]) -> bool:
return any(self.includes(path) for path in paths)
def read_changed_paths(data: bytes) -> list[str]:
"""Split a diff's path list, preferring the NUL-delimited form.
Taken as bytes rather than text because `git diff -z` emits raw filesystem
bytes, which need not be valid UTF-8. `surrogateescape` keeps an
undecodable name intact and matchable instead of failing the step.
"""
separator = b"\0" if b"\0" in data else b"\n"
return [
decoded
for chunk in data.split(separator)
if (decoded := chunk.decode("utf-8", errors="surrogateescape").strip())
]
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--patterns-env",
default="PATTERNS",
help="environment variable holding the newline-separated pattern list",
)
args = parser.parse_args(argv)
raw_patterns = os.environ.get(args.patterns_env)
if raw_patterns is None:
print(
f"::error::{args.patterns_env} is unset; changed-paths.py has no "
"filter to apply.",
file=sys.stderr,
)
return 2
try:
path_filter = Filter(raw_patterns.splitlines())
except ValueError as error:
print(f"::error::{error}", file=sys.stderr)
return 2
changed = read_changed_paths(sys.stdin.buffer.read())
run = path_filter.matches_any(changed)
# The matching subset is the useful half of the log: "no relevant changes"
# is indistinguishable from "the filter is broken" without it.
if run:
print("Relevant changed files:")
for path in changed:
if path_filter.includes(path):
print(f" {path}")
else:
print(f"No relevant changes among {len(changed)} changed file(s).")
value = "true" if run else "false"
github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a", encoding="utf-8") as handle:
handle.write(f"run={value}\n")
print(f"run={value}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env python3
"""Tests for the in-job `paths:` filter used by the merge-queue gating jobs.
Two halves. The first exercises the matcher directly. The second reads the
`PATTERNS` blocks out of the workflows that actually use it and asserts they
still select what their trigger-level `paths:` filters used to select -- a
regex that silently stops matching would hand every merge queue entry a full
E2E run, or worse, skip one that mattered.
"""
from pathlib import Path
import os
import re
import subprocess
import sys
import tempfile
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from importlib import import_module
changed_paths = import_module("changed-paths")
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKFLOWS = REPO_ROOT / ".github" / "workflows"
SCRIPT = Path(__file__).resolve().parent / "changed-paths.py"
# Every workflow that moved its filter into a `changes` job, with one path that
# must still select it and one that must not.
# Required checks with no `changes` job: cheap enough to always run, so their
# filtering was removed outright rather than moved. They still have to keep the
# merge_group trigger and stay unfiltered, and nothing else asserts it --
# `lint-action-pinning.yml` is covered by the frontend and release contract
# tests, but these two had no guard at all.
ALWAYS_RUN_WORKFLOWS = (
"architecture-lint.yml",
"lint-harness-files.yml",
)
GATED_WORKFLOWS = {
"backend-tests.yml": (
["apps/backend/internal/orchestrator/orchestrator.go"],
["apps/web/components/App.tsx", "apps/backend/AGENTS.md"],
),
"frontend-tests.yml": (
["apps/web/components/App.tsx"],
["apps/backend/internal/orchestrator/orchestrator.go", "apps/web/README.md"],
),
"e2e-tests.yml": (
["apps/web/e2e/specs/kanban.spec.ts"],
["docs/public/install.md", "README.md"],
),
}
def patterns_block(workflow: str) -> str:
"""Return the `PATTERNS:` heredoc-style YAML block of the `changes` job."""
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
match = re.search(r"(?m)^ PATTERNS: \|\n((?:^ .*\n|^\n)+)", text)
if match is None:
raise AssertionError(
f"{workflow} has no `PATTERNS: |` block in its `changes` job. The "
"gating job cannot filter anything without one, so every job it "
"guards would run on every event -- or none would."
)
return match.group(1)
class MatcherTest(unittest.TestCase):
def test_double_star_crosses_slashes_and_single_star_does_not(self) -> None:
crossing = changed_paths.Filter(["apps/web/**"])
self.assertTrue(crossing.includes("apps/web/components/deep/App.tsx"))
flat = changed_paths.Filter(["apps/web/*"])
self.assertTrue(flat.includes("apps/web/package.json"))
self.assertFalse(flat.includes("apps/web/components/App.tsx"))
def test_question_mark_matches_one_non_slash_character(self) -> None:
path_filter = changed_paths.Filter(["apps/?eb/main.ts"])
self.assertTrue(path_filter.includes("apps/web/main.ts"))
self.assertFalse(path_filter.includes("apps//eb/main.ts"))
def test_patterns_are_anchored_at_both_ends(self) -> None:
path_filter = changed_paths.Filter(["Makefile"])
self.assertTrue(path_filter.includes("Makefile"))
self.assertFalse(path_filter.includes("apps/backend/Makefile"))
self.assertFalse(path_filter.includes("Makefile.bak"))
def test_last_matching_pattern_decides(self) -> None:
"""`!**/*.md` after an include is an exclusion, not an extra include."""
path_filter = changed_paths.Filter(["apps/web/**", "!**/*.md"])
self.assertFalse(path_filter.includes("apps/web/README.md"))
self.assertTrue(path_filter.includes("apps/web/components/App.tsx"))
# Order matters: reversing the list makes the negation dead.
reversed_filter = changed_paths.Filter(["!**/*.md", "apps/web/**"])
self.assertTrue(reversed_filter.includes("apps/web/README.md"))
def test_a_single_relevant_file_selects_the_workflow(self) -> None:
path_filter = changed_paths.Filter(["apps/web/**", "!**/*.md"])
self.assertTrue(
path_filter.matches_any(["apps/web/README.md", "apps/web/lib/api.ts"]),
"A docs change bundled with a code change must still run the job.",
)
self.assertFalse(path_filter.matches_any(["apps/web/README.md"]))
self.assertFalse(
path_filter.matches_any([]),
"An empty diff selects nothing; the caller decides whether an "
"unresolvable base means 'run everything'.",
)
def test_blank_lines_and_comments_are_ignored(self) -> None:
path_filter = changed_paths.Filter(["", "# a note", "apps/web/**", " "])
self.assertTrue(path_filter.includes("apps/web/main.tsx"))
def test_unsupported_metacharacters_are_rejected(self) -> None:
for pattern in ("apps/web/*.[jt]s", "apps/web/a+b", "apps/{web,cli}/**"):
with self.subTest(pattern=pattern):
with self.assertRaises(ValueError):
changed_paths.Filter([pattern])
def test_an_empty_pattern_list_is_rejected(self) -> None:
with self.assertRaises(ValueError):
changed_paths.Filter(["", "# only comments"])
def test_a_bare_negation_is_rejected(self) -> None:
"""`!` alone compiles to a rule that can never match anything.
It reads like an exclusion and silently is not one, so reject it the
same way an unsupported metacharacter is rejected.
"""
with self.assertRaises(ValueError):
changed_paths.Filter(["apps/web/**", "!"])
class ChangedPathParsingTest(unittest.TestCase):
"""`git diff -z` output, which is where the quoting trap lives."""
def test_nul_delimited_input_is_split_on_nul(self) -> None:
data = "apps/web/a.ts\0apps/web/b.ts\0".encode("utf-8")
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/a.ts", "apps/web/b.ts"],
)
def test_newline_delimited_input_still_works(self) -> None:
data = b"apps/web/a.ts\napps/web/b.ts\n"
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/a.ts", "apps/web/b.ts"],
)
def test_a_non_ascii_path_survives_and_still_matches(self) -> None:
"""The regression this parsing exists for.
Under git's default `core.quotePath=true`, plain `--name-only` reports
this file as the literal string `"apps/web/caf\\303\\251.ts"`, quotes
and all. That matches no pattern, so a pull request touching only it
would skip every job and report a green gate having tested nothing.
`-z` emits the raw bytes instead.
"""
raw = "apps/web/café.ts\0".encode("utf-8")
parsed = changed_paths.read_changed_paths(raw)
self.assertEqual(parsed, ["apps/web/café.ts"])
self.assertTrue(changed_paths.Filter(["apps/web/**"]).matches_any(parsed))
quoted = ['"apps/web/caf\\303\\251.ts"']
self.assertFalse(
changed_paths.Filter(["apps/web/**"]).matches_any(quoted),
"The quoted form must not match -- that is the bug, and this "
"assertion is what proves the -z form above is doing the work.",
)
def test_a_path_containing_a_newline_survives_nul_splitting(self) -> None:
data = "apps/web/we ird\nname.ts\0apps/web/b.ts\0".encode("utf-8")
self.assertEqual(
changed_paths.read_changed_paths(data),
["apps/web/we ird\nname.ts", "apps/web/b.ts"],
)
def test_undecodable_bytes_do_not_crash_the_step(self) -> None:
parsed = changed_paths.read_changed_paths(b"apps/web/\xff\xfe.ts\0")
self.assertEqual(len(parsed), 1)
self.assertTrue(changed_paths.Filter(["apps/web/**"]).matches_any(parsed))
def test_an_empty_diff_yields_no_paths(self) -> None:
self.assertEqual(changed_paths.read_changed_paths(b""), [])
self.assertEqual(changed_paths.read_changed_paths(b"\0"), [])
class CommandLineTest(unittest.TestCase):
def run_script(self, patterns: str, changed: str) -> tuple[int, str, str]:
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp) / "github_output"
output.touch()
env = {
**os.environ,
"PATTERNS": patterns,
"GITHUB_OUTPUT": str(output),
}
result = subprocess.run(
[sys.executable, str(SCRIPT)],
input=changed,
env=env,
capture_output=True,
text=True,
)
return result.returncode, result.stdout, output.read_text(encoding="utf-8")
def test_writes_the_step_output(self) -> None:
code, stdout, step_output = self.run_script(
"apps/web/**\n!**/*.md\n", "apps/web/lib/api.ts\n"
)
self.assertEqual(code, 0)
self.assertIn("run=true", stdout)
self.assertEqual(step_output, "run=true\n")
def test_reports_false_without_a_relevant_change(self) -> None:
code, stdout, step_output = self.run_script(
"apps/web/**\n!**/*.md\n", "docs/public/install.md\napps/web/README.md\n"
)
self.assertEqual(code, 0)
self.assertIn("run=false", stdout)
self.assertEqual(step_output, "run=false\n")
def test_a_missing_pattern_variable_fails_loudly(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
env = {k: v for k, v in os.environ.items() if k != "PATTERNS"}
env["GITHUB_OUTPUT"] = str(Path(tmp) / "out")
result = subprocess.run(
[sys.executable, str(SCRIPT)],
input="apps/web/lib/api.ts\n",
env=env,
capture_output=True,
text=True,
)
self.assertNotEqual(
result.returncode,
0,
"An unset PATTERNS must fail the step. Defaulting to 'run nothing' "
"would pass a merge queue entry that was never tested.",
)
class WorkflowPatternsTest(unittest.TestCase):
"""The lists in the workflows must still mean what their filters meant."""
def test_each_gating_workflow_selects_and_rejects_the_expected_paths(self) -> None:
for workflow, (selected, rejected) in GATED_WORKFLOWS.items():
with self.subTest(workflow=workflow):
path_filter = changed_paths.Filter(patterns_block(workflow).splitlines())
for path in selected:
self.assertTrue(
path_filter.includes(path),
f"{workflow} must still run for {path}.",
)
for path in rejected:
self.assertFalse(
path_filter.includes(path),
f"{workflow} must not be selected by {path} alone.",
)
def test_every_gating_workflow_filters_on_its_own_definition(self) -> None:
"""A change to the workflow file must run the jobs it defines.
This is the trigger-level `paths:` entry each of these workflows
carried for itself. Losing it means a PR that rewrites the E2E workflow
never runs it.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
path_filter = changed_paths.Filter(patterns_block(workflow).splitlines())
self.assertTrue(
path_filter.includes(f".github/workflows/{workflow}"),
f"{workflow} must select itself.",
)
def test_gating_workflows_carry_no_trigger_level_path_filter(self) -> None:
"""The whole point: these workflows must always report a conclusion.
A `paths:` key back on `pull_request` or `merge_group` reintroduces the
pending-forever check that blocks a merge queue.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
triggers = text.partition("\nconcurrency:")[0]
self.assertNotIn(
"\n paths:",
triggers,
f"{workflow} must not filter its triggers by path. The "
"filter belongs in the `changes` job, where a skip still "
"reports a conclusion.",
)
self.assertIn(
"\n merge_group:\n",
triggers,
f"{workflow} must keep its merge_group trigger; without it "
"the merge queue waits forever for a check that never runs.",
)
def test_each_changes_job_feeds_the_script_a_nul_delimited_diff(self) -> None:
"""`-z` is load-bearing and the script cannot enforce it from inside.
Newline-delimited input still parses, by design, so dropping `-z` from
a workflow fails nothing at runtime -- it just quietly restores the
quoting bug for non-ASCII paths, where the gate passes without having
tested the change. This is the only place that can catch it.
"""
for workflow in GATED_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
self.assertIn(
'git diff --name-only -z "${BASE}" HEAD',
text,
f"{workflow}'s `changes` job must pass -z to git diff. "
"Without it git quotes non-ASCII paths, they match no "
"pattern, and every job is skipped on a green gate.",
)
def test_always_run_gates_keep_merge_group_and_stay_unfiltered(self) -> None:
"""Same two properties, for the gates that have no `changes` job.
These are required checks too. Adding a `paths:` filter back, or
dropping the merge_group trigger, stalls the queue on that check just
as surely as it would for the heavy workflows above -- and until now
nothing failed when it happened.
"""
for workflow in ALWAYS_RUN_WORKFLOWS:
with self.subTest(workflow=workflow):
text = (WORKFLOWS / workflow).read_text(encoding="utf-8")
triggers = text.partition("\nconcurrency:")[0]
self.assertIn(
"\n merge_group:\n",
triggers,
f"{workflow} must keep its merge_group trigger; without it "
"the merge queue waits forever for a check that never runs.",
)
self.assertNotIn(
"\n paths:",
triggers,
f"{workflow} must not filter its triggers by path. It has "
"no `changes` job to move the filter into, so a filter "
"here means the check simply stops reporting.",
)
if __name__ == "__main__":
unittest.main()
@@ -31,10 +31,6 @@ LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml"
STEP_MARKER = " - name: Run tests\n"
NEXT_STEP_MARKER = "\n - name: "
# The subjects above that live outside `.github/`, and so are not covered by
# this job's `.github/workflows/**` trigger.
UNTRIGGERED_SUBJECTS = ("apps/web/vitest.config.ts", "apps/.npmrc")
def run_tests_step(workflow: str) -> str:
"""Return the body of the `Run tests` step, up to the following step."""
@@ -44,26 +40,12 @@ def run_tests_step(workflow: str) -> str:
return remainder.partition(NEXT_STEP_MARKER)[0]
def trigger_paths(workflow: str, trigger: str) -> str:
"""Return the `paths:` list of `trigger`'s block under `on:`.
Scoped to the `paths:` key rather than the whole trigger block. GitHub
rejects `paths` and `paths-ignore` on the same event, so renaming the key
inverts the filter wholesale: the job would then run on everything *except*
these files, and a match against the block as a whole would not notice.
"""
def trigger_block(workflow: str, trigger: str) -> str:
"""Return `trigger`'s block under `on:`, up to the next top-level key."""
block = re.search(rf"(?m)^ {trigger}:\n(?:^ {{4}}.*\n|^\n)*", workflow)
if block is None:
raise AssertionError(f"lint-action-pinning.yml has no {trigger} trigger")
paths = re.search(r"(?m)^ paths:\n((?:^ - .*\n)+)", block.group(0))
if paths is None:
raise AssertionError(
f"lint-action-pinning.yml's {trigger} trigger has no `paths:` list. A "
"`paths-ignore:` key in its place inverts the filter, and the job stops "
"running for the very files listed under it."
)
return paths.group(1)
return block.group(0)
class FrontendTestsWorkflowContractTest(unittest.TestCase):
@@ -109,35 +91,40 @@ class FrontendTestsWorkflowContractTest(unittest.TestCase):
def test_this_contract_runs_when_its_subjects_change(self) -> None:
"""The assertions above are only worth anything if CI runs them.
`lint-action-pinning.yml` triggers on `.github/workflows/**`, which
covers `frontend-tests.yml` but neither subject in
`UNTRIGGERED_SUBJECTS`. `frontend-tests.yml` does not cover
`apps/.npmrc` either, so without an explicit trigger here a PR that
deletes `production=false` and nothing else runs no job at all and the
assertion above never executes. Mirrors the equivalent test in
`release-workflow-contract_test.py`.
Two of this file's subjects, `apps/web/vitest.config.ts` and
`apps/.npmrc`, live outside `.github/`. `lint-action-pinning.yml` used
to name them in a hand-maintained `paths:` list so that a PR deleting
`production=false` and nothing else still brought this job up; every
new subject meant remembering to extend that list, and every omission
was a silently unguarded file.
The step assertion at the end is weaker than the path ones by
The list is gone. `lint-action-pinning.yml` now runs on every push and
every pull request, which subsumes the old guarantee for every subject
at once -- and is what lets it be a merge-queue required check, since a
path-filtered workflow reports no conclusion when it is skipped and a
required check that never reports blocks the queue forever.
The step assertion at the end is weaker than the trigger ones by
construction: this file runs only from that step, so the PR that deletes
it also deletes the only thing that would fail. It holds for local runs
and for a step whose command is renamed or moved elsewhere. Keeping the
job a required check is what covers outright removal, and that lives in
branch protection, not here.
the repository ruleset, not here.
"""
lint_workflow = LINT_WORKFLOW.read_text(encoding="utf-8")
for trigger in ("push", "pull_request"):
paths = trigger_paths(lint_workflow, trigger)
for trigger in ("push", "pull_request", "merge_group"):
block = trigger_block(lint_workflow, trigger)
for subject in UNTRIGGERED_SUBJECTS:
self.assertIn(
f' - "{subject}"\n',
paths,
f"{subject} must be a {trigger} path trigger in "
"lint-action-pinning.yml. It is a subject of this contract "
"but lives outside .github/, so nothing else brings this "
"job up when it changes.",
)
self.assertNotIn(
" paths:",
block,
f"lint-action-pinning.yml's {trigger} trigger must stay "
"unfiltered. A `paths:` list there reintroduces both failure "
"modes it was removed for: subjects outside the list stop "
"being guarded, and a skipped run reports no conclusion, which "
"a merge queue waits on forever.",
)
self.assertIn(
"\n run: python3 .github/scripts/frontend-tests-workflow-contract_test.py\n",
@@ -442,32 +442,33 @@ class ReleaseWorkflowContractTest(unittest.TestCase):
self.assertEqual(primary_fingerprints[0], documented_fingerprint.group(1))
def test_release_contract_ci_runs_when_key_or_release_documentation_changes(self) -> None:
for trigger in ("push", "pull_request"):
"""This contract must run on every change, not a listed subset.
`lint-action-pinning.yml` used to name each subject of this file --
the signing key, the release docs, every `scripts/release/` helper --
in a hand-maintained `paths:` list, so that changing one brought the
job up. That list is gone: the workflow now triggers on every push and
every pull request, which covers every subject here and every one added
later without anybody remembering to extend a list.
The change was forced by the merge queue. A workflow skipped by a
`paths:` filter reports no conclusion at all, and a required check that
never reports blocks a pull request from entering the queue.
"""
for trigger in ("push", "pull_request", "merge_group"):
trigger_block = re.search(
rf" {trigger}:\n.*?(?=\n [a-z_]+:|\nconcurrency:)",
LINT_WORKFLOW,
re.DOTALL,
)
self.assertIsNotNone(trigger_block)
self.assertIn('".github/release-signing-key.asc"', trigger_block.group(0))
self.assertIn('"docs/public/release-process.md"', trigger_block.group(0))
self.assertIn('"scripts/release/package-npm-runtime.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/publish-npm.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/publish-npm.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-packages.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-view-version.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/npm-view-version.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-version.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-version.test.mjs"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/nightly-release.test.mjs"', trigger_block.group(0))
self.assertIn('"Makefile"', trigger_block.group(0))
self.assertIn('"apps/backend/Makefile"', trigger_block.group(0))
self.assertIn('"scripts/release/package-bundle.sh"', trigger_block.group(0))
self.assertIn('"scripts/release/runtime-bundle.test.sh"', trigger_block.group(0))
self.assertIn(
'"scripts/release/validate-darwin-arm64-helper.mjs"',
self.assertNotIn(
" paths:",
trigger_block.group(0),
f"lint-action-pinning.yml's {trigger} trigger must stay "
"unfiltered. A `paths:` list there both un-guards every "
"subject it omits and makes the check unreportable in a merge "
"queue.",
)
setup_node = (