Skip to content
Merged
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
42 changes: 29 additions & 13 deletions .github/workflows/deps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
# svelte-check ratchet, vitest, scene-lint, kit-diff, a11y, supply chain) and perf-budget.yml, both
# called as reusable workflows, then the integrators' e2e battery split into shards on
# GitHub-hosted runners (SwiftShader: no GPU, battery.yml): the REQUIRED set gates; the OPTIONAL set (red on
# SwiftShader with unchanged code, each with its reason in tests/e2e/battery.json) runs after it and is only
# reported. `deps-gate` is the one required check; the ruleset on
# SwiftShader with unchanged code, each with its reason in tests/e2e/battery.json) runs only when started by hand
# (workflow_dispatch) and is only reported. Perf budget + battery run only for a non-major change to the root
# app's npm packages (deps-policy.cjs `battery`): Actions, the agent and majors get ci.yml alone, so one monthly
# Dependabot round does not hold the account's 20 concurrent jobs for half a day. `deps-gate` is the one
# required check; the ruleset on
# `deps` also requires the `pc-battery` commit status, which the GPU check on a desktop posts for a
# PR that changes what the app ships (scripts/ci/pc-battery.sh — pulled from there, no runner of ours
# in this public repo) and this workflow posts as "not needed" for every other PR.
Expand Down Expand Up @@ -47,6 +50,7 @@ jobs:
cls: ${{ steps.policy.outputs.class }}
needs_pc: ${{ steps.policy.outputs.needs_pc }}
reason: ${{ steps.policy.outputs.reason }}
battery: ${{ steps.policy.outputs.battery }}
gate_ci: ${{ github.event_name != 'pull_request' || github.base_ref == 'deps' }}
steps:
- uses: actions/checkout@v4
Expand All @@ -68,7 +72,7 @@ jobs:
# a person's PR (or a dispatch): no automation; the PC check runs when the app's lockfile moved
pc=false
if [ -n "$BASE" ] && git diff --name-only "origin/$BASE...HEAD" | grep -qxE 'package(-lock)?\.json'; then pc=true; fi
printf 'class=manual\nneeds_pc=%s\nreason=not a Dependabot PR\n' "$pc" | tee -a "$GITHUB_OUTPUT"
printf 'class=manual\nneeds_pc=%s\nbattery=true\nreason=not a Dependabot PR\n' "$pc" | tee -a "$GITHUB_OUTPUT"
fi
- name: majors wait for a planned migration
if: steps.policy.outputs.class == 'major'
Expand All @@ -90,7 +94,7 @@ jobs:
# the budget gate in three parallel parts (~20 min each instead of ~60 in one)
perf:
needs: scope
if: needs.scope.outputs.gate_ci == 'true'
if: needs.scope.outputs.gate_ci == 'true' && needs.scope.outputs.battery == 'true'
strategy:
fail-fast: false
matrix:
Expand All @@ -102,6 +106,7 @@ jobs:

plan:
needs: scope
if: needs.scope.outputs.battery == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
Expand Down Expand Up @@ -133,7 +138,7 @@ jobs:
# the deadline left unrun) gets two more tries on a FRESH runner before it counts
confirm:
needs: [plan, e2e]
if: always() && needs.e2e.result == 'failure'
if: ${{ !cancelled() && needs.e2e.result == 'failure' }}
uses: ./.github/workflows/battery.yml
with:
set: confirm
Expand All @@ -142,11 +147,12 @@ jobs:
refs: ${{ needs.plan.outputs.refs }}
deadline: 20

# red or flaky on SwiftShader with the same red on unchanged code: run, reported, never waited for. They start
# AFTER the required shards and the confirm step (the free plan runs 20 jobs at once); deps-gate does not need them.
# red or flaky on SwiftShader with the same red on unchanged code: reported, never waited for, and only on a run
# started by hand (refreshing battery.json's classification); ~750 runner-min a time is too much for every PR.
# They start AFTER the required shards and the confirm step (the free plan runs 20 jobs at once).
e2e-optional:
needs: [plan, e2e, confirm]
if: always() && needs.plan.outputs.optional_shards != '0'
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && needs.plan.result == 'success' && needs.plan.outputs.optional_shards != '0' }}
uses: ./.github/workflows/battery.yml
with:
set: optional
Expand All @@ -157,7 +163,7 @@ jobs:

optional-report:
needs: e2e-optional
if: always() && needs.e2e-optional.result != 'skipped'
if: ${{ !cancelled() && needs.e2e-optional.result != 'skipped' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -172,7 +178,7 @@ jobs:
gate:
name: deps-gate
needs: [scope, ci, perf, plan, e2e, confirm]
if: always() && needs.scope.result == 'success'
if: ${{ !cancelled() && needs.scope.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -182,25 +188,35 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
if: needs.scope.outputs.battery == 'true'
with:
pattern: battery-required-*
path: battery
- uses: actions/download-artifact@v4
if: needs.confirm.result != 'skipped'
if: needs.scope.outputs.battery == 'true' && needs.confirm.result != 'skipped'
with:
pattern: battery-confirm-*
path: battery
- name: battery report
id: report
if: needs.scope.outputs.battery == 'true'
run: node scripts/ci/battery.cjs report battery
- name: the release gate
env:
GATE_CI: ${{ needs.scope.outputs.gate_ci }}
BATTERY: ${{ needs.scope.outputs.battery }}
CLS: ${{ needs.scope.outputs.cls }}
CI: ${{ needs.ci.result }}
PERF: ${{ needs.perf.result }}
E2E: ${{ needs.e2e.result }}
run: |
echo "### Release gate: ci=$CI perf-budget=$PERF (class ${{ needs.scope.outputs.cls }}: ${{ needs.scope.outputs.reason }})" >> "$GITHUB_STEP_SUMMARY"
if [ "$GATE_CI" = true ] && { [ "$CI" != success ] || [ "$PERF" != success ]; }; then exit 1; fi
echo "### Release gate: ci=$CI perf-budget=$PERF battery=$BATTERY (class $CLS: ${{ needs.scope.outputs.reason }})" >> "$GITHUB_STEP_SUMMARY"
if [ "$CLS" = major ]; then
echo "A major is not tested here (no perf budget, no battery): its planned migration runs them. Red on purpose." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
if [ "$BATTERY" = true ] && [ "$E2E" = skipped ]; then exit 1; fi
if [ "$GATE_CI" = true ] && { [ "$CI" != success ] || { [ "$BATTERY" = true ] && [ "$PERF" != success ]; }; }; then exit 1; fi
- uses: actions/upload-artifact@v4
with:
name: battery-durations
Expand Down
11 changes: 8 additions & 3 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,14 @@ plus GitHub Actions), and `.github/workflows/deps.yml` decides what happens to e
runner; green there = `flaky` (reported), red there = a red. SwiftShader fails any one suite now and then,
and twice on the same loaded runner too, so a same-shard retry proves nothing;
- the **optional** set (red or flaky on SwiftShader with unchanged code, each with its reason on its line)
runs AFTER that and is only reported (`optional-report`); nothing waits for it.
runs only on a run started by hand (Actions → deps → Run workflow, e.g. before refreshing `battery.json`),
AFTER the rest, and is only reported (`optional-report`); nothing waits for it.
The perf budget and the battery run only for a PR that moves the root app's npm packages and is not a major;
an Actions bump, an agent (`tools/agent`) bump and a major get ci.yml alone. One battery is about 30 jobs
and 600 runner-minutes against the account's cap of 20 concurrent jobs, and Dependabot opens its monthly
round all at once (9 PRs in the first one, 2026-10-25), so everything else on the account queues behind it.
One check sums it all up: **`deps-gate`**. Measured on unchanged code (2026-10-24): about an hour from push
to `deps-gate`, the optional set ~45 min more. The shard runner is `scripts/ci/battery.cjs`.
to `deps-gate` on an idle account. The shard runner is `scripts/ci/battery.cjs`.
2. **The GPU half.** `pc-battery` is a commit status posted from a desktop with a real GPU
(`scripts/ci/pc-battery.sh`): /kit, the app surfaces (base shot twice for the noise floor), the level shots
(head against the closer of two base runs; under 2 % of the pixels is animation) and frame time on the
Expand All @@ -81,7 +86,7 @@ plus GitHub Actions), and `.github/workflows/deps.yml` decides what happens to e
| patch, a development-only package, any GitHub Action | GitHub auto-merge into `deps` |
| a minor of something that ships (a `dependency`, or svelte / vite / kit / tailwind / flowbite) | label `deps-ready`; the monthly deps lane merges it after the release notes + a visual review |
| any Playwright bump | `deps-ready`: the e2e hosts outside GitHub must serve the same browser first |
| a major | `needs-migration` + a comment; a planned migration, never automation |
| a major | `needs-migration` + a comment; `deps-gate` red on purpose (not tested here); a planned migration, never automation |

The ruleset on `deps` requires `deps-gate` and `pc-battery`, so an auto-merge of a runtime patch waits
for the desktop. The ruleset also forbids deleting `deps`: it is never cleaned up with merged branches.
Expand Down
22 changes: 14 additions & 8 deletions scripts/ci/deps-policy.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@
// major labelled `needs-migration` with a pointer to the planned-migration notes; never merged
// by automation.
//
// The e2e battery (and the perf budget) run only when the PR moves the root app's npm packages and is
// not a major: an Actions bump, the agent under tools/ and a major (the migration lane runs the battery
// itself) get ci.yml alone; a major's deps-gate then fails on purpose, so nothing reads it as tested.
//
// "Ships in the app" is not the same as package.json's `dependencies`: svelte, vite, kit and tailwind
// are devDependencies of a static site and still decide every byte of the bundle, so they are listed
// in BUILD below. A new dependency of that kind belongs on the list.
//
// node scripts/ci/deps-policy.cjs '<updated-dependencies-json>' # prints class=… needs_pc=… reason=…
// node scripts/ci/deps-policy.cjs '<updated-dependencies-json>' # prints class=… needs_pc=… battery=… reason=…

/** devDependencies that end up in (or produce) the shipped bundle */
const BUILD = [
Expand All @@ -37,25 +41,27 @@ const HOLD = /^(playwright|playwright-core|@playwright\/.*|@axe-core\/playwright
/**
* @typedef {{ dependencyName: string, dependencyType?: string, updateType?: string, directory?: string, packageEcosystem?: string }} Dep
* @param {Dep[]} deps
* @returns {{ cls: 'auto'|'ready'|'major', needsPc: boolean, reason: string }}
* @returns {{ cls: 'auto'|'ready'|'major', needsPc: boolean, battery: boolean, reason: string }}
*/
function classify(deps) {
if (!deps.length) return { cls: 'ready', needsPc: true, reason: 'no dependency metadata: a person decides' };
if (!deps.length) return { cls: 'ready', needsPc: true, battery: true, reason: 'no dependency metadata: a person decides' };
const rank = { auto: 0, ready: 1, major: 2 };
/** @type {'auto'|'ready'|'major'} */
let cls = 'auto';
const why = [];
let needsPc = false;
let app = false;
for (const d of deps) {
const name = d.dependencyName;
const eco = d.packageEcosystem || 'npm_and_yarn';
const dir = (d.directory || '/').replace(/\/+$/, '') || '/';
const up = (d.updateType || '').replace('version-update:semver-', '');
const actions = eco === 'github_actions' || eco === 'github-actions';
// app = the root package (the shipped site); the agent under tools/ is a separate CLI
const app = !actions && dir === '/';
const runtime = !actions && (d.dependencyType === 'direct:production' || d.dependencyType === 'indirect' || (app && BUILD.includes(name)));
if (app && (runtime || d.dependencyType === 'indirect')) needsPc = true;
const inApp = !actions && dir === '/';
if (inApp) app = true;
const runtime = !actions && (d.dependencyType === 'direct:production' || d.dependencyType === 'indirect' || (inApp && BUILD.includes(name)));
if (inApp && (runtime || d.dependencyType === 'indirect')) needsPc = true;
/** @type {'auto'|'ready'|'major'} */
let c;
if (actions) c = 'auto';
Expand All @@ -68,13 +74,13 @@ function classify(deps) {
if (c !== 'auto') why.push(`${name} ${up || '?'}${HOLD.test(name) ? ' (e2e hosts pin it)' : runtime ? ' (ships)' : ''}`);
}
const reason = cls === 'auto' ? 'patch / development-only / actions' : why.join(', ');
return { cls, needsPc, reason };
return { cls, needsPc, battery: app && cls !== 'major', reason };
}

if (require.main === module) {
const raw = process.argv[2] || process.env.DEPS_JSON || '[]';
const r = classify(JSON.parse(raw));
const line = `class=${r.cls}\nneeds_pc=${r.needsPc}\nreason=${r.reason}\n`;
const line = `class=${r.cls}\nneeds_pc=${r.needsPc}\nbattery=${r.battery}\nreason=${r.reason}\n`;
if (process.env.GITHUB_OUTPUT) require('fs').appendFileSync(process.env.GITHUB_OUTPUT, line);
process.stdout.write(line);
}
Expand Down
Loading
Loading