diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 004fbccb6..7ba31316f 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,84 @@ jobs: git diff --exit-code [[ -z "$(git status --porcelain --untracked-files=all)" ]] + cli-unit: + 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: 10 + outputs: + shard-count: ${{ strategy.job-total }} + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@v7 + with: + node-version-file: .github/node-versions/22 + cache: pnpm + - 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 + run: pnpm --filter 'sentry^...' --if-present run build + - run: pnpm --filter sentry run ci:prepare + - name: Test CLI shard + env: + SHARD: ${{ matrix.shard }} + 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 + 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@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 + - uses: actions/setup-node@v7 + with: + node-version-file: .github/node-versions/22 + cache: pnpm + - run: pnpm --filter sentry install --frozen-lockfile --ignore-scripts + - 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: | + 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 + 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 +397,8 @@ jobs: project, npm-runtime, cli-asset-catalog, + cli-unit, + cli-coverage, cli-e2e, ] runs-on: ubuntu-latest @@ -337,24 +414,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..a117509ad 100644 --- a/docs/operations/github-actions.md +++ b/docs/operations/github-actions.md @@ -26,14 +26,22 @@ 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`. 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 +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 +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. 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/packages/cli/package.json b/packages/cli/package.json index 71fc1aa0b..c42c0fa1b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,11 +51,9 @@ "packageManager": "pnpm@10.11.0", "sentryCi": { "build": false, - "coverage": "coverage/lcov.info", "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..39e7a8fd3 100644 --- a/scripts/ci-projects.test.mjs +++ b/scripts/ci-projects.test.mjs @@ -1,6 +1,5 @@ import assert from "node:assert/strict"; -import { execFileSync, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { resolve } from "node:path"; import { describe, it } from "node:test"; @@ -35,25 +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.typecheck, "ci:typecheck"); - assert.equal(cli.test, "ci:test:unit"); - assert.equal(cli.policy, "ci:policy"); - assert.equal(cli.e2e, ""); - assert.equal(cli.coverage, "packages/cli/coverage/lcov.info"); - 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", () => { @@ -165,158 +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'/); - 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*\]/, - ); - 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 CLI E2E independently after project 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'\)/, - ); - }); - - 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_E2E_RESULT: selected ? "success" : "skipped", - }, - }); - assert.equal( - result.status, - passes ? 0 : 1, - `selected=${selected}, native=${nativeResult}, projects=${count}`, - ); - } - }); - - it("requires CLI E2E 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, /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}`, - ); - } - } - }); - it("requires safe metadata and a single owner of each specialized role", () => { assert.throws( () =>