Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 23 additions & 24 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -281,14 +281,14 @@ jobs:
run: npx playwright install-deps chromium

# Isolated runners avoid concurrent native rebuilds. Keep one worker per
# shard; fully-parallel distributes individual tests, including large specs.
# shard; duration estimates only assign tests, never select the test inventory.
- name: Run Electron workflows
if: matrix.suite == 'electron'
run: |
sudo apt-get install --yes dbus-x11 gnome-keyring
dbus-run-session -- bash -euo pipefail -c '
openssl rand -hex 32 | gnome-keyring-daemon --unlock --components=secrets
xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --shard=${{ matrix.shard-index }}/${{ matrix.shard-total }}
xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --balanced-shard=${{ matrix.shard-index }}/${{ matrix.shard-total }}
'

- name: Run browser workflows
Expand All @@ -313,7 +313,7 @@ jobs:
github.event.pull_request.base.ref == 'main' &&
github.event.pull_request.head.repo.full_name == github.repository)
)
needs: [provenance, unit-coverage, renderer-coverage]
needs: provenance
permissions:
actions: read
contents: read
Expand All @@ -322,13 +322,11 @@ jobs:
env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
steps:
- name: Require valid provenance or successful coverage
- name: Require valid provenance
env:
ELIGIBLE: ${{ needs.provenance.outputs.eligible }}
PROVENANCE_RESULT: ${{ needs.provenance.result }}
REUSE: ${{ needs.provenance.outputs.reuse }}
UNIT_COVERAGE_RESULT: ${{ needs.unit-coverage.result }}
RENDERER_COVERAGE_RESULT: ${{ needs.renderer-coverage.result }}
shell: bash
run: |
if [[ "$PROVENANCE_RESULT" != "success" ]]; then
Expand All @@ -344,12 +342,6 @@ jobs:
exit 1
fi

if [[ "$UNIT_COVERAGE_RESULT" != "success" ||
"$RENDERER_COVERAGE_RESULT" != "success" ]]; then
echo "Coverage jobs did not both succeed: unit-coverage=$UNIT_COVERAGE_RESULT renderer-coverage=$RENDERER_COVERAGE_RESULT"
exit 1
fi

- name: Checkout exact commit
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
Expand Down Expand Up @@ -379,6 +371,25 @@ jobs:
RELAY_SKIP_POCKETBASE_DOWNLOAD: '1'
run: npm ci --prefer-offline

- name: Install verified SonarScanner CLI
shell: bash
run: |
archive="$RUNNER_TEMP/sonar-scanner.zip"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
--retry 3 --max-time 120 \
--output "$archive" \
https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-8.1.0.6389-linux-x64.zip
printf '%s %s\n' bb8f709f9cb73352f8d1260a3b3c506c0f41146754bc630762c126d795499d0b "$archive" | shasum -a 256 -c -
unzip -q "$archive" -d "$RUNNER_TEMP/relay-sonar-scanner"
echo "$RUNNER_TEMP/relay-sonar-scanner/sonar-scanner-8.1.0.6389-linux-x64/bin" >> "$GITHUB_PATH"

- name: Wait for successful coverage
if: needs.provenance.outputs.reuse != 'true'
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: node scripts/wait-for-coverage.mjs

- name: Download unit coverage
if: needs.provenance.outputs.reuse != 'true'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
Expand Down Expand Up @@ -424,18 +435,6 @@ jobs:
if-no-files-found: error
retention-days: 1

- name: Install verified SonarScanner CLI
shell: bash
run: |
archive="$RUNNER_TEMP/sonar-scanner.zip"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 \
--retry 3 --max-time 120 \
--output "$archive" \
https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/sonar-scanner-cli-8.1.0.6389-linux-x64.zip
printf '%s %s\n' bb8f709f9cb73352f8d1260a3b3c506c0f41146754bc630762c126d795499d0b "$archive" | shasum -a 256 -c -
unzip -q "$archive" -d "$RUNNER_TEMP/relay-sonar-scanner"
echo "$RUNNER_TEMP/relay-sonar-scanner/sonar-scanner-8.1.0.6389-linux-x64/bin" >> "$GITHUB_PATH"

- name: Run Sonar finding gate
shell: bash
env:
Expand Down
22 changes: 18 additions & 4 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,14 @@ The Build workflow owns the full pull-request and `main` verification graph. Its
`Build quality gate` fails closed over formatting, linting, type checking, dependency audit, the
production build, unit coverage plus cache integration tests, four renderer-coverage shards, and
the mandatory `workflow-tests` matrix. Four isolated Electron runners execute
`npm run test:electron -- --fully-parallel --workers=1 --shard=N/4` under Xvfb with an unlocked
ephemeral keyring. Test-level sharding divides large specs across runners while keeping one worker
per runner. A fifth runner executes
`npm run test:electron -- --fully-parallel --workers=1 --balanced-shard=N/4` under Xvfb with an unlocked
ephemeral keyring. After the npm wrapper builds the app, `scripts/plan-electron-shards.mjs` discovers the full current
Playwright inventory (including specs that import emitted CSS) and assigns every test to exactly one of four shards, balancing the longest
measured tests first using `scripts/electron-test-durations.json`. The timing file records its
source run and only affects assignment: new or renamed tests receive a 30-second estimate, and
removed tests cannot remain in the inventory. Keep one worker per runner. To refresh estimates,
use successful Linux Electron job timings with the same file and full title identifiers; include
setup separately when comparing elapsed job time. A fifth runner executes
`npm run test:pocketbase -- verification/offline-replay-real-pb.test.ts verification/dynatrace-pipeline.test.ts`,
then `npm run test:web` under Xvfb against Chromium and WebKit. Electron runners install only the
Linux libraries they need; the web runner downloads the browsers. Each job uses its own npm install,
Expand All @@ -254,12 +259,21 @@ The matrix runs on every Build invocation, including when exact-tree reuse succe
disabled so every shard reports its result, with uniquely named failure artifacts. Any unsuccessful
or missing matrix result blocks the aggregate gate and the Release workflow that waits for it.
Those coverage jobs are canonical: Sonar consumes their merged reports instead of rerunning the
same tests. The required `SonarQube quality gate` and `Snyk security gate` names remain stable in
same tests. Sonar checkout, dependency installation, cache restore and scanner setup overlap with
coverage. Before downloading fresh coverage, a bounded five-minute wait requires successful unit
coverage and all four renderer coverage jobs from the exact GitHub run attempt and head SHA.
GitHub's attempt endpoint also includes successful jobs carried forward by partial reruns; failed,
skipped, ambiguous or mismatched jobs cannot authorize analysis. Validated exact-tree reuse still
uses its provenance-checked PR LCOV instead of waiting for intentionally skipped coverage jobs. The required `SonarQube quality gate` and `Snyk security gate` names remain stable in
the same workflow. Sonar always runs for the exact final `main` commit, including its reviewed-issue
reconciliation; optimization never turns a post-merge branch Sonar scan into a reused PR result.
When validated PR Snyk findings are reused, a lightweight main-only monitor still refreshes the
canonical Snyk project snapshot before the required Snyk gate succeeds.

Main scanner analysis/upload has a 15-minute deadline; PR scans retain 10 minutes. Both share the
existing 18-minute aggregate deadline across scanning and all subsequent API checks. This allows a
slow main scan to finish without an unnecessary retry while keeping a hard overall bound.

The Sonar wrapper records analysis/upload, server wait, reviewed-issue reconciliation, issue indexing,
and quality-gate timings in the GitHub job summary, including failed phases. It also ranks completed
sensors reported in the retained normal scanner output. Sensor timings are included in the
Expand Down
48 changes: 42 additions & 6 deletions scripts/ci-optimization-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
import { COVERAGE_JOBS } from './wait-for-coverage.mjs';
import unitConfig from '../vitest.config.ts';
import cacheConfig from '../vitest.cache.config.ts';
import rendererConfig from '../vitest.renderer.config.ts';
Expand Down Expand Up @@ -178,13 +179,23 @@ describe('CI optimization contracts', () => {
expect(electronDependencies.run).toBe('npx playwright install-deps chromium');
const electron = findStep(workflows, 'Run Electron workflows');
const web = findStep(workflows, 'Run browser workflows');
const pkg = await readJson('package.json');
expect(pkg.scripts['test:electron']).toBe(
'npm run build && node scripts/run-electron-tests.mjs',
);
const runner = await readProjectFile('scripts/run-electron-tests.mjs');
expect(runner).toContain('writeElectronShard(');
expect(runner.indexOf('writeElectronShard(balanced')).toBeLessThan(
runner.indexOf('runElectronTests({'),
);

expect(electron.run).toContain('sudo apt-get install --yes dbus-x11 gnome-keyring');
expect(electron.run).toContain('dbus-run-session -- bash -euo pipefail');
expect(electron.run).toContain(
'openssl rand -hex 32 | gnome-keyring-daemon --unlock --components=secrets',
);
expect(electron.run).toContain(
'xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --shard=${{ matrix.shard-index }}/${{ matrix.shard-total }}',
'xvfb-run --auto-servernum npm run test:electron -- --fully-parallel --workers=1 --balanced-shard=${{ matrix.shard-index }}/${{ matrix.shard-total }}',
);
expect(web.run).toBe('xvfb-run --auto-servernum npm run test:web');
expect(workflows.steps.indexOf(pocketbase)).toBeGreaterThan(workflows.steps.indexOf(install));
Expand Down Expand Up @@ -268,7 +279,35 @@ describe('CI optimization contracts', () => {

const sonar = build.jobs.sonarqube;
expect(sonar.name).toBe('SonarQube quality gate');
expect(sonar.needs).toEqual(['provenance', 'unit-coverage', 'renderer-coverage']);
expect(sonar.needs).toBe('provenance');
const wait = findStep(sonar, 'Wait for successful coverage');
expect(wait).toEqual({
name: 'Wait for successful coverage',
if: "needs.provenance.outputs.reuse != 'true'",
env: {
GH_TOKEN: '${{ github.token }}',
EXPECTED_HEAD_SHA: '${{ github.event.pull_request.head.sha || github.sha }}',
},
run: 'node scripts/wait-for-coverage.mjs',
});
expect(COVERAGE_JOBS).toEqual([
build.jobs['unit-coverage'].name,
...rendererCoverage.strategy.matrix['shard-index'].map((index) =>
rendererCoverage.name
.replace('${{ matrix.shard-index }}', index)
.replace('${{ matrix.shard-total }}', rendererCoverage.strategy.matrix['shard-total'][0]),
),
]);
for (const name of ['Install dependencies', 'Install verified SonarScanner CLI']) {
expect(sonar.steps.indexOf(findStep(sonar, name))).toBeLessThan(sonar.steps.indexOf(wait));
}
for (const name of [
'Download unit coverage',
'Download renderer coverage shards',
'Run Sonar finding gate',
]) {
expect(sonar.steps.indexOf(findStep(sonar, name))).toBeGreaterThan(sonar.steps.indexOf(wait));
}
const merge = findStep(sonar, 'Merge renderer coverage');
expect(merge.if).toBe("needs.provenance.outputs.reuse != 'true'");
expect(merge.run).toContain('--merge-reports');
Expand Down Expand Up @@ -413,19 +452,16 @@ describe('CI optimization contracts', () => {
}

const sonar = build.jobs.sonarqube;
const preflight = findStep(sonar, 'Require valid provenance or successful coverage');
const preflight = findStep(sonar, 'Require valid provenance');
expect(sonar.if).toContain('always()');
expect(preflight.env).toEqual({
ELIGIBLE: '${{ needs.provenance.outputs.eligible }}',
PROVENANCE_RESULT: '${{ needs.provenance.result }}',
RENDERER_COVERAGE_RESULT: '${{ needs.renderer-coverage.result }}',
REUSE: '${{ needs.provenance.outputs.reuse }}',
UNIT_COVERAGE_RESULT: '${{ needs.unit-coverage.result }}',
});
expect(preflight.run).toContain('[[ "$PROVENANCE_RESULT" != "success" ]]');
expect(preflight.run).toContain('[[ "$REUSE" == "true" ]]');
expect(preflight.run).toContain('[[ "$ELIGIBLE" == "true" ]]');
expect(preflight.run).toContain('[[ "$UNIT_COVERAGE_RESULT" != "success"');

expect(findStep(sonar, 'Checkout exact commit').with).toEqual({
'fetch-depth': 0,
Expand Down
Loading
Loading