From 58b0fc62e82441da1a0e1e0ac4506eb7b42f54a2 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 22 Sep 2026 13:52:37 +0200 Subject: [PATCH 1/3] ci: gate PRs on unit tests only and move integration to a daily run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four integration jobs move out of run_tests.yml into run_integration.yml, which now runs daily at 09:00 UTC and in the pre-tag gate, and nowhere near a pull request. The split needed no test changes: `make test` already excludes spec/integration/, and that directory is exactly what the four make targets cover. The unit lane declares no environment and takes no secrets, so a fork PR gets nothing and still goes green. Verified with no credentials present: 307 examples, 0 failures in 0.04s, rubocop clean, bundler-audit clean. The guard drops github.actor, which is the pusher rather than the PR author: clicking Update branch reattributes the merge commit to whoever clicked, and the skip would stop firing on the normal release path. The required status check on master becomes the 🧪 Tests aggregator. It has no matrix on purpose: a matrix job skipped by if: publishes one check run with the template unexpanded, so per-leg contexts could never be satisfied on a Release PR. Integration gates nothing, including the pre-tag run. A failure there would skip tagging while the Release PR is already merged carrying autorelease: pending, and nothing clears that label on its own. --- .github/workflows/ci.yml | 51 ++++++++- .github/workflows/release.yml | 9 ++ .github/workflows/run_integration.yml | 124 +++++++++++++++++++++ .github/workflows/run_tests.yml | 149 +------------------------- .github/workflows/scheduled_test.yml | 43 ++++++++ README.md | 16 +++ 6 files changed, 245 insertions(+), 147 deletions(-) create mode 100644 .github/workflows/run_integration.yml create mode 100644 .github/workflows/scheduled_test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 648867f..612cf4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,53 @@ permissions: contents: read jobs: - tests: + # Skipped on a Release PR: it only bumps the version and rewrites the changelog. Not + # keyed on github.actor, which is the pusher: clicking Update branch reattributes the + # merge commit to whoever clicked, and the skip would stop firing on the normal release + # path. The author and head repo clauses are what make it unforgeable; the branch name + # on its own would let any PR, a fork's included, call its branch release-please--x. + unit: + if: >- + ${{ !(github.event.pull_request.user.login == 'github-actions[bot]' + && github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'release-please--')) }} uses: ./.github/workflows/run_tests.yml - secrets: inherit + + # The one required status check on master. A matrix job skipped by `if:` publishes a + # single check run with the template unexpanded, so per-leg contexts could never be + # satisfied on a Release PR; this job carries no matrix for that reason. `skipped` is + # accepted only when the condition above holds, repeated here because Actions cannot + # share an expression. `!cancelled()` rather than `always()`: a cancelled run must stay + # red, not report a pass. + tests-passed: + name: 🧪 Tests + needs: unit + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check the unit lane + env: + RESULT: ${{ needs.unit.result }} + RELEASE_PR: >- + ${{ github.event.pull_request.user.login == 'github-actions[bot]' + && github.event.pull_request.head.repo.full_name == github.repository + && startsWith(github.head_ref, 'release-please--') }} + run: | + case "$RESULT" in + success) + echo "unit lane passed" + ;; + skipped) + if [ "$RELEASE_PR" = "true" ]; then + echo "release pr: unit lane skipped by design" + else + echo "::error::the unit lane was skipped on a PR that is not a Release PR" + exit 1 + fi + ;; + *) + echo "::error::unit lane reported $RESULT" + exit 1 + ;; + esac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3d319b6..33d2bb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,6 +164,15 @@ jobs: needs: detect if: needs.detect.outputs.ready == 'true' uses: ./.github/workflows/run_tests.yml + + # Deliberately absent from `release`'s `needs`. A failure here would skip tagging while + # the Release PR is already merged carrying `autorelease: pending`, and nothing clears + # that label on its own, so every later push would stand down. Integration is advisory. + integration: + name: Integration tests + needs: detect + if: needs.detect.outputs.ready == 'true' + uses: ./.github/workflows/run_integration.yml secrets: inherit # Irreversible half. diff --git a/.github/workflows/run_integration.yml b/.github/workflows/run_integration.yml new file mode 100644 index 0000000..ff574d3 --- /dev/null +++ b/.github/workflows/run_integration.yml @@ -0,0 +1,124 @@ +name: _run-integration + +on: + workflow_call: + secrets: + STREAM_API_SECRET: + required: true + STREAM_VIDEO_API_SECRET: + required: true + STREAM_GCP_API_SECRET: + required: false + +concurrency: + # A constant, not `github.workflow`, which inside a reusable workflow resolves to the + # caller's name and would let the daily run and the pre-tag gate hit the shared Stream + # app at once. Not cancel-in-progress: a half-run leaves users and channels behind. + group: run-integration-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + integration-chat: + name: 🧪 Chat integration tests + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ci + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run chat integration tests + env: + STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} + STREAM_BASE_URL: ${{ vars.STREAM_BASE_URL }} + run: make test-integration-chat + + integration-feed: + name: 🧪 Feed integration tests + # Runs after chat rather than beside it: both blank and restore the app-global + # file_upload_config, so in parallel one suite's restore lands mid-assertion in + # the other. !cancelled() keeps the ordering without inheriting the implicit + # success(), so a red chat no longer hides whether feed passes. + needs: integration-chat + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ci + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run feed integration tests + env: + STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} + STREAM_BASE_URL: ${{ vars.STREAM_BASE_URL }} + run: make test-integration-feed + + integration-video: + name: 🧪 Video integration tests + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ci + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run video integration tests + env: + STREAM_API_KEY: ${{ vars.STREAM_VIDEO_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_VIDEO_API_SECRET }} + STREAM_BASE_URL: ${{ vars.STREAM_VIDEO_BASE_URL }} + run: make test-integration-video + + integration-gcp-lb: + name: 🧪 GCP load balancer keep-alive + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: ci + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run GCP keep-alive integration test + env: + STREAM_API_KEY: ${{ vars.STREAM_GCP_API_KEY || vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_GCP_API_SECRET || secrets.STREAM_API_SECRET }} + STREAM_BASE_URL: ${{ vars.STREAM_GCP_BASE_URL }} + run: make test-integration-gcp-lb diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 4067748..ee7ac76 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -2,13 +2,6 @@ name: _run-tests on: workflow_call: - secrets: - STREAM_API_SECRET: - required: true - STREAM_VIDEO_API_SECRET: - required: true - STREAM_GCP_API_SECRET: - required: false concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -17,24 +10,12 @@ concurrency: permissions: contents: read -# Every job below skips on a Release PR. release-please only bumps the version and rewrites -# the changelog, and every source commit in one already passed this suite on the PR it came -# from. The guard sits on each job rather than on the calling job in ci.yml, because a job -# skipped by `if:` reports success and satisfies a required status check, while a reusable -# workflow that is never called produces no check at all. It tests the author as well as the -# branch name: on its own, the name would let any PR, a fork's included, call its branch -# release-please--x and skip every required check, which branch protection counts as met. -# github.actor is the third clause, and it is the pusher rather than the PR author, so a -# human commit pushed onto the Release PR to fix a conflict or a changelog entry is tested -# like any other commit instead of riding the skip into main untested. +# The unit lane declares no environment and receives no credentials. Keep it that way: a +# fork PR gets no secrets and still goes green, and a live spec added outside +# spec/integration/ fails here instead of passing against someone else's Stream app. jobs: unit: - if: >- - ${{ !(github.actor == 'github-actions[bot]' - && github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} - name: Unit Tests & Code Quality + name: 🧪 Unit tests & code quality runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -57,125 +38,3 @@ jobs: make format-check make lint make security - - integration-chat: - if: >- - ${{ !(github.actor == 'github-actions[bot]' - && github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} - name: Chat Integration Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: ci - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 - - - name: Run chat integration tests - env: - STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} - STREAM_BASE_URL: ${{ vars.STREAM_BASE_URL }} - run: make test-integration-chat - - integration-feed: - name: Feed Integration Tests - # Runs after chat rather than beside it: both blank and restore the app-global - # file_upload_config, so in parallel one suite's restore lands mid-assertion in - # the other. !cancelled() keeps the ordering without inheriting the implicit - # success(), so a red chat no longer hides whether feed passes. - needs: integration-chat - if: >- - ${{ !cancelled() - && !(github.actor == 'github-actions[bot]' - && github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: ci - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 - - - name: Run feed integration tests - env: - STREAM_API_KEY: ${{ vars.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }} - STREAM_BASE_URL: ${{ vars.STREAM_BASE_URL }} - run: make test-integration-feed - - integration-video: - if: >- - ${{ !(github.actor == 'github-actions[bot]' - && github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} - name: Video Integration Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: ci - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 - - - name: Run video integration tests - env: - STREAM_API_KEY: ${{ vars.STREAM_VIDEO_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_VIDEO_API_SECRET }} - STREAM_BASE_URL: ${{ vars.STREAM_VIDEO_BASE_URL }} - run: make test-integration-video - - integration-gcp-lb: - if: >- - ${{ !(github.actor == 'github-actions[bot]' - && github.event.pull_request.user.login == 'github-actions[bot]' - && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release-please--')) }} - name: GCP load balancer keep-alive - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: ci - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 - - - name: Run GCP keep-alive integration test - env: - STREAM_API_KEY: ${{ vars.STREAM_GCP_API_KEY || vars.STREAM_API_KEY }} - STREAM_API_SECRET: ${{ secrets.STREAM_GCP_API_SECRET || secrets.STREAM_API_SECRET }} - STREAM_BASE_URL: ${{ vars.STREAM_GCP_BASE_URL }} - run: make test-integration-gcp-lb diff --git a/.github/workflows/scheduled_test.yml b/.github/workflows/scheduled_test.yml new file mode 100644 index 0000000..0699e3e --- /dev/null +++ b/.github/workflows/scheduled_test.yml @@ -0,0 +1,43 @@ +name: Scheduled tests +# The integration lane's home. Advisory: see CONTRIBUTING.md. +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + integration: + uses: ./.github/workflows/run_integration.yml + secrets: inherit + + # A failure here lands on no PR, and GitHub emails it only to whoever last edited the cron. + report: + name: Report a red run + needs: integration + if: failure() + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write + steps: + - name: Open or update the tracking issue + env: + GH_TOKEN: ${{ github.token }} + TITLE: Daily integration run is red + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + num="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" \ + --json number,title --jq "map(select(.title == \"$TITLE\")) | .[0].number // empty")" + if [ -n "$num" ]; then + gh issue comment "$num" --repo "$GITHUB_REPOSITORY" --body "Still red: $RUN_URL" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body \ + "The daily \`spec/integration/\` run failed: $RUN_URL + + Integration is advisory, so nothing is blocked by this. It still has to be read: the suite + runs against a live Stream app shared by five SDK repos, so decide whether this is the app, + the backend, or this SDK, and close the issue once a daily run is green again." + fi diff --git a/README.md b/README.md index 8f4d5b3..9c5a967 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,22 @@ make test-integration # Run integration tests only make test-all # Run all tests (unit + integration) ``` +Anything under `spec/integration/` talks to a live Stream app and needs credentials. +`make test` excludes that directory and needs none. + +CI follows the same split: + +| When | What runs | Gates | +| --- | --- | --- | +| Pull request | `make test`, `format-check`, `lint`, `security` | yes, `🧪 Tests` is required on `master` | +| Daily at 09:00 UTC | `spec/integration/` | no, a red run opens an issue | +| Push to `master` with a release pending | both | only the unit half gates the tag | + +A Release PR skips the unit lane, and `🧪 Tests` still reports satisfied. Its checks sit +Pending until someone clicks **Approve and run**, because a PR opened with `GITHUB_TOKEN` +starts no workflow runs. **Update branch** does not bring the suite back, and neither does +pushing a commit by hand, so a commit pushed onto a Release PR reaches `master` untested. + #### Code Quality ```bash make format # Auto-format code with RuboCop From 20528702c1c64849d723d235620afd880fbbaf60 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 22 Sep 2026 16:23:41 +0200 Subject: [PATCH 2/3] ci: address review on the integration split - ci.yml gets a workflow-level concurrency group. Cancelling the reusable workflow's job does not cancel the run, so tests-passed kept running, read `cancelled` and published a red aggregator for a push that had already been superseded. - run_integration.yml serializes repository-wide rather than per ref. The contended resource is the Stream app, so a *.x release run could otherwise land beside the daily run on master. - The daily run moves to 11:00 UTC. stream-py and stream-sdk-java both run at 09:00 against the same shared app and all three mutate app-global settings. - The pre-tag integration job is gone. It gated nothing, reported to nobody, cost up to 60 minutes of runner time per release and queued the release lane behind the daily run. The daily run covers it. - The issue lookup in the daily report is guarded. Unguarded under `bash -e`, one non-2xx from the search API aborted the step and a red run reported nothing at all. - bundler-cache replaces five copies of `bundle install`, which also pins CI to one resolution instead of resolving fresh on every run. - README no longer states branch protection that does not exist yet, and describes the Release PR checks as held at action_required rather than never created. --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 10 ---------- .github/workflows/run_integration.yml | 25 +++++++++---------------- .github/workflows/run_tests.yml | 4 +--- .github/workflows/scheduled_test.yml | 15 +++++++++++---- README.md | 20 ++++++++++++-------- 6 files changed, 40 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 612cf4a..68a575d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,13 @@ on: pull_request: branches: [ master, main, '*.x' ] +# Needed at this level, not only in run_tests.yml: cancelling the reusable workflow's job +# does not cancel this run, so tests-passed would still run, read `cancelled` and publish a +# red aggregator for a push that has already been superseded. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33d2bb0..373b177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,16 +165,6 @@ jobs: if: needs.detect.outputs.ready == 'true' uses: ./.github/workflows/run_tests.yml - # Deliberately absent from `release`'s `needs`. A failure here would skip tagging while - # the Release PR is already merged carrying `autorelease: pending`, and nothing clears - # that label on its own, so every later push would stand down. Integration is advisory. - integration: - name: Integration tests - needs: detect - if: needs.detect.outputs.ready == 'true' - uses: ./.github/workflows/run_integration.yml - secrets: inherit - # Irreversible half. release: name: 🚀 Tag and release diff --git a/.github/workflows/run_integration.yml b/.github/workflows/run_integration.yml index ff574d3..4048057 100644 --- a/.github/workflows/run_integration.yml +++ b/.github/workflows/run_integration.yml @@ -11,10 +11,11 @@ on: required: false concurrency: - # A constant, not `github.workflow`, which inside a reusable workflow resolves to the - # caller's name and would let the daily run and the pre-tag gate hit the shared Stream - # app at once. Not cancel-in-progress: a half-run leaves users and channels behind. - group: run-integration-${{ github.ref }} + # Repository-wide, on purpose. The contended resource is the Stream app, not the branch, + # so keying on github.ref would let a *.x release run beside the daily run on master. + # Not `github.workflow` either: inside a reusable workflow that resolves to the caller's + # name. Not cancel-in-progress: a half-run leaves users and channels behind. + group: run-integration cancel-in-progress: false permissions: @@ -34,9 +35,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run chat integration tests env: @@ -64,9 +63,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run feed integration tests env: @@ -88,9 +85,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run video integration tests env: @@ -112,9 +107,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run GCP keep-alive integration test env: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index ee7ac76..60daf0b 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -26,9 +26,7 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: '3.1.0' - - - name: Install dependencies - run: bundle install --jobs 4 --retry 3 + bundler-cache: true - name: Run unit tests run: make test diff --git a/.github/workflows/scheduled_test.yml b/.github/workflows/scheduled_test.yml index 0699e3e..70a2bf0 100644 --- a/.github/workflows/scheduled_test.yml +++ b/.github/workflows/scheduled_test.yml @@ -1,8 +1,10 @@ name: Scheduled tests -# The integration lane's home. Advisory: see CONTRIBUTING.md. +# The integration lane's home. Advisory: see README.md. on: schedule: - - cron: "0 9 * * *" + # 11:00, not 09:00: stream-py and stream-sdk-java both run at 09:00 against the same + # shared Stream app, and all three mutate app-global settings. + - cron: "0 11 * * *" workflow_dispatch: permissions: @@ -29,8 +31,13 @@ jobs: TITLE: Daily integration run is red RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | - num="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" \ - --json number,title --jq "map(select(.title == \"$TITLE\")) | .[0].number // empty")" + # Unguarded, a non-2xx would abort the step under `bash -e` and a red run would + # report nothing at all. Fail toward creating the issue, which is the loud way. + if ! num="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "in:title \"$TITLE\"" \ + --json number,title --jq "map(select(.title == \"$TITLE\")) | .[0].number // empty")"; then + echo "::warning::Could not list open issues; creating a new one." + num="" + fi if [ -n "$num" ]; then gh issue comment "$num" --repo "$GITHUB_REPOSITORY" --body "Still red: $RUN_URL" else diff --git a/README.md b/README.md index 9c5a967..6f56738 100644 --- a/README.md +++ b/README.md @@ -256,14 +256,18 @@ CI follows the same split: | When | What runs | Gates | | --- | --- | --- | -| Pull request | `make test`, `format-check`, `lint`, `security` | yes, `🧪 Tests` is required on `master` | -| Daily at 09:00 UTC | `spec/integration/` | no, a red run opens an issue | -| Push to `master` with a release pending | both | only the unit half gates the tag | - -A Release PR skips the unit lane, and `🧪 Tests` still reports satisfied. Its checks sit -Pending until someone clicks **Approve and run**, because a PR opened with `GITHUB_TOKEN` -starts no workflow runs. **Update branch** does not bring the suite back, and neither does -pushing a commit by hand, so a commit pushed onto a Release PR reaches `master` untested. +| Pull request | `make test`, `format-check`, `lint`, `security` | `🧪 Tests`, once it is required on `master` | +| Daily at 11:00 UTC | `spec/integration/` | no, a red run opens an issue | +| Push to `master` with a release pending | the unit lane | yes, it gates the tag | + +`master` has no branch protection yet. `🧪 Tests` is the single check to require there, +alongside `👮 Conventional PR title`. + +A Release PR skips the unit lane, and `🧪 Tests` still reports satisfied. Its runs are +created held at `action_required` until someone clicks **Approve and run**, because +release-please opens the PR with `GITHUB_TOKEN`. **Update branch** does not bring the suite +back, and neither does pushing a commit by hand, so a commit pushed onto a Release PR +reaches `master` untested. #### Code Quality ```bash From 973dfc4475a7404c565da690313e64f2ceb3c4c0 Mon Sep 17 00:00:00 2001 From: Yun Wang Date: Tue, 22 Sep 2026 16:42:37 +0200 Subject: [PATCH 3/3] docs: note that the pre-tag unit lane keeps an untested Release PR commit from shipping --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6f56738..556ad07 100644 --- a/README.md +++ b/README.md @@ -263,11 +263,13 @@ CI follows the same split: `master` has no branch protection yet. `🧪 Tests` is the single check to require there, alongside `👮 Conventional PR title`. -A Release PR skips the unit lane, and `🧪 Tests` still reports satisfied. Its runs are -created held at `action_required` until someone clicks **Approve and run**, because -release-please opens the PR with `GITHUB_TOKEN`. **Update branch** does not bring the suite -back, and neither does pushing a commit by hand, so a commit pushed onto a Release PR -reaches `master` untested. +A Release PR skips the unit lane and `🧪 Tests` still reports satisfied: release-please +only bumps the version and rewrites the changelog. Its runs are created held at +`action_required` until someone clicks **Approve and run**, because release-please opens +the PR with `GITHUB_TOKEN`. The skip keys on the PR author, not the pusher, so **Update +branch** does not bring the suite back and neither does pushing a commit by hand, and such +a commit reaches `master` untested. It does not ship untested: `release.yml` runs the unit +lane on the merge commit and gates the tag on it. #### Code Quality ```bash