From 8ee5019191d4a5113f41df16236c56892aa11b94 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 4 Aug 2026 20:21:53 -0500 Subject: [PATCH 1/3] ci: wire up extension-update-test (0.1.0 -> stable) via bin/test_existing Adds the committed install->guard->update->assert->run-suite script (bin/test_existing, modeled on cat_tools's bin/test_existing) plus a generic per-extension structural-diff tool (bin/structural_diff[.sql], copied near-verbatim from cat_tools -- it's already written generically off pg_depend's deptype='e' membership edge) and a new CI job that exercises the 0.1.0->stable update path end to end: install 0.1.0, plant + prove the dependency guard, ALTER EXTENSION UPDATE, structurally compare against a fresh "stable" install, then run the full suite in existing mode. No binary pg_upgrade job is added: object_reference has no view/function that SELECTs * over a system catalog in either its current or 0.1.0 install script (checked directly), so the cross-PostgreSQL-major risk that job protects against is low here. Left as noted future work rather than built preemptively -- see the ci.yml "Test strategy" comment and the PR description. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 90 ++++++++++- bin/structural_diff | 85 +++++++++++ bin/structural_diff.sql | 179 ++++++++++++++++++++++ bin/test_existing | 314 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 667 insertions(+), 1 deletion(-) create mode 100755 bin/structural_diff create mode 100644 bin/structural_diff.sql create mode 100755 bin/test_existing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61f5d8f..1993a03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,55 @@ +# =========================================================================== +# Test strategy +# +# An object_reference install can be arrived at more than one way, each of +# which can break differently, so each is exercised by its own job below: +# +# - `lint`: the cheapest possible check (no database, no container beyond +# a plain checkout, seconds to run) -- gates everything else so a broken +# style baseline never ties up runner slots on the heavier jobs below. +# +# - `changes`: cheap docs-only gate, PLUS the single source of truth for +# the supported-PostgreSQL-major list every other job's matrix derives +# from (see its own "Derive ..." step). +# +# - `test`: FRESH install (CREATE EXTENSION at the current "stable" +# version) via `make test`, on every supported PostgreSQL major. The +# baseline a brand-new user gets. +# +# - `extension-update-test`: UPDATE TO CURRENT. Installs the one real +# historical PGXN release (0.1.0), ALTER EXTENSION UPDATEs it to +# "stable" via bin/test_existing's update-scenario, structurally +# compares the result against a fresh "stable" install +# (bin/structural_diff -- so a divergent function/view body produced +# only by the update path, and never by a fresh install, cannot slip +# through silently), then runs the full pgTAP suite against that real +# updated database in `existing` mode (TEST_LOAD_SOURCE=existing, +# --use-existing). A planted dependency guard (a view hard-referencing +# _object_reference.object's row type) blocks a stray non-CASCADE DROP +# EXTENSION throughout, and is re-proved present after every step -- see +# bin/test_existing's own header for the full rationale. Runs on a +# SINGLE PostgreSQL major (the newest supported), not the full matrix: +# 0.1.0's install script has no identified PostgreSQL-version floor (no +# SELECT * over a system catalog, no ALTER TYPE ... ADD VALUE), so +# crossing this axis against every major would just multiply job count +# for no added coverage. +# +# - No binary pg_upgrade job (the cat_tools reference this effort is +# modeled on has `pg-upgrade-test` / `pg-upgrade-stepwise`) exists yet. +# Deliberate, not an oversight: that job exists to catch a view/function +# that breaks across a PostgreSQL major specifically because it touches +# catalog internals (SELECT * over a system catalog whose columns get +# added/exposed/removed between majors). object_reference has no such +# construct in either its current or 0.1.0 install script (checked +# directly -- no view or function selects * from a system catalog; every +# object_reference table/view is an ordinary user object), so the risk +# that job protects against is correspondingly low here. Left as +# explicitly-noted future work rather than built preemptively; revisit +# if/when object_reference grows a catalog-touching view or function. +# +# - `all-checks-passed`: single stable required-status-check name; see its +# own comment below. +# =========================================================================== name: CI on: push: @@ -145,6 +197,42 @@ jobs: - name: Test on PostgreSQL ${{ matrix.pg }} run: make test + # Extension UPDATE path: install the one real historical PGXN release + # (0.1.0), ALTER EXTENSION UPDATE to the current version ("stable"), and + # run the full suite against the real updated database in `existing` mode + # -- see the "Test strategy" comment at the top of this file and + # bin/test_existing's own header for the full flow and dependency-guard + # rationale. + extension-update-test: + # Gated behind lint+test (not just changes): this job installs a second, + # older extension version and runs the update path, which is wasted + # effort against a baseline that's already broken by a style violation + # or a failing fresh-install test. success() must be written explicitly + # -- GitHub only assumes success() as a job's default when it has no + # if: at all. + needs: [changes, lint, test] + if: success() && needs.changes.outputs.docs_only != 'true' + name: โฌ†๏ธ Extension update test (0.1.0 โ†’ stable) + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + steps: + # A single PostgreSQL major (the newest supported, from the `changes` + # job's single source of truth) -- see the Test strategy comment at + # the top of this file for why this isn't crossed against the full PG + # matrix. + - name: Start PostgreSQL ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }} + run: pg-start ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }} + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install object_reference + its 0.1.0-only test dependency (count_nulls) + # TEST_LOAD_SOURCE=update activates the Makefile's conditional + # `install: count_nulls` prerequisite (see the Makefile's own + # comment on it): 0.1.0's install script needs count_nulls even + # though current object_reference.control no longer declares it. + run: make install TEST_LOAD_SOURCE=update + - name: Update 0.1.0 -> stable, structurally compare, run the suite (existing mode) + run: bin/test_existing update-scenario object_reference_update 0.1.0 + # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "๐Ÿ˜ PostgreSQL 14" # which would all need to be listed individually and updated whenever the @@ -152,7 +240,7 @@ jobs: # (e.g. test, on a docs-only push), and fails if any failed or were # cancelled. all-checks-passed: - needs: [changes, lint, test] + needs: [changes, lint, test, extension-update-test] if: always() runs-on: ubuntu-latest steps: diff --git a/bin/structural_diff b/bin/structural_diff new file mode 100755 index 0000000..fc3eef1 --- /dev/null +++ b/bin/structural_diff @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Structurally compare, object-by-object, every member of the +# object_reference extension in two databases: function bodies +# (pg_get_functiondef), view definitions (pg_get_viewdef), table/composite +# column lists, comments, and ACLs. A nonempty diff is a bug -- the whole +# point of an extension UPDATE script is that it reaches the SAME objects a +# fresh install of the target version would. +# +# Modeled on cat_tools's bin/structural_diff (Postgres-Extensions/cat_tools), +# which generalized a manual comparison that found a real fresh-vs-update +# divergence in cat_tools#46. bin/structural_diff.sql here is a near-verbatim +# copy of that file -- it is already written generically (parameterized on +# :extname, driven entirely off pg_depend's deptype='e' membership edge, with +# no cat_tools-specific object names), so it applies to any extension as-is. +# +# USAGE: bin/structural_diff [args] +# +# dump DB [EXTNAME] +# Print the signature of every EXTNAME member object in DB (EXTNAME +# defaults to object_reference). Useful on its own for eyeballing one +# database's structure, and it's what `compare` diffs under the hood. +# +# compare DB1 DB2 [EXTNAME] +# Dump both databases and diff them. Prints a unified diff and exits +# non-zero if they differ; exits 0 (and prints an OK line) if +# identical. +# +# See bin/structural_diff.sql for the query that defines "signature" (and how +# it decides which object kinds get a real structural definition vs. falling +# back to just identity/comment/ACL). +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd) + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +dump() { + local db=$1 extname=${2:-object_reference} + psql -d "$db" -v extname="'$extname'" -f "$SCRIPT_DIR/structural_diff.sql" +} + +compare() { + local db1=$1 db2=$2 extname=${3:-object_reference} + # Run in a subshell so the EXIT trap (temp-file cleanup) is scoped to this + # comparison only. A trap set with plain `trap ... RETURN` is NOT scoped to + # the function that set it -- it re-fires on every later function return in + # the same shell, including main()'s, by which point f1/f2 no longer exist. + ( + f1=$(mktemp) + f2=$(mktemp) + trap 'rm -f "$f1" "$f2"' EXIT + dump "$db1" "$extname" > "$f1" + dump "$db2" "$extname" > "$f2" + if diff -u --label "$db1" --label "$db2" "$f1" "$f2"; then + echo "OK: '$db1' and '$db2' are structurally identical for extension '$extname'" + else + echo "FAIL: structural diff between '$db1' and '$db2' for extension '$extname' (see diff above) -- an update path reached objects that differ from a fresh install" >&2 + exit 1 + fi + ) +} + +usage() { + echo "usage: bin/structural_diff [args]" >&2 + echo " dump DB [EXTNAME]" >&2 + echo " compare DB1 DB2 [EXTNAME]" >&2 + exit 2 +} + +# Explicit subcommand dispatch on $1, matching bin/test_existing's +# convention. +main() { + local cmd=${1:-} + shift || true + case "$cmd" in + dump) dump "$@" ;; + compare) compare "$@" ;; + *) usage ;; + esac +} + +main "$@" diff --git a/bin/structural_diff.sql b/bin/structural_diff.sql new file mode 100644 index 0000000..a68ebce --- /dev/null +++ b/bin/structural_diff.sql @@ -0,0 +1,179 @@ +/* + * Structural signature dump for every object that belongs to an extension + * (pg_depend deptype = 'e'), used by bin/structural_diff to compare a + * database reached via an extension UPDATE against a FRESH install of the + * same target version. Any nonempty diff between two runs of this query is a + * bug: the two paths are supposed to produce byte-identical objects. + * + * Run via: psql -d DBNAME -v extname="'object_reference'" -f bin/structural_diff.sql + * + * Modeled on cat_tools's bin/structural_diff.sql (Postgres-Extensions/ + * cat_tools), which generalized a manual comparison technique (diffing + * pg_get_functiondef / pg_get_viewdef / type labels / comments / ACLs / + * extension membership between a fresh install and an updated database) used + * to find a real fresh-vs-update divergence in that extension. This file is + * copied near-verbatim -- it is written generically off pg_depend's deptype + * = 'e' membership edge, with no cat_tools-specific object names, so it + * applies to object_reference (and any other extension) as-is; only the + * default :extname in bin/structural_diff's wrapper differs. + * + * Emits one text block per member object, ordered by its pg_describe_object() + * identity so the SAME object sorts to the SAME position regardless of the + * OIDs assigned along each installation path. Each block covers: + * - a structural definition, using pg_get_functiondef/pg_get_viewdef for + * routines/views, an ordered column dump for a plain table or standalone + * composite type, an ordered label list for enums, or a cast/domain + * summary -- whichever a member's catalog/kind actually calls for. + * object_reference currently has no enums, domains or casts of its own; + * those branches are kept anyway (harmless no-ops today) so a future + * member of one of those kinds is compared structurally too, without + * needing to remember to add it. Row types implicitly created BY a + * member relation, and the array type shadowing any other member type, + * are skipped: their structure is fully captured by the relation/base- + * type entry already, so listing them again would just duplicate that + * comparison under a second identity. + * - its comment (pg_description), generically via obj_description(). + * - its ACL, generically via whichever ACL column its catalog has (proacl / + * typacl / relacl / nspacl); sorted, since grant order is not meaningful. + * + * This is deliberately NOT specific to object_reference's current object + * list: any object kind this extension does not (yet) use falls through to + * the ELSE branch below, which still includes it (via its + * pg_describe_object identity, comment and ACL) so a future new member is + * compared at least at that level rather than silently skipped, even though + * this file does not (yet) know how to render a structural definition for + * it. + */ +\set ON_ERROR_STOP on +\pset format unaligned +\pset tuples_only on +\pset fieldsep '' + +WITH ext AS ( + SELECT oid FROM pg_extension WHERE extname = :extname +), members AS ( + SELECT d.classid, d.objid + FROM pg_depend d, ext + WHERE d.refclassid = 'pg_extension'::regclass + AND d.refobjid = ext.oid + AND d.deptype = 'e' +), skip_shadow AS ( + /* Implicit row type of a member relation: same structure as the relation + * itself, so comparing it too would just duplicate that check. */ + SELECT t.oid + FROM pg_type t + JOIN members rel ON rel.classid = 'pg_class'::regclass AND rel.objid = t.typrelid + WHERE t.typtype = 'c' + UNION + /* Array type shadowing another member type: same element type, no + * independent structure of its own. */ + SELECT t.oid + FROM pg_type t + JOIN members base ON base.classid = 'pg_type'::regclass AND base.objid = t.typelem + WHERE t.typelem <> 0 +), acl AS ( + SELECT m.classid, m.objid, + ( + SELECT array_to_string(array_agg(a::text ORDER BY a::text), ',') + FROM unnest( + CASE m.classid + WHEN 'pg_proc'::regclass THEN (SELECT proacl FROM pg_proc WHERE oid = m.objid) + WHEN 'pg_type'::regclass THEN (SELECT typacl FROM pg_type WHERE oid = m.objid) + WHEN 'pg_class'::regclass THEN (SELECT relacl FROM pg_class WHERE oid = m.objid) + WHEN 'pg_namespace'::regclass THEN (SELECT nspacl FROM pg_namespace WHERE oid = m.objid) + ELSE NULL + END + ) a + ) AS acl_text + FROM members m +), relation_cols AS ( + /* Ordered column dump, shared by the plain-table case (pg_class relkind + * 'r') and the standalone-composite-type case (pg_type typtype 'c' whose + * typrelid is NOT a member relation, i.e. survived skip_shadow) -- both + * describe a set of (name, type, not-null, default) columns identically. */ + SELECT m.classid, m.objid, + ( + SELECT string_agg( + format( + '%s %s%s%s' + , a.attname + , format_type(a.atttypid, a.atttypmod) + , CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END + , COALESCE(' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid), '') + ) + , E'\n' ORDER BY a.attnum + ) + FROM pg_attribute a + LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum + WHERE a.attrelid = CASE m.classid + WHEN 'pg_class'::regclass THEN m.objid + WHEN 'pg_type'::regclass THEN (SELECT typrelid FROM pg_type WHERE oid = m.objid) + END + AND a.attnum > 0 + AND NOT a.attisdropped + ) + /* Table constraints (PK/UNIQUE/CHECK/FK) have no equivalent on a + * standalone composite type, so this is NULL there and simply appends + * nothing. */ + || COALESCE( + E'\n' || ( + SELECT string_agg(pg_get_constraintdef(c.oid), E'\n' ORDER BY c.conname) + FROM pg_constraint c + WHERE m.classid = 'pg_class'::regclass AND c.conrelid = m.objid + ) + , '' + ) AS cols + FROM members m + WHERE (m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r') + OR (m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c') +) +SELECT + '=== ' || pg_describe_object(m.classid, m.objid, 0) || E' ===\n' + || 'DEFINITION:' || E'\n' || COALESCE( + CASE + WHEN m.classid = 'pg_proc'::regclass + THEN pg_get_functiondef(m.objid) + WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) IN ('v', 'm') + THEN pg_get_viewdef(m.objid, true) + WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r' + THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'e' + THEN (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c' + THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid) + WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'd' + THEN ( + SELECT format( + 'base=%s notnull=%s default=%s check=%s' + , t.typbasetype::regtype, t.typnotnull, t.typdefault + , (SELECT string_agg(pg_get_constraintdef(c.oid), ' AND ' ORDER BY c.oid) + FROM pg_constraint c WHERE c.contypid = m.objid) + ) + FROM pg_type t WHERE t.oid = m.objid + ) + WHEN m.classid = 'pg_cast'::regclass + THEN ( + SELECT format( + 'CAST (%s AS %s) METHOD %s CONTEXT %s' + , ct.castsource::regtype, ct.casttarget::regtype + , CASE ct.castmethod + WHEN 'f' THEN 'FUNCTION ' || ct.castfunc::regprocedure::text + WHEN 'i' THEN 'INOUT' + WHEN 'b' THEN 'BINARY COERCION' + END + , ct.castcontext + ) + FROM pg_cast ct WHERE ct.oid = m.objid + ) + ELSE NULL + END + , '(no structural definition rendered for this object kind -- see identity/comment/ACL below)' + ) + || E'\n' || 'COMMENT: ' || COALESCE(obj_description(m.objid, m.classid::regclass::text), '(none)') + || E'\n' || 'ACL: ' || COALESCE((SELECT acl_text FROM acl WHERE acl.classid = m.classid AND acl.objid = m.objid), '(none)') + || E'\n' + AS block + FROM members m + LEFT JOIN skip_shadow s ON m.classid = 'pg_type'::regclass AND s.oid = m.objid + WHERE s.oid IS NULL + ORDER BY pg_describe_object(m.classid, m.objid, 0); diff --git a/bin/test_existing b/bin/test_existing new file mode 100755 index 0000000..019872e --- /dev/null +++ b/bin/test_existing @@ -0,0 +1,314 @@ +#!/usr/bin/env bash +# +# Modeled on cat_tools's bin/test_existing (Postgres-Extensions/cat_tools) -- +# exercises the object_reference test suite against a REAL database whose +# extension was installed/updated OUTSIDE the suite ("existing" mode). The +# extension-update-test CI job repeats the same sequence: +# +# install -> plant dependency guard -> update -> assert version +# -> run the suite in existing mode +# +# so it lives here once instead of duplicated inline YAML. Not CI-only: a +# developer can run any subcommand locally against a scratch database. +# +# USAGE: bin/test_existing [args] +# +# plant-guard DB +# Plant + prove the dependency guard (extension must already be +# installed in DB). +# +# update DB [TO_VERSION] +# ALTER EXTENSION object_reference UPDATE [TO 'TO_VERSION'] (empty => +# the current default_version, "stable"). +# +# run-suite DB +# Run the suite in existing mode (extension must be at the current +# version). +# +# update-scenario DB FROM_VERSION +# Create DB + extension at FROM_VERSION (bringing in count_nulls, the +# 0.1.0-only dependency object_reference.control no longer declares), +# plant + prove the guard, update to the current version, structurally +# compare the result against a fresh install of the current version +# (see diff-fresh), then run the suite against that real updated +# database. +# +# diff-fresh DB VERSION +# Structurally compare DB's object_reference objects against a +# throwaway fresh install of VERSION (function/view/table definitions, +# comments, ACLs -- see bin/structural_diff.sql). Fails loudly on any +# nonempty diff. update-scenario calls this automatically; also useful +# standalone against any already-populated database. +# +# object_reference has only ONE real historical PGXN release (0.1.0) and only +# ONE update script (0.1.0--stable.sql, a direct hop with no already-tagged +# intermediate landing version), so cat_tools's prepare-old / update-check / +# update-check-version subcommands don't apply here: +# - prepare-old exists to bridge an old, pg_upgrade-unsafe version to a +# safe one before a binary pg_upgrade leg. No pg_upgrade CI job exists yet +# for object_reference (see the containing PR description for that +# decision), and 0.1.0 has no identified pg_upgrade-unsafe construct (no +# SELECT * over a system catalog in any view -- checked directly), so +# there is nothing to bridge. +# - update-check / update-check-version exist to assert an update script +# applying to an ALREADY-TAGGED intermediate version, where full +# fresh-vs-update parity is known to be permanently unattainable (the +# frozen version-specific file can never be edited to match). 0.1.0 only +# ever updates straight to "stable", the still-unpublished current +# version -- exactly the case diff-fresh/update-scenario already cover +# with a REAL parity guarantee (not a permanently-failing-by-design one). +# +# Why the dependency guard: "existing" mode must run the suite against the +# ACTUAL updated objects. If anything silently dropped + reinstalled the +# extension, the suite would test a FRESH install and hide a regression. As +# belt-and-suspenders to test/install/load.sql's guarantee (existing mode +# never drops the extension), we plant an object that HARD-references an +# object_reference member so a non-CASCADE DROP EXTENSION fails, and we +# actively PROVE that here (see plant_guard): if the drop unexpectedly +# succeeds, this script fails CI. +# +# See test/install/load.sql's own "existing mode" comment for the guard +# anchor rationale (_object_reference.object's row type). +set -euo pipefail + +# Run from the repository root (where `make` works and test paths resolve), +# regardless of the caller's cwd. bin/ sits directly under the repo root, so +# its parent is the root. readlink -f resolves any path the script was +# invoked through. +cd "$(dirname "$(readlink -f "$0")")/.." + +# A view whose output column has object_reference's own core row type creates +# a pg_depend edge to that extension member, so a non-CASCADE DROP EXTENSION +# cannot succeed. _object_reference.object is never dropped/redefined by the +# 0.1.0--stable update script, so the guard survives an update to the current +# version. +GUARD_SCHEMA=object_reference_drop_guard +GUARD_VIEW=guard + +# --------------------------------------------------------------------------- +# psql helpers +# --------------------------------------------------------------------------- + +# Capture the single-value (-tAc) output of a query against a database. Used +# for the many "read one value back out" calls below so the psql flags live +# in one place. +psql_value() { + local db=$1 sql=$2 + psql -d "$db" -tAc "$sql" +} + +# Run SQL that must succeed, aborting the whole script on any error +# (ON_ERROR_STOP). Extra args pass through to psql, so callers use either +# `psql_do DB -c '...'` or a heredoc (`psql_do DB <stable update script's own header) built + # from control.mk's EXTENSION_object_reference_VERSION variable. That is + # NOT the same thing as PGXNVERSION (meta.mk): PGXNVERSION is frozen at + # 0.1.0, the last real numbered PGXN release used for git tagging/dist, and + # does not track the current build's default_version at all once "stable" + # is in play. print-EXTENSION_object_reference_VERSION -- not + # print-PGXNVERSION -- is the correct dynamic derivation for "the version a + # fresh/updated install should currently land on" in this repo. + make -s print-EXTENSION_object_reference_VERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p' +} + +installed_version() { + psql_value "$1" \ + "SELECT extversion FROM pg_extension WHERE extname = 'object_reference'" +} + +guard_present() { + test "$(psql_value "$1" \ + "SELECT count(*) FROM pg_views WHERE schemaname = '$GUARD_SCHEMA' AND viewname = '$GUARD_VIEW'")" = 1 +} + +# Plant the guard and PROVE it blocks a non-CASCADE drop. Call right after +# CREATE EXTENSION (and before any update) so it persists through it. +plant_guard() { + local db=$1 + psql_do "$db" </dev/null 2>&1; then + echo "FAIL: DROP EXTENSION object_reference (non-CASCADE) unexpectedly SUCCEEDED in '$db' -- dependency guard is ineffective" >&2 + exit 1 + fi + guard_present "$db" \ + || { echo "FAIL: dependency guard missing from '$db' after the drop attempt" >&2; exit 1; } + test -n "$(installed_version "$db")" \ + || { echo "FAIL: object_reference extension missing from '$db' after the drop attempt" >&2; exit 1; } + echo "OK: non-CASCADE DROP EXTENSION is blocked in '$db' (dependency guard effective)" +} + +assert_version() { + local db=$1 expected=$2 installed + [ "$expected" = current ] && expected=$(current_version) + installed=$(installed_version "$db") + echo "version check '$db': installed='$installed' expected='$expected'" + if [ -z "$installed" ] || [ -z "$expected" ] || [ "$installed" != "$expected" ]; then + echo "FAIL: object_reference in '$db' is '$installed', expected '$expected'" >&2 + exit 1 + fi +} + +update_ext() { + local db=$1 to=${2:-} + # Prepend "TO " only when a target version is given, so a single statement + # covers both cases (empty $to => bare "ALTER EXTENSION ... UPDATE" to the + # current default_version). Use `if`, not `&&`: a false test under `set -e` + # would abort the script. + if [ -n "$to" ]; then to="TO '$to'"; fi + psql_do "$db" -c "ALTER EXTENSION object_reference UPDATE $to" +} + +# Structurally compare DB's object_reference objects (function/view/table +# definitions, comments, ACLs -- see bin/structural_diff.sql) against a FRESH +# install of VERSION. An update script only really "supports" a version if +# updating to it reaches the SAME objects a fresh install would -- see +# bin/structural_diff's header for the cat_tools bug this general technique +# was built to catch (a divergent function body that only an update path, +# never a fresh install, ever produced). +# +# Runs in a subshell so the EXIT trap dropping the throwaway reference +# database is scoped to THIS call, not the whole script: a plain +# `trap ... RETURN` re-fires on every later function return in the same +# shell (not just this one), and by the time some LATER function in this +# script returns, $fresh_db would be stale/wrong. A `RETURN` trap also does +# not fire at all under `set -e` if a later command in the *enclosing* +# function fails -- only `EXIT` is guaranteed to run on every path out of the +# process, including an errexit-triggered abort. See the containing PR +# description for how this was verified (deliberately forcing a failure and +# confirming the scratch database was still dropped). +assert_matches_fresh() { + local db=$1 version=$2 + # Separate statement: a self-referencing `local a=1 b=$a` is bash-version- + # dependent on whether $a is visible yet while computing b's value. + local fresh_db="${db}__fresh_ref" + ( + # Double-quoted so $fresh_db expands NOW, baking the actual database name + # into the trap as a literal -- the trap then no longer references the + # variable at all, so it is unaffected by anything that happens to it + # afterward. Also deliberately NOT `local fresh_db` above: an EXIT trap is + # process-global and only runs once the (sub)shell truly exits, so a + # `local` variable that had already gone out of scope by then would make + # the trap fail with "unbound variable" under `set -u`. Baking the value + # in at registration time sidesteps that entirely. + trap "dropdb --if-exists '$fresh_db'" EXIT + createdb "$fresh_db" + psql_do "$fresh_db" -c "CREATE EXTENSION object_reference VERSION '$version' CASCADE" + bin/structural_diff compare "$db" "$fresh_db" + ) +} + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +# update-scenario DB FROM_VERSION +# Full extension-update flow that runs the existing-mode suite: create the +# DB and extension at FROM_VERSION, plant + prove the guard, update to the +# current version, then run the suite against that real updated database. +update_scenario() { + local db=$1 from=$2 + createdb "$db" + # 0.1.0's own install script creates a trigger that calls count_nulls' + # not_null_count_trigger() -- a dependency object_reference.control no + # longer declares in `requires` now that the reg* pseudotype removal made + # it unnecessary, so the CASCADE below will NOT bring count_nulls in + # automatically. Install it explicitly first, matching + # test/install/load.sql's own update-mode handling; the caller is + # responsible for having `make install`ed it onto disk first (the + # Makefile's conditional `install: count_nulls` prerequisite, + # TEST_LOAD_SOURCE=update only, handles that -- see the CI job). + psql_do "$db" -c "CREATE EXTENSION IF NOT EXISTS count_nulls" + psql_do "$db" -c "CREATE EXTENSION object_reference VERSION '$from' CASCADE" + plant_guard "$db" + update_ext "$db" + assert_matches_fresh "$db" "$(current_version)" + run_suite "$db" +} + +# Run the pgTAP suite against an already-populated database in existing mode. +# Verifies the extension is at the current version, re-proves the guard +# still blocks a drop (i.e. it survived the update), runs the suite via +# --use-existing so pg_regress does NOT drop/recreate the database, then +# confirms the guard is still present (a CASCADE drop+reinstall would have +# removed it). +run_suite() { + local db=$1 + assert_version "$db" current + assert_drop_blocked "$db" + # In existing mode pg_regress runs against $db via --use-existing and must + # NOT create/drop its own database. Two consequences drive the make args + # below (see the advanced-extension-testing doc's ยง5): + # 1. PGXNTOOL_ENABLE_TEST_BUILD=no: base.mk auto-enables the test-build + # sanity check (test/build/*.sql exist in this repo) as a `test` + # prerequisite. test-build spawns a recursive `installcheck` that + # INHERITS this call's --use-existing (a command-line var propagates + # to sub-makes) but targets a fresh `regression` DB it cannot create + # under --use-existing, so it dies with "database regression does not + # exist". test-build is a fresh-install check already run by the + # `test` job on every supported PostgreSQL major, so it adds nothing + # here -- disable it. + # 2. verify-results depends on `test`, so it re-runs the suite; it must + # carry the SAME existing-mode overrides or it would re-run FRESH + # (against a new throwaway regression DB) instead of verifying THIS + # existing database. + local existing_args=(TEST_LOAD_SOURCE=existing "CONTRIB_TESTDB=$db" EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no) + make verify-results "${existing_args[@]}" + # Post-suite guard assertion: a CASCADE drop+reinstall during the run would + # have removed the guard view, meaning the suite tested a fresh install, + # not $db. + guard_present "$db" \ + || { echo "FAIL: dependency guard vanished during the suite run on '$db' -- extension was dropped+reinstalled (CASCADE)?" >&2; exit 1; } +} + +usage() { + echo "usage: bin/test_existing [args]" >&2 + echo " plant-guard DB" >&2 + echo " update DB [TO_VERSION]" >&2 + echo " run-suite DB" >&2 + echo " update-scenario DB FROM_VERSION" >&2 + echo " diff-fresh DB VERSION" >&2 + exit 2 +} + +# Explicit subcommand dispatch on $1. Defined first for readability; INVOKED +# at the very bottom, after every helper it calls is defined (bash resolves +# calls at runtime, so main() appearing first is fine). +main() { + local cmd=${1:-} + shift || true + case "$cmd" in + plant-guard) plant_guard "$@" ;; + update) update_ext "$@" ;; + run-suite) run_suite "$@" ;; + update-scenario) update_scenario "$@" ;; + diff-fresh) assert_matches_fresh "$@" ;; + *) usage ;; + esac +} + +main "$@" From dec71b51ae9c6bfebdfe860b9afc53131c03acae Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Wed, 5 Aug 2026 16:43:40 -0500 Subject: [PATCH 2/3] =?UTF-8?q?ci:=20add=20pg-upgrade-stepwise=20(=C2=A76c?= =?UTF-8?q?-bis)=20--=20binary=20pg=5Fupgrade=20climb=2012=E2=86=9218?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the binary pg_upgrade coverage this PR previously deferred: ONE cluster starting at the floor PostgreSQL major with 0.1.0 installed, updated straight to the current version, then climbing every later supported major in sequence via a real binary pg_upgrade, running the full suite (existing mode) and re-proving the dependency guard after every step. Per review of advanced-extension-testing.md's guidance: "unlikely to catch anything today" (no SELECT * over a system catalog found in object_reference's views/functions) is a weaker, non-self-correcting reason to skip a cheap job than a genuine cost argument -- an extension can grow catalog-touching code later without anyone revisiting a stale "skip, it's simple" decision. - bin/test_existing: add `prepare-old DB [INSTALL_VERSION]`, refactored out of update-scenario's existing create+guard logic. Simpler than cat_tools's own (no BRIDGE_TO parameter) since 0.1.0 has no identified pg_upgrade-unsafe construct to bridge away from. - .github/workflows/ci.yml: - `changes` job now also derives `climb_pg`, an ascending PG-major list from the same NEWEST/CURRENT_FLOOR constants the `test` job's matrix already uses -- no separate LEGACY_FLOOR, since 0.1.0 installs cleanly across the whole supported range. - New `pg-upgrade-stepwise` job, gated behind lint+test like extension-update-test. - `all-checks-passed` needs updated to include it. - Top-of-file "Test strategy" comment updated: pg_upgrade coverage is no longer deferred. make lint clean. --- .github/workflows/ci.yml | 182 ++++++++++++++++++++++++++++++++++----- bin/test_existing | 66 +++++++++----- 2 files changed, 206 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1993a03..47f430d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,18 +34,28 @@ # crossing this axis against every major would just multiply job count # for no added coverage. # -# - No binary pg_upgrade job (the cat_tools reference this effort is -# modeled on has `pg-upgrade-test` / `pg-upgrade-stepwise`) exists yet. -# Deliberate, not an oversight: that job exists to catch a view/function -# that breaks across a PostgreSQL major specifically because it touches -# catalog internals (SELECT * over a system catalog whose columns get -# added/exposed/removed between majors). object_reference has no such -# construct in either its current or 0.1.0 install script (checked -# directly -- no view or function selects * from a system catalog; every -# object_reference table/view is an ordinary user object), so the risk -# that job protects against is correspondingly low here. Left as -# explicitly-noted future work rather than built preemptively; revisit -# if/when object_reference grows a catalog-touching view or function. +# - `pg-upgrade-stepwise`: BINARY pg_upgrade coverage. ONE cluster starts +# at the oldest supported PostgreSQL major with the one real historical +# PGXN release (0.1.0) installed, updates straight to the current +# version, then climbs every later supported major in sequence +# (e.g. 12โ†’13โ†’...โ†’18) via a REAL binary pg_upgrade per step, running the +# full suite (existing mode) and re-proving the dependency guard after +# EVERY step. This catches a regression specific to one particular +# major-to-major boundary that a single before/after snapshot would +# never exercise -- a view/function that breaks because it touches +# catalog internals (columns added/exposed/removed between majors) is +# the concrete risk. No such construct has been found in object_reference +# today (checked directly -- no view or function selects * from a system +# catalog; every object_reference table/view is an ordinary user +# object), but that is a fact about the code today, not a permanent +# property of it, and this job is cheap (the same install+pg_upgrade +# shape as any other pg_upgrade leg, just run once per step) -- so +# "unlikely to catch anything today" is not treated as a reason to skip +# it; only genuine cost would be. There is no separate "single big jump" +# pg_upgrade job (cat_tools's `pg-upgrade-test`) -- with only one +# historical extension version and no identified PostgreSQL-version +# floor for it, a big-jump leg would add a second job with no coverage +# the stepwise climb doesn't already provide. # # - `all-checks-passed`: single stable required-status-check name; see its # own comment below. @@ -87,15 +97,17 @@ jobs: # all-checks-passed check would then never report and get stuck Pending in # branch protection. # - # Also derives the supported-PostgreSQL-major list the test job's matrix - # consumes, from a single pair of constants below, so adding or dropping a - # major is a one-line edit here instead of touching the matrix directly. + # Also derives the supported-PostgreSQL-major lists the test job's matrix + # and the pg-upgrade-stepwise job's climb consume, from a single pair of + # constants below, so adding or dropping a major is a one-line edit here + # instead of touching either job directly. changes: name: ๐Ÿ” Detect changes & derive PG matrix runs-on: ubuntu-latest outputs: docs_only: ${{ steps.diff.outputs.docs_only }} supported_pg: ${{ steps.pg.outputs.supported_pg }} + climb_pg: ${{ steps.pg.outputs.climb_pg }} steps: - name: Check out the repo uses: actions/checkout@v4 @@ -144,13 +156,13 @@ jobs: echo "changed files:" echo "$CHANGED" echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" - - name: Derive the supported-PostgreSQL-major list + - name: Derive the supported-PostgreSQL-major lists id: pg run: | # SINGLE SOURCE OF TRUTH for the supported PostgreSQL majors. To - # add or drop a major, edit only the two constants below; the test - # job's matrix derives its version list from them. Do NOT hardcode - # a supported major directly in a job matrix. + # add or drop a major, edit only the two constants below; every + # job's matrix/climb derives its version list from them. Do NOT + # hardcode a supported major directly in a job matrix or loop. # # NEWEST -- highest PostgreSQL major tested. # CURRENT_FLOOR -- oldest major supported. object_reference @@ -158,16 +170,32 @@ jobs: # and cat_tools's own current release declares # PostgreSQL 12 as its build floor, so # object_reference can't usefully claim support - # for anything older either. + # for anything older either. 0.1.0 (the one + # real historical PGXN release) has no + # PostgreSQL-version floor of its own (no + # SELECT * over a system catalog, no ALTER + # TYPE ... ADD VALUE in its update script), so + # unlike cat_tools's reference implementation + # there is no separate, older LEGACY_FLOOR -- + # 0.1.0 installs cleanly across the whole + # CURRENT_FLOOR..NEWEST range, and the stepwise + # climb (pg-upgrade-stepwise) starts right at + # CURRENT_FLOOR too. NEWEST=18 CURRENT_FLOOR=12 supported=$(seq "$NEWEST" -1 "$CURRENT_FLOOR") + # Ascending (ties floor-to-newest, opposite order from $supported + # above): pg-upgrade-stepwise reads the first element as its + # starting major and binary-pg_upgrades through the rest in turn. + climb=$(seq "$CURRENT_FLOOR" "$NEWEST") # Emit a JSON array for the test job's matrix to consume via # fromJSON. json=$(printf '%s\n' $supported | paste -sd, - | sed 's/^/[/; s/$/]/') echo "supported_pg=$json" >> "$GITHUB_OUTPUT" + # Space-separated for direct iteration in the stepwise bash loop. + echo "climb_pg=$(echo $climb)" >> "$GITHUB_OUTPUT" test: # Gated behind lint too, not just changes: lint is nearly free to run, @@ -233,6 +261,118 @@ jobs: - name: Update 0.1.0 -> stable, structurally compare, run the suite (existing mode) run: bin/test_existing update-scenario object_reference_update 0.1.0 + # Proves object_reference survives EVERY individual major-to-major binary + # pg_upgrade transition, not just the single newest-major snapshot the + # `test`/`extension-update-test` jobs above cover: ONE cluster that starts + # on the floor PostgreSQL major and climbs through every later supported + # major in sequence via a REAL binary pg_upgrade. See the "Test strategy" + # comment at the top of this file for why this is included even though no + # catalog-touching view/function has been found in object_reference today. + # + # Installs 0.1.0 on the floor major, updates it straight to the current + # version (0.1.0's update script has no PostgreSQL-version floor of its + # own -- no ALTER TYPE ... ADD VALUE -- so, unlike cat_tools's reference + # implementation, there is no reason to hold the extension at an old + # version through any step of the climb), then binary-pg_upgrades one + # major at a time through the rest of the range, running the full suite + # (existing mode) and re-proving the dependency guard after EVERY step. + pg-upgrade-stepwise: + # Gated behind lint+test, not just changes -- see extension-update-test's + # needs comment above: every leg here would fail anyway against an + # already-broken baseline. success() must be written explicitly -- + # GitHub only assumes success() as a job's default when it has no if: at + # all. + needs: [changes, lint, test] + if: success() && needs.changes.outputs.docs_only != 'true' + name: ๐Ÿชœ Stepwise pg_upgrade (0.1.0 โ†’ stable) + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + env: + # Every pg_upgrade step pairs two clusters that must share initdb + # options (checksums, auth) or pg_upgrade refuses to run. + INITDB_OPTS: --data-checksums --auth trust + DB: object_reference_stepwise + # Ascending list of every supported PostgreSQL major, from the single + # source in the changes job -- so a new major joins the climb with no + # edit here. The first element is the climb's starting (floor) major. + CLIMB_PG: ${{ needs.changes.outputs.climb_pg }} + steps: + - name: Read the climb's starting (floor) PostgreSQL major + id: floor + run: echo "pg=$(set -- $CLIMB_PG; echo "$1")" >> "$GITHUB_OUTPUT" + - name: Start PostgreSQL ${{ steps.floor.outputs.pg }} + run: pg-start ${{ steps.floor.outputs.pg }} + - name: Recreate the floor cluster with data checksums enabled + # pg-start's default "test" cluster doesn't enable data checksums, + # but binary pg_upgrade requires the old and new clusters to have + # MATCHING checksum/auth settings. + run: | + pg_ctlcluster ${{ steps.floor.outputs.pg }} test stop + pg_dropcluster ${{ steps.floor.outputs.pg }} test + # -p 5432: pg_createcluster assigns the next available port, which + # may not be 5432 after pg-start has claimed and released it. + # Force 5432 so later psql/createdb calls connect without -p. + pg_createcluster -p 5432 ${{ steps.floor.outputs.pg }} test -- $INITDB_OPTS + pg_ctlcluster ${{ steps.floor.outputs.pg }} test start + pg_isready -t 30 + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install object_reference + its 0.1.0-only test dependency (count_nulls) into the floor cluster + # TEST_LOAD_SOURCE=update activates the Makefile's conditional + # `install: count_nulls` prerequisite -- 0.1.0's install script + # needs count_nulls even though current object_reference.control no + # longer declares it. count_nulls stays installed as its own + # extension in the database even after updating object_reference + # past 0.1.0 (nothing drops it), so every later `make install` in + # this job's climb loop keeps passing TEST_LOAD_SOURCE=update too -- + # pg_upgrade needs count_nulls's files present in each new cluster + # for as long as it remains a real extension in the test database. + run: make install TEST_LOAD_SOURCE=update + - name: Prepare the floor cluster (install 0.1.0, plant guard, update to current, run suite) + run: | + bin/test_existing prepare-old "$DB" 0.1.0 + bin/test_existing update "$DB" + bin/test_existing run-suite "$DB" + - name: Climb every later major via binary pg_upgrade + # One sequential loop; each iteration binary-pg_upgrades the cluster + # from $old to $new (a single major step), re-installs + # object_reference + its dependencies into the new cluster first + # (pg_upgrade needs their files present in the NEW cluster's + # sharedir -- default pg_config on PATH may not be $new's once + # several majors are installed, hence PG_CONFIG explicit), then + # re-runs the full suite in existing mode -- re-proving the + # dependency guard survived, and that the same suite/expected-output + # still passes against the objects that just crossed a pg_upgrade. + run: | + set -- $CLIMB_PG + old=$1 + shift + for new in "$@"; do + echo "=== binary pg_upgrade PostgreSQL $old -> $new ===" + apt-get install -y postgresql-$new postgresql-server-dev-$new + make install PG_CONFIG=/usr/lib/postgresql/$new/bin/pg_config TEST_LOAD_SOURCE=update + pg_ctlcluster $old test stop + pg_createcluster -p 5432 $new test -- $INITDB_OPTS + # PG17+ writes pg_upgrade logs under the new datadir; older + # versions write to CWD. Dump both on failure. + mkdir -p /tmp/pg_upgrade_logs + chown postgres:postgres /tmp/pg_upgrade_logs + su -c "cd /tmp/pg_upgrade_logs && /usr/lib/postgresql/$new/bin/pg_upgrade \ + -b /usr/lib/postgresql/$old/bin \ + -B /usr/lib/postgresql/$new/bin \ + -d /var/lib/postgresql/$old/test \ + -D /var/lib/postgresql/$new/test \ + -o '-c config_file=/etc/postgresql/$old/test/postgresql.conf' \ + -O '-c config_file=/etc/postgresql/$new/test/postgresql.conf'" postgres \ + || { find /tmp/pg_upgrade_logs \ + /var/lib/postgresql/$new/test/pg_upgrade_output.d \ + -name '*.log' 2>/dev/null | sort | xargs -r tail -n +1; exit 1; } + pg_ctlcluster $new test start + pg_isready -t 30 + bin/test_existing run-suite "$DB" + old=$new + done + # A single stable check name for use as a required status check in branch # protection rules. Matrix jobs produce check names like "๐Ÿ˜ PostgreSQL 14" # which would all need to be listed individually and updated whenever the @@ -240,7 +380,7 @@ jobs: # (e.g. test, on a docs-only push), and fails if any failed or were # cancelled. all-checks-passed: - needs: [changes, lint, test, extension-update-test] + needs: [changes, lint, test, extension-update-test, pg-upgrade-stepwise] if: always() runs-on: ubuntu-latest steps: diff --git a/bin/test_existing b/bin/test_existing index 019872e..ec7a7ba 100755 --- a/bin/test_existing +++ b/bin/test_existing @@ -2,10 +2,11 @@ # # Modeled on cat_tools's bin/test_existing (Postgres-Extensions/cat_tools) -- # exercises the object_reference test suite against a REAL database whose -# extension was installed/updated OUTSIDE the suite ("existing" mode). The -# extension-update-test CI job repeats the same sequence: +# extension was installed/updated/pg_upgraded OUTSIDE the suite ("existing" +# mode). Both the extension-update-test and pg-upgrade-stepwise CI jobs repeat +# the same sequence: # -# install -> plant dependency guard -> update -> assert version +# install -> plant dependency guard -> update/upgrade -> assert version # -> run the suite in existing mode # # so it lives here once instead of duplicated inline YAML. Not CI-only: a @@ -21,14 +22,18 @@ # ALTER EXTENSION object_reference UPDATE [TO 'TO_VERSION'] (empty => # the current default_version, "stable"). # +# prepare-old DB [INSTALL_VERSION] +# Old-cluster prep for pg-upgrade-stepwise: create DB + extension at +# INSTALL_VERSION (default 0.1.0), then plant + prove the guard. +# # run-suite DB # Run the suite in existing mode (extension must be at the current # version). # # update-scenario DB FROM_VERSION -# Create DB + extension at FROM_VERSION (bringing in count_nulls, the -# 0.1.0-only dependency object_reference.control no longer declares), -# plant + prove the guard, update to the current version, structurally +# Create DB + extension at FROM_VERSION (via prepare-old, which brings +# in count_nulls, the 0.1.0-only dependency object_reference.control no +# longer declares), update to the current version, structurally # compare the result against a fresh install of the current version # (see diff-fresh), then run the suite against that real updated # database. @@ -42,14 +47,14 @@ # # object_reference has only ONE real historical PGXN release (0.1.0) and only # ONE update script (0.1.0--stable.sql, a direct hop with no already-tagged -# intermediate landing version), so cat_tools's prepare-old / update-check / -# update-check-version subcommands don't apply here: -# - prepare-old exists to bridge an old, pg_upgrade-unsafe version to a -# safe one before a binary pg_upgrade leg. No pg_upgrade CI job exists yet -# for object_reference (see the containing PR description for that -# decision), and 0.1.0 has no identified pg_upgrade-unsafe construct (no -# SELECT * over a system catalog in any view -- checked directly), so -# there is nothing to bridge. +# intermediate landing version), so cat_tools's update-check / +# update-check-version subcommands don't apply here, and prepare-old is +# simpler than cat_tools's own (no BRIDGE_TO parameter): +# - cat_tools's prepare-old bridges an old, pg_upgrade-unsafe version to a +# known-safe one before a binary pg_upgrade leg. 0.1.0 has no identified +# pg_upgrade-unsafe construct (no SELECT * over a system catalog in any +# view -- checked directly), so pg-upgrade-stepwise installs it directly +# and there is nothing to bridge before the climb starts. # - update-check / update-check-version exist to assert an update script # applying to an ALREADY-TAGGED intermediate version, where full # fresh-vs-update parity is known to be permanently unattainable (the @@ -226,12 +231,19 @@ assert_matches_fresh() { # Subcommand implementations # --------------------------------------------------------------------------- -# update-scenario DB FROM_VERSION -# Full extension-update flow that runs the existing-mode suite: create the -# DB and extension at FROM_VERSION, plant + prove the guard, update to the -# current version, then run the suite against that real updated database. -update_scenario() { - local db=$1 from=$2 +# prepare-old DB [INSTALL_VERSION] +# Old-cluster preparation shared by update-scenario and the pg-upgrade- +# stepwise CI job: create the DB and the extension at INSTALL_VERSION +# (default 0.1.0, the one real historical PGXN release), then plant + prove +# the dependency guard. No BRIDGE_TO parameter (unlike cat_tools's own +# prepare-old, which updates the old cluster to a known pg_upgrade-safe +# version before a binary pg_upgrade runs): 0.1.0's install script has no +# identified pg_upgrade-unsafe construct (no SELECT * over a system +# catalog in any view), so there is nothing to bridge before a binary +# pg_upgrade -- updating all the way to the current version, whenever +# that's wanted, is a separate `update` call after this one. +prepare_old() { + local db=$1 install=${2:-0.1.0} createdb "$db" # 0.1.0's own install script creates a trigger that calls count_nulls' # not_null_count_trigger() -- a dependency object_reference.control no @@ -243,8 +255,18 @@ update_scenario() { # Makefile's conditional `install: count_nulls` prerequisite, # TEST_LOAD_SOURCE=update only, handles that -- see the CI job). psql_do "$db" -c "CREATE EXTENSION IF NOT EXISTS count_nulls" - psql_do "$db" -c "CREATE EXTENSION object_reference VERSION '$from' CASCADE" + psql_do "$db" -c "CREATE EXTENSION object_reference VERSION '$install' CASCADE" plant_guard "$db" +} + +# update-scenario DB FROM_VERSION +# Full extension-update flow that runs the existing-mode suite: create the +# DB and extension at FROM_VERSION (via prepare-old), update to the +# current version, structurally compare against a fresh install, then run +# the suite against that real updated database. +update_scenario() { + local db=$1 from=$2 + prepare_old "$db" "$from" update_ext "$db" assert_matches_fresh "$db" "$(current_version)" run_suite "$db" @@ -289,6 +311,7 @@ usage() { echo "usage: bin/test_existing [args]" >&2 echo " plant-guard DB" >&2 echo " update DB [TO_VERSION]" >&2 + echo " prepare-old DB [INSTALL_VERSION]" >&2 echo " run-suite DB" >&2 echo " update-scenario DB FROM_VERSION" >&2 echo " diff-fresh DB VERSION" >&2 @@ -304,6 +327,7 @@ main() { case "$cmd" in plant-guard) plant_guard "$@" ;; update) update_ext "$@" ;; + prepare-old) prepare_old "$@" ;; run-suite) run_suite "$@" ;; update-scenario) update_scenario "$@" ;; diff-fresh) assert_matches_fresh "$@" ;; From 7a1c538e66adb770812b086c43b798f34e18a943 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Wed, 5 Aug 2026 16:57:21 -0500 Subject: [PATCH 3/3] ci: document the pg-upgrade-stepwise known failure in-repo Move the _sentry_mv / binary-pg_upgrade root-cause analysis into a code comment on the job itself, so it's visible to anyone reading ci.yml directly, not just in the PR description. --- .github/workflows/ci.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47f430d..d1979b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -276,6 +276,22 @@ jobs: # version through any step of the climb), then binary-pg_upgrades one # major at a time through the rest of the range, running the full suite # (existing mode) and re-proving the dependency guard after EVERY step. + # + # KNOWN FAILING as of PR #19: the very first climb step (12 -> 13) already + # fails restoring the new cluster's schema -- + # `REFRESH MATERIALIZED VIEW "_object_reference"."_sentry_mv"` errors + # `pg_class heap OID value not set when in binary upgrade mode`. Confirmed + # general (reproduced with a bare non-extension materialized view against + # a scratch cluster started with postgres's own `-b` flag, no extension + # involved) -- a REFRESH creates a new heap, and nothing pre-assigns it a + # binary-upgrade OID the way pg_dump's own generated CREATE MATERIALIZED + # VIEW ... WITH NO DATA statements do, so pg_extension_config_dump() on a + # materialized view cannot survive binary pg_upgrade. _sentry_mv is marked + # that way deliberately (it forces object_reference.post_restore()'s + # OID-repair logic to re-run on any restore that doesn't itself re-run + # CREATE EXTENSION), so fixing this needs a maintainer decision on how to + # preserve that guarantee some other way -- see PR #19 for the fix + # directions considered so far. pg-upgrade-stepwise: # Gated behind lint+test, not just changes -- see extension-update-test's # needs comment above: every leg here would fail anyway against an