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
118 changes: 74 additions & 44 deletions .github/workflows/size.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ name: Size
# ships); the retained-module-graph assertions in
# packages/signals/tests/treeshake.test.ts stay the which-module diagnostic
# underneath.
# The check job also runs on direct pushes to next: it is self-contained
# (absolute limits, no base ref), and PR-only triggering let the
# +createStore scenario sit failing at a release commit unnoticed — direct
# pushes never measured. The compare job stays PR-only; it needs a PR
# context (base branch, comment target). The floor-cap freeze step in check
# is PR-only for the same reason (it diffs against the base branch).
#
# Three jobs. `head` measures the commit under test; `base` (PRs only)
# measures the PR's base with the head's harness; `check` — the required
# status — decides with both (scripts/size/gate.mjs): a scenario fails only
# when it is over its brotli cap AND grew past the minified allowance over
# the base. The decision needs the base's minified numbers, so the base
# measurement moved from the old informational `compare` job (which ran after
# `check`) into a job `check` waits on; `head` and `base` run in parallel.
# Pushes to next are gated too, against the commit before the push —
# PR-only triggering let the +createStore scenario sit failing at a release
# commit unnoticed.
on:
push:
branches: [next]
Expand All @@ -26,9 +31,7 @@ permissions:
pull-requests: write

jobs:
# The hard gate: self-contained on the PR head, fails when any scenario
# exceeds its limit. Never depends on the base branch.
check:
head:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -48,46 +51,33 @@ jobs:
- run: pnpm build
- run: npm ci --no-audit --no-fund
working-directory: scripts/size
# size.mjs gates every scenario against its cap and prints the lazy
# chunks and per-package minified bytes for each, so a bump is
# attributed in the same log that reports it. The JSON feeds the
# compare job's PR comment.
- run: npm run size -- --json size-head.json
# size.mjs prints the lazy chunks and per-package minified bytes for
# each scenario, so a bump is attributed in the same log that reports
# it. --no-gate: the decision is check's. Uploaded on pushes too — a
# push to next's size-head is what `npm run ratchet -- --from` reads.
- run: npm run size -- --json size-head.json --no-gate
working-directory: scripts/size
- if: always() && github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v4
with:
name: size-head
path: scripts/size/size-head.json
# The floor and page caps (scripts/size/floor-caps.json) are frozen: a PR
# may lower them, never raise them, unless its body carries a
# `Size-Exception:` line. Compared against the PR's base branch, so
# PR events only.
- if: github.event_name == 'pull_request'
run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
- if: github.event_name == 'pull_request'
run: npm run check-floor-caps -- "origin/${{ github.base_ref }}"
working-directory: scripts/size
env:
SIZE_EXCEPTION: ${{ github.event.pull_request.body }}

# Best-effort base-vs-head delta comment. Builds the base branch and
# measures its artifacts with the HEAD's harness (SIZE_PACKAGES_ROOT), so
# both columns come from the same scenarios and the same Rolldown; the head
# numbers are the check job's. Informational and never blocks:
# continue-on-error keeps bootstrap PRs (and any hiccup) green. PR events
# only — it needs a base ref and a comment target.
compare:
# always(): the comment is most useful on the PR whose check failed.
if: always() && github.event_name == 'pull_request'
needs: check
# Builds the base and measures its artifacts with the HEAD's harness
# (SIZE_PACKAGES_ROOT), so both sides come from the same scenarios and the
# same Rolldown. On a PR the head is the merge commit with base.sha, so the
# difference is the PR's own change; on a push it is the pushed range
# (`before`), so a merge that passed with a noise warning does not turn
# next red on the absolute cap. continue-on-error: a base the head's
# harness cannot build or measure leaves check on absolute caps instead of
# blocking.
base:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
path: base
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
Expand All @@ -106,23 +96,50 @@ jobs:
working-directory: base
- run: npm ci --no-audit --no-fund
working-directory: scripts/size
- run: node size.mjs --json size-base.json || true
- run: node size.mjs --json size-base.json --no-gate
working-directory: scripts/size
env:
SIZE_PACKAGES_ROOT: ${{ github.workspace }}/base
- uses: actions/upload-artifact@v4
with:
name: size-base
path: scripts/size/size-base.json

# The hard gate (the required status). always(): it runs when base failed;
# a failed head leaves no size-head artifact and fails here.
check:
if: always()
needs: [head, base]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- run: npm ci --no-audit --no-fund
working-directory: scripts/size
# gate.mjs's own tests; scripts/size is outside the workspace `pnpm test`.
- run: npm test
working-directory: scripts/size
- uses: actions/download-artifact@v4
with:
name: size-head
path: scripts/size
- continue-on-error: true
uses: actions/download-artifact@v4
with:
name: size-base
path: scripts/size
# The job summary carries the report on every run. The comment only
# goes on PRs from this repository: a fork PR's token is read-only, so
# createComment answers 403. Rendered before the gate step so a failing
# PR still gets its table.
- run: node report.mjs size-head.json size-base.json > size-comment.md
working-directory: scripts/size
# The job summary carries the report on every PR. The comment only goes
# on PRs from this repository: a fork PR's token is read-only, so
# createComment answered 403 and the job showed as failed on every
# external PR.
- run: cat size-comment.md >> "$GITHUB_STEP_SUMMARY"
working-directory: scripts/size
- if: github.event.pull_request.head.repo.full_name == github.repository
- if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
Expand All @@ -135,3 +152,16 @@ jobs:
const mine = comments.find(c => c.user.type === "Bot" && c.body.startsWith(marker));
if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
else await github.rest.issues.createComment({ owner, repo, issue_number, body });
- run: node gate.mjs size-head.json size-base.json
working-directory: scripts/size
# The floor and page caps (scripts/size/floor-caps.json) are frozen: a PR
# may lower them, never raise them, unless its body carries a
# `Size-Exception:` line. Compared against the PR's base branch, so
# PR events only. always(): reported even when the gate step failed.
- if: always() && github.event_name == 'pull_request'
run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
- if: always() && github.event_name == 'pull_request'
run: npm run check-floor-caps -- "origin/${{ github.base_ref }}"
working-directory: scripts/size
env:
SIZE_EXCEPTION: ${{ github.event.pull_request.body }}
97 changes: 91 additions & 6 deletions scripts/size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,67 @@ compiled-template scenarios — a compiled floor and a JSX todo app in CSR and
hydrating form — the frames client as a package, two server-component PAGES:
base and live, and two server-entry floors: `getRequestEvent`/`isServer` and
`renderToString`) with hard brotli limits on the eager entry chunk. CI fails
when a scenario exceeds its limit — that means tree-shaking regressed, or a
deliberate feature landed and the limit should be bumped in the same PR with
a reason.
when a scenario exceeds its limit and grew more than a small minified
allowance over its base (see [The gate](#the-gate)) — that means tree-shaking regressed, or a deliberate
feature landed and the limit should be bumped in the same PR with a reason.

## The gate

Caps are **brotli** bytes on the eager entry chunk — brotli is what ships —
set at measured + 10 B rounded up to 0.01 KB. But brotli's layout is not
monotonic in the input: a change of a few minified bytes moves a scenario's
brotli by ±50–90 B, so a brotli-only gate failed PRs for noise and every
"fix" either raised a cap (permanently) or golfed the code until the layout
came out lucky. Minified bytes are deterministic, so the gate (`gate.mjs`)
uses them to tell noise from growth. Per scenario:

| brotli vs cap | minified vs base | verdict |
| ------------- | ------------------------------------- | ------------------------------ |
| at or under | anything | **pass** |
| over | grew by ≤ `MINIFIED_ALLOWANCE` (20 B) | **pass with a warning** |
| over | grew by more | **fail** |
| over | no base measurement | **fail** (the cap is absolute) |

"Base" is the PR's base commit (`pull_request.base.sha`, the first parent
of the merge commit CI measures as the head), measured in the same run
with the head's harness; on a push to `next` it is the commit before the
push. The warning — in the job summary, the PR size comment and as an
annotation — reads _over brotli cap by N B, minified +M B — layout noise;
cap will be re-based at the next ratchet_. The allowance is one constant,
`MINIFIED_ALLOWANCE` in `gate.mjs`; a scenario may set its own with
`minifiedAllowance` in `scenarios.js` (none does).

Real growth is still a decision made in the PR: lower the bytes, or raise
the scenario's cap in the same PR with a dated reason in its ledger. Raising
a frozen floor cap (below) additionally needs a `Size-Exception:` line in
the PR body — the override for growth the maintainer has accepted.

Locally, `npm run size` is the absolute gate (any scenario over its cap
fails); `node gate.mjs head.json base.json` applies the PR rule to two
`size.mjs --json` files, and `npm test` runs the decision's tests.

## The ratchet

Noise that passed with a warning leaves a scenario over its cap; savings
leave caps loose. `npm run ratchet` re-bases every cap on what the tree
measures now — measured + 10 B rounded up to 0.01 KB — and **only ever
lowers** a cap. It rewrites the inline caps in `scenarios.js` and the frozen
floors in `floor-caps.json`, adds a dated ledger line above each lowered
cap, and prints the lowered inline caps and the lowered **frozen floors** as
separate tables, so a floor change is seen as one. A scenario still over its
cap is listed and left alone: the ratchet does not raise.

Run it once per RC, on `next`, from CI's numbers — local and CI artifacts
differ by tens of brotli bytes:

```sh
gh run download <run-id> -n size-head # the Size run of the push to next
node ratchet.mjs --from size-head.json --note "RC.7, next @ abc1234" --dry-run
node ratchet.mjs --from size-head.json --note "RC.7, next @ abc1234"
```

Without `--from` it measures this checkout (build it first). Land the
result as its own PR.

## Bundler

Expand Down Expand Up @@ -62,10 +120,12 @@ the two server-entry floors have their caps in `floor-caps.json`, not in
**frozen**: a PR may lower them, never raise them. `check-floor-caps.mjs`
diffs the file against the PR's base branch in CI and fails on a raise unless
the PR body contains a line starting with `Size-Exception:` naming why the
maintainer accepted the cost. Ten weeks of individually justified 10–300 B
maintainer accepted the cost (read from the PR body when the run starts —
edit the body, then re-run). Ten weeks of individually justified 10–300 B
bumps took the signals floor from 7.1 to 9.9 KB; the freeze makes the next
one a decision, not a paragraph. See
`documentation/plans/size-reduction-audit.md`.
`documentation/plans/size-reduction-audit.md`. The ratchet may lower a
frozen floor like any cap.

## Attribution

Expand All @@ -75,7 +135,18 @@ the lazy chunks and the minified bytes each package contributed (`signals`,
attributed in the log that reports it. Minified bytes are the attributable
unit; brotli compresses across module boundaries. `node attribute.mjs [name]
[--min bytes]` lists the individual dist modules of matching scenarios.
`node size.mjs --json out.json` writes the results for the PR comment.
`node size.mjs --json out.json` writes the results for the gate and the PR
comment.

## CI

`.github/workflows/size.yml` runs three jobs. `head` builds the commit under
test and measures it; `base` builds the base (above) and measures it with
the head's harness (`SIZE_PACKAGES_ROOT`); the two run in parallel. `check`
— the required status — waits for both, renders the summary and the PR
comment (`report.mjs`), decides (`gate.mjs`) and checks the floor freeze
(`check-floor-caps.mjs`). A base that cannot be built or measured leaves the
caps absolute rather than blocking.

## Layout

Expand All @@ -91,3 +162,17 @@ first). `SIZE_PACKAGES_ROOT=<checkout>` measures another checkout's built
The retained-module-graph test in `packages/signals/tests/treeshake.test.ts`
is the companion diagnostic that names the re-coupled module when shaking
breaks.

## Ledger

- **2026-10-05 — minified allowance and ratchet.** The gate stopped failing
on brotli alone: over the cap with ≤ 20 B minified growth over the base
passes with a warning; caps stay brotli and are re-based downward per RC
by `npm run ratchet`. Prompted by one day of noise: #3807 +63 B minified
went +36 then +97 B brotli after `next` moved (a +61 B variant measured
+3 / −54); #3814 +10 B minified went +50 / +56 B; #3811 +20 B minified
went +55 / +89 B; #3817 reordered a condition for −20 B minified and
moved one scenario −90 B brotli. The base measurement moved out of the informational
`compare` job (which ran after `check`) into a parallel `base` job that
`check` waits on, and pushes to `next` now compare against the previous
commit instead of the absolute cap. No cap changed.
Loading
Loading