CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 34s
Build images / build-ml (push) Successful in 53s
Build images / build-web (push) Successful in 44s
CI / backend-lint-and-test (push) Successful in 1m6s
CI / integration (push) Successful in 4m5s
Closes the residue the first reconciliation pass left, and corrects a
factual error I put into the record.
sha256 was NOT missing a uniqueness guarantee. I read
`op.create_index("ix_image_record_sha256", ...)` at 0001 line 151 and
concluded duplicates were possible, without reading line 149 two lines
above it:
sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),
Uniqueness has held since the initial schema. The database expresses it
as a CONSTRAINT plus a separate non-unique lookup index; the model said
`unique=True, index=True`, which is one UNIQUE index under a different
name. Same guarantee, different objects — which is exactly why the two
schemas did not line up. The model now declares both objects. No DDL.
0088's docstring, which repeated the claim, is corrected in place.
Two real divergences, both the MODEL over-claiming:
* source: uq_source_artist_platform_url (alembic 0010) was declared
nowhere in the models — source.py had no __table_args__ at all — so
autogenerate would have proposed DROPPING it.
* head_metrics_snapshot.tag_id: model said NOT NULL, 0060 created it
nullable. Left nullable; the FK already cascades.
Seven constraints renamed to what the chain actually created, rather than
what base.py's naming convention renders: uq_series_page_image,
uq_series_chapter_anchor_page, fk_series_chapter_anchor_page,
fk_image_record_artist_id, fk_image_provenance_from_attachment, and the
two hand-shortened fk_tsr_* names from 0003.
Float server_defaults now mirror their own migration, per column. The
chain is MIXED: a plain string renders DEFAULT '0.90'::double precision,
sa.text() renders DEFAULT 0.90, and the migrations used both. Seven
columns take text(); the rest stay strings. Two literals also disagreed
outright — process_{auto_apply,conflict}_threshold said 0.9/0.5 against
the migration's 0.90/0.50.
baseline.yml gains two things. A repair for a SECOND generator defect in
the same class as the missing pgvector import: base.py's ck convention
contains %(constraint_name)s, so it applies even to a NAMED
CheckConstraint — autogenerate writes the already-rendered name into the
migration and running it applies the convention again, yielding
ck_ml_settings_ck_ml_settings_singleton. That is round-tripping damage,
not a claim the models make, so it is undone rather than counted.
And the diff now runs twice. Column ORDER differs permanently between a
schema built by 87 ADD COLUMNs and one built in a single shot — the
operator's database keeps chain order forever, a fresh install gets model
order — so a check that failed on it could never pass. The second pass
SORTS column lines within each CREATE TABLE instead of deleting them,
which cannot hide a column present on one side only, or one whose type,
nullability or default differs. Ordered diff is reported as information;
the order-insensitive one is the verdict.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
366 lines
17 KiB
YAML
366 lines
17 KiB
YAML
|
|
# TEMPORARY — milestone 328 steps 1-2. Delete once the baseline is stamped.
|
|
#
|
|
# Squashing 87 alembic revisions into one baseline has exactly one dangerous
|
|
# failure: the generated baseline does not reproduce the schema the chain
|
|
# produced, `alembic stamp` writes a version string anyway (it validates
|
|
# NOTHING), and the divergence surfaces on the next real migration against the
|
|
# operator's live data.
|
|
#
|
|
# So this workflow does the comparison in CI, where a pgvector Postgres already
|
|
# gets built from the chain on every integration run, and nothing is at risk.
|
|
# It answers one question: does `upgrade head` on the collapsed chain produce a
|
|
# byte-identical schema to `upgrade head` on the 87-revision chain?
|
|
#
|
|
# The chain is read from git rather than from the working tree, so this keeps
|
|
# working AFTER the old revisions are deleted — `chain_ref` names a commit that
|
|
# still has them. That is what makes this the proof for step 1 and the
|
|
# pre-flight for step 2, rather than a one-shot script.
|
|
#
|
|
# While the chain is still present it also autogenerates a candidate baseline
|
|
# from the models and prints it. That is a starting point, NOT the answer:
|
|
# autogenerate reads SQLAlchemy metadata, and three things here do not live
|
|
# there —
|
|
# * CREATE EXTENSION vector (0001)
|
|
# * CREATE EXTENSION tsm_system_rows (0004)
|
|
# * the HNSW index on image_record.siglip_embedding, which is raw SQL
|
|
# because alembic's create_index cannot express `USING hnsw (...)` (0036)
|
|
# plus any CHECK constraint or server_default that a migration added without
|
|
# the model declaring it. Those must be hand-added, and the diff below is what
|
|
# proves none were missed.
|
|
name: Alembic baseline
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
chain_ref:
|
|
description: 'Commit/tag that still carries the full 0001..0087 chain'
|
|
type: string
|
|
default: '0a5bbe8'
|
|
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 }}
|
|
run: |
|
|
set -eux
|
|
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_chain
|
|
git worktree add /tmp/chain "$CHAIN_REF"
|
|
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
|
|
# Second generator defect, same class as the missing import.
|
|
#
|
|
# base.py's naming convention includes %(constraint_name)s for ck,
|
|
# which — unlike uq/fk/ix — means the convention is applied even to
|
|
# a CheckConstraint that HAS a name. So a model declaring
|
|
# name="singleton" correctly becomes ck_ml_settings_singleton in
|
|
# the metadata. Autogenerate then writes that RENDERED name into
|
|
# the migration, and running the migration applies the convention a
|
|
# SECOND time: ck_ml_settings_ck_ml_settings_singleton.
|
|
#
|
|
# That is round-tripping damage done by the generator, not a claim
|
|
# the models make, so it is repaired here rather than counted as a
|
|
# schema difference. Undone by removing the ck_<table>_ prefix the
|
|
# convention will re-add — the exact inverse, and it only fires on
|
|
# a name that actually carries its own table's prefix.
|
|
python3 - alembic/versions/*.py <<'PYEOF'
|
|
import re, sys
|
|
|
|
table = None
|
|
for path in sys.argv[1:]:
|
|
out = []
|
|
for line in open(path):
|
|
m = re.search(r"op\.create_table\(\s*[\"']([A-Za-z0-9_]+)[\"']", line)
|
|
if m:
|
|
table = m.group(1)
|
|
if table and "CheckConstraint" in line:
|
|
prefix = f"ck_{table}_"
|
|
line = re.sub(
|
|
r"(name=[\"'])" + re.escape(prefix),
|
|
r"\1",
|
|
line,
|
|
)
|
|
out.append(line)
|
|
open(path, "w").writelines(out)
|
|
PYEOF
|
|
grep -n 'CheckConstraint' alembic/versions/*.py || true
|
|
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
|