Skip to content
Open
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
97 changes: 89 additions & 8 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Comment thread
BYK marked this conversation as resolved.
Resolved
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
Comment on lines +233 to +235

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this part can be cached/shared through artifacts to save time. We may even get incremental builds?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tested shared preparation:

4m41s versus 4m37s with preparation in each shard (single run).
So it make sense to keep jobs independent for now. Haven't tested cross-run build caching and incremental build. we'll look into it later

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do I not believe you now. Opus 5.5 can you get me fresh and true numbers with details please?

- 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
Expand Down Expand Up @@ -322,6 +397,8 @@ jobs:
project,
npm-runtime,
cli-asset-catalog,
cli-unit,
cli-coverage,
cli-e2e,
]
runs-on: ubuntu-latest
Expand All @@ -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"
24 changes: 16 additions & 8 deletions docs/operations/github-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 1 addition & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
174 changes: 1 addition & 173 deletions scripts/ci-projects.test.mjs
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
() =>
Expand Down
Loading