From 0310bfb9d95ada3ef8d69a3654910acb4d755884 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 18:37:18 +0100 Subject: [PATCH 1/8] ci(cli): shard unit tests and merge coverage --- .github/workflows/test.yml | 98 +++++++++++++++-- docs/operations/github-actions.md | 21 ++-- packages/cli/package.json | 4 +- scripts/ci-projects.test.mjs | 171 +++++++++++++++++++++++------- 4 files changed, 236 insertions(+), 58 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 004fbccb6..efd43b356 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -132,9 +132,6 @@ jobs: cache: pnpm - run: cp .env.example .env.local - run: pnpm install --frozen-lockfile - - name: Install zsh for CLI completion regression tests - if: matrix.name == 'sentry' - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh - name: Build project dependencies env: PROJECT_NAME: ${{ matrix.name }} @@ -209,6 +206,85 @@ jobs: git diff --exit-code [[ -z "$(git status --porcelain --untracked-files=all)" ]] + cli-unit: + name: CLI unit tests (${{ matrix.shard }}/3) + needs: discover-projects + if: contains(fromJSON(needs.discover-projects.outputs.matrix).include.*.name, 'sentry') + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + env: + BRAINTRUST_API_KEY: ci-test-key + GITHUB_TOKEN: ci-test-token + HONEYCOMB_API_KEY: ci-test-key + SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} + SENTRY_OAUTH_CLIENT_SECRET: ci-test-secret + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .github/node-versions/22 + cache: pnpm + - run: cp .env.example .env.local + - run: pnpm install --frozen-lockfile + - name: Install zsh for CLI completion regression tests + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh + - name: Build project dependencies + run: pnpm --filter 'sentry^...' --if-present run build + - run: pnpm --filter sentry run ci:prepare + - name: Test CLI shard + env: + SHARD: ${{ matrix.shard }} + run: pnpm --filter sentry run ci:test:unit --shard="$SHARD/3" --reporter=default --reporter=blob --outputFile.blob="coverage/shard-$SHARD.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: cli-unit-coverage-${{ matrix.shard }} + path: packages/cli/coverage/shard-${{ matrix.shard }}.json + if-no-files-found: error + overwrite: true + retention-days: 7 + - name: Check generated files + run: | + git diff --exit-code + [[ -z "$(git status --porcelain --untracked-files=all)" ]] + + cli-coverage: + name: CLI coverage + needs: [discover-projects, cli-unit] + if: contains(fromJSON(needs.discover-projects.outputs.matrix).include.*.name, 'sentry') + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: .github/node-versions/22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: cli-unit-coverage-* + path: ${{ runner.temp }}/cli-unit-reports + merge-multiple: true + - name: Require every shard report + run: | + for shard in 1 2 3; do + test -s "$RUNNER_TEMP/cli-unit-reports/shard-$shard.json" + done + - name: Merge CLI coverage + run: pnpm --filter sentry exec vitest --merge-reports="$RUNNER_TEMP/cli-unit-reports" --coverage + - name: Verify coverage + run: test -s packages/cli/coverage/lcov.info + cli-e2e: name: CLI E2E (Node 24) needs: discover-projects @@ -322,6 +398,8 @@ jobs: project, npm-runtime, cli-asset-catalog, + cli-unit, + cli-coverage, cli-e2e, ] runs-on: ubuntu-latest @@ -337,24 +415,28 @@ jobs: RUNTIME_COUNT: ${{ needs.discover-projects.outputs.runtime-count }} ASSET_CATALOG_RESULT: ${{ needs.cli-asset-catalog.result }} ASSET_CATALOG_SELECTED: ${{ contains(fromJSON(needs.discover-projects.outputs.matrix).include.*.name, 'sentry') }} + CLI_UNIT_RESULT: ${{ needs.cli-unit.result }} + CLI_COVERAGE_RESULT: ${{ needs.cli-coverage.result }} CLI_E2E_RESULT: ${{ needs.cli-e2e.result }} run: | - [[ "$DISCOVER_RESULT" == success ]] - [[ "$INSTALL_RESULT" == success ]] - [[ "$QUALITY_RESULT" == success ]] + test "$DISCOVER_RESULT" = success + test "$INSTALL_RESULT" = success + test "$QUALITY_RESULT" = success expected=success if [[ "$PROJECT_COUNT" == 0 ]]; then expected=skipped fi - [[ "$PROJECT_RESULT" == "$expected" ]] + test "$PROJECT_RESULT" = "$expected" expected=success if [[ "$RUNTIME_COUNT" == 0 ]]; then expected=skipped fi - [[ "$RUNTIME_RESULT" == "$expected" ]] + test "$RUNTIME_RESULT" = "$expected" expected=success if [[ "$ASSET_CATALOG_SELECTED" == false ]]; then expected=skipped fi test "$ASSET_CATALOG_RESULT" = "$expected" + test "$CLI_UNIT_RESULT" = "$expected" + test "$CLI_COVERAGE_RESULT" = "$expected" test "$CLI_E2E_RESULT" = "$expected" diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index bf43f198a..3d9ac3a97 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -26,14 +26,19 @@ from `.github/node-versions/`. The npm runtime job calls the self-contained `sentryCi.npmRuntime` build command once, then validates the same package on all three Node versions. -`CLI E2E (Node 24)` runs independently of the CLI project job. It builds the -standalone CLI on Node 22, then builds the npm bundle and tests both on Node 24. -Dependencies and generated sources are prepared once per job. The CLI project -job runs typechecks, unit tests, and coverage without rebuilding the standalone -or repeating generation. Public build and test commands still prepare their own -inputs for local use. The final `test` gate -requires E2E to pass whenever the CLI is selected, and requires it to be skipped -otherwise. +When the CLI is selected, its lint, typecheck, and policy checks run in the +project job. Unit tests run on Node 22 in three parallel shards, with the same +test directories and coverage as `pnpm --filter sentry test:unit`. Each shard +uploads a Vitest blob report; `CLI coverage` requires all three reports before +merging them into `packages/cli/coverage/lcov.info`. + +`CLI E2E (Node 24)` runs independently of the unit tests and project checks. It +builds the standalone CLI on Node 22 and the npm bundle on Node 24, then tests +both on Node 24. The project, unit-test, and E2E jobs each build their workspace +dependencies and run `ci:prepare` once before using the prepared sources. The regular development +commands still generate their prerequisites. The final `test` gate requires +the unit shards, coverage merge, and E2E job whenever the CLI is selected, and +requires them to be skipped otherwise. ### deploy.yml diff --git a/packages/cli/package.json b/packages/cli/package.json index 71fc1aa0b..0556ebd9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,11 +51,11 @@ "packageManager": "pnpm@10.11.0", "sentryCi": { "build": false, - "coverage": "coverage/lcov.info", + "coverage": false, "e2e": false, "npmRuntime": "bundle", "policy": "ci:policy", - "test": "ci:test:unit", + "test": false, "typecheck": "ci:typecheck" }, "scripts": { diff --git a/scripts/ci-projects.test.mjs b/scripts/ci-projects.test.mjs index 1f9e971d7..c96c4dfb8 100644 --- a/scripts/ci-projects.test.mjs +++ b/scripts/ci-projects.test.mjs @@ -1,7 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { describe, it } from "node:test"; import { @@ -45,11 +52,13 @@ describe("workspace CI selection", () => { const cli = matrix.include.find(({ name }) => name === "sentry"); assert.equal(cli.prepare, "ci:prepare"); assert.equal(cli.build, ""); + assert.equal(cli.lint, "lint"); assert.equal(cli.typecheck, "ci:typecheck"); - assert.equal(cli.test, "ci:test:unit"); assert.equal(cli.policy, "ci:policy"); + assert.equal(cli.test, ""); assert.equal(cli.e2e, ""); - assert.equal(cli.coverage, "packages/cli/coverage/lcov.info"); + assert.equal(cli.coverage, ""); + assert.equal(cli.npmRuntime, "bundle"); assert.equal( matrix.include.find(({ name }) => name === "@sentry/mcp-core").junit, "packages/mcp-core/tests.junit.xml", @@ -176,9 +185,28 @@ describe("workspace CI selection", () => { assert.match(workflow, /if: matrix\.prepare != ''/); assert.match(workflow, /CHECK_SCRIPT: \$\{\{ matrix\.prepare \}\}/); assert.doesNotMatch(workflow, /matrix\.name == 'sentry-cli-docs'/); - assert.match( - workflow, - /needs:\s*\[\s*discover-projects,\s*install,\s*quality,\s*project,\s*npm-runtime,\s*cli-asset-catalog,\s*cli-e2e,?\s*\]/, + const gate = workflow.split("\n test:\n")[1]; + assert.ok(gate); + const dependencies = gate.match(/needs:\s*\[([^\]]+)\]/)?.[1]; + assert.ok(dependencies); + assert.deepEqual( + new Set( + dependencies + .split(",") + .map((dependency) => dependency.trim()) + .filter(Boolean), + ), + new Set([ + "discover-projects", + "install", + "quality", + "project", + "npm-runtime", + "cli-asset-catalog", + "cli-unit", + "cli-e2e", + "cli-coverage", + ]), ); assert.match( workflow, @@ -211,18 +239,70 @@ describe("workspace CI selection", () => { assert.doesNotMatch(workflow.split("\njobs:", 1)[0], /paths(?:-ignore)?:/); }); - it("runs CLI E2E independently after project discovery", () => { + it("runs three CLI unit shards and E2E independently after discovery", () => { const workflow = readFileSync( resolve(root, ".github/workflows/test.yml"), "utf8", ); - const job = workflow.split("\n cli-e2e:\n")[1]?.split(/\n [\w-]+:\n/)[0]; - assert.ok(job); - assert.match(job, /\n needs: discover-projects\n/); - assert.match( - job, - /if: contains\(fromJSON\(needs\.discover-projects\.outputs\.matrix\)\.include\.\*\.name, 'sentry'\)/, + for (const name of ["cli-unit", "cli-e2e"]) { + const job = workflow + .split(`\n ${name}:\n`)[1] + ?.split(/\n [\w-]+:\n/)[0]; + assert.ok(job, `${name} job exists`); + assert.match(job, /\n needs: discover-projects\n/); + assert.match( + job, + /if: contains\(fromJSON\(needs\.discover-projects\.outputs\.matrix\)\.include\.\*\.name, 'sentry'\)/, + ); + if (name === "cli-unit") { + assert.match(job, /fail-fast: false/); + assert.match(job, /shard: \[1, 2, 3\]/); + assert.match(job, /--outputFile\.blob="coverage\/shard-\$SHARD\.json"/); + assert.match( + job, + /path: packages\/cli\/coverage\/shard-\$\{\{ matrix\.shard \}\}\.json/, + ); + assert.match(job, /if-no-files-found: error/); + } + } + }); + + it("rejects missing or empty shard reports before merging coverage", () => { + const workflow = readFileSync( + resolve(root, ".github/workflows/test.yml"), + "utf8", ); + const script = workflow + .split(" - name: Require every shard report\n")[1] + ?.split(" run: |\n")[1] + ?.split("\n - ")[0]; + assert.ok(script); + + const directory = mkdtempSync(join(tmpdir(), "ci-shard-reports-")); + try { + const reports = join(directory, "cli-unit-reports"); + mkdirSync(reports); + for (const shard of [1, 2, 3]) { + writeFileSync( + join(reports, `shard-${shard}.json`), + JSON.stringify({ shard }), + ); + } + const requireReports = () => + spawnSync("bash", ["-euc", script], { + encoding: "utf8", + env: { ...process.env, RUNNER_TEMP: directory }, + }).status; + + assert.equal(requireReports(), 0, "all three shard reports are present"); + const secondReport = join(reports, "shard-2.json"); + rmSync(secondReport); + assert.equal(requireReports(), 1, "the middle shard report is missing"); + writeFileSync(secondReport, ""); + assert.equal(requireReports(), 1, "the middle shard report is empty"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); it("requires native asset-catalog success when selected and a skip otherwise", () => { @@ -267,7 +347,9 @@ describe("workspace CI selection", () => { RUNTIME_RESULT: count === "0" ? "skipped" : "success", ASSET_CATALOG_RESULT: nativeResult, ASSET_CATALOG_SELECTED: String(selected), + CLI_UNIT_RESULT: selected ? "success" : "skipped", CLI_E2E_RESULT: selected ? "success" : "skipped", + CLI_COVERAGE_RESULT: selected ? "success" : "skipped", }, }); assert.equal( @@ -278,41 +360,50 @@ describe("workspace CI selection", () => { } }); - it("requires CLI E2E success when selected and a skip otherwise", () => { + it("requires every selected CLI test job and rejects unexpected skips", () => { const workflow = readFileSync( resolve(root, ".github/workflows/test.yml"), "utf8", ); const gate = workflow.split("\n test:\n")[1]; assert.ok(gate); - assert.match(gate, /CLI_E2E_RESULT: \$\{\{ needs\.cli-e2e\.result \}\}/); const script = gate.split(" run: |\n")[1]; assert.ok(script); - for (const selected of [true, false]) { - const expected = selected ? "success" : "skipped"; - for (const status of ["success", "failure", "cancelled", "skipped"]) { - const result = spawnSync("bash", ["-euc", script], { - encoding: "utf8", - env: { - ...process.env, - DISCOVER_RESULT: "success", - INSTALL_RESULT: "success", - QUALITY_RESULT: "success", - PROJECT_COUNT: "1", - PROJECT_RESULT: "success", - RUNTIME_COUNT: selected ? "1" : "0", - RUNTIME_RESULT: expected, - ASSET_CATALOG_RESULT: expected, - ASSET_CATALOG_SELECTED: String(selected), - CLI_E2E_RESULT: status, - }, - }); - assert.equal( - result.status, - status === expected ? 0 : 1, - `selected=${selected}, e2e=${status}`, - ); + for (const [variable, job] of [ + ["CLI_UNIT_RESULT", "cli-unit"], + ["CLI_E2E_RESULT", "cli-e2e"], + ["CLI_COVERAGE_RESULT", "cli-coverage"], + ]) { + assert.ok(gate.includes(`${variable}: \${{ needs.${job}.result }}`)); + for (const selected of [true, false]) { + const expected = selected ? "success" : "skipped"; + for (const status of ["success", "failure", "cancelled", "skipped"]) { + const result = spawnSync("bash", ["-euc", script], { + encoding: "utf8", + env: { + ...process.env, + DISCOVER_RESULT: "success", + INSTALL_RESULT: "success", + QUALITY_RESULT: "success", + PROJECT_COUNT: "1", + PROJECT_RESULT: "success", + RUNTIME_COUNT: selected ? "1" : "0", + RUNTIME_RESULT: expected, + ASSET_CATALOG_RESULT: expected, + ASSET_CATALOG_SELECTED: String(selected), + CLI_UNIT_RESULT: expected, + CLI_E2E_RESULT: expected, + CLI_COVERAGE_RESULT: expected, + [variable]: status, + }, + }); + assert.equal( + result.status, + status === expected ? 0 : 1, + `selected=${selected}, ${job}=${status}`, + ); + } } } }); From 8de8b8fc7f364e880dc6296006f700b17fd926d8 Mon Sep 17 00:00:00 2001 From: betegon Date: Thu, 29 Oct 2026 21:03:12 +0100 Subject: [PATCH 2/8] ci(cli): trim unit shard environment setup --- .github/workflows/test.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efd43b356..d759c63ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -216,12 +216,6 @@ jobs: fail-fast: false matrix: shard: [1, 2, 3] - env: - BRAINTRUST_API_KEY: ci-test-key - GITHUB_TOKEN: ci-test-token - HONEYCOMB_API_KEY: ci-test-key - SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} - SENTRY_OAUTH_CLIENT_SECRET: ci-test-secret steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -231,7 +225,6 @@ jobs: with: node-version-file: .github/node-versions/22 cache: pnpm - - run: cp .env.example .env.local - run: pnpm install --frozen-lockfile - name: Install zsh for CLI completion regression tests run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh From 6daf5b452e17cb810b096a2f8e82d2abf0cf59de Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 10:41:36 +0100 Subject: [PATCH 3/8] ci(cli): derive shard count from the matrix --- .github/workflows/test.yml | 24 +++++++++++++++--------- docs/operations/github-actions.md | 12 ++++++------ scripts/ci-projects.test.mjs | 29 +++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d759c63ca..921042067 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -207,21 +207,23 @@ jobs: [[ -z "$(git status --porcelain --untracked-files=all)" ]] cli-unit: - name: CLI unit tests (${{ matrix.shard }}/3) + name: CLI unit tests (${{ matrix.shard }}/${{ strategy.job-total }}) needs: discover-projects if: contains(fromJSON(needs.discover-projects.outputs.matrix).include.*.name, 'sentry') runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + shard-count: ${{ strategy.job-total }} strategy: fail-fast: false matrix: shard: [1, 2, 3] steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@v7 with: node-version-file: .github/node-versions/22 cache: pnpm @@ -234,8 +236,9 @@ jobs: - name: Test CLI shard env: SHARD: ${{ matrix.shard }} - run: pnpm --filter sentry run ci:test:unit --shard="$SHARD/3" --reporter=default --reporter=blob --outputFile.blob="coverage/shard-$SHARD.json" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + SHARD_COUNT: ${{ strategy.job-total }} + run: pnpm --filter sentry run ci:test:unit --shard="$SHARD/$SHARD_COUNT" --reporter=default --reporter=blob --outputFile.blob="coverage/shard-$SHARD.json" + - uses: actions/upload-artifact@v7 with: name: cli-unit-coverage-${{ matrix.shard }} path: packages/cli/coverage/shard-${{ matrix.shard }}.json @@ -254,23 +257,26 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@v7 with: persist-credentials: false - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + - uses: actions/setup-node@v7 with: node-version-file: .github/node-versions/22 cache: pnpm - run: pnpm install --frozen-lockfile - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@v8 with: pattern: cli-unit-coverage-* path: ${{ runner.temp }}/cli-unit-reports merge-multiple: true - name: Require every shard report + env: + SHARD_COUNT: ${{ needs.cli-unit.outputs.shard-count }} run: | - for shard in 1 2 3; do + test "$SHARD_COUNT" -gt 0 + for ((shard = 1; shard <= SHARD_COUNT; shard++)); do test -s "$RUNNER_TEMP/cli-unit-reports/shard-$shard.json" done - name: Merge CLI coverage diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 3d9ac3a97..dbcfb9a88 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -29,16 +29,16 @@ all three Node versions. When the CLI is selected, its lint, typecheck, and policy checks run in the project job. Unit tests run on Node 22 in three parallel shards, with the same test directories and coverage as `pnpm --filter sentry test:unit`. Each shard -uploads a Vitest blob report; `CLI coverage` requires all three reports before -merging them into `packages/cli/coverage/lcov.info`. +uploads a Vitest blob report; `CLI coverage` requires a report from every shard +before merging them into `packages/cli/coverage/lcov.info`. `CLI E2E (Node 24)` runs independently of the unit tests and project checks. It builds the standalone CLI on Node 22 and the npm bundle on Node 24, then tests both on Node 24. The project, unit-test, and E2E jobs each build their workspace -dependencies and run `ci:prepare` once before using the prepared sources. The regular development -commands still generate their prerequisites. The final `test` gate requires -the unit shards, coverage merge, and E2E job whenever the CLI is selected, and -requires them to be skipped otherwise. +dependencies and run `ci:prepare` once before using the prepared sources. Public +build and test commands still prepare their own inputs for local use. The final +`test` gate requires the unit shards, coverage merge, and E2E job whenever the +CLI is selected, and requires them to be skipped otherwise. ### deploy.yml diff --git a/scripts/ci-projects.test.mjs b/scripts/ci-projects.test.mjs index c96c4dfb8..8eab2cc95 100644 --- a/scripts/ci-projects.test.mjs +++ b/scripts/ci-projects.test.mjs @@ -288,13 +288,38 @@ describe("workspace CI selection", () => { JSON.stringify({ shard }), ); } - const requireReports = () => + const requireReports = (count = "3") => spawnSync("bash", ["-euc", script], { encoding: "utf8", - env: { ...process.env, RUNNER_TEMP: directory }, + env: { ...process.env, RUNNER_TEMP: directory, SHARD_COUNT: count }, }).status; assert.equal(requireReports(), 0, "all three shard reports are present"); + assert.equal( + requireReports("2"), + 0, + "a smaller matrix needs fewer reports", + ); + assert.equal( + requireReports("4"), + 1, + "a larger matrix needs another report", + ); + writeFileSync( + join(reports, "shard-4.json"), + JSON.stringify({ shard: 4 }), + ); + assert.equal( + requireReports("4"), + 0, + "all four shard reports are present", + ); + assert.notEqual(requireReports(""), 0, "the shard count is required"); + assert.notEqual( + requireReports("0"), + 0, + "the shard count must be positive", + ); const secondReport = join(reports, "shard-2.json"); rmSync(secondReport); assert.equal(requireReports(), 1, "the middle shard report is missing"); From 314f0827c838eaf09f2ffb2761f546a8cca0ba51 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 13:46:19 +0100 Subject: [PATCH 4/8] ci(cli): trim coverage-only installation --- .github/workflows/test.yml | 2 +- docs/operations/github-actions.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 921042067..4720c4b68 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -265,7 +265,7 @@ jobs: with: node-version-file: .github/node-versions/22 cache: pnpm - - run: pnpm install --frozen-lockfile + - run: pnpm --filter sentry install --frozen-lockfile --ignore-scripts - uses: actions/download-artifact@v8 with: pattern: cli-unit-coverage-* diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index dbcfb9a88..50188e6c8 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -31,6 +31,8 @@ project job. Unit tests run on Node 22 in three parallel shards, with the same test directories and coverage as `pnpm --filter sentry test:unit`. Each shard uploads a Vitest blob report; `CLI coverage` requires a report from every shard before merging them into `packages/cli/coverage/lcov.info`. +The merge job installs the CLI dependency set without install scripts; it does +not build packages or rerun tests. `CLI E2E (Node 24)` runs independently of the unit tests and project checks. It builds the standalone CLI on Node 22 and the npm bundle on Node 24, then tests From f3b6c01e8c4a2211c35c1c9ec40d9e40722a9c89 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 17:29:01 +0100 Subject: [PATCH 5/8] ci(cli): install only unit-test dependencies --- .github/workflows/test.yml | 2 +- docs/operations/github-actions.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4720c4b68..d39ffca0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -227,7 +227,7 @@ jobs: with: node-version-file: .github/node-versions/22 cache: pnpm - - run: pnpm install --frozen-lockfile + - run: pnpm --filter 'sentry...' install --frozen-lockfile - name: Install zsh for CLI completion regression tests run: sudo apt-get update && sudo apt-get install -y --no-install-recommends zsh - name: Build project dependencies diff --git a/docs/operations/github-actions.md b/docs/operations/github-actions.md index 50188e6c8..a117509ad 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -28,7 +28,8 @@ all three Node versions. When the CLI is selected, its lint, typecheck, and policy checks run in the project job. Unit tests run on Node 22 in three parallel shards, with the same -test directories and coverage as `pnpm --filter sentry test:unit`. Each shard +test directories and coverage as `pnpm --filter sentry test:unit`. Shards install +only the CLI and its workspace dependencies, retaining install scripts. Each uploads a Vitest blob report; `CLI coverage` requires a report from every shard before merging them into `packages/cli/coverage/lcov.info`. The merge job installs the CLI dependency set without install scripts; it does From 1c131fc515aa4dbd6c01a798facb5faf64331420 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 20:08:40 +0000 Subject: [PATCH 6/8] ci(cli): cap unit shards at 10 minutes The shards took 2m43s to 4m21s across the last three runs, so a 30-minute timeout only delayed failure on a hung shard. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017M6odVf4dibAURdbCfXv4q --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d39ffca0f..7ba31316f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -211,7 +211,7 @@ jobs: needs: discover-projects if: contains(fromJSON(needs.discover-projects.outputs.matrix).include.*.name, 'sentry') runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 10 outputs: shard-count: ${{ strategy.job-total }} strategy: From 97591cf8ca37fa2b8a0c32c16e3f3575c1baa85d Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 20:08:40 +0000 Subject: [PATCH 7/8] ci(cli): drop no-op sentryCi overrides `coverage: false` already follows from `test: false`, and discovery picks `ci:policy` by default. The generated matrix is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017M6odVf4dibAURdbCfXv4q --- packages/cli/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0556ebd9b..c42c0fa1b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,10 +51,8 @@ "packageManager": "pnpm@10.11.0", "sentryCi": { "build": false, - "coverage": false, "e2e": false, "npmRuntime": "bundle", - "policy": "ci:policy", "test": false, "typecheck": "ci:typecheck" }, From 16081372cd712a59f6312f005c9dca0fc1506cb2 Mon Sep 17 00:00:00 2001 From: betegon Date: Fri, 30 Oct 2026 20:08:40 +0000 Subject: [PATCH 8/8] test(ci): drop tests that assert test.yml's own text Remove the tests that matched workflow text with regexes or extracted its bash and ran it from Node, and the asserts that mirrored package.json values. The remaining tests cover ci-projects.mjs logic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017M6odVf4dibAURdbCfXv4q --- scripts/ci-projects.test.mjs | 292 +---------------------------------- 1 file changed, 2 insertions(+), 290 deletions(-) diff --git a/scripts/ci-projects.test.mjs b/scripts/ci-projects.test.mjs index 8eab2cc95..39e7a8fd3 100644 --- a/scripts/ci-projects.test.mjs +++ b/scripts/ci-projects.test.mjs @@ -1,14 +1,6 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import { - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { resolve } from "node:path"; import { describe, it } from "node:test"; import { @@ -42,27 +34,6 @@ describe("workspace CI selection", () => { assert.ok(names.includes("@sentry/mcp-core")); assert.ok(!names.includes("@sentry/mcp-smoke-tests")); assert.equal(new Set(names).size, names.length); - const docs = matrix.include.find(({ name }) => name === "sentry-cli-docs"); - assert.equal(docs.prepare, "ci:prepare"); - const docsScripts = JSON.parse( - readFileSync(resolve(root, "apps/cli-docs/package.json"), "utf8"), - ).scripts; - assert.match(docsScripts[docs.prepare], /sentry run generate:schema/); - assert.match(docsScripts[docs.prepare], /sentry run generate:docs/); - const cli = matrix.include.find(({ name }) => name === "sentry"); - assert.equal(cli.prepare, "ci:prepare"); - assert.equal(cli.build, ""); - assert.equal(cli.lint, "lint"); - assert.equal(cli.typecheck, "ci:typecheck"); - assert.equal(cli.policy, "ci:policy"); - assert.equal(cli.test, ""); - assert.equal(cli.e2e, ""); - assert.equal(cli.coverage, ""); - assert.equal(cli.npmRuntime, "bundle"); - assert.equal( - matrix.include.find(({ name }) => name === "@sentry/mcp-core").junit, - "packages/mcp-core/tests.junit.xml", - ); }); it("runs only affected projects and their transitive consumers", () => { @@ -174,265 +145,6 @@ describe("workspace CI selection", () => { } }); - it("keeps a stable CI status and one dynamic job per package", () => { - const workflow = readFileSync( - resolve(root, ".github/workflows/test.yml"), - "utf8", - ); - assert.match(workflow, /merge_group:/); - assert.match(workflow, /node scripts\/ci-projects\.mjs --event/); - assert.match(workflow, /name: \$\{\{ matrix\.name \}\}/); - assert.match(workflow, /if: matrix\.prepare != ''/); - assert.match(workflow, /CHECK_SCRIPT: \$\{\{ matrix\.prepare \}\}/); - assert.doesNotMatch(workflow, /matrix\.name == 'sentry-cli-docs'/); - const gate = workflow.split("\n test:\n")[1]; - assert.ok(gate); - const dependencies = gate.match(/needs:\s*\[([^\]]+)\]/)?.[1]; - assert.ok(dependencies); - assert.deepEqual( - new Set( - dependencies - .split(",") - .map((dependency) => dependency.trim()) - .filter(Boolean), - ), - new Set([ - "discover-projects", - "install", - "quality", - "project", - "npm-runtime", - "cli-asset-catalog", - "cli-unit", - "cli-e2e", - "cli-coverage", - ]), - ); - assert.match( - workflow, - /runtime-matrix: \$\{\{ steps\.projects\.outputs\.runtime-matrix \}\}/, - ); - assert.match( - workflow, - /matrix: \$\{\{ fromJSON\(needs\.discover-projects\.outputs\.runtime-matrix\) \}\}/, - ); - assert.match( - workflow, - /if: needs\.discover-projects\.outputs\.runtime-count != '0'/, - ); - assert.match(workflow, /Build and pack once on Node 22/); - assert.match(workflow, /Validate packaged runtime on Node 20/); - assert.match(workflow, /Validate packaged runtime on Node 22/); - assert.match(workflow, /Validate packaged runtime on Node 24/); - for (const major of [20, 22, 24]) { - const versionFile = `.github/node-versions/${major}`; - assert.ok(workflow.includes(`node-version-file: ${versionFile}`)); - assert.match( - readFileSync(resolve(root, versionFile), "utf8").trim(), - new RegExp(`^${major}\\.\\d+\\.\\d+$`), - ); - } - assert.match(workflow, /RUNTIME_RESULT.*needs\.npm-runtime\.result/); - assert.match(workflow, /name: Verify JUnit/); - assert.match(workflow, /name: Verify coverage/); - assert.match(workflow, /if: always\(\)/); - assert.doesNotMatch(workflow.split("\njobs:", 1)[0], /paths(?:-ignore)?:/); - }); - - it("runs three CLI unit shards and E2E independently after discovery", () => { - const workflow = readFileSync( - resolve(root, ".github/workflows/test.yml"), - "utf8", - ); - for (const name of ["cli-unit", "cli-e2e"]) { - const job = workflow - .split(`\n ${name}:\n`)[1] - ?.split(/\n [\w-]+:\n/)[0]; - assert.ok(job, `${name} job exists`); - assert.match(job, /\n needs: discover-projects\n/); - assert.match( - job, - /if: contains\(fromJSON\(needs\.discover-projects\.outputs\.matrix\)\.include\.\*\.name, 'sentry'\)/, - ); - if (name === "cli-unit") { - assert.match(job, /fail-fast: false/); - assert.match(job, /shard: \[1, 2, 3\]/); - assert.match(job, /--outputFile\.blob="coverage\/shard-\$SHARD\.json"/); - assert.match( - job, - /path: packages\/cli\/coverage\/shard-\$\{\{ matrix\.shard \}\}\.json/, - ); - assert.match(job, /if-no-files-found: error/); - } - } - }); - - it("rejects missing or empty shard reports before merging coverage", () => { - const workflow = readFileSync( - resolve(root, ".github/workflows/test.yml"), - "utf8", - ); - const script = workflow - .split(" - name: Require every shard report\n")[1] - ?.split(" run: |\n")[1] - ?.split("\n - ")[0]; - assert.ok(script); - - const directory = mkdtempSync(join(tmpdir(), "ci-shard-reports-")); - try { - const reports = join(directory, "cli-unit-reports"); - mkdirSync(reports); - for (const shard of [1, 2, 3]) { - writeFileSync( - join(reports, `shard-${shard}.json`), - JSON.stringify({ shard }), - ); - } - const requireReports = (count = "3") => - spawnSync("bash", ["-euc", script], { - encoding: "utf8", - env: { ...process.env, RUNNER_TEMP: directory, SHARD_COUNT: count }, - }).status; - - assert.equal(requireReports(), 0, "all three shard reports are present"); - assert.equal( - requireReports("2"), - 0, - "a smaller matrix needs fewer reports", - ); - assert.equal( - requireReports("4"), - 1, - "a larger matrix needs another report", - ); - writeFileSync( - join(reports, "shard-4.json"), - JSON.stringify({ shard: 4 }), - ); - assert.equal( - requireReports("4"), - 0, - "all four shard reports are present", - ); - assert.notEqual(requireReports(""), 0, "the shard count is required"); - assert.notEqual( - requireReports("0"), - 0, - "the shard count must be positive", - ); - const secondReport = join(reports, "shard-2.json"); - rmSync(secondReport); - assert.equal(requireReports(), 1, "the middle shard report is missing"); - writeFileSync(secondReport, ""); - assert.equal(requireReports(), 1, "the middle shard report is empty"); - } finally { - rmSync(directory, { recursive: true, force: true }); - } - }); - - it("requires native asset-catalog success when selected and a skip otherwise", () => { - const workflow = readFileSync( - resolve(root, ".github/workflows/test.yml"), - "utf8", - ); - const gate = workflow.split("\n test:\n")[1]; - assert.ok(gate); - assert.match( - gate, - /ASSET_CATALOG_RESULT: \$\{\{ needs\.cli-asset-catalog\.result \}\}/, - ); - assert.match( - gate, - /ASSET_CATALOG_SELECTED: \$\{\{ contains\(fromJSON\(needs\.discover-projects\.outputs\.matrix\)\.include\.\*\.name, 'sentry'\) \}\}/, - ); - const script = gate.split(" run: |\n")[1]; - assert.ok(script); - - for (const [selected, nativeResult, count, passes] of [ - [true, "success", "1", true], - [true, "failure", "1", false], - [true, "cancelled", "1", false], - [true, "skipped", "1", false], - [false, "skipped", "1", true], - [false, "success", "1", false], - [false, "failure", "1", false], - [false, "cancelled", "1", false], - [false, "skipped", "0", true], - ]) { - const result = spawnSync("bash", ["-euc", script], { - encoding: "utf8", - env: { - ...process.env, - DISCOVER_RESULT: "success", - INSTALL_RESULT: "success", - QUALITY_RESULT: "success", - PROJECT_COUNT: count, - PROJECT_RESULT: count === "0" ? "skipped" : "success", - RUNTIME_COUNT: count, - RUNTIME_RESULT: count === "0" ? "skipped" : "success", - ASSET_CATALOG_RESULT: nativeResult, - ASSET_CATALOG_SELECTED: String(selected), - CLI_UNIT_RESULT: selected ? "success" : "skipped", - CLI_E2E_RESULT: selected ? "success" : "skipped", - CLI_COVERAGE_RESULT: selected ? "success" : "skipped", - }, - }); - assert.equal( - result.status, - passes ? 0 : 1, - `selected=${selected}, native=${nativeResult}, projects=${count}`, - ); - } - }); - - it("requires every selected CLI test job and rejects unexpected skips", () => { - const workflow = readFileSync( - resolve(root, ".github/workflows/test.yml"), - "utf8", - ); - const gate = workflow.split("\n test:\n")[1]; - assert.ok(gate); - const script = gate.split(" run: |\n")[1]; - assert.ok(script); - - for (const [variable, job] of [ - ["CLI_UNIT_RESULT", "cli-unit"], - ["CLI_E2E_RESULT", "cli-e2e"], - ["CLI_COVERAGE_RESULT", "cli-coverage"], - ]) { - assert.ok(gate.includes(`${variable}: \${{ needs.${job}.result }}`)); - for (const selected of [true, false]) { - const expected = selected ? "success" : "skipped"; - for (const status of ["success", "failure", "cancelled", "skipped"]) { - const result = spawnSync("bash", ["-euc", script], { - encoding: "utf8", - env: { - ...process.env, - DISCOVER_RESULT: "success", - INSTALL_RESULT: "success", - QUALITY_RESULT: "success", - PROJECT_COUNT: "1", - PROJECT_RESULT: "success", - RUNTIME_COUNT: selected ? "1" : "0", - RUNTIME_RESULT: expected, - ASSET_CATALOG_RESULT: expected, - ASSET_CATALOG_SELECTED: String(selected), - CLI_UNIT_RESULT: expected, - CLI_E2E_RESULT: expected, - CLI_COVERAGE_RESULT: expected, - [variable]: status, - }, - }); - assert.equal( - result.status, - status === expected ? 0 : 1, - `selected=${selected}, ${job}=${status}`, - ); - } - } - } - }); - it("requires safe metadata and a single owner of each specialized role", () => { assert.throws( () =>