Skip to content
Merged
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
13 changes: 4 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand All @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/component-ci.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down
33 changes: 31 additions & 2 deletions scripts/ci/select_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,24 +250,53 @@ 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))
if output := os.environ.get("GITHUB_OUTPUT"):
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__":
Expand Down
94 changes: 93 additions & 1 deletion scripts/ci/test_select_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"})
Expand Down
Loading