diff --git a/.forgejo/workflows/baseline.yml b/.forgejo/workflows/baseline.yml new file mode 100644 index 0000000..da9fa9d --- /dev/null +++ b/.forgejo/workflows/baseline.yml @@ -0,0 +1,178 @@ + +# 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. +# +# `generate: true` additionally autogenerates a candidate baseline from the +# models and uploads it. That is a starting point, NOT the answer: autogenerate +# reads SQLAlchemy metadata, and three things in this schema 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' + generate: + description: 'Also autogenerate a candidate baseline from the models' + type: boolean + default: false + +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 + + # Optional: a candidate baseline, autogenerated from the models against an + # EMPTY database so every table shows up as a create. Uploaded for a human + # to finish — it will be missing the three raw-SQL items named at the top. + - name: Autogenerate a candidate baseline + if: ${{ github.event.inputs.generate == 'true' }} + run: | + set -eux + 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 in full 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; the job log is the + # retrieval channel that is actually proven here. + echo "===== BEGIN CANDIDATE BASELINE =====" + cat alembic/versions/*.py + echo "===== END CANDIDATE BASELINE =====" + # 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: whatever the CURRENT tree's alembic/versions produces. Before the + # squash that is the same 87 revisions and the diff is trivially clean — + # which is worth running once as a control, so a clean diff after the + # squash means something. + - name: Build the schema the CURRENT tree produces + run: | + set -eux + docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_base + ls alembic/versions/*.py | wc -l + DB_NAME=fc_base alembic upgrade head + 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. The only normalisation applied is dropping blank lines, + # comment lines and the alembic_version row-count noise — deliberately + # minimal, because a filter that hides a real difference is the one way + # this check passes when it should fail. Whatever is normalised is + # printed, so the filtering itself is reviewable. + - name: Diff + run: | + set -eu + norm() { + grep -vE '^\s*(--|$)' "$1" | 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" + if diff -u a.txt b.txt > schema.diff; then + echo "SCHEMAS IDENTICAL — the collapsed chain reproduces the old one." + else + echo "SCHEMAS DIFFER — $(grep -cE '^[+-]' schema.diff) changed lines:" + cat schema.diff + echo + echo "The baseline is wrong, not the database. Do not stamp." + exit 1 + fi