CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 49s
CI / integration (push) Successful in 1m57s
extension / lint (pull_request) Successful in 22s
Two unrelated things, both found while closing out milestone 328. **Folder import (#3367).** The operator's call, this session: the import-from-file surface was abandoned on purpose and is not coming back — "it has its own complexities that we didn't need." The README and the compose comment both described the missing button as a rough edge with a tracking issue, which promised a fix that is not coming. Both now say the retirement is the decision, name Subscriptions as the supported way to fill a new install, and describe /api/import/trigger as an unsupported escape hatch for anyone who wants to script one. **The CI readiness probe.** ci.yml's integration job and baseline.yml both waited for Postgres with `(echo > /dev/tcp/$PG_IP/5432)`. Those steps run under `sh -e` — act's default shell — where /dev/tcp is not a magic path but a filename that does not exist. The probe could therefore never succeed: run 18035, a GREEN run, spends 05:20:53 → 05:22:53 in that loop and exits it by exhaustion, not by connecting. Every integration run has been paying a flat 120s for a check that established nothing, and proceeding regardless. Replaced with a socket connect in python (present in the image, no package needed), and exhausting the budget is now a named failure instead of a silent fall-through — rule 156. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
350 lines
17 KiB
YAML
350 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: 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
|