Skip to content

fix(ci): fail Bundle Analysis when its ceilings were replaced on the base branch - #6315

Merged
os-warren merged 4 commits into
mainfrom
claude/issue-6245-bundle-ceiling-freshness
Aug 25, 2026
Merged

fix(ci): fail Bundle Analysis when its ceilings were replaced on the base branch#6315
os-warren merged 4 commits into
mainfrom
claude/issue-6245-bundle-ceiling-freshness

Conversation

@os-warren

@os-warrenos-warren commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#6245

Four commits. The first adds the freshness half. The second and third make this gate trigger on the files it is built from. The fourth fixes a test-hermeticity defect the first one exposed — see "A false green, and what it cost" below, which is the part most worth reading if you maintain this suite.

The premise, verified before anything was built

The card's framing holds on origin/main @ 090927f4f, and I confirmed it from the live run rather than from the card:

So a green, merge-gating Bundle Analysis verdict was computed against a ceiling that had already been retired. It did not bite — the payload was 3222.6 KB, under both numbers — but the aggregate headroom is now 44.0 KB against an 89.0 KB regression class (0.49x), so the next stale verdict has half a regression of room to hide in.

The mechanism is in this workflow, not in repository settings: actions/checkout on a pull_request event checks out the merge ref, and GitHub does not re-run a PR's checks when the base branch moves.

The mechanism, and why this one

evaluateCeilingFreshness is a fourth half beside the aggregate, per-chunk and sensitivity ones, built on the machinery this file already has rather than beside it.

Three readings, not two. The naive check — "do my ceilings match the base branch's?" — fails the one PR that must land: a re-baseline PR differs from the base branch on purpose. So the question is not whether they differ but whether the base branch moved them out from under this checkout:

readingwhat it is
headSourcethis checkout — what the verdict actually used
prBaseSourcethe base commit this checkout was computed from
baseSourcethe base branch tip right now — what the merge would land on

A ceiling is superseded when it moved between the first two and this checkout does not carry the move. A re-baseline PR moves it in headSource only, the first two agree, and nothing fires. VERDICT_CEILING_CONSTANTS is deliberately just the three constants a verdict is computed fromBASELINE and PER_CHUNK_BASELINE are excluded, so a baseline-only or comment-only edit on main cannot cry wolf.

Exit 2, never exit 1 — and the output says so. Exit 2 already means "a verdict about the GAUGE" here, and the workflow already routes it away from the size verdict. A superseded ceiling is exactly that — nothing grew, no ceiling drifted, the measurement is sound and so are the ceilings on main; the only thing wrong is that they were never weighed against each other. The step log prints:

❌ 1 ceiling constant this run weighed against has been SUPERSEDED: `main` moved it
after this checkout was made (48e53814e to 0409b766d), and nothing re-ran the verdicts above.
❌ MAX_EAGER_CLOSURE_GZIP_BYTES
weighed here : 4086000
in force now : 3345000
⛔ NOTHING GREW. This is not a size regression and it is not a drifted ceiling …
Fix: update this branch onto `main` … ⛔ Do NOT widen a ceiling to clear this.

and the PR comment gets its own verdict — ⚠️ superseded ceiling, not ⚠️ broken gauge — with its own note. Reusing the drifted-ceiling wording would have been false in both directions: it denies a measurement that happened, and it points the reader at a report that reads fine. A test pins that the two exit-2 notes stay separate when both halves object.

Commits 2 and 3: this gate now triggers on its own runtime closure

The first commit changed this workflow's YAML and none of it could run on the PR that made it. The paths: filters listed packages/**, apps/console/** and pnpm-lock.yaml; this PR touches only scripts/** and .github/workflows/**. Measured before commit 2: 25 check runs, and Bundle Analysis was not among them.

Adding the workflow's own path fixed that and was still not enough: a PR touching only scripts/check-eager-closure-budget.mjs — the file that computes the verdict — still would not have triggered the gate.

The convention on origin/main is the gate's runtime closure, not merely its own YAML. Measured across the 7 path-filtered workflows:

workflow with a paths: filterlists own ymllists the script(s) it runs
half-state-patrol.ymlyesyes — and scripts/invoked-as.mjs, that script's dependency
node-esm-load-gate.ymlyesyes
published-dist-gate.ymlyesyes
spec-range-floors.ymlyesyes
hook-selftests.ymlyesruns no scripts/ gate
changeset-guard.ymlnono — that is #6321, deliberately untouched here
performance-budget.ymlwas: nowas: no

Both filters now carry what this job actually executes:

 - 'scripts/check-eager-closure-budget.mjs'
- 'scripts/render-budget-comment.mjs'
- 'scripts/invoked-as.mjs'

invoked-as.mjs is there for a specific reason rather than symmetry: isEntrypoint decides whether main() runs at all, so a regression in it makes this checker exit 0 having measured nothing — the silent-green failure the rest of this gate exists to prevent.

Not listed: the scripts/__tests__/*.test.ts files. The closure is what the job executes; this job runs two node scripts/*.mjs commands and never vitest, so a test-only edit cannot move its verdict, and those tests already run in the root vitest unit project. A second contract test asserts that definition by deriving the expected set from the node scripts/*.mjs commands the workflow actually contains, so the list and its rationale cannot drift apart.

A false green, and what it cost

The most transferable thing in this PR. After commit 3, Test (shard 3/4) went red with four failures — all pre-existing assertions in check-eager-closure-budget.test.ts, all receiving exit 2 where 0 and 1 were the only outcomes they were written for.

The cause was the suite, not the checker. Adding env to main(argv, env = process.env) is right for production, but the run() helper omitted the argument and so inherited the process environment. GITHUB_EVENT_NAME=pull_request is ambient inside GitHub Actions, so in CI — and only in CI — that switched the ceiling-freshness half ON for every case routed through run(). With no EAGER_CLOSURE_*_SOURCE injected, the half correctly returned error and main correctly returned 2. Every piece of production code behaved exactly as designed; the test helper was measuring the machine it ran on.

So the vitest run scripts/ I reported as green at 2261 passing was a false green. It passed because a variable happened to be absent, not because the code was right — and the same is true of every ablation routed through that helper. The shape of the reverse-verification stands; "green on my machine" never did, for this file. Anyone extending this suite should assume the same trap until run()'s hermeticity is verified, which is why it is now a test rather than a comment.

Commit 4 makes run() inject { GITHUB_OUTPUT: outputPath, ...env } and stop touching process.env altogether. GITHUB_EVENT_NAME is deliberately absent from that object — that is what makes these cases exercise the non-pull_request path deterministically rather than by luck; a case opts in through the new env parameter. The production default is untouched, and none of the four assertions were changed to expect 2, which would have pinned the accident instead of fixing it.

What this does NOT close, stated plainly

It closes the window the card recorded: a run that executes after the constants move with a checkout that predates it. It cannot close the window where the run finishes before the constants move and the PR is merged after — no code of ours runs at that moment. That residual is what the push-on-main run detects after the fact, and closing it needs option 1 in the card body (require branches up to date before merging), a repository setting and maintainer-floor. Not implemented, not proposed, no repository setting touched, no required context added, no ceiling widened. Tracked as #6319.

Files

  • scripts/check-eager-closure-budget.mjsVERDICT_CEILING_CONSTANTS, extractCeilingDeclarations, evaluateCeilingFreshness, readSource; main(argv, env) takes an injected env and threads it to writeGithubOutput.
  • .github/workflows/performance-budget.ymlfetch-depth: 2, the Resolve the base-branch ceiling constants step, BUDGET_CLOSURE_FRESHNESS_STATUS into the comment step, an exit-2 budget_message that no longer claims "no trustworthy measurement", and both paths: filters carrying the runtime closure.
  • scripts/render-budget-comment.mjs — the fourth half row, its own verdict wording and note.
  • both scripts/__tests__/ files — 28 new assertions, and run() made hermetic.

continue-on-error: true on the resolve step is not a fail-open: when the step cannot produce both files the checker finds the variables unset and reports a freshness ERROR (exit 2), which fails the job. Failing the step itself would kill the run before the bundle is measured (#3152). The paths travel through $GITHUB_ENV rather than a step env: block because render-budget-comment.test.ts walks every NAME: ${{ steps.* }} line and requires the comment renderer to read it.

Verification

CI on b6cfa76f7 — every job green

jobconclusion
Test (shard 3/4) — the one that was redsuccess (step Run tests (shard 3/4): success)
Test shards 1/4, 2/4, 4/4success
Type Check (incl. Type-check scripts/)success
Build & E2E, Lint, Build Docssuccess
Bundle Analysis (run 32852342807)success

The freshness half, measured on CI in its discriminating case

Bundle Analysis run 32850298987 on b1e336a50 caught the case that matters. Quoted from its log:

EAGER_CLOSURE_PR_BASE_SHA: 090927f4f3a39ba708dd8a7a0eda2196d4ff79df
EAGER_CLOSURE_BASE_SHA: 864154e77ab07cac0d2b3b0d8f5bafcb7dd34959
EAGER_CLOSURE_BASE_REF: main
BUDGET_CLOSURE_STATUS: pass BUDGET_CLOSURE_CHUNK_STATUS: pass
BUDGET_CLOSURE_GZIP_KB: 3223.0 BUDGET_CLOSURE_HEADROOM_STATUS: pass
BUDGET_CLOSURE_BUDGET_KB: 3266.6 BUDGET_CLOSURE_FRESHNESS_STATUS: pass

The two SHAs differmain moved under this PR while it was open — so the half had to decide whether any ceiling moved with it. Confirmed independently: main advanced 7 commits across that span, git diff over the three closure files is empty, and both ceiling constants are byte-identical at each end. pass is therefore the correct answer on a genuinely moved base, not an accidental one — the half does not cry wolf on ordinary base movement. (The earlier run on 11d60f84a had both SHAs equal and exercised only the degenerate path.)

One honest limit: the checker's own stdout prose is not quotable from my seat — the job-log API truncates to ~4.4 KB and the full-log archive host is blocked by this container's proxy (403 on CONNECT). What I quote is the published output value, freshness.status from the same verdict object; I have not read the prose line and am not presenting it as read.

Red before, green after

The freshness half, reverse-verified from the committed state with the three production files reverted to origin/main, tests kept. Mutation proven on disk by grep counts and blob hash; restore proven byte-identical to HEAD. A behavioural probe using only exports that exist on origin/main:

pre-fixpost-fix
probe exit code0 — the race is OPEN, a superseded ceiling passed2
closure_freshness_statusnot publishederror
other three halvespasspass

The two test files under that ablation: 25 failed — behavioural, not import errors.

Each contract test ablated separately from its own committed state:

ablationresult
both - '.github/workflows/performance-budget.yml' entries deleted (2 → 0)exactly 1 red — the closure test
- 'scripts/check-eager-closure-budget.mjs' deleted from both filters (2 → 0)exactly 1 red — the closure test
a __tests__ path injected into TRIGGER_CLOSURE2 red — closure test and definition test
run() reverted to inheriting process.env, no ambient variable setexactly 1 red — the new hermeticity guard

That last row is the point of the guard: it reds locally, on the change that causes it, rather than on someone else's PR.

Hermeticity, checked in both directions

The whole scripts/ suite at b6cfa76f7, exit codes captured by redirect before any pipe:

ambient environmentexitverdict line
GITHUB_EVENT_NAME=pull_request (reproduces CI)0Tests 2262 passed (2262)
variable unset (a developer machine)0Tests 2262 passed (2262)
fuller fake-Actions env + decoy $GITHUB_OUTPUT0Tests 2262 passed (2262); decoy file never written

Setting the variable is what turns "it passes here" into a measurement — on the parent commit, the first row is exactly the run that reproduces CI's four failures.

Gates

All at b6cfa76f7, clean tree, exit codes by redirect before any pipe:

gateexitits own verdict line
pnpm type-check:scripts0
pnpm lint:root (full root population)0✖ 28 problems (0 errors, 28 warnings) — 0 in the changed files
check-control-bytes0✅ OK (scanned 5195 tracked text file(s); skipped 85 binary)
check-entry-guard0✓ 47 scripts/ file(s) … 42 export bindings, 42 of them inert on import
check-shell-escape-residue0✅ OK (4/4 root(s) resolved …)
check-changeset-presence0✅ No source of a released package changed in this range, so no changeset is owed.
check-pre-install-import-graph0✅ OK — 16 pre-install step(s) in 15 job(s) …

No changeset, on the gate's own verdict. objectstack-ai/objectui has no skip-changeset label mechanism, so nothing was labelled.


Generated by Claude Code

…base branch
`Bundle Analysis` is a required context, and a `pull_request` run checks out the
MERGE REF — the PR head merged with the base branch as GitHub last computed it.
GitHub does not re-run a PR's checks when the base branch moves, so a green
verdict can be computed against ceiling constants `main` has since replaced, and
the merge is then gated on a verdict about a ceiling that no longer exists.
Measured, not inferred. Run 32804357171 (`pull_request`, `conclusion: success`)
started at 03:13:27Z, 6m50s after `0409b766d` lowered
MAX_EAGER_CLOSURE_GZIP_BYTES from 4,086,000 to 3,345,000 on `main`, and
published `BUDGET_CLOSURE_BUDGET_KB: 3990.2` — 4,086,000 bytes, the retired
ceiling. It did not bite: the payload was 3222.6 KB, under both numbers. The
aggregate headroom is now 44.0 KB against an 89.0 KB regression class, so the
next stale verdict has half a regression of room to hide in.
`evaluateCeilingFreshness` is a fourth half alongside the aggregate, per-chunk
and sensitivity ones, and it compares VERDICT_CEILING_CONSTANTS across THREE
readings of this file: this checkout, the base commit the checkout was computed
from, and the base branch tip. Two readings would not do — a re-baseline PR
differs from the base branch deliberately and has to stay landable — so the
verdict fires only when the base branch moved a ceiling AND this checkout does
not carry the move.
It is exit 2, a verdict about the GAUGE, never exit 1: nothing grew, no ceiling
has drifted, and the message says so in as many words. A freshness failure that
read like "the bundle grew" would send its reader hunting a regression that is
not there — and the repair they would reach for is widening a ceiling, the one
thing this must never teach. The PR comment gets its own `superseded ceiling`
verdict and its own note rather than borrowing the broken-gauge wording, which
would be false here.
What this does not close: a run that FINISHES before the constants move and is
merged after. No code of ours runs at that moment. That residual is what the
`push`-on-`main` run detects after the fact, and closing it needs "require
branches to be up to date before merging" — a repository setting, and a
maintainer-floor decision this file has no business making.
Fixes#6245
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
The previous commit changed this gate's YAML — `fetch-depth: 2`, the resolve
step, the `$GITHUB_ENV` threading, `BUDGET_CLOSURE_FRESHNESS_STATUS` reaching
the comment step — and none of it could ever run on the PR that made it. The
`paths:` filters list `packages/**`, `apps/console/**` and `pnpm-lock.yaml`;
a PR editing only `scripts/**` and `.github/workflows/**` matches none of them,
so `Bundle Analysis` never appears. Measured on PR #6315 before this commit:
25 check runs, and `Bundle Analysis` was not among them.
That is worse here than it looks, precisely because the freshness half is
fail-closed by design. A wiring bug in the resolve step yields missing sources,
freshness `error`, exit 2 — correctly loud, but the red would land on the NEXT
`packages/**` PR, which belongs to another seat, and it would read to them as a
bundle problem on their own diff.
Self-inclusion is this repo's convention, not a new policy. Measured on
`origin/main` @ `090927f4f`: 29 workflows, 7 carry a `paths:` filter, and 5 of
those 7 list their own file (`half-state-patrol`, `hook-selftests`,
`node-esm-load-gate`, `published-dist-gate`, `spec-range-floors`). This gate was
one of the two exceptions. The cost is real and accepted: every future edit of
this file now pays a console build, the same tax the five already pay.
The contract test pins it for both the `push` and `pull_request` filters, so
the exception cannot quietly return.
Part of #6245
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3222.9 KB3266.6 KB
Main entry chunk (gzip)154.1 KB350 KB
Entry fileindex-BHAA5kjr.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.96KB4.16KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.33KB42.81KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.92KB30.85KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.83KB14.55KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Listing this workflow's own YAML in its `paths:` filters was necessary and still
not sufficient. A PR touching only `scripts/check-eager-closure-budget.mjs` —
the file that computes the verdict, and the file this card is about — did not
trigger `Bundle Analysis`. The previous commit's PR triggered it only because it
also edited the YAML; the next person to edit the checker alone would have hit
exactly the gap that commit closed.
The convention on `origin/main` is the gate's RUNTIME CLOSURE, not merely its own
workflow file. Measured across the 7 path-filtered workflows: `half-state-patrol`
lists the script it runs AND `scripts/invoked-as.mjs`, that script's dependency;
`node-esm-load-gate`, `published-dist-gate` and `spec-range-floors` each list
their gate script by exact path.
So both filters now carry what this job actually executes:
- scripts/check-eager-closure-budget.mjs
- scripts/render-budget-comment.mjs
- scripts/invoked-as.mjs
`invoked-as.mjs` is the third entry, and it is here for a specific reason rather
than for symmetry with `half-state-patrol`: `isEntrypoint` decides whether
`main()` runs at all, so a regression in it makes this checker exit 0 having
measured nothing — the silent-green failure the rest of this gate exists to
prevent.
NOT listed: the `scripts/__tests__/*.test.ts` files. The closure is defined by
what the job EXECUTES, and this job runs two `node scripts/*.mjs` commands and
never vitest, so a test-only edit cannot move its verdict; those tests already
run on every PR in the root vitest `unit` project. `published-dist-gate.yml` and
`spec-range-floors.yml` draw the same line.
Two contract tests hold it: one pins every closure entry in BOTH filters, the
other derives the expected set from the `node scripts/*.mjs` commands the
workflow actually contains, so the list and its definition cannot drift apart.
Part of #6245
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3223.0 KB3266.6 KB
Main entry chunk (gzip)154.2 KB350 KB
Entry fileindex-DT9eIAXJ.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.96KB4.16KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.63KB114.68KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.79KB59.99KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.33KB42.81KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.92KB30.85KB
plugin-gantt (index.js)164.17KB39.89KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.83KB14.55KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

… of inheriting it
`Test (shard 3/4)` went red on b1e336a with four failures, all pre-existing
assertions in this file, all receiving exit 2 where 0 and 1 were the only
outcomes they were written for.
The cause is this suite, not the checker. Adding `env` to `main(argv, env =
process.env)` was right for production, but the `run()` helper here omitted the
argument and so inherited the process environment. `GITHUB_EVENT_NAME=pull_request`
is ambient inside GitHub Actions, so in CI — and only in CI — that switched the
ceiling-freshness half ON for every case routed through `run()`. With no
`EAGER_CLOSURE_*_SOURCE` injected, the half correctly reported `error` and
`main` correctly returned 2. Both halves of the machinery behaved exactly as
designed; the test helper was measuring the machine it ran on.
⭐ Worth stating plainly, because it is the reusable lesson: the local
`vitest run scripts/` that reported 2261 passing was a FALSE GREEN. It passed
because a variable happened to be absent, not because the code was right, and
the same is true of every ablation routed through this helper. "Green on my
machine" was never evidence for this file.
`run()` now injects `{ GITHUB_OUTPUT: outputPath, ...env }` and no longer mutates
`process.env` at all. `GITHUB_EVENT_NAME` is deliberately absent from that object,
which is what makes these cases exercise the non-pull_request path
deterministically rather than by luck; a case can opt in through the new `env`
parameter. The production default is untouched — `env = process.env` is correct
— and none of the four assertions were changed to expect 2, which would have
pinned the accident instead of fixing it.
A new test sets `GITHUB_EVENT_NAME=pull_request` in-process and asserts `run()`
is unmoved by it, so a regression to inheritance reds locally on the change that
causes it rather than on someone else's unrelated PR.
Verified in both directions rather than one: the whole `scripts/` suite passes
with the variable ambient (which reproduces the CI failure on the parent commit),
with it unset, and under a fuller fake-Actions env whose decoy `$GITHUB_OUTPUT`
the suite never writes to.
Part of #6245
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CZgmFFzqA9cX8tBMhvpFe
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3223.0 KB3266.6 KB
Main entry chunk (gzip)154.2 KB350 KB
Entry fileindex-H9ocf79e.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.96KB4.16KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.75KB114.70KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)171.74KB47.48KB
fields (index.js)238.79KB59.99KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)211.95KB42.75KB
plugin-detail (index.js)245.10KB62.31KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.92KB30.85KB
plugin-gantt (index.js)164.14KB39.87KB
plugin-grid (index.js)201.14KB54.40KB
plugin-kanban (index.js)52.83KB14.55KB
plugin-list (index.js)111.94KB27.24KB
plugin-map (index.js)20.09KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.55KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bundle Analysis can gate a merge on a verdict computed against a superseded ceiling

2 participants

@os-warren@claude