diff --git a/.github/scripts/check_ci_results.py b/.github/scripts/check_ci_results.py new file mode 100644 index 000000000..36d1eed3b --- /dev/null +++ b/.github/scripts/check_ci_results.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +"""Fail a terminal CI job unless every serialized dependency succeeded. + +Parent workflows pass GitHub's `toJSON(needs)` object through the NEEDS +environment variable. Treat skipped and cancelled dependencies as failures too: +for a required fan-in job, only an explicit success is safe to accept. +""" + +import json +import os + + +def main() -> None: + # Keep result policy in one script so blocking-ci and postmerge-ci cannot + # drift in how they interpret dependency conclusions. + needs = json.loads(os.environ["NEEDS"]) + failures = sorted( + (name, dependency["result"]) + for name, dependency in needs.items() + if dependency["result"] != "success" + ) + + if failures: + print("CI dependencies did not succeed:") + for name, result in failures: + print(f"{name}: {result}") + raise SystemExit(1) + + print("All CI dependencies succeeded.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/v8_canary_changes.py b/.github/scripts/v8_canary_changes.py index 6d9693c60..0acc4ec3f 100644 --- a/.github/scripts/v8_canary_changes.py +++ b/.github/scripts/v8_canary_changes.py @@ -1,12 +1,44 @@ #!/usr/bin/env python3 +"""Decide which V8 canary work is needed for a commit range. + +The workflow deliberately has no trigger-level path filters because it is both +directly triggered for pull requests and called by postmerge-ci. Keeping the +patterns here gives those entrypoints one source of truth; unrelated events +still run metadata but skip the expensive build matrices. +""" + import argparse import subprocess import tomllib +from fnmatch import fnmatchcase from pathlib import Path ROOT = Path(__file__).resolve().parents[2] +# These patterns replace the old pull_request/push path filters. Include parent +# workflow changes because they can alter whether the canary is invoked. +CANARY_PATH_PATTERNS = { + ".bazelrc", + ".github/actions/setup-bazel-ci/**", + ".github/scripts/run_bazel_with_buildbuddy.py", + ".github/scripts/rusty_v8_bazel.py", + ".github/scripts/rusty_v8_module_bazel.py", + ".github/scripts/v8_canary_changes.py", + ".github/workflows/postmerge-ci.yml", + ".github/workflows/rusty-v8-release.yml", + ".github/workflows/v8-canary.yml", + "MODULE.bazel", + "MODULE.bazel.lock", + "codex-rs/Cargo.toml", + "patches/BUILD.bazel", + "patches/llvm_*.patch", + "patches/rules_cc_*.patch", + "patches/v8_*.patch", + "third_party/v8/**", +} +# Windows source builds are a narrower, more expensive subset of the canary. +# A V8 version change also requires them even when no path below changed. WINDOWS_SOURCE_BUILD_PATHS = { ".github/scripts/rusty_v8_bazel.py", ".github/scripts/rusty_v8_module_bazel.py", @@ -16,6 +48,30 @@ WINDOWS_SOURCE_BUILD_PATHS = { } +def matching_canary_paths(changed_files: set[str]) -> set[str]: + """Return changed paths that require the general V8 build matrix.""" + return { + path + for path in changed_files + if any(fnmatchcase(path, pattern) for pattern in CANARY_PATH_PATTERNS) + } + + +def canary_required( + changed_files: set[str], + base_v8_version: str, + head_v8_version: str, + *, + force: bool = False, +) -> bool: + """Return whether the general V8 build matrix should run.""" + return ( + force + or base_v8_version != head_v8_version + or bool(matching_canary_paths(changed_files)) + ) + + def resolved_v8_version(cargo_lock: bytes) -> str: versions = sorted( { @@ -36,6 +92,7 @@ def windows_source_required( *, force: bool = False, ) -> bool: + """Return whether Windows must rebuild rusty_v8 from source.""" return ( force or base_v8_version != head_v8_version @@ -58,6 +115,8 @@ def merge_base(base: str, head: str, *, root: Path = ROOT) -> str: def changed_files(base: str, head: str, *, root: Path = ROOT) -> set[str]: + # Three-dot diff gives PRs merge-base semantics while remaining equivalent + # to before/after for ordinary linear pushes to main. output = git_output( "diff", "--name-only", @@ -79,25 +138,44 @@ def parse_args() -> argparse.Namespace: def main() -> None: args = parse_args() if args.force: - required = True - reason = "manual workflow dispatch" + # workflow_dispatch has no comparison range, and callers use it as a + # manual retry path, so it intentionally runs every variant. + canary = True + canary_reason = "manual workflow dispatch" + windows_source = True + windows_source_reason = "manual workflow dispatch" elif not args.base or not args.head: raise SystemExit("--base and --head are required unless --force is set") else: files = changed_files(args.base, args.head) base_version = v8_version_at_revision(merge_base(args.base, args.head)) head_version = v8_version_at_revision(args.head) - required = windows_source_required(files, base_version, head_version) + + matched_canary_paths = sorted(matching_canary_paths(files)) + canary = canary_required(files, base_version, head_version) + windows_source = windows_source_required(files, base_version, head_version) if base_version != head_version: - reason = f"v8 version changed from {base_version} to {head_version}" + canary_reason = ( + f"v8 version changed from {base_version} to {head_version}" + ) + windows_source_reason = canary_reason else: - matched_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS) - reason = ( - ", ".join(matched_paths) if matched_paths else "no relevant changes" + canary_reason = ( + ", ".join(matched_canary_paths) + if matched_canary_paths + else "no relevant changes" + ) + matched_windows_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS) + windows_source_reason = ( + ", ".join(matched_windows_paths) + if matched_windows_paths + else "no relevant changes" ) - print(f"windows_source_required={str(required).lower()}") - print(f"windows_source_reason={reason}") + print(f"canary_required={str(canary).lower()}") + print(f"canary_reason={canary_reason}") + print(f"windows_source_required={str(windows_source).lower()}") + print(f"windows_source_reason={windows_source_reason}") if __name__ == "__main__": diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 7a6db869e..10dd8670d 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -4,10 +4,7 @@ name: Bazel # https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml on: - pull_request: {} - push: - branches: - - main + workflow_call: workflow_dispatch: concurrency: diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index ea50e8ee2..fddc51afe 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -1,7 +1,7 @@ name: blob-size-policy on: - pull_request: {} + workflow_call: jobs: check: @@ -14,13 +14,24 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Determine PR comparison range + - name: Determine comparison range id: range shell: bash run: | set -euo pipefail - echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" - echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + + # PRs inspect the proposed diff; main pushes inspect only the commit + # range that just landed. Both paths feed the same blob-size checker. + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + base='${{ github.event.pull_request.base.sha }}' + head='${{ github.event.pull_request.head.sha }}' + else + base='${{ github.event.before }}' + head='${{ github.sha }}' + fi + + echo "base=$base" >> "$GITHUB_OUTPUT" + echo "head=$head" >> "$GITHUB_OUTPUT" - name: Check changed blob sizes env: diff --git a/.github/workflows/blocking-ci.yml b/.github/workflows/blocking-ci.yml new file mode 100644 index 000000000..fe66fdd50 --- /dev/null +++ b/.github/workflows/blocking-ci.yml @@ -0,0 +1,79 @@ +name: blocking-ci + +# This is the single entrypoint for checks that block a PR merge. It also runs +# after pushes to main so the same check family stays grouped in the Actions UI. +on: + pull_request: {} + push: + branches: [main] + +jobs: + # Keep reusable workflow calls alphabetized. The `required` job below is the + # version-controlled list that the main-branch ruleset should require. + bazel: + name: Bazel + uses: ./.github/workflows/bazel.yml + secrets: inherit + + blob-size-policy: + name: Blob size policy + uses: ./.github/workflows/blob-size-policy.yml + secrets: inherit + + cargo-deny: + name: cargo-deny + uses: ./.github/workflows/cargo-deny.yml + secrets: inherit + + codespell: + name: Codespell + uses: ./.github/workflows/codespell.yml + secrets: inherit + + repo-checks: + name: repo-checks + uses: ./.github/workflows/repo-checks.yml + secrets: inherit + + rust-ci: + name: rust-ci + uses: ./.github/workflows/rust-ci.yml + secrets: inherit + + sdk: + name: sdk + uses: ./.github/workflows/sdk.yml + secrets: inherit + + required: + name: CI required + # Without `always()`, GitHub skips this job after a failed dependency and a + # required check can appear successful instead of reporting the failure. + if: ${{ always() }} + needs: + - bazel + - blob-size-policy + - cargo-deny + - codespell + - repo-checks + - rust-ci + - sdk + runs-on: ubuntu-24.04 + steps: + # Keep the helper on the same revision as the caller and child workflows. + # CI workflow uploads are restricted, so this repository does not need a + # separate trusted-base checkout for the terminal policy step. Using the + # PR head also lets the introducing PR exercise a newly added helper. + # + # During the initial rollout, PR branches created before + # check_ci_results.py exists must rebase onto main before this gate can + # run. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Require successful dependencies + env: + NEEDS: ${{ toJSON(needs) }} + run: python3 .github/scripts/check_ci_results.py diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index aa37d7cec..be00e1147 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -1,10 +1,7 @@ name: cargo-deny on: - pull_request: - push: - branches: - - main + workflow_call: # Cargo's libgit2 transport has been flaky when fetching git dependencies with # nested submodules. Prefer the system git CLI across every Cargo invocation. diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index b642a6dbe..a1751c8d2 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -3,10 +3,7 @@ name: Codespell on: - push: - branches: [main] - pull_request: - branches: [main] + workflow_call: permissions: contents: read diff --git a/.github/workflows/postmerge-ci.yml b/.github/workflows/postmerge-ci.yml new file mode 100644 index 000000000..ac2b46d1d --- /dev/null +++ b/.github/workflows/postmerge-ci.yml @@ -0,0 +1,40 @@ +name: postmerge-ci + +# This is the single entrypoint for main-only CI that is intentionally outside +# the merge-blocking suite. It keeps the broader postmerge signal in one run. +on: + push: + branches: [main] + +jobs: + # Keep reusable workflow calls alphabetized. Each child retains its own + # workflow_dispatch trigger so maintainers can rerun flaky suites directly. + rust-ci-full: + name: rust-ci-full + uses: ./.github/workflows/rust-ci-full.yml + secrets: inherit + + v8-canary: + name: v8-canary + uses: ./.github/workflows/v8-canary.yml + secrets: inherit + + results: + name: Postmerge CI results + needs: + - rust-ci-full + - v8-canary + if: ${{ always() }} + runs-on: ubuntu-24.04 + steps: + # Postmerge runs use the pushed main commit, so this helper always comes + # from the same revision that defined the parent workflow. + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require successful dependencies + env: + NEEDS: ${{ toJSON(needs) }} + run: python3 .github/scripts/check_ci_results.py diff --git a/.github/workflows/ci.yml b/.github/workflows/repo-checks.yml similarity index 98% rename from .github/workflows/ci.yml rename to .github/workflows/repo-checks.yml index a78ae034e..3a76a202e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/repo-checks.yml @@ -1,8 +1,7 @@ -name: ci +name: repo-checks on: - pull_request: {} - push: { branches: [main] } + workflow_call: jobs: build-test: diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 7ad1d4b3a..cb99c8911 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -1,8 +1,10 @@ name: rust-ci-full on: + workflow_call: push: branches: - - main + # Main pushes enter through postmerge-ci. Keep this opt-in branch trigger + # for developers who want the full suite before merging. - "**full-ci**" workflow_dispatch: diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index f693994d1..cf1afdfe2 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -1,6 +1,6 @@ name: rust-ci on: - pull_request: {} + workflow_call: workflow_dispatch: # Cargo's libgit2 transport has been flaky when fetching git dependencies with diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index dcb940679..41f887471 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -1,9 +1,7 @@ name: sdk on: - push: - branches: [main] - pull_request: {} + workflow_call: jobs: python-sdk: diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index efa06aaad..53d958510 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -1,44 +1,12 @@ name: v8-canary +# Do not use trigger-level path filters here. This workflow is also called by +# postmerge-ci, and GitHub cannot share a path filter between pull_request and +# workflow_call. v8_canary_changes.py is the single source of truth instead; +# unrelated events run only the cheap metadata job below. on: - pull_request: - paths: - - ".bazelrc" - - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - - ".github/scripts/rusty_v8_bazel.py" - - ".github/scripts/rusty_v8_module_bazel.py" - - ".github/scripts/v8_canary_changes.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/llvm_*.patch" - - "patches/rules_cc_*.patch" - - "patches/v8_*.patch" - - "third_party/v8/**" - push: - branches: - - main - paths: - - ".bazelrc" - - ".github/actions/setup-bazel-ci/**" - - ".github/scripts/run_bazel_with_buildbuddy.py" - - ".github/scripts/rusty_v8_bazel.py" - - ".github/scripts/rusty_v8_module_bazel.py" - - ".github/scripts/v8_canary_changes.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/llvm_*.patch" - - "patches/rules_cc_*.patch" - - "patches/v8_*.patch" - - "third_party/v8/**" + workflow_call: + pull_request: {} workflow_dispatch: # Cargo's libgit2 transport has been flaky when fetching git dependencies with @@ -54,6 +22,10 @@ jobs: metadata: runs-on: ubuntu-latest outputs: + # A stale PR head can contain the old detector, which does not emit this + # output. Missing must mean "run" so older branches cannot silently skip + # the expensive V8 coverage while reporting success. + canary_required: ${{ steps.changes.outputs.canary_required || 'true' }} v8_version: ${{ steps.v8_version.outputs.version }} windows_source_required: ${{ steps.changes.outputs.windows_source_required }} @@ -77,7 +49,7 @@ jobs: version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)" echo "version=${version}" >> "$GITHUB_OUTPUT" - - name: Detect whether Windows source artifacts need rebuilding + - name: Detect V8 canary changes id: changes env: BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} @@ -87,6 +59,8 @@ jobs: run: | set -euo pipefail + # Manual runs have no meaningful before/after range. Force every V8 + # variant so workflow_dispatch remains a reliable retry path. if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then output="$(python3 .github/scripts/v8_canary_changes.py --force)" else @@ -104,6 +78,8 @@ jobs: build: name: Build ${{ matrix.variant }} ${{ matrix.target }} needs: metadata + # Metadata always runs; only relevant changes pay for the large matrix. + if: ${{ needs.metadata.outputs.canary_required == 'true' }} runs-on: ${{ matrix.runner }} permissions: contents: read