CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / frontend-build (push) Successful in 24s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 2m17s
build.yml and baseline.yml both explained a missing artifact step by saying act_runner cannot run actions/upload-artifact@v4+ (and baseline.yml said ci-requirements.md records that, which it does not). The runner is now gitea/runner 3.x, which edits the action's GHES refusal out of its bundle, and stock v4+ is proven working on this forge (Scribe spike #3843). Comments only. Neither lane gains an upload step: build-web still reads the signed XPI from the release asset, and the baseline candidate is still printed to the log, because those remain the better channels. The comments now say why for the right reason. Scribe snippet #2271, milestone 395. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
351 lines
17 KiB
YAML
351 lines
17 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"
|
|
# Socket probe in python, not bash's /dev/tcp — these steps run under
|
|
# `sh -e`, where that path does not exist. Same fix and same reasoning
|
|
# as ci.yml's integration job; see the comment there.
|
|
pg_ready=""
|
|
for i in $(seq 1 60); do
|
|
if python -c "import socket,sys; s=socket.socket(); s.settimeout(2); sys.exit(0 if s.connect_ex(('$PG_IP', 5432)) == 0 else 1)"; then
|
|
pg_ready=1
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
if [ -z "$pg_ready" ]; then
|
|
echo "postgres at $PG_IP:5432 did not accept a connection within 120s"
|
|
exit 1
|
|
fi
|
|
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: the repo dropped actions/upload-artifact
|
|
# in 2026-05, when the runner could not run v4+, and the job log is the
|
|
# retrieval channel this job has proven. (gitea/runner 3.x runs stock
|
|
# upload-artifact now — Scribe snippet #2271 — so an artifact is an
|
|
# option if the log ever stops being enough.)
|
|
#
|
|
# 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
|