Commit Graph

56 Commits

Author SHA1 Message Date
Justin Maier 233d0aa8be fix(nav): scroll the sub nav tab row, and keep the filters on its line (#4834)
The row is content-width and never collapses — `useResolvedNav` derives the
bar/More split from the user's saved config, not the viewport — so it measures a
fixed ~1334px signed in at every width from 1024 to 1400. `@md:overflow-visible`
overrode both overflow axes above the `md` container breakpoint (1024px),
removing the row's only escape: above 1024 it could not scroll, and an ancestor
`overflow-hidden` clipped whatever exceeded the viewport.

Measured at 1136px signed in, on `/`, `/models` and `/leaderboard/overall`: row
right edge 1334, scrollable overflow 0, and neither Shop nor More hit-testable.
`document.scrollWidth` equalled the viewport at every width, which is why a
page-level overflow check finds nothing here.

Dropping the override restores the scroll the row already relies on below 1024.
`overflow-y` cannot be `visible` beside `overflow-x: auto` — it computes to
`auto` — so the row clips on both axes, and an outline contributes no scrollable
overflow. The row pays the focus ring's 4px of ink on the axis that scrolls,
sized from the ink and carrying `var(--mantine-scale)` as Mantine's own rule
does, rather than from a rem scale that only matches at a 16px root.

The padding is horizontal ONLY, and that is a trade rather than an oversight.
Padding all four sides also unclipped the ring top and bottom, including below
1024 where it is clipped today, but it made the bar taller on every page at every
width. Justin saw the rendered result and declined it. Not "unchanged behaviour",
though: the 36px More button used to set the row's height and leave a 32px pill 2px
of slack, so half the ring showed. Shrinking More to pill height took that. The
mechanism is unchanged; the amount is not.

Second problem, same bar: on feed routes the filters and the settings gear
wrapped to a second line, doubling the bar's height from 44px to 88px. `SubNav2`
wraps, and a wrapping container places items at their flex BASIS before shrinking
any of them, so at `basis: auto` the row's content width does not fit and the
siblings wrap. `flex-1` gives it `basis: 0`: both share the line, and the row
absorbs the shortfall by scrolling. `shrink-0` on the More button is the other
half, because that shrink then lands on the children and More is the one that
collapses, to an empty 28px circle.

`min-w-0` is deliberately absent: `overflow-x: auto` already zeroes a flex item's
automatic minimum size, and removing it changed nothing at any of eight widths.
So is `lg:flex-nowrap`, measured the same way.

Measured on /images, sub nav height, against a control built by reverting only
these classes on the same dev server: 88px to 44px at 1440/1280/1184/1136/1024/900,
and byte-identical child geometry at 768/640/390. The band is route-dependent and
those figures are one route: 40px on /models where every control is `h-8`, 44px on
/images where one filter control is 36px, and 36px on /comics, where
`FilterButton`'s `compact-sm` takes the `h-9` branch.

The rest is one size for the whole row, all of it measured rather than eyeballed:
`SubNav2` top-aligns its children, because on platforms that draw classic
scrollbars the scroller is taller than its pills and centring put the filters half
a scrollbar low; the More button is 32px at 14px/600, matching the pills, where it
was 36px at 16px/500; and the settings gear is 32px, circular, with a 16px icon in
`--mantine-color-bright`. The gear's icon was never smaller than its neighbours —
every icon in the bar is 16x16 with a 2px stroke — it was rgb(222,226,230) against
their rgb(254,254,254), and at a matched box that reads as smaller rather than
dimmer. `bright` is the same #222/#fefefe pair the pills use, so the gear matches
the pills in both schemes; against the globe specifically it is exact in dark and
slightly darker in light, where the globe is `text-gray-8`.

The scrollbar is deliberately left visible: it is the only thing telling anyone the
row scrolls, and "it doesn't look like it scrolls" was the original report.

Guarded at two tiers, and every guard was mutated:

  Shell's provider deleted     RED  expected false to be true
  scale-95 planted (whitelist) RED  to deeply equal [...7 items]
  relative on the row          RED  expected false to be true
  flex-1 removed               RED  expected 32 to be +0
  shrink-0 removed from More   RED  expected 28 to be close to 81.796875
  padding to px-0              RED  expected -4 to be >= 0
  @md:overflow-visible back    RED  expected 'visible' to be 'auto'

The hit-test is the one that needed building twice. The trap it guards — a
`position: sticky` or `relative` ancestor becoming the unportalled dropdown's
containing block — produces a dropdown whose rect is IDENTICAL to a working one
while its items stop being hit-testable, so a rect assertion and a `textContent`
assertion both pass against the broken state. The first version of that test still
passed with `relative` planted, because the geometry harness mounts a bare
`MantineProvider` and never receives `ThemeProvider`'s `Popover.withinPortal:
false`: the menu portalled in the test while rendering inline in the app. It now
nests a provider carrying that default.

The source gate strips comments before matching, so deleting the live row and
leaving a commented-out copy fails loudly instead of passing silently, and it
rejects positioning tokens on the row so the prohibition above is checked rather
than merely written down.

`.moreButton`'s height moved from an explicit `32px` to `h-8`, beside the pills'
own. Measured either side on the same route and browser rather than argued from
the cascade, because that cascade misled two reviewers on this file today:
32px / 16px / 10px / 14px / 600 in both states, identical on every axis.

A second tidy-up — dropping `variant="subtle"`, which `LegacyActionIcon` and
`ThemeProvider` each already set — is deliberately NOT here. The gear does not
render for the probe session, so it could not be measured, and an unverifiable
change whose only benefit is tidiness is not worth carrying.

`.moreButton` no longer states a font either: measured with the declarations
removed, the button still renders 14px/600, because that is what a Mantine
`size="sm"` Button already produces. Two lines of framework default, deleted.

The More button's height is now pinned in the geometry tier against a PILL's
rather than a literal — deleting `h-8` reddens with `expected 36 to be 32`,
where before it passed both tiers green while growing the bar 4px site-wide.

The More button's height and typography are pinned in the geometry tier against
a PILL's and against the literal 32, because parity alone passes when both move:
`h-8` to `h-9` on both controls leaves them equal at Mantine's 36px and grows the
bar 4px on every page. Mutants: 36-vs-32 either side, and 20px-vs-14px on the font.

The pill's `text-base font-medium` does NOT render. `globals.css`'s unlayered
`.mantine-Button-label *` sets both to `inherit` and sits after
`@tailwind utilities`, so the span takes Mantine's `size="sm"` 14px/600. Three
reviewers read those classes as 16px/500 in one day, so it is written beside them.

The `geometry` project is `continue-on-error` on pull requests, so everything it
measures informs rather than gates. The height claim is therefore asserted in the
`unit` tier too — a 0.3s check that the More button's `clsx` still carries `h-8`.
Paired control: removing it gives `expected [ 'shrink-0' ] to include 'h-8'`,
reordering the class list stays green. The font and geometry claims stay advisory
and the PR body says so.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 18:08:18 -06:00
briant 0b92ab649c docs: drop package and component counts that had gone stale
`@civitai/ui` gaining a vitest config took the packages/* suites to 13, and the primitives under
components/ui/ are at 54 — while the prose still said nine and 24. The counts are removed rather
than corrected: CI's ledger script already asserts every workspace suite ran, so a number in prose
only rots, and both of these had rotted twice.

The schema-drift README also said apps/* had no CI job and that the ledger script hardcoded
`packages/`. Both stopped being true when the App unit tests + typecheck job landed; it takes the
workspace as an argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:21:11 -06:00
Zachary Lowden e92cf5fe4a test(geometry): a browser tier that loads the real cascade at a phone viewport (#4601)
* test(geometry): a browser tier that loads the real cascade at a phone viewport

Adds a fourth Vitest project, `geometry`, and demonstrates it catching a defect
whose own source comment records that nothing rendered can see it.

WHAT THE GAP IS, AND WHAT IT IS NOT. The `component` project is NOT jsdom — it is
real headless Chromium via @vitest/browser-playwright, `page.viewport()` moves
`window.innerWidth`, and `getBoundingClientRect()` returns real boxes. What it is
missing is the STYLESHEET and the VIEWPORT. `test/component-setup.tsx` injects
only the `:root` custom properties parsed out of globals.css, so the document
holds 24 CSS rules: Mantine classes are styleless, Tailwind utilities are inert,
and any `getComputedStyle` assertion whose expected value is the CSS initial
value passes against a broken component. And nothing sets a viewport, so files
inherit the runner's silent 414x896.

THE SAME FIXTURE, THE SAME CORRECT SOURCE, IN BOTH TIERS (PageBlockHost in its
production shell chain):

                            `component`      `geometry`
  viewport                    414 x 896       390 x 844   (default vs set)
  CSS rules in the document          24           3,677
  box-sizing on a bare div  content-box      border-box
  `className="flex"`              block            flex
  chrome bar height                 200              31
  host frame height                 350             844
  APP COLUMN HEIGHT                 150             813

That last row is the argument. 150 is ALSO what the app column measures once the
recorded `flex: 1` defect is planted, so a threshold written in the `component`
tier would have to expect the number the DEFECT produces.

THE HARNESS. `test/geometry-setup.tsx` loads the production cascade in production
order — the `@layer tailwind-preflight, theme, mantine, modules;` statement first
(as _document.tsx emits it), then globals.css, then every `@mantine/*` layer
stylesheet _app.tsx imports. It defaults to a 390x844 phone and THROWS unless the
window reports back the size it asked for; tests assert `observed` against their
own literal on top of that. It exports measurement helpers: `box`,
`childrenUnionBox` (the union of child rects — `scrollHeight` is clamped to the
padding box and cannot see a parent taller than its content), `flexAxis`,
`flexLonghands` (longhands, because `getComputedStyle(el).flex` serialises
`1 1 220px` and `1 1 0%` identically), and `cascadeEvidence`.

WHY A PROJECT AND NOT A CHANGE TO THE SHARED SETUP. Loading the cascade in
`component-setup.tsx` moves existing numbers — measured, the same chrome bar is
200px there and 31px with the cascade, a 169px move on one element, under 212
files / 2,362 tests of which 14 read getBoundingClientRect and 20 read
getComputedStyle. The per-file import pattern (15 files do it today, 3 also take
globals.css) stays available and is not deprecated; what it cannot give is a
guarantee — those files each picked their own subset, none declares the @layer
order, none sets a viewport, and the "did my stylesheet load" guard is
re-hand-rolled per file. The `geometry` glob (`src/**/*.geometry.test.tsx`) is
disjoint from every other project's, so nothing that runs today changes project
and no file is collected twice.

DEMONSTRATED RED. `PageBlockHostFillHeight.geometry.test.tsx` asserts that the
app column reaches the bottom of the host frame at 390x844 and at 390x640.
Dropping `flex: 1` from `app-page-content` (the mutation PageBlockHost.tsx's own
comment records as invisible to every rendered tier) fails it:

  the app column ends at y=181 inside a frame that ends at y=844 — 663px of the
  phone is blank below a running App Block. The column measured 150px of the
  frame's 844px, with flex longhands {"grow":"0","shrink":"1","basis":"auto"}.

Restoring the property returns it to 10/10. A second mutant — dropping `flex: 1`
from the frame's `fit === 'fill'` branch — collapses the column to 269px and is
caught by the literal floor rather than by the frame/content comparison, which
both mutations keep satisfied.

BOTH MUTANTS ARE ALSO CAUGHT IN THE NODE TIER TODAY, by verbatim source pins in
pageBlockHostMaxWidth.test.ts and pageRunScrollContract.test.ts. Stated plainly
so this is not read as claiming otherwise. The difference is what each guard can
SEE: a source pin is a claim about the text of one file, blind to a collapse
arriving from the cascade, from an ancestor or from a viewport, and it has to be
rewritten every time the block is legitimately reformatted.

WHAT RUNS THIS TODAY: NOTHING. lint.yml selects `--project 'unit*'`,
`'@civitai/*'` and `'app:*'`; no pattern matches `component` or `geometry`,
because the Actions runners install no Chromium. `component` has the preview
pipeline's report-only status; `geometry` has no CI home at all. Wiring one is a
pipeline change and deliberately not in this PR — but a harness nothing runs
rots, so it is said out loud in the setup file rather than left to be discovered.

Verification: typecheck 0 errors · geometry 2 files / 10 tests passed · component
212 files / 2,362 tests passed (unchanged) · scripts unit tier 24 files / 521
tests passed · eslint clean on both new test files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci(geometry): run the geometry tier, and refuse a green that collected nothing

Adds a `Geometry tests` job to lint.yml. Without it this harness runs in no gate
at all — the workflow selects projects by name (`unit*`, `@civitai/*`, `app:*`)
and none of those patterns matches `geometry`.

ALIGNED WITH THE `unit` JOB'S COMMENT, NOT AN OVERRIDE OF IT. That comment gives
exactly one reason for keeping browser tests out — "they need Chromium, which
this job does not install. That is the whole reason." — which is a statement
about what that job provides, not a ban on providing it. The same comment then
retracts the only other objection on the record ("Don't cite a cold-cache flake
as a reason to keep this job Chromium-free") and names vitest.config.mts's
dedupe + optimizeDeps pre-bundling as the canonical fix. A job that DOES install
Chromium satisfies the stated condition.

GEOMETRY ONLY. `component` stays ungated and that is now visible rather than
fixed. Measured on a 16-core box: `geometry` is 2 files / 10 tests in 9.07s;
`component` is 212 files / 2,362 tests in 112.92s wall, of which 334s is test
time spread across workers — so a 2-core runner (browser pool `min(12, cpus-1)`
= 1 instance) does not divide it. That is an order of magnitude more expensive,
with 212 files of pass/fail history this workflow has never seen. It belongs in
its own PR.

REPORT-ONLY, mirroring `unit` and for its stated reason: blocking a brand-new
tier from day one would red unrelated PRs and the job would be switched off
within a week. `main` has no required_status_checks, so nothing here blocks a
merge either way; `continue-on-error` only decides whether a red renders as red
or as red-but-ignored. The FLIP TO BLOCKING note says concretely what would make
that safe. The `unit` job's selectors and its `continue-on-error` are untouched.

CHROMIUM comes from `pnpm exec playwright install --with-deps chromium` — the
workspace-local playwright, so the revision follows this repo's own pin rather
than a second version written into the workflow. Desynchronising those two is
the documented failure mode (CLAUDE.md records 59 preview specs dying on a
revision mismatch with zero specs run). A local NixOS bundle mismatch is a
property of that host and is handled by PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; the
pin is not moved to accommodate it.

A GREEN VITEST RUN IS A CLAIM, NOT EVIDENCE — the hazard the `packages` job's
ledger exists for, one project over. `--project` matching nothing exits 0, and
this tier's glob is deliberately narrow, which is exactly the kind of pattern
that can quietly stop matching. The new step asserts floors of 2 files and 10
tests from the JSON report, with `if: always()` so it also fires when the tests
fail or the runner aborts without writing a report.

Two things measured rather than assumed while writing it: the file count comes
from `testResults.length`, NOT `numTotalTestSuites` — against a real report this
run is 2 files while that field reads 4, because it counts `describe` blocks.
And the gate script was extracted back out of the parsed YAML and executed on
all three arms before commit: real report -> exit 0 (2 files, 10 tests); a
report with `testResults: []` -> exit 1; a missing report -> exit 1 with its own
message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ci): the geometry job must be report-only on PRs ONLY, not on pushes to main

Caught by this repo's own gate on the previous commit's run:
`scripts/__tests__/main-branch-ci-coverage.test.ts` > "has no unconditionally
report-only job on the push path" went red (Unit tests shard 2, 1 failed / 5,624
passed).

The job was written with a literal `continue-on-error: true`. That is report-only
on EVERY event, including a push to `main`, where it produces a run reporting
`success` while the step underneath failed — the stale-TRUE shape that guard
exists to forbid, and the exact hazard the `unit` job's own comment spells out:
"A green that has to be disbelieved is worse than no run at all."

Now `${{ github.event_name == 'pull_request' }}`, byte-identical to `unit`'s.
Report-only on PRs (a new tier should not red unrelated work while it settles),
honest verdict on `main` (where the merge has already happened and there is no
unrelated work to protect). The guard accepts a conditional precisely because a
conditional can differ between a PR and a push; a literal cannot.

The comment now records this so the next reader does not "simplify" it back.

Red at c2359ba847 (CI), green at HEAD: the guard file is 1 file / 6 tests passed
locally, and the whole `scripts/` unit tier is 24 files / 521 tests passed. That
tier was run BEFORE the workflow existed on the previous commit, which is why it
did not catch this locally — a suite is only evidence about the tree it ran on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 16:40:40 -05:00
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
Zachary Lowden afe84cf7c8 ci: shard the unit suite across 4 runners, 10.4m -> 3.8m (#4392)
Measured from the Actions API (n=12): the job was 555.5s of test work plus
66.5s of fixed overhead = 622s, the 10.4m median, and 3.4x the next-slowest
job. T(N) = 66.5 + 555.5/N puts N=4 at ~3.4m.

Measured after: 10.3m -> 3.8m on the critical path (2.7x) at +36%
runner-minutes, reproduced across two runs with a 1.12-1.14x shard spread.
N=4 rather than more because `App unit tests + typecheck` is 2.9m — past N=5
sharding optimises something that is no longer the bottleneck.

Ships with a positive control, `scripts/ci/assert-shard-ran.mjs`. Sharding
adds a failure this job did not have: a `--shard=i/N` selecting no files exits
0 and reports green, and four green checks look identical whether they ran
22,000 tests or none. The guard asserts per shard that work happened, counting
EXECUTED assertions rather than `numTotalTests` (which includes skipped, so an
all-self-skipping shard would pass a total-based floor), and its bounds scale
with `total` so a change to the matrix does not trip them.

It earned its keep immediately: on the first run it failed all four shards
while `Unit tests` reported SUCCESS. `pnpm run … -- --shard=…` forwards the
`--` literally, vitest DISCARDS everything after it, and so `--shard` and
`--outputFile` were both silently dropped — each runner executed the entire
suite (7m30s-9m54s per step) and wrote no report. Without the control this
would have merged as four green checks that tested nothing verifiable.

A subsequent adversarial audit found three further claims that measurement did
not support, all fixed here: bounds hardcoded off a ten-day-stale suite size
that would have tripped within weeks and blamed the wrong cause; a comment
asserting post-`--` args become filename filters when they are discarded; and
a "mutation-verified" claim that five of twelve mutants walked through. Four
are now covered by named cases; the fifth was unreachable dead code and is
removed rather than papered over with a test that could not kill it.

Final sweep, re-run after the Prettier pass: 10 mutants, 10 killed, each by its
own named test, with a no-op negative control that correctly survived. Suites
20/20. Verified live across two runs; the typecheck-scripts gate was confirmed
to have executed rather than assumed green.

Not verified: behaviour on a push to `main`, where `continue-on-error` is false
and a red shard renders as a genuine failure. That path only exercises on merge.
2026-08-25 15:59:41 -05:00
Zachary Lowden 5770b83f4e ci: give a commit on main a CI verdict again, and pin that it keeps one (#4234)
* ci: give a commit on `main` a CI verdict again, and pin that it keeps one

Nothing has produced a CI verdict for a commit on `main` since 2026-06-14. The
cause was not a decision: `.github/workflows/pr-check.yml` was the only workflow
this repo has ever had with `push: branches: [main]`, and #2547 deleted it while
moving PR checks to Tekton. Every workflow written since — lint (#3362),
submodule-pin-guard, schema-drift (#3643), windows-dev-env (#4162) — is
`pull_request`-only, so the main-push trigger was never rebuilt and nothing said
it was gone.

Tekton does not close the gap. Its `pr-check` pipeline is driven by a resource
that watches open pull requests targeting `main`; the only main-branch trigger
builds a container image for the staging deployment, runs no test suite and
posts no commit status. "Tekton covers main" is the assumption that let this
persist for two months.

The old June runs stayed `success`, so "is main green?" went on answering yes.
That is the expensive half: the signal was not wrong, it was stale, and
staleness is invisible unless you read the dates. a3e790ff2e was pushed straight
to `main` with no PR, broke model-file-scan.service.test.ts, and sat undetected
for ~7 hours — surfacing only when unrelated PRs inherited it through their
merge commits, which reads as a CI outage rather than as one bad commit.

What changes

- `lint.yml` also runs on `push` to `main`. Reusing this file rather than adding
  a second workflow keeps one definition of each tier; a duplicate would drift.
- `eslint` is gated to pull requests. Every step in it diffs against
  `origin/$BASE_REF`, and `github.base_ref` is empty on a push, so on `main` it
  would fail for reasons unrelated to the code. The consequence is stated in the
  file rather than left implied: the added-file lint/Prettier gate and the
  stray-migrations guard stay reachable only through a PR.
- `typecheck` gains an explicit push arm. It is redundant today only because
  `github.event.pull_request` is null on a push, which is an accident of
  null-comparison semantics rather than a decision.
- `unit` is report-only on pull requests exactly as before, and renders its real
  verdict on `main`. `continue-on-error` makes a run report `success` while the
  step underneath fails — on `main` that would replace the stale TRUE with a
  live one, which is worse. Whether the PR side flips to blocking is a separate,
  deliberately-unmade decision and is untouched here.
- The concurrency group varies per commit on a push. Keyed on `github.ref` it is
  one group for the whole branch, so a busy `main` would cancel its own runs,
  leave only the last commit checked, and render the losers as `cancelled` — a
  status this repo already cannot distinguish from a real failure.

Coverage

`scripts/__tests__/main-branch-ci-coverage.test.ts` asserts the outcome, not the
spelling: it names no workflow file and no job, and asks whether a push to
`main` reaches a whole-repo typecheck and the unit suite at all. Splitting or
renaming things keeps it green; losing main coverage does not, however it is
lost. It also pins the three ways this would decay quietly — an unconditionally
report-only job on the push path, a job whose steps consume PR-only context, and
a branch-wide concurrency group.

Six mutants, each killed by the test that owns the property: reverting the
workflow to its pre-change state (the historical defect) fails the coverage
assertions; `continue-on-error: true`, an un-gated base-ref consumer, a
`github.ref` concurrency group, and gating either the typecheck or the unit tier
away from push each fail exactly one test, by name.

* style: format the main-branch CI guard test (added-file Prettier gate is blocking)

* docs(ci): enumerate the push-to-main triggers instead of asserting there was one

The claim 'pr-check.yml was the only workflow this repo ever had with
push: branches: [main]' was reasoned from the current tree, not measured.
Parsing every revision of all 12 files that have ever existed under
.github/workflows/ gives TWO: docker-deploy.yml (removed 2023-11) and
pr-check.yml. Two near misses are named so they are not re-counted —
auth-app.yml filters push on tags only, which never fires on a branch push,
and the final docker-deploy.yml had main commented out under branches.

* ci: wire `db:check-generated` into CI — a four-time regression with nothing enforcing it

`packages/civitai-db-schema/src/*` is generated but tracked, so a commit whose
author's editor reformatted it lands a file that disagrees with its own
generator, and every developer then gets a spurious dirty tree after
`pnpm install`.

`db:check-generated` has existed for exactly this and was wired to nothing.
Measured 2026-08-21: it appears in no workflow. Counting wrapped type aliases in
enums.ts per commit shows it flipping four times in four days —

  470f0fd993  0   "stop prettier reformatting the generated file"
  4214ecb10b  30  re-introduced
  15c1408d3d  31  "the generator emits Prettier-formatted bytes"
  a5ed2dc83e  0   re-introduced
  d0327f55ef  31  re-fixed

— twice AFTER the commit that was supposed to end it by making the generator own
the formatting. That fix is correct. It was a convention with nothing enforcing
it, which is why it did not hold. `main` is clean right now by coincidence of
which commit landed last, not by construction.

Parked in the `packages` job for the same two reasons as the Next-SVG step above
it: it needs a completed `pnpm install` and a job that fails the build, and this
is the cheapest one that is both. postinstall has already run `db:generate`, so
the re-run costs ~1s.

Verified, both arms, on the merged base:
  green  unmodified tree                       -> exit 0
  red    the real regression shape, COMMITTED  -> exit 1, 62 changed alias lines
                                                  (= 2 x 31 aliases)

The red arm needed two attempts and the first one is the interesting half: the
same edit left UNCOMMITTED exits 0, because the script regenerates before it
diffs and overwrites the mutation. A working-tree edit cannot exercise this gate
at all. Recorded in the step comment so the next person testing it does not read
that 0 as "the gate does not work".

Also guards the vacuous-pass case: `git diff --exit-code -- <path>` exits 0 when
the path exists on neither side, so a future move/rename/untrack would leave this
silently and permanently green. `test -f` fails loudly instead. Verified an
--exit-code diff against a nonexistent sibling path exits 0 here today.

Merged origin/main in first (the branch was 9 commits behind and carried the
unwrapped flip state, so the gate was red at base on the branch tip while green
on the merged tree CI actually builds).

actionlint clean, with a positive control confirming it can go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 16:33:17 -05:00
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
Zachary Lowden b8f84204d7 fix(auth): fix the two type errors gating apps/auth, and bring it into the CI typecheck gate (#4188)
apps/auth was the last app excluded from `scripts/ci/typecheck-apps.mjs` (#4156),
on two pre-existing errors. Both are fixed at the root, not silenced, and the
exclusion is gone — the gate now covers all 7 apps.

providers.ts:165 — Parameter 'p' implicitly has an 'any' type
  The stub entry's key is a COMPUTED property (`['stub' as ProviderId]`) whose type
  is the whole ProviderId union rather than one literal, so TS cannot match it to a
  member of `Record<ProviderId, ProviderDef>` and drops contextual typing for the
  value. `satisfies ProviderDef` restores it. Measured, both arms: with `satisfies`,
  misspelling a required field (`scope` -> `scopes`) is caught; with it removed, the
  same misspelling is silently accepted and only the implicit-any resurfaces. So the
  whole entry was unchecked against ProviderDef, not just that one parameter.

establish-session.test.ts:100 — Property '_store' does not exist on type 'never'
  The Cookies stub was cast `as never` to satisfy establishSession's parameter, but
  `never` has no properties, so reading `_store` back in an assertion was itself an
  error. Cookies has exactly five members, so the stub now implements all of them
  and needs no cast at all.

Removing the exclusion breaks the guard suite, which is the real work here: six of
its eight cases put an `auth` app in every fixture purely to satisfy the
stale-exclusion guard, so an empty map made them fail for reasons unrelated to what
they test. Rather than delete those guards, the exclusion map is now a parameter of
an exported `runTypecheckApps({ excluded })`, and the tests drive it with a synthetic
app. Deliberately a function parameter and not an env var: an env-var override would
be a live way to un-gate an app in CI, which is the failure class the script exists
to close. All ten cases now pass regardless of what the shipped map contains.

Also neutralises the "six apps" counts in the script header and workflow comment,
which this change would otherwise make wrong, and applies prettier to two
pre-existing unformatted lines in the touched files.
2026-08-20 11:07:40 -05:00
Zachary Lowden 23cecb57c0 ci: run per-app typecheck scripts in CI (#4156)
* ci: run per-app typecheck scripts in CI

The root tsconfig.json include is:

    scripts/local-dev/*.ts src packages/*/src tests .next/types/**/*.ts

No apps/ entry, so `pnpm run typecheck` does not reach any sibling app.
Each app defines its own typecheck script but none were run by any CI job.

Six of seven apps pass today; wire them up as steps in the existing `apps`
(App unit tests) job, which already has the workspace installed.

  app               script                          result
  event-engine      tsc --noEmit -p tsconfig...     clean
  notifications     tsc --noEmit                    clean
  orchestrator-gateway  tsc --noEmit                clean
  storage           tsc --noEmit                    clean
  creator-studio    svelte-check --tsconfig ./...   clean
  moderator         svelte-check --tsconfig ./...   clean
  auth              svelte-check --tsconfig ./...   2 errors + 1 warning

apps/auth is excluded from the CI wiring. Its pre-existing errors:

  src/lib/server/auth/providers.ts:165
    Parameter 'p' implicitly has an 'any' type.
  src/lib/server/auth/__tests__/establish-session.test.ts:100
    Property '_store' does not exist on type 'nev
  src/routes/login/+page.svelte:28
    state_referenced_locally (warning)

Ticket: 868kt8pfu

* ci: make the app typecheck prove it ran, and report every failing app

Review follow-up on the six `pnpm --filter <pkg> run typecheck` steps. Two ways they could
report SUCCESS while checking nothing or hiding work, plus a blame problem.

🔴 `pnpm --filter <name> run <script>` EXITS 0 WHEN THE FILTER MATCHES NOTHING. Measured on
pnpm 10.28.1: a bogus package name prints "No projects matched the filters" and returns 0.
Six hardcoded package names are six chances for a rename to turn a gate into a green no-op
— the same shape as `prettier --check "$FILES"` reporting "All matched files use Prettier
code style!" across zero files. Nothing in the six steps could notice.

🔴 A NEW app under `apps/` is simply absent from a hardcoded list. The gate stays green and
the app is unchecked — this PR's own hole, reopened by the next person to add an app.

So the app set is now a LEDGER READ FROM DISK: every `apps/*` with a `typecheck` script must
run, each run must be proven to have selected a real package, and `auth`'s exemption is an
explicit entry that hard-errors if it goes stale. Adding an app wires it automatically. This
mirrors `scripts/ci/assert-workspace-suites-ran.mjs`, which exists for the same reason on
the vitest side.

Failures are COLLECTED rather than fatal on the first. Six sequential steps abort the job at
the first red app, so a shared type change breaking four of them reports one.

The job is renamed `App unit tests` -> `App unit tests + typecheck`. The typecheck steps were
added under the old name, so a type error would have rendered in the checks list as a failing
unit-test job, pointing the reader at the wrong suite. Safe: neither `main` nor `release` has
any required_status_checks — re-verified 2026-08-20 against the branch-protection API rather
than inherited from the note already in this file.

Kept in the `apps` job on purpose: `pnpm install` is the expensive part and this job has
already paid for it. A matrix would give parallel legs and per-app check names for the price
of six installs; the rename plus collected failures buys most of the legibility for free.

Eight guard tests in `scripts/__tests__/typecheck-apps.test.ts`, each driven against a stub
apps/ tree with a fake `pnpm` on PATH so the no-match and failure paths are exercised for
real: happy path, stale filter caught, red app, failures collected not masked, empty
discovery refused, new app auto-wired, stale exclusion hard-errors, auth excluded while the
rest still run. All 8 pass; the first draft had six failing for the wrong reason (fixtures
omitted `auth`, so the stale-exclusion guard fired first) — fixtures fixed, not the guard.

Two things deliberately NOT changed:
- The Svelte apps' bare `typecheck` script is fine as-is. Their tsconfig extends
  `./.svelte-kit/tsconfig.json`, absent from a fresh checkout, which looks like a vacuous
  green waiting to happen. It is not: `"prepare": "svelte-kit sync"` runs during
  `pnpm install`. And if it ever stops, svelte-check FAILS LOUDLY rather than passing —
  verified by moving `.svelte-kit` aside, which gave exit 1, "Cannot read file
  .svelte-kit/tsconfig.json", `1 FILES 1 ERRORS`. An earlier review comment of mine claimed
  it would go silently vacuous; that was wrong, and switching to `check` is unnecessary.
- `scripts/__tests__/` is outside every typecheck program (root `include` carries only
  `scripts/local-dev/*.ts`), so this new test file is unchecked — as are the 12 already
  there. Not introduced here; it is the `scripts/` half of this ticket's widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: prettier the two new files

The `Prettier (added files)` step is BLOCKING by design — a new file has no pre-existing
findings, so holding it to the rules is free. Both new files failed it and I did not run the
formatter before pushing. Reproduced locally with `prettier --list-different` (both listed),
fixed with `--write`, and re-ran the 8 guard tests after the reformat: still 8/8, so the
formatting did not disturb the fixture strings or the fake-pnpm heredocs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: DevPod Agent <agent@devpod.local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 00:18:59 -05:00
Zachary Lowden 3d828eaadd ci: validate the default Windows contributor setup, non-blocking (#4162)
Every job in this repo runs on ubuntu-latest, so nothing validates Windows —
while Windows support is real and maintained: defender-exclusions.ps1, win32
branches in test-unit-run.mjs / bench.mjs / console.mjs / worktree.mjs, and
it.skipIf(win32) guards in scripts/__tests__. A supported platform verified by
nothing is how a contributor's first hour goes to a break nobody else can
reproduce.

The specific exposure this targets: `pnpm install` runs `preinstall`
(npx only-allow pnpm) and `postinstall` (pnpm run db:generate). The Makefile
carried `# TODO fix postinstall on git bash` against that path — and the target
it sat on was DEAD for everyone, because `npm i` exits 1 under only-allow. So the
hook path has gone a long time unexercised on Windows. Un-breaking the target
(4107) means the next Windows contributor is the first to walk it in a while.
This walks it first.

Both shells on purpose. pwsh is the Windows default; bash is Git Bash, and the
TODO was Git-Bash-specific. A pwsh-only job would report green over the single
case we have written evidence about.

Non-blocking (continue-on-error) to start. A gate that lands red on day one and
stops unrelated PRs trains everyone to click through, which is worse than no
gate. Flip it once it has been green long enough to mean something.

Does NOT start the compose stack: those are Linux containers and Docker on
Windows runners is slow and flaky — noise, not signal. Everything up to
`pnpm dev` is covered; the services half is Linux-identical and already exercised
by the ubuntu jobs.

Costs nothing: windows-latest is a standard GitHub-hosted runner, free for public
repositories.

The install step carries a positive control, because `pnpm install` exiting 0 is
not evidence the postinstall hook ran — a hook that silently no-ops also exits 0,
and the missing client then surfaces much later as a confusing runtime error. The
job asserts packages/civitai-db-schema/prisma/schema.prisma exists afterwards.
That file is a genuine control precisely because it is GITIGNORED: it cannot
arrive from the checkout, so its presence proves the hook executed on this runner.

The guard was tested against its own failure modes before being trusted, rather
than assumed to work:

  artifact absent          -> rc=1, "postinstall did not run"
  artifact 3 lines         -> rc=1, "slim schema looks truncated"
  artifact 200 lines       -> rc=0

Each failure fires for its OWN reason with a distinct title, so a red run names
which thing broke. The 50-line floor sits against a source schema of 8,087 lines —
two orders of magnitude of headroom, so ordinary churn cannot trip it while a stub
still fails.

Also restores the git-bash TODO to the Makefile rather than leaving it deleted.
Nothing has verified it either way; dropping the note would have retired the only
written record of a known hazard on a path that had stopped being walked. It now
points at this workflow as the thing that will answer it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:18:04 -05:00
Justin Maier 7562c2633a fix(event-engine): make lint and test actually run (#4031)
* fix(event-engine): make lint and test actually run

Both scripts existed and both were broken.

`lint` had no eslint config of its own, so it cascaded to the repo-root
`eslint-config-next` config and crashed in `consistent-type-imports`
(TypeError, exit 2) without linting a file. Adds a package-local
`.eslintrc.js` with `root: true`, mirroring the one already in
`src/common`, and clears the 26 `no-unused-vars` errors it surfaces. The
202 `no-explicit-any` warnings are left as warnings.

`test` ran `jest` with no jest config and no TS transform, so the one
test file failed to parse: 1 suite failed, 0 tests. That file is a
`node:test` suite; it is now a vitest suite, and the package gets a
`vitest.config.ts` named `app:event-engine`. That name is what makes the
root config's `apps/*/vitest.config.ts` glob and `test:apps:run` pick it
up, so CI runs it — `assert-workspace-suites-ran.mjs` now ledgers 6
apps/ members instead of 5.

Two WIP stub handlers (`image-scanned`, `outbox/image-scan`) keep their
unused bindings behind a file-scoped disable rather than being renamed:
their bodies are commented out pending the old ingestion service's
removal and reference exactly those names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(event-engine): resolve `@/` in vitest, narrow the WIP lint disables

Review findings on the first commit.

The vitest config had no `@/` alias. Runtime gets that from
`tsconfig-paths/register` and the build from `tsc-alias`; vitest has
neither, so any test of the 19 source files that import through `@/`
would have failed at collection with `Cannot find package '@/...'` —
reading as a broken test rather than as missing config. The one existing
test passed only because it imports relatively. Matched as `/^@\//` so it
cannot swallow `@civitai/*`.

The two WIP stub handlers had file-scoped disables, which turned
`no-unused-vars` — the only error-level rule here — off for whole files.
Narrowed to the specific lines, so new dead code in them is still caught.

`.github/workflows/lint.yml` carried a second copy of the claim that
event-engine has no vitest config and is a `node:test` suite. The first
commit corrected that in `vitest.config.mts` and missed this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(event-engine): enforce the lint in CI, move the test out of src/common

Second review round.

The lint fixes had no enforcement. `.github/workflows/lint.yml` filters
changed files to `^(src|packages)/`, so nothing in CI lints
`apps/event-engine` — the next unused import would merge green and put
the package back to exit 1 unnoticed, which is the failure this PR
exists to fix, one level up. The filter now includes
`apps/event-engine/`. The other apps/ members stay out: they have no
config of their own and would fall through to the root Next config,
whose typed rules crash outside `src/`.

The suite lived in `src/common`, a vendored copy that
`scripts/sync-submodule.ts` re-syncs from event-engine-common — which
has no tests. A sync would have deleted it, and because the CI ledger
derives its expectations from disk, event-engine would have dropped out
of the apps job silently instead of turning red. Moved to
`src/__tests__/`, importing through the `@/` alias added last commit.

`formatMessage`'s rest parameter is deleted rather than `_`-renamed: no
call site passes it and the body ignored it, so the rename was cementing
dead API surface that read as if the args were formatted.

The reason written above the `search<T>` disable was wrong — `T` checks
nothing. Corrected to say so, with what was tried: `Promise<{ hits: T[] }>`
makes it real but breaks the interface, since meilisearch's own `Index`
stops satisfying it. Dropping `T` is TS2558 at the images.feed.ts call
site. Both verified, not assumed.

Also corrected the reason on the `image-scanned.ts` disables: the point
is that the handler is absent from the registry in `handlers/index.ts`
and nothing publishes `orchestrator.imageScanned`, so none of it runs —
not merely that the TODOs are unwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(event-engine): give the lint real rules and typecheck the test file

Third review round.

The eslint config had no `extends`, so `no-unused-vars` was the only
error-level rule in it. Now that CI's blocking "ESLint (added files)"
gate covers this package, a green annotation on an event-engine file
would have meant materially less than the same annotation on a src/ or
packages/ file. Adding `eslint:recommended` and
`plugin:@typescript-eslint/recommended` surfaced two errors, both fixed:
a stray semicolon, and a lazy `require('kafkajs')` in the health check
that bought nothing — kafkajs is already imported statically by
`index.ts` and `debezium-manager.ts`.

`tsconfig.json` excludes `**/*.test.ts` so the build does not emit tests
into dist/, which left the suite typechecked by nothing: vitest
transpiles without checking. `typecheck` now runs against
`tsconfig.typecheck.json`, the same config with the tests put back.
Verified both ways — a planted `const x: number = "s"` in the test file
is now TS2322, and `build` still emits zero test files.

The reason I wrote above the moved test last commit was wrong.
`src/common` is not a submodule — `.gitmodules` declares only
`event-engine-common` at the repo root — and `scripts/sync-submodule.ts`
pushes *out* of that directory rather than re-syncing into it, so it
could not delete a test living there. The real risk is a manual
re-vendor of a copy that has already diverged; the comment now says
that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(event-engine): delete two scripts that assume src/common is a submodule

`src/common` was a git submodule once. It is not now — `.gitmodules`
declares only `event-engine-common`, at the repo root — and both scripts
still assume otherwise. Neither is in `package.json` scripts or CI, so
each only runs if someone types it by hand, which the filenames invite.

`sync-submodule.ts` chdirs into `src/common` and runs `git add -A`,
`git commit` and `git push origin main`. With that directory no longer
its own repo, all three resolve to `model-share`: it stages every
uncommitted file in the tree, including other people's in-flight work,
and pushes it.

`install-git-hooks.ts` guards on `git submodule status src/common`,
which exits 0 for a path that is not a submodule, so the guard never
fires. It then `mkdirSync`s `apps/event-engine/.git/hooks` recursively —
creating a `.git` directory inside the package, which makes git treat
`apps/event-engine` as its own repository root and drop it from
`model-share`'s tracking. The hook it writes is inert regardless: its
body is gated on a `.gitmodules` grep that no longer matches.

Deleting rather than repairing, because neither does the job its name
implies. `sync-submodule` pushes out of `src/common` and never syncs
content in, so it is not the tool someone reaching for it wants either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(event-engine): lint the whole package, widen the vitest glob

Fourth review round.

The package script was `eslint src` while the CI filter widened last
commit covers `apps/event-engine/` entirely, so a file under `scripts/`
was blocking-linted in CI and invisible locally — green here, red there.
Justin's call was to close it by widening rather than narrowing: `lint`
is now `eslint . --ext .ts`, and the 13 errors that surfaced in
`scripts/` are fixed. Same trivial class as the 26 before: unused
imports and args, one `require()`, one `prefer-const`.

Those four scripts are typechecked by nothing (`tsconfig.json` includes
only `src/**`), so the edits were checked against a throwaway config
including `scripts/`: 8 pre-existing type errors before, the same 8
after, none in the files touched.

The vitest `include` was `src/**/*.{test,spec}.ts`, narrower than the CI
ledger's own detection regex, which scans the whole package. A test at
`scripts/x.test.ts` would have run nowhere while the job stayed green —
the ledger asserts the member is present, not that all its tests ran.
Widened to match, with node_modules/dist excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(event-engine): exclude nested node_modules from the vitest project

Setting `exclude` REPLACES Vitest's defaults rather than adding to them,
so the root-anchored `node_modules/**` left every nested one collected
once `include` was widened to the whole package last commit.

Controlled both ways with a throwaway test at
`src/__probe_nm/node_modules/evil.test.ts`: root-anchored collects it
(2 files / 5 tests), `**/node_modules/**` does not (1 file / 4 tests).

Nothing nested exists today, so this is a trap rather than a live bug —
but `src/common` re-acquiring one, or any subdirectory getting its own
install, would have put third-party tests into `app:event-engine`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:42:51 -06:00
Zachary Lowden 193324c640 perf(db): port the hottest per-user read lookups to Kysely over pg (#3820)
* perf(db): port the hottest per-user read lookups to Kysely over pg

The Prisma query engine runs in-process and its CPU cost scales with call
volume, so the highest-frequency read statements pay it most. Routing those
through Kysely over the app's own `pg.Pool` bypasses the engine entirely for
that query. (`$queryRaw` on the Prisma client does NOT: it still goes through
the engine. Only the separate-pool path removes it.)

Ported the two highest-volume read paths that are read-only, transaction-free
and stable in shape:

- the per-user vote overlay behind the votable-tag endpoint —
  `listImageTagVotes`, `listImageTagVotesMany`, `listModelTagVotes`. The tag
  rows themselves already come from a cache; only the caller's own votes are
  read per request.
- the per-visible-set engagement membership read — `listModelEngagements`.

Each is a 1:1 swap onto the same read tier: `dbRead` (Prisma) and `kyselyRead`
ride the same underlying pool, so no routing, pagination or ordering changes.
Both were already reachable from `kyselyDb`, so no new module edges.

Behaviour equivalence is the whole point of the change, so it is pinned by a
suite that runs BOTH implementations against the SAME rows in the SAME database
and deep-compares, rather than asserting the new result against a hand-written
expectation: `src/server/db/__tests__/kysely-prisma-parity.test.ts` (opt-in via
`KYSELY_PARITY_DATABASE_URL`, throwaway DB, schema fixture alongside). It covers
empty results, cross-user and cross-entity isolation, both vote polarities and
every engagement type.

Two ports-specific hazards handled explicitly:

- `IN ()`. Kysely compiles `where(col, 'in', [])` to a Postgres syntax error
  where Prisma silently returned []. Both bulk queries short-circuit an empty
  array, and it is reachable from a request — the votable-tag input schema
  accepts an empty `ids`. Verified by removing the guard and watching the
  parity test fail with `syntax error at or near ")"`.
- enum arrays. pg has no parser for an array of a user-defined enum (dynamic
  oid), so such a column arrives as the raw `{a,b}` literal unless
  `registerEnumArrayTypeParsers` has run. None of the columns selected here is
  an array type — `ModelEngagement.type` is a SCALAR enum, which needs no
  registration. A seam guard in the parity suite checks that against the
  catalog of the fixture database the suite itself creates.

`listImageTagVotesMany` deliberately keeps the source query's narrow select
(`tagId`/`vote`, no `imageId`): the caller keys votes by tagId alone, so
widening it would change what the endpoint returns.

The two existing suites that mocked the Prisma client for these tables now mock
the query-function seam instead, and additionally assert the Prisma path is no
longer called — so a regression back onto the engine fails the test.

Gate: typecheck 0 errors; unit 14149 passed / 0 failed (920 files); db-queries
23 passed with no DB, 27 passed + 4 new DB-backed EXPLAIN checks against a real
Postgres; lint-rules 215 passed; parity 16 passed. The `IN ()` guards, the
`userId` predicates and the select-shape guard were each mutation-tested and
watched to fail for their own specific reason.

* test(db): kill two surviving mutants in the Kysely-port guards

An adversarial audit of this PR confirmed the ported queries behave correctly
and found two guards that are claimed to work and do not. Both are fixed here
and both were re-verified with the exact mutant that survived them.

1. The enum-array seam guard could not fail for the hazard it advertised.

Its `selected` set was a HAND-WRITTEN literal, disconnected from the actual
`.select()` calls, so it only ever checked six columns somebody remembered to
type. The audit added a real `"ModelEngagementType"[]` column to
`ModelEngagement`, widened `listModelEngagements` onto it, and the guard PASSED
— reproduced here before the fix. That is worse than no guard, because the
commit message and the test's own comment both promised a widening would fail
loudly, so a maintainer who believed it would skip the check that matters.

It is now structural: `PORTED_QUERIES` runs each ported function against the
package's `compileHarness` and the select list is read back off the COMPILED
SQL, so it tracks the real query. `selectedColumns()` THROWS on any statement
shape it does not fully understand rather than returning an empty set — a
silent zero is how the previous version became vacuous — and a positive control
asserts the derivation produced at least the 8 columns the four ports select
today. The pg catalog probe is unchanged in substance but now runs through the
same helper the assertion uses, so the positive control exercises the real
query path. `@civitai/db-queries` gains a `./test-harness` export for this.

Wording: the guard checks the catalog of the FIXTURE database this suite
creates, not "the live catalog". Corrected in the test, the commit message and
the PR body.

2. The moved test seam no longer pinned the read tier.

Before the port these reads were `dbRead.tagsOn*Vote.findMany` /
`dbRead.modelEngagement.findMany`, and the mock lived on `dbRead` SPECIFICALLY,
so a wrong-tier read had no mock to hit and blew up. Mocking the query-function
seam instead moved that property out of the suites, which asserted the client
argument as `expect.anything()`. Measured by the audit: routing all four ported
reads at `kyselyWrite` fails 1 test on pre-port code and passes all 35 after.

The client is now pinned to `kyselyRead`. Note the matcher: `kyselyRead` and
`kyselyWrite` are distinct objects that compare DEEP-EQUAL, so
`toHaveBeenCalledWith(kyselyRead, …)` still passes under the wrong-tier mutant
— the first cut of this fix used it and the mutant survived. Only `toBe`
discriminates, and each file carries a negative control asserting exactly that
(`not.toBe` + `toEqual`), which fails if the two clients ever become one
object. Coverage also widened from 2 of the 4 ported reads to all 4:
`getVotableTags(image)`, `getVotableTags(model)`, `getVotableImageTags` and
`getUserEngagedModelsByIds`.

3. Over-claims in durable text.

- the commit message said "6 new DB-backed EXPLAIN checks"; it is 4.
- lint.yml said 4 DB-backed tests skip across two files. Measured with no DB:
  8 skipped across three (6 tag.db.explain, 1 model.db.explain, 1
  enum-array-parsers.explain). It now also states plainly that those EXPLAIN
  checks and the 16-case parity suite have never run in CI.

Post-fix mutant battery, all three tiers per mutant (parity / db-queries
package / the two service suites), each mutant applied to a restored tree:

  control                  parity 16 pass · pkg 23 pass, 8 skip · svc 40 pass
  drop userId predicate    parity 2 fail (cross-user leak) + pkg SQL assertion
  widen onto an enum array parity 4 fail, incl. the seam guard by its OWN
                           message: "ported query selects ARRAY-typed
                           column(s) [ModelEngagement.types]"
  route reads at the writer svc 5 fail, all "ported read was handed a client
                           other than kyselyRead"
  drop the engagement IN() guard   parity: syntax error at or near ")"
  drop the votes IN() guard        parity: syntax error at or near ")"
  off-by-one on the id list        parity 2 fail + pkg SQL assertion
  break the guard's own derivation parity: "expected 0 to be greater than or
                                   equal to 8" (the positive control fires)

typecheck 0 errors (instrument validated: a deliberate error in model.db.ts is
reported by file and line). eslint on the changed files, all three revisions
measured in ONE tree so a single plugin resolution applies: base 12 problems
(2 errors), PR head 10 (2 errors), here 10 (2 errors) — no new error or
warning. Full unit project: 14139 passed, 17 skipped, 15 failed in 2 files
(hiddenBlocks, recent-source-images) that fail identically on the merge-base —
a local jsdom/environment break, not this PR.

* test(db): close two silent-degradation paths in the enum-array seam guard

A delta re-audit of the previous round confirmed both its fixes land, and found
that the replacement seam guard has two ways to report green while the hazard it
exists to catch is present — the same class of defect it was written to remove.
Both were reproduced end to end before being fixed, and both fixes are pinned by
the mutant that survived them.

1. The guard inspected only the LAST compiled statement.

It read `harness.lastQuery()`, so a ported query that issues more than one
statement was checked on its final statement alone. Reproduced: give
`listModelEngagements` a first statement selecting a real
`"ModelEngagementType"[]` column and the whole suite — seam guard included —
passes 16/16. The same column as the LAST statement fails 4 tests, which is what
makes this a blind spot rather than a dead test. It also contradicted the
guard's own docblock, which promises it "may not degrade quietly".

It now iterates `harness.queries`, so every statement a ported call compiles is
parsed. Post-fix the two-statement mutant fails, ALONE, with the guard's own
message: `ported query selects ARRAY-typed column(s) [ModelEngagement.types]`.

2. Neither positive control covered the derivation -> catalog NAME seam.

`expect(selected.length).toBeGreaterThanOrEqual(8)` proves 8 items were derived,
not that any of them is a name the catalog can match, and the catalog control
asserted a hardcoded `_ParityArrayProbe.x` rather than a derived name. So the
gap between the two controls was exactly where a mangled name lives. Reproduced
with a ONE-CHARACTER edit — `item.slice(1, -1)` -> `item.slice(1)` in
`selectedColumns` — after which every derived name carries a trailing quote,
matches nothing, leaves `offenders` permanently `[]`, and both controls stay
green. Stacked with a genuine enum-array widening, the seam guard drops out of
the failure set entirely (4 failures become 3) while the hazard is present.

The guard now asserts that every derived `(table, column)` pair exists in
`pg_attribute`, failing with `derived column name(s) match no catalog column`.
One catalog query now feeds both the existence check and the array check, so the
control cannot certify a path the assertion does not use. Post-fix the
one-character mutant fails for that reason and no other — notably the `>= 8`
yield control does NOT fire, so this is not a mutant killed by a neighbouring
guard.

3. The `./test-harness` subpath exposed the DB-backed helpers.

The subpath added last round pointed at `test/harness.ts`, which also exports
`explainHarness()` and `testDbUrl()`; the latter falls back to
`process.env.DATABASE_URL` — a real environment's writer in any checkout that
has one, and precisely the fallback the parity suite refuses on purpose. Nothing
imports it today, but a production import of `explainHarness()` would open a
second pool against the real database.

`compileHarness` moved to its own module, which is what the subpath now points
at; `harness.ts` re-exports it for the package's own tests. Nothing reachable
from the public subpath resolves a connection string, so the parity suite's
refusal to fall back is now a property of its whole import graph rather than
just of its own code. `harness-exports.test.ts` pins it as a ledger that fails
in BOTH directions, with a positive control asserting the excluded helpers
actually exist (otherwise the exclusion is unfalsifiable).

Post-fix battery. Every mutant applied to a freshly restored tree, tiers run per
mutant, control first and again at the end. Each failure is quoted by its own
reason, since a mutant killed by a different guard's error would still be green
with the intended guard deleted.

  control                     parity 16 pass | pkg 26 pass, 8 skip | svc 40 pass
  drop the userId predicate   parity 2 fail ("to have a length of 2 but got 3")
                              + pkg compiled-SQL assertion
  comment out the engagement
    empty-array guard         parity 1 fail: syntax error at or near ")"
  comment out the votes
    empty-array guard         parity 1 fail: syntax error at or near ")"
  off-by-one on the id slice  parity 2 fail ("length of 4 but got 2")
                              + pkg compiled-SQL assertion
  route reads at kyselyWrite  svc 5 fail, all "ported read was handed a client
                              other than kyselyRead"
  widen onto an enum array,
    LAST statement            parity 4 fail incl. the seam guard by its own
                              "selects ARRAY-typed column(s)" message
  widen onto an enum array,
    FIRST of two              parity 1 fail — the seam guard ALONE, same message
                              (was 16/16 green before this commit)
  one-character slice break
    in the derivation         parity 1 fail: "derived column name(s) match no
                              catalog column" (was 16/16 green before)
  subpath repointed at the
    DB-backed harness         pkg 1 fail: expected './src/test/harness.ts' to be
                              './src/test/compile-harness.ts'
  testDbUrl added to the
    public module             pkg 2 fail: "expected [ 'compileHarness',
                              'testDbUrl' ] to deeply equal [ 'compileHarness' ]"

typecheck 0 errors. Note `tsconfig.json` excludes `src/**/__tests__/**`, so
`pnpm typecheck` does not see the parity suite at all (`--listFilesOnly` puts it
in 0 of 11,552 program files); it was typechecked separately under a scoped
config, and both instruments were validated by watching a deliberate error get
reported by file and line. The other four changed files ARE in the root program.

eslint unchanged: 6 errors / 0 warnings in the `packages` scope and 0/0 on the
changed `src` file, measured at base and at head IN ONE TREE so a single plugin
resolution applies. Both scopes carry a positive control — an added `~/...`
import raises packages 6 -> 7 naming the new file, an unused local raises the
src file 0 -> 1 warning — because `packages/.eslintrc.cjs` is `root: true` and
enables only two rules, so a "0 problems" there is otherwise unreadable.
prettier clean on all five files.

Deliberately out of scope, recorded not fixed: the statement-shape docblock
slightly overstates what the regex rejects; the structural half of the guard is
gated behind a DB it does not need; two service functions share one mock; and
`PORTED_QUERIES` is still hand-maintained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(db): make three guard docblocks match what the guards enforce

Each of these was a docblock claiming a property the adjacent test did not
check. Two were real gaps, not wording.

1. compile-harness.ts said nothing reachable from it touches a connection
   string, and called that "a property of the whole module graph". Only the
   export NAMES were pinned. `harness-exports.test.ts` now walks the transitive
   import graph, asserts the reachable first-party files and third-party
   packages against an exact ledger (`pg` is not in it), and scans each
   first-party file for pool/connection-string tokens. The walker is
   positive-controlled against `harness.ts`, which it must flag for reaching
   `packages/civitai-db/src/kysely.ts`; a walker that finds nothing everywhere
   would certify everything. Proven: `process.env.DATABASE_URL` added inside
   `compileHarness()` now fails with
   "compile-harness.ts: DATABASE_URL" — the export ledger still passes, which is
   exactly why the graph check had to exist.

2. The exports ledger asserted one key while claiming to cover "the surface".
   A second subpath (`./test-harness-full` -> `./src/test/harness.ts`) re-exposed
   `explainHarness`/`testDbUrl` with everything green. The whole `exports` map is
   now ledgered, and the reachability test reads the map off disk rather than
   from the ledger constant, so that mutant fails twice — once as a ledger
   mismatch, once as a real reachability violation.

3. DummyDriver resolves every `.execute()` to zero rows, so a two-step read
   (`ids = await select...; if (!ids.length) return []; select ... in ids`)
   compiles only its first statement and the second is never parsed. The
   fleet-wide `selected.length >= 8` floor cannot see that on a newly added port,
   because the existing four already satisfy it. `PORTED_QUERIES` entries now
   declare their expected compiled-statement count and it is asserted per port.
   The comment that over-promised is corrected to state the real limitation.

Verified with the full mutant battery (control run first and last, both green:
pkg 29, parity 16, services 40). Each mutant failed for its own reason: dropped
`userId`; each empty-array guard commented out; off-by-one and one-char `slice`
derivation breaks; reads routed at `kyselyWrite`; enum-array widen as the last
statement and as the first of two; ledger repoint; ledger grow; plus the three
new proving mutants above. Re-confirmed the parity suite still refuses to fall
back to `DATABASE_URL` (16 skipped with only `DATABASE_URL` set).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 18:55:04 -05:00
Zachary Lowden 018f9a0e7f fix(og): unblock the SVG loader Next 16.3.0 leaves blocked, so /api/og stops 500ing (#3777)
Next 16.3.0's image optimizer applies a process-global libvips loader allowlist that omits SVG. next/og rasterizes satori's SVG through the same sharp instance, so every ImageResponse threw 'Input buffer contains unsupported image format' once the optimizer initialized — making /api/og return 500 on 100% of requests.

Carries upstream vercel/next.js#96681 verbatim as a version-pinned pnpm patch (both the CJS and ESM image-optimizer copies), plus a CI guard that fails the build if the patch stops applying.

Verified on the standalone preview build: /api/og returns a valid PNG on both the FallbackCard and OgCard paths after the optimizer is warmed to a cache miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:29:46 -05:00
Justin Maier 28e3ff98ab fix(db-schema): unblock the drift gate on two cosmeticId findings the stale snapshot invents (#3747)
* fix(db-schema): accept two cosmeticId findings the stale snapshot invents

The gate compares the schema against a COMMITTED CATALOG SNAPSHOT, not a
live database. That snapshot is from 2026-08-03; the packs migration
landed on 2026-08-04 and made `CosmeticShopItem.cosmeticId` and
`UserCosmeticShopPurchases.cosmeticId` nullable. The schema says optional,
the frozen snapshot still says NOT NULL, so the gate blocks every PR.

Production has the migration applied - both columns are NULLABLE there and
both pack join tables exist. There is no real drift. The schema
declarations are correct and unchanged.

The actual remedy is a catalog recapture, which is a deliberate
maintenance act: it would also pull in every other change since 2026-08-03
and need its findings triaged. Until someone does that, record these two
as accepted so the gate stops blocking unrelated work. drift:baseline
takes it 61 -> 63, both entries `enforced`, no escalations absorbed.

Delete both entries at the next recapture - they describe the snapshot's
age, not the database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(db-schema): correct the stale finding count in the gate doc comment

The re-baseline moved main from 61 to 63 accepted findings and updated
the same sentence in the workflow and README; gate.ts was missed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:05:04 -06:00
Zachary Lowden f22e45f184 ci(lint): narrow Typecheck to the paths our other CI does not reach (#3721)
The in-cluster Tekton `pr-check` pipeline runs `tsc --noEmit` on every PR and has
posted the `tekton / typecheck` commit status since 2026-08-06. Until now this
job typechecked the same tree a second time on every internal PR.

The bake window that gated this is satisfied: the status has posted a real
`success` (PR #3706) and a real `failure` (a deliberately planted TS2322 on a
throwaway PR, since closed) — a gate seen only green is not a gate shown to work.

The job is NARROWED, not deleted. Its `if:` is the complement of what the other
system covers, and both arms were measured rather than assumed:

  * fork PRs — gated away there by author association, on purpose.
  * base != main — only PRs targeting `main` are watched there, so a PR
    targeting `release` would otherwise get nothing. 4 PRs have ever targeted
    `release`; rare, not never.

Also corrects the header. It claimed typecheck "runs on fork PRs TOO" — true of
the configuration, false of the outcome. Measured over all 16 fork-originated
PRs among the ~500 most recent: 11 have a retained `Lint` run and all 11 sit at
`conclusion=action_required`, i.e. awaiting maintainer approval with zero
repository code executed; the other 5 have aged out. No fork PR has ever produced
a Typecheck result. Both this claim and the one it replaced were read off the
config; only running the query settled it.

Verification:
  * actionlint clean on the result (0 findings), and RED with rc=1 pointing at
    the new `if:` when a doubled `||` is planted in it — the linter was shown to
    be able to fail before its pass was believed.
  * Neither `main` nor `release` has any required_status_checks, so renaming the
    job cannot break a merge gate.

Refs civitai/talos-infra#855
2026-08-07 12:01:37 -05:00
Zachary Lowden 5d1e44551d docs(ci): correct the lint workflow header — typecheck is NOT skipped on forks (#3714)
The file header and the typecheck job's own comment said opposite things:

  header (line 5-7): "It is skipped on fork PRs (see the job comment), so a fork
                      gets no gating from this workflow at all."
  job    (line 154): "Runs on forks now."

The job comment is the correct one. Verified structurally: no job in this file
carries a top-level `if:`, so none of them are gated on the PR's origin.

This is worth fixing rather than leaving as a stale line, because it is wrong in
a costly direction. Typecheck is currently the only typechecking a fork PR gets,
and it is deliberately secret-free so it can run there — the ssh-agent step was
REMOVED rather than left unused precisely to keep that path working. A maintainer
who believed the header could reasonably delete the job as dead weight for forks,
silently removing the one place untrusted code gets type-checked in a sandbox we
would otherwise have to build ourselves.

Comment-only. No job, step, or condition changes.

Not verified: that a fork PR actually succeeds end to end — no fork PR appears in
the last 60 runs of this workflow, so what is confirmed is the absence of a gate
excluding them, not a green fork run.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 21:37:33 -05:00
Zachary Lowden ca699f5def ci(test): run the apps/* vitest suites — 369 tests CI had never executed (#3694)
The root vitest.config.mts registered `packages/*/vitest.config.*` as projects
and nothing for `apps/*`, and CI runs only `--project unit` (root-relative
`include`) and `--project '@civitai/*'`. So no test under apps/ has ever run in
CI: 43 files / 369 tests across five apps, green only for whoever remembered
`pnpm --filter <app> test` by hand.

This is the same gap #3591 documented for packages/, in the sibling directory,
missed when that one was closed.

Register `apps/*/vitest.config.{ts,mts}` — globbed on the CONFIG FILE for the
reason the packages comment already gives — and give each app project an
explicit `app:` name. That name is load-bearing: every app is also published as
`@civitai/*` (`@civitai/auth-app`, and `@civitai/orchestrator-gateway` with no
suffix), so under default naming no pattern separates apps from packages and
`--project '@civitai/*'` would have silently swept the apps into the packages
job. Verified the packages selector is unchanged: same 9 packages, same 957
tests, zero app files in the report.

apps/creator-studio gets a config it never had — it declared a `test` script
and owned a vitest test file that no runner could reach.

Generalize scripts/ci/assert-package-suites-ran.mjs to take the workspace dir
and rename it to assert-workspace-suites-ran.mjs, so one ledger serves both
jobs, and wire an `App unit tests` job that runs it.

Per-app counts: auth 29 files/237, notifications 10/101, storage 1/17,
orchestrator-gateway 2/6, creator-studio 1/8. All green.

Positive controls (both with vitest exiting 0, assertion exiting 1 on its own
`missing` branch): breaking one app's `include` so it collects nothing, and
dropping one app's `test.name` so the selector no longer matches it.

Also corrects an inherited premise in the script header — on vitest 4.0.18 a
`--project` filter matching nothing fails startup and exits 1, it does not
exit 0 as the comment claimed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:52:36 -05:00
Zachary Lowden ebe9aa278b ci(db-schema): gate a PR on NEW schema<->database drift, not on the backlog (#3643)
* ci(db-schema): gate a PR on NEW schema<->database drift, not on the backlog

The drift detector (#3591) reports the gap that exists. A gate has to answer a
narrower question — did THIS change make it worse? `--strict` fails on any
finding, and there are 61 on `main`; a gate red on every run teaches everyone
to click through it and gets switched off within a week. Same reasoning as the
report-only ESLint and Prettier steps in lint.yml.

So: block only a PASS->FAIL regression the change introduces, warn about
everything already broken, never block on a pre-existing finding. Modelled on
the delta gate this org already runs on its infrastructure repo.

`drift-baseline.json` records the 61 accepted findings, keyed by a fingerprint
that deliberately EXCLUDES the `declared`/`actual` prose — #3589 corrected
eight declared referential actions and touched no constraint, and a fingerprint
that folded that in would have retired eight entries and raised eight
identical-looking new ones. Nullability is the one exception: its `declared` is
a single word and that word IS the finding, so a field flipped from optional to
required against a NULLABLE column cannot inherit the old entry's pass.

TWO SEVERITIES, decided structurally rather than by taste. Migrations here are
applied by hand, so a declaration being ahead of the database is a normal
intermediate state; a gate that could not tell that from real drift would block
every PR that adds a column. The discriminator is whether the finding concerns
database surface that already exists:

  enforced  columns are in the catalog, the constraint is not  -> BLOCKS
  pending   the column is not in the catalog at all            -> warns

`missing-column` is always pending by construction, `nullability` and
`uniqueness` always enforced by construction; `missing-foreign-key` is the only
kind decided at runtime. On today's backlog that is 49 enforced, 12 pending.

NOT MEASURED, and it says so rather than printing a clean zero: referential
actions. The committed snapshot carries no ON DELETE/UPDATE data, so all 408
comparable foreign keys read "not comparable". A live run found 45 — every one
an ON UPDATE Cascade-vs-NoAction on a hand-written App Blocks foreign key, zero
ON DELETE mismatches, inert because `id` is never updated. They are absorbed by
being structurally unmeasurable here, not by being waved through.

READ-ONLY BY CONSTRUCTION. gate-cli.ts has no database code path — no `pg`
import, no flag that opens a connection. This repo is public and so are these
logs, so that is a requirement, not a convenience. Unknown arguments are
redacted before being echoed, as in cli.ts.

Positive controls, because "0 new drift" and "wired to nothing" print the same
page: the verdict always reports matched-vs-baseline as a PAIR; an empty
baseline or a run that reproduced none of its baseline exits 2, not 0; a
catalog that covered nothing exits 2; and the workflow greps for the verdict
line because `pnpm --filter` exits 0 when it matches no project.

25 tests, and every guard was mutation-tested — seven mutations, each killed by
the test whose intent matches it.

* fix(drift-gate): audit round 1 — NUL byte, snapshot decay, tier escalation

Addresses the adversarial audit of #3643.

A literal NUL byte in gate.ts made the file BINARY to git, to grep and to
GitHub: all 293 lines of the gate's logic were absent from the PR diff, and
`grep -c` returned rc=1, which reads as "0 matches". It is written as the
 escape now, matching compare.ts:15. This is the second independent
occurrence of the exact defect (see #3647's plan.ts:53) — the class is a
composite-key separator emitted as a raw byte rather than an escape.

The gate compares against a FROZEN snapshot, so every column created after the
capture can only ever be tiered pending/warn: blocking coverage shrinks
monotonically while the check stays green, and nothing said so. The snapshot now
carries `capturedAt` (stamped by --dump-catalog), the verdict prints its age on
EVERY run rather than only past a threshold, and 90 days triggers an explicit
STALE note. Advisory, never fatal — a gate that reddened because a date passed
would be red for everyone at once, for a reason no PR author can fix. This makes
the decay VISIBLE; it does not remove it. Only recapturing against a live
database does that.

Tier escalation was invisible. `tier` was written to the baseline and never read
back, so a finding accepted as `pending` ("no such column yet") that became
enforceable when its migration landed WITHOUT its constraint — the shape of 37
of the 61 baseline findings — was absorbed into `matched` and exited 0.
`evaluateGate` now compares the live tier against the recorded one and blocks on
`pending -> enforced`.

A no-op `drift:baseline` produced a ~250-line reformat, because Prettier and
JSON.stringify disagree about the file by 246 lines. That buried the one entry a
reviewer needed to see, defeating the reason for committing a baseline at all.
The generator now owns the format (.prettierignore): a no-op refresh is a
zero-line diff, and accepting one finding is a twelve-line one.

The fingerprint excluded the referenced table, so repointing a relation from
Image to Post — same model, same field, same constrained column — inherited the
old entry's pass silently. It is folded in now; the ON DELETE/UPDATE prose stays
out, which is what #3589 required. The cross-module parse of compare.ts's
`declared` string is pinned by a test over all 37 real missing-FK findings.

The "this catalog carries no ON DELETE/UPDATE data" parenthetical was hardcoded
and printed even against a catalog that did carry it. It is conditional now.

Two mutation survivors closed. `.every -> .some` in the FK tier was structurally
undetectable because every fixture used single-column keys — a composite case
now distinguishes them. `assertCatalogSanity`'s CALL SITE was untested (the
function was): the only exit-2 CLI case used an empty catalog, which trips
assessCoverage instead, so deleting the call survived. A uniform-notNull catalog
now exercises it.

Sweep re-run against the changed source rather than citing the old table: 13
mutants, 13 killed, each by the test whose intent matches it, with a clean-tree
control.

Also: "blocks" softened throughout. `main` has branch protection but no
required_status_checks, so this check is advisory and a PR can merge with it
red. The README now states plainly what the gate can catch (a schema edit
promising what the captured database lacked), what it cannot (anything the
database itself does), and what degrades (any column younger than the capture).

* fix(drift-gate): stamp the snapshot without reformatting 21,520 lines

The capturedAt stamp was added by a JSON round-trip, which collapsed the
prettier-formatted fixture to a single line: 1 insertion, 21,520 deletions.

That is precisely the defect the audit raised against drift-baseline.json one
finding earlier — a mechanical rewrite burying the one line a reviewer needs —
so shipping it here while fixing it there would have been a straight
regression. Inserting the key textually keeps the file's existing formatting:
1 insertion, 0 deletions, and prettier still agrees with the result.

Generated JSON in this package now has two owners, deliberately and
differently. The CATALOG fixture stays prettier-formatted: it is written once
and read by a human when a finding is disputed. drift-baseline.json is
prettierignored and owned by its generator: it is rewritten on every
acceptance, so the generator has to be able to reproduce it byte-for-byte or
the diff stops being reviewable.

Also bounds a flake before it lands. Every case in the gate's CLI suite spawns
the real entry point as a process, paying a cold tsx transpile plus a Node
start before its first assertion. The slowest measures 3.5s on an idle machine
against Vitest's 5s default — a 1.4x margin a 2-core runner will not honour,
which is a test that goes red on ambient machine speed, a different case each
run. 60s still bounds a genuine hang. Verified load-bearing rather than
assumed: a deliberate 6s probe passes inside the describe and fails with "Test
timed out in 5000ms" once the option is removed.

Set on the describe rather than in the package's vitest.config.ts because that
config is a shared file with a concurrent change already in flight for the
sibling cli.test.ts, which has the same shape and the same problem.

* fix(drift-gate): audit round 2 — test the capturedAt producer, close the refresh seam

N1 (blocking). The staleness signal had a tested consumer and an untested
producer. `snapshotAge` was exercised exhaustively; the line that WRITES the
stamp had no test at all, so mutating it to `catalog.capturedAt` — which makes
every fresh capture carry no date and silently disables the whole signal round 1
added — survived with PASS=44 FAIL=0. The only assertion touching the stamp
checked that the committed FIXTURE has one, and that value is a hand-written
midnight instant the command would never emit, so it never exercised the
producer. A new suite drives `drift --dump-catalog` as a process and asserts the
stamp is a real instant from this run, that an existing stamp is preserved
rather than refreshed (a re-dump must not launder a stale snapshot young), and
that the catalog is otherwise unchanged. The exact surviving mutant now dies.

N2. `--dump-catalog` emitted single-line JSON, so a content-identical re-dump of
the committed fixture was a 21,522-line diff — the same unreviewable-diff class
this PR fixed for drift-baseline.json, and worse, because the gate's own STALE
message instructs the operator to run precisely that command. Prettier cannot be
the owner instead: it collapses short arrays in a way JSON.stringify will not
reproduce (measured: 3,598 lines apart), so the artefact would only stay clean
if every operator remembered a formatter afterwards, which nothing enforces —
`prettier --check` on a MODIFIED file is report-only here. The generator now
emits indented JSON and the fixture is prettierignored, matching how the
baseline is handled. A re-dump is now byte-identical (sha256 verified). The
fixture is reformatted once, +2,746/-852, content deep-equal before and after.

N3. The escalation comment and README claimed the guard covers "the exact shape
of 37 of the 61 baseline findings". Measured, escalation needs an entry that is
both `pending` and `missing-foreign-key`, and the baseline has ZERO of those —
all 12 pending entries are `missing-column`, hardcoded `pending`, which can
never rise. The guard is purely forward-looking and now says so.

N4. That guard also had a bypass: a catalog only gains a column via a recapture,
and the documented recapture procedure refreshes the baseline in the same
commit, turning the escalation into a tier flip inside a regenerated file rather
than a failure. `drift:baseline` now reports every pending -> enforced
transition it absorbs, so it lands in the recapture commit's log instead of
nowhere. It does not fail; a refresh is deliberate.

N5. A STALE note is advisory, so the check renders green and the one signal
about shrinking coverage was a line inside a passing job's log. Promoted to a
`::warning::` annotation on the PR.

N6. The pending-tier text told developers a pending finding "will stop being
reported once the snapshot is recaptured". True for missing-column; for
missing-foreign-key a recapture makes it ENFORCED. Now spells out both, since
this is the text read at the moment someone decides to accept a pending finding.

Smaller: the `>=` staleness boundary is tested at the threshold, not one past
it; a future-dated capture is treated as a problem rather than as maximally
fresh (a negative age could never reach the threshold, disabling the signal
indefinitely); the NUL tuple separator is pinned by a collision case that a
printable separator gets wrong in the failing direction; the referencedTarget
comment claimed a parse failure would "quietly merge" findings, when measured it
gives 0 collisions and a loud 37-resolved/37-new block; README says eleven-line,
not twelve; and remaining unqualified "blocks" wording is now "fails the check".

Sweep re-run against changed source with the harness validated FIRST, which
mattered: the initial runner counted reporter glyphs, and this reporter prints a
tick per FILE rather than per test, so it read a real failure as FAIL=0.
Rewritten to parse the summary line, with NO-SUMMARY treated as an unmeasured
result rather than a kill — one mutant made the tool emit a single 226 KB line
and blew ARG_MAX in the harness itself, which the first version scored as a
kill. Controls: clean tree PASS=56 FAIL=0, deliberate break PASS=51 FAIL=5.
13 mutants, 13 killed.

Also caught by that validation: STALE_AFTER_DAYS could be changed to 999999
with no test failing, because every staleness assertion was written in terms of
the constant itself. The policy is now pinned in absolute terms.

* fix(drift-gate): audit round 3 — finish the round-2 correction, cover --update-baseline

Two blocking items, both instances of a fix landing in one place and being
reported as landing in two.

The round-2 correction to the escalation claim reached gate.ts and not the
README, which then contradicted itself four lines apart: "the shape of 37 of the
61 baseline findings" at :372 against "the current baseline has zero of those"
at :378. Re-measured: all 37 missing-foreign-key entries are already
tier: enforced and cannot rise. The false sentence is gone; the PR body carried
the same sentence and is corrected too.

--update-baseline had no test of any kind — not the write, not the escalation
report, not the previous-baseline read. Two mutants passed the full suite green:
`absorbed = []`, which makes the entire N4 remedy inert, and reading `previous`
AFTER the write instead of before, which is the likeliest real edit to that
block. Both are now killed by a CLI case that seeds a pending tier, refreshes,
and asserts the transition is named and the file rewritten. Six cases in total,
all against a COPY in a temp dir so none can touch the committed artefact. This
is the isolation seam reproduced inside a round-2 fix: the pure function was
tested, its only caller was not.

Folded in:

A corrupt previous baseline printed nothing about escalations, so "0 absorbed"
and "could not read the previous baseline" were byte-identical output — a
reassuring zero indistinguishable from a probe wired to nothing, in a tool built
against exactly that everywhere else. The refresh now prints either the pair
(absorbed N, compared against M entries) or an explicit SKIPPED note.

A malformed-but-parseable previous baseline (`{}`) crashed the refresh AFTER it
had already written the file: exit 2, "Cannot read properties of undefined
(reading 'map')", 677 lines on disk. The previous baseline is now validated at
read time, before the write, and an unusable one degrades to the SKIPPED note.

`set -euo pipefail` in the workflow aborted the step the moment the gate exited
non-zero, skipping the no-verdict guard and the STALE annotation PRECISELY when
the gate fires — and the comment beneath claimed the guard runs "including when
it blocks", describing an unreachable path. The step now captures PIPESTATUS,
runs both checks, and propagates the verdict last. Verified by extracting the
step and running it against stubbed gates across five paths: pass, pass+STALE,
FAIL+STALE, no-verdict-but-exit-0, and exit 2. The failing-with-STALE case
emits 0 annotations under the old form and 1 under the new.

--dump-catalog returned before assertCatalogSanity, so the recapture command the
STALE message recommends could emit a catalog the next drift run would reject.
The check now runs first: a uniform-notNull catalog exits 2 having written zero
bytes, and a healthy one still dumps sha256-identically.

cli.ts said the prettier/generator disagreement is 3,598 lines; re-measured,
3,589. The sibling figures (246, 21,522) re-derive exactly.

The two-kind pending-tier message was unpinned prose next to a line that a CLI
test pins; it now has its own assertion.

Sweep re-run against changed source over the WHOLE package suite, with the
harness asserting 17 files as well as both summary lines: 17 mutants, 17 killed
(16 in the batch, G3's NUL-separator anchor run standalone because the escape
sequence cannot be passed through the shell harness). Controls: clean tree
PASS=377 FAIL=0, deliberate break PASS=371 FAIL=6.

* fix(drift-gate): the errexit fix was inert in CI; restore the correct prettier figure

Both findings this round have one root cause: a measurement taken in an
environment that is not the one it targets.

GitHub runs a `run:` step with no `shell:` key as `bash -e {0}`, so errexit is
already on before the first line. `set -uo pipefail` does not clear it, and
adding pipefail made it strictly worse: the pipeline now returns the gate's
non-zero status, errexit fires on it, and the step dies at `tee` before
`rc=${PIPESTATUS[0]}` is ever reached. The STALE annotation was still skipped
precisely when the gate fires, and the comments describing that path could not
run. `set +e -uo pipefail` explicitly.

The reason this shipped is the finding itself: under plain `bash` both forms
emit the annotation, and plain `bash` is what the previous round measured.
Re-measured under `bash -e` with a stubbed gate exiting 1 and printing STALE:
old form 0 annotations, new form 1. Positive control on a PASSING gate: all four
combinations emit 1, so the harness can observe one under `-e`. All five paths
re-verified under `bash -e` against the step extracted from the shipped file:
pass 0/0, pass+STALE 0/1, FAIL+STALE 1/1, no-verdict 1/error, exit-2 2.

cli.ts's prettier-disagreement figure goes back to 3,598. 3,589 is the count at
prettier's DEFAULT printWidth 80 — the number you get by copying the file to
/tmp, where `.prettierrc` cannot resolve. In the repo, where it resolves to
printWidth 100, every method agrees on 3,598: 852 insertions + 2,746 deletions,
plain diff, -u, -U0 and git --numstat, with a self-diff control of 0. The
comment now records how the number must be measured, since getting it wrong is
one `cp` away.

Three guards that a differently-built sweep found surviving:

assertCatalogSanity's move ahead of the --dump-catalog return had no regression
coverage — deleting the call, and moving it back behind the return, both passed
382/0. Now pinned by a case asserting a uniform-notNull catalog exits 2 having
written zero bytes. Same "only caller untested" shape the --update-baseline work
existed to close.

The NUL-separator guard was spelled to `_`: its fixture only collided under that
one character, so mutating the separator to `|` or to empty survived. Rewritten
as a property over five candidate separators — empty, underscore, pipe, dot,
space — each building the pair that collides under ITS own separator, so any
character legal in a Postgres identifier fails at least one. Second instance of
the spelled-guard class in this file, and the reason the rewrite asserts the
invariant rather than a character. The original `_` mutant still dies.

A describe-scoped `let workBaseline` was assigned by one test and never read; it
is a local const.

Verified: 382 tests, 0 failures; typecheck 0 errors; gate 61 of 61 at exit 0;
re-dump of the committed snapshot still sha256-identical. Four mutants that
survived the independent sweep now die, each to the test whose name states that
guard's intent.

* test(drift-gate): derive the separator alphabet; the "property" was five spellings

Round 4 rewrote the NUL-separator guard and claimed it made "any character
legal in a Postgres identifier fail at least one" case. That was false, and the
mechanism is exact: each case builds `A{S}B`/`C` against `A`/`B{S}C`, and those
two keys are equal IFF S equals the implementation separator — so a case only
ever catches its own. Reproduced: `-`, `#`, `::`, `--` and `~!~` all survived at
382/0. `-` and `#` are legal inside a QUOTED Postgres identifier, so this is the
hazard class the guard exists for rather than a hypothetical.

The case list is now derived from an alphabet instead of hand-picked: empty,
space, all 32 ASCII punctuation characters, five multi-character candidates, and
five non-ASCII ones. The non-ASCII entries were added because `§` survived the
first version of this enumeration — an ASCII-only alphabet excludes exactly the
characters a quoted identifier makes legal.

Added alongside it, and constructed differently on purpose: a deterministic
seeded fuzz that splits one character run at two different points, so the pairs
are ambiguous BY CONSTRUCTION and no separator is named anywhere in it. It
carries its own positive control on the loop counter, because a `continue` that
swallowed every case would leave it green having asserted nothing.

Ten separators that previously survived now die, each to its own named case:
`-`, `#`, `::`, `--`, `~!~`, `_`, `|`, `@`, `^`, `§`. Single-character ones die
twice — the enumerated case and the fuzz independently.

The claim is now stated at its true scope in the test comment as well as the PR
body. A finite enumeration cannot prove a property over an infinite alphabet:
what is proven is every separator in that alphabet plus whatever the fuzz
catches, and what is NOT proven is an arbitrary unenumerated multi-character run
— `ZZQ` still survives it, and the comment says so rather than implying
otherwise. Overstating the scope is what produced this round.

Also recorded, as a known gap rather than a fix: the workflow's STALE and
no-verdict logic has no automated test in this repo, and the live check only
ever exercises the passing path, so it structurally cannot observe the
FAIL+STALE branch. That is precisely why the round-3 errexit fix shipped inert.
It is verified by hand each round against the step extracted from the shipped
file, under `bash -e`.

422 tests, 0 failures.
2026-08-05 22:22:46 -05:00
Zachary Lowden 902d222ed1 ci: run the nine packages/* test suites, which nothing has ever executed (#3642)
* ci: run the nine packages/* test suites, which nothing has ever executed

The root `unit` Vitest project's `include` is root-relative (`src/**`,
`scripts/**`), and the `Unit tests` job runs `vitest run --project unit`. So
no CI job in this repo has ever invoked a workspace package's suite: 616 tests
across nine `packages/*` packages ran only for whoever remembered
`pnpm --filter <pkg> test` by hand.

That is not theoretical. The schema-drift detector (#3591) shipped 81 tests
into this gap, and two of them had been RED on `main` since #3592 landed —
with nothing anywhere to say so.

Root `vitest.config.mts` now globs each package's OWN config file, so every
package keeps the config it was written against. The new `Package unit tests`
job is BLOCKING: the reasoning that made `Unit tests` report-only does not
transfer, because this is 616 tests with no browser, no database and no Next
module graph, and the whole run is ~15s.

Three pre-existing failures the job surfaced, all fixed here rather than
skipped:

  * civitai-db-queries/src/infra/enum-array-parsers.explain.test.ts could
    never pass without a database. `describe.skipIf(!url)` skips the tests but
    Vitest still EXECUTES the describe callback during collection, so the
    eager `new Pool({ connectionString: noVerify(undefined) })` threw
    `TypeError: Invalid URL` and failed the whole FILE to import — which
    reports as "1 failed test file" with no test count, not as a failing test.
    The Pool moves into `beforeAll`, which genuinely does not run when skipped.

  * production-snapshot.test.ts pinned 235 nullability findings on seven
    `*Rank` tables. #3592 marked those columns optional to match the database
    and the findings correctly went away. The assertion now guards the
    REMEDIATION (Rank family at zero) with a positive control on that zero —
    the 11 findings that remain.

  * parse-prisma-schema.test.ts pinned Prisma's defaulted `onDelete` at one
    named site, `TagsOnImageNew.image`, and #3589 gave that relation an
    explicit `onDelete`. Re-anchored to the POPULATION of bare relations, which
    an ordinary schema edit cannot retire.

A green vitest run is a claim, not evidence: `--project` matching nothing exits
0, and so does a config whose globs stopped resolving. scripts/ci/assert-
package-suites-ran.mjs therefore asserts a ledger — every 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 the totals cannot see. Validated against
three known-bad reports (empty run, one package dropped, missing report file)
before being trusted.

Test count, measured in CI: 811 files / 12,031 tests before, all from `src/`;
61 files / 616 tests added by the new job, of which 81 are the drift detector's.

* fix(ci): correct the "blocking" claim, and close three ledger blind spots

Audit follow-ups on the packages/* CI job.

The job was described as BLOCKING. It is not, and the distinction is
load-bearing for whoever next decides whether to flip `unit`: `main` has branch
protection but no required_status_checks at all, so a red check here does not
prevent a merge. What it actually buys is rendering RED instead of
red-but-ignored. Corrected in the workflow comment, the README and the PR body,
along with what would make it a real interlock.

The Rank positive control was green for the wrong reason. Asserting the Rank
family is at zero nullability findings is satisfied just as well by a differ
that never visits a Rank table — measured: making compare.ts `continue` on every
model whose table ends in `Rank` left that test GREEN and reddened only two
neighbouring ones. The `nullability.length >= 11` control could not see it,
because it proves the KIND is still emitted, not that those seven tables are in
scope. Now pinned via findings of a different kind that the same seven models
must still produce; re-running the identical mutation kills the test itself.

Three ledger blind spots, each demonstrated with a probe before and after:

  - It only looked in `src/`. A package with its tests in a top-level
    `__tests__/` was invisible to the EXPECTED set, so it could never be
    noticed going missing. Now scans the whole package directory.
  - It only matched `.test.ts`/`.spec.tsx`. `.mts`, `.cts` and `.js` tests were
    invisible the same way.
  - It counted SKIPPED tests as having run, summing assertionResults.length. A
    package that self-skipped in its entirety still satisfied the ledger — and
    self-skipping on a missing DATABASE_URL is exactly the pattern these suites
    use, so that failure mode is live, not hypothetical. It now requires a
    non-zero EXECUTED count and reports skips beside it, so a package quietly
    turning itself off is visible rather than absent from the arithmetic.

Both directions verified by exit code, not by reading output: real report 0;
tests-outside-src probe 1; .mts probe 1; wholly-skipped package 1; empty run 1;
one package dropped 1; missing report 1.

Known remaining gap, deliberately not widened into this change: `apps/*` has
four more vitest configs and ~43 test files that no CI job runs. Same one-line
fix in the same projects array, plus teaching the ledger about `apps/` — it
hardcodes `packages/` today. Follow-up PR.

Also: the CI job's JSON report is gitignored (the documented command left an
untracked artifact at the repo root), and the workflow comment's "four EXPLAIN
tests" is corrected to 3 + 1 across two files.
2026-08-05 19:55:32 -05:00
Zachary Lowden 35a9598dc9 docs(app-blocks,ci): record the owns-nothing analytics decision, and correct a stale flake claim (#3653)
* docs(app-blocks): record the owns-nothing analytics decision, and pin it

`getMyAppAnalytics` returns an UNFLAGGED all-zero payload when the caller
owns no apps, while a caller who asks for an app they do not own gets
`unavailable: 'notOwned'`. Two reviewers have flagged that asymmetry as
"defensible but wants a recorded decision"; it was still unrecorded.

The reasoning, now written at the `unavailable` field: the discriminator's
test is whether the zeros were MEASURED. `notEntitled` and `notOwned` are
both cases where no aggregate ran, so their zeros are fabricated. Owning
nothing is not — an aggregate over an empty owned set genuinely is zero, so
the short-circuit only skips work whose answer is already known. Flagging it
would put every honest new author behind an `unavailable` branch on their
first visit and train clients to skip the flag that exists to stop them
believing a fabricated zero.

Precedent cited: #3581, which settled the same question for the sibling
`getMyRevenue` with ONE value rather than two, because `getRevenueForOwner`
never computes ownership (it scopes by `appOwnerUserId` in the WHERE clause)
so `notOwned` is unproducible there. Analytics does compute ownership, hence
the second value; the underlying rule is the same one.

#3613 then applied that rule to `views`, whose own flag tracks the payload's
— so the owns-nothing path leaves views unflagged too.

No behaviour change. The added test pins the decision at the SERVICE
boundary, which the existing coverage did not: every `unavailable` assertion
today is against the `emptyAnalytics` helper, and the one owns-nothing test
through `getMyAppAnalytics` asserts only `notOwned === false` and
`installs.total`. Mutation-verified — flipping the branch to
`emptyAnalytics(range, false, 'notOwned')` reds ONLY the new test
("expected 'notOwned' to be undefined"), and forcing `unavailableViews()`
reds its views assertion ("expected true to be undefined") alongside the
existing helper test. Both mutations restored byte-identically
(sha256-verified).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj1HosDFkP6LgxMAtJdDmJ

* docs(ci): the unit job's cold-optimizeDeps citation points at the FIX

The unit job excluded component tests "because they need Chromium and carry
the cold-optimizeDeps flake documented at vitest.config.mts:98-124". Half of
that was wrong.

vitest.config.mts:109-127 documents the FIX for that flake, not an open one:
it dedupes React ("Canonical fix; protects every component test from this
class of dual-React crash") and pre-bundles `vitest-browser-react` plus the
JSX runtimes so "the optimize pass happen[s] BEFORE the run starts, so
there's no mid-run reload". The cited range was also off by a block —
98-102 is a separate hookTimeout fix for cold `await import()` in
beforeAll/beforeEach, not the optimizeDeps narrative at all.

The Chromium half is still true and stays: this job installs no browser.
Comment-only; no job, trigger or step changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj1HosDFkP6LgxMAtJdDmJ

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:20:28 -05:00
Zachary Lowden c318ef1fb8 fix(typecheck): make a crashed typecheck report as crashed, not as clean (#3619)
* fix(typecheck): make a crashed typecheck report as crashed, not as clean

`pnpm run typecheck` was `cross-env NODE_OPTIONS="--max_old_space_size=8192"
tsc --noEmit`. When the heap cap is too small for the program graph, V8 aborts
part way through checking, so tsc emits ZERO diagnostics and dies. cross-env
normalises the SIGABRT to exit 1, and V8's explanation goes to stderr — so a
caller that captures stdout gets an empty log, a bare non-zero exit, and no
type errors anywhere in it.

That is indistinguishable from a clean pass to anything that judges the run by
its output, which is what people and scripts actually do (a clean run also
prints nothing). Reproduced with a deliberate `const x: number = 'nope'` in
`src/`: at a 4096 MB cap the run reported 0 errors and hid it completely; the
same tree at 8192 MB reported it.

Measured cold on a clean checkout, with that error in place as a visibility
control:

  node 24.18.1  4096 -> OOM/0 diags   4608 -> OOM/0 diags
                5120 -> pass/found    8192 -> pass/found
  node 22.22.2  6144 -> pass/found    8192 -> pass/found

So the current 8192 is NOT at the cliff — the cliff is between 4608 and 5120,
and 8192 carries ~1.6x headroom. The number is left alone deliberately: the CI
runner has 16 GB, and a cap near that trades a self-describing V8 abort for a
kernel OOM-kill, which says less. Raising it would only move the cliff anyway.

What changes is that crossing the cliff becomes loud. `scripts/typecheck.mjs`
runs tsc and classifies the outcome:

  - clean            -> prints an explicit "typecheck: OK" line, so silence is
                        no longer what a pass looks like
  - type errors      -> passed through untouched, exit code preserved
  - crashed          -> a CRASHED banner naming the cause, on stdout AND stderr
                        (the original blind spot was a stdout-only capture),
                        plus a ::error:: annotation under Actions
  - exit 0 w/ diags  -> treated as a crash rather than trusted

Heap exhaustion, an outside kill (out of system RAM / a container limit) and an
unexplained abort are named separately, because the fix differs — an outside
kill wants a LOWER cap, not a higher one. The cap is passed as an argv flag
rather than via NODE_OPTIONS so an inherited NODE_OPTIONS cannot override it.

Override per-run with TYPECHECK_HEAP_MB=<mb>.

Covered by scripts/__tests__/typecheck.test.ts, which drives the classifier with
stub typecheckers (sub-second, vs minutes for a real run). Each of the five
cases was mutation-checked against the wrapper: 6/6 mutations killed, each by
its own test. One mutation initially SURVIVED and exposed a real gap in the
test — the crash banner is written to stderr, so asserting only on stdout let a
grep-poisoning regression through; both streams are asserted now.

CI already invoked this via `pnpm run typecheck` and so inherits the wrapper;
the step carries a comment against being "simplified" back to a bare tsc.

* fix(husky): stop the pre-push hook echoing success over a failed typecheck

The hook was:

    npm run typecheck

    echo "Typecheck successful"

`sh` without `set -e` runs the next line regardless of what the previous one
returned, and a script's exit status is its last command's — so the `echo`
became the hook's verdict. A failing typecheck on `main` printed "Typecheck
successful" and the push went through.

Measured against the real hook in a throwaway repo on `main`, with a stub `npm`
whose exit code is controlled:

    npm exit    hook exit (before)    hook exit (after)
       0              0                     0
       1              0                     1
     134              0                   134

Before, all three printed "Typecheck successful". The 134 row is the case this
matters most for: that is V8 aborting on heap exhaustion, which emits no
diagnostics at all, so the hook was echoing success over a typecheck that had
not merely failed but never finished. The failure message points at
scripts/typecheck.mjs, which distinguishes the two.

The branch/username guard above is unchanged, and still makes the hook a no-op
off `main`.
2026-08-04 13:48:02 -05:00
Justin Maier 04f3cf67c6 ci: run typecheck on forks and add a unit test job (#3440)
* ci: run typecheck on forks and add a unit test job

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: drop ssh-agent from the pin guard

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: sync submodule url in new worktrees

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: start the unit job report-only

A full local run passed 8,937/8,943 but timed out 5 tests under load;
all passed in isolation. Blocking on that from day one would red
unrelated PRs. Flip once the pass rate is known.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:43:44 -06:00
Justin Maier b0a1cdef7a chore: keep migrations in the schema package (#3438)
* docs: point migrations at the schema package

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: fail PRs that add migrations outside the schema package

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 17:42:37 -06:00
Justin Maier d96a8a284e chore(lint): add CI job, drop type-aware override and prettier plugin (#3362)
* chore(lint): add CI job, drop type-aware override and prettier plugin

`pnpm lint` took ~2h40m and 61% of its findings duplicated `prettier:check`.
Nothing enforced lint, typecheck, or formatting in CI, which is how a
config-load crash survived ~2 months unnoticed.

- Remove the `*.ts`/`*.tsx` `parserOptions.project` override. Its own `rules`
  block was empty; the only type-aware rule anywhere was root-level
  `@typescript-eslint/restrict-template-expressions` (warn), which `tsc
  --noEmit` largely subsumes. Both are gone. Measured on src/utils (82 files):
  4m34s -> 8.4s. Full `pnpm lint`: ~2h40m -> 1m07s.
- Drop eslint-plugin-prettier and the `prettier/prettier` rule.
  `pnpm prettier:check` already globs `**/*.{ts,tsx}` with no .prettierignore,
  a strict superset of lint's `src/`-only scope, with matching options.
  `eslint-config-prettier` stays in `extends`.
- Add .github/workflows/lint.yml: ESLint + Prettier on PR-changed files only,
  full typecheck in a parallel job.
- Delete unreferenced devDeps (airbnb, airbnb-typescript, mantine configs,
  eslint-import-resolver-typescript, lint-staged) and the lint-staged config
  block, which invoked `tsc-files` — a package that was never installed.
- Pin eslint-config-next to major 15 via pnpm.overrides; v16 is flat-config-only
  and silently kills lint when extended from eslintrc.
- Delete the top-level npm-style `overrides.openai.zod` block. pnpm reads
  pnpm.overrides, so it never applied; openai@4 declares zod ^3.23.8 as an
  optional peer while the repo is on zod 4, so honoring it would assert a
  compatibility that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): make CI lint steps report-only, skip typecheck on forks

Review fixes for the CI job.

- ESLint and Prettier steps get `continue-on-error`. For a formatter,
  "changed file" means the whole file: 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, forcing 3-line
  bugfixes to ship as 200-line reformats. Annotations still surface. Both
  flip to blocking once the backlog clears with the Prettier 2->3 upgrade.
  Typecheck stays blocking; it is clean today and is the real guard.
- Skip the whole typecheck job on fork PRs. civitai/civitai is public and a
  fork gets no secrets, so ssh-agent hard-fails on an empty key. Gating only
  the ssh/submodule steps trades that for a wall of missing-module errors,
  so the job is skipped rather than half-run.
- Drop `--depth=1` from the base fetch; `fetch-depth: 0` already has the ref
  and the graft can break the merge-base walk on a conflicting PR.
- Bump typecheck timeout 15 -> 25 minutes.
- Fix the header comment, which conflated changed-file with changed-line.
- Remove the commented-out lint-staged block and tsc-files note from
  .husky/pre-commit, left over from the dep removed in the previous commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): annotate findings, block on newly added files

Re-review fixes.

- Add .github/problem-matchers/eslint-unix.json and register it, so ESLint
  findings become file annotations on the PR diff. The previous header
  claimed "annotations still surface" - they did not. There was no matcher
  anywhere in .github/, and neither setup-node nor action-setup registers
  one, so findings only ever reached a step log inside a green check.
  Prettier annotates via ::warning / ::error commands.
- Split the lint steps by diff filter. Newly ADDED files (--diff-filter=A)
  are BLOCKING; modified/renamed (CMR) stay report-only. This stops the
  backlog growing while the full flip waits on the Prettier 2->3 upgrade.
  Added files are checked for errors only, without --max-warnings: the repo
  carries 3,470 warnings and 1,762 uses of `any`, so failing on warnings
  would hold new files to a stricter bar than anything already merged.
- Qualify the header claim about typecheck being the real gate; it is
  skipped on fork PRs, so a fork gets no gating from this workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 16:47:33 -06:00
Zachary Lowden eaeca777c0 chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build (#2781)
* chore(auth): add release:auth scripts + sync version; remove redundant GH Actions build

Wire a per-app release flow for the auth hub (apps/auth), mirroring the main
app's `pnpm run release` ergonomics:

- Add root `release:auth[:patch|:minor|:major]` scripts. They bump
  apps/auth/package.json via `npm --prefix apps/auth version <bump>
  --tag-version-prefix=auth-app-v`, create an `auth-app-vX.Y.Z` tag, and push
  --follow-tags. No `release:base` — the hub deploys off the tag (ghcr+Flux),
  not the `release` branch.
- Sync apps/auth/package.json 0.0.0 -> 0.1.0 to match the manually-cut deployed
  0.1.0, so the first scripted release is 0.1.1 (above the deployed semver that
  Flux's highest-semver ImagePolicy selects).
- Remove .github/workflows/auth-app.yml: the hub now builds in-cluster via the
  Tekton tag-webhook on the same auth-app-v* tags; keeping the GH Actions
  workflow would double-build (paid runner) and push a competing image.
- Docs: add docs/auth/releasing.md, link it from auth-index.md, and add a
  Releasing section to apps/auth/README.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(auth-release): commit+tag via release-app.mjs (npm --prefix skips git at root)

Audit (PR #2781) found the release:auth scripts were inert: npm version --prefix
apps/auth only creates the commit/tag when .git is in apps/auth, but .git is at
the monorepo root — so it silently bumped the version field and skipped the tag
(exit 0), releasing nothing + leaving a dirty tree. Replace with
scripts/release-app.mjs: guards on-main + clean-tree, git pull --rebase, bumps
with --no-git-tag-version, then commits ONLY apps/auth/package.json + annotated
tag + push --follow-tags. Verified in a throwaway monorepo (tag created+pushed,
root untouched, tree clean). Docs: prerequisites + rollback + one-at-a-time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:00:15 -05:00
ZacxDev df785325a1 ci(auth): add GitHub Actions build+push for ghcr.io/civitai/civitai-auth
First per-app CI in the monorepo. Replaces the hand-built hub image with a
tag-triggered Actions workflow that publishes a semver-tagged image, slotting
into the existing Flux ImagePolicy (semver >=0.0.1) in datapacket-talos.

- .github/workflows/auth-app.yml: buildx + gha cache build of apps/auth/Dockerfile
  (context = repo root, linux/amd64). Push ghcr.io/civitai/civitai-auth:<semver>
  (+ :sha-<short>) on a `auth-app-v*` tag; PR-paths gate compiles without pushing.
  No :latest (Flux selects by semver). permissions: contents:read, packages:write.
- apps/auth/.env.example: fix stale cookie comment — the session cookie is
  civ-token / __Secure-civ-token (distinct from legacy civitai-token), not the
  shared civitai-token. Matches SESSION_COOKIE_BASE in packages/civitai-auth.
- apps/auth/docs/ci.md: scoping note (Actions vs Tekton evidence, tag scheme,
  Flux tie-in, release flow).

Authored + actionlint-clean; not run-verified (tag builds need the workflow on
the default branch — first run is post-merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 13:55:48 -05:00
Zachary Lowden ab7030b0bd ci: remove deploy-release/branch/stage workflows (move off GitHub Actions runners) (#2551)
* ci: remove deploy-release.yml (failing Tekton-deploy tracker)

The "Release Deploy" workflow parks a runner up to 45min on every `release`
push polling for a GitHub deployment object — which the Tekton-based deploy no
longer creates, so it times out and fails every run (4/4 recent = 40min
failures). Tekton builds + deploys civitai-prod independently on the `release`
push; this workflow only *tracked* (read deployment statuses) and now tracks
nothing. Removing it eliminates the largest remaining GitHub Actions runner
consumer with zero effect on the actual deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: remove deploy-branch.yml + deploy-stage.yml (dead dev/stage deploy triggers)

These fired dev/stage builds in civitai/civitai-deployment on deploy-dev /
deploy-stage labels but have been dormant (last 30 runs of each = all skipped;
dev/stage deploys retired/moved). Together with deploy-release.yml (this PR),
pr-check.yml (#2547), and the disabled CodeQL default-setup, this removes
civitai's billable GitHub Actions runner usage.

Intentionally retained: submodule-pin-guard.yml (the "event-engine-common pin"
check) — a cheap (~22s, only when the pin changes) guard against an accidental
backward submodule-pin move that already shipped a prod regression once. Worth
its trivial runner cost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:57:45 -05:00
Zachary Lowden e29a606955 test(component): Vitest browser-mode component-testing scaffold (runs in Tekton, removes GH Actions pr-check) (#2547)
* test(component): add Vitest browser-mode component-testing scaffold + SeedInput

Adds a second Vitest project (`component`, browser mode / real Chromium via
Playwright) alongside the unchanged 857-test `unit` suite. Includes a
renderWithProviders scaffold (Mantine + QueryClient + next/router mock), a
process.env shim for browser mode, a report-only GH Actions `component-tests`
job, and the first test on the high-churn, e2e-impossible SeedInput generation
leaf (6 cases, mutation-proven).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(component): address audit findings (typecheck test/, seed-test teeth, optimizeDeps)

- tsconfig: add `test` to include so the load-bearing browser-process-shim +
  component-setup are actually typechecked (were only checked transitively/not
  at all). (audit H1)
- SeedInput test: stub Math.random for an exact-value assertion bounded by
  MAX_RANDOM_SEED (was a loose MAX_SEED range that survived a constant-seed
  mutation). (audit M1)
- vitest component project: optimizeDeps.include next/router to stop the
  "Vite unexpectedly reloaded a test" flake warning. (audit L4)
- drop the stale "857 tests" count from the config comment. (audit M2)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: remove GitHub Actions pr-check.yml — consolidate PR checks onto Tekton

typecheck + unit-tests already run in the Tekton PR-preview pipeline (author-
gated on MEMBER/OWNER/COLLABORATOR), and component-tests now run there too
(talos-infra: report-only npm-component-tests task). Removing this workflow
stops paying for GitHub Actions runner time. Tradeoff: external-contributor PRs
(not author-authorized) no longer get automated checks — accepted per the
"pure Tekton" decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:55:14 -05:00
Zachary Lowden 21c115b9af ci: guard event-engine-common submodule pin against accidental regressions (#2506)
Fails a PR that moves the event-engine-common gitlink BACKWARD (to an
ancestor of the base pin). Catches the stale-submodule-checkout clobber
that silently reverts the pin alongside unrelated changes — which shipped
an image:tagIds cache TTL regression to prod (79e30dbb8 reverting #2475).

Forward bumps (descendant) pass; intentional downgrades use the
'allow-submodule-downgrade' label. Reuses the existing SUBMODULE_SSH_KEY
(same as pr-check.yml); only fetches the submodule when the pin changed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:47:24 -05:00
Zachary Lowden de6712ec63 ci(bundle): report-only First Load JS budget in the Dockerfile build (Tekton) (#2511)
* ci(bundle): add report-only size-limit bundle budget job

Next 16 removed per-route build stats, leaving no bundle-size regression
signal. Adds a `bundle-budget` job to pr-check.yml that builds the app
(SKIP_ENV_VALIDATION, no secrets) and runs size-limit over the shared
client chunks (framework/main/webpack/_app + a coarse total) defined in
.size-limit.json.

Report-only for now: continue-on-error + intentionally loose limits. This
is also the first GH Actions job to run a full `next build` (~8GB heap vs
~7GB standard runner) so early runs probe feasibility. Once a baseline is
observed: tighten limits to baseline+headroom, drop continue-on-error,
and make "Bundle Budget" a required check to gate. If Build OOMs, move to
a larger runner label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* ci(bundle): run size-limit in the Dockerfile build, not GH Actions

Switch the bundle-size check from a separate GH Actions job (which would
duplicate the full ~8GB next build) to a stage in the Dockerfile builder,
right after `pnpm run build` where .next already exists. The Tekton
buildkit build (preview + prod) now reports the size-limit numbers with
no extra build — consistent with where app builds live.

Report-only during the soak via `|| true` (numbers print to the build
log). To gate later: drop `|| true` so a bundle regression fails the
image build. Reverts the pr-check.yml bundle-budget job; keeps
.size-limit.json + the size-limit deps + the `size` script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(bundle): fix size-limit globs for Turbopack output

The live preview build (next 16.2.7 Turbopack) revealed the webpack-era
globs match nothing — Turbopack emits opaque hashed chunks
(0--619vzepha0.js, turbopack-*.js), no framework-/main-/webpack-/_app-
files. Those 4 entries errored ("can't find files"); only the recursive
total worked.

Baseline from the build: total client JS = 38.29 MB brotli (3615 chunks).
Drop the 4 broken named-chunk entries; keep the working total with a
42 MB limit (~10% headroom). Still report-only (|| true in Dockerfile).

Note: the coarse total is a weak regression signal under Turbopack's
heavy code-splitting; a per-page First Load JS budget needs parsing
.next/build-manifest.json (follow-up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(bundle): manifest-based First Load JS budget (replaces size-limit)

size-limit's globs can't see Turbopack's opaque hashed chunks, so it could
only report a coarse 38 MB total (weak signal). Replace it with
scripts/bundle-budget.mjs, which parses .next/build-manifest.json to
reconstruct the metric Next used to print:

  First Load JS(route) = brotli(union(pages[route], pages["/_app"], polyfills))
  shared-by-all-pages  = brotli(pages["/_app"] + polyfills)

Reports shared + total + the heaviest routes, checks .bundle-budget.json
(report-only; `--gate` exits non-zero on a breach). No deps (Node stdlib
zlib/fs), no extra build — still runs in the Dockerfile builder stage.
Removes size-limit + @size-limit/file + .size-limit.json.

Budgets are loose placeholders; tighten to baseline+headroom from the
first build's printed First Load JS numbers, then add --gate + drop the
`|| true` to enforce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(bundle): tighten First Load JS budgets to baseline + headroom

From build pr-preview-2511-fzrjd: shared-by-all = 425.9 kB, heaviest route
(/user/[username]/models) = 1.13 MB. Set shared 470 kB (~10%) and routeMax
1.3 MB (~15%) so the report-only check is meaningful instead of passing
trivially at the 1 MB/3 MB placeholders. Still report-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(bundle): bake bundle-budget report into the image for PR surfacing

Write the size report to /app/bundle-budget.txt (still report-only, still
printed to the build log) and COPY it into the runner image. A new Tekton
bundle-comment task surfaces it on the PR via `kubectl exec ... cat` — no
duplicate build. Uses redirect+cat instead of `| tee` so the script's exit
code is preserved for the future --gate flip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: retrigger preview build (bundle-comment task now live)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: retrigger preview (bundle-comment rollout-race fix live)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: retrigger preview (pr-deployer exec RBAC now granted)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(bundle): drop pnpm preamble from the bundle report

Invoke `node scripts/bundle-budget.mjs` directly instead of `pnpm run size`
so pnpm's lifecycle echo (`> model-share@… size /app`) stays out of
/app/bundle-budget.txt and the PR comment. The `size` script stays in
package.json for local use.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: retrigger preview (collapsible bundle comment)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-13 15:26:47 -05:00
Zachary Lowden 6431a7579c ci(pr-check): gate the full vitest unit suite (857 tests) (#2489)
The full test:unit:run suite was made CI-green by #2478 but pr-check.yml
still ran only typecheck + the no-io-in-transaction lint-rule subset; the
suite ran report-only in the DP Tekton preview pipeline. Add a unit-tests
job that runs the full suite on every PR so it actually blocks GitHub
checks (pending branch-protection making it required). No DB/Redis needed
— it is unit-level; Prisma client comes from the install postinstall.
2026-06-12 07:16:53 -05:00
Zachary Lowden 1730a4dc2a feat(lint): no-io-in-transaction rule + clear existing violations (#2382)
* feat(lint): no-io-in-transaction rule + clear existing violations

Adds a custom ESLint rule (eslint-local-rules.js) that flags awaited
external/non-DB I/O inside a Prisma interactive `$transaction(async (tx) =>
…)` callback. Such calls (HTTP fetch, image scanner, Buzz API, Axiom logging,
Redis cache busts, search-index queueing) add their latency to the txn's
wall-clock timeout budget and, when slow, blow it ("Transaction already
closed"). This is the recurring class behind #2375 / #2377 / #2379 — the rule
turns "sweep it again" into "caught in review/IDE".

Detection is a curated denylist of known I/O call names (low false-positive);
calls on the tx client itself (`tx.*`, `tx.$queryRaw`/`$executeRaw`) are
always allowed. Validated with a RuleTester suite (10 cases).

Wiring is conditional in .eslintrc.js: the rule activates automatically once
`eslint-plugin-local-rules` is installed (`pnpm add -D eslint-plugin-local-
rules`) and is skipped until then, so `next lint` keeps working and CI's
`pnpm install --frozen-lockfile` is unaffected (no package.json/lockfile
change in this PR — pnpm wasn't available to regenerate the lockfile).

Brings the codebase to a clean baseline for the rule:

Fixed (moved external work after commit / made fire-and-forget):
- collection/article(x2)/model(x2)/bountyEntry: userXCountCache.refresh()
  (Redis) moved to after the txn commits, using the returned row's id.
- referral/redeemableCode: error-branch logToAxiom() de-awaited (Axiom HTTP),
  matching the #2379 pattern (.catch retained / added).

Ratchet-disabled with TODO(tx-io) (intentional / needs careful change):
- bounty.createBounty + bountyEntry.awardBountyEntry: Buzz charge/settlement
  inside the txn — moving needs charge→tx→refund-on-failure compensation
  (a PG rollback can't undo an external Buzz charge); left for a domain owner.
- report.createReport CSAM branch: search-index delete inside the txn —
  moving needs hoisting the CSAM guard post-commit on a sensitive path.

tsc --noEmit error-neutral vs baseline across all touched files (pre-existing
Prisma-client-drift errors unchanged; CI regenerates the client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): audit follow-ups — install plugin, warn-level, create-only refresh, tests

Addresses the audit of #2382:

- H1: install `eslint-plugin-local-rules` (devDep + lockfile via pnpm
  --lockfile-only) and wire the rule unconditionally. Previously the rule was
  only activated when the plugin happened to resolve, but the 6
  `// eslint-disable-next-line local-rules/no-io-in-transaction` directives
  error with "Definition for rule not found" in ESLint 8 when the rule is
  unconfigured — so `next lint` broke in the plugin-absent state. With the
  plugin now a real dependency the rule is configured and the directives are
  valid.
- Rule severity set to `warn` (not `error`): surfaces in the editor / next lint
  as a guardrail without failing lint or the build; escalate later.
- M1: bountyEntry.upsertBountyEntry count-cache refresh is now gated to the
  create path (`!id`). The pre-move code only refreshed in the create branch;
  the first move ran it on updates too (extra primary-DB COUNT + Redis on every
  description edit). Restored create-only semantics.
- Test coverage: add src/server/services/__tests__/no-io-in-transaction.test.ts
  (RuleTester via vitest, 20 cases incl. FP/FN regression guards). Runs with
  `pnpm test:unit:run`; 20/20 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(pr-check): gate the no-io-in-transaction rule via its RuleTester suite

Adds a `test:lint-rules` script (vitest run of the rule's test) and a
"Test lint rules" step to the pr-check workflow, right after typecheck (reuses
the job's already-installed deps).

Scoped to the rule's own test rather than the full `test:unit:run` suite: the
full suite currently has pre-existing failures (e.g. a timezone-dependent
redeemableCode date assertion) and isn't CI-green, so wiring it wholesale would
block all PRs. This step deterministically gates the custom rule's correctness;
broadening to the full suite is a separate cleanup once those failures are fixed.

Note: the rule itself is `warn`-level, so `next lint` surfaces violations
without failing the build — this CI step gates the RULE (regressions in
eslint-local-rules.js), not new violations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: retrigger preview (prior build hit transient pnpm-install network timeouts)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:55:26 -05:00
Zach Lowden 7cd8e3b3c2 fix: replace broken deploy-release workflow with Tekton status tracker
The old workflow called civitai-deployment/deploy.yml which no longer
exists (migrated to Tekton). Replace with a status poller that watches
the GitHub Deployments API for Tekton pipeline status updates.

Devs get a green/red check on release branch commits. The actual
build/deploy is handled by Tekton on the gpu-fleet cluster.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 12:48:31 -05:00
Justin Maier cc12913373 Remove pr bot 2026-02-05 13:40:48 -07:00
Justin Maier 3f23c04f7d fix: Follow redirects in PR bot webhook curl
Add -L and --post301 flags to handle nginx 301 redirects without
losing the POST body.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:32:54 -07:00
Justin Maier d5c967de3e Merge pull request #1982 from civitai/chore/migrate-to-pnpm 2026-01-23 08:53:50 -07:00
Justin Maier 322792055b Add PR Bot webhook workflow for automated PR analysis
Triggers on PR open/sync/reopen events and sends webhook to PR Bot
service which uses Claude to analyze changes and post summaries to Discord.

Requires secrets:
- PR_BOT_WEBHOOK_SECRET: HMAC signing key
- PR_BOT_WEBHOOK_URL: PR Bot endpoint URL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:25:39 -07:00
Justin Maier 3c8e8ab41a chore: update pr-check workflow to use pnpm
- Use corepack to install pnpm@10.28.1
- Add pnpm store caching for faster CI runs
- Replace npm ci with pnpm install --frozen-lockfile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 10:11:31 -07:00
Zach Lowden 0f483eedef Remove lint job from PR check workflow
The lint job was failing due to 31 pre-existing prettier/eslint errors
in the codebase. Removing it to focus on type checking only for now.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:35:06 -06:00
Zach Lowden 68bdfb2a16 Add PR check workflow for typecheck and lint
Adds GitHub Actions workflow that runs on all PRs to main:
- Type checking with `npm run typecheck`
- Linting with `npm run eslint`

This prevents TypeScript errors from being merged to main
and breaking CI builds.

Note: Requires SUBMODULE_SSH_KEY secret for event-engine-common submodule.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Fix SSH key handling for submodule checkout

Use webfactory/ssh-agent to properly configure SSH for submodules
while using HTTPS (default token) for the main repo checkout.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:11:23 -06:00
Brett Woodward 51827aaf64 - stage deployment update
- hide auction titles that have bad words in them
- show tooltip on truncated titles
2025-05-15 15:23:29 -04:00
Sean Sube cbef49752c add workflow to deploy the stage site 2024-08-06 13:57:15 -05:00
Sean Sube 0f7d7fca1c enable manual deploys for main and release 2024-06-11 12:10:02 -05:00
Sean Sube b468e2c0c7 extract source branch name from PRs 2023-11-30 16:30:29 -06:00
Sean Sube 21963f8189 remove assocation check 2023-11-22 14:57:12 -06:00
Sean Sube 345ead9877 include more roles 2023-11-22 14:21:44 -06:00
Sean Sube 0882d5f10e use PR branch name 2023-11-22 14:10:50 -06:00
Sean Sube 5b850d2fb6 move branch patterns into condition 2023-11-22 13:51:55 -06:00
Sean Sube 8bfffc7a05 deploy from feature and fix branches if PR was opened by a team member 2023-11-22 12:02:58 -06:00
Sean Sube 94c4974ae3 add workflow for branch deploys 2023-11-20 13:29:39 -06:00