Files
civitai__civitai/.github/workflows/lint.yml
T
briant 6e61e1821c test(preview): fail a stranded /moderator probe in the local suite, not only in a job nobody reads
#3573 migrated the moderator surfaces to the standalone app and four preview specs kept probing
paths that now 302 off-origin. `preview / smoke-tests` went red and STAYED red on every PR based
after it, and because that job is report-only nothing stopped: three PRs merged through it in the
four hours before someone looked, and an agent nearly attributed a genuine `main` breakage to their
own PR because it arrived inside an already-red set. #4179 fixed the four failures.

This is the recurrence guard. It scans the preview specs for `/moderator/*` literals and fails when
one has migrated or resolves to no page, so the next migration reddens the machine of whoever
performs it — `test:unit:run` is on the before-committing list — as one named test carrying the path
and the fix, rather than as browser assertions in a job whose red is ambient.

Be precise about what that buys, because the obvious reading is wrong: it does NOT make the fact
blocking. The `unit` job is `continue-on-error: true` and `main` has no required status checks, so
this is report-only too. What changes is where and how the failure appears.

The three specs that assert the redirect on purpose carry an inline `@migrated-route-probe` marker,
checked in both directions — a path that comes BACK to this app strands the assertion the same way.
A marked line may hold only one probe, or the marker would excuse the others silently.

Two properties the scan needs and did not get for free: the positive control asserts the ENFORCED
partition rather than the total, because `it.each([])` registers zero tests and exits 0 (measured),
so marking every line would empty the guard with nothing to show for it; and the capture stops
before `?`, since `/moderator/reports?status=Pending` is the natural shape of a queue probe and the
exact route the incident was about.

Documentation this turned up as stale: the convention-guard list named four of seven, `test:lint-
rules` is invoked by no workflow (those guards run because they match the `unit` project), the root
test-command list omitted the packages and apps suites entirely, and the SvelteKit standard had no
testing section at all despite all three apps having one.

The `unit` job's flip-to-blocking note now carries what was measured today rather than leaving it to
be rediscovered: 8 of 39 recent runs had a red `Unit tests` STEP across 8 distinct branches, which
reads as flake but was trunk-red from a ledger test failing on every PR (fixed by #4191); 5 of 5
green after it. Plus the two traps — the run-level `conclusion` says success while the step under it
failed, and this workflow is `pull_request`-only, so a trunk-red test shows up as every PR reddening
at once.

Refs 868kubuz6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 13:18:32 -06:00

450 lines
23 KiB
YAML

name: Lint
# Regression guard for code quality.
#
# Typecheck is full-repo, and as of talos-infra #855 it is now a NARROW job: it
# runs only where the in-cluster Tekton `pr-check` pipeline does not reach. For
# an internal PR targeting `main` — every PR merged here in living memory — the
# authoritative typecheck is the `tekton / typecheck` commit status, and this job
# skips. See the `typecheck` job comment for the two cases it still covers and
# how each was measured.
#
# 🔴 Read this before treating the fork case as covered. The job carries no
# secret and is not gated by us on the PR's origin — but GitHub gates it for us,
# and the practical outcome is that FORK PRs GET NO TYPECHECK AT ALL TODAY.
# Measured 2026-08-07 over all 16 fork-originated PRs among the ~500 most recent
# PRs: 11 have a retained `Lint` run and ALL ELEVEN sit at
# `conclusion=action_required` (awaiting maintainer approval, zero repository
# code executed); the other 5 are closed PRs whose runs have aged out. `gh pr
# checks` on the open ones reports "no checks reported". Not one fork PR has ever
# produced a Typecheck result.
#
# Two earlier revisions of this comment got this wrong in opposite directions —
# first "typecheck is skipped on fork PRs", then (correcting that) "it runs on
# fork PRs TOO". Both were reasoning from the workflow's CONFIGURATION, which
# says nothing about whether the job RUNS. The configuration genuinely does not
# gate on origin; the approval policy does. Keep the job — it is where fork
# coverage will land the moment a maintainer approves a run, and the sandbox is
# GitHub's rather than ours — but do not cite it as active fork protection.
#
# ESLint and Prettier are split by how the PR touched the file:
#
# ADDED files -> BLOCKING. A new file has no pre-existing findings by
# definition, so this is free, and it stops the backlog
# from growing while the full flip waits.
# MODIFIED/RENAMED -> report-only (`continue-on-error`). For a formatter,
# "changed file" means the WHOLE file, not the changed
# lines: 789 of 4,116 src files fail `prettier --check`
# today. Across the 98 src files touched by the last 30
# commits, 39 fail Prettier and 10 carry a pre-existing
# ESLint error - 44 (45%) would red the job for reasons
# unrelated to the PR, turning a 3-line bugfix into a
# 200-line reformat. The job would be off within a week.
#
# Findings surface as file annotations on the PR diff: ESLint via the problem
# matcher in .github/problem-matchers/, Prettier via ::warning / ::error commands.
# Report-only steps still show a failed-but-ignored marker in the checks list.
#
# The modified-file steps flip to blocking once the backlog is cleared, planned to
# ride along with the Prettier 2->3 upgrade (which forces a reformat anyway).
on:
pull_request:
branches: [main, release]
permissions:
contents: read
concurrency:
group: lint-${{ github.ref }}
cancel-in-progress: true
jobs:
eslint:
name: ESLint + Prettier (changed files)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Collect changed files
id: changed
env:
BASE_REF: ${{ github.base_ref }}
run: |
set -euo pipefail
git fetch -q origin "$BASE_REF"
git diff --name-only --diff-filter=A FETCH_HEAD...HEAD -- '*.ts' '*.tsx' > added.txt
git diff --name-only --diff-filter=CMR FETCH_HEAD...HEAD -- '*.ts' '*.tsx' > modified.txt
# Only paths an eslint config covers. apps/event-engine has its own `root: true`
# config, which eslint resolves per file, so linting it from here works; the other
# apps/ members have none and would fall through to the root Next config, whose
# typed rules crash outside src/.
LINTABLE='^(src|packages|apps/event-engine)/'
grep -E "$LINTABLE" added.txt > added-lint.txt || true
grep -E "$LINTABLE" modified.txt > modified-lint.txt || true
echo "added ($(wc -l < added.txt)):"; cat added.txt
echo "modified ($(wc -l < modified.txt)):"; cat modified.txt
# BLOCKING. Migrations live next to the schema in packages/civitai-db-schema.
# The root prisma/migrations directory predates the monorepo and has no
# migration_lock.toml, so Prisma never reads it — 68 migrations were written
# there by hand and silently ignored. `pnpm db:migrate:empty` now targets the
# right path; this catches the hand-authored case it can't.
- name: Migrations are in the schema package
run: |
set -euo pipefail
STRAYS=$(git diff --name-only --diff-filter=A FETCH_HEAD...HEAD -- 'prisma/migrations/**' || true)
if [ -n "$STRAYS" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
echo "::error file=$f::Migrations belong in packages/civitai-db-schema/prisma/migrations/. Prisma does not read prisma/migrations/ at the repo root. Use: pnpm run db:migrate:empty \"description\""
done <<< "$STRAYS"
exit 1
fi
echo "no migrations added outside the schema package"
- name: Register ESLint problem matcher
run: echo "::add-matcher::.github/problem-matchers/eslint-unix.json"
# BLOCKING. New files start clean, so holding them to the rules costs nothing.
# Errors only (no --max-warnings): the repo has 3,470 warnings and 1,762 uses of
# `any`, so failing new files on warnings would hold them to a stricter bar than
# anything already merged. Warnings still annotate.
- name: ESLint (added files)
run: |
set -euo pipefail
if [ ! -s added-lint.txt ]; then echo "no new lintable files"; exit 0; fi
xargs -a added-lint.txt pnpm exec eslint --format unix \
| sed "s|^$GITHUB_WORKSPACE/||"
- name: Prettier (added files)
run: |
set -euo pipefail
if [ ! -s added.txt ]; then echo "no new files"; exit 0; fi
xargs -a added.txt pnpm exec prettier --list-different > unformatted-added.txt || true
while IFS= read -r f; do
[ -n "$f" ] || continue
echo "::error file=$f::Not formatted. Run: pnpm exec prettier --write $f"
done < unformatted-added.txt
if [ -s unformatted-added.txt ]; then
echo "$(wc -l < unformatted-added.txt) new file(s) are not formatted."
exit 1
fi
echo "all new files formatted"
# Report-only: 10% of recently-touched files carry a pre-existing
# error-severity finding. See the header comment.
- name: ESLint (modified files, report-only)
continue-on-error: true
run: |
set -euo pipefail
if [ ! -s modified-lint.txt ]; then echo "no modified lintable files"; exit 0; fi
xargs -a modified-lint.txt pnpm exec eslint --format unix \
| sed "s|^$GITHUB_WORKSPACE/||"
# Report-only: 19% of src files are unformatted today and a formatter has no
# changed-line granularity. See the header comment.
- name: Prettier (modified files, report-only)
continue-on-error: true
run: |
set -euo pipefail
if [ ! -s modified.txt ]; then echo "no modified files"; exit 0; fi
xargs -a modified.txt pnpm exec prettier --list-different > unformatted.txt || true
while IFS= read -r f; do
[ -n "$f" ] || continue
echo "::warning file=$f::Not formatted (pre-existing, not blocking). Run: pnpm exec prettier --write $f"
done < unformatted.txt
if [ -s unformatted.txt ]; then
echo "$(wc -l < unformatted.txt) modified file(s) are not formatted."
exit 1
fi
echo "all modified files formatted"
typecheck:
name: Typecheck (fork PRs / non-main base)
runs-on: ubuntu-latest
# 🔴 NARROWED, not retired (talos-infra #855, stage 1). The in-cluster Tekton
# `pr-check` pipeline runs `tsc --noEmit` on every PR and posts the result as
# the `tekton / typecheck` commit status. Running this job as well typechecks
# the same tree a second time on every internal PR. Tekton is the one that
# kept reporting through the 2026-08-06 Actions `major_outage` — it POLLS the
# PR API on a 1m interval rather than taking a webhook, and the incident
# degraded webhook delivery.
#
# This `if:` is the complement of what Tekton covers. Both disjuncts were
# measured on 2026-08-07, not assumed:
#
# fork PR — the Tekton side gates fork-authored PRs away by author
# association, deliberately: it executes PR code on our own
# infrastructure, and GitHub's untrusted-code sandbox is doing work we
# would otherwise have to build. So this job is the only typecheck a fork
# PR can get. ⚠️ In practice this arm is DORMANT — see the header.
#
# base != main — the Tekton side only watches PRs targeting `main`, so a
# PR targeting `release` gets no check from it at all. Rare but real: 4
# PRs have ever targeted `release` (0 of the last 200 merged) — most
# recent #2582, closed 2026-06-16; last merged #2248, 2026-05-09. Rare is
# what makes this cheap, not a reason to drop it.
#
# Deleting either disjunct silently removes the only typecheck on that path.
# The honest way to retire this job entirely is to widen Tekton's coverage
# first and prove it on a real PR of each shape.
#
# The name changed with the gate so the checks list explains its own absence.
# Safe: neither `main` nor `release` has any required_status_checks (verified
# 2026-08-07 via the branch-protection API), so no merge gate keys on the old
# name.
if: >-
github.event.pull_request.head.repo.full_name != github.repository
|| github.event.pull_request.base.ref != 'main'
# event-engine-common is public and fetched over HTTPS, so this needs no
# secret — which is why the ssh-agent step is gone rather than merely unused.
# Leaving it in would have kept the fork case broken AND masked the HTTPS
# path: webfactory/ssh-agent rewrites
# url."git@key-<sha>.github.com:<owner>/<repo>".insteadOf "https://github.com/<owner>/<repo>"
# for any loaded key whose comment names a github.com repo, silently sending
# the new HTTPS URL back over SSH.
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
# Must stay `pnpm run typecheck`, not a bare `tsc --noEmit`. The script is
# a wrapper (scripts/typecheck.mjs) that sets the V8 heap cap AND
# classifies the outcome: a `tsc` that dies of heap exhaustion emits zero
# diagnostics, so a raw invocation produces an empty log that reads as a
# clean pass to anyone scanning it. The wrapper says CRASHED instead.
- name: Typecheck
run: pnpm run typecheck
unit:
name: Unit tests
runs-on: ubuntu-latest
# ~8,000 tests that until now only ran on whoever remembered to run them.
# Node env, no browser — component tests (`*.browser.test.tsx`) are not here
# on purpose: they need Chromium, which this job does not install. That is
# the whole reason. This comment used to add "and carry the cold-optimizeDeps
# flake documented at vitest.config.mts:98-124", which was wrong: those lines
# document the FIX for that flake, not an open one. vitest.config.mts:109-127
# dedupes React and pre-bundles `vitest-browser-react` + the JSX runtimes so
# the optimize pass happens BEFORE the run instead of reloading mid-run, and
# calls itself the canonical fix; 98-102 is a separate hookTimeout fix.
# Don't cite a cold-cache flake as a reason to keep this job Chromium-free.
#
# REPORT-ONLY to start, deliberately. A full local run passed 8,937/8,943 but
# timed out 5 tests under machine load; every one of them passed in isolation
# (that file runs in 138ms — the cost is the ~33s import/transform phase, not
# the assertions). The per-test timeout is 60s, so a 2-core runner could trip
# the same way. Blocking on that from day one would red unrelated PRs and the
# job would be switched off within a week — the same reasoning the ESLint and
# Prettier modified-file steps are built on.
#
# FLIP TO BLOCKING once a couple of weeks of runs show a clean pass rate.
# Remove `continue-on-error` and this comment together.
#
# WHERE THAT STANDS, measured 2026-08-20 so the next reader does not repeat the
# archaeology. Over the 39 most recent completed runs the `Unit tests` STEP was red
# in 8, across 8 DISTINCT branches — which reads as flake but was not: every one
# predates #4191, which fixed a ledger test that had been TRUNK red and therefore
# failed on every PR regardless of its contents. Of the runs started after that
# merge, 5 of 5 are green across 4 branches. Encouraging, and far too short a
# window to act on — 36 minutes is not "a couple of weeks".
#
# Two things to know before re-measuring. The run-level `conclusion` is USELESS
# here: `continue-on-error` makes it `success` while the step underneath is
# `failure`, so read `steps[].conclusion` from the jobs endpoint, not the run.
# And this workflow is `pull_request`-only, so the suite never runs against `main`
# itself — a trunk-red test shows up as every PR going red at once, which is the
# signature to look for and exactly what the 8 above turned out to be.
continue-on-error: true
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Unit tests
run: pnpm run test:unit:run
packages:
name: Package unit tests
runs-on: ubuntu-latest
# The nine `packages/*` suites — 616 tests that, until this job existed, NO CI job ran.
# The `unit` job above runs `vitest run --project unit`, whose `include` is root-relative
# (`src/**`, `scripts/**`), so nothing in this repo ever invoked a workspace package's
# suite. That is not a small gap: the schema-drift detector shipped 81 tests into it,
# and two of them had been RED on `main` since #3592 corrected the schema out from under
# their assertions, with nothing to say so.
#
# NOT `continue-on-error`, unlike `unit`. Be precise about what that does and does not
# buy: `main` has branch protection but NO required_status_checks, so a red check here
# does not prevent a merge. The difference from `unit` is that this renders RED rather
# than red-but-ignored — a signal someone has to look at and dismiss, not one the UI
# hides. Making it an actual interlock means adding "Package unit tests" to the required
# checks, which is a repo-settings change, not a workflow one.
#
# The reasoning that made `unit` report-only still does not transfer, which is why this
# one is not marked ignorable: `unit` is ~12,000 tests whose cost is a ~33s
# import/transform phase per file, and it timed out five tests under load. This is 616
# tests with no browser, no database and no Next module graph — ~15s locally.
#
# DB-backed tiers self-skip: `@civitai/db-queries` sources DATABASE_URL from the root
# `.env`, which does not exist on a runner, so its 8 DB-backed tests across three files
# (6 in tag.db.explain, 1 in model.db.explain, 1 in enum-array-parsers.explain) report
# as skipped. They are visible as skips in the summary rather than as a silent absence.
# Those EXPLAIN checks therefore have NEVER run in CI — they are laptop-only evidence.
# The same is true of the 16-case Kysely/Prisma parity suite, which lives in the app's
# `unit` project and self-skips without KYSELY_PARITY_DATABASE_URL.
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
# Not a package suite, and deliberately parked in this job anyway: it needs a completed
# `pnpm install` and a job that FAILS the build, and this is the cheapest one that is
# both. Adding a fourth job would buy a tidier name for the price of a fourth full
# install; the step name is what GitHub shows on a failure, so blame stays readable.
#
# What it guards: `/api/og` returned 500 on every request for two days because Next
# 16.3.0 leaves the libvips SVG loader blocked and `next/og` rasterizes through it. We
# carry the upstream one-line fix as a pnpm patch. The behavioural regression test for
# that outage lives in the `unit` job above — which is `continue-on-error`, so it cannot
# stop the same breakage reaching production a second time. This runs the structural
# half of it, against the INSTALLED node_modules rather than the patch file, so a patch
# that exists but did not apply is caught. ~50ms, no test runner.
- name: Next SVG loader patch applied
run: node scripts/ci/assert-next-svg-patch-applied.mjs
- name: Package unit tests
run: pnpm run test:packages:run --reporter=default --reporter=json --outputFile=packages-report.json
# A green vitest run is a claim, not evidence: `--project` matching nothing exits 0,
# and so does a config whose globs stopped resolving. This asserts the LEDGER — every
# workspace package with a vitest config and a test file on disk must appear in the
# results — so the job fails when the executed set shrinks, which is the regression the
# totals cannot see.
- name: Assert every package suite actually ran
if: always()
run: node scripts/ci/assert-workspace-suites-ran.mjs packages-report.json packages
apps:
# Named for BOTH things it runs. It used to be "App unit tests", and the typecheck steps
# were added under that name — so a type error in an app would have rendered in the checks
# list as a failing unit-test job, pointing the reader at the wrong suite. Safe to rename:
# neither `main` nor `release` has any required_status_checks (re-verified 2026-08-20 via
# the branch-protection API, not inherited from the earlier note), so no merge gate keys
# on the old string.
name: App unit tests + typecheck
runs-on: ubuntu-latest
# The `apps/*` suites — the same gap as `packages` above, in the sibling directory, and
# missed when that one was closed: the root config registered `packages/*/vitest.config.*`
# as projects and nothing for `apps/*`, so 369 tests across five apps had never once run
# in CI. They only ever ran for whoever remembered `pnpm --filter <app> test` by hand.
#
# A SEPARATE job rather than folding the apps into `packages`, because the two selectors
# have to stay disjoint anyway (see the `apps/*` note in vitest.config.mts) and separate
# jobs keep the blame attributable: a red app suite should not make the package suites
# look broken. Costs one more `pnpm install`; these suites run in seconds.
#
# Not `continue-on-error`, for the same reason as `packages`: no browser, no database, no
# Next module graph, ~7s locally. As there, RED here does not block a merge — `main` has
# no required_status_checks — it is a signal someone has to look at and dismiss.
#
# Selection is by vitest config, so an app without one is silently not covered here. Every app
# under `apps/` has one today; scripts/ci/assert-workspace-suites-ran.mjs is what notices if that
# stops being true.
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: App unit tests
run: pnpm run test:apps:run --reporter=default --reporter=json --outputFile=apps-report.json
# Same positive control as the packages job, and it earns its keep harder here: these
# projects are named by hand (`app:auth`, ...), so an app that loses its `test.name`
# stops being selected while the other four keep the run green and exit 0. Measured,
# not assumed — see the script header.
- name: Assert every app suite actually ran
if: always()
run: node scripts/ci/assert-workspace-suites-ran.mjs apps-report.json apps
# ── App typechecks ──────────────────────────────────────────────────
# No app under `apps/` was typechecked by anything before this. The root
# `pnpm run typecheck` is bounded by the root tsconfig `include` — `src`,
# `packages/*/src`, `scripts/local-dev/*.ts`, `tests`, `test`,
# `.next/types/**/*.ts` — with no `apps/*` entry, and BOTH tiers (this
# workflow's `typecheck` job and the `tekton / typecheck` status) run that
# same script, so the gap could not close on its own.
#
# Parked in this job rather than its own: `pnpm install` is the expensive
# part and this job has already paid for it. The tradeoff is that a type
# error renders under this job's name — hence the job rename above.
#
# Deliberately ONE step driving a script, not one `pnpm --filter` step per
# app. Per-app steps abort at the first red app, so a shared type change
# that breaks several of them reports one; and `pnpm --filter <name>` EXITS
# 0 WHEN THE NAME MATCHES NOTHING (measured, pnpm 10.28.1 — it prints "No
# projects matched the filters" and succeeds), so a hardcoded list of
# package names is one chance per app for a rename to turn this gate into a
# green no-op. The script derives the app set from disk, proves each filter
# selected a real package, and collects every failure. See its header.
- name: Typecheck apps
run: node scripts/ci/typecheck-apps.mjs