Skip to content

Commit 32e1afe

Browse files
authored
ci: move SonarCloud branch scans out of the merge queue, serialize per branch (erigontech#21669)
## Problem SonarCloud rejects a branch analysis dated older than the branch's latest processed one. Every merge-queue entry analyzes the target branch, so when queue entry N's report uploads *after* entry N+1's, SonarCloud rejects N's with *"Date of analysis cannot be older than the date of the last known analysis"* and flips that commit's **SonarCloud Code Analysis** check to `cancelled`. Seen on [`5027d259c2`](erigontech@5027d25): three stacked queue entries (erigontech#21648, erigontech#21584, erigontech#21640) scanned concurrently; erigontech#21584 started 6s later but uploaded 21s earlier, so erigontech#21648's analysis was rejected as out of order. No impact on the gate (the scan step is `continue-on-error` and the merge succeeded), but it leaves a spurious failed check on a merged commit. ## Fix Make it structurally impossible to submit branch analyses out of order: - **`merge_group` runs** keep `make test-sonar-coverage` as a gate check but **no longer scan**. They upload `coverage-test-all.out` as a 7-day artifact. - **A new `SonarCloud Branch Scan` workflow** (`push` on `main` + `release/**`, matching the branches SonarCloud tracks) downloads that artifact and runs **only the scanner** (~7 min vs ~25). The merge queue fast-forwards the target branch to the already-tested merge commit, so the pushed SHA equals the merge-queue run's head SHA and the artifact maps to the commit exactly. If no artifact exists (direct push, or expired retention), it falls back to running the tests itself. - Branch scans are serialized per branch **FIFO** via `concurrency.queue: max` ([GitHub Actions, 2026-05](https://github.blog/changelog/2026-05-07-github-actions-concurrency-groups-now-allow-larger-queues/)), so every merged commit is analyzed, in order, with **no runs cancelled** during merge bursts. - **PR scans** (Sonar PR analyses — separate from main-branch date ordering) and **cache-warming** runs are unchanged. A failed analysis upload no longer touches the gate at all: scanner failures now surface on a post-merge run, while PR runs keep exercising the scanner so config/scanner breakage is still caught before merge. ## Notes - `actionlint` (≤ v1.7.12, the latest release) does not know the `queue` concurrency key yet, so `lint.yml` gets a narrow `--ignore` alongside the existing `workspace` one. Removable once actionlint learns the key. - `concurrency.queue: max` is ~1 month old — first real merge-burst after this lands is where FIFO ordering gets exercised live. ## Testing - `make lint`, `actionlint` (CI flags), and `zizmor` (repo config) all clean; the new workflow is finding-free. - The artifact-lookup script was shellchecked and run against live API data: correctly resolves the merge-queue run by head SHA, filters expired artifacts, and degrades to the test-running fallback on a missing artifact or total API failure (never fails the job).
1 parent 3ec49a3 commit 32e1afe

3 files changed

Lines changed: 91 additions & 7 deletions

File tree

.github/workflows/lint.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ jobs:
3333
# runner.workspace is suppressed: it was dropped from the official context
3434
# spec but is still available on self-hosted runners; QA workflows rely on
3535
# it until they are migrated to github.workspace / runner.temp.
36+
# The "queue" concurrency key (GitHub, 2026-05) is newer than any released
37+
# actionlint schema; drop that ignore once actionlint learns the key.
3638
- name: actionlint
37-
run: actionlint -shellcheck '' --ignore '"workspace" is not defined in object type'
39+
run: |
40+
actionlint -shellcheck '' \
41+
--ignore '"workspace" is not defined in object type' \
42+
--ignore 'unexpected key "queue" for "concurrency" section'
3843
3944
- name: Actions security audit (zizmor)
4045
run: |
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: SonarCloud Branch Scan
2+
3+
# SonarCloud rejects a branch analysis dated older than the branch's latest
4+
# processed one, so analyses must reach it in chronological order. This
5+
# workflow is the only submitter of branch analyses: it scans after each push,
6+
# serialized per branch by a FIFO concurrency queue, reusing the coverage
7+
# profile uploaded by the commit's merge-queue run.
8+
9+
on:
10+
push:
11+
branches:
12+
- main
13+
- 'release/**'
14+
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
queue: max
18+
19+
permissions:
20+
actions: read
21+
contents: read
22+
23+
jobs:
24+
sonar:
25+
uses: ./.github/workflows/sonar.yml
26+
with:
27+
scan-only: true
28+
secrets: inherit

.github/workflows/sonar.yml

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ on:
88
description: "Compile test binaries only; skip test execution (for cache warming runs)"
99
type: boolean
1010
default: false
11+
scan-only:
12+
description: "Reuse the coverage artifact from this commit's merge-queue run instead of running tests (for post-merge branch scans)"
13+
type: boolean
14+
default: false
1115

1216
defaults:
1317
run:
@@ -34,12 +38,59 @@ jobs:
3438
cleanup-space: true
3539
ramdisk: true
3640

41+
# The merge queue fast-forwards the target branch to the already-tested
42+
# merge commit, so the pushed SHA equals a merge-queue run's head SHA and
43+
# that run's coverage artifact corresponds to this commit exactly.
44+
- name: Locate merge-queue coverage artifact
45+
id: coverage-artifact
46+
if: inputs.scan-only
47+
env:
48+
GH_TOKEN: ${{ github.token }}
49+
run: |
50+
found=false
51+
run_id=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${GITHUB_SHA}&event=merge_group" \
52+
--jq '[.workflow_runs[] | select(.path == ".github/workflows/ci-gate.yml")][0].id // empty' 2>/dev/null) || run_id=""
53+
if [ -n "$run_id" ]; then
54+
count=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?name=sonar-coverage" \
55+
--jq '[.artifacts[] | select(.expired | not)] | length' 2>/dev/null) || count=0
56+
if [ "${count:-0}" -gt 0 ]; then
57+
found=true
58+
fi
59+
fi
60+
if [ "$found" = "false" ]; then
61+
echo "::warning::No merge-queue coverage artifact for ${GITHUB_SHA}; running tests with coverage in this run"
62+
fi
63+
{
64+
echo "found=$found"
65+
echo "run_id=$run_id"
66+
} >> "$GITHUB_OUTPUT"
67+
68+
- name: Download merge-queue coverage artifact
69+
if: inputs.scan-only && steps.coverage-artifact.outputs.found == 'true'
70+
uses: actions/download-artifact@v8
71+
with:
72+
name: sonar-coverage
73+
run-id: ${{ steps.coverage-artifact.outputs.run_id }}
74+
github-token: ${{ github.token }}
75+
3776
- name: Run tests with coverage
38-
if: '!inputs.cache-warming-only'
77+
if: "!inputs.cache-warming-only && (!inputs.scan-only || steps.coverage-artifact.outputs.found != 'true')"
3978
env:
4079
SKIP_FLAKY_TESTS: 'true'
4180
run: make test-sonar-coverage
4281

82+
# Merge-queue runs produce the coverage profile but leave the scan to the
83+
# push-triggered SonarCloud Branch Scan workflow, which submits branch
84+
# analyses one at a time so SonarCloud never rejects them as out of order.
85+
- name: Upload coverage artifact for the post-merge scan
86+
if: "!inputs.cache-warming-only && github.event_name == 'merge_group'"
87+
uses: actions/upload-artifact@v7
88+
with:
89+
name: sonar-coverage
90+
path: coverage-test-all.out
91+
retention-days: 7
92+
if-no-files-found: error
93+
4394
- name: Build test binaries (cache warming)
4495
if: inputs.cache-warming-only
4596
run: go test -run=^$ -cover ./...
@@ -99,7 +150,7 @@ jobs:
99150
# otherwise download from scanner.sonarcloud.io (a 403-prone CDN);
100151
# if the image ever drops it, the scanner falls back to downloading.
101152
- name: Use preinstalled JDK for SonarCloud scanner
102-
if: '!inputs.cache-warming-only'
153+
if: "!inputs.cache-warming-only && github.event_name != 'merge_group'"
103154
run: |
104155
if [ -n "${JAVA_HOME_21_X64}" ] && [ -x "${JAVA_HOME_21_X64}/bin/java" ]; then
105156
echo "SONAR_SCANNER_SKIP_JRE_PROVISIONING=true" >> "$GITHUB_ENV"
@@ -111,7 +162,7 @@ jobs:
111162
# scanner.sonarcloud.io. A miss (engine version rotated since the last
112163
# base-branch push) falls back to downloading plus the retry below.
113164
- name: Restore SonarCloud scanner engine cache
114-
if: '!inputs.cache-warming-only'
165+
if: "!inputs.cache-warming-only && github.event_name != 'merge_group'"
115166
uses: actions/cache/restore@v5
116167
with:
117168
path: ~/.sonar/cache
@@ -123,19 +174,19 @@ jobs:
123174
# rides out short blips instead of failing the gate.
124175
- name: SonarCloud scan
125176
id: scan
126-
if: '!inputs.cache-warming-only'
177+
if: "!inputs.cache-warming-only && github.event_name != 'merge_group'"
127178
continue-on-error: true
128179
uses: SonarSource/sonarqube-scan-action@v8
129180
env:
130181
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
131182
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
132183

133184
- name: Wait before SonarCloud scan retry
134-
if: "!inputs.cache-warming-only && steps.scan.outcome == 'failure'"
185+
if: steps.scan.outcome == 'failure'
135186
run: sleep 90
136187

137188
- name: SonarCloud scan (retry)
138-
if: "!inputs.cache-warming-only && steps.scan.outcome == 'failure'"
189+
if: steps.scan.outcome == 'failure'
139190
uses: SonarSource/sonarqube-scan-action@v8
140191
env:
141192
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)