ci: balance e2e shards by test duration (#2489)
This commit is contained in:
@@ -340,7 +340,9 @@ jobs:
|
||||
test-windows:
|
||||
name: Backend (windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
# The race-sensitive Windows package suite can exceed 15 minutes on a
|
||||
# busy hosted runner; keep the cap finite while allowing normal completion.
|
||||
timeout-minutes: 20
|
||||
# Least-privilege: this job only reads source and runs tests, no PR/issue
|
||||
# writes or release operations. Flagged by CodeQL workflow audit + matching
|
||||
# CodeRabbit nitpick.
|
||||
|
||||
+297
-11
@@ -26,6 +26,12 @@ on:
|
||||
# Manual trigger for verifying CI on draft PRs and ad-hoc reruns. The
|
||||
# default sharded run uses the same ref the user picks in the dispatch UI.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
fail_on_flaky:
|
||||
description: "Fail the run when Playwright needs a retry"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -33,6 +39,8 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# The build/report jobs read the latest successful main timing artifact.
|
||||
actions: read
|
||||
# Required so the containerized build / e2e shards / e2e-report jobs can
|
||||
# pull the kandev-ci image from GHCR via GITHUB_TOKEN.
|
||||
packages: read
|
||||
@@ -92,6 +100,98 @@ jobs:
|
||||
- name: Build Docker E2E helper binaries
|
||||
run: make build-backend-linux-helpers
|
||||
|
||||
- name: Locate latest successful main timing profile
|
||||
id: main-timing-run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
(async () => {
|
||||
const url =
|
||||
process.env.GITHUB_API_URL +
|
||||
"/repos/" +
|
||||
process.env.GITHUB_REPOSITORY +
|
||||
"/actions/workflows/e2e-tests.yml/runs?branch=main&status=success&per_page=20";
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: "Bearer " + process.env.GH_TOKEN,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.log(
|
||||
"::warning::Unable to query successful main E2E runs (" +
|
||||
response.status +
|
||||
"); using count fallback.",
|
||||
);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=\n");
|
||||
return;
|
||||
}
|
||||
const payload = await response.json();
|
||||
const run = (payload.workflow_runs ?? []).find(
|
||||
(candidate) => candidate.head_branch === "main" && candidate.conclusion === "success",
|
||||
);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=" + (run?.id ?? "") + "\n");
|
||||
console.log(
|
||||
run
|
||||
? "Using successful main E2E run " + run.id + " for timing history."
|
||||
: "No successful main E2E run found; using count fallback.",
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_id=\n");
|
||||
});
|
||||
NODE
|
||||
|
||||
- name: Download latest main timing profile
|
||||
if: steps.main-timing-run.outputs.run_id != ''
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-timing-profile
|
||||
path: e2e/previous-profile
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ steps.main-timing-run.outputs.run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Upload selected timing baseline for report job
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-selected-main-timing-profile
|
||||
path: e2e/previous-profile/timing-profile.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
- name: Generate duration-aware E2E shard manifests
|
||||
working-directory: apps/web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf "$GITHUB_WORKSPACE/e2e-manifests"
|
||||
if [[ -f "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json" ]]; then
|
||||
echo "Planning from the latest successful main timing profile."
|
||||
pnpm exec tsx e2e/scripts/plan-shards.ts \
|
||||
--web-root "$GITHUB_WORKSPACE/apps/web" \
|
||||
--output-dir "$GITHUB_WORKSPACE/e2e-manifests" \
|
||||
--profile "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json"
|
||||
else
|
||||
echo "Planning with deterministic count fallback; no usable main timing profile was downloaded."
|
||||
pnpm exec tsx e2e/scripts/plan-shards.ts \
|
||||
--web-root "$GITHUB_WORKSPACE/apps/web" \
|
||||
--output-dir "$GITHUB_WORKSPACE/e2e-manifests"
|
||||
fi
|
||||
|
||||
- name: Upload E2E shard manifests
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-shard-manifests
|
||||
path: e2e-manifests/
|
||||
retention-days: 3
|
||||
|
||||
- name: Upload backend build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -197,14 +297,26 @@ jobs:
|
||||
working-directory: apps/web
|
||||
run: tar -xf web-dist.tar && rm web-dist.tar
|
||||
|
||||
- name: Enable strict flaky-test diagnostics
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fail_on_flaky
|
||||
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download E2E shard manifests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-shard-manifests
|
||||
path: e2e-manifests
|
||||
|
||||
# Pinned to chromium + mobile-chrome so the dedicated `e2e-containers`
|
||||
# job below owns the real-Docker project (which covers both the Docker
|
||||
# executor and the SSH executor's sshd target). Without --project the
|
||||
# container-bound specs would run here too, fail seeding (Docker not
|
||||
# enabled for these shards), and spam every shard.
|
||||
- name: Run E2E tests (shard ${{ matrix.shard }}/14)
|
||||
- name: Run planned E2E tests (shard ${{ matrix.shard }}/14)
|
||||
working-directory: apps/web
|
||||
run: npx playwright test --config e2e/playwright.config.ts --project=chromium --project=mobile-chrome --shard=${{ matrix.shard }}/14
|
||||
env:
|
||||
E2E_SHARD: ${{ matrix.shard }}
|
||||
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/normal/${{ matrix.shard }}.json"
|
||||
|
||||
- name: Upload blob report
|
||||
if: always()
|
||||
@@ -225,9 +337,8 @@ jobs:
|
||||
# Real-Docker E2E: exercises the local_docker executor end-to-end.
|
||||
# ubuntu-latest ships with Docker preinstalled, so no service container is
|
||||
# required. Container-bound tests are slow (~2 min each), so we shard across
|
||||
# 6 runners — Playwright's --shard splits the project's tests into N groups.
|
||||
# Cleans up any leftover kandev-managed containers at the end so a leaky
|
||||
# process doesn't poison the next run on the same self-hosted runner.
|
||||
# 6 runners — the build job creates a duration-aware manifest for this
|
||||
# cohort, and each runner executes its explicit file selection.
|
||||
#
|
||||
# This job runs the `containers` Playwright project, which covers both the
|
||||
# Docker executor and the SSH executor (since the SSH executor's e2e tests
|
||||
@@ -366,15 +477,22 @@ jobs:
|
||||
working-directory: apps/web
|
||||
run: tar -xf web-dist.tar && rm web-dist.tar
|
||||
|
||||
- name: Run container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor)
|
||||
- name: Enable strict flaky-test diagnostics
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.fail_on_flaky
|
||||
run: echo "E2E_FAIL_ON_FLAKY=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download E2E shard manifests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-shard-manifests
|
||||
path: e2e-manifests
|
||||
|
||||
- name: Run planned container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor)
|
||||
working-directory: apps/web
|
||||
env:
|
||||
KANDEV_E2E_CONTAINERS: "1"
|
||||
run: npx playwright test --config e2e/playwright.config.ts --project=containers --shard=${{ matrix.shard }}/6
|
||||
|
||||
- name: Cleanup leftover kandev containers
|
||||
if: always()
|
||||
run: docker ps -aq --filter label=kandev.managed=true | xargs -r docker rm -f
|
||||
E2E_SHARD: ${{ matrix.shard }}
|
||||
run: bash e2e/scripts/run-planned-shard.sh "$GITHUB_WORKSPACE/e2e-manifests/containers/${{ matrix.shard }}.json"
|
||||
|
||||
# Named with the same `blob-report-*` prefix the merge job globs for
|
||||
# so the containers results land in the unified HTML report alongside
|
||||
@@ -487,6 +605,16 @@ jobs:
|
||||
working-directory: apps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The published runtime image may predate the report-tool dependency;
|
||||
# keep older images usable while the image rebuild adds unzip permanently.
|
||||
- name: Ensure timing report tools are installed
|
||||
run: |
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends unzip
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
fi
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -494,7 +622,165 @@ jobs:
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download E2E shard manifests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-shard-manifests
|
||||
path: e2e-manifests
|
||||
|
||||
- name: Download selected main timing profile
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: e2e-selected-main-timing-profile
|
||||
path: e2e/previous-profile
|
||||
|
||||
- name: Collect timing and retry diagnostics
|
||||
if: always()
|
||||
working-directory: apps/web
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json" ]]; then
|
||||
pnpm exec tsx e2e/scripts/e2e-timings.ts \
|
||||
--input e2e/blob-reports \
|
||||
--output e2e/timing-profile.json \
|
||||
--web-root "$GITHUB_WORKSPACE/apps/web" \
|
||||
--branch "$GITHUB_REF_NAME" \
|
||||
--sha "$GITHUB_SHA" \
|
||||
--run-id "$GITHUB_RUN_ID" \
|
||||
--previous "$GITHUB_WORKSPACE/e2e/previous-profile/timing-profile.json"
|
||||
else
|
||||
pnpm exec tsx e2e/scripts/e2e-timings.ts \
|
||||
--input e2e/blob-reports \
|
||||
--output e2e/timing-profile.json \
|
||||
--web-root "$GITHUB_WORKSPACE/apps/web" \
|
||||
--branch "$GITHUB_REF_NAME" \
|
||||
--sha "$GITHUB_SHA" \
|
||||
--run-id "$GITHUB_RUN_ID"
|
||||
fi
|
||||
pnpm exec tsx e2e/scripts/retry-summary.ts \
|
||||
--input e2e/blob-reports \
|
||||
--output e2e/retry-summary.json \
|
||||
--manifest-dir "$GITHUB_WORKSPACE/e2e-manifests" \
|
||||
--attachment-output e2e/retry-artifacts \
|
||||
--artifact-name e2e-timing-diagnostics \
|
||||
--artifact-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID#artifacts"
|
||||
|
||||
- name: Write E2E timing summary
|
||||
if: always()
|
||||
working-directory: apps/web
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const summaryPath = "e2e/retry-summary.json";
|
||||
if (!fs.existsSync(summaryPath)) process.exit(0);
|
||||
const summary = JSON.parse(fs.readFileSync(summaryPath, "utf8"));
|
||||
const counts = summary.counts;
|
||||
const diagnosticTests = summary.tests.filter(
|
||||
(test) => test.attempts > 1 || !["passed", "skipped"].includes(test.finalStatus),
|
||||
);
|
||||
const markdown = (value) => String(value).replaceAll("|", "\\|").replace(/\s+/g, " ");
|
||||
const lines = [
|
||||
"## E2E timing and retry summary",
|
||||
"",
|
||||
"- Plan mode: " + summary.planning.mode,
|
||||
"- Unknown units: " + summary.planning.unknownUnits,
|
||||
"- Warm units: " + summary.planning.warmUnits,
|
||||
"- Stale profile units: " + summary.planning.staleUnits,
|
||||
"- Passed after retry: " + counts.passedAfterRetry,
|
||||
"- Final failures: " + (counts.failed + counts.timedOut),
|
||||
"",
|
||||
"| Cohort | Shard | Predicted (s) | Actual (s) | Delta (s) | Exit |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: |",
|
||||
...summary.shards.map(
|
||||
(shard) =>
|
||||
"| " +
|
||||
shard.cohort +
|
||||
" | " +
|
||||
shard.shard +
|
||||
"/" +
|
||||
shard.shardCount +
|
||||
" | " +
|
||||
shard.predictedSeconds.toFixed(1) +
|
||||
" | " +
|
||||
shard.actualSeconds.toFixed(1) +
|
||||
" | " +
|
||||
shard.deltaSeconds.toFixed(1) +
|
||||
" | " +
|
||||
shard.exitCode +
|
||||
" |",
|
||||
),
|
||||
];
|
||||
if (diagnosticTests.length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"### Retry and failure details",
|
||||
"",
|
||||
"| Test | Attempts | Final status | Error | Artifacts |",
|
||||
"| --- | ---: | --- | --- | --- |",
|
||||
...diagnosticTests.map((test) => {
|
||||
const links = test.attachments
|
||||
.map((attachment) =>
|
||||
attachment.artifactUrl
|
||||
? "[" + markdown(attachment.name + " — " + attachment.path) + "](" +
|
||||
attachment.artifactUrl +
|
||||
")"
|
||||
: markdown(attachment.name),
|
||||
)
|
||||
.join(", ");
|
||||
return (
|
||||
"| `" +
|
||||
markdown(test.key) +
|
||||
"` | " +
|
||||
test.attempts +
|
||||
" | " +
|
||||
test.finalStatus +
|
||||
" | " +
|
||||
test.errorCategory +
|
||||
" | " +
|
||||
(links || "Not captured") +
|
||||
" |"
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, lines.join("\n") + "\n");
|
||||
NODE
|
||||
|
||||
- name: Upload timing profile candidate
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-timing-profile
|
||||
path: apps/web/e2e/timing-profile.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload retry and shard timing summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-retry-summary
|
||||
path: apps/web/e2e/retry-summary.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload timing diagnostics bundle
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-timing-diagnostics
|
||||
path: |
|
||||
apps/web/e2e/timing-profile.json
|
||||
apps/web/e2e/retry-summary.json
|
||||
apps/web/e2e/retry-artifacts/
|
||||
apps/web/e2e/blob-reports/*metadata.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
- name: Merge reports
|
||||
if: hashFiles('apps/web/e2e/blob-reports/**/*.zip') != ''
|
||||
working-directory: apps/web
|
||||
# `-c e2e/playwright.config.ts` is required because the blobs come from
|
||||
# two different absolute checkout paths: the containerized `e2e` shards
|
||||
|
||||
Reference in New Issue
Block a user