From bd7c8663ac9acc216d281cce3a8d64594746d166 Mon Sep 17 00:00:00 2001 From: Roland Rodriguez Date: Tue, 6 Oct 2026 18:04:35 -0600 Subject: [PATCH] fix(ci): select affected components on main pushes --- .github/workflows/ci.yml | 13 ++--- docs/component-ci.md | 14 +++-- scripts/ci/select_checks.py | 33 ++++++++++- scripts/ci/test_select_checks.py | 94 +++++++++++++++++++++++++++++++- 4 files changed, 138 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad60d0..47a5fc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: name: Select affected components runs-on: ubuntu-24.04 outputs: + base: ${{ steps.select.outputs.base }} tooling: ${{ steps.select.outputs.tooling }} runtime: ${{ steps.select.outputs.runtime }} cli: ${{ steps.select.outputs.cli }} @@ -33,15 +34,9 @@ jobs: id: select env: EVENT_NAME: ${{ github.event_name }} - BASE_REVISION: ${{ github.event.pull_request.base.sha }} + BASE_REVISION: ${{ github.event.pull_request.base.sha || github.event.before || '' }} run: | - args=(--head HEAD) - if [[ "$EVENT_NAME" == pull_request ]]; then - args+=(--base "$BASE_REVISION") - else - args+=(--full) - fi - python3 scripts/ci/select_checks.py "${args[@]}" + python3 scripts/ci/select_checks.py --head HEAD --event "$EVENT_NAME" --base "$BASE_REVISION" validation: needs: changes uses: ./.github/workflows/full-validation.yml @@ -54,7 +49,7 @@ jobs: mssql: ${{ needs.changes.outputs.mssql == 'true' }} cel: ${{ needs.changes.outputs.cel == 'true' }} site: ${{ needs.changes.outputs.site == 'true' }} - base: ${{ github.event.pull_request.base.sha || '' }} + base: ${{ needs.changes.outputs.base }} site-candidate-server: ${{ needs.changes.outputs.runtime == 'true' || needs.changes.outputs.cli == 'true' || needs.changes.outputs.surrealdb == 'true' || needs.changes.outputs.postgres == 'true' || needs.changes.outputs.mssql == 'true' }} required: name: Required CI diff --git a/docs/component-ci.md b/docs/component-ci.md index 2518227..e8c9335 100644 --- a/docs/component-ci.md +++ b/docs/component-ci.md @@ -1,14 +1,19 @@ # Component CI and release validation -Pull requests validate the changed components and their consumers. Main, the -nightly schedule, manual CI runs, and release tags validate every component. +Pull requests and pushes to main validate the changed components and their +consumers. The nightly schedule, manual CI runs, and release tags validate every +component. Release packaging and signing wait for successful validation of the same tag commit. The required branch-protection check remains `Required CI`. -## Pull request selection +## Change selection `scripts/ci/select_checks.py` compares the pull request merge commit with the -merge base of its base branch. Deleted and renamed files participate in selection. +merge base of its base branch. A normal main push compares the pre-push commit +with the new tip, including every commit in the push. Missing, zero, or +non-ancestor push bases fall back to full validation. The verified comparison +base is also passed to metadata validation. Deleted and renamed files participate +in selection. Unknown executable/source paths, shared interfaces, dependencies, feature flags, toolchains, and CI policy changes select the complete suite. @@ -36,6 +41,7 @@ Use these commands to inspect selection locally: ```sh python3 scripts/ci/select_checks.py --base origin/main --head HEAD +python3 scripts/ci/select_checks.py --event push --base "$(git rev-parse HEAD^)" --head HEAD python3 scripts/ci/select_checks.py --head HEAD --full python3 -m unittest discover -s scripts/ci -p 'test_*.py' python3 scripts/ci/validate_metadata.py --head HEAD diff --git a/scripts/ci/select_checks.py b/scripts/ci/select_checks.py index c8be98c..54e189a 100644 --- a/scripts/ci/select_checks.py +++ b/scripts/ci/select_checks.py @@ -250,17 +250,45 @@ def classify(paths, before=None, after=None, full=False): return checks +def select_changes(base, head, repo="."): + ancestor, paths = changed_paths(base, head, repo) + return classify(paths, snapshot(ancestor, repo), snapshot(head, repo)), ancestor + + +def select_event(event, base, head, repo="."): + """Select a normal push range; validate fully when its history is unavailable.""" + if event in ("schedule", "workflow_dispatch"): + return classify([], full=True), "" + if event == "push": + if not re.fullmatch(r"[0-9a-f]{40}", base or "") or base == "0" * 40: + return classify([], full=True), "" + ancestry = subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor", base, head], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + if ancestry.returncode != 0: + return classify([], full=True), "" + elif event != "pull_request": + raise ValueError(f"Unsupported CI event: {event}") + if not base: + raise ValueError("Pull request selection requires a base revision") + return select_changes(base, head, repo) + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--base") parser.add_argument("--head", default="HEAD") parser.add_argument("--full", action="store_true") + parser.add_argument("--event", choices=("pull_request", "push", "schedule", "workflow_dispatch")) args = parser.parse_args() + base = "" if args.full: checks = classify([], full=True) + elif args.event: + if args.event == "pull_request" and not args.base: + parser.error("Pull request selection requires --base") + checks, base = select_event(args.event, args.base, args.head) elif args.base: - base, paths = changed_paths(args.base, args.head) - checks = classify(paths, snapshot(base), snapshot(args.head)) + checks, base = select_changes(args.base, args.head) else: parser.error("--base is required unless --full is supplied") print(json.dumps(checks, sort_keys=True)) @@ -268,6 +296,7 @@ def main(): with open(output, "a", encoding="utf-8") as file: for name, selected in checks.items(): file.write(f"{name}={str(selected).lower()}\n") + file.write(f"base={base}\n") if __name__ == "__main__": diff --git a/scripts/ci/test_select_checks.py b/scripts/ci/test_select_checks.py index 9bd872c..2079b9f 100644 --- a/scripts/ci/test_select_checks.py +++ b/scripts/ci/test_select_checks.py @@ -7,7 +7,7 @@ import tempfile import unittest -from select_checks import SUITES, changed_paths, classify, git, satisfies, snapshot +from select_checks import SUITES, changed_paths, classify, git, satisfies, select_event, snapshot from validate_metadata import validate @@ -234,6 +234,98 @@ def test_merge_base_ignores_changes_only_on_base_branch(self): ancestor, paths = changed_paths("HEAD", head, self.repo) self.assertEqual(ancestor, self.base) self.assertEqual(paths, ["README.md"]) + checks, comparison_base = select_event("pull_request", "HEAD", head, self.repo) + self.assertFalse(any(checks.values())) + self.assertEqual(comparison_base, self.base) + + def test_main_push_documentation_is_fast(self): + self.write({"README.md": "documentation\n"}) + self.commit() + checks, base = select_event("push", self.base, "HEAD", self.repo) + self.assertFalse(any(checks.values())) + self.assertEqual(base, self.base) + + def test_main_push_covers_every_commit(self): + self.write({"crates/schema-forge-mssql/src/lib.rs": "fn storage() {}\n"}) + self.commit() + self.write({"README.md": "documentation\n"}) + self.commit() + checks, base = select_event("push", self.base, "HEAD", self.repo) + self.assertEqual({name for name, selected in checks.items() if selected}, {"mssql"}) + self.assertEqual(base, self.base) + + def test_main_push_version_only_is_fast_and_checks_changelog(self): + self.write(fixture("0.4.1")) + self.commit() + checks, base = select_event("push", self.base, "HEAD", self.repo) + self.assertFalse(any(checks.values())) + before, after = snapshot(base, self.repo), snapshot("HEAD", self.repo) + self.assertEqual(validate(after, before), []) + after["CHANGELOG.md"] = before["CHANGELOG.md"] + self.assertTrue(any("CHANGELOG" in error for error in validate(after, before))) + + def test_main_push_missing_or_invalid_base_falls_back_to_full(self): + for base in (None, "", "0" * 40, "f" * 40, "HEAD^", "--unsafe"): + with self.subTest(base=base): + checks, comparison_base = select_event("push", base, "HEAD", self.repo) + self.assertTrue(all(checks.values())) + self.assertEqual(comparison_base, "") + + def test_main_push_rollback_falls_back_to_full(self): + self.write({"crates/schema-forge-core/src/lib.rs": "fn shared() {}\n"}) + self.commit() + before = git("rev-parse", "HEAD", repo=self.repo).decode().strip() + checks, base = select_event("push", before, self.base, self.repo) + self.assertTrue(all(checks.values())) + self.assertEqual(base, "") + + def test_main_push_divergent_history_falls_back_to_full(self): + git("checkout", "-qb", "topic", repo=self.repo) + self.write({"README.md": "documentation\n"}) + self.commit() + head = git("rev-parse", "HEAD", repo=self.repo).decode().strip() + git("checkout", "-q", "--detach", self.base, repo=self.repo) + self.write({"crates/schema-forge-core/src/lib.rs": "fn old_shared() {}\n"}) + self.commit() + before = git("rev-parse", "HEAD", repo=self.repo).decode().strip() + checks, base = select_event("push", before, head, self.repo) + self.assertTrue(all(checks.values())) + self.assertEqual(base, "") + + def test_nightly_and_manual_events_always_validate_fully(self): + for event in ("schedule", "workflow_dispatch"): + with self.subTest(event=event): + checks, base = select_event(event, None, "unknown", "not-a-repository") + self.assertTrue(all(checks.values())) + self.assertEqual(base, "") + + def test_push_cli_outputs_verified_base_for_metadata(self): + self.write({"README.md": "documentation\n"}) + self.commit() + script = Path(__file__).with_name("select_checks.py").resolve() + output = self.repo / "outputs" + result = subprocess.run(["python3", str(script), "--event", "push", "--base", self.base], + cwd=self.repo, env={**os.environ, "GITHUB_OUTPUT": str(output)}, + check=True, capture_output=True, text=True) + self.assertFalse(any(json.loads(result.stdout).values())) + self.assertIn(f"base={self.base}\n", output.read_text()) + + def test_push_cli_fallback_clears_metadata_base(self): + script = Path(__file__).with_name("select_checks.py").resolve() + output = self.repo / "outputs" + result = subprocess.run(["python3", str(script), "--event", "push", "--base", "0" * 40], + cwd=self.repo, env={**os.environ, "GITHUB_OUTPUT": str(output)}, + check=True, capture_output=True, text=True) + self.assertTrue(all(json.loads(result.stdout).values())) + self.assertIn("base=\n", output.read_text()) + self.assertNotIn(f"base={'0' * 40}\n", output.read_text()) + + def test_pull_request_cli_requires_base(self): + script = Path(__file__).with_name("select_checks.py").resolve() + result = subprocess.run(["python3", str(script), "--event", "pull_request"], + cwd=self.repo, check=False, capture_output=True, text=True) + self.assertEqual(result.returncode, 2) + self.assertIn("requires --base", result.stderr) def test_cli_emits_boolean_json_and_github_outputs(self): self.write({"crates/schema-forge-codegen/src/lib.rs": "fn render() {}\n"})