Files
Zachary Lowden 20e5c21c07 ci(cache): reclaim 9.65 GB of the cache budget and cache the vitest transform cache (#4415)
The repo's Actions cache holds 21 entries / 10,878,231,374 bytes against a
10 GB per-repo limit, so LRU eviction is live and is throwing away useful
entries.

There are exactly two cache keys, same content hash, differing only by OS:

  node-cache-Linux-x64-pnpm-428a04cc...     1 copy,  on refs/heads/main, 511 MB
  node-cache-Windows-x64-pnpm-428a04cc...  20 copies, each on a different
                                           refs/pull/N/merge, ~518 MB each
                                           = 9.65 GB, 95.3% of the budget

Mechanism: an Actions cache entry is scoped to a ref. A PR run can read an
entry saved on its base branch, but not one saved by another PR, and it saves
into its own refs/pull/N/merge scope. windows-dev-env.yml triggers on
pull_request only, so there was no entry on refs/heads/main for a PR to
restore from: every PR missed, and every PR then saved its own ~518 MB copy
under the same key in its own dead scope. lint.yml does not have this problem
precisely because it has a push-to-main leg.

Two changes:

1. windows-dev-env.yml gains push: branches: [main]. One base-branch entry
   then serves every PR and no PR saves its own. Two side effects the guard
   test in scripts/__tests__/main-branch-ci-coverage.test.ts pins, both fixed
   here: the concurrency group must vary per commit on main (it was
   github.ref, one group for the whole branch), and a job on the push path
   may not be unconditionally continue-on-error (a run reporting success over
   a failed step). The canary stays report-only on PRs, which is what its
   header actually argues for, and renders honestly on main. No step in that
   workflow reads github.base_ref or github.event.pull_request, so nothing
   needed gating.

2. lint.yml's unit job caches node_modules/.vite. Measured on a warm local
   checkout: 302 MB, all of it esbuild pre-bundles under
   .vite/vitest/<hash>/{deps,deps_ssr}. All four shards rebuild it cold on
   every PR today. Restore always, save only on main, so this can never
   repeat the per-PR-scope leak above: four entries total, 1.21 GB worst
   case, restorable by every PR from its base branch.

Verified: actionlint clean on both files (and red on a deliberately broken
copy, as a negative control); both parse as YAML; the main-branch CI coverage
guard is 6/6 green, and goes red on exactly the two assertions above when the
concurrency group and continue-on-error are reverted. The unit, packages and
apps jobs keep no job-level if:, so they still run on every PR to main and
release.

The 20 orphaned Windows entries are being deleted separately by the operator;
nothing here reaps them.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 22:06:51 -05:00

177 lines
8.7 KiB
YAML

name: Windows dev-env smoke
# Non-blocking canary for the DEFAULT (non-Nix) contributor setup on native Windows.
#
# WHY: every other job in this repo runs on ubuntu-latest, so nothing validates
# Windows — while Windows support is real and actively maintained:
# `.claude/skills/dev-server/scripts/defender-exclusions.ps1`, `win32` branches in
# `scripts/test-unit-run.mjs`, `scripts/test-perf/bench.mjs`,
# `.claude/skills/dev-server/console.mjs` and `.../scripts/worktree.mjs`, plus
# `it.skipIf(process.platform === 'win32')` guards in `scripts/__tests__/`.
# A supported platform verified by nothing is how a contributor's first hour gets
# spent on a break nobody else can reproduce.
#
# THE SPECIFIC EXPOSURE this is aimed at: `pnpm install` runs two lifecycle hooks —
# `preinstall` (`npx only-allow pnpm`) and `postinstall` (`pnpm run db:generate`,
# i.e. `node scripts/generate-slim-schema.js && prisma generate --no-hints`). The
# Makefile carried `# TODO fix postinstall on git bash` against exactly that path.
# That target was ALSO dead for everyone — `npm i` exits 1 under `only-allow` — so
# the hook path went a long time without being exercised on Windows by anybody.
# Un-breaking the target means the next Windows contributor is the first to walk
# it in a long while. This job walks it first.
#
# BOTH SHELLS ON PURPOSE: `pwsh` is the Windows default, `bash` is Git Bash — and
# the TODO above was Git-Bash-specific. A pwsh-only job would not reproduce it, and
# would report green over the one case we have written evidence about.
#
# DELIBERATELY NON-BLOCKING ON PULL REQUESTS to start. A gate that lands red on day
# one and stops unrelated PRs trains everyone to click through, which is strictly
# worse than no gate. Turn it off once this has been green long enough to mean
# something. It is NOT non-blocking on a push to `main` — see the job's
# `continue-on-error` comment for why the two cases differ.
#
# DELIBERATELY NOT starting the compose stack: those are Linux containers, and
# Docker on Windows runners is slow and flaky — it would buy noise, not signal.
# This covers everything up to `pnpm dev`; the services half is Linux-identical
# and already exercised by the ubuntu jobs.
#
# COST: `windows-latest` is a standard GitHub-hosted runner, which is free for
# public repositories. This adds no Actions minutes.
#
# ── ALSO RUNS ON PUSH TO `main`, AND THAT IS A CACHE FIX ─────────────────────
#
# `setup-node` with `cache: pnpm` writes an Actions cache entry keyed
# `node-cache-Windows-x64-pnpm-<lockfile-hash>`. Actions caches are SCOPED TO A
# REF: a PR run can READ an entry saved on its base branch, but it cannot read
# one saved on another PR, and it saves into its own `refs/pull/N/merge` scope.
#
# With this workflow `pull_request`-only there was no entry on `refs/heads/main`
# for a PR to restore from, so EVERY PR missed, re-resolved the whole store and
# then saved its own ~518 MB copy under the same key in its own scope. Measured
# 2026-08-25: 20 such copies, one per open PR, 9.65 GB — 95.3% of the repo's
# 10 GB cache budget, against ONE 511 MB Linux entry on `main` from `lint.yml`.
# The repo was over the limit, so LRU eviction was live and was evicting the
# useful entries to keep 20 identical useless ones.
#
# `lint.yml` never had this problem, and the reason is exactly the leg added
# below: it has a `push: [main]` trigger, so one base-branch entry serves every
# PR and no PR ever saves its own. This makes that true here too. One entry,
# reused by every PR, refreshed whenever `main` moves.
#
# The two matrix shells share one cache key (same OS, arch and lockfile), so on
# `main` they race to save it and one logs "another job may be creating this
# cache". That is the intended outcome — one entry is the point — not a defect.
#
# Nothing in this workflow reads `github.base_ref` or `github.event.pull_request`
# (both are empty/null on a push), so no step needs gating. That is not luck:
# `scripts/__tests__/main-branch-ci-coverage.test.ts` asserts it as a repo-wide
# relationship over every push-reachable job.
on:
pull_request:
branches: [main, release]
push:
branches: [main]
permissions:
contents: read
# On a PR, unchanged: `github.ref` is `refs/pull/N/merge`, so a new push to the
# branch cancels the in-flight run.
#
# On a push to `main` it MUST NOT be `github.ref` — that is one group for the
# whole branch, so a busy `main` would cancel its own runs and only the last
# commit would ever be checked (and the losers would render as `cancelled`,
# which is indistinguishable from a real failure). Same expression as `lint.yml`,
# for the same reason, and pinned by the same test.
concurrency:
group: windows-dev-env-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: true
jobs:
smoke:
name: Windows dev-env (${{ matrix.shell }})
runs-on: windows-latest
# REPORT-ONLY ON PULL REQUESTS, REAL VERDICT ON `main` — the same split
# `lint.yml`'s `unit` job makes, for the same reason. The header's
# non-blocking rationale is explicitly about not stopping UNRELATED PRs over
# a canary that might be red on day one. On a push the merge has already
# happened, so there is no unrelated work to stop and nothing to block; what
# `continue-on-error: true` would buy there is a run reporting `success`
# while the step underneath failed, which is a green that has to be
# disbelieved. `main` carries no required_status_checks either way.
continue-on-error: ${{ github.event_name == 'pull_request' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shell: [pwsh, bash]
defaults:
run:
shell: ${{ matrix.shell }}
steps:
# `submodules: true` is CI's equivalent of the documented
# `git submodule update --init event-engine-common`. Without it the install
# fails in a way that looks like a code defect rather than a missing checkout
# step — the exact confusion that cost a session earlier.
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
# Report rather than assume. `engines.node` is ADVISORY — pnpm prints
# `WARN Unsupported engine` and exits 0 on a wrong major — so a mismatch here
# would otherwise surface later as unrelated-looking failures.
- name: Toolchain actually in use
run: |
node --version
pnpm --version
# THE STEP UNDER TEST. Both lifecycle hooks run here.
- name: pnpm install (runs preinstall + postinstall)
run: pnpm install --frozen-lockfile
# POSITIVE CONTROL — the half that makes the green mean something.
#
# `pnpm install` exiting 0 is NOT proof the postinstall hook did its job: a
# hook that silently no-ops also exits 0, and the missing client then surfaces
# much later as a confusing runtime error. So assert the artifact.
#
# This particular artifact is a genuine control because
# `packages/civitai-db-schema/prisma/schema.prisma` is GITIGNORED — it cannot
# arrive from the checkout, so its presence proves the hook executed here.
#
# Runs under `node` rather than the matrix shell on purpose: this checks an
# artifact, not shell behaviour, and it keeps the assertion free of pwsh-vs-bash
# quoting differences.
- name: Assert postinstall actually generated the slim schema
shell: node {0}
run: |
const fs = require('fs');
const f = 'packages/civitai-db-schema/prisma/schema.prisma';
if (!fs.existsSync(f)) {
console.log(`::error title=postinstall did not run::${f} is absent after \`pnpm install\`. It is gitignored, so it can only come from the postinstall hook (\`pnpm run db:generate\`). The install reported success, so the hook failed silently or was skipped.`);
process.exit(1);
}
const lines = fs.readFileSync(f, 'utf8').split('\n').length;
console.log(`${f}: ${lines} lines`);
// A truncated or empty generate also exits 0. 50 is far below any real
// schema and far above an accidental stub, so it separates the two
// without pinning a number that ordinary schema churn would trip.
if (lines < 50) {
console.log(`::error title=slim schema looks truncated::${f} exists but has only ${lines} lines.`);
process.exit(1);
}
# Standalone re-run: the hook working once inside `pnpm install` does not mean
# a contributor can run it by hand, which the docs tell them to do.
- name: db:generate standalone
run: pnpm run db:generate