name: CI
on:
  push:
    branches:
      - master
  pull_request:
concurrency:
  # Don't let a superseded push's full matrix (dozens of legs, several
  # compiling pg_tle from source) keep occupying runner slots once a newer
  # push on the same ref supersedes it. Not for master: every push there is
  # its own point in history and should get a complete, uncancelled run.
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
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.
  #
  # It also derives, from a SINGLE set of constants, the supported-PostgreSQL-
  # major lists that the test / extension-update / stepwise jobs consume (see
  # the "Derive ..." step). Because those lists live here and every heavy job
  # already needs this job, adding a PG major is a one-line edit with no new job.
  changes:
    name: 🔍 Detect changes & derive PG matrix
    runs-on: ubuntu-latest
    permissions:
      # Needed only by "Find the last commit where real code changed" below,
      # to query the Actions API for a past run's conclusion/URL.
      actions: read
    outputs:
      docs_only: ${{ steps.diff.outputs.docs_only }}
      # Set when docs_only is true: whether there's a real "last tested" commit
      # to report at all (false), or this entire PR/push chain has never
      # touched anything but docs (true, docs_only_pr) -- see the "Find the
      # last commit..." step below for how these are derived.
      docs_only_pr: ${{ steps.last_tested.outputs.docs_only_pr }}
      last_tested_sha: ${{ steps.last_tested.outputs.last_tested_sha }}
      last_tested_conclusion: ${{ steps.last_tested.outputs.last_tested_conclusion }}
      last_tested_url: ${{ steps.last_tested.outputs.last_tested_url }}
      # Derived PG-major lists (see the "Derive ..." step for their meaning).
      supported_pg: ${{ steps.pg.outputs.supported_pg }}
      climb_pg: ${{ steps.pg.outputs.climb_pg }}
      legacy_pg: ${{ steps.pg.outputs.legacy_pg }}
    steps:
      - name: Check out the repo
        uses: actions/checkout@v7
        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"

      - name: Find the last commit where real code changed
        # This push is docs-only, so the heavy jobs below are about to skip --
        # meaning THIS run's checks don't cover the code currently on this
        # branch. Someone reviewing before merge needs to know whether that
        # code was actually fully tested somewhere, and whether it was green.
        # Walk backward from HEAD (bounded by the PR's base, or a generous
        # depth cap for a direct push) to the newest commit whose OWN diff
        # from its parent touched something other than docs -- every commit
        # strictly after that one only changed docs, so that commit's CI run
        # is still the authoritative test of the code on this branch now.
        if: steps.diff.outputs.docs_only == 'true'
        id: last_tested
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPO: ${{ github.repository }}
          PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
          # actions/checkout defaults to checking out an EPHEMERAL MERGE
          # COMMIT for pull_request events (refs/pull/N/merge), not the PR's
          # real head commit -- `git rev-parse HEAD` would return that merge
          # commit's SHA, and walking `^` from it follows the wrong (base
          # branch) first parent, landing on an unrelated commit entirely
          # (confirmed hitting exactly this in real CI). Use the actual PR
          # head SHA directly instead, same event-type split already used in
          # "Compute per-push changed files" above.
          PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
        run: |
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            HEAD_SHA="$PR_HEAD_SHA"
          else
            HEAD_SHA="${{ github.sha }}"
          fi
          if [ -n "$PR_BASE_SHA" ] && ! [[ "$PR_BASE_SHA" =~ ^0+$ ]]; then
            BOUNDARY=$(git merge-base "$PR_BASE_SHA" "$HEAD_SHA")
          else
            # Not a PR (e.g. a direct push to master): no PR base to bound
            # the walk against, so cap it at a generous depth instead of
            # walking arbitrarily far into history.
            BOUNDARY=$(git rev-list --max-count=1 --skip=100 "$HEAD_SHA" 2>/dev/null || echo "$HEAD_SHA")
          fi

          commit="$HEAD_SHA"
          last_code_commit=""
          while [ "$commit" != "$BOUNDARY" ]; do
            parent=$(git rev-parse "$commit^" 2>/dev/null) || break
            changed=$(git diff --name-only "$parent" "$commit")
            all_docs=true
            while IFS= read -r f; do
              [ -z "$f" ] && continue
              if ! [[ "$f" =~ \.(md|asc)$ ]]; then all_docs=false; break; fi
            done <<< "$changed"
            if [ "$all_docs" = true ]; then
              commit="$parent"
            else
              last_code_commit="$commit"
              break
            fi
          done

          if [ -z "$last_code_commit" ]; then
            # Reached the PR's own base (or the depth cap) without ever
            # finding a commit that changed code: this whole PR/push chain
            # is docs-only, so there is no "last tested" run to point to.
            echo "docs_only_pr=true" >> "$GITHUB_OUTPUT"
            echo "This entire PR/push contains only documentation changes -- no code testing applies."
            exit 0
          fi

          echo "docs_only_pr=false" >> "$GITHUB_OUTPUT"
          echo "last_tested_sha=$last_code_commit" >> "$GITHUB_OUTPUT"
          echo "last code-changing commit: $last_code_commit"

          # Find that commit's most recent completed run of this same "CI"
          # workflow (there can be other workflows -- e.g. Claude Code Review
          # -- triggered on the same SHA, hence the name filter).
          RUN_JSON=$(gh api "repos/$REPO/actions/runs?head_sha=$last_code_commit&status=completed" --jq \
            '[.workflow_runs[] | select(.name == "CI")] | sort_by(.run_started_at) | last // empty' 2>/dev/null || echo "")
          if [ -z "$RUN_JSON" ] || [ "$RUN_JSON" = "null" ]; then
            echo "last_tested_conclusion=unknown" >> "$GITHUB_OUTPUT"
            echo "last_tested_url=" >> "$GITHUB_OUTPUT"
            echo "No completed CI run found for $last_code_commit"
          else
            echo "last_tested_conclusion=$(jq -r '.conclusion' <<<"$RUN_JSON")" >> "$GITHUB_OUTPUT"
            echo "last_tested_url=$(jq -r '.html_url' <<<"$RUN_JSON")" >> "$GITHUB_OUTPUT"
          fi

      - name: Derive the supported-PostgreSQL-major lists
        id: pg
        run: |
          # Spending 20+ lines to replace a handful of version references looks
          # silly on the surface, but the point is CONSISTENCY: every job below
          # (the `test`/`pg-tle-test` matrix, `legacy-extension-update-test`'s single
          # legacy major, and the stepwise climb) derives its PostgreSQL set
          # from this ONE source, so they cannot drift onto different version
          # lists.
          #
          # SINGLE SOURCE OF TRUTH for the supported PostgreSQL majors. To add
          # or drop a PG major, edit ONLY the three constants below; every job
          # derives its version list from them (adding the newest major is a
          # one-line NEWEST bump). Do NOT hardcode a supported major in any job
          # matrix or loop.
          #
          #   NEWEST        -- highest PostgreSQL major cat_tools is tested on.
          #   CURRENT_FLOOR -- oldest major the CURRENT extension version supports:
          #                    the 0.2.3->0.3.0 update runs ALTER TYPE ... ADD
          #                    VALUE, which cannot run in a pre-PG12 transaction.
          #   LEGACY_FLOOR  -- oldest major the pre-0.2.2 install scripts still
          #                    load on (PG11 added pg_attribute.attmissingval and
          #                    PG12+ exposes the oid system column in SELECT *, both
          #                    of which those old scripts trip over). Only
          #                    legacy-extension-update-test and the stepwise path reach
          #                    back this far.
          NEWEST=18
          CURRENT_FLOOR=12
          LEGACY_FLOOR=10

          # supported = CURRENT_FLOOR..NEWEST (newest-first). The FRESH-install
          # matrix (test job) runs exactly these.
          supported=$(seq "$NEWEST" -1 "$CURRENT_FLOOR")
          # climb = LEGACY_FLOOR+1 .. NEWEST (ascending). The stepwise job starts
          # one cluster on the legacy floor and binary-pg_upgrades through every
          # later major in turn, so its targets are every major above the floor.
          climb=$(seq $((LEGACY_FLOOR + 1)) "$NEWEST")

          # 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"
          echo "legacy_pg=$LEGACY_FLOOR" >> "$GITHUB_OUTPUT"
          # Space-separated for direct iteration in the stepwise bash loop.
          echo "climb_pg=$(echo $climb)" >> "$GITHUB_OUTPUT"

  # ===========================================================================
  # Test strategy
  #
  # A cat_tools install can be arrived at several ways, each of which can break
  # differently, so each is exercised by its own job below (the per-job
  # comments carry the details; this is the big picture):
  #
  # The `test` job runs two checks, on every supported PostgreSQL major:
  #   1. FRESH install -- CREATE EXTENSION at the current version, via
  #      `make verify-results`. The baseline a brand-new user gets.
  #   2. UPDATE TO CURRENT -- bin/test_existing's update-scenario: CREATE
  #      EXTENSION at the 0.2.2 backward-compat floor, ALTER EXTENSION
  #      UPDATE to current, structurally compare against a fresh install,
  #      then run the full suite in existing mode. A planted dependency
  #      guard blocks a stray non-CASCADE drop throughout, proving the
  #      update didn't just quietly reinstall. USED to be its own matrix job
  #      (legacy-extension-update-test's PG12+ leg) -- folded in here since it runs
  #      on the same majors as (1), and this job's container, checkout, and
  #      cat_tools install are already there; a separate job would pay for
  #      all of that again for no added coverage.
  #
  # The `pg-upgrade-test` job does a BINARY pg_upgrade, SINGLE jump: install
  # an OLD version on an OLD major, binary-upgrade the cluster straight to a
  # NEWER major (skipping intermediate majors), then update the extension.
  # Proves objects created on an old server work when read on a new one.
  #
  # The `pg-upgrade-stepwise` job does a BINARY pg_upgrade, EVERY major in
  # sequence: one cluster climbing 10 -> 11 -> ... -> 18, exercising each
  # individual major-to-major transition in turn.
  #
  # The `legacy-extension-update-test` job is PG10 ONLY now (see the changes job's
  # LEGACY_FLOOR comment): the pre-0.2.2 install scripts (0.2.0/0.2.1) load
  # on no other major, so this job's only remaining purpose is the
  # 0.2.0->0.2.2, 0.2.1->0.2.2, and 0.2.2->0.2.3 (view-rebuild) update
  # scripts, on the one major that can still run them. USED to also cover
  # the PG12+ update-to-current check across the full matrix -- that leg
  # moved into `test` above (see (2) there), since it ran on the same
  # majors as `test`'s own fresh-install check and didn't need a separate
  # job.
  #
  # The `pg-tle-test` job covers the SAME majors as `test`, proving cat_tools
  # actually works when deployed via pg_tle (AWS's Trusted Language
  # Extensions) instead of a filesystem .control file -- the same
  # fresh-install and update-path scenarios `test` proves for a filesystem
  # install, run again through pg_tle's own registration. That proof only
  # means what it claims if pg_tle ISOLATION holds throughout the run (a
  # stale filesystem .control file silently wins over a pg_tle registration
  # of the same name -- PostgreSQL just resolves from disk instead of
  # erroring), so every step here is bracketed by filesystem-cleanliness
  # checks -- isolation is the precondition for a trustworthy result, not
  # the point of the job. NOT folded into `test`: pg_tle needs
  # shared_preload_libraries (mixing pg_tle/non-pg_tle installs on one
  # cluster can misbehave), so it needs its own dedicated cluster.
  #
  # The `pg-tle-upgrade-test` job is the pg_tle-deployed equivalent of
  # `pg-upgrade-test`, on the jump pairs within pg_tle's own supported
  # PostgreSQL range.
  #
  # Supported update origins are 0.2.0, 0.2.1 and 0.2.2 (0.1.x is unsupported).
  # 0.2.0 and 0.2.1 are BOTH tested as origins because ALTER EXTENSION UPDATE
  # takes the shortest path: a 0.2.0 origin updates straight through the
  # 0.2.0--0.2.2 script and never touches 0.2.1--0.2.2, so starting at 0.2.1 is
  # the only way to exercise the 0.2.1--0.2.2 update script (spelled out at the
  # pg-upgrade-test matrix).
  #
  # Two PostgreSQL-version floors shape the matrices:
  #   - The pre-0.2.2 install scripts (0.2.0 / 0.2.1) load ONLY on PG10: PG11
  #     added pg_attribute.attmissingval and PG12+ exposes the oid system column
  #     in SELECT *, both of which those old scripts trip over. So a 0.2.0 / 0.2.1
  #     origin can only start on PG10.
  #   - The current version needs PG12+: the 0.2.3->0.3.0 update runs
  #     ALTER TYPE ... ADD VALUE, which cannot run in a pre-PG12 transaction (and
  #     an extension update script is one).
  #
  # KEY invariant: every pg_upgrade leg CLIMBS to a PostgreSQL that supports the
  # current version, then updates to the current version and runs the full suite
  # -- no leg stops short. A PG10/11 origin simply HOLDS the extension at 0.2.3
  # (the highest version reachable on those majors) until the cluster reaches
  # PG12+, where it is updated to the current version.
  # ===========================================================================
  test:
    needs: [changes]
    if: needs.changes.outputs.docs_only != 'true'
    strategy:
      matrix:
        # Current-supported majors, from the single source in the changes job.
        pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }}
    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@v7
      - name: Install rsync and server headers
        # postgresql-server-dev-NN provides catalog/pg_class.h, which
        # test/gen-relkinds.sh reads for the relkind drift check in
        # test/sql/relation__.sql.
        run: apt-get install -y rsync postgresql-server-dev-${{ matrix.pg }}
      - name: Test on PostgreSQL ${{ matrix.pg }}
        run: |
          # Fail if the relkind drift source is empty (headers missing): the
          # drift check must run on every version, not pass silently.
          make check-relkind-source
          # verify-results is the real gate: base.mk declares `verify-results:
          # $(TEST_DEPS)`, not `verify-results: test`, because test's own
          # recipe now exits non-zero on a regression, which would abort the
          # chain before verify-results could inspect and report the diff.
          # Either way this runs the suite via installcheck, then checks
          # pgtap/regression.diffs. A bare `make test` is redundant here for
          # the same reason -- it now also exits non-zero on a regression
          # (pgxntool 2.3.0+), but verify-results is the stricter, documented
          # check.
          make verify-results

          # The update-to-current check (see the Test strategy comment
          # above). USED to be its own matrix job (legacy-extension-update-test's
          # PG12+ leg) -- folded in here since it runs on these same majors,
          # and this job's container, checkout, and cat_tools install are
          # already there; a separate job would pay for all of that again
          # for no added coverage.
          #
          # NOT equivalent to `make test-update` (install 0.2.2 -> ALTER
          # EXTENSION UPDATE -> run the suite, all inside pg_regress's own
          # throwaway db). update-scenario creates its own database
          # (cat_tools_update, not pg_regress's throwaway db from
          # verify-results above -- confirmed no name collision), ALSO
          # plants a dependency guard (proving a stray non-CASCADE drop
          # stays blocked through the update, not just that the update
          # itself ran) and structurally compares the result against a
          # fresh install (assert_matches_fresh, via bin/structural_diff) --
          # see bin/test_existing's own header for why -- before running
          # the full suite against the real updated database in existing
          # mode. Reusing the SAME suite and expected output asserts the
          # updated database behaves identically to a fresh install. See
          # legacy-extension-update-test below for the PG10-only legacy checks
          # this does NOT cover (those pre-0.2.2 scripts don't load on
          # PG12+ at all).
          bin/test_existing update-scenario cat_tools_update 0.2.2

  # Style linter (https://github.com/Postgres-Extensions/linter, vendored at
  # .vendor/linter). Deliberately checked out WITHOUT submodules -- `make
  # lint` is the same command a developer runs locally, and lint.mk
  # self-initializes the submodule on first use (see its comment). Using the
  # exact same entry point here is what actually proves that self-init works,
  # rather than papering over it with a submodules: true checkout. The
  # linter's own test suite (fixtures + scanner edge cases) is that repo's
  # own CI's job, not this one's. No PostgreSQL needed -- sql-lint is a
  # standalone Perl script -- so this doesn't use the pgxn-tools container.
  lint:
    needs: [changes]
    if: needs.changes.outputs.docs_only != 'true'
    name: 🧹 SQL Lint
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Lint SQL
        # CRITICAL: call `make lint` directly, not some other path (a script, a
        # different target, etc). lint.mk's vendored include is guarded on
        # `$(wildcard .git)` so a source tarball (no .git) can still build/install
        # -- if that guard ever went false here (checkout somehow left no .git),
        # `lint` wouldn't exist as a target at all, and `make lint` fails loudly
        # ("No rule to make target 'lint'") rather than silently skipping. Any
        # wrapper that swallows that exit code or calls a different entry point
        # would defeat this safety net.
        run: make lint

  # cancel-on-close.yml cancels in-flight CI/claude-review runs on PR close by
  # joining the SAME concurrency group strings this workflow and
  # claude-code-review.yml use -- but it only works because those strings are
  # hand-copied there, not referenced. No `if: docs_only` gate here on
  # purpose: this is a near-instant metadata check, not worth ever skipping,
  # and workflow files aren't "docs" anyway.
  verify-cancel-on-close-coupling:
    name: 🔗 Verify cancel-on-close.yml coupling
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Confirm ci.yml / claude-code-review.yml still match cancel-on-close.yml's assumptions
        run: |
          python3 - <<'PY'
          import os, sys, yaml

          # GitHub Actions pre-substitutes any expression marker (dollar sign
          # directly followed by double-open-brace) it finds anywhere in a
          # run: block's raw text -- including inside this heredoc -- before
          # the script ever runs, regardless of quoting. D keeps that four-
          # character sequence from ever appearing contiguously in this file
          # (not even in this comment -- deliberately not spelled out here),
          # so the expected strings below stay literal text for Python to
          # compare against, instead of being evaluated away by Actions
          # itself (which would make this check compare each expression's
          # live value against itself -- always "equal", catching nothing).
          D = "$"

          def load(path):
              if not os.path.isfile(path):
                  return None, f"{path} does not exist"
              with open(path) as f:
                  return yaml.safe_load(f), None

          errors = []

          ci, err = load('.github/workflows/ci.yml')
          if err:
              errors.append(err)
          else:
              if ci.get('name') != 'CI':
                  errors.append(f"ci.yml name is {ci.get('name')!r}, expected 'CI'")
              ci_group = (ci.get('concurrency') or {}).get('group')
              expected = D + "{{ github.workflow }}-" + D + "{{ github.ref }}"
              if ci_group != expected:
                  errors.append(f"ci.yml concurrency.group is {ci_group!r}, expected {expected!r}")

          review, err = load('.github/workflows/claude-code-review.yml')
          if err:
              errors.append(err)
          elif not review.get('jobs', {}).get('claude-review'):
              errors.append("claude-code-review.yml no longer has a 'claude-review' job")
          else:
              review_group = (review.get('concurrency') or {}).get('group')
              expected = (
                  "claude-review-" + D + "{{ github.event.pull_request.number }}"
                  + D + "{{ (github.event.action == 'labeled' && github.event.label.name != 'claude-debug')"
                  + " && format('-{0}', github.event.label.name) || '' }}"
              )
              if review_group != expected:
                  errors.append(f"claude-code-review.yml concurrency.group is {review_group!r}, expected {expected!r}")

          if errors:
              print("cancel-on-close.yml's assumptions about ci.yml / claude-code-review.yml no longer hold:")
              for e in errors:
                  print(f"  - {e}")
              print("Update the hardcoded group: strings in .github/workflows/cancel-on-close.yml (or restore the files/fields) to match.")
              sys.exit(1)
          print("OK: cancel-on-close.yml's group strings still match ci.yml and claude-code-review.yml.")
          PY

  # Proves cat_tools survives a BINARY pg_upgrade (in-place catalog migration to a
  # newer PostgreSQL major), not just an in-place extension update. Each leg is a
  # SINGLE jump: install an old cat_tools on an old cluster, plant a dependency
  # guard, binary-pg_upgrade STRAIGHT to a newer major (skipping any intermediate
  # majors), update the extension to the current version, then run 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. Every leg's new_pg supports the current version (PG12+); the
  # version-by-version climb through EVERY major is pg-upgrade-stepwise.
  pg-upgrade-test:
    # Gated behind test+lint (not just changes): this matrix is expensive
    # (multiple binary pg_upgrades per leg), and every leg would fail anyway
    # on a baseline that's already broken by a failing fresh-install test or
    # lint violation. success() is required explicitly once a job's `if:`
    # references anything -- GitHub only assumes success() as a default when
    # no `if:` is written at all.
    needs: [changes, test, lint]
    if: success() && needs.changes.outputs.docs_only != 'true'
    strategy:
      matrix:
        include:
          # old_pg=10 origins: the FULL real-world journey for a user on old
          # PostgreSQL + old cat_tools. old_install is 0.2.0/0.2.1 -- the only
          # versions that install on PG10 (PG11 added pg_attribute.attmissingval,
          # PG12+ exposes oid in SELECT *, so 0.2.0/0.2.1 install ONLY on PG10).
          # The BRIDGE update to 0.2.3, run on PG10 BEFORE pg_upgrade, makes the
          # objects pg_upgrade-safe: the shipped 0.2.0->0.2.2 / 0.2.1->0.2.2
          # scripts leave fresh-vs-update divergences (relhasoids/relhaspkey never
          # stripped from _cat_tools.pg_class_v via the no-op `!= ANY` omit_column),
          # and the 0.2.2->0.2.3 update repairs them (its pg_class_v DROP+CREATE
          # rebuild strips the dropped columns). 0.2.3 is the highest version a
          # PG10 cluster can hold (0.2.3->0.3.0 needs PG12+); the single jump lands
          # on a PG12+ major, where the post-upgrade step updates 0.2.3 -> current.
          #
          # BOTH 0.2.0 and 0.2.1 appear as origins because ALTER EXTENSION UPDATE
          # takes the SHORTEST path: a 0.2.0 origin updates straight through the
          # 0.2.0--0.2.2 script and never exercises 0.2.1--0.2.2, so starting at
          # 0.2.1 is the ONLY way to exercise the 0.2.1--0.2.2 update script.
          - old_pg: "10"
            new_pg: "18"
            old_install: "0.2.0"
            bridge_to: "0.2.3"
          - old_pg: "10"
            new_pg: "18"
            old_install: "0.2.1"
            bridge_to: "0.2.3"
          # old_pg>=11: 0.2.0/0.2.1 do not install on PG11+, and 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 to a PG12+ major, then update to the current
          # version.
          - 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: ""
    # Include old_install in the name so matrix legs that differ only by origin
    # version (e.g. the two 10 → 18 legs from 0.2.0 and 0.2.1) get distinct,
    # unambiguous check names.
    name: 🔄 Binary pg_upgrade ${{ matrix.old_pg }} → ${{ matrix.new_pg }} (from ${{ matrix.old_install }})
    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: Check out the repo
        uses: actions/checkout@v7
      - name: Install rsync
        run: apt-get install -y rsync
      - name: Recreate old cluster with data checksums enabled
        run: .github/scripts/pg_upgrade_cluster recreate-old ${{ matrix.old_pg }}
      - 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 the current version,
        # so the post-upgrade ALTER EXTENSION UPDATE always has real work to do.
        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: .github/scripts/pg_upgrade_cluster upgrade ${{ matrix.old_pg }} ${{ matrix.new_pg }}
      - 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. The 0.2.3 -> 0.3.0 script uses
        # ALTER TYPE ... ADD VALUE, which is why every leg's new_pg is >= 12.
        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 cat_tools survives EVERY individual major-to-major binary pg_upgrade
  # transition, not just a single big jump (that is pg-upgrade-test): ONE cluster
  # that starts on the oldest origin and climbs through every major in sequence.
  # Installs 0.2.0 on PG10, bridges to 0.2.3 (the highest version a PG10 cluster
  # can hold -- see the pg-upgrade-test matrix comment), plants the dependency
  # guard, then binary-pg_upgrades 10->11->12->...->18 ONE major at a time. The
  # extension is HELD at 0.2.3 for the 10->11 and 11->12 steps (PG11 cannot run
  # the 0.2.3->0.3.0 update, which needs PG12+); on reaching PG12 it is updated
  # 0.2.3 -> the current version, and from PG12 on the full suite runs in existing
  # mode at EVERY major (12..18) against the REAL pg_upgraded objects, re-proving
  # the guard survived each step. CI-heavy on purpose: it installs PostgreSQL 10
  # through 18, performs 8 sequential pg_upgrades, and runs the suite 7 times.
  pg-upgrade-stepwise:
    # Gated behind test+lint -- see the comment on pg-upgrade-test's needs.
    needs: [changes, test, lint]
    if: success() && needs.changes.outputs.docs_only != 'true'
    name: 🪜 Stepwise pg_upgrade 10 → 18 (from 0.2.0)
    runs-on: ubuntu-latest
    container: pgxn/pgxn-tools
    env:
      # Every pg_upgrade in the climb pairs two clusters that must share initdb
      # options (checksums, auth), exactly as in pg-upgrade-test.
      INITDB_OPTS: --data-checksums --auth trust
      DB: cat_tools_stepwise
      # Climb targets and starting (legacy) major, from the single source in the
      # changes job -- so a new PG major joins the climb with no edit here.
      CLIMB_PG: ${{ needs.changes.outputs.climb_pg }}
      LEGACY_PG: ${{ needs.changes.outputs.legacy_pg }}
    steps:
      - name: Start PostgreSQL 10
        run: pg-start 10
      - name: Recreate the PG10 cluster with data checksums enabled
        run: |
          pg_ctlcluster 10 test stop
          pg_dropcluster 10 test
          # -p 5432: force the well-known port (see pg-upgrade-test for why).
          pg_createcluster -p 5432 10 test -- $INITDB_OPTS
          pg_ctlcluster 10 test start
          pg_isready -t 30
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Install rsync
        # Must NOT install a newer server-dev yet: `make install` just below runs
        # with the DEFAULT pg_config, which must still resolve to PG10. Each newer
        # major's server-dev is installed inside the climb loop, at which point the
        # default pg_config advances with it (the final-major suite reads that
        # version's catalog header for the relkind drift check; see the test job).
        run: apt-get install -y rsync
      - name: Install cat_tools into the PG10 cluster
        run: make install
      - name: Prepare PG10 (install 0.2.0, bridge to 0.2.3, plant guard)
        run: bin/test_existing prepare-old "$DB" 0.2.0 0.2.3
      - name: Climb every major via binary pg_upgrade (10 → 11 → ... → 18)
        # One sequential loop; each iteration binary-pg_upgrades the cluster from
        # $old to $new (a single major step). cat_tools is a SQL-only extension, so
        # pg_upgrade only needs its scripts present in the NEW cluster's sharedir:
        # `make install PG_CONFIG=<new>` before each step (the $old cluster already
        # has them, from the previous iteration or the PG10 install above). The
        # extension is held at 0.2.3 until PG12, where it is updated to the current
        # version; the suite then runs at every major from PG12 on (see the suite
        # policy inside the loop). The per-step pg_upgrade invocation and log
        # handling mirror pg-upgrade-test.
        run: |
          old=$LEGACY_PG
          for new in $CLIMB_PG; do
            echo "=== binary pg_upgrade PostgreSQL $old -> $new ==="
            apt-get install -y postgresql-$new postgresql-server-dev-$new
            # PG_CONFIG explicit: several majors are installed, so the default
            # pg_config on PATH may not be $new's.
            make install PG_CONFIG=/usr/lib/postgresql/$new/bin/pg_config
            pg_ctlcluster $old test stop
            pg_createcluster -p 5432 $new test -- $INITDB_OPTS
            # PG17+ writes logs under the new datadir; older versions to CWD. Dump
            # both on failure (same handling as pg-upgrade-test).
            mkdir -p /tmp/pg_upgrade_logs
            chown postgres:postgres /tmp/pg_upgrade_logs
            su -c "cd /tmp/pg_upgrade_logs && /usr/lib/postgresql/$new/bin/pg_upgrade \
              -b /usr/lib/postgresql/$old/bin \
              -B /usr/lib/postgresql/$new/bin \
              -d /var/lib/postgresql/$old/test \
              -D /var/lib/postgresql/$new/test \
              -o '-c config_file=/etc/postgresql/$old/test/postgresql.conf' \
              -O '-c config_file=/etc/postgresql/$new/test/postgresql.conf'" postgres \
              || { find /tmp/pg_upgrade_logs \
                        /var/lib/postgresql/$new/test/pg_upgrade_output.d \
                        -name '*.log' 2>/dev/null | sort | xargs -r tail -n +1; exit 1; }
            pg_ctlcluster $new test start
            pg_isready -t 30
            # Suite policy across the climb. We unfortunately CANNOT run the suite
            # against the OLD version (0.2.3) at the pre-PG12 steps (10->11, 11->12):
            # the suite matches the CURRENT version's objects and we do not maintain
            # an old-version expected-output set. So those steps only verify that
            # 0.2.3 SURVIVES each pg_upgrade -- i.e. the climb completing without
            # error. On reaching PG12 (the floor for 0.2.3->0.3.0, which needs
            # ALTER TYPE ... ADD VALUE) update once to the current version; from
            # PG12 on the extension is at current, so run the full suite in existing
            # mode at EVERY major (run-suite asserts the version and re-proves the
            # guard survived this step's pg_upgrade).
            if [ "$new" = 12 ]; then
              bin/test_existing update "$DB"
            fi
            if [ "$new" -ge 12 ]; then
              bin/test_existing run-suite "$DB"
            fi
            old=$new
          done

  # Proves the pre-0.2.2 legacy install/update scripts: CREATE EXTENSION at
  # 0.2.0/0.2.1 then ALTER EXTENSION UPDATE (no pg_upgrade). PG10 ONLY -- see
  # the Test strategy comment above for why, and why the PG12+ update-to-
  # current leg lives in the `test` job instead of here.
  legacy-extension-update-test:
    # Gated behind test+lint -- see the comment on pg-upgrade-test's needs.
    needs: [changes, test, lint]
    if: success() && needs.changes.outputs.docs_only != 'true'
    # Single source of truth for the legacy PG10 floor: needs.changes.outputs.legacy_pg,
    # not a hardcoded '10'.
    env:
      LEGACY_PG: ${{ needs.changes.outputs.legacy_pg }}
    name: ⬆️ Legacy extension update test on PostgreSQL ${{ needs.changes.outputs.legacy_pg }}
    runs-on: ubuntu-latest
    container: pgxn/pgxn-tools
    steps:
      - name: Start PostgreSQL ${{ env.LEGACY_PG }}
        run: pg-start ${{ env.LEGACY_PG }}
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Install rsync
        # No server-dev header package here: this job runs no suite (see the
        # job comment above), so nothing needs the relkind drift check.
        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
        # 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. Both
        # origins are checked because ALTER EXTENSION UPDATE takes the
        # shortest path (see pg-upgrade-test above): 0.2.0 goes via the
        # 0.2.0--0.2.2 script, 0.2.1 via 0.2.1--0.2.2. update-check-version
        # asserts each lands on 0.2.2 -- NOT plain update-check: landing on
        # 0.2.2 from these origins is a KNOWN divergence from a fresh 0.2.2
        # install (trigger__parse and the pg_class_v omit_column bug,
        # repaired in 0.2.2->0.2.3; a type-ACL gap, repaired only in
        # 0.2.3->0.3.0), and both 0.2.0->0.2.2 / 0.2.1->0.2.2 are
        # already-published scripts that cannot be edited to fix it
        # directly, so asserting fresh-parity here would fail forever by
        # design.
        #
        # The rebuild_020/rebuild_021 checks exercise the REAL broken path
        # end to end from BOTH pre-0.2.2 origins: a 0.2.0 (resp. 0.2.1)
        # install leaves relhasoids in _cat_tools.pg_class_v (the buggy
        # 0.2.0--0.2.2 / 0.2.1--0.2.2 omit_column no-op), and updating to
        # 0.2.3 routes through 0.2.2--0.2.3 (shortest path <origin>--0.2.2
        # then 0.2.2--0.2.3), firing the conditional rebuild that strips
        # relhasoids and the trigger__parse repair. It stays
        # update-check-version, not plain update-check: 0.2.3 is ALSO
        # already-published (tagged), so the type-ACL gap above is not (and
        # cannot be) fixed until 0.2.3->0.3.0 either -- this landing point
        # still diverges from a fresh 0.2.3 install on that ACL alone. 0.2.3
        # is the furthest PG10 can reach (0.2.3--0.3.0 needs PG12+). The
        # psql assertion fails loudly if the rebuild did not fire (relhasoids
        # still present) -- complementing the stronger 10→18 pg_upgrade
        # bridge legs. `$$` is escaped as `\$\$` so the shell passes literal
        # dollar quotes through to psql.
        run: |
          bin/test_existing update-check-version cat_tools_from_020 0.2.0 0.2.2
          bin/test_existing update-check-version cat_tools_from_021 0.2.1 0.2.2
          for origin in 020:0.2.0 021:0.2.1; do
            db="cat_tools_rebuild_${origin%%:*}"
            from="${origin##*:}"
            bin/test_existing update-check-version "$db" "$from" 0.2.3
            psql -d "$db" -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 \$\$"
          done

  pg-tle-test:
    # Gated behind test+lint -- see the comment on pg-upgrade-test's needs.
    needs: [changes, test, lint]
    if: success() && needs.changes.outputs.docs_only != 'true'
    strategy:
      matrix:
        # Current-supported majors, from the single source in the changes job
        # (see its "Derive ..." step) -- same list the `test` job uses. This
        # currently happens to line up with pg_tle 1.5.2's own supported range
        # (12-18, see pgxntool/pgtle_versions.md) too, but that's a coincidence:
        # if either range moves independently in the future, re-check they
        # still overlap before assuming this job covers what it claims to.
        # 1.5.2 itself is what AWS RDS for PostgreSQL 17 currently ships
        # (RDS PG17.7+, our primary deployment target -- see
        # https://docs.aws.amazon.com/AmazonRDS/latest/PostgreSQLReleaseNotes/postgresql-versions.html).
        pg: ${{ fromJSON(needs.changes.outputs.supported_pg) }}
    name: 🧩 pg_tle ${{ matrix.pg }}
    runs-on: ubuntu-latest
    container: pgxn/pgxn-tools
    env:
      PG_TLE_RELEASE: "1.5.2"
    steps:
      # A dedicated cluster, never shared with the other jobs in this
      # workflow: pg_tle requires shared_preload_libraries and mixing
      # pg_tle/non-pg_tle extension installs on one cluster can misbehave.
      - name: Start PostgreSQL ${{ matrix.pg }}
        run: pg-start ${{ matrix.pg }}
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Install rsync
        run: apt-get install -y rsync
      - name: Install pgtap (test harness dependency)
        # pgTAP is a filesystem-installed dependency of the TEST HARNESS, not
        # part of what this job proves is pg_tle-only -- it's not being
        # deployed via pg_tle here, and never will be. `bin/test_existing`'s
        # pg_tle-mode run_suite calls `make testdeps`, which would otherwise
        # filesystem-install pgTAP for the first time partway through the job
        # and trip the contamination checks below (confirmed happening in a
        # real CI run). Installing it explicitly here, before the baseline
        # snapshot, makes it part of the accepted starting state -- like any
        # other extension already on disk -- instead of needing a hardcoded
        # exclude-by-name rule that would erode the whole point of diffing
        # against a baseline.
        run: make pgtap
      - name: Snapshot filesystem extension control files (pre-pg_tle baseline)
        # Whatever ships on disk by default (e.g. contrib, and now pgtap)
        # before we install pg_tle. bin/assert_fs_clean's later checks diff
        # against this, so they flag ANY extension that lands on disk instead
        # of being registered via pg_tle -- not just cat_tools -- without
        # hardcoding contrib/pgtap names.
        run: bin/assert_fs_clean snapshot ${{ matrix.pg }} /tmp/control_baseline.txt
      - name: Build and install pg_tle ${{ env.PG_TLE_RELEASE }}
        # flex/bison/libkrb5-dev aren't in the pgxn-tools image; pg_tle's build
        # needs them (guc-file.l, and clientauth.c includes gssapi.h).
        run: |
          apt-get install -y flex bison libkrb5-dev
          git clone --branch v${{ env.PG_TLE_RELEASE }} --depth 1 https://github.com/aws/pg_tle.git /tmp/pg_tle
          make -C /tmp/pg_tle install
      - name: Enable pg_tle and restart PostgreSQL ${{ matrix.pg }}
        run: |
          echo "shared_preload_libraries = 'pg_tle'" >> /etc/postgresql/${{ matrix.pg }}/test/postgresql.conf
          pg_ctlcluster ${{ matrix.pg }} test restart
          pg_isready -t 30
      - name: Register pg_tle + cat_tools against template1
        # template1, not the ambient default db: pg_tle's registration catalog
        # is per-database, and `createdb` only inherits it because it copies
        # template1 by default. Every cat_tools database used below (the
        # smoke-test db, and whatever bin/test_existing createdb's) is created
        # AFTER this step specifically so it inherits both registrations.
        run: |
          psql -d template1 -c "CREATE EXTENSION pg_tle"
          PGDATABASE=template1 make run-pgtle
      - name: Verify no stray extension control files landed on the filesystem
        # CRITICAL, and intentionally redundant with the cat_tools-specific
        # check in the next step: a filesystem control file silently wins
        # over a pg_tle-registered extension of the same name, which would
        # make this whole job a false pass without ever raising an error.
        # Confirmed hitting exactly this while researching pg_tle version
        # compatibility (a stale filesystem cat_tools install shadowed the
        # pg_tle-registered one under test). Run again after every step below
        # that could plausibly write extension files to disk -- never trust a
        # single check to catch everything.
        run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt
      - name: Install cat_tools purely via pg_tle (fresh install, no filesystem trace)
        # cat_tools is never `make install`ed in this job, so a successful
        # CREATE EXTENSION here can only be resolving through pg_tle's
        # registration, not a control file on disk. Checked explicitly here
        # too (not just via the comprehensive check above) as a guard
        # specifically for the extension under test, in case that check's
        # exclude-list logic has a bug.
        run: |
          test ! -e /usr/share/postgresql/${{ matrix.pg }}/extension/cat_tools.control
          createdb cat_tools_smoke
          psql -d cat_tools_smoke -c "CREATE EXTENSION cat_tools"
      - name: Verify cat_tools works when deployed via pg_tle
        run: |
          INSTALLED=$(psql -d cat_tools_smoke -tAc "SELECT extversion FROM pg_extension WHERE extname = 'cat_tools'")
          EXPECTED=$(make -s print-PGXNVERSION 2>/dev/null | sed -n 's/.*set to "\(.*\)"$/\1/p')
          echo "installed=$INSTALLED expected=$EXPECTED"
          if [ -z "$INSTALLED" ] || [ -z "$EXPECTED" ] || [ "$INSTALLED" != "$EXPECTED" ]; then
            echo "FAIL: installed='$INSTALLED' expected='$EXPECTED'"; exit 1
          fi
          psql -d cat_tools_smoke -v ON_ERROR_STOP=1 -c "SELECT relname FROM cat_tools.pg_class_v LIMIT 1" > /dev/null
      - 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
      - name: Install PostgreSQL ${{ matrix.pg }} server dev headers
        # Needed by `make check-relkind-source`, which bin/test_existing's
        # run_suite calls -- every other job that reaches that check already
        # installs this; pg-tle-test didn't need it until now because the
        # smoke test above never runs the full suite.
        run: apt-get install -y postgresql-server-dev-${{ matrix.pg }}
      - name: Test the update path via pg_tle (full pgTAP suite, --use-existing)
        # Exercises the SAME update path the `test` job proves for a
        # filesystem install (0.2.2 -> current), but entirely through pg_tle.
        # bin/test_existing's update_scenario is UNMODIFIED from the
        # filesystem case -- only run_suite's internals differ, gated by this
        # one env var (see the TEST_EXISTING_DEPLOY comment in
        # bin/test_existing) -- so this reuses the exact same update/upgrade
        # code the other jobs use instead of duplicating it for pg_tle.
        run: TEST_EXISTING_DEPLOY=pgtle bin/test_existing update-scenario cat_tools_update 0.2.2
      - name: Verify no stray extension control files after the pg_tle update test
        # Runs AFTER the complete update+full-suite flow, not before: the
        # whole point of pg_tle-mode testing is proving NOTHING touched the
        # filesystem THROUGHOUT that flow, not merely that the environment
        # started clean. A check placed before this point could not catch a
        # regression introduced by anything update-scenario itself does.
        run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt

  pg-tle-upgrade-test:
    # Gated behind test+lint -- see the comment on pg-upgrade-test's needs.
    needs: [changes, test, lint]
    if: success() && needs.changes.outputs.docs_only != 'true'
    strategy:
      matrix:
        include:
          # Mirrors pg-upgrade-test's old_pg=12 legs (the only ones cleanly
          # within pg_tle 1.5.2's own supported range, PG12-18) so the SAME PG
          # version jumps are proven for both deployment methods. Unlike
          # pg-upgrade-test, there is no bridge/old_install complexity here --
          # pg_tle has no equivalent of the pre-0.2.2 filesystem install-script
          # issues (those are about SELECT * over catalog columns in old SQL
          # files, irrelevant to how the extension gets registered).
          - old_pg: "12"
            new_pg: "13"
          - old_pg: "12"
            new_pg: "18"
    name: 🧩🔄 pg_tle 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
      PG_TLE_RELEASE: "1.5.2"
    steps:
      # A dedicated cluster pair, never shared with the other jobs in this
      # workflow -- same reasoning as pg-tle-test.
      - name: Start PostgreSQL ${{ matrix.old_pg }}
        run: pg-start ${{ matrix.old_pg }}
      - name: Check out the repo
        uses: actions/checkout@v7
      - name: Install rsync
        run: apt-get install -y rsync
      - name: Recreate old cluster with data checksums enabled
        run: .github/scripts/pg_upgrade_cluster recreate-old ${{ matrix.old_pg }}
      - name: Snapshot filesystem extension control files (old cluster baseline)
        run: bin/assert_fs_clean snapshot ${{ matrix.old_pg }} /tmp/control_baseline_old.txt
      - name: Build and install pg_tle ${{ env.PG_TLE_RELEASE }} on the old cluster
        # flex/bison/libkrb5-dev aren't in the pgxn-tools image; pg_tle's build
        # needs them (guc-file.l, and clientauth.c includes gssapi.h).
        run: |
          apt-get install -y flex bison libkrb5-dev
          git clone --branch v${{ env.PG_TLE_RELEASE }} --depth 1 https://github.com/aws/pg_tle.git /tmp/pg_tle_old
          make -C /tmp/pg_tle_old install
      - name: Enable pg_tle and restart PostgreSQL ${{ matrix.old_pg }}
        run: |
          echo "shared_preload_libraries = 'pg_tle'" >> /etc/postgresql/${{ matrix.old_pg }}/test/postgresql.conf
          pg_ctlcluster ${{ matrix.old_pg }} test restart
          pg_isready -t 30
      - name: Register pg_tle + cat_tools against template1 (old cluster)
        run: |
          psql -d template1 -c "CREATE EXTENSION pg_tle"
          PGDATABASE=template1 make run-pgtle
      - name: Verify no stray extension control files on the old cluster
        run: bin/assert_fs_clean verify ${{ matrix.old_pg }} /tmp/control_baseline_old.txt
      - name: Prepare the old cluster (install via pg_tle + dependency guard)
        # A fresh 0.2.2 install (no bridge needed -- see pg-upgrade-test's own
        # comment on why its old_pg>=11 legs don't need one), created via
        # pg_tle only, then the same dependency guard bin/test_existing's
        # other subcommands rely on to prove existing-mode never silently
        # drops+reinstalls the extension.
        run: |
          test ! -e /usr/share/postgresql/${{ matrix.old_pg }}/extension/cat_tools.control
          createdb cat_tools_tle_upgrade
          psql -d cat_tools_tle_upgrade -c "CREATE EXTENSION cat_tools VERSION '0.2.2'"
          bin/test_existing plant-guard cat_tools_tle_upgrade
      - name: Install PostgreSQL ${{ matrix.new_pg }}
        run: apt-get install -y postgresql-${{ matrix.new_pg }} postgresql-server-dev-${{ matrix.new_pg }}
      - name: Install pgtap on the new cluster (test harness dependency)
        # Must happen BEFORE the new-cluster baseline snapshot below, same
        # reasoning as pg-tle-test's "Install pgtap" step: pgTAP is a
        # filesystem-installed dependency of the test harness, unrelated to
        # cat_tools's own pg_tle-only deployment story, so it needs to be part
        # of the accepted starting state rather than flagged as contamination.
        # PATH is set explicitly (not just relying on apt's update-alternatives
        # switch from installing postgresql-${{ matrix.new_pg }} above) since
        # `pgxn install` resolves pg_config via PATH, not via a passed-through
        # PG_CONFIG make variable.
        run: PATH=/usr/lib/postgresql/${{ matrix.new_pg }}/bin:$PATH make pgtap
      - name: Snapshot filesystem extension control files (new cluster baseline)
        run: bin/assert_fs_clean snapshot ${{ matrix.new_pg }} /tmp/control_baseline_new.txt
      - name: Build and install pg_tle ${{ env.PG_TLE_RELEASE }} on the new cluster
        # Only pg_tle's OWN framework is needed on the new binary tree -- NOT
        # cat_tools. Binary pg_upgrade's schema-only restore recreates each
        # object individually (binary_upgrade_create_empty_extension), and
        # pg_tle's own catalog tables (holding cat_tools's registered SQL) are
        # carried over as ordinary heap data during the physical copy phase --
        # no re-registration needed on the new side. A SEPARATE clone
        # (pg_tle_new, not pg_tle_old rebuilt) avoids installing a .so still
        # compiled against the old cluster's PostgreSQL headers: pg_tle has C
        # code, unlike cat_tools, so switching PG_CONFIG on an already-built
        # tree without a fresh build would risk installing the wrong binary.
        run: |
          git clone --branch v${{ env.PG_TLE_RELEASE }} --depth 1 https://github.com/aws/pg_tle.git /tmp/pg_tle_new
          make -C /tmp/pg_tle_new install PG_CONFIG=/usr/lib/postgresql/${{ matrix.new_pg }}/bin/pg_config
      - name: Verify no stray extension control files on the new cluster (pre-upgrade)
        run: bin/assert_fs_clean verify ${{ matrix.new_pg }} /tmp/control_baseline_new.txt
      - name: Stop old cluster, create new cluster, enable pg_tle, binary pg_upgrade, start new cluster
        run: .github/scripts/pg_upgrade_cluster upgrade ${{ matrix.old_pg }} ${{ matrix.new_pg }} "shared_preload_libraries = 'pg_tle'"
      - name: Verify no stray extension control files after binary pg_upgrade
        run: bin/assert_fs_clean verify ${{ matrix.new_pg }} /tmp/control_baseline_new.txt
      - name: Update the pg_upgraded extension to the current version
        # Exercises ALTER EXTENSION UPDATE on genuinely pg_upgraded objects
        # registered via pg_tle. update_ext() is pure psql (no `make` calls),
        # so it's identical regardless of deployment method -- no pg_tle-mode
        # flag needed here, only for run-suite below.
        run: bin/test_existing update cat_tools_tle_upgrade
      - name: Run the suite against the pg_upgraded database via pg_tle (existing mode)
        run: TEST_EXISTING_DEPLOY=pgtle bin/test_existing run-suite cat_tools_tle_upgrade
      - name: Verify no stray extension control files after the pg_tle upgrade+update+suite run
        # CRITICAL: must run AFTER the complete flow, not before -- same
        # principle as pg-tle-test's final checkpoint. Proves nothing touched
        # the filesystem across binary pg_upgrade, the extension update, AND
        # the full pgTAP suite run, not merely that the environment started
        # clean.
        run: bin/assert_fs_clean verify ${{ matrix.new_pg }} /tmp/control_baseline_new.txt

  # 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, lint, verify-cancel-on-close-coupling, pg-upgrade-test, pg-upgrade-stepwise, legacy-extension-update-test, pg-tle-test, pg-tle-upgrade-test]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - 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
      - name: Summarize what this push actually tested
        # This job's own checks report "skipped" for every heavy job on a
        # docs-only push -- true for THIS push, but easy to mistake for "never
        # tested" when just glancing at the PR's Checks tab. Make it explicit
        # what's actually being vouched for. Reporting only -- does not affect
        # pass/fail; see the "Check all jobs passed" step above for that gate.
        run: |
          if [ "${{ needs.changes.outputs.docs_only }}" != "true" ]; then
            echo "✅ This push's own CI run tested the code (not a docs-only push)." >> "$GITHUB_STEP_SUMMARY"
          elif [ "${{ needs.changes.outputs.docs_only_pr }}" = "true" ]; then
            echo "📄 This entire PR/push contains only documentation changes -- no code testing applies." >> "$GITHUB_STEP_SUMMARY"
          else
            SHA="${{ needs.changes.outputs.last_tested_sha }}"
            CONCLUSION="${{ needs.changes.outputs.last_tested_conclusion }}"
            URL="${{ needs.changes.outputs.last_tested_url }}"
            if [ "$CONCLUSION" = "success" ]; then ICON="✅"; else ICON="⚠️"; fi
            if [ -n "$URL" ]; then
              echo "$ICON This push only changed docs. The code was last fully tested at \`${SHA:0:7}\` -- [$CONCLUSION]($URL)" >> "$GITHUB_STEP_SUMMARY"
            else
              echo "⚠️ This push only changed docs, and no completed CI run was found for the last code-changing commit (\`${SHA:0:7}\`) -- verify manually." >> "$GITHUB_STEP_SUMMARY"
            fi
          fi
# vi: expandtab ts=2 sw=2
