Skip to content

fix(types): SchemaRegistry's kanban entry stops describing a component it cannot name #10074

fix(types): SchemaRegistry's kanban entry stops describing a component it cannot name

fix(types): SchemaRegistry's kanban entry stops describing a component it cannot name #10074

name: Bundle Analysis
on:
push:
branches: [main, develop]
paths:
- 'packages/**'
- 'apps/console/**'
- 'pnpm-lock.yaml'
# This file itself (objectui#6245). Without it a change to this gate ships
# to `main` having never run once: the PR that edits it touches no
# `packages/**` path, so `Bundle Analysis` never appears on it, and a
# wiring bug surfaces on the NEXT `packages/**` PR — a required context
# turning red on someone else's diff, reading to them as a bundle problem
# of their own. Self-inclusion is this repo's convention for a
# path-filtered gate: measured on `origin/main`, 5 of the 7 workflows
# carrying a `paths:` filter list their own file, and this was one of the
# two that did not. The cost is honest and accepted — every edit of this
# file now pays a console build — and it is the same tax the other
# self-included gates already pay.
- '.github/workflows/performance-budget.yml'
# ...and the gate's RUNTIME CLOSURE: the files this job actually executes.
# Listing the YAML alone was still not enough — a PR touching only
# `check-eager-closure-budget.mjs`, the file that computes the verdict and
# the one this card is about, did not trigger this gate. The convention
# measured on `origin/main` is the closure, not just the workflow:
# `half-state-patrol.yml` lists its script AND `scripts/invoked-as.mjs`,
# the dependency of that script.
#
# `invoked-as.mjs` is here for the same reason it is there, and the reason
# is specific rather than tidy: `isEntrypoint` decides whether `main()`
# runs at all, so a regression in it makes this checker exit 0 having
# measured nothing — the exact silent-green failure the rest of this gate
# exists to prevent.
#
# NOT listed: `scripts/__tests__/*.test.ts`. This job never runs vitest —
# it runs two `node scripts/*.mjs` commands — so a test-only edit cannot
# change this gate's verdict, and 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.
- 'scripts/check-eager-closure-budget.mjs'
- 'scripts/render-budget-comment.mjs'
- 'scripts/invoked-as.mjs'
# The SDUI registration pins (objectui#6683) and their two dependencies.
# Same rule as the three above — this job runs them, so an edit to any of
# them must be able to turn this gate red on its OWN PR rather than on
# somebody else's next `packages/**` change.
- 'scripts/check-sdui-registration-pins.mjs'
- 'scripts/check-side-effects-array.mjs'
- 'scripts/component-registrations.mjs'
- 'scripts/js-comment-mask.mjs'
pull_request:
branches: [main, develop]
paths:
- 'packages/**'
- 'apps/console/**'
- 'pnpm-lock.yaml'
# This file itself (objectui#6245). Without it a change to this gate ships
# to `main` having never run once: the PR that edits it touches no
# `packages/**` path, so `Bundle Analysis` never appears on it, and a
# wiring bug surfaces on the NEXT `packages/**` PR — a required context
# turning red on someone else's diff, reading to them as a bundle problem
# of their own. Self-inclusion is this repo's convention for a
# path-filtered gate: measured on `origin/main`, 5 of the 7 workflows
# carrying a `paths:` filter list their own file, and this was one of the
# two that did not. The cost is honest and accepted — every edit of this
# file now pays a console build — and it is the same tax the other
# self-included gates already pay.
- '.github/workflows/performance-budget.yml'
# ...and the gate's RUNTIME CLOSURE: the files this job actually executes.
# Listing the YAML alone was still not enough — a PR touching only
# `check-eager-closure-budget.mjs`, the file that computes the verdict and
# the one this card is about, did not trigger this gate. The convention
# measured on `origin/main` is the closure, not just the workflow:
# `half-state-patrol.yml` lists its script AND `scripts/invoked-as.mjs`,
# the dependency of that script.
#
# `invoked-as.mjs` is here for the same reason it is there, and the reason
# is specific rather than tidy: `isEntrypoint` decides whether `main()`
# runs at all, so a regression in it makes this checker exit 0 having
# measured nothing — the exact silent-green failure the rest of this gate
# exists to prevent.
#
# NOT listed: `scripts/__tests__/*.test.ts`. This job never runs vitest —
# it runs two `node scripts/*.mjs` commands — so a test-only edit cannot
# change this gate's verdict, and 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.
- 'scripts/check-eager-closure-budget.mjs'
- 'scripts/render-budget-comment.mjs'
- 'scripts/invoked-as.mjs'
# The SDUI registration pins (objectui#6683) and their two dependencies.
# Same rule as the three above — this job runs them, so an edit to any of
# them must be able to turn this gate red on its OWN PR rather than on
# somebody else's next `packages/**` change.
- 'scripts/check-sdui-registration-pins.mjs'
- 'scripts/check-side-effects-array.mjs'
- 'scripts/component-registrations.mjs'
- 'scripts/js-comment-mask.mjs'
concurrency:
group: bundle-analysis-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
bundle-analysis:
name: Bundle Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
submodules: true
# Depth 2, not 1: on a `pull_request` run HEAD is the MERGE REF, whose
# first parent is the base commit this checkout was computed against.
# The ceiling-freshness half (objectui#6245) needs that commit's copy
# of the checker to tell a stale checkout from a PR that re-baselines
# on purpose, and one extra commit is cheaper than a second fetch.
fetch-depth: 2
- name: Enable Corepack
run: corepack enable
- name: Verify pnpm version
run: pnpm --version
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'
# ── Cache bookkeeping cannot void a recorded verdict (objectui#7048) ──
# THE ORDERING, carried here from the type-check split in `ci.yml`
# (objectui#6577, PR #7047) because it is what a future reader needs in
# order to judge whether these steps may be touched: the verdict is
# recorded by the checking steps; everything after them is bookkeeping,
# and bookkeeping must never discard an answer the gate already produced.
#
# Why the SPLIT rather than a timeout on one step: combined
# `actions/cache` declares `main: dist/restore/index.js` plus
# `post: dist/save/index.js`, so its save is a step the RUNNER generates
# at job end (`Post Turbo Cache`). No workflow syntax attaches
# `timeout-minutes` or `continue-on-error` to a generated post step.
# Measured once, on `ci.yml`'s type-check cache: a 1-second save took
# 789s, ran that job into its ceiling, and the resulting `cancelled` —
# which the merge queue cannot tell from `failure` — ejected an all-green
# pull request (objectui#6577). `actions/cache`'s own `save-always`
# deprecation text points at this same split.
#
# ⚠️ This job declares NO `timeout-minutes`, so its ceiling is GitHub's
# default of 360 minutes, and `Bundle Analysis` is a required context
# (objectui#6245). A stalled save would therefore hold a required check
# open for up to six hours before reporting `cancelled`. ⛔ Adding a
# ceiling is NOT the fix and is not attempted here — it would change what
# this gate rejects. The bound below is on the bookkeeping step alone.
#
# The restore half is deliberately left UNBOUNDED: a restore stall fails
# BEFORE any verdict exists — a gate that did not run, which is honest —
# rather than a recorded verdict discarded.
- name: Restore Turbo Cache
id: turbo-cache
uses: actions/cache/restore@v6
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
id: build_packages
run: pnpm turbo run build --filter='./packages/*'
- name: Build Console
run: pnpm --filter @object-ui/console build
# objectui#6245: `Bundle Analysis` is a required context, and 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 ceiling that no longer exists. Observed
# live: run 32804357171 started at 03:13:27Z, 6m50s after `0409b766d`
# lowered MAX_EAGER_CLOSURE_GZIP_BYTES from 4,086,000 to 3,345,000, and
# published `BUDGET_CLOSURE_BUDGET_KB: 3990.2` — the retired ceiling — as a
# success.
#
# This step hands the checker THREE readings of the constants: the two
# below plus its own checkout. Two 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. See `evaluateCeilingFreshness`.
#
# `continue-on-error` is deliberate and is NOT a fail-open: when this step
# cannot produce both files the checker finds the variables unset and
# reports a freshness ERROR (exit 2), which fails the job. Letting the step
# itself fail the job would kill it before the bundle is measured, and a
# run that hides its numbers is how this gate loses its readers
# (objectui#3152).
#
# The paths go out through `$GITHUB_ENV` rather than a step `env:` block:
# `render-budget-comment.test.ts` walks every `NAME: ${{ steps.* }}` line
# in this file and requires the COMMENT RENDERER to read it, which is the
# right rule for the comment's inputs and the wrong one for the checker's.
- name: Resolve the base-branch ceiling constants
id: base_ceilings
if: ${{ github.event_name == 'pull_request' }}
continue-on-error: true
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
CHECKER='scripts/check-eager-closure-budget.mjs'
OUT="${RUNNER_TEMP}/eager-closure-ceilings"
mkdir -p "$OUT"
# The base commit the merge ref was computed against. `fetch-depth: 2`
# normally makes it a local object already; the fetch is the fallback
# for a merge ref GitHub recomputed against a different base.
if ! git cat-file -e "${PR_BASE_SHA}^{commit}" 2>/dev/null; then
git fetch --no-tags --depth=1 origin "$PR_BASE_SHA"
fi
git cat-file -p "${PR_BASE_SHA}:${CHECKER}" > "$OUT/pr-base.partial"
mv "$OUT/pr-base.partial" "$OUT/pr-base.mjs"
# The base branch as it stands right now — what the merge would land on.
git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
BASE_SHA=$(git rev-parse "refs/remotes/origin/${BASE_REF}")
git cat-file -p "${BASE_SHA}:${CHECKER}" > "$OUT/base.partial"
mv "$OUT/base.partial" "$OUT/base.mjs"
# Written only after BOTH `mv`s: a half-populated pair would let the
# checker compare against a file it thinks is the base branch.
{
echo "EAGER_CLOSURE_PR_BASE_SOURCE=$OUT/pr-base.mjs"
echo "EAGER_CLOSURE_BASE_SOURCE=$OUT/base.mjs"
echo "EAGER_CLOSURE_PR_BASE_SHA=$PR_BASE_SHA"
echo "EAGER_CLOSURE_BASE_SHA=$BASE_SHA"
echo "EAGER_CLOSURE_BASE_REF=$BASE_REF"
} >> "$GITHUB_ENV"
echo "Ceilings resolved: ${PR_BASE_SHA} (this checkout's base) vs ${BASE_SHA} (${BASE_REF} now)"
- name: Check console performance budget
id: budget
run: |
# TWO measurements, one verdict.
#
# 1. The `index-*.js` entry chunk against a 350 KB line. This is the
# original budget and it stays: a fat entry chunk is still worth
# its own signal, and replacing a gauge is not licence to weaken
# the one already here.
# 2. The EAGER CLOSURE — every chunk the entry reaches through STATIC
# imports, i.e. everything the browser must fetch and parse before
# the app renders.
#
# Measurement 1 alone was the whole budget until objectui#5324. On
# `77f846a8b` the entry chunk gzips to 25.9 KB against the 350 KB line
# while the closure it pulls in is 3,881,609 bytes across 58 of 507
# chunks — so the gate passed on 0.67% of the payload it claimed to
# govern, and the 89 KiB regression of objectui#5266 landed in
# `vendor-objectstack-*.js` where nothing here could see it.
# `advancedChunks` routes vendor and workspace code into named chunks
# on purpose, so MOST regressions land outside `index-*.js`.
#
# The closure ceiling is NOT set here. It lives in
# `scripts/check-eager-closure-budget.mjs` next to the reasoning that
# produced it, so a re-baseline is one edit against a documented
# argument rather than a number in YAML: today's measured payload plus
# half the 89 KiB regression the gate exists to catch, which keeps the
# headroom NARROWER than that regression while leaving as much room for
# the payload to shrink as to grow. Since objectui#5924 the checker
# enforces that itself, against the report it just read rather than
# against a frozen constant, for the aggregate ceiling and each
# per-chunk one — a ceiling that has drifted out of range of the
# regression it must catch is exit 2, a verdict about the gauge. It is
# a truthful current-state ceiling, not a statement that 3.15 MB is fine.
#
# Both measurements run before either may fail the step: a run that
# reports one number and hides the other teaches readers to distrust
# the comment, which is how objectui#3152 nearly took this gate down.
MAX_ENTRY_GZIP_KB=350
DIST_DIR="apps/console/dist/assets"
if [ ! -d "$DIST_DIR" ]; then
echo "❌ Build output not found at $DIST_DIR"
# Declare the outcome instead of leaving every output empty. An
# empty `budget_status` used to render as a ❌ FAIL verdict
# downstream (objectui#3152); the comment renderer now needs each
# path to say what happened rather than infer it from silence.
echo "budget_status=error" >> "$GITHUB_OUTPUT"
echo "budget_message=Build output not found at $DIST_DIR" >> "$GITHUB_OUTPUT"
exit 1
fi
# Find the main entry chunk (index-*.js)
ENTRY_FILE=$(find "$DIST_DIR" -name 'index-*.js' -not -name '*.gz' -not -name '*.br' | head -1)
if [ -z "$ENTRY_FILE" ]; then
echo "⚠️ Could not find main entry chunk, checking all JS files..."
ENTRY_FILE=$(find "$DIST_DIR" -name '*.js' -not -name '*.gz' -not -name '*.br' | sort | head -1)
fi
if [ -z "$ENTRY_FILE" ]; then
echo "❌ No JS files found in $DIST_DIR"
echo "budget_status=error" >> "$GITHUB_OUTPUT"
echo "budget_message=No JS files found in $DIST_DIR" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "📦 Main entry file: $(basename $ENTRY_FILE)"
# Calculate gzip size
GZIP_BYTES=$(gzip -c "$ENTRY_FILE" | wc -c)
GZIP_KB=$(awk "BEGIN {printf \"%.1f\", $GZIP_BYTES / 1024}")
echo " Raw size: $(awk "BEGIN {printf \"%.1f\", $(wc -c < "$ENTRY_FILE") / 1024}") KB"
echo " Gzip size: ${GZIP_KB} KB"
echo " Budget: ${MAX_ENTRY_GZIP_KB} KB"
echo "gzip_kb=$GZIP_KB" >> "$GITHUB_OUTPUT"
echo "budget_kb=$MAX_ENTRY_GZIP_KB" >> "$GITHUB_OUTPUT"
echo "entry_file=$(basename $ENTRY_FILE)" >> "$GITHUB_OUTPUT"
ENTRY_OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}")
if [ "$ENTRY_OVER" -eq 1 ]; then
echo "❌ ENTRY BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
else
echo "✅ Entry budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)"
fi
echo ""
echo "📦 Eager closure (what a page load actually pays for):"
# Writes closure_status / closure_gzip_kb / closure_budget_kb /
# closure_chunks / closure_chunk_status / closure_headroom_status to
# $GITHUB_OUTPUT itself. EVERY key it publishes is passed into the
# comment step below and rendered there — a verdict published to
# $GITHUB_OUTPUT that no consumer reads is a verdict the PR comment
# cannot name, which is what objectui#6230 was (the per-chunk and
# sensitivity halves both went unread). A fourth half added here must
# be wired through too; `render-budget-comment.test.ts` fails if it is
# not. The fourth half — ceiling freshness, objectui#6245 — arrived
# through exactly that door: it publishes `closure_freshness_status`,
# and is wired into the comment step below.
#
# Exit codes are distinct on
# purpose: 1 = over budget (a verdict about the bundle), 2 = no
# trustworthy measurement (a verdict about the gauge). Reporting one as
# the other is how a broken gauge gets read as a size regression, and a
# size regression as a broken gauge.
set +e
node scripts/check-eager-closure-budget.mjs
CLOSURE_CODE=$?
set -e
if [ "$CLOSURE_CODE" -eq 2 ]; then
echo "budget_status=error" >> "$GITHUB_OUTPUT"
echo "budget_message=The entry chunk measured ${GZIP_KB} KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either." >> "$GITHUB_OUTPUT"
exit 1
fi
if [ "$ENTRY_OVER" -eq 1 ] || [ "$CLOSURE_CODE" -ne 0 ]; then
echo ""
echo "❌ BUDGET EXCEEDED"
echo "budget_status=fail" >> "$GITHUB_OUTPUT"
exit 1
fi
echo ""
echo "✅ Budget OK: entry chunk and eager closure are both within budget"
echo "budget_status=pass" >> "$GITHUB_OUTPUT"
# The ARTIFACT half of the `sideEffects` contract (objectui#6683). The
# static gate in `ci.yml` proves the array agrees with the module bodies;
# it cannot prove that a real bundler reading that array still EMITS the
# registrations, and that is the question the hazard turns on —
# `"sideEffects": false` is statically coherent and drops three live SDUI
# widget registrations to 0 chunks on a green build (objectui#6535).
#
# The key set is derived from the array itself, so this step needs no
# list of its own. It runs after the budget step and never instead of it:
# a dropped registration and a size regression are different verdicts, and
# a size win bought by deleting a feature must not read as a size win.
#
# Not `if: always()` — with no `dist/` the checker exits 2 and says so,
# which is the honest verdict for a run that measured nothing, but there
# is no reason to spend it on a build that already failed.
- name: Pin the SDUI registrations the `sideEffects` array promises
run: pnpm check:sdui-registration-pins
- name: Generate package size report
id: size-report
# NOT `always()`. `always()` also fires on a cancelled run, where
# `packages/*/dist` is only partly populated — the report was then
# emitted from that partial tree and looked complete while silently
# missing 7 packages (objectui#3152). The report is only meaningful
# once every package has finished building, so gate it on that.
if: ${{ !cancelled() && steps.build_packages.outcome == 'success' }}
run: |
echo "## 📦 Bundle Size Report" > size-report.md
echo "" >> size-report.md
echo "| Package | Size | Gzipped |" >> size-report.md
echo "|---------|------|---------|" >> size-report.md
for pkg in packages/*/dist; do
if [ -d "$pkg" ]; then
pkg_name=$(basename $(dirname $pkg))
# Calculate sizes for main bundle files
for file in "$pkg"/*.js; do
if [ -f "$file" ] && [ ! -f "${file}.map" ]; then
# Use portable method to get file size
size=$(wc -c < "$file")
size_kb=$(awk "BEGIN {printf \"%.2f\", $size/1024}")
# Estimate gzipped size
gzip_size=$(gzip -c "$file" | wc -c)
gzip_kb=$(awk "BEGIN {printf \"%.2f\", $gzip_size/1024}")
echo "| $pkg_name ($(basename $file)) | ${size_kb}KB | ${gzip_kb}KB |" >> size-report.md
fi
done
fi
done
echo "" >> size-report.md
echo "### Size Limits" >> size-report.md
echo "- ✅ Core packages should be < 50KB gzipped" >> size-report.md
echo "- ✅ Component packages should be < 100KB gzipped" >> size-report.md
echo "- ⚠️ Plugin packages should be < 150KB gzipped" >> size-report.md
# Rendering lives in `scripts/render-budget-comment.mjs` so it can be unit
# tested (`scripts/__tests__/render-budget-comment.test.ts`) — the bug this
# replaces was purely a rendering bug, and logic inlined in YAML is
# untestable. Step outputs are passed through `env` rather than
# interpolated into a JS string literal, so an absent output stays an
# empty *variable* instead of vanishing into source text.
- name: Render performance budget comment
id: render_comment
# `!cancelled()`, NOT `always()`: a cancelled run measured nothing, and
# the superseding run posts the real verdict moments later. Commenting
# on cancellation is what produced a ❌ FAIL on every PR that got a
# second push (objectui#3152).
if: ${{ github.event_name == 'pull_request' && !cancelled() }}
env:
BUDGET_STATUS: ${{ steps.budget.outputs.budget_status }}
BUDGET_MESSAGE: ${{ steps.budget.outputs.budget_message }}
BUDGET_GZIP_KB: ${{ steps.budget.outputs.gzip_kb }}
BUDGET_LIMIT_KB: ${{ steps.budget.outputs.budget_kb }}
BUDGET_ENTRY_FILE: ${{ steps.budget.outputs.entry_file }}
BUDGET_CLOSURE_STATUS: ${{ steps.budget.outputs.closure_status }}
BUDGET_CLOSURE_GZIP_KB: ${{ steps.budget.outputs.closure_gzip_kb }}
BUDGET_CLOSURE_BUDGET_KB: ${{ steps.budget.outputs.closure_budget_kb }}
BUDGET_CLOSURE_CHUNKS: ${{ steps.budget.outputs.closure_chunks }}
# The per-chunk (objectui#5490) and sensitivity (objectui#5924) halves.
# The step's exit code folds all three halves into one `budget_status`,
# so without these the comment can say a budget objected but not which
# half did — the reader has to open the job log to find out.
BUDGET_CLOSURE_CHUNK_STATUS: ${{ steps.budget.outputs.closure_chunk_status }}
BUDGET_CLOSURE_HEADROOM_STATUS: ${{ steps.budget.outputs.closure_headroom_status }}
# The freshness half (objectui#6245). Empty on a run it does not apply
# to; the renderer filters an empty half out rather than rendering it.
BUDGET_CLOSURE_FRESHNESS_STATUS: ${{ steps.budget.outputs.closure_freshness_status }}
BUDGET_STEP_OUTCOME: ${{ steps.budget.outcome }}
BUILD_PACKAGES_OUTCOME: ${{ steps.build_packages.outcome }}
run: node scripts/render-budget-comment.mjs > budget-comment.md
- name: Comment PR with results
if: ${{ github.event_name == 'pull_request' && !cancelled() }}
continue-on-error: true
uses: actions/github-script@v9
with:
# GitHub's API intermittently rejects valid run tokens with 401
# (seen on PR #1627), so 401/403 must stay retryable here.
retries: 3
retry-exempt-status-codes: 400,404,422
script: |
const fs = require('fs');
if (!fs.existsSync('budget-comment.md')) {
core.warning('budget-comment.md was not rendered; skipping PR comment.');
return;
}
const body = fs.readFileSync('budget-comment.md', 'utf8');
try {
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
});
} catch (error) {
core.warning(`Could not comment on PR: ${error.message}`);
await core.summary.addRaw(body).write();
}
# The bookkeeping half of the split documented at the restore step above,
# placed HERE — last in the job, after the budget verdict AND after the
# comment that reports it — because that is exactly where the post phase
# it replaces already ran. Nothing about which commits this gate accepts
# or rejects moves with it.
#
# `timeout-minutes: 5` is DERIVED FOR THIS SITE, and landing on the same
# number PR #7047 chose is a result rather than a copy — objectui#7048
# fences inheriting that 5, so here is this site's own arithmetic. This
# job's own save of this cache measured 1s on 2026-09-02 (`Post Turbo
# Cache`, job 100096569559); the whole job was 4m07s. 5 minutes is 300x
# the measured save — the same ratio #7047 sized its bound at, arrived at
# from this job's own number.
#
# `continue-on-error: true` is the other half, and without it the bound
# would only trade a `cancelled` gate for a red one. A cache that failed
# to upload costs the next run some time; it says nothing whatsoever
# about the bundle sizes under test, so it must not speak for them.
#
# Behaviour preserved, spelled out so the equivalence is checkable:
# - `cache-hit != 'true'` reproduces the combined action's own "exact
# hit on the primary key ⇒ do not save" skip. A `restore-keys` prefix
# match leaves `cache-hit` false and the save still runs, exactly as
# the combined action behaved.
# - the condition names no status function, so the implicit `success()`
# still applies — matching the combined action's `post-if: success()`.
# - same `path` and same `key` as the restore step above.
- name: Save Turbo Cache
if: steps.turbo-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
timeout-minutes: 5
continue-on-error: true
with:
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ github.sha }}