From df2cb7b918086989f17fb54bf29e5cff3fa2343e Mon Sep 17 00:00:00 2001 From: Torrey Betts Date: Sat, 24 Oct 2026 17:20:01 -0400 Subject: [PATCH] Prepare Motus CI for a merge queue Turning on a merge queue means requiring checks, and the workflow could not support that as it stood. Its trigger-level path filters meant a pull request that touched only docs never got the checks, so it could never merge or enter the queue, and the merge_group event takes no path filter at all. Requiring the per-OS ".NET (...)" checks would also fail on its own: a matrix job skipped at the job level reports one check under its unexpanded name, so the expanded names never arrive. The workflow now runs on push, pull_request and merge_group alike. A small first job lists the changed files with plain git, against the range each event carries, and the build and test jobs run only when the code paths the filters used to name were touched. Anything it cannot decide runs everything. A single CI job runs last on every event and passes only when every other job succeeded or was skipped, which makes it the one check to require. Signed-off-by: Torrey Betts --- .github/workflows/motus-ci.yml | 124 +++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 12 deletions(-) diff --git a/.github/workflows/motus-ci.yml b/.github/workflows/motus-ci.yml index 4ae7d53..c22c6bb 100644 --- a/.github/workflows/motus-ci.yml +++ b/.github/workflows/motus-ci.yml @@ -1,22 +1,16 @@ name: Motus CI +# Every event runs the workflow, and the `changes` job below decides whether the code paths were +# touched. A trigger-level `paths` filter cannot do that job once checks are required: a pull request +# that touches only docs would never get the checks, so it could never merge or enter the merge +# queue, and `merge_group` takes no path filter at all. on: push: branches: [main] - paths: - - "src/**" - - "tests/**" - - "samples/**" - - "Motus.sln" - - ".github/workflows/motus-ci.yml" pull_request: branches: [main] - paths: - - "src/**" - - "tests/**" - - "samples/**" - - "Motus.sln" - - ".github/workflows/motus-ci.yml" + merge_group: + types: [checks_requested] env: # The browser every leg tests against. Raise it deliberately, the way a dependency is raised, so a @@ -24,8 +18,82 @@ env: CHROME_VERSION: 149.0.7827.156 jobs: + # Decides from the changed files whether the jobs that build and test need to run. Each event + # carries its own range: a pull request is compared from where it left its base, a merge group + # from the tip of main it was built on, and a push from the commit main pointed at before it. + # Anything that cannot be decided runs everything, since a needless run costs minutes and a + # skipped one could let a broken change through. + changes: + name: Changes + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.diff.outputs.code }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + # Only the commits and trees are needed to list changed paths, so file contents stay on + # the server. + filter: blob:none + + - name: Diff the changed paths + id: diff + shell: bash + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + GROUP_BASE: ${{ github.event.merge_group.base_sha }} + GROUP_HEAD: ${{ github.event.merge_group.head_sha }} + PUSH_BEFORE: ${{ github.event.before }} + PUSH_AFTER: ${{ github.event.after }} + run: | + # The same paths the trigger filters named before this job replaced them. + code_paths=(src tests samples Motus.sln .github/workflows/motus-ci.yml) + + case "$EVENT" in + pull_request) base="$PR_BASE"; head="$PR_HEAD" ;; + merge_group) base="$GROUP_BASE"; head="$GROUP_HEAD" ;; + push) base="$PUSH_BEFORE"; head="$PUSH_AFTER" ;; + *) base=""; head="" ;; + esac + + # A push that creates the branch reports an all-zero `before`, which has nothing to + # compare against. One that is no longer reachable after a force push fails the diff below + # and lands in the same place. + if [ -z "$base" ] || [ -z "$head" ] || [[ "$base" =~ ^0+$ ]]; then + echo "No range to compare for '$EVENT', so everything runs." + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Three dots compare the head with the commit it shares with the base, so commits that + # landed on main meanwhile are not counted as this change's own. + range="$base...$head" + + # No rename detection: a file moved out of a code path changes that path as surely as one + # moved into it, and listing both sides says so. + if ! changed="$(git diff --name-only --no-renames "$range" -- "${code_paths[@]}")"; then + echo "Could not diff $range, so everything runs." + echo "code=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -n "$changed" ]; then + echo "Code paths changed in $range:" + echo "$changed" + echo "code=true" >> "$GITHUB_OUTPUT" + else + echo "No code paths changed in $range, so the build and test jobs are skipped." + echo "code=false" >> "$GITHUB_OUTPUT" + fi + dotnet: name: .NET (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.code == 'true' runs-on: ${{ matrix.os }} # A backstop above the step timeouts below. Without it a stalled run holds a runner for the # six-hour default, and the log of a job that never ends cannot be read until it does. @@ -378,3 +446,35 @@ jobs: name: firefox-integration-test-results path: TestResults/**/*.trx retention-days: 14 + + # The one check to require. A matrix job skipped at the job level reports a single check under its + # unexpanded name, `.NET (${{ matrix.os }})`, so requiring the per-OS names would block every + # change that skips them. This job runs on every event, whatever the others did, and passes only + # when each of them succeeded or was skipped on purpose. A failure or a cancellation anywhere, + # including in `changes`, fails it. + ci: + name: CI + if: always() + needs: [changes, dotnet, integration, integration-firefox] + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Check the jobs it needs + shell: bash + env: + CHANGES: ${{ needs.changes.result }} + DOTNET: ${{ needs.dotnet.result }} + INTEGRATION: ${{ needs.integration.result }} + INTEGRATION_FIREFOX: ${{ needs.integration-firefox.result }} + run: | + status=0 + for job in CHANGES DOTNET INTEGRATION INTEGRATION_FIREFOX; do + result="${!job}" + echo "$job: $result" + case "$result" in + success|skipped) ;; + *) status=1 ;; + esac + done + exit $status