CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 30s
Build images / build-web (push) Successful in 2m12s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Failing after 3m42s
89 files and 6,300 lines become one file of 807. Nothing about the resulting schema changes; what goes away is the requirement that a new installation replay our development history to arrive at it. revision = "0089", down_revision = None. That pairing IS the migration strategy for existing installs, not a detail of it: a deployed database already has alembic_version = '0089' from running the real 0089, so alembic reads the version table, sees head reached, and does nothing. No stamp is required — which matters, because `alembic stamp` writes a version string without validating anything about the schema it is writing it against, and a wrong stamp is indistinguishable from a right one until the next migration fails. An empty database runs the file and records 0089. Both paths converge. The next migration is 0090, as it would have been; the numbering is continuous across the collapse on purpose. Autogenerate produced nearly all of this unaided, which was NOT true of the first attempt — that one was reverted because the generator silently dropped eleven indexes and three uniqueness guarantees. #3275 put those on the models first, so the HNSW index with its opclass, the COALESCE expression index, the partial uniques, 107 server_defaults and the enum CHECKs are all emitted now. Doing the reconciliation before the squash, rather than after, is what made this work. Hand-added, because none of it can live in a model: * CREATE EXTENSION vector / tsm_system_rows (0001, 0004) — database objects, not table metadata. * The pgvector import. Autogenerate writes qualified pgvector.sqlalchemy.vector.VECTOR references without importing the package, so its own output cannot run (run 4988). * THE TWO SEED ROWS. 0002 and 0003 did not only build schema — each inserted a settings singleton, and nothing in the app ever creates them: ImportSettings.load() and MLSettings.load() are select(...).scalar_one(), which RAISES NoResultFound rather than returning None. A models-only baseline would leave both tables empty and crash a fresh install on first settings access, while baseline.yml reported a perfect schema match. Only running the app against a new database finds that. Not carried over: 0023's DELETE FROM tag and 0047's series deletes, which are historical cleanups operating on rows an empty database lacks. downgrade() raises. A baseline's downgrade is "drop every table", which is a data-loss event wearing a migration as a disguise; offering it as one invites someone to run it. Restore from a backup. Also removed, per the plan: the 10 test_migration_*.py files (they assert intermediate states and backfills that no longer exist — a test that a column exists is already the model tests' job) and backend/app/utils/artist_backfill.py, whose only importer was 0008. Verified no other consumer anywhere in backend/ or tests/. baseline.yml changes with it. chain_ref now DEFAULTS to725bf15, since the tree no longer carries a chain to compare against — that pinned commit is the last one that does. And the CheckConstraint repair is removed, because it never fired. I added it claiming autogenerate re-doubles a constraint name on the round trip and asserted inb979062that it was "still correct and still needed". It is not: autogenerate wraps names in op.f(), which marks them already-formatted and blocks the convention from re-applying. Tested against the real candidate line — the regex matches nothing. What actually fixed the mismatch was 0088's renames alone. The doubling is a real hazard, but of hand-writing a pre-prefixed name, not of the generator; the comment asserting otherwise was worse than the dead code under it. The header comment is rewritten for the same reason — it described 87 revisions, and claimed the HNSW index could not be expressed in a model, which #3275 disproved. It now also states plainly what this check CANNOT see: it compares schema, so a green run means the schema is right, not that the baseline is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
339 lines
16 KiB
YAML
339 lines
16 KiB
YAML
|
|
# TEMPORARY — milestone 328. Delete once the baseline has shipped and settled.
|
|
#
|
|
# Collapsing 89 alembic revisions into one baseline has exactly one dangerous
|
|
# failure: the baseline does not reproduce the schema the chain produced, and
|
|
# the divergence surfaces later, on the operator's live data, in whatever
|
|
# migration comes next.
|
|
#
|
|
# So the comparison happens in CI, against a throwaway pgvector Postgres, where
|
|
# nothing is at risk. It answers one question: does `upgrade head` on the
|
|
# collapsed tree produce the same schema as `upgrade head` on the full chain?
|
|
#
|
|
# The chain is read out of GIT, not the working tree, which is what lets this
|
|
# keep working now that the revisions are deleted — `chain_ref` names a commit
|
|
# that still carries 0001..0089. That is the whole reason this is a workflow
|
|
# rather than a script someone ran once.
|
|
#
|
|
# WHAT THIS CANNOT SEE, and it matters: the comparison is of SCHEMA. Migrations
|
|
# 0002 and 0003 also INSERTED rows (the import_settings and ml_settings
|
|
# singletons), and the application reads those with scalar_one(), which raises
|
|
# on an empty result. A baseline that omitted them would produce an identical
|
|
# schema, pass this check with a perfect diff, and crash a fresh install on its
|
|
# first settings access. Only running the app against a new database finds
|
|
# that class of defect. Do not read a green run here as "the baseline is
|
|
# correct" — read it as "the schema is correct".
|
|
#
|
|
# Autogenerate now emits nearly all of the baseline unaided, which was NOT true
|
|
# before #3275 put the previously migration-only objects onto the models — the
|
|
# HNSW index with its opclass, the COALESCE expression index, the partial
|
|
# unique indexes, 107 server_defaults, the enum CHECKs. An earlier attempt at
|
|
# this squash was reverted precisely because the generator dropped them all
|
|
# silently. What still needs hand-adding is only what cannot live in a model:
|
|
# the two CREATE EXTENSION statements, the two seed rows, and the pgvector
|
|
# import the generator forgets to write.
|
|
name: Alembic baseline
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
chain_ref:
|
|
description: 'Commit/tag carrying the full 0001..0089 chain (pinned: the tree no longer has it)'
|
|
type: string
|
|
default: '725bf15'
|
|
mode:
|
|
description: 'chain = compare against this tree''s migrations; models = compare against a schema built from the MODELS'
|
|
type: string
|
|
default: 'chain'
|
|
|
|
jobs:
|
|
compare:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
env:
|
|
DB_USER: fabledcurator
|
|
DB_PASSWORD: ci_integration
|
|
DB_PORT: "5432"
|
|
DB_NAME: fabledcurator_test
|
|
SECRET_KEY: ci_integration_placeholder
|
|
services:
|
|
postgres:
|
|
image: pgvector/pgvector:pg16
|
|
env:
|
|
POSTGRES_USER: fabledcurator
|
|
POSTGRES_PASSWORD: ci_integration
|
|
POSTGRES_DB: fabledcurator_test
|
|
options: >-
|
|
--health-cmd "pg_isready -U fabledcurator"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# Full history is the point: `chain_ref` is read out of git, so a
|
|
# shallow clone would not have the revisions to compare against.
|
|
fetch-depth: 0
|
|
|
|
- name: Resolve the Postgres service and install deps
|
|
run: |
|
|
set -eux
|
|
# Same service-IP dance as ci.yml's integration job; see the long
|
|
# comment there for why the job name must stay separator-free.
|
|
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
|
test -n "$PG"
|
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
|
test -n "$PG_IP"
|
|
echo "PG_CONTAINER=$PG" >> "$GITHUB_ENV"
|
|
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
|
|
for i in $(seq 1 60); do
|
|
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
|
sleep 2
|
|
done
|
|
if command -v uv >/dev/null 2>&1; then
|
|
uv pip install --system -r requirements.txt
|
|
else
|
|
pip install -r requirements.txt
|
|
fi
|
|
|
|
# DB 1: the 87-revision chain, read out of git at `chain_ref`.
|
|
#
|
|
# A git worktree rather than a checkout, so the current tree — which is
|
|
# what we are testing — is left completely alone.
|
|
- name: Build the schema the OLD chain produces
|
|
env:
|
|
CHAIN_REF: ${{ github.event.inputs.chain_ref }}
|
|
THIS_SHA: ${{ github.sha }}
|
|
run: |
|
|
set -eux
|
|
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_chain
|
|
# Blank means "the chain in this ref", which is what you want while
|
|
# the chain is still intact — comparing the models against a PINNED
|
|
# older commit reports every migration written since as a difference.
|
|
# Pin it only after the collapse, when the tree no longer has them.
|
|
git worktree add /tmp/chain "${CHAIN_REF:-$THIS_SHA}"
|
|
ls /tmp/chain/alembic/versions/*.py | wc -l
|
|
cd /tmp/chain
|
|
DB_NAME=fc_chain alembic upgrade head
|
|
cd -
|
|
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
|
|
--no-owner --no-privileges -d fc_chain > chain.sql
|
|
wc -l chain.sql
|
|
# Emit the dump itself, checksummed, for local analysis. Reconciling
|
|
# the models against the deployed schema (#3275) needs the ACTUAL
|
|
# schema, not an inference from a diff — parsing table context out of
|
|
# unified-diff hunks drops every table whose CREATE TABLE line falls
|
|
# outside a hunk, which silently under-reports.
|
|
#
|
|
# base64 + sha256 for the same reason as the candidate: a plain cat
|
|
# of a file this size was truncated mid-line by the runner with the
|
|
# step still green (run 4964).
|
|
set +x
|
|
B64=$(base64 -w 120 chain.sql)
|
|
echo "===== BEGIN CHAIN SCHEMA (base64) ====="
|
|
echo "$B64"
|
|
echo "===== END CHAIN SCHEMA ====="
|
|
echo "chain-sha256: $(sha256sum chain.sql | cut -d' ' -f1)"
|
|
echo "chain-bytes: $(wc -c < chain.sql)"
|
|
set -x
|
|
|
|
# A candidate baseline, autogenerated from the models against an EMPTY
|
|
# database so every table shows up as a create. Printed for a human to
|
|
# finish — it will be missing the three raw-SQL items named at the top.
|
|
#
|
|
# Gated on the TREE, not on a workflow input. A `type: boolean` input
|
|
# read back as `github.event.inputs.generate == 'true'` silently
|
|
# evaluated false on this runner (run 4960 skipped this step entirely
|
|
# with no diagnostic) — the same `github.event.inputs` typing quirk
|
|
# build.yml already works around. The file count is the real question
|
|
# anyway: there is nothing to generate once the chain is collapsed.
|
|
- name: Autogenerate a candidate baseline
|
|
run: |
|
|
set -eux
|
|
if [ "$(ls alembic/versions/*.py | wc -l)" -le 1 ]; then
|
|
echo "already collapsed — nothing to generate"
|
|
exit 0
|
|
fi
|
|
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_gen
|
|
# Hide the existing revisions so alembic sees an empty history and
|
|
# emits the whole schema rather than a delta.
|
|
mkdir -p /tmp/versions_held
|
|
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
|
|
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
|
|
# Printed rather than uploaded: ci-requirements.md records that this
|
|
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
|
|
# the action entirely in 2026-05, so the job log is the retrieval
|
|
# channel actually proven here.
|
|
#
|
|
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
|
|
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
|
|
# `sa.Column('mime', sa.String(length=128)` and carried straight on
|
|
# to the next traced command, with the step still green. A silent
|
|
# cut in the middle of a schema definition is the worst possible
|
|
# failure here, because the truncated text still looks like a
|
|
# plausible file.
|
|
#
|
|
# base64 at a fixed narrow width gives many short lines instead of
|
|
# few long ones, and — the actual point — a checksum and a line
|
|
# count that make truncation DETECTABLE rather than invisible.
|
|
set +x
|
|
F=$(ls alembic/versions/*.py | head -1)
|
|
B64=$(base64 -w 120 "$F")
|
|
echo "===== BEGIN CANDIDATE BASELINE (base64) ====="
|
|
echo "$B64"
|
|
echo "===== END CANDIDATE BASELINE ====="
|
|
echo "candidate-sha256: $(sha256sum "$F" | cut -d' ' -f1)"
|
|
echo "candidate-bytes: $(wc -c < "$F")"
|
|
echo "candidate-b64-lines: $(echo "$B64" | wc -l)"
|
|
set -x
|
|
mkdir -p /tmp/candidate
|
|
cp alembic/versions/*.py /tmp/candidate/
|
|
# Put the tree back exactly as it was; this job never mutates state.
|
|
rm -f alembic/versions/*.py
|
|
mv /tmp/versions_held/*.py alembic/versions/ 2>/dev/null || true
|
|
|
|
# DB 2: what the CURRENT tree produces.
|
|
#
|
|
# `mode: models` applies the candidate autogenerated from the MODELS
|
|
# instead, which is what answers "do the models describe the schema?" —
|
|
# the question #3275 exists because nobody had ever asked it. Under that
|
|
# mode a clean diff means autogenerate is trustworthy again.
|
|
#
|
|
# The two extensions are created by hand first. They are database
|
|
# objects, not table metadata, so no model can carry them and their
|
|
# absence is not a model defect — it is simply outside what this
|
|
# comparison is asking about.
|
|
- name: Build the schema the CURRENT tree produces
|
|
env:
|
|
MODE: ${{ github.event.inputs.mode }}
|
|
run: |
|
|
set -eux
|
|
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_base
|
|
if [ "${MODE:-chain}" = "models" ]; then
|
|
docker exec "$PG_CONTAINER" psql -U fabledcurator -d fc_base \
|
|
-c "CREATE EXTENSION IF NOT EXISTS vector" \
|
|
-c "CREATE EXTENSION IF NOT EXISTS tsm_system_rows"
|
|
mkdir -p /tmp/held
|
|
mv alembic/versions/*.py /tmp/held/
|
|
cp /tmp/candidate/*.py alembic/versions/
|
|
# Autogenerate EMITS pgvector.sqlalchemy.vector.VECTOR(...) without
|
|
# importing it, so the file it writes cannot run:
|
|
# NameError: name 'pgvector' is not defined
|
|
# Observed on run 4988, which is the proof rather than the theory.
|
|
# This is a defect in the GENERATOR, not in the models, so it is
|
|
# repaired here rather than counted as a schema difference — the
|
|
# comparison is about whether the models describe the schema.
|
|
sed -i '0,/^import sqlalchemy as sa$/s//import sqlalchemy as sa\nimport pgvector.sqlalchemy.vector/' alembic/versions/*.py
|
|
grep -n 'import pgvector' alembic/versions/*.py
|
|
ls alembic/versions/*.py
|
|
DB_NAME=fc_base alembic upgrade head
|
|
rm -f alembic/versions/*.py
|
|
mv /tmp/held/*.py alembic/versions/
|
|
else
|
|
ls alembic/versions/*.py | wc -l
|
|
DB_NAME=fc_base alembic upgrade head
|
|
fi
|
|
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
|
|
--no-owner --no-privileges -d fc_base > baseline.sql
|
|
wc -l baseline.sql
|
|
|
|
# The verdict.
|
|
#
|
|
# pg_dump orders dumpable objects by name within type, not by creation
|
|
# order, so two schemas built by different routes are directly
|
|
# comparable. Normalisation is deliberately minimal, because a filter
|
|
# that hides a real difference is the one way this check passes when it
|
|
# should fail — blank lines, SQL comments, trailing whitespace, and:
|
|
#
|
|
# \restrict / \unrestrict — a per-invocation RANDOM NONCE that newer
|
|
# pg_dump emits to fence the dump against injection during restore. It
|
|
# differs on every run by construction, so it is noise by definition,
|
|
# not a schema difference. Measured on run 4960, the control: two dumps
|
|
# of the SAME schema came back 1123 lines each and differed on exactly
|
|
# these two lines and nothing else. That control is what licenses this
|
|
# filter — it was observed to be the only false positive, rather than
|
|
# assumed to be one.
|
|
# Column ORDER inside a CREATE TABLE is compared separately from column
|
|
# CONTENT, and only content is fatal.
|
|
#
|
|
# A table built by 87 migrations has its columns in ADD COLUMN order; the
|
|
# same table built in one shot has them in declaration order. That is a
|
|
# real and permanent difference which no baseline can erase — the
|
|
# operator's existing database keeps chain order forever, a fresh install
|
|
# gets model order — so a check that fails on it would never pass and
|
|
# would teach nothing. FC reaches every column through the ORM by name,
|
|
# and `SELECT *` ordering is not depended on anywhere.
|
|
#
|
|
# So the second pass SORTS the column lines within each CREATE TABLE
|
|
# rather than DELETING them. That distinction is the whole point: sorting
|
|
# cannot hide a column that exists on one side only, or one whose type,
|
|
# nullability or default differs — those still land in the diff. A filter
|
|
# could have hidden all three.
|
|
#
|
|
# Both diffs are reported. The ordered one is informational; the
|
|
# order-insensitive one is the verdict.
|
|
- name: Diff
|
|
run: |
|
|
set -eu
|
|
norm() {
|
|
grep -vE '^\s*(--|$)' "$1" \
|
|
| grep -vE '^\\(un)?restrict ' \
|
|
| sed 's/[[:space:]]*$//'
|
|
}
|
|
norm chain.sql > a.txt
|
|
norm baseline.sql > b.txt
|
|
echo "normalised: chain=$(wc -l < a.txt) lines, current=$(wc -l < b.txt) lines"
|
|
|
|
sort_table_columns() {
|
|
python3 - "$1" <<'PYEOF'
|
|
import re, sys
|
|
|
|
lines = open(sys.argv[1]).read().splitlines()
|
|
out, block = [], None
|
|
for line in lines:
|
|
if block is not None:
|
|
# ');' on its own closes the CREATE TABLE body.
|
|
if line.strip() == ");":
|
|
out.extend(sorted(block))
|
|
out.append(line)
|
|
block = None
|
|
else:
|
|
# Drop the list comma before sorting. Only the LAST
|
|
# column lacks one, so keeping it would make every
|
|
# reordering look like a content change as well — the
|
|
# comma is punctuation, and carries no schema meaning.
|
|
block.append(line.rstrip().rstrip(","))
|
|
continue
|
|
out.append(line)
|
|
if re.match(r"CREATE TABLE .*\($", line):
|
|
block = []
|
|
if block is not None: # unterminated body: emit it rather than drop it
|
|
out.extend(block)
|
|
print("\n".join(out))
|
|
PYEOF
|
|
}
|
|
sort_table_columns a.txt > a.sorted.txt
|
|
sort_table_columns b.txt > b.sorted.txt
|
|
test "$(wc -l < a.sorted.txt)" = "$(wc -l < a.txt)"
|
|
test "$(wc -l < b.sorted.txt)" = "$(wc -l < b.txt)"
|
|
|
|
if diff -u a.txt b.txt > schema.diff; then
|
|
echo "ORDERED DIFF: identical, column order included."
|
|
else
|
|
echo "ORDERED DIFF: $(grep -cE '^[+-]' schema.diff) changed lines (informational):"
|
|
cat schema.diff
|
|
fi
|
|
echo
|
|
echo "================================================================"
|
|
echo
|
|
if diff -u a.sorted.txt b.sorted.txt > sorted.diff; then
|
|
echo "SCHEMAS MATCH — every difference above is column ORDER alone."
|
|
else
|
|
echo "SCHEMAS DIFFER — $(grep -cE '^[+-]' sorted.diff) changed lines that are NOT ordering:"
|
|
cat sorted.diff
|
|
echo
|
|
echo "The baseline is wrong, not the database. Do not stamp."
|
|
exit 1
|
|
fi
|