Repository navigation
fork: CI check that refuses database schema and migration changes - #3
Conversation
T3 has no down-migrations, so a fork patch that changes the schema would strand every install that later returns to upstream. fork-checks.yml runs on every pull request and fails when the change touches the migration files or adds a schema statement to shipped source. The follow workflow runs the same script over the whole patch series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
lens: correctness+design+tests · reviewer: opus high (t3lens-cdt) · read at: 2146267
|
Review round 1 of the schema check: - The workflow runs on pull_request_target, so its file and the check script come from the default branch; a pull request can no longer weaken, skip or delete the check that judges it. The pull request's commits are only fetched and read as blobs, never checked out or run. - Changes to the check's own files or to any .gitattributes are refused once the base has the check; only the owner can merge such a change. - Files the migrations import (transitively, computed from both trees) count as schema files, and a shipped file whose text holds a schema statement before or after the change is refused; statements match across lines. - Paths come from `git diff --raw -z` and texts from the blobs, so quoted non-ASCII paths and .gitattributes cannot hide a change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 1: the check can no longer be bypassed by the PR it judges (head
17/17 tests pass; against the round-0 script 14 fail and every e2e case exits 0. The ruleset text for the owner is in the description. Not proven until merge: a live 🤖 Generated with Claude Code |
|
lens: security+correctness r2 · reviewer: opus high (t3lens-secr2) · read at: e9afd0c
|
- A check file or .gitattributes that the branch carries unchanged from
the base passes. After main is rebased onto a new upstream release, a
branch from the old main is told to rebase onto main (its commits are
matched on main by author, author time and subject), never to merge
past the red check.
- Package root imports (@t3tools/contracts) are followed to the files
that declare the imported names, so migration 064's schemas in
threadPullRequest.ts are covered while the rest of the package is not.
- Schema statements also match across SQL comments and string joins, and
with upper-case keywords around an interpolation (CREATE ${kind}).
- parseRawDiff throws on output it cannot read; actions/checkout is
pinned by commit SHA.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 2 (head a7328e4), answering the r2 lens comment:
|
|
lens: correctness+security r3 · reviewer: opus high (t3lens-3-r3) · read at: a7328e4
|
…traced names
A name imported from a package root that no file is seen to declare now
guards the whole barrel and everything it re-exports, instead of dropping
out of the guarded set. The tracer also knows export async function, a local
export { type X }, export { default as X } from, and destructured exports.
The barrels on the way to a declaring file are guarded too, so a barrel-only
edit cannot re-point a name a migration imports.
On a branch holding commits already on main, a real edit to a check file is
reported as one, and the message no longer assumes main was rebased.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 3: head ca47142 (was a7328e4)
|
|
lens: correctness+security r4 · reviewer: opus high (t3lens-3-r4) · read at: ca47142
|
…resolve
- The package.json of every workspace package that schema code imports is
guarded, so an edit that re-points such an import (another export target,
a condition Node picks first, exports removed in favour of main) is
refused. Every file any export condition can pick is guarded, and main,
module and types are followed when a package has no exports.
- An import that should resolve inside the repository but does not is
refused, naming the import: a workspace package with no entry file, a
relative import of a missing file, a #name import, a computed import()
or require(), import.meta.glob. Only packages outside the workspace
resolve to nothing; the output lists them.
- Workspace packages come from the pnpm workspace list (scoped or not, at
any depth), and a change to that list is refused. A test-path file that
schema code imports is guarded like any other.
- The tracer no longer counts the key in `export const { S: renamed }` as
declaring S.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 4: dee218a (read at ca47142 by the r4 lens)
|
|
lens: correctness+security r5 · reviewer: opus high (t3lens-3-r5) · read at: dee218a
|
…only new unfollowable imports - The dependency specs of every name schema code imports (workspace packages and outside names such as effect) are compared in the package.json files of the packages holding schema code and the root one, plus pnpm-workspace.yaml catalog/catalogs/overrides/patchedDependencies and the root pnpm.overrides/patchedDependencies/resolutions. Patch files of those names are guarded. Other dependency changes stay free. - A package.json is judged by the fields that map the imports schema code makes through it (name, type, main/module/types/browser, imports, the export entries answering those keys), so adding a module to a package stays free. - An import the check cannot follow is refused only when the branch brings it; ones already on the merge-base are listed, not refused. Imports are read from code with comments and string contents blanked, so prose like "require (in order)" is not an import. - Tests: the browser fallback, the four importer-side probes and more, manifest field judging, the lexer and the merge-base rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
fix round 5 (head 5c2688e), against the r5 lens:
Tests: 47/47 pass; against the dee218a script, 14 fail; 12 hand-breaks of the fixes each turn tests red. Guarded set: 114 files. This PR vs main, and #4 (fc08057) vs this head: both exit 0, about 1.3 s. Lint wasn't run locally (the oxlint plugin didn't load), so CI will show it. |
|
lens: correctness+security r6 · reviewer: opus high (t3lens-3-r6) · read at: 5c2688e
|
This adds a CI check that rejects any fork change to the database schema.
T3 has no down-migrations. A fork patch that changes the schema would leave every install that later moves back to upstream with a database upstream does not know. Fork state belongs in existing JSON fields such as settings and model options.
What it adds
.github/scripts/fork-schema-check.cjs <base> <head>readsgit diff --raw -z merge-base headand reads every changed file as a git blob (before and after). It refuses:apps/server/src/persistence/Migrations/**,Migrations.ts,databaseMigrationHead.ts,initializeV2Database.ts,reconcileV2PreviewMigration.ts) or to any file they import, transitively;pnpm-workspace.yamlpackages:); in thepackage.jsonof an imported workspace package, the fields that answer the imports schema code makes (name,type,main/module/types/browser,imports, and theexportsentries for those keys, e.g."."of contracts and"./assistantCitations"of shared); and the dependency spec of every name schema code imports (@t3tools/contracts,@t3tools/shared,effect): its entry in the dependency fields of the packages holding schema code (apps/server,packages/contracts,packages/shared) and of the rootpackage.json, inpnpm-workspace.yamlcatalog:/catalogs:/overrides:/patchedDependencies:(override keys likea>effect@^4count), and in the root'spnpm.overrides/pnpm.patchedDependencies/resolutions; the patch file of such a name is guarded like schema code. Specs of other names, and other export entries (a new shared module), stay free;#nameimport, a computedimport()/require(),import.meta.glob, a file whose comment or template never ends. Ones already on the merge-base (e.g. from an upstream release) are listed as "already on the merge-base (not this branch's doing; the check needs teaching)", not refused. Imports are read from code with comments and string contents blanked, so prose such asrequire (in order)in a comment is not an import. Only packages outside the workspace resolve to nothing; the output lists them ("Imports from outside this repository (their dependency specs are guarded, their code is not): effect, node:fs, node:sqlite");.ts/.tsx/.mts/.cts/.js/.mjs/.cjs/.sqlunderapps/orpackages/, not a test path) that holds a schema statement before or after the change:CREATE [UNIQUE] TABLE/INDEX/VIEW/TRIGGER,CREATE VIRTUAL TABLE,ALTER TABLE,DROP TABLE/INDEX/VIEW/TRIGGER. Between the keywords may stand line breaks, SQL comments (/* */,--) and string joins ("CREATE " + "TABLE"). An interpolation in place of the object kind (CREATE ${kind}) counts too, but only with upper-case keywords, so UI text like`Create ${label}`stays free;fork-checks.yml) that differs from the base's copy, once the base has the check;.gitattributesthat differs from the base's copy (it can hide changes from review);fork-checks.ymlruns the script's tests and then the check on every pull request.Not caught, on purpose (the check stops mistakes; review stops deliberate tricks):
/test/,.test.,testkit) or in a non-source file (.txt,.json,.md), even when shipped code loads it;CREATE/ALTER/DROPkeyword is itself computed, or lower-case keywords around an interpolation;resolve.aliasinapps/server/vite.config.ts, whichvp packuses), tsconfigpaths(none today), the lockfile (pnpm-lock.yaml),.npmrc,.pnpmfile.cjshooks (readPackagecan re-pointeffector@t3tools/*without touching a guarded spec) and hoisting or linking settings (publicHoistPattern,node-linker,dependenciesMeta,packageExtensions), YAML anchors or flow maps spread over lines inpnpm-workspace.yaml;effect): only their specs are guarded;readFileSync; none in the persistence code today), and abrowserobject map in apackage.json(the server runs on Node);import()in a guarded file and a PR adds another to the same file, the output lists the new one as "already on the merge-base" (the run is still red: the edit to a guarded file is refused);)or}is read as a division, which can hide an import on the rest of that line.Caught on purpose, though not server schema:
apps/web/src/components/files/binaryPreview.worker.tsholdsCREATE VIRTUAL TABLEinside aLIKEpattern (a web worker reading SQLite files, not the server database), so any edit to it is refused. The rule before this PR did the same; an owner merge past red is the way through.Decisions
pull_request_target, notpull_request. GitHub then runs the workflow file and the script from the default branch, so a pull request cannot weaken, skip or delete the check that judges it. The pull request's commits are only fetched by SHA as git objects (persist-credentials: false) and read as text; nothing from them is checked out or run. Permissions arecontents: read;actions/checkoutis pinned by commit SHA.node,import,types,default, custom, nested, arrays,./*patterns), so it never guesses which condition a tool uses; withoutexportsit followsmain,module,types, a stringbrowserandindex. Of the package'spackage.jsononly the fields that answer those imports are compared (see above), so upstream's and the fork's ordinary edits topackages/shared/package.json(about 400 subpath exports) stay free; an entry schema code imports through cannot be swapped, gain a condition or a competing pattern. For a package root (@t3tools/contracts) it followsexport */export { } fromto the files that declare the imported names (e.g. migration 064 ->threadPullRequest.tsand what that imports). The barrels on that path (contracts/src/index.ts) are guarded as well, but their other re-exports are not followed, so the rest of the package (settings.ts,model.ts) stays free. A name the tracer cannot find a declaration for fails closed: the barrel and everything it re-exports are guarded. The closure is the union of the merge-base and head trees. So re-pointing a name schema code imports is caught when the edit is to a barrel on the path, to a new or changed declaring file, to the export entry or fallback fields that map it, or to its dependency spec, catalog, override or patch; the other channels are under Not caught.pull_request_targetruns main's copy, so this PR itself gets no run of the check until it is merged. Edits to the check files are allowed only while the base does not have the check yet.baseHasCheckstays read from the event's base SHA, not from the checkout: a PR into another branch (e.g.fork/ci-base) must be judged against that branch, which the default-branch checkout is not.Branch protection (owner, after merge; nothing is set yet)
A check only blocks merges when a ruleset requires it. Suggested ruleset for the default branch:
~DEFAULT_BRANCH; enforcementactivedeletion;non_fast_forward;pull_requestwithrequired_approving_review_count: 0(an owner cannot approve their own PR);required_status_checkswith contextNo database schema changesandintegration_id15368 (GitHub Actions)bypass_mode: pull_request: the owner can merge past a red check only through a visible PR, never by a direct push;bypass_mode: always, either a deploy key (actor_type: DeployKey, write, this repo only) or a GitHub App installed only on this repo. Unverified: that a deploy-key or App push may change files under.github/workflows/(a token push needs theworkflowscope). Until that is tested, the follow workflow keepsFORK_PUSH_TOKEN, which then needs analwaysbypass of its own.FORK_PUSH_TOKEN, or the deploy key) goes in an Environment (e.g.fork-push) whose deployment branches are limited tomain, and the follow job namesenvironment: fork-push(a change in fork: follow each stable upstream release with the patch series #4). Why: a repository secret is handed to every workflow run started from a branch of this repository, including apull_requestworkflow that a PR branch adds itself (only PRs from other forks get no secrets). An Environment secret reaches only jobs whose ref passes the branch rule, and a PR run's ref (refs/pull/N/merge) does not.Tested
node --test .github/scripts/fork-schema-check.test.cjs: 47 pass at 5c2688e. The same tests against the dee218a script: 14 fail (the importer-side probes, manifest field judging, the merge-base rule (unit and e2e), the lexer, the mapping and imported-name assertions, the browser fallback, the outside wording, and 5 closure tests that no longer listpackage.json). Each fix broken by hand turns tests red: no spec comparison (1), nopnpm-workspace.yamlspecs (1), no root overrides/resolutions (1), holders only the root (1), patch files not guarded (1), unresolved from both trees (2), no lexer (1), an open comment/template not reported (1), nobrowserfallback (1), the wholepackage.jsoncompared (1), export entries ignored (2), imported names not collected (2).package.jsonfiles, now judged by field; the file list is otherwise identical), mappingscontracts"."andshared"./assistantCitations","./composerContextReferences"; guarded names@t3tools/contracts,@t3tools/shared,effect; holders root,apps/server,packages/contracts,packages/shared. Probe commits on this head: (A)apps/server@t3tools/contracts->link:../../x/evil, (B)overrides: "@t3tools/contracts": link:./x/evil, (C) catalogeffect->link:./x/effect, (D)@t3tools/contracts->npm:left-pad@1.3.0, (E) rootpnpm.overrides.effect, (G) shared./assistantCitationsimport swapped: the dee218a script exits 0 on A-E, this one exits 1 on all six. (F) a new shared export + module and (H) awscatalog bump plus aneffect-acpspec change: exit 0 (dee218a refused F). An "upstream" commit adding// each step must require (in order)and animport.meta.globtoMigrations.ts, then a README-only PR: dee218a exits 1 (both reported), this one exits 0 and lists the glob as already on the merge-base. This PR against main (ee69d98) and fork: follow each stable upstream release with the patch series #4 (fc08057) against this head: exit 0 (about 1.3 s).node --test .github/scripts/fork-schema-check.test.cjs: 41 pass at dee218a. The same tests against the ca47142 script: 13 fail (the three package.json tricks, all three listed as passing there; the unresolvable package e2e; outside list; workspace list; every export condition; main fallback; unresolved imports; workspace members;{ S: renamed }; and the 4 closure tests that now include package.json). Each fix broken by hand at dee218a turns tests red: manifest not guarded (8), only the import condition read (1), unresolved package silent (2), relative import silent (1),#nameas outside (1), computed import silent (1), test-path targets skipped (1), the old destructuring tracer (1), workspace list unchecked (1), scoped names only (8), no main fallback (2). The e2e "untraced name" test now reaches the fail-closed path: with the round-3 fail-closed return removed it fails (with 3 others).packages/contracts/package.json,packages/shared/package.json), 115 -> 117 on fork main; no unresolved import; outside:effect,node:fs,node:sqlite. Commits on this head that (1) swapcontractsexports["."].importto./src/settings.ts, (2) add anodecondition to a newevil.ts, (3) dropexportsformain: ./src/evil.ts: the ca47142 script exits 0 on all three, this one exits 1 on all three (about 1.2 s). This PR against main and fork: follow each stable upstream release with the patch series #4 (62216fc) against dee218a: exit 0.node --test .github/scripts/fork-schema-check.test.cjs: 32 pass. Run against the a7328e4 script, 10 fail: declaration shapes, an untraced name (unit and e2e), a barrel-only re-point, barrels in both closure tests, a stale branch that also edits the check (unit and e2e), a cherry-pick-only branch, and the stale wording. On e9afd0c the 7 round-2 tests failed.export { ProviderKind as ThreadLinkedPullRequest } from "./orchestration.ts";tocontracts/src/index.ts: the a7328e4 script exits 0, this one exits 1. The closure gains exactlypackages/contracts/src/index.ts(113 -> 114 files here, 114 -> 115 on fork main). No real import hits the fail-closed path.e9afd0c4+ a README commit, judged against24e77473e+ an empty "upstream release" commit + the two check commits cherry-picked (what fork: follow each stable upstream release with the patch series #4 produces): before, exit 1 with three "the owner merges past it"; now, exit 1 with "2 commit(s) of this branch are already on main as rebased copies ... rebase it onto main", in 1.5 s. Against the old main the same branch passes.threadPullRequest.ts,baseSchemas.ts,pullRequest.ts,sourceControl.ts,vcs.ts,composerContext.ts,environment.ts,guestShare.ts,chatAttachment.ts,assistantCitations.ts);settings.tsandmodel.tsare not in it. The wider statement rule flags no new shipped file outside the migrations.CREATE TABLE, an edit of the check itself, deleting the workflow, a non-ASCII file name, a.gitattributeschange, and a change to a helper the migrations import. Settings-only changes and PRs fork: the server and CLI update from the desktop app's repository #1, fork: release workflow for v<upstream>-fork.N on GitHub-hosted runners #2 and Auto-update: server updates itself when quiet, desktop installs on quit #5 pass. 200 upstream commits beforev0.1.9: the check flags upstream's migration 081,Migrations.tsand aDROP TRIGGERinmessageSearchIndex.ts(not rerun this round).Fail-open search (round 4)
Searched the resolver and tracer for a lookup that returns nothing and is skipped. Found and fixed: a workspace package with no resolvable entry (silent); a relative import of a missing file (silent); package names matched only when scoped, so
effect-acpand the like were never followed; manifests read only atapps|packages/*/package.json, so deeper members were invisible; the tree held only source files, so an import of a.jsonor other file resolved to nothing; apackage.jsonthat is not JSON dropped silently (now an import matching no package is refused while one is broken);#nameimports taken for outside packages; computedimport()/require()andimport.meta.globnot seen at all; test-path files that schema code imports skipped. Already closed: an untraced name and an unresolvable re-export inside a barrel (whole barrel walked). Left, on purpose or out of reach (listed under Not caught).Round 5 closed the importer side (dependency specs, catalogs, overrides, patches); the remaining channels are in Not caught above.
Not proven yet
pull_request_targetrun, and that it satisfies a required status check. Both need this PR merged first.There is deliberately no override. Turning the check off means the owner changing this workflow and merging past the red check through a PR.
🤖 Generated with Claude Code