From 655a3270113b5cd48522ea5bfdf7950b1993b31e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 21:39:59 -0700 Subject: [PATCH 1/2] quest: claim quest/m0/ci/release-gates From 9afd91c1c377046d3bd3bd6b65a641fdfed1a154 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 20 Oct 2026 22:07:09 -0700 Subject: [PATCH 2/2] ci: gate release PRs behind one Release gate context release-gate.yml runs on every PR into release, calls Platform, Features, and each optional workflow whose paths the diff touches, and fails its `Release gate` job when any of them fails or is cancelled. The optional workflows drop their own pull_request triggers, so release can require one context that always reports. smoke.yml drops its main PR trigger, which the queue policy test forbids. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/android.yml | 29 -- .github/workflows/audio-quality.yml | 16 - .github/workflows/cpp.yml | 18 - .github/workflows/features.yml | 4 - .github/workflows/interop.yml | 24 +- .github/workflows/moq-sh.yml | 13 +- .github/workflows/nightly.yml | 4 +- .github/workflows/obs.yml | 33 +- .github/workflows/oss-scanner.yml | 9 - .github/workflows/platform.yml | 13 +- .github/workflows/release-dart-ffi.yml | 14 - .github/workflows/release-dart.yml | 8 - .github/workflows/release-gate.yml | 487 ++++++++++++++++++++++++ .github/workflows/release-go-ffi.yml | 17 +- .github/workflows/release-js.yml | 7 - .github/workflows/release-kt-lib.yml | 13 - .github/workflows/release-swift-lib.yml | 14 - .github/workflows/runner-vm.yml | 10 - .github/workflows/smoke.yml | 15 +- .github/workflows/ts.yml | 43 +-- .github/workflows/uring.yml | 25 +- .github/workflows/video.yml | 20 +- .github/workflows/wasm.yml | 29 -- .github/workflows/x11.yml | 14 - quest/m0/ci/README.md | 2 +- quest/m0/ci/mbx-r2-verify.md | 2 +- quest/m0/ci/release-gates.md | 44 +-- sh/dispatch.sh | 2 +- sh/gh/queue.test.ts | 118 +++++- sh/gh/ts-paths.py | 14 +- sh/gh/ts-paths.test.py | 20 + 31 files changed, 666 insertions(+), 415 deletions(-) create mode 100644 .github/workflows/release-gate.yml diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 62286f7140f..5d069ac04da 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -24,34 +24,6 @@ permissions: on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. `closed` is here only so merging/closing a PR - # cancels its in-flight run via the concurrency group below; the job itself - # is skipped on close. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "rs/moq-video/**" - - "rs/moq-ffi/**" - - "rs/moq-tokio/**" - - "rs/moq-mux/**" - - "rs/moq-net/**" - - "rs/moq-nvenc/**" - - "rs/moq-loc/**" - - "rs/moq-msf/**" - - "rs/moq-json/**" - - "rs/moq-flate/**" - - "rs/moq-pattern/**" - - "rs/hang/**" - - "rs/kio/**" - - "rs/justfile" - - "justfile" - - ".cargo/config.toml" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - ".github/workflows/android.yml" concurrency: group: android-${{ github.ref }} @@ -63,7 +35,6 @@ env: jobs: android: name: Android - if: github.event.action != 'closed' # x86_64 rather than the arm runners check.yml uses: GitHub documents the # Android SDK and NDK as preinstalled on this image. runs-on: ubuntu-24.04 diff --git a/.github/workflows/audio-quality.yml b/.github/workflows/audio-quality.yml index a21fbad57c6..1a93a856c8e 100644 --- a/.github/workflows/audio-quality.yml +++ b/.github/workflows/audio-quality.yml @@ -7,22 +7,6 @@ name: Audio quality # in nightly.yml. on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "js/watch/**" - # The container consumer decides what a replay delivers, waits for, and skips, on the - # groups, tracks, and signals underneath it. - - "js/hang/**" - - "js/net/**" - - "js/signals/**" - - "test/audio-quality/**" - - "test/lib/**" - - "test/justfile" - - ".github/workflows/audio-quality.yml" concurrency: group: audio-quality-${{ github.ref }} diff --git a/.github/workflows/cpp.yml b/.github/workflows/cpp.yml index 3cd318db691..a61341a5920 100644 --- a/.github/workflows/cpp.yml +++ b/.github/workflows/cpp.yml @@ -13,23 +13,6 @@ permissions: on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. `closed` is here only so merging/closing a PR - # cancels its in-flight run via the concurrency group below; the job itself - # is skipped on close. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "cpp/moq/**" - - "cpp/justfile" - - "rs/moq-ffi/**" - - "sh/cpp/**" - - "rs/moq-c/native-libs/**" - - "doc/lib/cpp/**" - - "doc/lib/samples.sh" - - "Cargo.lock" - - ".github/workflows/cpp.yml" concurrency: group: cpp-${{ github.ref }} @@ -38,7 +21,6 @@ concurrency: jobs: clang: name: C++ (clang) - if: github.event.action != 'closed' runs-on: ubuntu-24.04 timeout-minutes: 60 diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index 40d6e473138..29f459d7130 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -5,9 +5,6 @@ permissions: on: workflow_call: - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited, closed] concurrency: group: features-${{ github.ref }} @@ -16,7 +13,6 @@ concurrency: jobs: features: name: Features (${{ matrix.recipe }}) - if: github.event.action != 'closed' runs-on: ubuntu-24.04-arm environment: ${{ github.ref == 'refs/heads/main' && 'cache-write' || '' }} timeout-minutes: 60 diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index e7c1765731a..2127319a0ae 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -10,28 +10,8 @@ on: # Nightly: catch an interop regression in the current tree before a user does. schedule: - cron: "0 8 * * *" - # On release PRs that touch the harness itself, so a backported change to the - # interop test is validated before it merges. Main PRs skip it; the schedule - # above covers main. `edited` catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "test/lib/**" - - "test/p2p/**" - - "js/p2p/**" - - "test/interop/**" - - "test/max-age/**" - - "test/portrait/**" - - "rs/moq-tokio/tests/integration/max_age.rs" - # The interop job also depends on the recipe that invokes it and the - # workspace membership that links the JS clients to source. - - "test/justfile" - - "package.json" - # The Go client builds against the modules this script stages. - - "sh/go/**" - - "cpp/moq/**" - - ".github/workflows/interop.yml" + # release-gate.yml, on release PRs that touch the harness. + workflow_call: concurrency: group: interop-${{ github.ref }} diff --git a/.github/workflows/moq-sh.yml b/.github/workflows/moq-sh.yml index bff42ab4fe1..eeb438eff0c 100644 --- a/.github/workflows/moq-sh.yml +++ b/.github/workflows/moq-sh.yml @@ -11,15 +11,10 @@ on: paths: - "infra/moq-sh/**" - ".github/workflows/moq-sh.yml" - pull_request: - # Main PRs skip it: the merge queue's Check runs its fixtures on Linux, and - # nightly.yml's platform.yml call runs them and the smoke on macOS. `edited` - # catches a main PR retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "infra/moq-sh/**" - - ".github/workflows/moq-sh.yml" + # Release PRs, through release-gate.yml. Main PRs skip it: the merge queue's + # Check runs its fixtures on Linux, and nightly.yml's platform.yml call runs + # them and the smoke on macOS. + workflow_call: workflow_dispatch: permissions: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index de40637fbeb..f38d719db2f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -30,7 +30,7 @@ name: Nightly # Main PRs run no cloud CI beyond the merge queue's Check, so every # path-filtered gate (Android, C++, WASM, X11, the audio replays, the OSS # Scanner image, platform.yml and the release dry-runs) runs here on main too, -# and on release PRs. +# and release-gate.yml runs them on release PRs. # # A failure here lands on main rather than being caught in review, which is the # accepted trade. Anything that must block a merge belongs in check.yml. @@ -173,7 +173,7 @@ jobs: artifact-path: target/coverage/js # OBS (over moq-ffi) and moq-c's C fixtures link a Rust staticlib from # outside cargo, so a stale `rs/moq-c/native-libs/*.txt` breaks them and - # no cargo build. obs.yml's path filter can't list every such input; + # no cargo build. release-gate.yml's OBS filter can't list every such input; # this diff-independent run catches what it misses, a day late. - name: OBS recipe: obs ci diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml index 03df92de9bc..3c882de5389 100644 --- a/.github/workflows/obs.yml +++ b/.github/workflows/obs.yml @@ -38,7 +38,7 @@ name: OBS # A list of paths still can't be complete here -- a vendored C source or a # .cargo/config.toml could do it too -- and `rs/**` would run this on nearly # every PR, which is the cost that removed the Windows and macOS gates (#2600). -# So this filter covers the likely inputs at PR time, and pushes to `release` +# So release-gate.yml's filter covers the likely inputs at PR time, and pushes to `release` # rebuild on any Rust or plugin change to cover the rest. Main PRs skip it: # nightly.yml runs the same Linux recipes on main diff-independently. @@ -61,32 +61,12 @@ on: - "flake.nix" - "flake.lock" - ".github/workflows/obs.yml" - pull_request: - # `edited` catches a main PR retargeted to release. `closed` is here only so - # merging/closing a PR cancels its in-flight run via the concurrency group - # below; the job itself is skipped on close. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "cpp/obs/**" - - "cpp/moq/**" - - "sh/obs/**" - - "rs/moq-ffi/**" - - "rs/moq-c/**" - - "doc/lib/c/**" - - "doc/lib/samples.sh" - - "rs/**/Cargo.toml" - - "rs/**/build.rs" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "flake.nix" - - "flake.lock" - - ".github/workflows/obs.yml" + # Release PRs, through release-gate.yml. + workflow_call: -# Keyed and cancelled like platform.yml: a merged PR's `closed` run must not -# cancel the push run it triggers, and `release` can merge faster than a push -# run finishes, so pushes queue rather than cancel. +# Keyed and cancelled like platform.yml: a release PR's run must not cancel a +# push run, and `release` can merge faster than a push run finishes, so pushes +# queue rather than cancel. concurrency: group: obs-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -94,7 +74,6 @@ concurrency: jobs: build: name: OBS - if: github.event.action != 'closed' runs-on: ubuntu-24.04-arm timeout-minutes: 60 diff --git a/.github/workflows/oss-scanner.yml b/.github/workflows/oss-scanner.yml index cbb1875b1ca..d0109424dda 100644 --- a/.github/workflows/oss-scanner.yml +++ b/.github/workflows/oss-scanner.yml @@ -10,15 +10,6 @@ name: OSS Scanner # disk the ~20 GB scanner image needs, and the image brings its own toolchain. on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - ".oss-scanner/**" - - "sh/oss-scanner.sh" - - ".github/workflows/oss-scanner.yml" permissions: contents: read diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index ff578659159..845f33f8bd5 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -1,7 +1,7 @@ name: Platform -# The native macOS and Windows gates run nightly on main (nightly.yml calls this) -# and on every release PR, without path filters, so release can require their names. +# The native macOS and Windows gates run nightly on main and on every release +# PR (nightly.yml and release-gate.yml call this), selecting suites from the diff. permissions: contents: read @@ -22,12 +22,6 @@ on: - "justfile" - "**/justfile" - ".github/**" - pull_request: - branches: [release] - # `closed` only cancels an in-flight run. `edited` runs the gates when a main - # PR is retargeted to release. Any edit reruns them, since a skipped rerun - # would post a passing context over a failed one. - types: [opened, synchronize, reopened, edited, closed] workflow_call: inputs: benchmarks: @@ -52,10 +46,7 @@ env: jobs: scope: - # Require this check too: a failed selector otherwise skips its dependents, - # and GitHub accepts skipped jobs as successful required checks. name: Platform scope - if: github.event.action != 'closed' runs-on: ubuntu-24.04-arm timeout-minutes: 10 outputs: diff --git a/.github/workflows/release-dart-ffi.yml b/.github/workflows/release-dart-ffi.yml index 08ef2d0507c..c8510df8cd4 100644 --- a/.github/workflows/release-dart-ffi.yml +++ b/.github/workflows/release-dart-ffi.yml @@ -12,20 +12,6 @@ on: # workflow this way so a broken build surfaces before the tag is cut. workflow_call: workflow_dispatch: - # Packaging changes only, like the other FFI release workflows. A change to - # the moq-ffi sources is covered on every platform by cheaper gates: check.yml - # compiles it and regenerates the Dart bindings, platform.yml compiles it for - # macOS, Windows, and iOS, and nightly runs this full matrix. Main PRs skip - # it; `edited` catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - ".github/workflows/release-dart-ffi.yml" - - ".github/workflows/release-ffi.yml" - - "dart/moq_ffi/**" - - "sh/dart/**" - - "rs/moq-ffi/build.sh" permissions: contents: read diff --git a/.github/workflows/release-dart.yml b/.github/workflows/release-dart.yml index b5eecbac28b..825c03395e3 100644 --- a/.github/workflows/release-dart.yml +++ b/.github/workflows/release-dart.yml @@ -11,14 +11,6 @@ on: # Dry-run: check the source package, publish nothing. Only a tag push # publishes, so nightly.yml calls this on main in place of main PRs. workflow_call: - # `edited` catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - ".github/workflows/release-dart.yml" - - "dart/moq/**" - - "sh/dart/**" permissions: contents: read diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml new file mode 100644 index 00000000000..22e3edbf648 --- /dev/null +++ b/.github/workflows/release-gate.yml @@ -0,0 +1,487 @@ +name: Release gate + +# Every optional workflow a PR into `release` must pass, behind one required +# context. Main PRs run none of them (nightly.yml covers main), and `release` +# has no merge queue, so its ruleset requires `Check` and `Release gate`. +# +# Each workflow keeps the paths that select it, here rather than in its own +# trigger, so a workflow the diff skips still reports through this gate instead +# of never reporting and blocking the PR forever. +permissions: + contents: read + +on: + pull_request: + branches: [release] + # `closed` only cancels an in-flight run. `edited` runs the gate when a main + # PR is retargeted to release. Any edit reruns it, since a skipped rerun + # would post a passing context over a failed one. + types: [opened, synchronize, reopened, edited, closed] + +concurrency: + group: release-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + scope: + name: Scope + if: github.event.action != 'closed' + runs-on: ubuntu-24.04-arm + timeout-minutes: 10 + outputs: + android: ${{ steps.filter.outputs.android }} + audio-quality: ${{ steps.filter.outputs.audio-quality }} + cpp: ${{ steps.filter.outputs.cpp }} + interop: ${{ steps.filter.outputs.interop }} + moq-sh: ${{ steps.filter.outputs.moq-sh }} + obs: ${{ steps.filter.outputs.obs }} + oss-scanner: ${{ steps.filter.outputs.oss-scanner }} + release-dart: ${{ steps.filter.outputs.release-dart }} + release-dart-ffi: ${{ steps.filter.outputs.release-dart-ffi }} + release-go-ffi: ${{ steps.filter.outputs.release-go-ffi }} + release-js: ${{ steps.filter.outputs.release-js }} + release-kt-lib: ${{ steps.filter.outputs.release-kt-lib }} + release-swift-lib: ${{ steps.filter.outputs.release-swift-lib }} + runner-vm: ${{ steps.filter.outputs.runner-vm }} + ts: ${{ steps.filter.outputs.ts }} + uring: ${{ steps.filter.outputs.uring }} + video: ${{ steps.filter.outputs.video }} + wasm: ${{ steps.filter.outputs.wasm }} + x11: ${{ steps.filter.outputs.x11 }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + # Diff with git against the merge base: the REST file list a token would + # use stops at 3,000 files, which a cut can exceed. + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + token: "" + base: ${{ github.base_ref }} + filters: | + android: + - "rs/moq-video/**" + - "rs/moq-ffi/**" + - "rs/moq-tokio/**" + - "rs/moq-mux/**" + - "rs/moq-net/**" + - "rs/moq-nvenc/**" + - "rs/moq-loc/**" + - "rs/moq-msf/**" + - "rs/moq-json/**" + - "rs/moq-flate/**" + - "rs/moq-pattern/**" + - "rs/hang/**" + - "rs/kio/**" + - "rs/justfile" + - "justfile" + - ".cargo/config.toml" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - ".github/workflows/android.yml" + audio-quality: + - "js/watch/**" + # The container consumer decides what a replay delivers, waits for, and skips, on the + # groups, tracks, and signals underneath it. + - "js/hang/**" + - "js/net/**" + - "js/signals/**" + - "test/audio-quality/**" + - "test/lib/**" + - "test/justfile" + - ".github/workflows/audio-quality.yml" + cpp: + - "cpp/moq/**" + - "cpp/justfile" + - "rs/moq-ffi/**" + - "sh/cpp/**" + - "rs/moq-c/native-libs/**" + - "doc/lib/cpp/**" + - "doc/lib/samples.sh" + - "Cargo.lock" + - ".github/workflows/cpp.yml" + # A backported change to the interop harness itself; nightly covers main. + interop: + - "test/lib/**" + - "test/p2p/**" + - "js/p2p/**" + - "test/interop/**" + - "test/max-age/**" + - "test/portrait/**" + - "rs/moq-tokio/tests/integration/max_age.rs" + # The interop job also depends on the recipe that invokes it and the + # workspace membership that links the JS clients to source. + - "test/justfile" + - "package.json" + # The Go client builds against the modules this script stages. + - "sh/go/**" + - "cpp/moq/**" + - ".github/workflows/interop.yml" + moq-sh: + - "infra/moq-sh/**" + - ".github/workflows/moq-sh.yml" + obs: + - "cpp/obs/**" + - "cpp/moq/**" + - "sh/obs/**" + - "rs/moq-ffi/**" + - "rs/moq-c/**" + - "doc/lib/c/**" + - "doc/lib/samples.sh" + - "rs/**/Cargo.toml" + - "rs/**/build.rs" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "flake.nix" + - "flake.lock" + - ".github/workflows/obs.yml" + oss-scanner: + - ".oss-scanner/**" + - "sh/oss-scanner.sh" + - ".github/workflows/oss-scanner.yml" + release-dart: + - ".github/workflows/release-dart.yml" + - "dart/moq/**" + - "sh/dart/**" + # Packaging changes only, like the other FFI release workflows. A + # change to the moq-ffi sources is covered on every platform by + # cheaper gates: Check compiles it and regenerates the Dart + # bindings, Platform compiles it for macOS, Windows, and iOS, and + # nightly runs this full matrix. + release-dart-ffi: + - ".github/workflows/release-dart-ffi.yml" + - ".github/workflows/release-ffi.yml" + - "dart/moq_ffi/**" + - "sh/dart/**" + - "rs/moq-ffi/build.sh" + release-go-ffi: + - ".github/workflows/release-go-ffi.yml" + - ".github/workflows/release-ffi.yml" + - ".github/workflows/release-shared-ffi.yml" + - "sh/gh/ffi*" + - "sh/gh/release.sh" + - "go/ffi/**" + - "sh/go/package-ffi.sh" + - "sh/go/publish-ffi.sh" + - "rs/moq-ffi/build.sh" + release-js: + - "js/**" + - ".github/workflows/release-js.yml" + release-kt-lib: + - "kt/moq/**" + - "kt/gradle.properties" + - "kt/build.gradle.kts" + - "kt/settings.gradle.kts" + - ".github/workflows/release-kt-lib.yml" + - "sh/gh/release.sh" + - "rs/moq-ffi/build.sh" + release-swift-lib: + - "swift/VERSION" + - "swift/Package.swift.template" + - "swift/Sources/Moq/**" + - "swift/Tests/**" + - "sh/swift/package.sh" + - "sh/swift/publish.sh" + - "sh/swift/verify.sh" + - ".github/workflows/release-swift-lib.yml" + - "sh/gh/release.sh" + runner-vm: + - "nix/**" + - "flake.nix" + - "flake.lock" + - ".github/justfile" + - ".github/workflows/runner-vm.yml" + ts: + - "test/ts/**" + - "test/lib/**" + - "test/justfile" + # Exactly the workspace crates run.sh builds; sh/gh/ts-paths.py fails Check on drift. + - "rs/hang/**" + - "rs/kio/**" + - "rs/moq-archive/**" + - "rs/moq-auth/**" + - "rs/moq-cli/**" + - "rs/moq-clock/**" + - "rs/moq-flate/**" + - "rs/moq-hls/**" + - "rs/moq-ice/**" + - "rs/moq-json/**" + - "rs/moq-loc/**" + - "rs/moq-msf/**" + - "rs/moq-mux/**" + - "rs/moq-net/**" + - "rs/moq-pattern/**" + - "rs/moq-quic/**" + - "rs/moq-relay/**" + - "rs/moq-rtc/**" + - "rs/moq-rtmp/**" + - "rs/moq-rtsp/**" + - "rs/moq-shaper/**" + - "rs/moq-sock/**" + - "rs/moq-srt/**" + - "rs/moq-stats/**" + - "rs/moq-tokio/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "flake.nix" + - "flake.lock" + - ".github/actions/rust-cache/**" + - ".github/workflows/ts.yml" + uring: + # The shared Rust selector walks reverse dependencies, including feature + # dependencies. Broad paths keep newly added shared crates covered. + - "rs/**" + - "sh/rs/**" + - "sh/changed.sh" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "justfile" + - ".cargo/**" + - ".config/nextest.toml" + - "flake.nix" + - "flake.lock" + - ".github/actions/**" + - ".github/workflows/uring.yml" + video: + - "js/video/**" + - "js/publish/src/preview.ts" + - "js/publish/src/element.ts" + - "test/video/**" + - "test/justfile" + - "package.json" + - "bun.lock" + - "flake.nix" + - "flake.lock" + - ".github/workflows/video.yml" + wasm: + - "rs/moq-wasm/**" + - "rs/moq-clock/**" + - "rs/moq-net/**" + - "rs/kio/**" + - "js/wasm/**" + - "test/wasm/**" + - "test/lib/**" + - "justfile" + - "js/justfile" + - "rs/justfile" + - "test/justfile" + - ".cargo/config.toml" + - "package.json" + - "bun.lock" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "flake.nix" + - "flake.lock" + - ".github/workflows/wasm.yml" + x11: + - "rs/moq-video/**" + - "rs/justfile" + - "Cargo.toml" + - "Cargo.lock" + - ".cargo/**" + - "flake.nix" + - "flake.lock" + - ".github/workflows/x11.yml" + + # Selects its native suites from the diff itself. + platform: + name: Platform + if: github.event.action != 'closed' + uses: ./.github/workflows/platform.yml + secrets: inherit + + features: + name: Features + if: github.event.action != 'closed' + uses: ./.github/workflows/features.yml + secrets: inherit + + android: + name: Android + needs: scope + if: needs.scope.outputs.android == 'true' + uses: ./.github/workflows/android.yml + + audio-quality: + name: Audio quality + needs: scope + if: needs.scope.outputs.audio-quality == 'true' + uses: ./.github/workflows/audio-quality.yml + + cpp: + name: C++ + needs: scope + if: needs.scope.outputs.cpp == 'true' + uses: ./.github/workflows/cpp.yml + + interop: + name: Interop + needs: scope + if: needs.scope.outputs.interop == 'true' + uses: ./.github/workflows/interop.yml + secrets: inherit + permissions: + contents: read + id-token: write + + moq-sh: + name: moq.sh + needs: scope + if: needs.scope.outputs.moq-sh == 'true' + uses: ./.github/workflows/moq-sh.yml + + obs: + name: OBS + needs: scope + if: needs.scope.outputs.obs == 'true' + uses: ./.github/workflows/obs.yml + secrets: inherit + + oss-scanner: + name: OSS Scanner + needs: scope + if: needs.scope.outputs.oss-scanner == 'true' + uses: ./.github/workflows/oss-scanner.yml + + runner-vm: + name: Runner VM + needs: scope + if: needs.scope.outputs.runner-vm == 'true' + uses: ./.github/workflows/runner-vm.yml + + ts: + name: TS + needs: scope + if: needs.scope.outputs.ts == 'true' + uses: ./.github/workflows/ts.yml + secrets: inherit + permissions: + contents: read + id-token: write + + uring: + name: io_uring + needs: scope + if: needs.scope.outputs.uring == 'true' + uses: ./.github/workflows/uring.yml + secrets: inherit + + video: + name: Video + needs: scope + if: needs.scope.outputs.video == 'true' + uses: ./.github/workflows/video.yml + + wasm: + name: WASM + needs: scope + if: needs.scope.outputs.wasm == 'true' + uses: ./.github/workflows/wasm.yml + secrets: inherit + + x11: + name: X11 capture + needs: scope + if: needs.scope.outputs.x11 == 'true' + uses: ./.github/workflows/x11.yml + secrets: inherit + + # The release workflows as dry-runs, as nightly.yml runs them: they gate + # publishing on a tag, a push, or (Kotlin) a dry-run input, and get no + # secrets. A caller grants every permission a nested job declares, skipped or + # not, so these carry the release-time grants that nothing here uses. + release-dart: + name: Release Dart + needs: scope + if: needs.scope.outputs.release-dart == 'true' + uses: ./.github/workflows/release-dart.yml + permissions: + contents: read + id-token: write + + release-dart-ffi: + name: Release Dart FFI + needs: scope + if: needs.scope.outputs.release-dart-ffi == 'true' + uses: ./.github/workflows/release-dart-ffi.yml + permissions: + contents: write + id-token: write + + release-go-ffi: + name: Release Go FFI + needs: scope + if: needs.scope.outputs.release-go-ffi == 'true' + uses: ./.github/workflows/release-go-ffi.yml + + release-js: + name: Release JS + needs: scope + if: needs.scope.outputs.release-js == 'true' + uses: ./.github/workflows/release-js.yml + permissions: + contents: read + id-token: write + + release-kt-lib: + name: Release Kotlin lib + needs: scope + if: needs.scope.outputs.release-kt-lib == 'true' + uses: ./.github/workflows/release-kt-lib.yml + permissions: + contents: read + actions: read + + release-swift-lib: + name: Release Swift Lib + needs: scope + if: needs.scope.outputs.release-swift-lib == 'true' + uses: ./.github/workflows/release-swift-lib.yml + # Its publisher check reads upstream run jobs. + permissions: + contents: read + actions: read + + gate: + # The one context the release ruleset requires. Skipped jobs pass it; a + # failed scope fails it, rather than skipping what it would have selected. + # `always()` also runs it in a cancelled run, so a rerun on `edited` cannot + # leave a skipped, passing context on the same commit while it runs. + name: Release gate + needs: + - scope + - platform + - features + - android + - audio-quality + - cpp + - interop + - moq-sh + - obs + - oss-scanner + - runner-vm + - ts + - uring + - video + - wasm + - x11 + - release-dart + - release-dart-ffi + - release-go-ffi + - release-js + - release-kt-lib + - release-swift-lib + if: always() && github.event.action != 'closed' + runs-on: ubuntu-24.04-arm + timeout-minutes: 5 + steps: + - name: Fail on a failed or cancelled workflow + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 diff --git a/.github/workflows/release-go-ffi.yml b/.github/workflows/release-go-ffi.yml index 8f10b1d0ace..09a7e490641 100644 --- a/.github/workflows/release-go-ffi.yml +++ b/.github/workflows/release-go-ffi.yml @@ -7,28 +7,13 @@ name: Release Go FFI on: # The tag coordinator supplies shared default-feature artifacts. Manual - # calls and Go PR dry-runs still build their own target subset. + # calls and release PR dry-runs still build their own target subset. workflow_call: inputs: reuse-native: type: boolean default: false workflow_dispatch: - # Main PRs skip it; nightly.yml runs it on main through release-shared-ffi.yml. - # `edited` catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - ".github/workflows/release-go-ffi.yml" - - ".github/workflows/release-ffi.yml" - - ".github/workflows/release-shared-ffi.yml" - - "sh/gh/ffi*" - - "sh/gh/release.sh" - - "go/ffi/**" - - "sh/go/package-ffi.sh" - - "sh/go/publish-ffi.sh" - - "rs/moq-ffi/build.sh" permissions: contents: read diff --git a/.github/workflows/release-js.yml b/.github/workflows/release-js.yml index b2305222548..29bbcb90de1 100644 --- a/.github/workflows/release-js.yml +++ b/.github/workflows/release-js.yml @@ -14,13 +14,6 @@ on: # JSR-only failures before they break a release on `release`. Only a push # publishes, so nightly.yml calls this on main in place of main PRs. workflow_call: - # `edited` catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - 'js/**' - - '.github/workflows/release-js.yml' # Only publishing runs share the serialized group. GitHub keeps a single pending # run per group and drops the older one, so a dry-run sharing it would cancel a diff --git a/.github/workflows/release-kt-lib.yml b/.github/workflows/release-kt-lib.yml index 5b315876d49..05776c8231e 100644 --- a/.github/workflows/release-kt-lib.yml +++ b/.github/workflows/release-kt-lib.yml @@ -30,19 +30,6 @@ on: - "kt/settings.gradle.kts" - ".github/workflows/release-kt-lib.yml" - "sh/gh/release.sh" - # Main PRs skip it; nightly.yml calls it as a dry-run on main. `edited` - # catches a main PR retargeted to release. - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "kt/moq/**" - - "kt/gradle.properties" - - "kt/build.gradle.kts" - - "kt/settings.gradle.kts" - - ".github/workflows/release-kt-lib.yml" - - "sh/gh/release.sh" - - "rs/moq-ffi/build.sh" # Manual trigger so a wrapper bump can be published once the bindings it # depends on land on Maven Central (the guard below skips otherwise). Only a # dispatch on `release` publishes. diff --git a/.github/workflows/release-swift-lib.yml b/.github/workflows/release-swift-lib.yml index d22c35b0cac..3148cd1d295 100644 --- a/.github/workflows/release-swift-lib.yml +++ b/.github/workflows/release-swift-lib.yml @@ -26,20 +26,6 @@ on: - "sh/swift/verify.sh" - ".github/workflows/release-swift-lib.yml" - "sh/gh/release.sh" - pull_request: - branches: [release] - # `edited` catches a main PR retargeted to release. - types: [opened, synchronize, reopened, edited] - paths: - - "swift/VERSION" - - "swift/Package.swift.template" - - "swift/Sources/Moq/**" - - "swift/Tests/**" - - "sh/swift/package.sh" - - "sh/swift/publish.sh" - - "sh/swift/verify.sh" - - ".github/workflows/release-swift-lib.yml" - - "sh/gh/release.sh" workflow_call: workflow_dispatch: workflow_run: diff --git a/.github/workflows/runner-vm.yml b/.github/workflows/runner-vm.yml index 7fb353058dc..ca59a0e14d0 100644 --- a/.github/workflows/runner-vm.yml +++ b/.github/workflows/runner-vm.yml @@ -2,15 +2,6 @@ name: Runner VM on: workflow_call: - pull_request: - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "nix/**" - - "flake.nix" - - "flake.lock" - - ".github/justfile" - - ".github/workflows/runner-vm.yml" permissions: contents: read @@ -21,7 +12,6 @@ concurrency: jobs: check: - if: github.event.action != 'closed' runs-on: ubuntu-24.04 timeout-minutes: 30 steps: diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 5f3d92cdacc..2cb761aef66 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -10,17 +10,10 @@ on: history_url: description: Seed the relay report history from this URL instead of the deployed site required: false - # On PRs that touch the harness itself (smoke.sh / token.sh / clients), run - # the matrix against the published packages to catch a broken cell before it - # merges. Published-package regressions still surface nightly; this guards - # changes to the test. Other PRs don't change what is published. - pull_request: - paths: - - "test/smoke/**" - - ".github/workflows/smoke.yml" - # Merges of the same: the build caches below (mbx, cargo binaries) only save - # from the default branch's push or dispatch runs, never from the nightly - # schedule, so this is what keeps them warm for pull requests. + # Merges that touch the harness itself (smoke.sh / token.sh / clients) run the + # matrix against the published packages. The build caches below (mbx, cargo + # binaries) only save from the default branch's push or dispatch runs, never + # from the nightly schedule, so this also keeps them warm. push: branches: [main] paths: diff --git a/.github/workflows/ts.yml b/.github/workflows/ts.yml index b562c579d02..77c132c2623 100644 --- a/.github/workflows/ts.yml +++ b/.github/workflows/ts.yml @@ -6,48 +6,7 @@ on: workflow_dispatch: schedule: - cron: "0 8 * * *" - pull_request: - # Main PRs skip it; the schedule above runs it on main. `edited` catches a - # main PR retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "test/ts/**" - - "test/lib/**" - - "test/justfile" - # Exactly the workspace crates run.sh builds; sh/gh/ts-paths.py fails Check on drift. - - "rs/hang/**" - - "rs/kio/**" - - "rs/moq-archive/**" - - "rs/moq-auth/**" - - "rs/moq-cli/**" - - "rs/moq-clock/**" - - "rs/moq-flate/**" - - "rs/moq-hls/**" - - "rs/moq-ice/**" - - "rs/moq-json/**" - - "rs/moq-loc/**" - - "rs/moq-msf/**" - - "rs/moq-mux/**" - - "rs/moq-net/**" - - "rs/moq-pattern/**" - - "rs/moq-quic/**" - - "rs/moq-relay/**" - - "rs/moq-rtc/**" - - "rs/moq-rtmp/**" - - "rs/moq-rtsp/**" - - "rs/moq-shaper/**" - - "rs/moq-sock/**" - - "rs/moq-srt/**" - - "rs/moq-stats/**" - - "rs/moq-tokio/**" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "flake.nix" - - "flake.lock" - - ".github/actions/rust-cache/**" - - ".github/workflows/ts.yml" + workflow_call: concurrency: group: ts-${{ github.ref }} diff --git a/.github/workflows/uring.yml b/.github/workflows/uring.yml index 80e35e1e788..36b149b511b 100644 --- a/.github/workflows/uring.yml +++ b/.github/workflows/uring.yml @@ -4,27 +4,9 @@ permissions: contents: read on: - pull_request: - # Main PRs skip it; nightly.yml's `rs uring` runs the whole suite on main. - # `edited` catches a main PR retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - # The shared Rust selector walks reverse dependencies, including feature - # dependencies. Broad paths keep newly added shared crates covered. - - "rs/**" - - "sh/rs/**" - - "sh/changed.sh" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "justfile" - - ".cargo/**" - - ".config/nextest.toml" - - "flake.nix" - - "flake.lock" - - ".github/actions/**" - - ".github/workflows/uring.yml" + # release-gate.yml calls it on release PRs; nightly.yml's `rs uring` runs the + # whole suite on main. + workflow_call: concurrency: group: uring-${{ github.ref }} @@ -33,7 +15,6 @@ concurrency: jobs: uring: name: io_uring - if: github.event.action != 'closed' # This image provides a 6.12+ kernel; MOQ_STRICT fails the tests if that changes. runs-on: ubuntu-24.04-arm timeout-minutes: 60 diff --git a/.github/workflows/video.yml b/.github/workflows/video.yml index ef6c8fc4937..431ba055041 100644 --- a/.github/workflows/video.yml +++ b/.github/workflows/video.yml @@ -16,24 +16,7 @@ on: workflow_dispatch: schedule: - cron: "0 8 * * *" - pull_request: - # Main PRs skip it; the schedule above runs it on main. `edited` catches a - # main PR retargeted to release. `closed` is here only so merging/closing a - # PR cancels its in-flight run via the concurrency group below; the job - # itself is skipped on close. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "js/video/**" - - "js/publish/src/preview.ts" - - "js/publish/src/element.ts" - - "test/video/**" - - "test/justfile" - - "package.json" - - "bun.lock" - - "flake.nix" - - "flake.lock" - - ".github/workflows/video.yml" + workflow_call: concurrency: group: video-${{ github.ref }} @@ -42,7 +25,6 @@ concurrency: jobs: video: name: Video - if: github.event.action != 'closed' runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 8d2b94aa1b4..2e168ecbe0d 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -34,34 +34,6 @@ permissions: on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. `closed` is here only so merging/closing a PR - # cancels its in-flight run via the concurrency group below; the job itself - # is skipped on close. - branches: [release] - types: [opened, synchronize, reopened, edited, closed] - paths: - - "rs/moq-wasm/**" - - "rs/moq-clock/**" - - "rs/moq-net/**" - - "rs/kio/**" - - "js/wasm/**" - - "test/wasm/**" - - "test/lib/**" - - "justfile" - - "js/justfile" - - "rs/justfile" - - "test/justfile" - - ".cargo/config.toml" - - "package.json" - - "bun.lock" - - "Cargo.toml" - - "Cargo.lock" - - "rust-toolchain.toml" - - "flake.nix" - - "flake.lock" - - ".github/workflows/wasm.yml" concurrency: group: wasm-${{ github.ref }} @@ -70,7 +42,6 @@ concurrency: jobs: wasm: name: WASM - if: github.event.action != 'closed' # ARM64 like check.yml; the harness builds the relay natively, which the # merge queue's Check and Test publish. runs-on: ubuntu-24.04-arm diff --git a/.github/workflows/x11.yml b/.github/workflows/x11.yml index 5544454a065..319e8b02bef 100644 --- a/.github/workflows/x11.yml +++ b/.github/workflows/x11.yml @@ -4,20 +4,6 @@ name: X11 capture # Correctness is gated; paired timings are retained without a runner-speed threshold. on: workflow_call: - pull_request: - # Main PRs skip it; nightly.yml runs it on main. `edited` catches a main PR - # retargeted to release. - branches: [release] - types: [opened, synchronize, reopened, edited] - paths: - - "rs/moq-video/**" - - "rs/justfile" - - "Cargo.toml" - - "Cargo.lock" - - ".cargo/**" - - "flake.nix" - - "flake.lock" - - ".github/workflows/x11.yml" permissions: contents: read diff --git a/quest/m0/ci/README.md b/quest/m0/ci/README.md index aeab734aa84..5bbf504aa17 100644 --- a/quest/m0/ci/README.md +++ b/quest/m0/ci/README.md @@ -73,7 +73,7 @@ The historical samples locate candidates; they are not controlled benchmarks. ## Required -- [Require the release gates](/quest/m0/ci/release-gates.md) - release PRs require platform and the optional workflows; main requires only the Linux gate +- [Require the release gate](/quest/m0/ci/release-gates.md) - condition: the next cut carries `release-gate.yml`; then release PRs require it - [CI pipeline audit](/quest/m0/ci/audit.md) - every queue and nightly job earns its minutes, and the Rust cache reuses unchanged crates - [Pin Quest's gate](/quest/m0/ci/quest-gate-pin.md) - make shared command admission available - [Share local check capacity](/quest/m0/ci/local-gate.md) - gate expensive phases and bound internal fanout diff --git a/quest/m0/ci/mbx-r2-verify.md b/quest/m0/ci/mbx-r2-verify.md index ec4e950124f..b257bd8a28d 100644 --- a/quest/m0/ci/mbx-r2-verify.md +++ b/quest/m0/ci/mbx-r2-verify.md @@ -26,5 +26,5 @@ closes this: ## Related -- [Require the release gates](/quest/m0/ci/release-gates.md) - the macOS and Windows jobs this cache serves +- [Require the release gate](/quest/m0/ci/release-gates.md) - the macOS and Windows jobs this cache serves - [Verify the Nix cache](/quest/m0/ci/nix-cache-verify.md) - the same R2 and Environment bootstrap for Nix diff --git a/quest/m0/ci/release-gates.md b/quest/m0/ci/release-gates.md index 3358b6e587a..1d6839625b7 100644 --- a/quest/m0/ci/release-gates.md +++ b/quest/m0/ci/release-gates.md @@ -1,37 +1,29 @@ -# [XS] Require the release gates +# [XS] Require the release gate ## Goal -PRs into `release` (cuts and backports) cannot merge until the platform and -optional workflows pass, and main's merge queue requires only the Linux gate. -A maintainer edits the rulesets; the implementation PRs make no ruleset -changes. +Condition: the next cut carries `.github/workflows/release-gate.yml` to +`release`. Check with `git show origin/release:.github/workflows/release-gate.yml`. + +Then the maintainer adds `Release gate` from the GitHub Actions integration +(15368) to release ruleset 24392632, beside `Check`, so PRs into `release` +cannot merge until the platform and optional workflows pass. Main ruleset +2420853 already requires only `Check`. ## Plan Decided 2026-10-19: expensive and optional checks are required on `release` -and run only nightly on `main`. This supersedes the 2026-10-14 plan to -require `Platform scope`, `macOS` and `Windows` in main's merge queue, which -never got activated. +and run only nightly on `main`. -- Main ruleset 2420853 requires `Check` and `Test` from the GitHub Actions - integration (15368) and nothing else. The one-pass workflow - folds them into one job that keeps the name `Check`. Remove `Test` from the - ruleset just before that PR is enqueued. Removing a context only loosens - the gate, and groups built earlier still report both, so nothing waits on a - context the workflow stops reporting. -- Release ruleset 24392632 has no merge queue and requires `Check` and - `Test`. In the same edit as the cut that carries the one-pass workflow to - `release`, remove `Test` there too, and add `Platform scope`, `macOS`, - `Windows`, and the optional workflows that trigger only on release PRs - (`sh/gh/queue.test.ts` lists the policy). Only require a context that every release PR reports: a workflow with - path filters never reports on an unrelated diff and would block forever, - so it needs an always-run wrapper job first (the `platform.yml` pattern). -- Require `Platform scope` too: a failed selector skips its OS dependents, - and skipped jobs alone satisfy required checks. -- A release PR runs the workflows merged with `release`, so wait until the - next cut carries these workflows there. Before that, backports would wait - forever on contexts the old workflows never report. +- `release-gate.yml` calls Platform, Features, and every path-selected optional + workflow, and its `Release gate` job fails when any of them fails or is + cancelled. Skipped workflows pass it, so one context replaces a list of job + names that each rename would break. `sh/gh/queue.test.ts` holds the policy. +- A release PR runs the workflows merged with `release`, so a PR opened before + the cut never reports `Release gate` and would block once it is required. + Add the context right after the cut merges, then edit the title of each open + release PR; `edited` reruns the gate. +- Before then, release PRs still run the gate; it just does not block. - Measured 2026-10-14 to 10-16, for sizing the release PR wait: of 100 queue macOS jobs, runner wait was p50 0.2, p90 33 and max 83 minutes. Nightly and releases share the five-job macOS cap. diff --git a/sh/dispatch.sh b/sh/dispatch.sh index bba969ad532..87dbd3a37a8 100755 --- a/sh/dispatch.sh +++ b/sh/dispatch.sh @@ -92,7 +92,7 @@ declare -A scope=( [flake]='(^rs/|^Cargo\.(toml|lock)$|^flake\.lock$|\.nix$)' # ts.yml's crate filters against the closure test/ts/run.sh builds, which # moves only with a manifest, the lockfile, or the harness's packages. - [ts_paths]='^(rs/.*Cargo\.toml$|Cargo\.(toml|lock)$|test/ts/run\.sh$|\.github/workflows/ts\.yml$|sh/gh/ts-paths\.py$)' + [ts_paths]='^(rs/.*Cargo\.toml$|Cargo\.(toml|lock)$|test/ts/run\.sh$|\.github/workflows/release-gate\.yml$|sh/gh/ts-paths\.py$)' [markdown]='' [shell]='' [toml]='' diff --git a/sh/gh/queue.test.ts b/sh/gh/queue.test.ts index f3f91c6af53..9ba06b8eb38 100644 --- a/sh/gh/queue.test.ts +++ b/sh/gh/queue.test.ts @@ -1,13 +1,14 @@ // Exercise the event policy: main PRs run no cloud CI, the merge queue runs only -// Check, and release PRs keep everything since release has no queue. +// Check, and release PRs keep everything, behind Check and the Release gate, +// since release has no queue. import assert from "node:assert/strict"; import { readdir } from "node:fs/promises"; const workflow = async (name: string) => Bun.YAML.parse(await Bun.file(`.github/workflows/${name}.yml`).text()); const check = await workflow("check"); +const gate = await workflow("release-gate"); const platform = await workflow("platform"); const dart = await workflow("release-dart-ffi"); -const swift = await workflow("release-swift-lib"); const nightly = await workflow("nightly"); function condition(expression: string, event: string, base: string, selected = true, action = "opened"): unknown { @@ -39,24 +40,110 @@ assert.equal(check.concurrency.group, "check-${{ github.ref }}"); assert.equal(check.concurrency["cancel-in-progress"], true); // Every other workflow stays out of main PRs and main's merge groups: optional -// checks run nightly on main and on release PRs, retargeted ones included. +// checks run nightly on main, and on release PRs through the Release gate. const files = (await readdir(".github/workflows")).filter((file) => file.endsWith(".yml")); for (const file of files) { const name = file.replace(/\.yml$/, ""); - if (name === "check") continue; + if (name === "check" || name === "release-gate") continue; const config = await workflow(name); assert.equal(config.on.merge_group, undefined, `${file} must not run in the merge queue`); - if (!config.on.pull_request) continue; - assert.deepEqual(config.on.pull_request.branches, ["release"], `${file} must skip main PRs`); - assert.ok(config.on.pull_request.types.includes("edited"), `${file} must rerun when retargeted to release`); + assert.equal(config.on.pull_request, undefined, `${file} must run on release PRs through release-gate.yml`); } -// Platform reports on every release PR without path filters, so release can -// require its names, and Nightly runs it on main. -assert.equal(platform.on.pull_request.paths, undefined, "required release PR contexts must always report"); -assert.equal(condition(platform.jobs.scope.if, "pull_request", "release"), true); -assert.equal(condition(platform.jobs.scope.if, "pull_request", "release", true, "edited"), true); -assert.equal(condition(platform.jobs.scope.if, "schedule", ""), true); +// Release PRs report one context for every optional workflow, retargeted PRs included. +assert.deepEqual(Object.keys(gate.on), ["pull_request"]); +assert.deepEqual(gate.on.pull_request.branches, ["release"]); +assert.equal(gate.on.pull_request.paths, undefined, "the required release PR context must always report"); +assert.ok(gate.on.pull_request.types.includes("edited"), "retargeting to release must rerun"); +assert.ok(gate.on.pull_request.types.includes("closed"), "closing must cancel an in-flight run"); + +const calls = Object.entries(gate.jobs).filter(([, job]) => (job as { uses?: string }).uses) as [ + string, + { uses: string; if: string }, +][]; +for (const [id, job] of calls) { + const called = await workflow(job.uses.replace(/^\.\/\.github\/workflows\/|\.yml$/g, "")); + assert.ok("workflow_call" in called.on, `${job.uses} must be callable`); + assert.equal(id, job.uses.replace(/^\.\/\.github\/workflows\/|\.yml$/g, ""), `${id} must be named after its workflow`); +} +// Every workflow that used to run on release PRs blocks a release now. +assert.deepEqual(calls.map(([id]) => id).sort(), [ + "android", + "audio-quality", + "cpp", + "features", + "interop", + "moq-sh", + "obs", + "oss-scanner", + "platform", + "release-dart", + "release-dart-ffi", + "release-go-ffi", + "release-js", + "release-kt-lib", + "release-swift-lib", + "runner-vm", + "ts", + "uring", + "video", + "wasm", + "x11", +]); + +// Platform and Features select their own work; every other call runs when its filter matches. +const filters = Object.keys(Bun.YAML.parse(gate.jobs.scope.steps.at(-1).with.filters) as object); +const unfiltered = ["features", "platform"]; +for (const [id, job] of calls) { + if (unfiltered.includes(id)) { + assert.equal(condition(job.if, "pull_request", "release"), true); + assert.equal(condition(job.if, "pull_request", "release", true, "closed"), false); + continue; + } + assert.equal(job.if, `needs.scope.outputs.${id} == 'true'`); + assert.equal(gate.jobs.scope.outputs[id], `\${{ steps.filter.outputs.${id} }}`); +} +assert.deepEqual( + filters.sort(), + calls + .map(([id]) => id) + .filter((id) => !unfiltered.includes(id)) + .sort(), +); +assert.deepEqual(Object.keys(gate.jobs.scope.outputs).sort(), filters.sort()); +// The REST file list stops at 3,000 files, so the filter diffs with git. +assert.equal(gate.jobs.scope.steps.at(-1).with.token, ""); +assert.equal(gate.jobs.scope.steps[0].with["fetch-depth"], 0); + +// The gate fails on any failure or cancellation, a failed scope included, and +// passes skipped workflows. It runs even in a cancelled run, so an `edited` +// rerun never leaves a skipped, passing context on the commit. +const release = gate.jobs.gate; +assert.equal(release.name, "Release gate"); +assert.deepEqual( + [...release.needs].sort(), + Object.keys(gate.jobs) + .filter((id) => id !== "gate") + .sort(), +); +function verdict(expression: string, action: string, results: string[]): unknown { + const source = expression + .replace(/always\(\)/g, "true") + .replace(/contains\(needs\.\*\.result, '(\w+)'\)/g, "results.includes('$1')"); + return new Function("github", "results", `return (${source});`)({ event: { action } }, results); +} +assert.equal(verdict(release.if, "synchronize", ["cancelled"]), true); +assert.equal(verdict(release.if, "closed", ["success"]), false); +const fail = release.steps.find((step: { run?: string }) => step.run === "exit 1"); +for (const [results, failed] of [ + [["success", "skipped"], false], + [["success", "failure"], true], + [["skipped", "cancelled"], true], +] as const) { + assert.equal(verdict(fail.if, "synchronize", [...results]), failed, `gate over ${results}`); +} + +// Platform selects its native suites from the diff, and Nightly runs it on main. assert.deepEqual(Object.keys(platform.jobs).sort(), ["macos", "scope", "windows"]); // The selector runs before any toolchain, through the same query as Check. assert.equal(platform.jobs.scope.steps.at(-1).run, 'just ci-select-platform "$CI_BASE" >> "$GITHUB_OUTPUT"'); @@ -85,17 +172,14 @@ assert.deepEqual((await workflow("obs")).on.push.branches, ["release"]); assert.equal((await workflow("cpp")).jobs.msvc, undefined); assert.deepEqual((await workflow("moq-sh")).jobs.test.strategy.matrix.os, ["ubuntu-latest"]); assert.equal(await Bun.file(".github/workflows/swift.yml").exists(), false); -assert.deepEqual(swift.on.pull_request.branches, ["release"]); // Release Dart FFI only runs on release PRs and nightly, so it packages every platform. const targets = JSON.parse(dart.jobs.ffi.with.targets); assert.ok(targets.some((target: string) => target.includes("-apple-"))); assert.ok(targets.some((target: string) => target.includes("-windows-"))); -console.log("queue event policy passed"); // Neither the feature matrix nor the optimized WASM build shares the queue's // default-feature test artifacts, but both must block a release. const features = await workflow("features"); assert.deepEqual(features.jobs.features.strategy.matrix.recipe, ["rs features", "js wasm"]); -assert.equal(condition(features.jobs.features.if, "pull_request", "release", true, "closed"), false); -assert.equal(condition(features.jobs.features.if, "workflow_call", ""), true); +console.log("queue event policy passed"); diff --git a/sh/gh/ts-paths.py b/sh/gh/ts-paths.py index 7a918e4792a..3da3832d187 100755 --- a/sh/gh/ts-paths.py +++ b/sh/gh/ts-paths.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Fail when ts.yml's crate path filters differ from the workspace crates test/ts/run.sh builds. +"""Fail when release-gate.yml's TS crate path filters differ from the workspace crates test/ts/run.sh builds. The filter covers exactly the activated closure of run.sh's `-p` packages with their default features: a crate outside it must not trigger the expensive workflow, and a @@ -13,7 +13,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] -WORKFLOW = ROOT / ".github/workflows/ts.yml" +WORKFLOW = ROOT / ".github/workflows/release-gate.yml" HARNESS = ROOT / "test/ts/run.sh" # ts.yml's ubuntu-latest runner, so target-specific dependencies resolve the same on any host. TARGET = "x86_64-unknown-linux-gnu" @@ -35,6 +35,14 @@ def built_dirs(tree: str, metadata: dict) -> set[str]: return {members[key] for key in (tuple(line.split()[:2]) for line in tree.splitlines()) if key in members} +def ts_filter(workflow: str) -> str: + """The `ts:` entry of the gate's paths filter, apart from the other workflows' crates.""" + match = re.search(r"^( +)ts:\n((?:\1 +.*\n)+)", workflow, re.MULTILINE) + if not match: + sys.exit(f"error: no `ts:` filter in {WORKFLOW.relative_to(ROOT)}") + return match.group(2) + + def cargo(*args: str) -> str: return subprocess.run(["cargo", *args], cwd=ROOT, check=True, stdout=subprocess.PIPE, text=True).stdout @@ -49,7 +57,7 @@ def main() -> None: built = built_dirs(tree, json.loads(cargo("metadata", "--locked", "--no-deps", "--format-version", "1"))) # Every `rs//**` filter, even one whose crate is gone; other filters are the harness's own inputs. - filtered = set(re.findall(r'^\s+- "(rs/[^"*]+)/\*\*"$', WORKFLOW.read_text(), re.MULTILINE)) + filtered = set(re.findall(r'^\s+- "(rs/[^"*]+)/\*\*"$', ts_filter(WORKFLOW.read_text()), re.MULTILINE)) missing, extra = sorted(built - filtered), sorted(filtered - built) for path in missing: diff --git a/sh/gh/ts-paths.test.py b/sh/gh/ts-paths.test.py index 7ac0de87eec..0e72fc4b7bb 100644 --- a/sh/gh/ts-paths.test.py +++ b/sh/gh/ts-paths.test.py @@ -42,5 +42,25 @@ def test_parentheses(self): self.check("/repo (copy)", "/") +class TsFilterTest(unittest.TestCase): + def test_only_the_ts_entry(self): + workflow = "".join(f"{line}\n" for line in ( + " filters: |", + " obs:", + ' - "rs/moq-c/**"', + " ts:", + ' - "test/ts/**"', + " # A comment.", + ' - "rs/moq-net/**"', + " uring:", + ' - "rs/**"', + )) + self.assertEqual(module.ts_filter(workflow), ' - "test/ts/**"\n # A comment.\n - "rs/moq-net/**"\n') + + def test_missing(self): + with self.assertRaises(SystemExit): + module.ts_filter(" obs:\n - \"rs/moq-c/**\"\n") + + if __name__ == "__main__": unittest.main()