Skip to content

Commit 95133fd

Browse files
jnasbyupgradeclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 27d1c18 commit 95133fd

4 files changed

Lines changed: 666 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,55 @@
1+
# ===========================================================================
2+
# Test strategy
3+
#
4+
# An object_reference install can be arrived at more than one way, each of
5+
# which can break differently, so each is exercised by its own job below:
6+
#
7+
# - `lint`: the cheapest possible check (no database, no container beyond
8+
# a plain checkout, seconds to run) -- gates everything else so a broken
9+
# style baseline never ties up runner slots on the heavier jobs below.
10+
#
11+
# - `changes`: cheap docs-only gate, PLUS the single source of truth for
12+
# the supported-PostgreSQL-major list every other job's matrix derives
13+
# from (see its own "Derive ..." step).
14+
#
15+
# - `test`: FRESH install (CREATE EXTENSION at the current "stable"
16+
# version) via `make test`, on every supported PostgreSQL major. The
17+
# baseline a brand-new user gets.
18+
#
19+
# - `extension-update-test`: UPDATE TO CURRENT. Installs the one real
20+
# historical PGXN release (0.1.0), ALTER EXTENSION UPDATEs it to
21+
# "stable" via bin/test_existing's update-scenario, structurally
22+
# compares the result against a fresh "stable" install
23+
# (bin/structural_diff -- so a divergent function/view body produced
24+
# only by the update path, and never by a fresh install, cannot slip
25+
# through silently), then runs the full pgTAP suite against that real
26+
# updated database in `existing` mode (TEST_LOAD_SOURCE=existing,
27+
# --use-existing). A planted dependency guard (a view hard-referencing
28+
# _object_reference.object's row type) blocks a stray non-CASCADE DROP
29+
# EXTENSION throughout, and is re-proved present after every step -- see
30+
# bin/test_existing's own header for the full rationale. Runs on a
31+
# SINGLE PostgreSQL major (the newest supported), not the full matrix:
32+
# 0.1.0's install script has no identified PostgreSQL-version floor (no
33+
# SELECT * over a system catalog, no ALTER TYPE ... ADD VALUE), so
34+
# crossing this axis against every major would just multiply job count
35+
# for no added coverage.
36+
#
37+
# - No binary pg_upgrade job (the cat_tools reference this effort is
38+
# modeled on has `pg-upgrade-test` / `pg-upgrade-stepwise`) exists yet.
39+
# Deliberate, not an oversight: that job exists to catch a view/function
40+
# that breaks across a PostgreSQL major specifically because it touches
41+
# catalog internals (SELECT * over a system catalog whose columns get
42+
# added/exposed/removed between majors). object_reference has no such
43+
# construct in either its current or 0.1.0 install script (checked
44+
# directly -- no view or function selects * from a system catalog; every
45+
# object_reference table/view is an ordinary user object), so the risk
46+
# that job protects against is correspondingly low here. Left as
47+
# explicitly-noted future work rather than built preemptively; revisit
48+
# if/when object_reference grows a catalog-touching view or function.
49+
#
50+
# - `all-checks-passed`: single stable required-status-check name; see its
51+
# own comment below.
52+
# ===========================================================================
153
name: CI
254
on:
355
push:
@@ -144,14 +196,49 @@ jobs:
144196
# suite's pgTAP-based tests) and exits non-zero on failure.
145197
run: make verify-results
146198

199+
# Extension UPDATE path: install the one real historical PGXN release
200+
# (0.1.0), ALTER EXTENSION UPDATE to the current version ("stable"), and
201+
# run the full suite against the real updated database in `existing` mode
202+
# -- see the "Test strategy" comment at the top of this file and
203+
# bin/test_existing's own header for the full flow and dependency-guard
204+
# rationale.
205+
extension-update-test:
206+
# Gated behind test (not just changes): this job installs a second,
207+
# older extension version and runs the update path, which is wasted
208+
# effort against a baseline that's already broken by a failing
209+
# fresh-install test. success() must be written explicitly -- GitHub
210+
# only assumes success() as a job's default when it has no if: at all.
211+
needs: [changes, test]
212+
if: success() && needs.changes.outputs.docs_only != 'true'
213+
name: ⬆️ Extension update test (0.1.0 → stable)
214+
runs-on: ubuntu-latest
215+
container: pgxn/pgxn-tools
216+
steps:
217+
# A single PostgreSQL major (the newest supported, from the `changes`
218+
# job's single source of truth) -- see the Test strategy comment at
219+
# the top of this file for why this isn't crossed against the full PG
220+
# matrix.
221+
- name: Start PostgreSQL ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }}
222+
run: pg-start ${{ fromJSON(needs.changes.outputs.supported_pg)[0] }}
223+
- name: Check out the repo
224+
uses: actions/checkout@v4
225+
- name: Install object_reference + its 0.1.0-only test dependency (count_nulls)
226+
# TEST_LOAD_SOURCE=update activates the Makefile's conditional
227+
# `install: count_nulls` prerequisite (see the Makefile's own
228+
# comment on it): 0.1.0's install script needs count_nulls even
229+
# though current object_reference.control no longer declares it.
230+
run: make install TEST_LOAD_SOURCE=update
231+
- name: Update 0.1.0 -> stable, structurally compare, run the suite (existing mode)
232+
run: bin/test_existing update-scenario object_reference_update 0.1.0
233+
147234
# A single stable check name for use as a required status check in branch
148235
# protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14"
149236
# which would all need to be listed individually and updated whenever the
150237
# matrix changes. This job passes if all others passed or were skipped
151238
# (e.g. test, on a docs-only push), and fails if any failed or were
152239
# cancelled.
153240
all-checks-passed:
154-
needs: [changes, lint, test]
241+
needs: [changes, lint, test, extension-update-test]
155242
if: always()
156243
runs-on: ubuntu-latest
157244
steps:

bin/structural_diff

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Structurally compare, object-by-object, every member of the
4+
# object_reference extension in two databases: function bodies
5+
# (pg_get_functiondef), view definitions (pg_get_viewdef), table/composite
6+
# column lists, comments, and ACLs. A nonempty diff is a bug -- the whole
7+
# point of an extension UPDATE script is that it reaches the SAME objects a
8+
# fresh install of the target version would.
9+
#
10+
# Modeled on cat_tools's bin/structural_diff (Postgres-Extensions/cat_tools),
11+
# which generalized a manual comparison that found a real fresh-vs-update
12+
# divergence in cat_tools#46. bin/structural_diff.sql here is a near-verbatim
13+
# copy of that file -- it is already written generically (parameterized on
14+
# :extname, driven entirely off pg_depend's deptype='e' membership edge, with
15+
# no cat_tools-specific object names), so it applies to any extension as-is.
16+
#
17+
# USAGE: bin/structural_diff <subcommand> [args]
18+
#
19+
# dump DB [EXTNAME]
20+
# Print the signature of every EXTNAME member object in DB (EXTNAME
21+
# defaults to object_reference). Useful on its own for eyeballing one
22+
# database's structure, and it's what `compare` diffs under the hood.
23+
#
24+
# compare DB1 DB2 [EXTNAME]
25+
# Dump both databases and diff them. Prints a unified diff and exits
26+
# non-zero if they differ; exits 0 (and prints an OK line) if
27+
# identical.
28+
#
29+
# See bin/structural_diff.sql for the query that defines "signature" (and how
30+
# it decides which object kinds get a real structural definition vs. falling
31+
# back to just identity/comment/ACL).
32+
set -euo pipefail
33+
34+
SCRIPT_DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)
35+
36+
# ---------------------------------------------------------------------------
37+
# Subcommand implementations
38+
# ---------------------------------------------------------------------------
39+
40+
dump() {
41+
local db=$1 extname=${2:-object_reference}
42+
psql -d "$db" -v extname="'$extname'" -f "$SCRIPT_DIR/structural_diff.sql"
43+
}
44+
45+
compare() {
46+
local db1=$1 db2=$2 extname=${3:-object_reference}
47+
# Run in a subshell so the EXIT trap (temp-file cleanup) is scoped to this
48+
# comparison only. A trap set with plain `trap ... RETURN` is NOT scoped to
49+
# the function that set it -- it re-fires on every later function return in
50+
# the same shell, including main()'s, by which point f1/f2 no longer exist.
51+
(
52+
f1=$(mktemp)
53+
f2=$(mktemp)
54+
trap 'rm -f "$f1" "$f2"' EXIT
55+
dump "$db1" "$extname" > "$f1"
56+
dump "$db2" "$extname" > "$f2"
57+
if diff -u --label "$db1" --label "$db2" "$f1" "$f2"; then
58+
echo "OK: '$db1' and '$db2' are structurally identical for extension '$extname'"
59+
else
60+
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
61+
exit 1
62+
fi
63+
)
64+
}
65+
66+
usage() {
67+
echo "usage: bin/structural_diff <subcommand> [args]" >&2
68+
echo " dump DB [EXTNAME]" >&2
69+
echo " compare DB1 DB2 [EXTNAME]" >&2
70+
exit 2
71+
}
72+
73+
# Explicit subcommand dispatch on $1, matching bin/test_existing's
74+
# convention.
75+
main() {
76+
local cmd=${1:-}
77+
shift || true
78+
case "$cmd" in
79+
dump) dump "$@" ;;
80+
compare) compare "$@" ;;
81+
*) usage ;;
82+
esac
83+
}
84+
85+
main "$@"

bin/structural_diff.sql

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Structural signature dump for every object that belongs to an extension
3+
* (pg_depend deptype = 'e'), used by bin/structural_diff to compare a
4+
* database reached via an extension UPDATE against a FRESH install of the
5+
* same target version. Any nonempty diff between two runs of this query is a
6+
* bug: the two paths are supposed to produce byte-identical objects.
7+
*
8+
* Run via: psql -d DBNAME -v extname="'object_reference'" -f bin/structural_diff.sql
9+
*
10+
* Modeled on cat_tools's bin/structural_diff.sql (Postgres-Extensions/
11+
* cat_tools), which generalized a manual comparison technique (diffing
12+
* pg_get_functiondef / pg_get_viewdef / type labels / comments / ACLs /
13+
* extension membership between a fresh install and an updated database) used
14+
* to find a real fresh-vs-update divergence in that extension. This file is
15+
* copied near-verbatim -- it is written generically off pg_depend's deptype
16+
* = 'e' membership edge, with no cat_tools-specific object names, so it
17+
* applies to object_reference (and any other extension) as-is; only the
18+
* default :extname in bin/structural_diff's wrapper differs.
19+
*
20+
* Emits one text block per member object, ordered by its pg_describe_object()
21+
* identity so the SAME object sorts to the SAME position regardless of the
22+
* OIDs assigned along each installation path. Each block covers:
23+
* - a structural definition, using pg_get_functiondef/pg_get_viewdef for
24+
* routines/views, an ordered column dump for a plain table or standalone
25+
* composite type, an ordered label list for enums, or a cast/domain
26+
* summary -- whichever a member's catalog/kind actually calls for.
27+
* object_reference currently has no enums, domains or casts of its own;
28+
* those branches are kept anyway (harmless no-ops today) so a future
29+
* member of one of those kinds is compared structurally too, without
30+
* needing to remember to add it. Row types implicitly created BY a
31+
* member relation, and the array type shadowing any other member type,
32+
* are skipped: their structure is fully captured by the relation/base-
33+
* type entry already, so listing them again would just duplicate that
34+
* comparison under a second identity.
35+
* - its comment (pg_description), generically via obj_description().
36+
* - its ACL, generically via whichever ACL column its catalog has (proacl /
37+
* typacl / relacl / nspacl); sorted, since grant order is not meaningful.
38+
*
39+
* This is deliberately NOT specific to object_reference's current object
40+
* list: any object kind this extension does not (yet) use falls through to
41+
* the ELSE branch below, which still includes it (via its
42+
* pg_describe_object identity, comment and ACL) so a future new member is
43+
* compared at least at that level rather than silently skipped, even though
44+
* this file does not (yet) know how to render a structural definition for
45+
* it.
46+
*/
47+
\set ON_ERROR_STOP on
48+
\pset format unaligned
49+
\pset tuples_only on
50+
\pset fieldsep ''
51+
52+
WITH ext AS (
53+
SELECT oid FROM pg_extension WHERE extname = :extname
54+
), members AS (
55+
SELECT d.classid, d.objid
56+
FROM pg_depend d, ext
57+
WHERE d.refclassid = 'pg_extension'::regclass
58+
AND d.refobjid = ext.oid
59+
AND d.deptype = 'e'
60+
), skip_shadow AS (
61+
/* Implicit row type of a member relation: same structure as the relation
62+
* itself, so comparing it too would just duplicate that check. */
63+
SELECT t.oid
64+
FROM pg_type t
65+
JOIN members rel ON rel.classid = 'pg_class'::regclass AND rel.objid = t.typrelid
66+
WHERE t.typtype = 'c'
67+
UNION
68+
/* Array type shadowing another member type: same element type, no
69+
* independent structure of its own. */
70+
SELECT t.oid
71+
FROM pg_type t
72+
JOIN members base ON base.classid = 'pg_type'::regclass AND base.objid = t.typelem
73+
WHERE t.typelem <> 0
74+
), acl AS (
75+
SELECT m.classid, m.objid,
76+
(
77+
SELECT array_to_string(array_agg(a::text ORDER BY a::text), ',')
78+
FROM unnest(
79+
CASE m.classid
80+
WHEN 'pg_proc'::regclass THEN (SELECT proacl FROM pg_proc WHERE oid = m.objid)
81+
WHEN 'pg_type'::regclass THEN (SELECT typacl FROM pg_type WHERE oid = m.objid)
82+
WHEN 'pg_class'::regclass THEN (SELECT relacl FROM pg_class WHERE oid = m.objid)
83+
WHEN 'pg_namespace'::regclass THEN (SELECT nspacl FROM pg_namespace WHERE oid = m.objid)
84+
ELSE NULL
85+
END
86+
) a
87+
) AS acl_text
88+
FROM members m
89+
), relation_cols AS (
90+
/* Ordered column dump, shared by the plain-table case (pg_class relkind
91+
* 'r') and the standalone-composite-type case (pg_type typtype 'c' whose
92+
* typrelid is NOT a member relation, i.e. survived skip_shadow) -- both
93+
* describe a set of (name, type, not-null, default) columns identically. */
94+
SELECT m.classid, m.objid,
95+
(
96+
SELECT string_agg(
97+
format(
98+
'%s %s%s%s'
99+
, a.attname
100+
, format_type(a.atttypid, a.atttypmod)
101+
, CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END
102+
, COALESCE(' DEFAULT ' || pg_get_expr(ad.adbin, ad.adrelid), '')
103+
)
104+
, E'\n' ORDER BY a.attnum
105+
)
106+
FROM pg_attribute a
107+
LEFT JOIN pg_attrdef ad ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
108+
WHERE a.attrelid = CASE m.classid
109+
WHEN 'pg_class'::regclass THEN m.objid
110+
WHEN 'pg_type'::regclass THEN (SELECT typrelid FROM pg_type WHERE oid = m.objid)
111+
END
112+
AND a.attnum > 0
113+
AND NOT a.attisdropped
114+
)
115+
/* Table constraints (PK/UNIQUE/CHECK/FK) have no equivalent on a
116+
* standalone composite type, so this is NULL there and simply appends
117+
* nothing. */
118+
|| COALESCE(
119+
E'\n' || (
120+
SELECT string_agg(pg_get_constraintdef(c.oid), E'\n' ORDER BY c.conname)
121+
FROM pg_constraint c
122+
WHERE m.classid = 'pg_class'::regclass AND c.conrelid = m.objid
123+
)
124+
, ''
125+
) AS cols
126+
FROM members m
127+
WHERE (m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r')
128+
OR (m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c')
129+
)
130+
SELECT
131+
'=== ' || pg_describe_object(m.classid, m.objid, 0) || E' ===\n'
132+
|| 'DEFINITION:' || E'\n' || COALESCE(
133+
CASE
134+
WHEN m.classid = 'pg_proc'::regclass
135+
THEN pg_get_functiondef(m.objid)
136+
WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) IN ('v', 'm')
137+
THEN pg_get_viewdef(m.objid, true)
138+
WHEN m.classid = 'pg_class'::regclass AND (SELECT relkind FROM pg_class WHERE oid = m.objid) = 'r'
139+
THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid)
140+
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'e'
141+
THEN (SELECT string_agg(enumlabel, ',' ORDER BY enumsortorder) FROM pg_enum WHERE enumtypid = m.objid)
142+
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'c'
143+
THEN (SELECT cols FROM relation_cols rc WHERE rc.classid = m.classid AND rc.objid = m.objid)
144+
WHEN m.classid = 'pg_type'::regclass AND (SELECT typtype FROM pg_type WHERE oid = m.objid) = 'd'
145+
THEN (
146+
SELECT format(
147+
'base=%s notnull=%s default=%s check=%s'
148+
, t.typbasetype::regtype, t.typnotnull, t.typdefault
149+
, (SELECT string_agg(pg_get_constraintdef(c.oid), ' AND ' ORDER BY c.oid)
150+
FROM pg_constraint c WHERE c.contypid = m.objid)
151+
)
152+
FROM pg_type t WHERE t.oid = m.objid
153+
)
154+
WHEN m.classid = 'pg_cast'::regclass
155+
THEN (
156+
SELECT format(
157+
'CAST (%s AS %s) METHOD %s CONTEXT %s'
158+
, ct.castsource::regtype, ct.casttarget::regtype
159+
, CASE ct.castmethod
160+
WHEN 'f' THEN 'FUNCTION ' || ct.castfunc::regprocedure::text
161+
WHEN 'i' THEN 'INOUT'
162+
WHEN 'b' THEN 'BINARY COERCION'
163+
END
164+
, ct.castcontext
165+
)
166+
FROM pg_cast ct WHERE ct.oid = m.objid
167+
)
168+
ELSE NULL
169+
END
170+
, '(no structural definition rendered for this object kind -- see identity/comment/ACL below)'
171+
)
172+
|| E'\n' || 'COMMENT: ' || COALESCE(obj_description(m.objid, m.classid::regclass::text), '(none)')
173+
|| E'\n' || 'ACL: ' || COALESCE((SELECT acl_text FROM acl WHERE acl.classid = m.classid AND acl.objid = m.objid), '(none)')
174+
|| E'\n'
175+
AS block
176+
FROM members m
177+
LEFT JOIN skip_shadow s ON m.classid = 'pg_type'::regclass AND s.oid = m.objid
178+
WHERE s.oid IS NULL
179+
ORDER BY pg_describe_object(m.classid, m.objid, 0);

0 commit comments

Comments
 (0)