From 06deaeb35067576c680f4f8f3ac1c7c6163f97f7 Mon Sep 17 00:00:00 2001 From: Tyler Jang Date: Thu, 27 Aug 2026 16:22:47 +0000 Subject: [PATCH 1/3] ci: add a branch-protection fan-in gate per workflow GitHub evaluates a job's `if:` before expanding its matrix, so a matrix job skipped at job level reports one check run under the unexpanded name -- `Build CLI for ${{ matrix.platform.target }}` rather than one context per platform. Any per-leg context required by branch protection then never reports and the PR hangs on "Expected -- Waiting for status to be reported". Each PR workflow now ends in a `gate` job that always runs and passes when every job it needs finished success-or-skipped. That gives branch protection one stable context per workflow instead of 12 hand-typed matrix-leg names that also break on any matrix edit (runner labels and Xcode versions are currently baked into them). Each gate's `needs` lists the jobs that are required checks today, so the required set is preserved rather than widened. It also removes a latent ambiguity: the required context `build` currently matches the `build` job in both ruby.yml and wasm.yml. No behavior change on its own -- the gate only aggregates results. --- .github/workflows/pull_request.yml | 29 ++++++++++++++++++++++++++++ .github/workflows/pyo3.yml | 31 ++++++++++++++++++++++++++++++ .github/workflows/ruby.yml | 30 +++++++++++++++++++++++++++++ .github/workflows/wasm.yml | 29 ++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 9be9e8a3..29614a10 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -301,3 +301,32 @@ jobs: - name: Setup and build wasm uses: ./.github/actions/setup_build_wasm + + gate: + name: PR Gate + runs-on: ubuntu-latest + # Branch-protection fan-in: one stable required context for this whole workflow. + # A matrix job skipped by its job-level `if:` reports a single check run under the + # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is + # never evaluated -- so per-leg contexts never report and required checks hang on + # "Expected". This job always runs, so its name always reports. + if: always() + needs: [build_release, test, trunk_check_runner, build_pyo3, build_wasm] + steps: + - name: Check fan-in results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + shell: bash + run: | + read -ra results <<<"${RESULTS}" + for result in "${results[@]}"; do + case "${result}" in + # A skipped job is a pass: it was deliberately not run for this change. + success | skipped) ;; + *) + echo "::error::a needed job reported '${result}'" + exit 1 + ;; + esac + done + echo "needed jobs all passed or were skipped: ${RESULTS}" diff --git a/.github/workflows/pyo3.yml b/.github/workflows/pyo3.yml index 4eb7cd43..baf7c6d0 100644 --- a/.github/workflows/pyo3.yml +++ b/.github/workflows/pyo3.yml @@ -300,3 +300,34 @@ jobs: - name: Annotate workflow with S3 reference run: | echo "::notice title=Published bindings::context-py bindings published to S3 at ${{ needs.get-date-sha.outputs.reference }}" + + gate: + name: context-py Gate + runs-on: ubuntu-latest + # Branch-protection fan-in: one stable required context for this whole workflow. + # A matrix job skipped by its job-level `if:` reports a single check run under the + # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is + # never evaluated -- so per-leg contexts never report and required checks hang on + # "Expected". This job always runs, so its name always reports. + # linux-pytest is omitted: it is not a required check today, and this job + # preserves that set rather than widening it. + if: always() + needs: [linux, macos, sdist] + steps: + - name: Check fan-in results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + shell: bash + run: | + read -ra results <<<"${RESULTS}" + for result in "${results[@]}"; do + case "${result}" in + # A skipped job is a pass: it was deliberately not run for this change. + success | skipped) ;; + *) + echo "::error::a needed job reported '${result}'" + exit 1 + ;; + esac + done + echo "needed jobs all passed or were skipped: ${RESULTS}" diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 66360420..309042a8 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -204,3 +204,33 @@ jobs: platform: x86_64-linux artifact-pattern: "" knapsack-pro-test-suite-token-rspec: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC }} + + gate: + name: rspec Gate + runs-on: ubuntu-latest + # Branch-protection fan-in: one stable required context for this whole workflow. + # A matrix job skipped by its job-level `if:` reports a single check run under the + # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is + # never evaluated -- so per-leg contexts never report and required checks hang on + # "Expected". This job always runs, so its name always reports. + # build-ruby-gem / test-ruby-gem-uploads are omitted: not required checks today. + if: always() + needs: [build] + steps: + - name: Check fan-in results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + shell: bash + run: | + read -ra results <<<"${RESULTS}" + for result in "${results[@]}"; do + case "${result}" in + # A skipped job is a pass: it was deliberately not run for this change. + success | skipped) ;; + *) + echo "::error::a needed job reported '${result}'" + exit 1 + ;; + esac + done + echo "needed jobs all passed or were skipped: ${RESULTS}" diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 53dd62b0..b79d55f6 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -64,3 +64,32 @@ jobs: if: "!cancelled() && github.event_name != 'pull_request'" run: | echo "::notice title=Published bindings::context-js bindings published to S3 at ${{ steps.get-date-sha.outputs.reference }}" + + gate: + name: context-js Gate + runs-on: ubuntu-latest + # Branch-protection fan-in: one stable required context for this whole workflow. + # A matrix job skipped by its job-level `if:` reports a single check run under the + # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is + # never evaluated -- so per-leg contexts never report and required checks hang on + # "Expected". This job always runs, so its name always reports. + if: always() + needs: [build] + steps: + - name: Check fan-in results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + shell: bash + run: | + read -ra results <<<"${RESULTS}" + for result in "${results[@]}"; do + case "${result}" in + # A skipped job is a pass: it was deliberately not run for this change. + success | skipped) ;; + *) + echo "::error::a needed job reported '${result}'" + exit 1 + ;; + esac + done + echo "needed jobs all passed or were skipped: ${RESULTS}" From 33b531b1082014d3c6e238fab5d81c49e3a5b934 Mon Sep 17 00:00:00 2001 From: Tyler Jang Date: Thu, 27 Aug 2026 16:26:55 +0000 Subject: [PATCH 2/3] ci: cap the fan-in gate jobs at 5 minutes The gate only reads `needs.*.result`, so anything beyond a few seconds means it is wedged rather than working -- and a wedged required check blocks merges until the default 6h job timeout expires. --- .github/workflows/pull_request.yml | 1 + .github/workflows/pyo3.yml | 1 + .github/workflows/ruby.yml | 1 + .github/workflows/wasm.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 29614a10..a11ffd92 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -305,6 +305,7 @@ jobs: gate: name: PR Gate runs-on: ubuntu-latest + timeout-minutes: 5 # Branch-protection fan-in: one stable required context for this whole workflow. # A matrix job skipped by its job-level `if:` reports a single check run under the # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is diff --git a/.github/workflows/pyo3.yml b/.github/workflows/pyo3.yml index baf7c6d0..e6f34ba3 100644 --- a/.github/workflows/pyo3.yml +++ b/.github/workflows/pyo3.yml @@ -304,6 +304,7 @@ jobs: gate: name: context-py Gate runs-on: ubuntu-latest + timeout-minutes: 5 # Branch-protection fan-in: one stable required context for this whole workflow. # A matrix job skipped by its job-level `if:` reports a single check run under the # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 309042a8..05601e99 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -208,6 +208,7 @@ jobs: gate: name: rspec Gate runs-on: ubuntu-latest + timeout-minutes: 5 # Branch-protection fan-in: one stable required context for this whole workflow. # A matrix job skipped by its job-level `if:` reports a single check run under the # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index b79d55f6..258ce70a 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -68,6 +68,7 @@ jobs: gate: name: context-js Gate runs-on: ubuntu-latest + timeout-minutes: 5 # Branch-protection fan-in: one stable required context for this whole workflow. # A matrix job skipped by its job-level `if:` reports a single check run under the # *unexpanded* name (`Test for ${{ matrix.platform.target }}`) because the matrix is From 7ef04cf90b79a883027a46bdc1bdb26b9ab73bef Mon Sep 17 00:00:00 2001 From: Tyler Jang Date: Thu, 27 Aug 2026 16:23:51 +0000 Subject: [PATCH 3/3] ci: gate PR jobs with trunk-io/dynamic-ci Adds the Dynamic CI Filter in pre-job mode to the four PR workflows (pull_request, pyo3, ruby, wasm): one upstream job asks Trunk for a per-job verdict and every downstream job gates on its output. Gating is scoped to pull_request explicitly -- every gate leads with `github.event_name != 'pull_request' ||`, so push (main), tags and workflow_dispatch run everything outright rather than relying on the skipped filter job's empty outputs. Unlike trunk2, TRUNK_PUBLIC_API_ADDRESS is left unset so the action talks to prod (api.trunk.io) with TRUNK_PROD_ORG_API_TOKEN -- this repo's merge queue and CI history are in prod, not staging. --- .github/workflows/pull_request.yml | 52 +++++++++++++++++++++++++++++- .github/workflows/pyo3.yml | 52 +++++++++++++++++++++++++++--- .github/workflows/ruby.yml | 40 ++++++++++++++++++++++- .github/workflows/wasm.yml | 24 ++++++++++++++ 4 files changed, 162 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index a11ffd92..2c830c25 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -18,8 +18,38 @@ env: CARGO_TERM_COLOR: always jobs: + # Trunk decides which jobs this PR needs. Fails open: no verdict means no + # output, so every gate below tests `!= 'false'`. Gating is scoped to + # pull_request -- push (main), tags and dispatch run everything outright. + dynamic-ci-filter: + name: Dynamic CI Filter + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'pull_request' + # Per-job outputs are set at runtime, so they are re-exported here by name. + outputs: + build_release: ${{ steps.ci-filter.outputs.build_release }} + test: ${{ steps.ci-filter.outputs.test }} + trunk_check_runner: ${{ steps.ci-filter.outputs.trunk_check_runner }} + build_pyo3: ${{ steps.ci-filter.outputs.build_pyo3 }} + build_wasm: ${{ steps.ci-filter.outputs.build_wasm }} + steps: + - name: Run Dynamic CI Filter + id: ci-filter + uses: trunk-io/dynamic-ci@v1 + with: + # Prod, not staging: this repo's merge queue lives in prod. + token: ${{ secrets.TRUNK_PROD_ORG_API_TOKEN }} + build_release: name: Build CLI for ${{ matrix.platform.target }} + needs: [dynamic-ci-filter] + # Also runs when `test` is wanted: it consumes this job's binary artifact. + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build_release != 'false' || + needs.dynamic-ci-filter.outputs.test != 'false') strategy: matrix: platform: @@ -97,7 +127,12 @@ jobs: test: name: Test for ${{ matrix.platform.target }} - needs: [build_release] + needs: [build_release, dynamic-ci-filter] + if: >- + !cancelled() && + needs.build_release.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.test != 'false') strategy: matrix: platform: @@ -245,6 +280,11 @@ jobs: trunk_check_runner: name: Trunk Check runner [linux] runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.trunk_check_runner != 'false') steps: - uses: actions/checkout@v4 @@ -279,6 +319,11 @@ jobs: build_pyo3: name: Build context-py runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build_pyo3 != 'false') steps: - uses: actions/checkout@v4 @@ -292,6 +337,11 @@ jobs: build_wasm: name: Build context-js (WASM) runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build_wasm != 'false') steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pyo3.yml b/.github/workflows/pyo3.yml index e6f34ba3..4732a812 100644 --- a/.github/workflows/pyo3.yml +++ b/.github/workflows/pyo3.yml @@ -19,6 +19,28 @@ permissions: id-token: write jobs: + # Trunk decides which jobs this PR needs. Fails open: no verdict means no + # output, so every gate below tests `!= 'false'`. Gating is scoped to + # pull_request -- push (main), tags and dispatch run everything outright. + dynamic-ci-filter: + name: Dynamic CI Filter + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'pull_request' + # Per-job outputs are set at runtime, so they are re-exported here by name. + outputs: + linux: ${{ steps.ci-filter.outputs.linux }} + linux-pytest: ${{ steps.ci-filter.outputs.linux-pytest }} + macos: ${{ steps.ci-filter.outputs.macos }} + sdist: ${{ steps.ci-filter.outputs.sdist }} + steps: + - name: Run Dynamic CI Filter + id: ci-filter + uses: trunk-io/dynamic-ci@v1 + with: + # Prod, not staging: this repo's merge queue lives in prod. + token: ${{ secrets.TRUNK_PROD_ORG_API_TOKEN }} + get-date-sha: runs-on: ubuntu-latest outputs: @@ -37,7 +59,14 @@ jobs: echo "sha=${GIT_SHA}" >> $GITHUB_OUTPUT echo "reference=${DATE}/${GIT_SHA}" >> $GITHUB_OUTPUT linux: - needs: [get-date-sha] + needs: [get-date-sha, dynamic-ci-filter] + # Also runs when `linux-pytest` is wanted: it consumes this job's wheels. + if: >- + !cancelled() && + needs.get-date-sha.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.linux != 'false' || + needs.dynamic-ci-filter.outputs.linux-pytest != 'false') runs-on: ${{ matrix.platform.runner }} strategy: fail-fast: false @@ -100,7 +129,12 @@ jobs: sha: ${{ needs.get-date-sha.outputs.sha }} linux-pytest: - needs: [linux] + needs: [linux, dynamic-ci-filter] + if: >- + !cancelled() && + needs.linux.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.linux-pytest != 'false') runs-on: ${{ matrix.platform.runner }} strategy: fail-fast: false @@ -218,7 +252,12 @@ jobs: directory: ./context-py macos: - needs: [get-date-sha] + needs: [get-date-sha, dynamic-ci-filter] + if: >- + !cancelled() && + needs.get-date-sha.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.macos != 'false') runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -264,7 +303,12 @@ jobs: date: ${{ needs.get-date-sha.outputs.date }} sha: ${{ needs.get-date-sha.outputs.sha }} sdist: - needs: [get-date-sha] + needs: [get-date-sha, dynamic-ci-filter] + if: >- + !cancelled() && + needs.get-date-sha.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.sdist != 'false') runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 05601e99..e3993627 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -14,8 +14,34 @@ permissions: contents: read jobs: + # Trunk decides which jobs this PR needs. Fails open: no verdict means no + # output, so every gate below tests `!= 'false'`. Gating is scoped to + # pull_request -- push (main), tags and dispatch run everything outright. + dynamic-ci-filter: + name: Dynamic CI Filter + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'pull_request' + # Per-job outputs are set at runtime, so they are re-exported here by name. + outputs: + build: ${{ steps.ci-filter.outputs.build }} + build-ruby-gem: ${{ steps.ci-filter.outputs.build-ruby-gem }} + test-ruby-gem-uploads: ${{ steps.ci-filter.outputs.test-ruby-gem-uploads }} + steps: + - name: Run Dynamic CI Filter + id: ci-filter + uses: trunk-io/dynamic-ci@v1 + with: + # Prod, not staging: this repo's merge queue lives in prod. + token: ${{ secrets.TRUNK_PROD_ORG_API_TOKEN }} + build: runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build != 'false') steps: - uses: actions/checkout@v4 @@ -88,6 +114,13 @@ jobs: build-ruby-gem: name: Build Ruby gem runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + # Also runs when `test-ruby-gem-uploads` is wanted: it consumes this gem. + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build-ruby-gem != 'false' || + needs.dynamic-ci-filter.outputs.test-ruby-gem-uploads != 'false') steps: - uses: actions/checkout@v4 @@ -147,7 +180,12 @@ jobs: test-ruby-gem-uploads: name: Test Ruby gem uploads runs-on: ubuntu-latest - needs: build-ruby-gem + needs: [build-ruby-gem, dynamic-ci-filter] + if: >- + !cancelled() && + needs.build-ruby-gem.result == 'success' && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.test-ruby-gem-uploads != 'false') steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 258ce70a..f77f8ace 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -14,8 +14,32 @@ permissions: contents: read jobs: + # Trunk decides which jobs this PR needs. Fails open: no verdict means no + # output, so every gate below tests `!= 'false'`. Gating is scoped to + # pull_request -- push (main), tags and dispatch run everything outright. + dynamic-ci-filter: + name: Dynamic CI Filter + runs-on: ubuntu-latest + timeout-minutes: 5 + if: github.event_name == 'pull_request' + # Per-job outputs are set at runtime, so they are re-exported here by name. + outputs: + build: ${{ steps.ci-filter.outputs.build }} + steps: + - name: Run Dynamic CI Filter + id: ci-filter + uses: trunk-io/dynamic-ci@v1 + with: + # Prod, not staging: this repo's merge queue lives in prod. + token: ${{ secrets.TRUNK_PROD_ORG_API_TOKEN }} + build: runs-on: ubuntu-latest + needs: [dynamic-ci-filter] + if: >- + !cancelled() && + (github.event_name != 'pull_request' || + needs.dynamic-ci-filter.outputs.build != 'false') steps: - uses: actions/checkout@v4