ci: run the work lanes on larger runners and merge the split jobs
Every Linux lane that does real work ran on a 4-core `ubuntu-latest`. The Python suite and the JS checks were split into many small jobs to make that size usable. Each split job repeated the full setup. In most of the JS jobs the repeated setup cost more than the work. The work lanes move to larger runners. Then the splits that existed only to make small runners usable go away. Python tests: 12 slices become 1 job on a 96-core runner. Slicing cost a matrix job, a duration cache, a per-slice artifact and a merge job. 96 cores clear the floor that the slowest single test file sets, which is about 82s. A second slice divides work that is already at that floor, and adds a second setup. Duration data from run 32522943054 gives the numbers behind this: 3178 files, 11645s in series. The worker count is explicit, because `run_tests.sh` defaults to twice the core count. A later commit sets it from a measurement on this hardware. JS checks: 14 jobs become 1. The matrix paid about 371s of repeated setup to spread about 612s of work. One larger runner installs one time. The three UI shard scripts and `run-ui-shard.mjs` are therefore removed, because the unsharded `test:ui` covers the same tests. The unit of parallel work inside that job is a CHECK, and not a workspace. apps/desktop is most of the payload, and its own `check` is a serial && chain. A spread across workspaces alone therefore leaves that chain as the long pole. A package that declares `check:*` sub-scripts gives one unit for each sub-script. That is the same selection rule the matrix used. The loop lives in `.github/scripts/run-workspace-checks.mjs`, so the same sequence runs on a laptop. It runs 11 units together, buffers the output of each one, and fails at the end with the full list. Children that share one stdout interleave their lines and make a failure hard to read. `npm run --ws check` stops at the first workspace that fails. `check:test:plugins` joins the desktop `check` script. The matrix prefers `check:*` sub-scripts over the plain `check` script, so `check:test:plugins` ran only as its own leg. Without this change the merge drops that suite and the job stays green. node_modules is cached on the lockfile, and `npm ci` is skipped on an exact hit. The `cache: npm` option of `setup-node` caches only the ~/.npm tarball cache, which leaves the extract and the postinstalls to pay again. The arm64 image build stays on a native arm64 runner. A build of linux/arm64 on an x64 host uses emulation. The docker test lane caps its workers at the core count. Each of those tests drives a container, so the docker daemon sets the limit and not the processor. `.github/actionlint.yaml` declares the runner labels. actionlint knows the GitHub-hosted labels only, and an undeclared label reads as an error that hides the real findings. The `detect` job checks out one file through a sparse checkout, and its timeout drops to 1 minute. It reads `scripts/ci/classify_changes.py` and nothing else. Verification: - actionlint reports 9 findings across all workflows. An unmodified HEAD with the same config reports the same 9. This change adds none. - A wrong label still fails. actionlint reports `ubuntu-latest-32-cor` and `ubuntu-latest-32-arm-cores`. - Every changed workflow parses, and `name` parses as a string. - A replay of the `save-durations` merge step against a three-artifact layout returns all 3178 entries. - An expansion of the npm script graph gives the same leaf commands for the parallel units and for a plain `npm run check`, in both directions. Against the 13-leg matrix the count is 13 to 11, and the whole difference is the three UI shards that collapse into one unsharded `check:test:ui`. - `--list` reports the 11 units, and a full local run completes and reports the time of each unit. - The runner labels cannot be verified here. The first real run is the test.
This commit is contained in:
9
.github/actionlint.yaml
vendored
Normal file
9
.github/actionlint.yaml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# actionlint knows only GitHub-hosted runner labels. An org admin names the
|
||||
# larger runners. Each one therefore reads as "unknown runner label" and hides
|
||||
# the real findings, unless this file declares it.
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- ubuntu-latest-96-core
|
||||
- ubuntu-latest-32-core
|
||||
- ubuntu-latest-32-arm-core
|
||||
- windows-latest-32-core
|
||||
141
.github/scripts/run-workspace-checks.mjs
vendored
Normal file
141
.github/scripts/run-workspace-checks.mjs
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
// Run every workspace check at the same time and report all failures.
|
||||
//
|
||||
// The unit of work is a CHECK, and not a workspace. A package that declares
|
||||
// `check:*` sub-scripts gives one unit for each sub-script. A package with a
|
||||
// plain `check` gives that. This is the same selection rule the old CI matrix
|
||||
// used, so the set of commands is unchanged. Only the schedule is different.
|
||||
//
|
||||
// This is not `npm run --ws check`, because that command is serial and stops
|
||||
// at the first workspace that fails. This runs every unit and fails at the
|
||||
// end with the full list.
|
||||
//
|
||||
// The output of each unit goes to a buffer and prints on completion inside a
|
||||
// group that collapses. Children that write to one stdout together interleave
|
||||
// their lines, and a failure is then hard to read.
|
||||
//
|
||||
// This also runs on a laptop: `node .github/scripts/run-workspace-checks.mjs`.
|
||||
// `--concurrency N` sets the limit. `--list` prints the units and exits.
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { availableParallelism } from 'node:os'
|
||||
|
||||
const IS_CI = Boolean(process.env.GITHUB_ACTIONS)
|
||||
const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
/** @returns {{pkg: string, script: string}[]} */
|
||||
function discoverUnits() {
|
||||
const raw = execFileSync(NPM, ['query', '.workspace'], {
|
||||
encoding: 'utf-8',
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
/** @type {{location: string, scripts?: Record<string,string>}[]} */
|
||||
const pkgs = JSON.parse(raw)
|
||||
|
||||
/** @type {{pkg: string, script: string}[]} */
|
||||
const units = []
|
||||
for (const pkg of pkgs) {
|
||||
const scripts = pkg.scripts || {}
|
||||
const subs = Object.keys(scripts).filter((s) => /^check:.+$/.test(s))
|
||||
if (subs.length > 0) {
|
||||
for (const script of subs) units.push({ pkg: pkg.location, script })
|
||||
} else if (scripts.check) {
|
||||
units.push({ pkg: pkg.location, script: 'check' })
|
||||
}
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
/** @param {{pkg: string, script: string}} unit */
|
||||
function runUnit(unit) {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now()
|
||||
const child = spawn(NPM, ['run', '--prefix', unit.pkg, unit.script], {
|
||||
// Buffer, and do not inherit. Children that share one stdout
|
||||
// interleave their lines, and a failure is then hard to read.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: process.platform === 'win32',
|
||||
})
|
||||
/** @type {Buffer[]} */
|
||||
const chunks = []
|
||||
child.stdout.on('data', (c) => chunks.push(c))
|
||||
child.stderr.on('data', (c) => chunks.push(c))
|
||||
child.on('error', (err) => {
|
||||
chunks.push(Buffer.from(`failed to spawn: ${err.message}\n`))
|
||||
resolve({ unit, code: 1, output: Buffer.concat(chunks).toString('utf-8'), ms: Date.now() - started })
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
unit,
|
||||
code: code ?? 1,
|
||||
output: Buffer.concat(chunks).toString('utf-8'),
|
||||
ms: Date.now() - started,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const argv = process.argv.slice(2)
|
||||
const units = discoverUnits()
|
||||
|
||||
if (units.length === 0) {
|
||||
console.error(
|
||||
'::error::No workspace package declares a check script — refusing to report green having run nothing.',
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (argv.includes('--list')) {
|
||||
for (const u of units) console.log(`${u.pkg} :: ${u.script}`)
|
||||
return
|
||||
}
|
||||
|
||||
const flagIdx = argv.indexOf('--concurrency')
|
||||
const concurrency = Math.max(
|
||||
1,
|
||||
flagIdx !== -1 ? Number(argv[flagIdx + 1]) : Math.min(units.length, availableParallelism()),
|
||||
)
|
||||
|
||||
console.log(`running ${units.length} checks, up to ${concurrency} at a time:`)
|
||||
for (const u of units) console.log(` ${u.pkg} :: ${u.script}`)
|
||||
console.log('')
|
||||
|
||||
const queue = [...units]
|
||||
/** @type {{unit: {pkg: string, script: string}, code: number, output: string, ms: number}[]} */
|
||||
const results = []
|
||||
|
||||
async function worker() {
|
||||
for (;;) {
|
||||
const unit = queue.shift()
|
||||
if (!unit) return
|
||||
const res = await runUnit(unit)
|
||||
results.push(res)
|
||||
const label = `${res.unit.pkg} :: ${res.unit.script}`
|
||||
const secs = (res.ms / 1000).toFixed(1)
|
||||
const status = res.code === 0 ? 'PASS' : 'FAIL'
|
||||
if (IS_CI) console.log(`::group::${status} ${label} (${secs}s)`)
|
||||
else console.log(`----- ${status} ${label} (${secs}s) -----`)
|
||||
process.stdout.write(res.output.endsWith('\n') ? res.output : res.output + '\n')
|
||||
if (IS_CI) console.log('::endgroup::')
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, units.length) }, worker))
|
||||
|
||||
const failed = results.filter((r) => r.code !== 0)
|
||||
console.log('\n=== summary ===')
|
||||
for (const r of [...results].sort((a, b) => b.ms - a.ms)) {
|
||||
console.log(
|
||||
` ${r.code === 0 ? 'pass' : 'FAIL'} ${(r.ms / 1000).toFixed(1).padStart(6)}s ${r.unit.pkg} :: ${r.unit.script}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (failed.length > 0) {
|
||||
for (const r of failed) console.error(`::error::${r.unit.pkg} :: ${r.unit.script} failed`)
|
||||
console.error(`::error::${failed.length} of ${results.length} checks failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`\nall ${results.length} checks passed`)
|
||||
}
|
||||
|
||||
await main()
|
||||
6
.github/workflows/ci.yaml
vendored
6
.github/workflows/ci.yaml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
detect:
|
||||
name: Detect affected areas
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 1
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
python_prod: ${{ steps.classify.outputs.python_prod }}
|
||||
@@ -61,6 +61,8 @@ jobs:
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
sparse-checkout: scripts/ci/classify_changes.py
|
||||
sparse-checkout-cone-mode: false
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -72,8 +74,6 @@ jobs:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
slice_count: 12
|
||||
|
||||
# macOS + Windows lanes. The main `tests` lane above is Linux-only, and
|
||||
# the OS-marked tests it collects are skipped there by design (see the
|
||||
|
||||
17
.github/workflows/docker.yml
vendored
17
.github/workflows/docker.yml
vendored
@@ -76,12 +76,14 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-latest-32-core
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
# arm64 builds on the native arm64 larger runner. A build of
|
||||
# linux/arm64 on an x64 host uses emulation.
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runner: ubuntu-latest-32-arm-core
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
@@ -169,7 +171,11 @@ jobs:
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
# Each of these tests drives a container, so the docker daemon sets
|
||||
# the limit and not the processor. This caps the workers. The
|
||||
# default from run_tests.sh is cpu_count*2, which starts 64
|
||||
# containers together on the 32-core amd64 runner.
|
||||
HERMES_TEST_WORKERS=$(nproc) scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebuild and push each architecture only after the unprivileged build/test
|
||||
@@ -184,12 +190,13 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
runner: ubuntu-latest-32-core
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
# Native arm64 for the same reason as the build matrix above.
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runner: ubuntu-latest-32-arm-core
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
5
.github/workflows/e2e-desktop.yml
vendored
5
.github/workflows/e2e-desktop.yml
vendored
@@ -17,7 +17,10 @@ concurrency:
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
# This job builds the renderer and the electron bundle, then drives a real
|
||||
# Electron app under xvfb. vite, tsc and the Playwright workers all scale
|
||||
# with the core count.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
review_status: ${{ steps.review-status.outputs.review_status }}
|
||||
|
||||
132
.github/workflows/js-tests.yml
vendored
132
.github/workflows/js-tests.yml
vendored
@@ -5,87 +5,17 @@ on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checks: ${{ steps.set-matrix.outputs.checks }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 26
|
||||
cache: npm
|
||||
|
||||
- name: grab npm 12
|
||||
run: |
|
||||
# No-op once the bundled npm is already 12.x — saves ~5-15s/job and
|
||||
# keeps the installed major aligned with the npm12 cache-key tag.
|
||||
npm --version | grep -q '^12\.' || npm i -g npm@12
|
||||
|
||||
# ``setup-node``'s ``cache: npm`` only caches the ~/.npm tarball cache;
|
||||
# every job still re-extracts the full workspace node_modules and reruns
|
||||
# postinstalls (including the Electron binary fetch). Cache the installed
|
||||
# tree itself, keyed on the lockfile, and skip ``npm ci`` on an exact
|
||||
# hit. No restore-keys: a partial hit would leave a stale tree, so
|
||||
# anything but an exact lockfile match reinstalls from scratch.
|
||||
# The discovery job installs with --ignore-scripts, so its tree differs
|
||||
# from the check jobs' — hence the distinct ``-noscripts`` key.
|
||||
- name: Restore node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
ui-tui/node_modules
|
||||
ui-tui/packages/*/node_modules
|
||||
tests-js/node_modules
|
||||
web/node_modules
|
||||
key: node-modules-noscripts-${{ runner.os }}-node26-npm12-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
- uses: ./.github/actions/retry
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- id: set-matrix
|
||||
run: |
|
||||
node -e '
|
||||
const { execSync } = require("child_process");
|
||||
const pkgs = JSON.parse(execSync("npm query .workspace", { encoding: "utf-8" }));
|
||||
if (pkgs.length === 0) {
|
||||
console.error("::error::Workspace discovery produced an empty package list — refusing to emit a zero-length matrix (would skip all JS/TS checks silently).");
|
||||
process.exit(1);
|
||||
}
|
||||
const checks = [];
|
||||
for (const pkg of pkgs) {
|
||||
const scripts = pkg.scripts || {};
|
||||
const subs = Object.keys(scripts).filter(s => /^check:.+$/.test(s));
|
||||
if (subs.length > 0) {
|
||||
for (const script of subs) {
|
||||
checks.push({ package: pkg.location, script });
|
||||
}
|
||||
} else if (scripts.check) {
|
||||
checks.push({ package: pkg.location, script: "check" });
|
||||
}
|
||||
}
|
||||
if (checks.length === 0) {
|
||||
console.error("::error::No check scripts found in any workspace package.");
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("checks=" + JSON.stringify(checks) + "\n");
|
||||
' >> "$GITHUB_OUTPUT"
|
||||
|
||||
check:
|
||||
name: ${{ matrix.package }} / ${{ matrix.script }}
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
include: ${{ fromJson(needs.workspaces.outputs.checks) }}
|
||||
fail-fast: false # report all failures, not just the first one
|
||||
name: JS & TS checks
|
||||
# One 32-core job replaces a 14-leg matrix. The matrix spread about 612s
|
||||
# of check payload over 4-core runners. It paid about 371s of repeated
|
||||
# setup to do it: 14 checkouts, 14 node installs, 14 node_modules
|
||||
# restores.
|
||||
#
|
||||
# One larger runner installs one time. vitest, tsc and eslint each size
|
||||
# their own worker pool from the core count.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
@@ -99,12 +29,21 @@ jobs:
|
||||
# keeps the installed major aligned with the npm12 cache-key tag.
|
||||
npm --version | grep -q '^12\.' || npm i -g npm@12
|
||||
|
||||
# Same rationale as the discovery job's cache above, but this ``npm ci``
|
||||
# runs WITH install scripts, so the tree includes postinstall artifacts
|
||||
# (electron's postinstall unpacks its binary into node_modules/electron/
|
||||
# dist, which lives inside the cached tree — the ~/.cache/electron
|
||||
# download cache is deliberately NOT cached: with npm ci skipped on hit
|
||||
# it would never be read, only inflate the archive).
|
||||
# The ``cache: npm`` option of ``setup-node`` caches only the ~/.npm
|
||||
# tarball cache. The job then extracts the full workspace node_modules
|
||||
# again and runs the postinstalls again, which includes the Electron
|
||||
# binary fetch. This caches the installed tree itself, keyed on the
|
||||
# lockfile, and skips ``npm ci`` on an exact hit. There are no
|
||||
# restore-keys: a partial hit leaves a stale tree, so anything other
|
||||
# than an exact lockfile match reinstalls from the start.
|
||||
#
|
||||
# This install runs WITH scripts, so the tree holds the postinstall
|
||||
# artifacts. The postinstall of electron unpacks its binary into
|
||||
# node_modules/electron/dist, which is inside the cached tree.
|
||||
#
|
||||
# The ~/.cache/electron download cache stays out of the key on purpose.
|
||||
# ``npm ci`` is skipped on a hit, so nothing reads that cache. It only
|
||||
# makes the archive larger.
|
||||
- name: Restore node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
@@ -122,4 +61,23 @@ jobs:
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} ${{ matrix.script }}
|
||||
|
||||
# Every check runs at the same time. The step fails only after all of
|
||||
# them finish. There are two reasons this is not ``npm run --ws check``.
|
||||
#
|
||||
# * ``--ws`` is serial and stops at the first workspace that fails. A
|
||||
# run then reports one failure, where the matrix this replaced
|
||||
# reported every failure together.
|
||||
# * The unit of work is a CHECK, and not a workspace. apps/desktop is
|
||||
# most of the payload, and its own ``check`` is a serial && chain.
|
||||
# A spread across workspaces alone leaves that chain as the long
|
||||
# pole. This expands the ``check:*`` sub-scripts of a package, so
|
||||
# its lint, ui, electron and plugin suites all run together. That
|
||||
# is the same selection rule the old matrix job used.
|
||||
#
|
||||
# Discovery is ``npm query .workspace``. A new package or a new
|
||||
# ``check:*`` script needs no change here. An empty list is an error and
|
||||
# not an empty run, because an empty run reports green after it checks
|
||||
# nothing.
|
||||
- name: Run all workspace checks
|
||||
run: node .github/scripts/run-workspace-checks.mjs
|
||||
|
||||
6
.github/workflows/nix.yml
vendored
6
.github/workflows/nix.yml
vendored
@@ -51,8 +51,10 @@ jobs:
|
||||
needs: [detect]
|
||||
if: needs.detect.outputs.nix == 'true'
|
||||
# The build compiles the package and its whole dependency closure, so this
|
||||
# is minutes and not seconds when the cache misses.
|
||||
runs-on: ubuntu-latest
|
||||
# takes minutes and not seconds when the cache misses. `nix flake check`
|
||||
# builds 21 checks, and --max-jobs defaults to the core count. It uses the
|
||||
# wider runner with no more configuration.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
5
.github/workflows/rust-tests.yml
vendored
5
.github/workflows/rust-tests.yml
vendored
@@ -27,7 +27,10 @@ concurrency:
|
||||
jobs:
|
||||
bootstrap-installer:
|
||||
name: cargo test (bootstrap installer)
|
||||
runs-on: ubuntu-latest
|
||||
# cargo builds codegen units and test binaries in parallel across the
|
||||
# cores. This lane also builds the crate from the start when Cargo.toml
|
||||
# changes.
|
||||
runs-on: ubuntu-latest-32-core
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
|
||||
6
.github/workflows/tests-os.yml
vendored
6
.github/workflows/tests-os.yml
vendored
@@ -16,9 +16,9 @@ name: OS-specific tests
|
||||
#
|
||||
# Deliberately NOT sliced. The marked set is small (tens of tests, not
|
||||
# thousands), so one plain ``pytest`` process per OS is both faster and far
|
||||
# less machinery than the LPT-sliced per-file runner the Linux lane needs.
|
||||
# less machinery than the per-file parallel runner the Linux lane uses.
|
||||
# If either lane grows past its timeout, that is the signal to reach for
|
||||
# scripts/run_tests.sh --slice here too.
|
||||
# scripts/run_tests.sh here too.
|
||||
#
|
||||
# Each lane FAILS when it selects zero tests (pytest exit code 5). Without
|
||||
# that guard, a renamed marker or a bad selector would report a green job
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runner: macos-latest
|
||||
marker: macos_only
|
||||
- name: Windows-only tests
|
||||
runner: windows-latest
|
||||
runner: windows-latest-32-core
|
||||
marker: windows_only
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
113
.github/workflows/tests.yml
vendored
113
.github/workflows/tests.yml
vendored
@@ -2,11 +2,6 @@ name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
slice_count:
|
||||
description: Number of parallel test slices
|
||||
type: number
|
||||
default: 8
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,42 +12,17 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
# Saves use test-durations-${run_id}, so the exact key above never
|
||||
# matches — without this prefix fallback the cache ALWAYS missed,
|
||||
# LPT slicing ran on no data, and unbalanced slices pushed heavy
|
||||
# files toward the per-file timeout under load.
|
||||
restore-keys: |
|
||||
test-durations-
|
||||
|
||||
- name: Generate test slices
|
||||
id: matrix
|
||||
run: |
|
||||
MATRIX=$(python3 scripts/run_tests_parallel.py --generate-slices ${{ inputs.slice_count }})
|
||||
echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT"
|
||||
|
||||
test:
|
||||
name: Run tests slice ${{ matrix.slice.index }}/${{ inputs.slice_count }}
|
||||
needs: generate
|
||||
runs-on: ubuntu-latest
|
||||
name: Run tests
|
||||
# One 96-core runner for the whole suite. There is no slicing. Slicing
|
||||
# existed to spread the suite over 4-core runners. It cost a matrix job, a
|
||||
# duration cache, a per-slice artifact and a merge job to do it.
|
||||
#
|
||||
# 96 cores clear the floor that the slowest single test file sets (about
|
||||
# 82s). A second slice divides work that is already at that floor, and
|
||||
# adds a second setup.
|
||||
runs-on: ubuntu-latest-96-core
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.generate.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -117,73 +87,30 @@ jobs:
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
|
||||
- name: Run tests (slice ${{ matrix.slice.index }}/${{ inputs.slice_count }})
|
||||
- name: Run tests
|
||||
# Per-file isolation via scripts/run_tests.sh: each test file runs
|
||||
# in its own freshly-spawned `python -m pytest <file>` subprocess
|
||||
# with bounded parallelism. No xdist, no shared workers, no
|
||||
# module-level state leakage between files.
|
||||
#
|
||||
# File list is pre-computed by the generate job (--generate-slices)
|
||||
# which runs LPT distribution once and passes the file list to each
|
||||
# matrix job via --files. Previously each job re-discovered files and
|
||||
# re-ran LPT independently — redundant N times.
|
||||
# No --files: the runner discovers the suite itself. The discovered
|
||||
# set is identical to the list the removed matrix job used to pass in.
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
scripts/run_tests.sh --files '${{ matrix.slice.files }}'
|
||||
scripts/run_tests.sh
|
||||
env:
|
||||
# This is the maximum number of test FILES that run together.
|
||||
# run_tests_parallel.py starts one pytest subprocess for each file
|
||||
# from a single ThreadPoolExecutor, so this value IS the limit. The
|
||||
# default is cpu_count*2, which is 192 here.
|
||||
#
|
||||
# A later commit sets this from a sweep on the real runner.
|
||||
HERMES_TEST_WORKERS: 144
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
# Advisory artifact (feeds slice balancing) — a transient artifact-
|
||||
# service blip must not fail an otherwise-green test slice.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice.index }}
|
||||
path: test_durations.json
|
||||
retention-days: 1
|
||||
|
||||
# Merge per-slice duration data into a single cache, so future runs
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
# Each slice uploads the same file name (test_durations.json).
|
||||
# With merge-multiple, the parallel downloads write to one path.
|
||||
# This causes two problems: a race can write two JSON documents
|
||||
# into one file, and the last write erases the other slices.
|
||||
# Without merge-multiple, each artifact gets its own directory.
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
|
||||
- name: Merge into single durations file
|
||||
run: |
|
||||
python3 -c "
|
||||
import json, glob, os
|
||||
merged = {}
|
||||
for f in glob.glob('durations/*/test_durations.json'):
|
||||
with open(f) as fh:
|
||||
merged.update(json.load(fh))
|
||||
with open('test_durations.json', 'w') as fh:
|
||||
json.dump(merged, fh, indent=2, sort_keys=True)
|
||||
print(f'Merged {len(merged)} file durations')
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations-${{ github.run_id }}
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
@@ -66,14 +66,12 @@
|
||||
"test:find-in-page-native": "electron electron/find-in-page-native-fixture",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check:test:ui": "npm run test:ui",
|
||||
"check:test:desktop:platforms": "npm run test:desktop:platforms",
|
||||
"check:test:plugins": "node --test src/plugins/*/tests/*.test.mjs",
|
||||
"check:test:ui:shard-1of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:ui:shard-2of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:ui:shard-3of3": "node scripts/run-ui-shard.mjs",
|
||||
"check:test:desktop:all": "npm run test:desktop:all",
|
||||
"check:test:plugins": "node --test src/plugins/*/tests/*.test.mjs",
|
||||
"check:lint": "npm run typecheck && npm run lint",
|
||||
"check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all",
|
||||
"check": "npm run check:lint && npm run test:ui && npm run test:desktop:platforms && npm run test:desktop:all && npm run check:test:plugins",
|
||||
"test:e2e": "npm run build && playwright test e2e/",
|
||||
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
// Runs one shard of the UI vitest suite, deriving the shard index/count from
|
||||
// the npm script NAME (npm_lifecycle_event), so the name and the flag can
|
||||
// never disagree. A copy-paste slip like "check:test:ui:shard-2of3" running
|
||||
// --shard=1/3 would silently skip a third of the suite while CI stays green;
|
||||
// deriving from the name makes that impossible.
|
||||
//
|
||||
// It also validates that this package.json declares exactly the shard family
|
||||
// 1..M for a single M, so a partial 3→4 migration (adding shard-4of4 without
|
||||
// updating the siblings) fails loudly instead of dropping coverage.
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SHARD_RE = /^check:test:ui:shard-(\d+)of(\d+)$/
|
||||
|
||||
const scriptName = process.env.npm_lifecycle_event ?? ''
|
||||
const match = scriptName.match(SHARD_RE)
|
||||
if (!match) {
|
||||
console.error(
|
||||
`run-ui-shard: must be invoked via an npm script named check:test:ui:shard-<N>of<M> (got ${JSON.stringify(scriptName)})`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
const [, indexRaw, countRaw] = match
|
||||
const index = Number(indexRaw)
|
||||
const count = Number(countRaw)
|
||||
if (!(index >= 1 && index <= count)) {
|
||||
console.error(`run-ui-shard: shard index ${index} out of range 1..${count}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// The whole family must be exactly 1..M of one M — otherwise a rename or a
|
||||
// partial count bump leaves a silently untested slice of the suite.
|
||||
const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)))
|
||||
const pkg = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8'))
|
||||
const family = Object.keys(pkg.scripts ?? {})
|
||||
.map((name) => name.match(SHARD_RE))
|
||||
.filter(Boolean)
|
||||
const counts = new Set(family.map((m) => Number(m[2])))
|
||||
const indices = family.map((m) => Number(m[1])).sort((a, b) => a - b)
|
||||
const expected = Array.from({ length: count }, (_, i) => i + 1)
|
||||
if (counts.size !== 1 || indices.length !== count || indices.some((v, i) => v !== expected[i])) {
|
||||
console.error(
|
||||
`run-ui-shard: shard scripts must form exactly 1..M for a single M; found indices [${indices}] with counts {${[...counts]}}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Delegate through test:ui so the vitest command stays single-sourced.
|
||||
// npm resolves to npm.cmd on Windows, which needs a shell (same handling as
|
||||
// test-desktop.mjs and stage-native-deps.mjs).
|
||||
const result = spawnSync(
|
||||
'npm',
|
||||
['run', 'test:ui', '--', `--shard=${index}/${count}`, ...process.argv.slice(2)],
|
||||
{ stdio: 'inherit', cwd: pkgDir, shell: process.platform === 'win32' },
|
||||
)
|
||||
if (result.error) {
|
||||
console.error(`run-ui-shard: ${result.error.message}`)
|
||||
}
|
||||
process.exit(result.status ?? 1)
|
||||
Reference in New Issue
Block a user