From 0eafd8ac633341b20587670a2568444f8161a847 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 31 Jul 2026 18:38:10 -0500 Subject: [PATCH 1/6] Phase 3: TEST_LOAD_SOURCE (fresh/update/existing) in test/install test/install/load.sql now selects fresh/update/existing via the count_nulls.test_load_mode GUC (TEST_LOAD_SOURCE make var), matching pgxntool/README.asc's documented U&U pattern: - fresh: CREATE EXTENSION count_nulls (unchanged from phase 1/2). - update: CREATE EXTENSION VERSION '0.9.6', then ALTER EXTENSION UPDATE - committed, since test/install runs outside any per-test rolled-back transaction. - existing: asserts count_nulls is already installed and current, touches nothing - for a real pg_upgrade run external to this invocation (a later phase adds the CI job that drives this). Added the extension-update-test CI job: just `make verify-results TEST_LOAD_SOURCE=update`, no external script needed - unlike a real pg_upgrade, an in-place update is pure SQL, so test/install handles the whole fresh-vs-updated comparison within one pg_regress invocation. Verified locally against PG17: fresh, update, and existing modes all pass via make verify-results, including update x TEST_SCHEMA=Quoted crossed together - zero expected-output changes needed for any combination, confirming phase 2's schema-invariant design holds across load modes too (load-bearing for a later phase that crosses U&U with schema in CI). existing mode verified manually against a real out-of-band CREATE EXTENSION + --use-existing run. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 27 ++++++++++++++++++ Makefile | 32 +++++++++++++++++++++ test/install/load.sql | 61 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 58630cd..814ea17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,33 @@ jobs: - name: Test on PostgreSQL ${{ matrix.pg }} run: pg-build-test + # Proves the in-place extension update path: CREATE EXTENSION at the + # oldest version we still ship a full install script for (0.9.6), then + # ALTER EXTENSION UPDATE (no pg_upgrade, same PostgreSQL), all within + # test/install/load.sql's own committed session (TEST_LOAD_SOURCE=update - + # see the Makefile) - then run the FULL suite against the updated + # database via the SAME expected output as a fresh install (see + # test/README.md for how the suite stays schema/load-mode invariant). + # No external script needed for this leg: unlike a real pg_upgrade + # (pg-upgrade-test), the update itself is just SQL, so test/install can + # do the whole thing inside one pg_regress invocation. + extension-update-test: + strategy: + matrix: + pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] + name: ⬆️ Extension update test on PostgreSQL ${{ matrix.pg }} + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + steps: + - name: Start PostgreSQL ${{ matrix.pg }} + run: pg-start ${{ matrix.pg }} + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install count_nulls + run: make install + - name: Update 0.9.6 -> current and run the suite + run: make verify-results TEST_LOAD_SOURCE=update + pg-tle-test: strategy: matrix: diff --git a/Makefile b/Makefile index d6ed08a..059351f 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,35 @@ testdeps: $(wildcard test/*/*.sql) $(wildcard test/*.sql) # Be careful not to in # still fails loudly instead of looking identical to a deliberately empty one. TEST_SCHEMA ?= export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_schema=$(TEST_SCHEMA) + +# TEST_LOAD_SOURCE selects how test/install/load.sql installs count_nulls +# for the WHOLE test run: +# - fresh (default): CREATE EXTENSION count_nulls (current version). +# - update: CREATE EXTENSION at the oldest version we still ship a full +# install script for (0.9.6), then ALTER EXTENSION UPDATE to current - +# committed, since test/install runs outside any per-test rolled-back +# transaction (see pgxntool/README.asc's Update & Upgrade (U&U) Testing +# section for why the commit matters). +# - existing: count_nulls is already installed (a real `pg_upgrade` run, +# external to this invocation) - test/install only asserts it's present +# and current, it does not drop/create/update anything. Meant to be run +# with CONTRIB_TESTDB= EXTRA_REGRESS_OPTS=--use-existing against a +# real database, not via a make wrapper here. +# +# "update" (this) is extension-level (ALTER EXTENSION UPDATE); "upgrade" is +# cluster-level (pg_upgrade) - 'existing' is how that axis is exercised. +# +# Propagated the same way as TEST_SCHEMA: via the count_nulls.test_load_mode +# GUC, exported unconditionally through PGOPTIONS, read without missing_ok. +TEST_LOAD_SOURCE ?= fresh +ifeq ($(filter $(TEST_LOAD_SOURCE),fresh update existing),) +$(error TEST_LOAD_SOURCE must be 'fresh', 'update' or 'existing', got '$(TEST_LOAD_SOURCE)') +endif +export PGOPTIONS := $(PGOPTIONS) -c count_nulls.test_load_mode=$(TEST_LOAD_SOURCE) + +# Convenience wrapper: `make test-update` == `make test TEST_LOAD_SOURCE=update`. +# Must recurse (a fresh $(MAKE)) rather than depend on `test`, so the +# parse-time TEST_LOAD_SOURCE conditional above re-evaluates with update set. +.PHONY: test-update +test-update: + $(MAKE) test TEST_LOAD_SOURCE=update diff --git a/test/install/load.sql b/test/install/load.sql index 1737ebc..701ef25 100644 --- a/test/install/load.sql +++ b/test/install/load.sql @@ -32,4 +32,65 @@ CREATE SCHEMA IF NOT EXISTS :"schema"; SET search_path = :"schema"; \endif +/* + * Mode selection: 'fresh' installs the current version directly; 'update' + * installs the oldest version we still ship a full script for (0.9.6) and + * runs ALTER EXTENSION UPDATE, committed (this file runs outside any + * per-test rolled-back transaction, unlike the old test/deps.sql approach - + * see pgxntool/README.asc's U&U section for why the commit matters); + * 'existing' asserts count_nulls is already installed (a real `pg_upgrade` + * run, external to this invocation) and touches nothing. + * + * Read without missing_ok, same reasoning as count_nulls.test_schema above. + */ +SELECT current_setting('count_nulls.test_load_mode') AS count_nulls_test_load_mode +\gset + +DO $$ +BEGIN + IF current_setting('count_nulls.test_load_mode') NOT IN ('fresh', 'update', 'existing') THEN + RAISE EXCEPTION + 'count_nulls.test_load_mode must be ''fresh'', ''update'' or ''existing'', got ''%''' + , current_setting('count_nulls.test_load_mode') + ; + END IF; +END +$$; + +SELECT :'count_nulls_test_load_mode' = 'update' AS count_nulls_update_mode +\gset +SELECT :'count_nulls_test_load_mode' = 'existing' AS count_nulls_existing_mode +\gset + +\if :count_nulls_existing_mode +/* + * Already installed by something external to this pg_regress invocation + * (a real pg_upgrade run - see the pg-upgrade-test CI job). Only assert + * it's present and at the current version; do NOT drop/create/update it - + * the whole point of this mode is testing the REAL migrated objects. + */ +DO $$ +DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'); + v_default text := (SELECT default_version FROM pg_available_extensions WHERE name = 'count_nulls'); +BEGIN + IF v_installed IS NULL THEN + RAISE EXCEPTION 'count_nulls.test_load_mode=existing but count_nulls is not installed'; + END IF; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'count_nulls installed at % but default_version is %', v_installed, v_default; + END IF; +END +$$; +\elif :count_nulls_update_mode +CREATE EXTENSION count_nulls VERSION '0.9.6'; +/* + * Suppress the "already installed, no update" NOTICE class of messages any + * update script might emit. + */ +SET client_min_messages = WARNING; +ALTER EXTENSION count_nulls UPDATE; +SET client_min_messages = NOTICE; +\else CREATE EXTENSION count_nulls; +\endif From 85db47d495fff2bf015a1e16851ae5e4320be749 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 31 Jul 2026 18:43:55 -0500 Subject: [PATCH 2/6] Phase 3.5: CI hygiene - docs-only gate, all-checks-passed, single-source PG list Independent of the U&U testing work itself, but best done now that multiple CI jobs exist and before the next phase adds the most expensive one (a real pg_upgrade job): - `changes` job: computes the actual per-push diff and skips test/ extension-update-test/pg-tle-test entirely on doc-only pushes, always triggering itself (no workflow-level paths-ignore, which would leave all-checks-passed stuck Pending on doc-only pushes in branch protection). - Derives the supported-PostgreSQL-major list from ONE set of constants (NEWEST/FLOOR) in that same job, consumed by both the `test` and `extension-update-test` matrices via fromJSON - they can't silently drift onto different lists, and a new major is a one-line change. - `all-checks-passed`: single stable required-status-check name, with a self-check that its own needs list can't silently omit a newly-added job. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 183 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 814ea17..0370847 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,25 @@ +# =========================================================================== +# Test strategy +# +# count_nulls can be arrived at several ways, each of which can break +# differently, so each is exercised by its own job below: +# +# test -- FRESH install: CREATE EXTENSION at the current +# version, across every supported PostgreSQL +# major x schema leg (empty/none and Quoted - +# see TEST_SCHEMA in the Makefile). +# extension-update-test -- IN-PLACE update: CREATE EXTENSION at 0.9.6 +# then ALTER EXTENSION UPDATE (same PostgreSQL, +# no pg_upgrade), same PG matrix as test. +# pg-tle-test -- pg_tle DEPLOYMENT: fresh install registered +# through AWS pg_tle's database-backed catalog +# instead of a filesystem .control file. +# +# `changes` is a cheap gate that lets the heavy jobs above skip themselves on +# doc-only pushes, and also derives the shared PostgreSQL-major list those +# jobs consume from a single set of constants. `all-checks-passed` is the +# single stable required-status-check name. +# =========================================================================== name: CI on: push: @@ -5,6 +27,115 @@ on: - master pull_request: jobs: + # Cheap gate that lets the heavy jobs below skip themselves on commits that + # touch only docs. Must run on every push/pull_request (no paths-ignore on + # the workflow itself), otherwise the required all-checks-passed check + # would never report on doc-only pushes and get stuck Pending in branch + # protection. + # + # Also derives, from a SINGLE set of constants, the supported-PostgreSQL- + # major list the test / extension-update-test jobs consume: every job that + # cares which majors are supported reads the SAME list, so they can't + # silently drift onto different sets, and adding a new major is a one-line + # change here instead of an edit in several jobs. + changes: + name: 🔍 Detect docs-only changes & derive PG matrix + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.diff.outputs.docs_only }} + supported_pg: ${{ steps.pg.outputs.supported_pg }} + steps: + - name: Check out the repo + uses: actions/checkout@v4 + with: + # Full history needed so BASE and HEAD below are both reachable + # for `git diff`. + fetch-depth: 0 + - name: Compute per-push changed files + id: diff + run: | + # Fail safe to running the full matrix: default docs_only to false + # immediately, before anything below has a chance to compute or + # fail. Writing the same GITHUB_OUTPUT key twice is fine (the last + # write wins), so the only way this step ends with docs_only=true + # is by genuinely proving it further down - never by skipping past + # an edge case with a default. + echo "docs_only=false" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.action }}" = "synchronize" ] && \ + [ -n "${{ github.event.before }}" ]; then + # A push to an already-open PR: before/after give the true + # per-push diff, same as for a branch push. + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + elif [ "${{ github.event_name }}" = "pull_request" ]; then + # First run for this PR (opened/reopened/etc, or synchronize + # without a usable before): fall back to the whole base...head + # diff. + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + fi + + echo "base=$BASE" + echo "head=$HEAD" + + # A missing HEAD, or an all-zeros BASE (e.g. a new branch's first + # push, where GitHub reports no prior commit), means we can't + # compute a real diff. docs_only is already false from above; + # just stop here rather than risk skipping tests. + if [ -z "$HEAD" ] || [ -z "$BASE" ] || [[ "$BASE" =~ ^0+$ ]]; then + exit 0 + fi + + CHANGED=$(git diff --name-only "$BASE" "$HEAD" || echo __DIFF_FAILED__) + + DOCS_ONLY=true + if [ "$CHANGED" = "__DIFF_FAILED__" ] || [ -z "$CHANGED" ]; then + DOCS_ONLY=false + else + while IFS= read -r f; do + if ! [[ "$f" =~ \.(md|asc)$ ]]; then + DOCS_ONLY=false + break + fi + done <<< "$CHANGED" + fi + + echo "changed files:" + echo "$CHANGED" + echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" + + - name: Derive the supported-PostgreSQL-major list + id: pg + run: | + # A dozen-odd lines to replace what looks like a handful of version + # references, but it buys CONSISTENCY: both the fresh-install + # `test` matrix and the `extension-update-test` matrix derive their + # PostgreSQL set from this ONE source, so they cannot silently + # drift onto different lists. Adding a new major is a one-line + # NEWEST bump here, not an edit in N places. + # + # Only one floor is needed here: 0.9.6 (the oldest version + # count_nulls still ships a full install script for) is pure SQL + # over anyarray/json/jsonb with no catalog-version sensitivity, so + # it installs on every PostgreSQL major count_nulls supports - + # there's no separate legacy-only floor to carve out. + NEWEST=18 + FLOOR=10 + + supported=$(seq "$NEWEST" -1 "$FLOOR") + + # Emit a JSON array from a list of ints, for the job matrices to + # consume with fromJSON (GitHub evaluates a literal dollar-brace + # expression even inside a run block, so none is written here). + json() { printf '%s\n' "$@" | paste -sd, - | sed 's/^/[/; s/$/]/'; } + + echo "supported_pg=$(json $supported)" >> "$GITHUB_OUTPUT" + # Fresh install, across the PG matrix AND a schema matrix (TEST_SCHEMA, # picked up from the environment by test/install/load.sql via the # count_nulls.test_schema GUC - see the Makefile). Empty ('') runs WITHOUT @@ -13,9 +144,12 @@ jobs: # both pass against the SAME test/expected/extension_tests.out (see # test/README.md for how the suite keeps its output schema-invariant). test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: - pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] + # From the single source in the changes job. + pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }} schema: ["", Quoted] name: 🐘 PostgreSQL ${{ matrix.pg }} (schema ${{ matrix.schema == '' && 'none' || matrix.schema }}) runs-on: ubuntu-latest @@ -41,9 +175,12 @@ jobs: # (pg-upgrade-test), the update itself is just SQL, so test/install can # do the whole thing inside one pg_regress invocation. extension-update-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: - pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] + # From the single source in the changes job. + pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }} name: ⬆️ Extension update test on PostgreSQL ${{ matrix.pg }} runs-on: ubuntu-latest container: pgxn/pgxn-tools @@ -58,6 +195,8 @@ jobs: run: make verify-results TEST_LOAD_SOURCE=update pg-tle-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: # Intersection of count_nulls' own supported range (10-18, see the @@ -175,3 +314,43 @@ jobs: fi - name: Verify no stray extension control files after the fresh-install smoke test run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg_tle.control + + # A single stable check name for use as a required status check in branch + # protection rules. Matrix jobs produce check names like + # "🐘 PostgreSQL 14 (schema none)" which would all need to be listed + # individually and updated whenever the matrix changes. This job passes if + # all others passed or were skipped (e.g. the heavy jobs gated off by the + # `changes` job on a docs-only push), and fails if any failed or were + # cancelled. + all-checks-passed: + needs: [changes, test, extension-update-test, pg-tle-test] + if: always() + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify all jobs are listed in needs + # Ensures this job won't silently ignore a newly-added job that was + # omitted from the needs list above. + run: | + DEFINED=$(python3 -c " + import yaml + with open('.github/workflows/ci.yml') as f: + w = yaml.safe_load(f) + print('\n'.join(sorted(j for j in w['jobs'] if j != 'all-checks-passed'))) + ") + NEEDED=$(echo '${{ toJson(needs) }}' | python3 -c " + import json, sys + print('\n'.join(sorted(json.load(sys.stdin)))) + ") + if [ "$DEFINED" != "$NEEDED" ]; then + echo "Some jobs are missing from all-checks-passed needs:" + diff <(echo "$DEFINED") <(echo "$NEEDED") + exit 1 + fi + - name: Check all jobs passed or were skipped + run: | + if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more jobs failed or were cancelled" + exit 1 + fi +# vi: expandtab ts=2 sw=2 From 8a6a467983a1a3084ba6f9755b9b0871359d7df4 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 16:11:44 -0500 Subject: [PATCH 3/6] Phase 4: real pg_upgrade support via a reduced bin/test_existing Adds the pg-upgrade-test CI job: install 0.9.6 on an old PostgreSQL major, plant + prove a dependency guard, binary pg_upgrade to a newer major, ALTER EXTENSION UPDATE the migrated objects, then run the suite against the real upgraded database in existing mode. bin/test_existing is much smaller than the equivalent script would have been pre-test/install: only prepare-old and run-suite are genuinely external-to-pg_regress concerns (a real pg_upgrade binary run isn't something pg_regress can invoke itself), plus a small `update` subcommand for the post-upgrade ALTER EXTENSION UPDATE step. There's no update-scenario subcommand at all - that entire scenario is just `make test-update` now (test/install/load.sql's own 'update' mode, added in phase 3), since an in-place update has no external step to drive. run_suite() gates on plain `make test`, not the old belt-and-suspenders `make test && make verify-results` - pgxntool 2.3.0 (this repo's phase 0) already made `make test` itself exit non-zero on regression failures. Not yet crossed with TEST_SCHEMA - that's the next phase, once both this job and extension-update-test can cross it together. Verified locally against PG17 (prepare-old -> update -> run-suite, without a real pg_upgrade - this container's clusters are persistent shared infra, so the actual binary pg_upgrade leg is left for CI's ephemeral containers, same reasoning as the pg-tle-test work). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 108 ++++++++++++- bin/test_existing | 210 +++++++++++++++++++++++++ bin/test_existing.sql/assert_guard.sql | 35 +++++ bin/test_existing.sql/drop_guard.sql | 11 ++ bin/test_existing.sql/plant_guard.sql | 30 ++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100755 bin/test_existing create mode 100644 bin/test_existing.sql/assert_guard.sql create mode 100644 bin/test_existing.sql/drop_guard.sql create mode 100644 bin/test_existing.sql/plant_guard.sql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0370847..40bcbf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ # extension-update-test -- IN-PLACE update: CREATE EXTENSION at 0.9.6 # then ALTER EXTENSION UPDATE (same PostgreSQL, # no pg_upgrade), same PG matrix as test. +# pg-upgrade-test -- BINARY pg_upgrade: install 0.9.6 on an OLD +# PostgreSQL major, binary-upgrade the cluster +# to a NEWER major, then update the extension +# to current - proves objects created on an +# old server still work when read on a new +# one. A smaller old_pg/new_pg matrix (not the +# full PG matrix - by far the most expensive +# job here, installing two full PostgreSQL +# majors and running the real pg_upgrade +# binary per leg). # pg-tle-test -- pg_tle DEPLOYMENT: fresh install registered # through AWS pg_tle's database-backed catalog # instead of a filesystem .control file. @@ -194,6 +204,102 @@ jobs: - name: Update 0.9.6 -> current and run the suite run: make verify-results TEST_LOAD_SOURCE=update + # Proves count_nulls survives a BINARY pg_upgrade (in-place catalog + # migration to a newer PostgreSQL major), not just an in-place extension + # update. Installs 0.9.6 on an old cluster, plants a dependency guard, + # binary-pg_upgrades to a newer cluster, updates the extension to current, + # then runs the suite against the REAL migrated objects in existing mode. + # No bridge-update step first: count_nulls has always been pure SQL + # functions with no SELECT-*-over-catalog views, so it has no known + # pg_upgrade-unsafe old version to bridge past. + # + # Deliberately not doing a stepwise every-major-in-sequence climb (one + # cluster walking 10->11->12->...->newest, vs. the single big jumps here): + # that would catch a regression specific to one particular major-to-major + # boundary, which would matter if count_nulls had views/functions touching + # catalog internals, but it doesn't - pure SQL functions over anyarray/ + # json/jsonb, nothing version-sensitive to break at a specific boundary. + # Revisit if count_nulls ever grows something catalog-touching. + # + # Not yet crossed with TEST_SCHEMA (a later phase adds that, once it can + # do so for both this job and extension-update-test together). + pg-upgrade-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' + strategy: + matrix: + old_pg: ["10", "12"] + new_pg: ["18"] + name: 🔄 Binary pg_upgrade ${{ matrix.old_pg }} → ${{ matrix.new_pg }} + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + env: + # Both clusters must use the same initdb options or pg_upgrade + # refuses to run. + INITDB_OPTS: --data-checksums --auth trust + steps: + - name: Start PostgreSQL ${{ matrix.old_pg }} + run: pg-start ${{ matrix.old_pg }} + - name: Recreate old cluster with data checksums enabled + run: | + pg_ctlcluster ${{ matrix.old_pg }} test stop + pg_dropcluster ${{ matrix.old_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 subsequent psql/createdb calls connect without -p. + pg_createcluster -p 5432 ${{ matrix.old_pg }} test -- $INITDB_OPTS + pg_ctlcluster ${{ matrix.old_pg }} test start + pg_isready -t 30 + - name: Check out the repo + uses: actions/checkout@v4 + - name: Install count_nulls into old cluster + run: make install + - name: Prepare the old cluster (install + dependency guard) + # prepare-old installs count_nulls at 0.9.6, then plants + proves + # the dependency guard, so a later accidental CASCADE drop anywhere + # in this job cannot silently make the eventual existing-mode run + # test a fresh install instead. + run: bin/test_existing prepare-old count_nulls_upgrade "" 0.9.6 + - name: Install PostgreSQL ${{ matrix.new_pg }} + run: apt-get install -y postgresql-${{ matrix.new_pg }} postgresql-server-dev-${{ matrix.new_pg }} + - name: Install count_nulls into new cluster + # PG_CONFIG must be specified explicitly: at this point both old + # and new PostgreSQL are installed, and the default pg_config on + # PATH may not be the new version's. + run: make install PG_CONFIG=/usr/lib/postgresql/${{ matrix.new_pg }}/bin/pg_config + - name: Stop old cluster, binary pg_upgrade to PostgreSQL ${{ matrix.new_pg }}, start new cluster + run: | + pg_ctlcluster ${{ matrix.old_pg }} test stop + pg_createcluster -p 5432 ${{ matrix.new_pg }} test -- $INITDB_OPTS + # PG17+ writes logs to $new_datadir/pg_upgrade_output.d/; older + # versions write to CWD. Search 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/${{ matrix.new_pg }}/bin/pg_upgrade \ + -b /usr/lib/postgresql/${{ matrix.old_pg }}/bin \ + -B /usr/lib/postgresql/${{ matrix.new_pg }}/bin \ + -d /var/lib/postgresql/${{ matrix.old_pg }}/test \ + -D /var/lib/postgresql/${{ matrix.new_pg }}/test \ + -o '-c config_file=/etc/postgresql/${{ matrix.old_pg }}/test/postgresql.conf' \ + -O '-c config_file=/etc/postgresql/${{ matrix.new_pg }}/test/postgresql.conf'" postgres \ + || { find /tmp/pg_upgrade_logs \ + /var/lib/postgresql/${{ matrix.new_pg }}/test/pg_upgrade_output.d \ + -name '*.log' 2>/dev/null | sort | xargs -r tail -n +1; exit 1; } + pg_ctlcluster ${{ matrix.new_pg }} test start + - name: Update the pg_upgraded extension to the current version + # Exercises ALTER EXTENSION UPDATE on genuinely pg_upgraded objects + # (the extension binary pg_upgrade just migrated), running the + # 0.9.6->stable update script. + run: bin/test_existing update count_nulls_upgrade + - name: Run the suite against the pg_upgraded database (existing mode) + # run-suite asserts the version, re-proves the dependency guard + # still blocks a non-CASCADE drop (i.e. it survived pg_upgrade), + # drops the guard, then runs the suite against the REAL pg_upgraded + # + updated database via --use-existing (so pg_regress does not + # drop/recreate it) - a plain fresh `make test` would silently test + # a fresh install instead of the migrated objects. + run: bin/test_existing run-suite count_nulls_upgrade "" + pg-tle-test: needs: [changes] if: needs.changes.outputs.docs_only != 'true' @@ -323,7 +429,7 @@ jobs: # `changes` job on a docs-only push), and fails if any failed or were # cancelled. all-checks-passed: - needs: [changes, test, extension-update-test, pg-tle-test] + needs: [changes, test, extension-update-test, pg-upgrade-test, pg-tle-test] if: always() runs-on: ubuntu-latest steps: diff --git a/bin/test_existing b/bin/test_existing new file mode 100755 index 0000000..d0c2cb6 --- /dev/null +++ b/bin/test_existing @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# +# Exercise the count_nulls test suite against a REAL database whose extension +# was installed/upgraded OUTSIDE this pg_regress invocation ("existing" mode) +# - specifically, a real binary pg_upgrade run. Unlike an in-place ALTER +# EXTENSION UPDATE (which test/install/load.sql's own 'update' mode handles +# entirely by itself - see `make test-update`), a real pg_upgrade is an +# external binary process pg_regress can't invoke itself, so THAT leg needs +# an external script to drive it. This is a smaller script than it might look +# like it needs to be: only the pieces that genuinely can't live inside a +# single pg_regress invocation are here. +# +# The pg-upgrade-test CI job repeats the same sequence: +# +# prepare-old (install + plant guard) -> [real pg_upgrade binary, in CI] -> +# update (ALTER EXTENSION UPDATE) -> run-suite (assert + run existing-mode) +# +# so it lives here once instead of being duplicated as inline YAML. Not +# CI-only: a developer can run any subcommand locally against a scratch +# database. Modeled on Postgres-Extensions/cat_tools's bin/test_existing. +# Two differences from that script: count_nulls ships no +# SELECT-*-over-catalog views, so it has no known pg_upgrade-unsafe old +# version to bridge past before running pg_upgrade; and its own suite has a +# legitimate (though harmless - always rolled back) DROP EXTENSION test, so +# here the guard is dropped before run-suite instead of surviving through it. +# +# USAGE: bin/test_existing [args] +# +# prepare-old DB SCHEMA INSTALL_VERSION +# Old-cluster prep for pg-upgrade-test: create DB + extension at +# INSTALL_VERSION in SCHEMA, then plant + prove the dependency guard. +# +# update DB [TO_VERSION] +# ALTER EXTENSION count_nulls UPDATE [TO 'TO_VERSION'] (empty => current). +# +# run-suite DB SCHEMA +# Assert the current version, re-prove the guard, drop it, then run the +# suite in existing mode (extension must be at the current version). +# +# Run `bin/test_existing` with no subcommand to print usage. +# +# Why the dependency guard: "existing" mode must run the suite against the +# ACTUAL upgraded objects. If anything silently dropped + reinstalled the +# extension (a stray CASCADE, a logic bug, a bad CI step), the suite would +# test a FRESH install and hide a regression. We plant an object that HARD- +# references a count_nulls member so a non-CASCADE DROP EXTENSION fails, and +# actively PROVE that (see bin/test_existing.sql/assert_guard.sql): if the +# drop unexpectedly succeeds, this script fails CI rather than silently +# passing. +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")")/.." + +# --------------------------------------------------------------------------- +# psql helpers +# --------------------------------------------------------------------------- + +psql_value() { + local db=$1 sql=$2 + psql -d "$db" -tAc "$sql" +} + +psql_do() { + local db=$1 + shift + psql -d "$db" -v ON_ERROR_STOP=1 "$@" +} + +# --------------------------------------------------------------------------- +# Version / guard helpers +# --------------------------------------------------------------------------- + +current_version() { + # EXTENSION_count_nulls_VERSION (the .control file's default_version), NOT + # PGXNVERSION (the PGXN distribution version, from META.in.json) - a + # version-less CREATE EXTENSION/ALTER EXTENSION UPDATE installs whatever + # the control file's default_version says, and count_nulls' is currently + # the 'stable' pseudo-version, not the last real release. Using PGXNVERSION + # here would compare an installed 'stable' against an expected real version + # number and always report a mismatch. See RELEASE.md's note on + # distribution vs. extension versions; the pg-tle-test CI job makes the + # same distinction for the same reason. + make -s print-EXTENSION_count_nulls_VERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p' +} + +installed_version() { + psql_value "$1" \ + "SELECT extversion FROM pg_extension WHERE extname = 'count_nulls'" +} + +# Plant the guard and PROVE it blocks a non-CASCADE drop. Call right after +# CREATE EXTENSION (and before any update/upgrade) so it persists through them. +plant_guard() { + local db=$1 schema=$2 + psql -d "$db" -v ON_ERROR_STOP=1 -v schema="$schema" -f bin/test_existing.sql/plant_guard.sql + assert_drop_blocked "$db" +} + +# The core safeguard self-check: a non-CASCADE DROP EXTENSION MUST fail while +# the guard exists. assert_guard.sql fails loudly (nonzero exit) if the drop +# unexpectedly succeeds, or if the extension/guard are missing afterward. +assert_drop_blocked() { + local db=$1 + psql -d "$db" -v ON_ERROR_STOP=1 -f bin/test_existing.sql/assert_guard.sql + echo "OK: non-CASCADE DROP EXTENSION is blocked in '$db' (dependency guard effective)" +} + +drop_guard() { + local db=$1 + psql -d "$db" -v ON_ERROR_STOP=1 -f bin/test_existing.sql/drop_guard.sql +} + +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: count_nulls 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 + # current). Use `if`, not `&&`: a false test under `set -e` would abort. + if [ -n "$to" ]; then to="TO '$to'"; fi + psql_do "$db" -c "ALTER EXTENSION count_nulls UPDATE $to" +} + +# CREATE EXTENSION count_nulls at VERSION, targeting SCHEMA - unless SCHEMA +# is empty, in which case it's created untouched, wherever the session's +# own default search_path resolves (ordinarily 'public'). A quoted empty +# identifier ("") is a real Postgres syntax error, so this can't just always +# emit `CREATE SCHEMA IF NOT EXISTS "$schema"` - the empty case has to skip +# that entirely, mirroring test/install/load.sql's own :count_nulls_has_schema +# branch. +create_extension_in_schema() { + local db=$1 schema=$2 version=$3 sql="" + if [ -n "$schema" ]; then + sql="CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; " + fi + psql_do "$db" -c "${sql}CREATE EXTENSION count_nulls VERSION '$version'" +} + +# --------------------------------------------------------------------------- +# Subcommand implementations +# --------------------------------------------------------------------------- + +# prepare-old DB SCHEMA INSTALL_VERSION +# Old-cluster preparation for pg-upgrade-test: create the database and the +# extension at INSTALL_VERSION in SCHEMA, then plant + prove the guard. No +# bridge-update step first: count_nulls ships no SELECT-*-over-catalog +# views, so it has no known pg_upgrade-unsafe old version to bridge past. +prepare_old() { + local db=$1 schema=$2 install=$3 + createdb "$db" + create_extension_in_schema "$db" "$schema" "$install" + plant_guard "$db" "$schema" +} + +# Run the pgTAP suite against an already-populated database in existing mode. +# Verifies count_nulls is at the current version, re-proves the guard still +# blocks a drop (i.e. it survived the update/upgrade), drops the guard (see +# the file header for why - count_nulls's own suite legitimately drops the +# extension, harmlessly, inside a transaction that's always rolled back), +# then runs the suite via --use-existing so pg_regress does NOT drop/recreate +# the database. +run_suite() { + local db=$1 schema=$2 + assert_version "$db" current + assert_drop_blocked "$db" + drop_guard "$db" + # In existing mode pg_regress runs against $db via --use-existing and must + # NOT create/drop its own database. `make test` (not just `make + # verify-results`) is a real gate as of pgxntool 2.3.0 - it now exits + # non-zero on regression failures instead of always exiting 0 regardless + # of pg_regress's result (see this repo's pgxntool 2.3.0 bump). + make test TEST_LOAD_SOURCE=existing TEST_SCHEMA="$schema" CONTRIB_TESTDB="$db" EXTRA_REGRESS_OPTS=--use-existing +} + +usage() { + echo "usage: bin/test_existing [args]" >&2 + echo " prepare-old DB SCHEMA INSTALL_VERSION" >&2 + echo " update DB [TO_VERSION]" >&2 + echo " run-suite DB SCHEMA" >&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 + prepare-old) prepare_old "$@" ;; + update) update_ext "$@" ;; + run-suite) run_suite "$@" ;; + *) usage ;; + esac +} + +main "$@" diff --git a/bin/test_existing.sql/assert_guard.sql b/bin/test_existing.sql/assert_guard.sql new file mode 100644 index 0000000..33946cf --- /dev/null +++ b/bin/test_existing.sql/assert_guard.sql @@ -0,0 +1,35 @@ +/* + * Proves the guard planted by plant_guard.sql actually blocks a + * non-CASCADE DROP EXTENSION - prove it, don't assume it. Re-run after + * every step (install, pg_upgrade, post-upgrade ALTER EXTENSION UPDATE): + * the guard disappearing at any point means a CASCADE drop happened + * somewhere upstream, i.e. the "existing" run downstream would actually be + * a silent fresh install. + * + * Usage: psql -v ON_ERROR_STOP=1 -f assert_guard.sql + */ +\set ON_ERROR_STOP on + +DO $$ +BEGIN + DROP EXTENSION count_nulls; + -- Only reached if the drop above unexpectedly succeeded. + RAISE EXCEPTION 'GUARD FAILURE: non-CASCADE DROP EXTENSION count_nulls unexpectedly succeeded'; +EXCEPTION WHEN dependent_objects_still_exist THEN + RAISE NOTICE 'guard held: DROP EXTENSION count_nulls correctly blocked'; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'count_nulls') THEN + RAISE EXCEPTION 'GUARD FAILURE: count_nulls extension missing after guard check'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'guard' AND n.nspname = 'count_nulls_drop_guard' + ) THEN + RAISE EXCEPTION 'GUARD FAILURE: count_nulls_drop_guard.guard view missing'; + END IF; +END +$$; diff --git a/bin/test_existing.sql/drop_guard.sql b/bin/test_existing.sql/drop_guard.sql new file mode 100644 index 0000000..744c3b5 --- /dev/null +++ b/bin/test_existing.sql/drop_guard.sql @@ -0,0 +1,11 @@ +/* + * Removes the guard (plant_guard.sql) once its job is done - proving the + * install/pg_upgrade/update steps didn't corrupt the real extension - so it + * doesn't then block the pgTap suite's own DROP EXTENSION test + * (test__shutdown__drop_all, run in a transaction that's rolled back + * regardless, so re-dropping the real extension there is harmless). + * + * Usage: psql -v ON_ERROR_STOP=1 -f drop_guard.sql + */ +\set ON_ERROR_STOP on +DROP SCHEMA count_nulls_drop_guard CASCADE; diff --git a/bin/test_existing.sql/plant_guard.sql b/bin/test_existing.sql/plant_guard.sql new file mode 100644 index 0000000..1c3e84d --- /dev/null +++ b/bin/test_existing.sql/plant_guard.sql @@ -0,0 +1,30 @@ +/* + * Dependency guard: plants an object with a hard pg_depend dependency on a + * stable, never-dropped/redefined extension member (null_count(anyarray), + * unchanged since 0.9.0), so that a non-CASCADE DROP EXTENSION count_nulls + * is blocked. Used by the pg_upgrade CI job to prove a real pg_upgrade/ + * update run didn't silently destroy the extension it's meant to be + * testing (a stray CASCADE drop, a logic bug, a bad CI step would + * otherwise fall through to a silent fresh reinstall and the job would + * still report green). + * + * Usage: psql -v ON_ERROR_STOP=1 -v schema= -f plant_guard.sql + * (empty schema means "wherever null_count already resolves unqualified" - + * i.e. count_nulls was installed without targeting a schema). + */ +\set ON_ERROR_STOP on + +/* + * schema_prefix: either empty, or the quoted schema name followed by a + * literal '.' - so the view definition below is a single statement with a + * plain (unquoted) substitution, rather than branching the whole CREATE + * VIEW on whether a schema was given. quote_ident(), not :"schema" - + * :schema_prefix is pasted as-is (unquoted substitution), so it must + * already be valid, properly-quoted SQL text by the time it lands there. + */ +SELECT CASE WHEN :'schema' <> '' THEN quote_ident(:'schema') || '.' ELSE '' END AS schema_prefix +\gset + +CREATE SCHEMA IF NOT EXISTS count_nulls_drop_guard; +CREATE OR REPLACE VIEW count_nulls_drop_guard.guard AS + SELECT :schema_prefix null_count(NULL::int, NULL::int) AS guarded_member; From 8647cf993ba329b7a35152a5ec1f81f377e38f81 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 16:13:33 -0500 Subject: [PATCH 4/6] Phase 5: cross extension-update-test/pg-upgrade-test with TEST_SCHEMA The novel piece this whole redesign was building toward: nobody in this org currently tests update/upgrade crossed with schema scenarios (checked directly - cat_tools' own extension-update-test/pg-upgrade-test matrices are PG-version-only, extension_tools has no U&U testing at all). Phase 2's schema-invariant assertion descriptions make this free - zero new expected-output files needed for either job, exactly as designed. - extension-update-test: added `schema: ["", Quoted]` to the matrix, TEST_SCHEMA env var - the job's own `make verify-results TEST_LOAD_SOURCE=update` picks it up automatically via Make's environment auto-import. - pg-upgrade-test: added the same schema axis (old_pg/new_pg were already plain matrix dimensions, not an `include:` list, so adding a third axis cross-products cleanly into the same 4 legs cat_tools would produce for 2 old_pg values x 2 schema values), and threaded matrix.schema through to bin/test_existing's prepare-old/run-suite calls (previously hardcoded to ""). Verified locally against PG17: prepare-old -> update -> run-suite passes end to end with TEST_SCHEMA=Quoted (previously only verified with an untargeted schema). Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40bcbf2..481e069 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,21 +10,28 @@ # see TEST_SCHEMA in the Makefile). # extension-update-test -- IN-PLACE update: CREATE EXTENSION at 0.9.6 # then ALTER EXTENSION UPDATE (same PostgreSQL, -# no pg_upgrade), same PG matrix as test. +# no pg_upgrade), same PG x schema matrix as +# test. # pg-upgrade-test -- BINARY pg_upgrade: install 0.9.6 on an OLD # PostgreSQL major, binary-upgrade the cluster # to a NEWER major, then update the extension # to current - proves objects created on an # old server still work when read on a new -# one. A smaller old_pg/new_pg matrix (not the -# full PG matrix - by far the most expensive -# job here, installing two full PostgreSQL -# majors and running the real pg_upgrade -# binary per leg). +# one. A smaller old_pg/new_pg x schema matrix +# (not the full PG matrix - by far the most +# expensive job here, installing two full +# PostgreSQL majors and running the real +# pg_upgrade binary per leg). # pg-tle-test -- pg_tle DEPLOYMENT: fresh install registered # through AWS pg_tle's database-backed catalog # instead of a filesystem .control file. # +# test / extension-update-test / pg-upgrade-test all cross PostgreSQL major +# with the TEST_SCHEMA axis: without a schema specified at all (empty) and +# with one explicitly targeted, using a name that requires SQL identifier +# quoting. Every leg of every job passes against the SAME expected output - +# see test/README.md for how the suite stays schema/load-mode invariant. +# # `changes` is a cheap gate that lets the heavy jobs above skip themselves on # doc-only pushes, and also derives the shared PostgreSQL-major list those # jobs consume from a single set of constants. `all-checks-passed` is the @@ -191,9 +198,12 @@ jobs: matrix: # From the single source in the changes job. pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }} - name: ⬆️ Extension update test on PostgreSQL ${{ matrix.pg }} + schema: ["", Quoted] + name: ⬆️ Extension update test on PostgreSQL ${{ matrix.pg }} (schema ${{ matrix.schema == '' && 'none' || matrix.schema }}) runs-on: ubuntu-latest container: pgxn/pgxn-tools + env: + TEST_SCHEMA: ${{ matrix.schema }} steps: - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} @@ -221,8 +231,6 @@ jobs: # json/jsonb, nothing version-sensitive to break at a specific boundary. # Revisit if count_nulls ever grows something catalog-touching. # - # Not yet crossed with TEST_SCHEMA (a later phase adds that, once it can - # do so for both this job and extension-update-test together). pg-upgrade-test: needs: [changes] if: needs.changes.outputs.docs_only != 'true' @@ -230,7 +238,8 @@ jobs: matrix: old_pg: ["10", "12"] new_pg: ["18"] - name: 🔄 Binary pg_upgrade ${{ matrix.old_pg }} → ${{ matrix.new_pg }} + schema: ["", Quoted] + name: 🔄 Binary pg_upgrade ${{ matrix.old_pg }} → ${{ matrix.new_pg }} (schema ${{ matrix.schema == '' && 'none' || matrix.schema }}) runs-on: ubuntu-latest container: pgxn/pgxn-tools env: @@ -259,7 +268,7 @@ jobs: # the dependency guard, so a later accidental CASCADE drop anywhere # in this job cannot silently make the eventual existing-mode run # test a fresh install instead. - run: bin/test_existing prepare-old count_nulls_upgrade "" 0.9.6 + run: bin/test_existing prepare-old count_nulls_upgrade "${{ matrix.schema }}" 0.9.6 - name: Install PostgreSQL ${{ matrix.new_pg }} run: apt-get install -y postgresql-${{ matrix.new_pg }} postgresql-server-dev-${{ matrix.new_pg }} - name: Install count_nulls into new cluster @@ -298,7 +307,7 @@ jobs: # + updated database via --use-existing (so pg_regress does not # drop/recreate it) - a plain fresh `make test` would silently test # a fresh install instead of the migrated objects. - run: bin/test_existing run-suite count_nulls_upgrade "" + run: bin/test_existing run-suite count_nulls_upgrade "${{ matrix.schema }}" pg-tle-test: needs: [changes] From 4c3ea52efdf5eb398b1bac8190cd69ad6b5d856a Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 16:17:04 -0500 Subject: [PATCH 5/6] Phase 6a: structural fresh-vs-update comparison (bin/compare_fresh_vs_update) Adds the one checklist item genuinely missing everywhere (cat_tools' own PR #46 only did this manually, uncommitted, per the design doc this whole series is based on): a script that installs fresh and 0.9.6-then-updated copies of count_nulls in their own scratch databases and diffs pg_get_functiondef/comments/ACLs for every object the extension owns (discovered live via pg_depend, not a hardcoded object list). Wired into extension-update-test as an automatic step, crossed with TEST_SCHEMA same as the rest of that job. Also scopes out extending pg-tle-test to the update path (documented inline, and filed as Postgres-Extensions/pgxntool#90): pgxntool 2.3.0's own fix for installcheck's ordering bug (#83) made installcheck unconditionally depend on install, which writes a real .control file to disk - defeating the entire point of proving a pg_tle deployment never touches the filesystem. There's currently no way to invoke the real pgTAP suite without a filesystem install happening first. The fresh-install pg_tle smoke test (already on master via #16) is unaffected, since it never calls installcheck. Verified locally against PG17: fresh/update x empty/Quoted all pass via make verify-results; bin/compare_fresh_vs_update reports identical definitions for both schema legs. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yml | 17 ++++++ bin/compare_fresh_vs_update | 103 ++++++++++++++++++++++++++++++++++++ test/README.md | 8 +++ 3 files changed, 128 insertions(+) create mode 100755 bin/compare_fresh_vs_update diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 481e069..3a2deb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,14 @@ jobs: run: make install - name: Update 0.9.6 -> current and run the suite run: make verify-results TEST_LOAD_SOURCE=update + - name: Structurally compare the updated objects against a fresh install + # A fixed pgTAP suite only proves the specific behaviors it asserts + # still hold; it can't catch an update script that leaves some + # definition/comment/ACL subtly different from what a fresh install + # of the same version produces. bin/compare_fresh_vs_update installs + # both ways itself (in its own scratch databases) and diffs every + # object the extension owns - any nonempty diff fails the step. + run: bin/compare_fresh_vs_update "${{ matrix.schema }}" 0.9.6 # Proves count_nulls survives a BINARY pg_upgrade (in-place catalog # migration to a newer PostgreSQL major), not just an in-place extension @@ -309,6 +317,15 @@ jobs: # a fresh install instead of the migrated objects. run: bin/test_existing run-suite count_nulls_upgrade "${{ matrix.schema }}" + # Fresh-install smoke test only, deliberately - NOT extended to the + # update path. pgxntool 2.3.0's fix for installcheck's ordering bug + # (Postgres-Extensions/pgxntool#83) made `installcheck` (and so `make + # test`) unconditionally depend on `install`, which writes a real + # .control file to disk - defeating the entire point of proving a pg_tle + # deployment never touches the filesystem. There's currently no way to + # invoke the real pgTAP suite without that happening first; filed as + # Postgres-Extensions/pgxntool#90. Revisit extending this job to the + # update path once that's resolved. pg-tle-test: needs: [changes] if: needs.changes.outputs.docs_only != 'true' diff --git a/bin/compare_fresh_vs_update b/bin/compare_fresh_vs_update new file mode 100755 index 0000000..24df130 --- /dev/null +++ b/bin/compare_fresh_vs_update @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# +# compare_fresh_vs_update - structurally compare every object the count_nulls +# extension owns between a FRESH install (CREATE EXTENSION at current) and an +# UPDATED one (CREATE EXTENSION at FROM_VERSION, then ALTER EXTENSION +# UPDATE), in the same schema. A fixed pgTAP expected-output suite (see +# TEST_LOAD_SOURCE in the Makefile) only proves the specific behaviors it +# happens to assert still work - it does not prove an update script left +# object definitions/comments/ACLs BYTE-FOR-BYTE identical to a fresh +# install of the same version. This catches divergence classes a fixed +# suite doesn't already know to test for. +# +# Modeled on the manual technique used in Postgres-Extensions/cat_tools#46, +# which found a real bug this way (a pre-0.2.2 update path left +# `EXECUTE PROCEDURE` hardcoded in a trigger body that fresh installs had +# already updated to `EXECUTE FUNCTION`). Unlike that PR, this is committed, +# reusable tooling rather than a one-off manual diff. +# +# What's compared, per object the extension owns (discovered live via +# pg_depend - see extension_members(), not a hardcoded object list, so a +# newly added function is automatically covered without editing this +# script): pg_get_functiondef() (full definition: schema, args, body, +# volatility, strictness - everything), its comment (obj_description), and +# its ACL (proacl). count_nulls currently ships only functions/triggers, no +# views - the doc this is modeled on also compares pg_get_viewdef/type +# labels/extension membership for extensions that have those; add a query +# for the relevant catalog (pg_class for views, pg_type for types, ...) the +# same way if count_nulls ever grows one. +# +# USAGE: bin/compare_fresh_vs_update [SCHEMA] [FROM_VERSION] +# SCHEMA - schema both installs target (default: unqualified, same +# as TEST_SCHEMA empty - see the Makefile). Both installs +# use the SAME schema, since the point is comparing object +# definitions, not exercising schema-qualification (that's +# TEST_SCHEMA's job in the regular suite). +# FROM_VERSION - the update origin (default: 0.9.6, the oldest version +# count_nulls still ships a full install script for). +# +# Exits nonzero (and prints a real diff) on ANY difference. Scratch +# databases are dropped on exit regardless of outcome. +set -euo pipefail + +cd "$(dirname "$(readlink -f "$0")")/.." + +schema=${1:-} +from_version=${2:-0.9.6} + +fresh_db=compare_fresh_vs_update_fresh +update_db=compare_fresh_vs_update_updated +fresh_snapshot=$(mktemp) +update_snapshot=$(mktemp) + +cleanup() { + dropdb --if-exists "$fresh_db" + dropdb --if-exists "$update_db" + rm -f "$fresh_snapshot" "$update_snapshot" +} +trap cleanup EXIT + +# extension_members(): every object pg_depend records as owned by the +# count_nulls extension (deptype 'e'), restricted to pg_proc for now (see +# the header comment on extending this). Ordered by name/args so the two +# snapshots line up for a textual diff regardless of OID assignment order, +# which differs between a fresh install and an update. +query() { + cat <<'SQL' +SELECT + '-- ' || p.oid::regprocedure::text || E'\n' + || pg_get_functiondef(p.oid) || E'\n' + || '-- comment: ' || coalesce(obj_description(p.oid, 'pg_proc'), '(none)') || E'\n' + || '-- acl: ' || coalesce(p.proacl::text, '(default)') || E'\n' +FROM pg_depend d +JOIN pg_extension x ON d.refobjid = x.oid AND x.extname = 'count_nulls' +JOIN pg_proc p ON d.objid = p.oid AND d.classid = 'pg_proc'::regclass +WHERE d.deptype = 'e' +ORDER BY p.proname, p.oid::regprocedure::text; +SQL +} + +install_in_schema() { + local sql="" + if [ -n "$schema" ]; then + sql="CREATE SCHEMA IF NOT EXISTS \"$schema\"; SET search_path = \"$schema\"; " + fi + echo "$sql" +} + +createdb "$fresh_db" +psql -d "$fresh_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls" + +createdb "$update_db" +psql -d "$update_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls VERSION '$from_version'" +psql -d "$update_db" -v ON_ERROR_STOP=1 -c "SET client_min_messages = WARNING; ALTER EXTENSION count_nulls UPDATE" + +psql -d "$fresh_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$fresh_snapshot" +psql -d "$update_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$update_snapshot" + +if diff -u "$fresh_snapshot" "$update_snapshot"; then + echo "OK: fresh install and $from_version->current update produce IDENTICAL object definitions/comments/ACLs" +else + echo "FAIL: update path diverges from a fresh install of the same version - see diff above" >&2 + exit 1 +fi diff --git a/test/README.md b/test/README.md index 4d87210..4c34efb 100644 --- a/test/README.md +++ b/test/README.md @@ -23,6 +23,14 @@ then invoke via `runtests()`. `test__*` functions covering function definitions, immutability/ strictness, and behavior across `anyarray`/`json`/`jsonb` and both trigger functions. +- `../bin/compare_fresh_vs_update` — not part of the pgTAP suite itself: a + standalone script the `extension-update-test` CI job runs after + `TEST_LOAD_SOURCE=update`, which installs fresh and 0.9.6-then-updated + copies of the extension in their own scratch databases and diffs + `pg_get_functiondef`/comments/ACLs for every object the extension owns. + Catches an update script leaving some definition subtly different from a + fresh install, even when the fixed pgTAP suite above still passes (it + only asserts the specific behaviors it happens to check). - `sql/extension_tests.sql` — `\i`'s `core/functions.sql`, adds two more `test__*` functions of its own (`test__check_ncs`, asserting count_nulls landed where expected; `test__shutdown__drop_all`, asserting it can be From 660e71f79d312e748abfd3071de50b53542b2051 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 4 Aug 2026 13:18:59 -0500 Subject: [PATCH 6/6] Run bin/compare_fresh_vs_update against the real pg_upgraded database too extension-update-test already runs the structural comparison against its own scratch fresh/updated database pair, but pg-upgrade-test only ran the fixed pgTAP suite against the actual binary-pg_upgraded + updated database - a divergence introduced by surviving a real catalog migration (as opposed to only an in-place ALTER EXTENSION UPDATE) would go uncaught. Add an optional EXISTING_DB argument so the script can compare against an already-populated database instead of always creating and updating its own, and wire a new step into pg-upgrade-test that points it at count_nulls_upgrade. Verified locally (this container's PG17, no real pg_upgrade run - that's shared persistent dev infra): confirmed existing scratch-db usage still passes, confirmed the new EXISTING_DB path passes against a manually simulated update-only database and leaves it undropped afterward, then introduced a deliberate COMMENT ON divergence and confirmed the script correctly fails and still leaves the caller-owned database in place. --- .github/workflows/ci.yml | 10 +++++++++ bin/compare_fresh_vs_update | 41 +++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a2deb1..91793b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -316,6 +316,16 @@ jobs: # drop/recreate it) - a plain fresh `make test` would silently test # a fresh install instead of the migrated objects. run: bin/test_existing run-suite count_nulls_upgrade "${{ matrix.schema }}" + - name: Structurally compare the pg_upgraded database against a fresh install + # Same rationale as extension-update-test's own use of this tool (see + # below), but here the "other side" is the REAL database a binary + # pg_upgrade + ALTER EXTENSION UPDATE just produced, not a scratch + # database this tool created itself - passed as EXISTING_DB so the + # script queries it in place instead of re-deriving it. Catches a + # divergence class the fixed pgTAP suite above doesn't: an object + # left subtly different (body, comment, ACL) by surviving a real + # catalog migration, as opposed to only an in-place update. + run: bin/compare_fresh_vs_update "${{ matrix.schema }}" 0.9.6 count_nulls_upgrade # Fresh-install smoke test only, deliberately - NOT extended to the # update path. pgxntool 2.3.0's fix for installcheck's ordering bug diff --git a/bin/compare_fresh_vs_update b/bin/compare_fresh_vs_update index 24df130..b21fb59 100755 --- a/bin/compare_fresh_vs_update +++ b/bin/compare_fresh_vs_update @@ -27,7 +27,7 @@ # for the relevant catalog (pg_class for views, pg_type for types, ...) the # same way if count_nulls ever grows one. # -# USAGE: bin/compare_fresh_vs_update [SCHEMA] [FROM_VERSION] +# USAGE: bin/compare_fresh_vs_update [SCHEMA] [FROM_VERSION] [EXISTING_DB] # SCHEMA - schema both installs target (default: unqualified, same # as TEST_SCHEMA empty - see the Makefile). Both installs # use the SAME schema, since the point is comparing object @@ -35,24 +35,42 @@ # TEST_SCHEMA's job in the regular suite). # FROM_VERSION - the update origin (default: 0.9.6, the oldest version # count_nulls still ships a full install script for). +# Ignored when EXISTING_DB is given - that database's +# history is whatever already produced it. +# EXISTING_DB - compare against this ALREADY-POPULATED database instead +# of creating+updating a scratch one (default: none, create +# our own scratch "updated" database as before). Lets +# callers that produced their update/upgrade result some +# other way - e.g. bin/test_existing's real binary +# pg_upgrade path - reuse this same comparison without this +# script re-deriving that database itself. The caller owns +# EXISTING_DB's lifecycle: it is never created, updated, or +# dropped here, only queried. # # Exits nonzero (and prints a real diff) on ANY difference. Scratch -# databases are dropped on exit regardless of outcome. +# database(s) this script created itself are dropped on exit regardless of +# outcome; an EXISTING_DB passed in is left untouched. set -euo pipefail cd "$(dirname "$(readlink -f "$0")")/.." schema=${1:-} from_version=${2:-0.9.6} +existing_db=${3:-} fresh_db=compare_fresh_vs_update_fresh -update_db=compare_fresh_vs_update_updated +update_db=${existing_db:-compare_fresh_vs_update_updated} fresh_snapshot=$(mktemp) update_snapshot=$(mktemp) cleanup() { dropdb --if-exists "$fresh_db" - dropdb --if-exists "$update_db" + # Only drop update_db if we created it ourselves - an EXISTING_DB belongs + # to the caller (e.g. the real pg_upgraded database bin/test_existing is + # still using) and must survive this script running. + if [ -z "$existing_db" ]; then + dropdb --if-exists "$update_db" + fi rm -f "$fresh_snapshot" "$update_snapshot" } trap cleanup EXIT @@ -88,16 +106,21 @@ install_in_schema() { createdb "$fresh_db" psql -d "$fresh_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls" -createdb "$update_db" -psql -d "$update_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls VERSION '$from_version'" -psql -d "$update_db" -v ON_ERROR_STOP=1 -c "SET client_min_messages = WARNING; ALTER EXTENSION count_nulls UPDATE" +if [ -z "$existing_db" ]; then + createdb "$update_db" + psql -d "$update_db" -v ON_ERROR_STOP=1 -c "$(install_in_schema)CREATE EXTENSION count_nulls VERSION '$from_version'" + psql -d "$update_db" -v ON_ERROR_STOP=1 -c "SET client_min_messages = WARNING; ALTER EXTENSION count_nulls UPDATE" +fi psql -d "$fresh_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$fresh_snapshot" psql -d "$update_db" -tA -v ON_ERROR_STOP=1 -c "$(query)" > "$update_snapshot" +source_desc="$from_version->current update" +[ -n "$existing_db" ] && source_desc="'$existing_db'" + if diff -u "$fresh_snapshot" "$update_snapshot"; then - echo "OK: fresh install and $from_version->current update produce IDENTICAL object definitions/comments/ACLs" + echo "OK: fresh install and $source_desc produce IDENTICAL object definitions/comments/ACLs" else - echo "FAIL: update path diverges from a fresh install of the same version - see diff above" >&2 + echo "FAIL: $source_desc diverges from a fresh install of the same version - see diff above" >&2 exit 1 fi