Overhaul the test harness: committed-once install, load modes, existing-mode CI #234
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: | |
| - master | |
| pull_request: | |
| env: | |
| PGUSER: postgres | |
| jobs: | |
| # Cheap gate that lets the heavy jobs below skip themselves on commits | |
| # that touch only docs. This 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. | |
| changes: | |
| name: 🔍 Detect docs-only changes | |
| runs-on: ubuntu-latest | |
| outputs: | |
| docs_only: ${{ steps.diff.outputs.docs_only }} | |
| steps: | |
| - name: Check out the repo | |
| uses: actions/checkout@v6 | |
| 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: | | |
| 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" | |
| # Fail-safe: 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. Run the full matrix rather | |
| # than risk skipping tests. | |
| if [ -z "$HEAD" ] || [ -z "$BASE" ] || [[ "$BASE" =~ ^0+$ ]]; then | |
| echo "docs_only=false" >> "$GITHUB_OUTPUT" | |
| 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" | |
| test: | |
| needs: [changes] | |
| if: needs.changes.outputs.docs_only != 'true' | |
| strategy: | |
| matrix: | |
| pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] | |
| name: 🐘 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@v6 | |
| - name: Install rsync | |
| run: apt-get install -y rsync | |
| - name: Test on PostgreSQL ${{ matrix.pg }} | |
| run: make test | |
| # Proves cat_tools survives a BINARY pg_upgrade (in-place catalog migration to a | |
| # newer PostgreSQL major), not just an in-place extension update. Installs an old | |
| # cat_tools 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. Catches view/catalog breakage | |
| # that only surfaces when objects created on an old server are read on a new one. | |
| pg-upgrade-test: | |
| needs: [changes] | |
| if: needs.changes.outputs.docs_only != 'true' | |
| strategy: | |
| matrix: | |
| include: | |
| # old_pg=10: install an old cat_tools on the oldest PostgreSQL, then | |
| # BRIDGE-update to 0.2.3 (the current version) on PG10 BEFORE pg_upgrade | |
| # -- modelling a user who updates the extension, then binary-upgrades to | |
| # a newer major. | |
| # | |
| # old_install SHOULD be 0.2.0: proving that a database installed at the | |
| # OLDEST version can update to 0.2.3 and then survive binary pg_upgrade | |
| # is the whole point of this leg. It is TEMPORARILY pinned to a FRESH | |
| # 0.2.2 because a 0.2.0-origin database cannot yet survive the journey -- | |
| # the shipped 0.2.x update scripts have fresh-vs-update divergences that | |
| # break on newer PostgreSQL. Specifically cat_tools.trigger__parse as | |
| # produced by the update scripts hardcodes `EXECUTE PROCEDURE` (PG11+ | |
| # needs an `EXECUTE (?:PROCEDURE|FUNCTION)` match) and omits the | |
| # empty-args guard, so it errors with "cannot determine type of empty | |
| # array" when the suite parses a trigger (surfaces on PG18). (The | |
| # pg_class_v view divergence is already repaired by 0.2.2->0.2.3.) A | |
| # FRESH 0.2.2 has the corrected trigger__parse and already strips | |
| # relhasoids/relhaspkey (fixed `!= ALL` omit_column), so it is both | |
| # pg_upgrade-safe and free of that divergence. | |
| # | |
| # Restore old_install=0.2.0 once the 0.2.2->0.2.3 repair also fixes | |
| # trigger__parse (and any remaining divergence) -- that work is in | |
| # progress. Meanwhile the 0.2.0-only install and its 0.2.2->0.2.3 view | |
| # rebuild stay covered by extension-update-test on PG10. | |
| - old_pg: "10" | |
| new_pg: "11" | |
| old_install: "0.2.2" | |
| bridge_to: "0.2.3" | |
| - old_pg: "10" | |
| new_pg: "18" | |
| old_install: "0.2.2" | |
| bridge_to: "0.2.3" | |
| # old_pg>=11: such users are already on 0.2.2+. A FRESH 0.2.2 install | |
| # already strips relhasoids/relhaspkey (fixed `!= ALL` omit_column), so | |
| # it is pg_upgrade-safe with no bridge. pg_upgrade, then update to current. | |
| - old_pg: "11" | |
| new_pg: "12" | |
| old_install: "0.2.2" | |
| bridge_to: "" | |
| - old_pg: "11" | |
| new_pg: "18" | |
| old_install: "0.2.2" | |
| bridge_to: "" | |
| - old_pg: "12" | |
| new_pg: "13" | |
| old_install: "0.2.2" | |
| bridge_to: "" | |
| - old_pg: "12" | |
| new_pg: "18" | |
| old_install: "0.2.2" | |
| bridge_to: "" | |
| 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 so pg_upgrade sees | |
| # consistent settings (checksums, auth) on old and new clusters. | |
| 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@v6 | |
| - name: Install rsync | |
| run: apt-get install -y rsync | |
| - name: Install cat_tools into old cluster | |
| run: make install | |
| - name: Prepare the old cluster (install + bridge + dependency guard) | |
| # prepare-old installs cat_tools at old_install and, when bridge_to is set | |
| # (old_pg=10), ALTER EXTENSION UPDATEs to it BEFORE pg_upgrade -- the | |
| # bridge that makes the views pg_upgrade-safe (see prepare-old in the | |
| # script). It then plants + proves the dependency guard so the later | |
| # existing-mode run cannot silently drop+reinstall and test a fresh | |
| # install instead. The extension is always older than or equal to | |
| # current, so the post-upgrade ALTER EXTENSION UPDATE is exercised too. | |
| run: >- | |
| bin/test_existing prepare-old cat_tools_upgrade | |
| "${{ matrix.old_install }}" "${{ matrix.bridge_to }}" | |
| - name: Install PostgreSQL ${{ matrix.new_pg }} | |
| run: apt-get install -y postgresql-${{ matrix.new_pg }} postgresql-server-dev-${{ matrix.new_pg }} | |
| - name: Install cat_tools 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 | |
| # version-to-version scripts up to the current version. A no-op when the | |
| # bridge already reached current (old_pg=10), which still verifies UPDATE | |
| # does not error on pg_upgraded objects. | |
| run: bin/test_existing update cat_tools_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), 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 cat_tools_upgrade | |
| # Proves the in-place extension update path: CREATE EXTENSION at an OLD cat_tools | |
| # version then ALTER EXTENSION UPDATE (no pg_upgrade, same PostgreSQL). On PG12+ | |
| # it updates 0.2.2 -> current and runs the FULL suite against the updated | |
| # database (same expected output as a fresh install, so an updated DB must behave | |
| # identically). The PG10 leg only exercises the pre-0.2.2 update scripts, the | |
| # sole version where they still load. Complements pg-upgrade-test, which covers | |
| # the cross-major-version binary upgrade instead. | |
| extension-update-test: | |
| needs: [changes] | |
| if: needs.changes.outputs.docs_only != 'true' | |
| strategy: | |
| matrix: | |
| # The update-scenario legs (0.2.2 -> current + full suite, existing mode) | |
| # run on PG12-18. 0.2.2 is the update-from floor: the 0.2.0/0.2.1 install | |
| # scripts fail on PG11+/PG12+. | |
| # PG10 is the ONLY version where the pre-0.2.2 install scripts still load, | |
| # so it is the only place the 0.2.0->0.2.2 / 0.2.1->0.2.2 update scripts | |
| # and the 0.2.2->0.2.3 view rebuild on the broken path can be exercised. | |
| # It runs only those legacy checks (no current-version suite; that is | |
| # covered on PG10 by the `test` job). See the per-step `if` guards. | |
| pg: [18, 17, 16, 15, 14, 13, 12, 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@v6 | |
| - name: Install rsync | |
| run: apt-get install -y rsync | |
| - name: Install cat_tools (all versions) | |
| run: make install | |
| - name: Test pre-0.2.2 update scripts + 0.2.2→0.2.3 rebuild (PG10 only) | |
| # 0.2.0/0.2.1 install only on PG10; their update scripts target 0.2.2 (not | |
| # the current version) and are otherwise never exercised. PG takes the | |
| # shortest path, so 0.2.0 → 0.2.2 uses the direct 0.2.0--0.2.2 script; | |
| # starting at 0.2.1 exercises the 0.2.1--0.2.2 script directly. update-check | |
| # asserts each lands on 0.2.2. | |
| # | |
| # The third check exercises the REAL broken path end to end: a 0.2.0 | |
| # install on PG10 leaves relhasoids in _cat_tools.pg_class_v (the buggy | |
| # 0.2.0--0.2.2 omit_column no-op), and updating to 0.2.3 routes through | |
| # 0.2.2--0.2.3 (shortest path 0.2.0--0.2.2 then 0.2.2--0.2.3), firing the | |
| # conditional rebuild that strips relhasoids. On PG12+ relhasoids never | |
| # existed, so only PG10 exercises the rebuild. The psql assertion fails | |
| # loudly if the rebuild did not fire (relhasoids still present) -- | |
| # complementing the stronger 10→18 pg_upgrade bridge leg. `$$` is escaped | |
| # as `\$\$` so the shell passes literal dollar quotes through to psql. | |
| if: matrix.pg == '10' | |
| run: | | |
| bin/test_existing update-check cat_tools_from_020 0.2.0 0.2.2 | |
| bin/test_existing update-check cat_tools_from_021 0.2.1 0.2.2 | |
| bin/test_existing update-check cat_tools_rebuild_020 0.2.0 0.2.3 | |
| psql -d cat_tools_rebuild_020 -v ON_ERROR_STOP=1 -c "DO \$\$ BEGIN IF EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid='_cat_tools.pg_class_v'::regclass AND attname='relhasoids' AND NOT attisdropped AND attnum>0) THEN RAISE EXCEPTION 'pg_class_v still exposes relhasoids after update through 0.2.2->0.2.3 -- rebuild did not fire'; END IF; END \$\$" | |
| - name: Update 0.2.2 → current and run the suite (existing mode, PG12+) | |
| # update-scenario creates a real database at 0.2.2, plants + proves the | |
| # dependency guard, ALTER EXTENSION UPDATEs to the current version, and | |
| # runs the suite against that updated database in existing mode (asserting | |
| # the version and that the guard still blocks a drop). Reusing the SAME | |
| # suite and expected output asserts the updated database behaves | |
| # identically to a fresh install. | |
| if: matrix.pg != '10' | |
| run: bin/test_existing update-scenario cat_tools_update 0.2.2 | |
| # 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 | |
| # 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, pg-upgrade-test, extension-update-test] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - 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 |