Skip to content

Chore(ci): cancel superseded pull request CI runs via concurrency groups - #504

Open
AmaadMartin wants to merge 2 commits into
mainfrom
feat/ci-concurrency-groups
Open

Chore(ci): cancel superseded pull request CI runs via concurrency groups#504
AmaadMartin wants to merge 2 commits into
mainfrom
feat/ci-concurrency-groups

Conversation

@AmaadMartin

@AmaadMartin AmaadMartin commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Please ensure you have read the contribution guide before creating a pull request.

Link to Issue or Description of Change

  1. Link to an existing issue (if applicable):
    N/A — no existing issue.
  2. Or, if no issue exists, describe the change:
    Problem: Every push to an open pull request starts a fresh, additive set of CI runs while the previous runs for the same pull request keep executing to completion. grep -rn concurrency .github/ returned no matches before this change: none of the six workflows declared a concurrency group. The superseded runs test a commit that has already been replaced, so their result is discarded — the minutes are pure waste. The worst offender, Cross-Language Tests, runs on macos-latest (billed at 10x the Linux rate) and does npm install + two go mod tidy + npm run build + the cross-language suite on every one of them; single head branches were observed accumulating 3–17 overlapping runs.

Solution: Add a workflow-scoped concurrency block to the three pull_request-triggered workflows — validation.yaml, cross-language-integration.yml, license-check.yml:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
  cancel-in-progress: true

Why this exact key, rather than the more common github.ref / github.head_ref forms:

  • github.ref is unsafe here. All three workflows also trigger on push to main, where github.ref is refs/heads/main — every main run would share one group, so a later merge would cancel an in-progress main run. Downgrading to cancel-in-progress: ${{ github.event_name == 'pull_request' }} does not fix it either: with the default queue: single, a newly queued run cancels the existing pending run in the same group, so three merges in quick succession would leave run 2 cancelled. github.event.pull_request.number || github.run_id puts each non-pull-request run alone in its own group, where it can be neither cancelled nor queued.
  • github.head_ref collides across forks. It is the bare head branch name, so two pull requests opened from different forks with the same branch name (patch-1 is very common on a public repo) would land in the same group and cancel each other. The pull request number cannot collide. This also matches the sibling repo google/adk-python (.github/workflows/pr-triage.yml), which already uses ${{ github.event.pull_request.number || github.run_id }}.
  • ${{ github.workflow }} is included because concurrency group names are repository-global; without it the three workflows would cancel one another. All six workflow name: values are distinct.

The block is top-level (workflow-scoped), not per-job, so a cancelled validation run takes all three matrix legs (ubuntu-latest, windows-latest, macos-latest) with it.

Deliberately not modified: release-please.yml (runs only on push to main, has nothing to supersede, and a group would risk a release-creating run being cancelled while pending), auto-assignment.yml and csat.yml (pull_request: types: [opened] / issues: types: [closed] — each fires at most once and can never be superseded). Runner/matrix changes are out of scope.

Behavioural note: superseded pull request runs now end as cancelled rather than success/failure. Nothing in the repository pins these workflows as required status checks, so no configuration needs updating.

Collision check (required by our process): gh pr list --repo AmaadMartin/adk-js --state open --limit 1000 returned 404 open PRs; I grepped the diffs of all 21 CI/workflow-adjacent ones (#450, #428, #427, #421, #418, #416, #415, #414, #406, #403, #393, #379, #377, #370, #345, #343, #338, #306, #296, #237, #133) for concurrencyzero hits, so nothing else adds a concurrency group. #403 (job timeout-minutes) and #450/#406/#296 touch the same workflow files but at different keys, and #418/#343 add repo-config tests under tests/integration/repo_config/, a different directory. No stacking required.

Testing Plan

Please describe the tests that you ran to verify your changes. This is required for all PRs that are not small documentation or typo fixes.
Unit Tests:
[x] I have added or updated unit tests for my change.
[x] All unit tests pass locally.

New file tests/integration/workflows/workflow_concurrency_test.ts (91 lines, runs in the existing integration vitest project, which npm run test:coverage — and therefore the validation workflow — already executes). It reads only .github/workflows, shells out to nothing, and touches no network. It derives the supersedable set from each workflow's pull_request activity types (explicit types, or GitHub's opened/synchronize/reopened default when types is omitted) instead of hard-coding a file list, so a workflow added later is covered automatically and auto-assignment.yml (types: [opened]) is correctly excluded.

$ npx vitest run --project integration tests/integration/workflows --reporter=verbose
 ✓ Workflow concurrency > discovers at least one supersedable workflow
 ✓ Workflow concurrency > cross-language-integration.yml cancels superseded pull request runs
 ✓ Workflow concurrency > license-check.yml cancels superseded pull request runs
 ✓ Workflow concurrency > validation.yaml cancels superseded pull request runs
 ✓ Workflow concurrency > release-please.yml never cancels an in-progress release run
 Test Files  1 passed (1) · Tests  5 passed (5)

Proof the tests can fail. Every assertion was run against mutated input and observed to fail with an actionable message:

# Mutation Failure
1 Remove the whole concurrency block from validation.yaml validation.yaml declares no concurrency group: expected undefined to be defined
2 cancel-in-progress: truefalse in license-check.yml expected false to be true // Object.is equality
3 Group key → ${{ github.workflow }}-${{ github.ref }} in cross-language-integration.yml (drops the run-id fallback that protects main) expected '${{ github.workflow }}-${{ github.ref…' to contain 'github.event.pull_request.number'
4 Append a cancel-in-progress: true group to release-please.yml expected true to be false // Object.is equality
5 Strip the on: block from license-check.yml license-check.yml declares no 'on' trigger block: expected undefined to be defined
6 Mutate the discovery filter in the test ('synchronize''synchronised') so nothing matches expected 0 to be greater than 0

Mutations 5 and 6 are the vacuity guards: js-yaml v4 follows the YAML 1.2 core schema, so the workflow key on: parses as the string 'on' and not the boolean true — if a future parser changed that, or if the discovery filter silently matched nothing, the parameterised assertions would all disappear rather than fail. Both cases now fail loudly. Mutation 6 also confirms an empty it.each does not abort collection.

Coverage: unchanged by construction — vitest.config.ts collects coverage only from core/src/**, dev/src/**, integrations/src/**, and this change adds zero lines under those roots. The thresholds are untouched.

No suppressions were added (@ts-expect-error, eslint-disable, any, as any: zero in the diff). The parsed YAML is typed via unknown + an isWorkflow narrowing guard, and expect.fail-free — assertions carry messages instead. No new dependency: js-yaml is already a runtime dependency of core/dev hoisted to the workspace root, and @types/js-yaml is already a root devDependency, so package.json/package-lock.json are untouched.

Manual End-to-End (E2E) Tests:
Please provide instructions on how to manually test your changes, including any necessary setup or configuration.

Because a pull_request run executes the merge ref, this pull request exercises the new blocks on its own checks — so the supersession test below was run for real on this branch, not just described.

  1. Supersession works — observed. On an earlier revision of this branch (SHAs since rewritten by a review fixup): pushed commit A, waited until its runs were live, then pushed commit B with an identical tree. gh run list --branch feat/ci-concurrency-groups immediately after:

    Cross-Language Tests     <B>  in_progress
    License Header Check     <B>  completed   success
    validation               <B>  in_progress
    auto-assignment          <A>  completed   success
    License Header Check     <A>  completed   success
    Cross-Language Tests     <A>  completed   success
    validation               <A>  completed   cancelled   <-- superseded
    

    gh run view 30737173542 confirms "conclusion": "cancelled", "event": "pull_request" for commit A's validation run. validation was the only one of the three still in flight when the newer push landed; License Header Check (7s) and Cross-Language Tests had already completed, so there was nothing left to cancel — exactly the intended behaviour.

  2. Group isolation — observed. In the same run list, auto-assignment for the superseded SHA completed success: it carries no concurrency group, and the ${{ github.workflow }} prefix keeps it out of validation's group. Nothing belonging to another workflow or another pull request was cancelled.

  3. main is never cancelled. Not observable pre-merge (this branch produces no push-to-main events). Reasoned from the resolved group key: on a push, github.event.pull_request.number is null, so the group becomes <workflow>-<github.run_id> and run_id is unique per run — a main run is alone in its group and can be neither cancelled nor queued. After merge, confirm the push-to-main runs of all three workflows complete (and that two closely spaced merges both finish, neither cancelled nor stuck queued), and that release-please still opens/updates its release pull request.

  4. Local sanity, run on the exact commit pushed:

    • npx vitest run --project integration tests/integration/workflows → 5 passed
    • npm run build → succeeds
    • npm run lint → clean
    • npm run format:check → "All matched files use Prettier code style!"
    • npm run ts:check → 281 pre-existing errors, byte-identical to the count on the base commit (b390217); zero of them mention the new file. This script is not run by CI today.

Checklist

[x] I have read the CONTRIBUTING.md document.
[x] I have performed a self-review of my own code.
[x] I have commented my code, particularly in hard-to-understand areas.
[x] I have added tests that prove my fix is effective or that my feature works.
[x] New and existing unit tests pass locally with my changes.

CI on this pull request (head commit c7a47e4): all real test jobs green.

check-license                pass   7s
run-tests                    pass   1m40s   (Cross-Language Tests, macos-latest)
run-tests (ubuntu-latest)    pass   5m40s
run-tests (macos-latest)     pass   4m18s
run-tests (windows-latest)   pass   6m46s

@AmaadMartin
AmaadMartin force-pushed the feat/ci-concurrency-groups branch from a694135 to fe3c43f Compare August 2, 2026 07:08
Amaad Martin added 2 commits August 2, 2026 00:30
Every push to an open pull request started a fresh, additive set of CI runs
while the previous runs for the same pull request kept executing to completion.
Those superseded runs test a commit that has already been replaced, so their
result is discarded -- the minutes are pure waste, and the worst offender runs
on macos-latest.

Add a workflow-scoped concurrency group keyed on the pull request number to the
three pull_request-triggered workflows, so a newer run cancels the in-progress
one. The group key falls back to the unique run id on non-pull-request events,
which keeps every push-to-main run alone in its group: a merge can therefore
neither cancel nor queue behind an in-progress main run. release-please.yml,
auto-assignment.yml and csat.yml are deliberately untouched -- none of them can
be superseded, and cancelling a release run would be harmful.
Discovers supersedable workflows from their pull_request activity types rather
than hard-coding a file list, so a workflow added later is covered
automatically, and encodes the rule that release-please must never cancel an
in-progress run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant