[SCM] PostGIS branch master updated. 3.7.0beta1-81-gbbf0087251

git at osgeo.org git at osgeo.org
Mon Jul 27 11:56:44 PDT 2026


This is an automated email from the git hooks/post-receive script. It was
generated because a ref change was pushed to the repository containing
the project "PostGIS".

The branch, master has been updated
       via  bbf00872510c92d78b1bdaae297652378b353f95 (commit)
       via  597138b97f8a666c56f7f9d284fe6e4a89bd504d (commit)
       via  772215b879f89485af9ffe11c9386435324f2508 (commit)
       via  42b0d845a7c00848d501fdeacf3633e41fe68eb1 (commit)
      from  023ac85a987b77c2045c165c7ff539d94ba067ff (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.

- Log -----------------------------------------------------------------
commit bbf00872510c92d78b1bdaae297652378b353f95
Merge: 023ac85a98 597138b97f
Author: Darafei Praliaskouski <komzpa at gmail.com>
Date:   Mon Jul 27 11:56:42 2026 -0700

    Merge pull request 'ci: gate expensive pull-request workflows by changed surface' (!529) from Komzpa/postgis:ci/proportional-path-filters-20260726 into master
    
    Reviewed-on: https://gitea.osgeo.org/postgis/postgis/pulls/529


commit 597138b97f8a666c56f7f9d284fe6e4a89bd504d
Author: Darafei Praliaskouski <me at komzpa.net>
Date:   Mon Jul 27 22:23:46 2026 +0400

    ci: move gating helpers under ci

diff --git a/.github/workflows/ci-freebsd.yml b/.github/workflows/ci-freebsd.yml
index e8ae945aa7..ea88bbcbc3 100644
--- a/.github/workflows/ci-freebsd.yml
+++ b/.github/workflows/ci-freebsd.yml
@@ -30,7 +30,7 @@ jobs:
         id: changed-surface
         run: |
           set +e
-          python3 utils/ci_need_suite.py github-ci
+          python3 ci/ci_need_suite.py github-ci
           gate_status=$?
           set -e
           if test "${gate_status}" = 78; then
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index e1d67b5b89..2241c337c2 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -27,7 +27,7 @@ jobs:
         id: changed-surface
         run: |
           set +e
-          python3 utils/ci_need_suite.py github-ci
+          python3 ci/ci_need_suite.py github-ci
           gate_status=$?
           set -e
           if test "${gate_status}" = 78; then
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fae239715b..20d7f9c50e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -54,7 +54,7 @@ jobs:
       id: changed-surface
       run: |
         set +e
-        python3 utils/ci_need_suite.py github-ci
+        python3 ci/ci_need_suite.py github-ci
         gate_status=$?
         set -e
         if test "${gate_status}" = 78; then
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 6909620cb1..d87640e061 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -45,7 +45,7 @@ jobs:
       id: changed-surface
       run: |
         set +e
-        python3 utils/ci_need_suite.py github-ci
+        python3 ci/ci_need_suite.py github-ci
         gate_status=$?
         set -e
         if test "${gate_status}" = 78; then
diff --git a/.github/workflows/msys.yml b/.github/workflows/msys.yml
index 2026c0edb8..34c3dbc46e 100644
--- a/.github/workflows/msys.yml
+++ b/.github/workflows/msys.yml
@@ -28,7 +28,7 @@ jobs:
       shell: bash
       run: |
         set +e
-        python3 utils/ci_need_suite.py github-ci
+        python3 ci/ci_need_suite.py github-ci
         gate_status=$?
         set -e
         if test "${gate_status}" = 78; then
diff --git a/.woodpecker/qa.yml b/.woodpecker/qa.yml
index f647055826..6ba78f226d 100644
--- a/.woodpecker/qa.yml
+++ b/.woodpecker/qa.yml
@@ -41,7 +41,7 @@ steps:
           QA_MODE: usan_clang
     commands:
       - <<: *steps-env
-      - python3 $${SRCDIR}/utils/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - mkdir -p "$${BUILDDIR}"
       - cd "$${BUILDDIR}"
@@ -61,7 +61,7 @@ steps:
           QA_MODE: standard_conforming_strings_off
     commands:
       - <<: *steps-env
-      - python3 $${SRCDIR}/utils/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - mkdir -p "$${BUILDDIR}"
       - cd "$${BUILDDIR}"
diff --git a/.woodpecker/regress.yml b/.woodpecker/regress.yml
index 155a4713bb..f88575c4ff 100644
--- a/.woodpecker/regress.yml
+++ b/.woodpecker/regress.yml
@@ -18,7 +18,7 @@ variables:
     - . ci/start-postgresql.sh
     - psql -c 'select version()' template1
   steps-pg-build: &steps-pg-build
-    - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+    - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
     - mkdir -p "$${BUILDDIR}"
     - cd "$${BUILDDIR}"
     - $${SRCDIR}/configure
@@ -31,7 +31,7 @@ variables:
     # make graph can otherwise race generation of files such as postgis.control.
     - make -j1
   steps-pg-test-all-upgrades: &steps-pg-test-all-upgrades
-    - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+    - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
     - cd "$${BUILDDIR}"
     - export CHECK_FIFO=check-$${UPGRADE_EXTENSION}.fifo
     - export CHECK_LOG=check-$${UPGRADE_EXTENSION}.log
@@ -86,7 +86,7 @@ steps:
           REGRESS_SUITE: preinstall
     commands:
       - <<: *steps-env
-      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}"; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}"; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" $${REGRESS_TARGET}
 
@@ -98,7 +98,7 @@ steps:
           REGRESS_SUITE: install
     commands:
       - <<: *steps-env
-      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" install
       - make -C "$${BUILDDIR}" $${REGRESS_TARGET}
@@ -111,7 +111,7 @@ steps:
           REGRESS_SUITE: extension-upgrade
     commands:
       - export SRCDIR=$${CI_WORKSPACE}
-      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-env
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" staged-install
@@ -126,7 +126,7 @@ steps:
           REGRESS_SUITE: cluster-upgrade
     commands:
       - export SRCDIR=$${CI_WORKSPACE}
-      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
+      - python3 $${SRCDIR}/ci/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - make -C build/pg$${PGVER} install
       - make -C build/pg$${SECOND_PGVER} install
       - export PGPORT=$$((20000 + ($${CI_PIPELINE_NUMBER:-0} % 1000) * 32 + ($${CI_WORKFLOW_NUMBER:-30} % 32)))
diff --git a/utils/ci_changed_paths.py b/ci/ci_changed_paths.py
similarity index 100%
rename from utils/ci_changed_paths.py
rename to ci/ci_changed_paths.py
diff --git a/utils/ci_need_suite.py b/ci/ci_need_suite.py
similarity index 98%
rename from utils/ci_need_suite.py
rename to ci/ci_need_suite.py
index 57a8569cec..e05f8429e3 100755
--- a/utils/ci_need_suite.py
+++ b/ci/ci_need_suite.py
@@ -89,7 +89,7 @@ def match_any(path: str, globs: tuple[str, ...]) -> str | None:
 
 
 def changed_paths(base: str | None, head: str) -> tuple[list[str], str]:
-    cmd = ["python3", "utils/ci_changed_paths.py", "--head", head]
+    cmd = ["python3", "ci/ci_changed_paths.py", "--head", head]
     if base:
         cmd.extend(["--base", base])
     result = subprocess.run(
diff --git a/doc/development/testing/ci-gating.md b/doc/development/testing/ci-gating.md
index 8f7814219e..95157fdaa5 100644
--- a/doc/development/testing/ci-gating.md
+++ b/doc/development/testing/ci-gating.md
@@ -26,7 +26,7 @@ The gated pull-request bodies are:
 | GitHub Actions mirror workflows | Linux Docker, FreeBSD, macOS, MSYS2, and CodeQL build or analysis bodies |
 
 The status context still appears. A skipped body exits successfully after
-printing a `SKIP:` reason from `utils/ci_need_suite.py`.
+printing a `SKIP:` reason from `ci/ci_need_suite.py`.
 
 Documentation checks owned by `.woodpecker/docs.yml` are not reduced by this
 gate. Cheap setup steps that exist only to keep workflow structure intact may
@@ -80,7 +80,7 @@ pull-request events. Once a change lands, push CI runs the full set.
 ## How To Tell Whether A Pull Request Was Gated
 
 Open the CI job log for a skipped or unexpectedly short context and look for
-the `utils/ci_need_suite.py` verdict near the start of the job body.
+the `ci/ci_need_suite.py` verdict near the start of the job body.
 
 `RUN:` means the expensive body was kept. The line includes the file or rule
 that required the suite, or says that discovery failed open.
@@ -97,8 +97,8 @@ To reproduce a decision locally, fetch the pull-request base and run the same
 helper against the branch:
 
 ```sh
-python3 utils/ci_need_suite.py install --target check --extension none --base upstream/master
-python3 utils/ci_need_suite.py extension-upgrade --target check --extension postgis --upgrade-surface --base upstream/master
+python3 ci/ci_need_suite.py install --target check --extension none --base upstream/master
+python3 ci/ci_need_suite.py extension-upgrade --target check --extension postgis --upgrade-surface --base upstream/master
 ```
 
 Status `0` means run. Status `78` means skip.
@@ -117,6 +117,6 @@ force the expensive pull-request bodies. Remove the temporary forcing change
 before merge if it is not part of the intended patch.
 
 If a forced run exposes that the gate skipped a suite it should have run, fix
-the glob or ABI rule in `utils/ci_need_suite.py` or
+the glob or ABI rule in `ci/ci_need_suite.py` or
 `utils/check_upgrade_surface.py` instead of relying on the temporary forcing
 change.
diff --git a/utils/check_upgrade_surface.py b/utils/check_upgrade_surface.py
index 7ee61548bb..a6d88074f4 100755
--- a/utils/check_upgrade_surface.py
+++ b/utils/check_upgrade_surface.py
@@ -75,7 +75,7 @@ def match_any(path: str, globs: tuple[str, ...]) -> str | None:
 
 
 def resolve_base(base: str | None, head: str) -> tuple[str, list[str]]:
-    cmd = ["python3", "utils/ci_changed_paths.py", "--head", head]
+    cmd = ["python3", "ci/ci_changed_paths.py", "--head", head]
     if base:
         cmd.extend(["--base", base])
     result = run(cmd, check=False)

commit 772215b879f89485af9ffe11c9386435324f2508
Author: Darafei Praliaskouski <me at komzpa.net>
Date:   Mon Jul 27 20:19:05 2026 +0400

    docs: explain pull-request CI gating

diff --git a/doc/development/testing/_index.md b/doc/development/testing/_index.md
index c03319aee7..47c9a8becf 100644
--- a/doc/development/testing/_index.md
+++ b/doc/development/testing/_index.md
@@ -135,6 +135,9 @@ Other useful targets:
 * [CI inventory standards](ci.md) describe how to keep build-bot and badge
   inventories checkable instead of copying stale dashboard markup into
   maintained prose.
+* [Pull request CI gating](ci-gating.md) explains why expensive pull-request
+  suites may be skipped, how upgrade ABI decisions are made, and how to force a
+  full run when the gate is wrong.
 
 ## Garden Checks
 
diff --git a/doc/development/testing/ci-gating.md b/doc/development/testing/ci-gating.md
new file mode 100644
index 0000000000..8f7814219e
--- /dev/null
+++ b/doc/development/testing/ci-gating.md
@@ -0,0 +1,122 @@
+---
+title: "Pull Request CI Gating"
+date: 2026-07-27
+weight: 20
+geekdocHidden: false
+---
+
+PostGIS pull requests keep the usual CI status contexts, but some expensive job
+bodies are skipped when the changed files cannot affect them. The gate exists
+to make pull-request testing proportional to the changed surface. It does not
+change integration-branch coverage.
+
+## What Is Gated
+
+The gate runs only for pull-request events. Pushes, tags, `master`, and
+`stable-*` integration branches run the full workflow set. If the gate cannot
+identify the pull-request base, compute the changed paths, build the ABI
+comparison trees, or compare symbols, it fails open and runs the suite.
+
+The gated pull-request bodies are:
+
+| CI surface | Gated body |
+| ---------- | ---------- |
+| Woodpecker regression matrix | build, install, preinstall, extension-upgrade, and cluster-upgrade work in `.woodpecker/regress.yml` |
+| Woodpecker QA matrix | sanitizer and standard-conforming-strings-off work in `.woodpecker/qa.yml` |
+| GitHub Actions mirror workflows | Linux Docker, FreeBSD, macOS, MSYS2, and CodeQL build or analysis bodies |
+
+The status context still appears. A skipped body exits successfully after
+printing a `SKIP:` reason from `utils/ci_need_suite.py`.
+
+Documentation checks owned by `.woodpecker/docs.yml` are not reduced by this
+gate. Cheap setup steps that exist only to keep workflow structure intact may
+still run before the expensive body is skipped.
+
+## What Always Runs
+
+Pull requests run the relevant expensive body when they change CI, build, or
+test-control files, including `.woodpecker/*.yml`, `.github/workflows/*.yml`,
+`ci/**`, `configure.ac`, `autogen.sh`, `GNUmakefile.in`, `Makefile.in`,
+`*/Makefile.in`, `macros/**`, and `build-aux/**`.
+
+Subsystem source changes run the matching subsystem coverage:
+
+| Changed surface | Pull-request coverage |
+| --------------- | --------------------- |
+| `liblwgeom/**`, `libpgcommon/**`, `postgis/**`, `regress/core/**`, `regress/dumper/**`, `deps/**`, `sfcgal/**` | core build, CUnit, and core regression coverage |
+| `raster/**`, `extensions/postgis_raster/**` | raster regression and raster upgrade coverage |
+| `topology/**`, `extensions/postgis_topology/**` | topology regression and topology upgrade coverage |
+| `loader/**`, `regress/loader/**` | loader regression coverage |
+| `Version.config`, `extensions/**`, SQL template files, upgrade generators, `postgis_restore.pl`, `utils/check_all_upgrades.sh`, `utils/check_cluster_upgrade.sh` | extension, double-upgrade, locked-upgrade, cluster-upgrade, and platform workflow coverage |
+| `NEWS`, `utils/check_news.sh`, `utils/test_check_news.py` | NEWS checker coverage when that suite uses the gate |
+
+Unknown suites run rather than skip.
+
+## Upgrade And ABI Decisions
+
+Upgrade suites first check SQL-visible paths. Any change to extension SQL,
+upgrade generators, version metadata, restore helpers, or upgrade scripts runs
+the upgrade coverage without an ABI comparison.
+
+If no SQL-visible path changed but a compiled module source changed, the gate
+builds the merge base and pull-request head in temporary worktrees and compares
+the exported modules. It uses `abidiff` when available. In a Debian CI
+container running as root, it tries to install `abigail-tools` to get
+`abidiff`. If `abidiff` is unavailable, it falls back to
+`nm -D --defined-only`.
+
+The `nm` fallback has an important blind spot: it detects added and removed
+exported symbol names, but it cannot prove that a symbol's type, arguments, or
+ABI contract stayed compatible. Because of that blind spot, unchanged symbol
+names under the `nm` fallback still run the upgrade tests.
+
+## Why Integration Branches Are Exempt
+
+`master` and every `stable-*` branch are release and backpatch integration
+surfaces. They need complete, comparable CI history even when an individual
+merge looked narrow during review. The gate therefore applies only to
+pull-request events. Once a change lands, push CI runs the full set.
+
+## How To Tell Whether A Pull Request Was Gated
+
+Open the CI job log for a skipped or unexpectedly short context and look for
+the `utils/ci_need_suite.py` verdict near the start of the job body.
+
+`RUN:` means the expensive body was kept. The line includes the file or rule
+that required the suite, or says that discovery failed open.
+
+`SKIP:` means the expensive body was intentionally skipped. The line includes
+the reason, such as no matching subsystem input or no upgrade-relevant change.
+
+On GitHub Actions mirror workflows, the "Check changed surface" step writes the
+skip decision and later expensive steps are marked skipped. On Woodpecker,
+matrix entries keep their workflow context and exit successfully after the
+`SKIP:` verdict.
+
+To reproduce a decision locally, fetch the pull-request base and run the same
+helper against the branch:
+
+```sh
+python3 utils/ci_need_suite.py install --target check --extension none --base upstream/master
+python3 utils/ci_need_suite.py extension-upgrade --target check --extension postgis --upgrade-surface --base upstream/master
+```
+
+Status `0` means run. Status `78` means skip.
+
+## How To Force The Full Set
+
+For maintainers, the clean force path is to test the branch after it is merged
+or otherwise pushed to an integration ref, because non-pull-request events
+always run the full set.
+
+For a pull request that must prove the full matrix before merge, add a small
+explicit change to a CI or build-control file owned by the gate, such as a
+comment in `.woodpecker/regress.yml`, `.woodpecker/qa.yml`, or the affected
+GitHub workflow. CI/build-control changes match the always-run patterns and
+force the expensive pull-request bodies. Remove the temporary forcing change
+before merge if it is not part of the intended patch.
+
+If a forced run exposes that the gate skipped a suite it should have run, fix
+the glob or ABI rule in `utils/ci_need_suite.py` or
+`utils/check_upgrade_surface.py` instead of relying on the temporary forcing
+change.
diff --git a/doc/development/testing/ci.md b/doc/development/testing/ci.md
index 1e10da445d..cf12bd6215 100644
--- a/doc/development/testing/ci.md
+++ b/doc/development/testing/ci.md
@@ -126,7 +126,9 @@ readback.
 ## Relationship To Other Docs
 
 Use [Testing and debugging](_index.md) for test commands and local validation
-workflows. Use [Release process](../release-process.md) for release-manager
-greenlight checks, Debbie release jobs, and branch-opening steps. Use
-[Website maintenance](../website.md) and the public website compatibility matrix
-for user-facing support status.
+workflows. Use [Pull request CI gating](ci-gating.md) for the changed-surface
+rules that can skip expensive pull-request bodies while keeping status contexts
+present. Use [Release process](../release-process.md) for release-manager
+greenlight checks, Debbie release jobs, and branch-opening steps. Use [Website
+maintenance](../website.md) and the public website compatibility matrix for
+user-facing support status.

commit 42b0d845a7c00848d501fdeacf3633e41fe68eb1
Author: Darafei Praliaskouski <me at komzpa.net>
Date:   Sun Jul 26 19:44:10 2026 +0400

    ci: gate expensive pull-request workflows by changed surface

diff --git a/.github/workflows/ci-freebsd.yml b/.github/workflows/ci-freebsd.yml
index 601aebfc73..e8ae945aa7 100644
--- a/.github/workflows/ci-freebsd.yml
+++ b/.github/workflows/ci-freebsd.yml
@@ -24,8 +24,23 @@ jobs:
       - uses: actions/checkout at 11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
         with:
           persist-credentials: false
+          fetch-depth: 50
+
+      - name: Check changed surface
+        id: changed-surface
+        run: |
+          set +e
+          python3 utils/ci_need_suite.py github-ci
+          gate_status=$?
+          set -e
+          if test "${gate_status}" = 78; then
+            echo "skip=true" >> "${GITHUB_OUTPUT}"
+            exit 0
+          fi
+          exit "${gate_status}"
 
       - name: Cache ccache
+        if: steps.changed-surface.outputs.skip != 'true'
         uses: actions/cache at 27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
         with:
           path: ${{ env.CCACHE_DIR }}
@@ -34,6 +49,7 @@ jobs:
             freebsd-${{ matrix.version }}-ccache-
 
       - name: Build and test on FreeBSD
+        if: steps.changed-surface.outputs.skip != 'true'
         uses: vmactions/freebsd-vm at a6de9343ef5747433d9c25784c90e84998b9d69a # v1.4.6
         with:
           release: ${{ matrix.version }}
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index 67c0cc1d4d..e1d67b5b89 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -21,8 +21,23 @@ jobs:
       - uses: actions/checkout at 11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
         with:
           persist-credentials: false
+          fetch-depth: 50
+
+      - name: Check changed surface
+        id: changed-surface
+        run: |
+          set +e
+          python3 utils/ci_need_suite.py github-ci
+          gate_status=$?
+          set -e
+          if test "${gate_status}" = 78; then
+            echo "skip=true" >> "${GITHUB_OUTPUT}"
+            exit 0
+          fi
+          exit "${gate_status}"
 
       - name: Cache ccache
+        if: steps.changed-surface.outputs.skip != 'true'
         uses: actions/cache at 27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
         with:
           path: ${{ env.CCACHE_DIR }}
@@ -31,6 +46,7 @@ jobs:
             macos-ccache-
 
       - name: Install dependencies
+        if: steps.changed-surface.outputs.skip != 'true'
         run: |
           brew update
           brew install \
@@ -45,6 +61,7 @@ jobs:
           sudo ln -sfn "${HOMEBREW_PREFIX}/opt/postgresql@${PG}/bin/postgres" /usr/local/bin/postgres
 
       - name: Build and test
+        if: steps.changed-surface.outputs.skip != 'true'
         env:
           CC: "ccache clang"
           CXX: "ccache clang++"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 82646b0bac..fae239715b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,8 +47,24 @@ jobs:
 
     - name: 'Check Out'
       uses: actions/checkout at v6
+      with:
+        fetch-depth: 50
+
+    - name: Check changed surface
+      id: changed-surface
+      run: |
+        set +e
+        python3 utils/ci_need_suite.py github-ci
+        gate_status=$?
+        set -e
+        if test "${gate_status}" = 78; then
+          echo "skip=true" >> "${GITHUB_OUTPUT}"
+          exit 0
+        fi
+        exit "${gate_status}"
 
     - name: 'Prepare Image'
+      if: steps.changed-surface.outputs.skip != 'true'
       run: |
         set -e
         retry() {
@@ -89,13 +105,14 @@ jobs:
         docker build --pull=false -t pgtest .
 
     - name: 'Build & Test'
+      if: steps.changed-surface.outputs.skip != 'true'
       run: |
         echo "/tmp/logbt-coredumps/core.%p.%E" | sudo tee /proc/sys/kernel/core_pattern
         ci_env=`bash .github/codecov_env.bash`
         docker run -e RUNTESTFLAGS=-v $ci_env --name pgtest-${GITHUB_RUN_NUMBER} pgtest
 
     - name: 'Clean Up Image'
-      if: ${{ success() }}
+      if: ${{ success() && steps.changed-surface.outputs.skip != 'true' }}
       run: |
         docker rm pgtest-${GITHUB_RUN_NUMBER}
 
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index ff3fa0261f..6909620cb1 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -2,7 +2,7 @@ name: "CodeQL"
 
 on:
   push:
-  pull_request:
+  pull_request: ~
 
 concurrency:
   group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
@@ -38,9 +38,25 @@ jobs:
     steps:
     - name: Checkout repository
       uses: actions/checkout at v6
+      with:
+        fetch-depth: 50
+
+    - name: Check changed surface
+      id: changed-surface
+      run: |
+        set +e
+        python3 utils/ci_need_suite.py github-ci
+        gate_status=$?
+        set -e
+        if test "${gate_status}" = 78; then
+          echo "skip=true" >> "${GITHUB_OUTPUT}"
+          exit 0
+        fi
+        exit "${gate_status}"
 
     # Initializes the CodeQL tools for scanning.
     - name: Initialize CodeQL
+      if: steps.changed-surface.outputs.skip != 'true'
       uses: github/codeql-action/init at v3
       with:
         languages: ${{ matrix.language }}
@@ -52,17 +68,20 @@ jobs:
         # queries: security-extended,security-and-quality
 
     - name: Install dependencies
+      if: steps.changed-surface.outputs.skip != 'true'
       run: |
           sudo apt-get update
           sudo apt-get install -y g++ autoconf automake libgeos-dev libproj-dev libgdal-dev libjson-c-dev libsfcgal-dev gettext libxml2-dev postgresql-server-dev-16 postgresql-client-16 libprotobuf-c-dev libprotoc-dev protobuf-c-compiler clang-19
 
     - name: Build
+      if: steps.changed-surface.outputs.skip != 'true'
       run: |
           ./autogen.sh
           ./configure
           make -j$(nproc)
 
     - name: Perform CodeQL Analysis
+      if: steps.changed-surface.outputs.skip != 'true'
       uses: github/codeql-action/analyze at v3
       with:
         category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/msys.yml b/.github/workflows/msys.yml
index 51a68c14be..2026c0edb8 100644
--- a/.github/workflows/msys.yml
+++ b/.github/workflows/msys.yml
@@ -1,5 +1,7 @@
 name: MSYS2
-on: [push, pull_request]
+on:
+  push:
+  pull_request: ~
 
 jobs:
   mingw:
@@ -19,7 +21,23 @@ jobs:
       # see https://github.com/msys2/setup-msys2
     - name: checkout
       uses: actions/checkout at v6
+      with:
+        fetch-depth: 50
+    - name: Check changed surface
+      id: changed-surface
+      shell: bash
+      run: |
+        set +e
+        python3 utils/ci_need_suite.py github-ci
+        gate_status=$?
+        set -e
+        if test "${gate_status}" = 78; then
+          echo "skip=true" >> "${GITHUB_OUTPUT}"
+          exit 0
+        fi
+        exit "${gate_status}"
     - name: '${{ matrix.icon }} Setup MSYS2'
+      if: steps.changed-surface.outputs.skip != 'true'
       uses: msys2/setup-msys2 at v2
       with:
         msystem: ${{ matrix.sys }}
@@ -42,6 +60,7 @@ jobs:
           postgresql:p
 
     - name: '🚧 Build TOOL'
+      if: steps.changed-surface.outputs.skip != 'true'
       run: |
         MSYSTEM= /${{ matrix.sys }}/bin/initdb -D D:/a/_tmp/psql
         MSYSTEM= /${{ matrix.sys }}/bin/pg_ctl -D D:/a/_tmp/psql -l logfile start
diff --git a/.woodpecker/qa.yml b/.woodpecker/qa.yml
index 2befd93b63..f647055826 100644
--- a/.woodpecker/qa.yml
+++ b/.woodpecker/qa.yml
@@ -41,6 +41,7 @@ steps:
           QA_MODE: usan_clang
     commands:
       - <<: *steps-env
+      - python3 $${SRCDIR}/utils/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - mkdir -p "$${BUILDDIR}"
       - cd "$${BUILDDIR}"
@@ -60,6 +61,7 @@ steps:
           QA_MODE: standard_conforming_strings_off
     commands:
       - <<: *steps-env
+      - python3 $${SRCDIR}/utils/ci_need_suite.py qa; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - mkdir -p "$${BUILDDIR}"
       - cd "$${BUILDDIR}"
diff --git a/.woodpecker/regress.yml b/.woodpecker/regress.yml
index e8402be479..155a4713bb 100644
--- a/.woodpecker/regress.yml
+++ b/.woodpecker/regress.yml
@@ -18,6 +18,7 @@ variables:
     - . ci/start-postgresql.sh
     - psql -c 'select version()' template1
   steps-pg-build: &steps-pg-build
+    - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
     - mkdir -p "$${BUILDDIR}"
     - cd "$${BUILDDIR}"
     - $${SRCDIR}/configure
@@ -30,6 +31,7 @@ variables:
     # make graph can otherwise race generation of files such as postgis.control.
     - make -j1
   steps-pg-test-all-upgrades: &steps-pg-test-all-upgrades
+    - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
     - cd "$${BUILDDIR}"
     - export CHECK_FIFO=check-$${UPGRADE_EXTENSION}.fifo
     - export CHECK_LOG=check-$${UPGRADE_EXTENSION}.log
@@ -84,6 +86,7 @@ steps:
           REGRESS_SUITE: preinstall
     commands:
       - <<: *steps-env
+      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}"; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" $${REGRESS_TARGET}
 
@@ -95,6 +98,7 @@ steps:
           REGRESS_SUITE: install
     commands:
       - <<: *steps-env
+      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" install
       - make -C "$${BUILDDIR}" $${REGRESS_TARGET}
@@ -106,6 +110,8 @@ steps:
       - matrix:
           REGRESS_SUITE: extension-upgrade
     commands:
+      - export SRCDIR=$${CI_WORKSPACE}
+      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - <<: *steps-env
       - <<: *steps-start-postgresql
       - make -C "$${BUILDDIR}" staged-install
@@ -119,6 +125,8 @@ steps:
       - matrix:
           REGRESS_SUITE: cluster-upgrade
     commands:
+      - export SRCDIR=$${CI_WORKSPACE}
+      - python3 $${SRCDIR}/utils/ci_need_suite.py "$${REGRESS_SUITE}" --target "$${REGRESS_TARGET}" --extension "$${UPGRADE_EXTENSION}" --upgrade-surface; gate_status=$$?; if test "$${gate_status}" = 78; then exit 0; elif test "$${gate_status}" != 0; then exit "$${gate_status}"; fi
       - make -C build/pg$${PGVER} install
       - make -C build/pg$${SECOND_PGVER} install
       - export PGPORT=$$((20000 + ($${CI_PIPELINE_NUMBER:-0} % 1000) * 32 + ($${CI_WORKFLOW_NUMBER:-30} % 32)))
diff --git a/utils/check_upgrade_surface.py b/utils/check_upgrade_surface.py
new file mode 100755
index 0000000000..7ee61548bb
--- /dev/null
+++ b/utils/check_upgrade_surface.py
@@ -0,0 +1,265 @@
+#!/usr/bin/env python3
+"""Check whether a change touches the PostGIS upgrade-relevant surface.
+
+The verdict is conservative.  Status 78 means the expensive upgrade suite may be
+skipped.  Status 0 means it must run, including every uncertain condition.
+"""
+
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+SQL_VISIBLE_GLOBS = (
+    "Version.config",
+    "extensions/**",
+    "postgis/*.sql.in",
+    "raster/rt_pg/*.sql.in",
+    "topology/*.sql.in",
+    "topology/**/*.sql.in",
+    "sfcgal/**/*.sql.in",
+    "utils/create_upgrade.pl",
+    "utils/create_unpackaged.pl",
+    "utils/create_extension_unpackage.pl",
+    "utils/postgis_restore.pl",
+    "utils/postgis_restore.pl.in",
+)
+
+MODULE_GLOBS = (
+    "liblwgeom/**",
+    "postgis/**",
+    "raster/rt_pg/**",
+    "topology/**",
+    "sfcgal/**",
+)
+
+MODULE_PATTERNS = (
+    "liblwgeom/.libs/liblwgeom-*.so*",
+    "postgis/.libs/postgis-*.so*",
+    "postgis/postgis-*.so*",
+    "raster/rt_pg/.libs/postgis_raster-*.so*",
+    "raster/rt_pg/postgis_raster-*.so*",
+    "topology/.libs/postgis_topology-*.so*",
+    "topology/postgis_topology-*.so*",
+    "sfcgal/.libs/postgis_sfcgal-*.so*",
+    "sfcgal/postgis_sfcgal-*.so*",
+)
+
+
+def run(args: list[str], cwd: Path | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(
+        args,
+        cwd=cwd,
+        check=check,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+    )
+
+
+def git(args: list[str], check: bool = True) -> str:
+    return run(["git", *args], check=check).stdout
+
+
+def match_any(path: str, globs: tuple[str, ...]) -> str | None:
+    for pattern in globs:
+        if fnmatch.fnmatch(path, pattern):
+            return pattern
+    return None
+
+
+def resolve_base(base: str | None, head: str) -> tuple[str, list[str]]:
+    cmd = ["python3", "utils/ci_changed_paths.py", "--head", head]
+    if base:
+        cmd.extend(["--base", base])
+    result = run(cmd, check=False)
+    if result.returncode != 0:
+        raise RuntimeError(result.stdout.strip() or "changed path helper failed")
+    paths = [line for line in result.stdout.splitlines() if line]
+
+    resolved_base = base
+    if not resolved_base:
+        for candidate in ("refs/ci-target/master", "upstream/master", "origin/master", "master"):
+            if run(["git", "rev-parse", "--verify", candidate], check=False).returncode == 0:
+                resolved_base = candidate
+                break
+    if not resolved_base:
+        raise RuntimeError("no base ref available for symbol comparison")
+    merge_base = git(["merge-base", resolved_base, head]).strip()
+    if not merge_base:
+        raise RuntimeError(f"no merge base between {resolved_base} and {head}")
+    return merge_base, paths
+
+
+def build_tree(ref: str, root: Path, name: str) -> Path:
+    tree = root / name / "src"
+    build = root / name / "build"
+    if tree.exists():
+        shutil.rmtree(tree)
+    if build.exists():
+        shutil.rmtree(build)
+    run(["git", "worktree", "add", "--detach", str(tree), ref])
+    build.mkdir(parents=True, exist_ok=True)
+    configure = [
+        str(tree / "configure"),
+        "--with-library-minor-version",
+        "--without-raster",
+        "--without-topology",
+        "--without-sfcgal",
+        "--without-protobuf",
+        "--disable-spellcheck-tests",
+    ]
+    run([str(tree / "autogen.sh")], cwd=tree)
+    run(configure, cwd=build)
+    run(["make", "-j1", "SUBDIRS=liblwgeom libpgcommon postgis"], cwd=build)
+    return build
+
+
+def module_files(build: Path) -> list[Path]:
+    files: list[Path] = []
+    for pattern in MODULE_PATTERNS:
+        files.extend(build.glob(pattern))
+    return sorted(path for path in files if path.is_file())
+
+
+def relative_module_map(build: Path) -> dict[str, Path]:
+    result: dict[str, Path] = {}
+    for path in module_files(build):
+        rel = str(path.relative_to(build))
+        result[rel] = path
+    return result
+
+
+def symbol_set(path: Path) -> set[str]:
+    output = run(["nm", "-D", "--defined-only", str(path)]).stdout
+    symbols: set[str] = set()
+    for line in output.splitlines():
+        parts = line.split()
+        if len(parts) >= 3:
+            symbols.add(parts[-1])
+    return symbols
+
+
+def compare_symbols(base_build: Path, head_build: Path) -> tuple[bool, str]:
+    abidiff = ensure_abidiff()
+    base_modules = relative_module_map(base_build)
+    head_modules = relative_module_map(head_build)
+    names = sorted(set(base_modules) | set(head_modules))
+    if not names:
+        return True, "no built modules found for comparison"
+
+    for name in names:
+        if name not in base_modules:
+            return True, f"exported module added: {name}"
+        if name not in head_modules:
+            return True, f"exported module removed: {name}"
+        if abidiff:
+            diff = run([abidiff, str(base_modules[name]), str(head_modules[name])], check=False)
+            if diff.returncode not in (0,):
+                return True, f"abidiff reports ABI change in {name}"
+        else:
+            base_symbols = symbol_set(base_modules[name])
+            head_symbols = symbol_set(head_modules[name])
+            if base_symbols != head_symbols:
+                added = sorted(head_symbols - base_symbols)
+                removed = sorted(base_symbols - head_symbols)
+                sample = (removed or added)[0]
+                direction = "removed" if removed else "added"
+                return True, f"defined symbol {direction} in {name}: {sample}"
+
+    if not abidiff:
+        return True, "abidiff is unavailable; nm fallback found no added or removed symbols but cannot prove signatures unchanged"
+    return False, "no SQL-visible path or exported ABI change"
+
+
+def ensure_abidiff() -> str | None:
+    abidiff = shutil.which("abidiff")
+    if abidiff:
+        print(f"INFO: using abidiff at {abidiff}")
+        return abidiff
+
+    if os.geteuid() != 0 or not shutil.which("apt-get"):
+        print("INFO: abidiff is unavailable and cannot be installed by this user")
+        return None
+
+    install = run(
+        [
+            "sh",
+            "-c",
+            "apt-get update && apt-get install -y --no-install-recommends abigail-tools && rm -rf /var/lib/apt/lists/*",
+        ],
+        check=False,
+    )
+    if install.returncode != 0:
+        print("INFO: installing abigail-tools failed; falling back to nm")
+        print(install.stdout)
+        return None
+
+    abidiff = shutil.which("abidiff")
+    if abidiff:
+        print(f"INFO: installed abidiff at {abidiff}")
+    else:
+        print("INFO: abigail-tools install completed but abidiff is still unavailable")
+    return abidiff
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--base")
+    parser.add_argument("--head", default="HEAD")
+    parser.add_argument(
+        "--work-dir",
+        default=os.environ.get("TMPDIR", ".tmp") + "/upgrade-surface",
+    )
+    args = parser.parse_args()
+
+    try:
+        merge_base, paths = resolve_base(args.base, args.head)
+    except Exception as exc:
+        print(f"RUN: upgrade-surface discovery failed open: {exc}")
+        return 0
+
+    if not paths:
+        print("RUN: changed-path list is empty; failing open")
+        return 0
+
+    for path in paths:
+        pattern = match_any(path, SQL_VISIBLE_GLOBS)
+        if pattern:
+            print(f"RUN: {path} matches SQL-visible upgrade pattern {pattern}")
+            return 0
+
+    if not any(match_any(path, MODULE_GLOBS) for path in paths):
+        print("SKIP: no upgrade-relevant change (no SQL-visible path or module source changed)")
+        return 78
+
+    root = Path(args.work_dir).resolve()
+    root.mkdir(parents=True, exist_ok=True)
+    try:
+        base_build = build_tree(merge_base, root, "base")
+        head_build = build_tree(args.head, root, "head")
+        needs_upgrade, reason = compare_symbols(base_build, head_build)
+    except Exception as exc:
+        print(f"RUN: symbol comparison failed open: {exc}")
+        return 0
+    finally:
+        for tree in (root / "base" / "src", root / "head" / "src"):
+            if tree.exists():
+                run(["git", "worktree", "remove", "--force", str(tree)], check=False)
+
+    if needs_upgrade:
+        print(f"RUN: {reason}")
+        return 0
+
+    print(f"SKIP: no upgrade-relevant change ({reason})")
+    return 78
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/utils/ci_changed_paths.py b/utils/ci_changed_paths.py
new file mode 100755
index 0000000000..f1daf6c17a
--- /dev/null
+++ b/utils/ci_changed_paths.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""Report paths changed by the current CI build.
+
+The script is intentionally conservative.  If it cannot identify a pull-request
+base, it prints the reason and exits with status 2 so callers can fail open.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+
+def run_git(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
+    return subprocess.run(
+        ["git", *args],
+        check=check,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+
+
+def resolve_base(explicit_base: str | None) -> str:
+    if explicit_base:
+        return explicit_base
+
+    for name in (
+        "CI_COMMIT_TARGET_BRANCH",
+        "WOODPECKER_PULL_REQUEST_TARGET",
+        "GITHUB_BASE_REF",
+    ):
+        target = os.environ.get(name)
+        if target:
+            ref = f"refs/ci-target/{target}"
+            fetch = run_git(
+                ["fetch", "--no-tags", "origin", f"+refs/heads/{target}:{ref}"],
+                check=False,
+            )
+            if fetch.returncode == 0:
+                return ref
+            fetch = run_git(
+                ["fetch", "--no-tags", "upstream", f"+refs/heads/{target}:{ref}"],
+                check=False,
+            )
+            if fetch.returncode == 0:
+                return ref
+            raise RuntimeError(
+                f"{name}={target} is set, but fetching that branch failed"
+            )
+
+    for candidate in ("upstream/master", "origin/master", "master"):
+        exists = run_git(["rev-parse", "--verify", candidate], check=False)
+        if exists.returncode == 0:
+            return candidate
+
+    raise RuntimeError("no pull-request target branch or local master ref found")
+
+
+def changed_paths(base: str, head: str) -> list[str]:
+    merge_base = run_git(["merge-base", base, head]).stdout.strip()
+    if not merge_base:
+        raise RuntimeError(f"no merge base between {base} and {head}")
+    diff = run_git(["diff", "--name-only", f"{merge_base}...{head}"]).stdout
+    return [line for line in diff.splitlines() if line]
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--base", help="base ref to diff against")
+    parser.add_argument("--head", default="HEAD", help="head ref to diff")
+    parser.add_argument(
+        "--output",
+        type=Path,
+        help="write the changed path list here instead of stdout",
+    )
+    args = parser.parse_args()
+
+    try:
+        base = resolve_base(args.base)
+        paths = changed_paths(base, args.head)
+    except Exception as exc:
+        print(f"RUN: changed-path discovery failed open: {exc}", file=sys.stderr)
+        return 2
+
+    output = "\n".join(paths)
+    if output:
+        output += "\n"
+    if args.output:
+        args.output.write_text(output, encoding="utf-8")
+    else:
+        print(output, end="")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/utils/ci_need_suite.py b/utils/ci_need_suite.py
new file mode 100755
index 0000000000..57a8569cec
--- /dev/null
+++ b/utils/ci_need_suite.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""Decide whether an expensive CI suite is needed for this change.
+
+Exit status is part of the interface:
+
+* 0 means run the suite.
+* 78 means skip the expensive body; the CI step should exit successfully.
+
+Any ambiguity prints a RUN verdict and exits 0.
+"""
+
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import os
+import subprocess
+import sys
+
+
+CI_AND_BUILD_GLOBS = (
+    ".woodpecker/*.yml",
+    ".github/workflows/*.yml",
+    "ci/**",
+    "configure.ac",
+    "configure.in",
+    "autogen.sh",
+    "GNUmakefile.in",
+    "Makefile.in",
+    "*/Makefile.in",
+    "macros/**",
+    "build-aux/**",
+)
+
+CORE_GLOBS = (
+    "liblwgeom/**",
+    "libpgcommon/**",
+    "postgis/**",
+    "regress/core/**",
+    "regress/dumper/**",
+    "deps/**",
+    "sfcgal/**",
+)
+
+RASTER_GLOBS = (
+    "raster/**",
+    "extensions/postgis_raster/**",
+)
+
+TOPOLOGY_GLOBS = (
+    "topology/**",
+    "extensions/postgis_topology/**",
+)
+
+LOADER_GLOBS = (
+    "loader/**",
+    "regress/loader/**",
+)
+
+UPGRADE_GLOBS = (
+    "Version.config",
+    "extensions/**",
+    "postgis/*.sql.in",
+    "raster/rt_pg/*.sql.in",
+    "topology/*.sql.in",
+    "topology/**/*.sql.in",
+    "sfcgal/**/*.sql.in",
+    "utils/create_upgrade.pl",
+    "utils/create_unpackaged.pl",
+    "utils/create_extension_unpackage.pl",
+    "utils/postgis_restore.pl",
+    "utils/postgis_restore.pl.in",
+    "utils/check_all_upgrades.sh",
+    "utils/check_cluster_upgrade.sh",
+)
+
+NEWS_GLOBS = (
+    "NEWS",
+    "utils/check_news.sh",
+    "utils/test_check_news.py",
+)
+
+
+def match_any(path: str, globs: tuple[str, ...]) -> str | None:
+    for pattern in globs:
+        if fnmatch.fnmatch(path, pattern):
+            return pattern
+    return None
+
+
+def changed_paths(base: str | None, head: str) -> tuple[list[str], str]:
+    cmd = ["python3", "utils/ci_changed_paths.py", "--head", head]
+    if base:
+        cmd.extend(["--base", base])
+    result = subprocess.run(
+        cmd,
+        check=False,
+        text=True,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+    )
+    if result.returncode != 0:
+        raise RuntimeError(result.stderr.strip() or "changed path helper failed")
+    return [line for line in result.stdout.splitlines() if line], result.stdout
+
+
+def run_upgrade_surface(base: str | None, head: str) -> int:
+    cmd = ["python3", "utils/check_upgrade_surface.py", "--head", head]
+    if base:
+        cmd.extend(["--base", base])
+    return subprocess.call(cmd)
+
+
+def need_for_suite(paths: list[str], suite: str, target: str, extension: str) -> tuple[bool, str]:
+    for path in paths:
+        pattern = match_any(path, CI_AND_BUILD_GLOBS)
+        if pattern:
+            return True, f"{path} matches always-run CI/build pattern {pattern}"
+
+    if suite == "news":
+        for path in paths:
+            pattern = match_any(path, NEWS_GLOBS)
+            if pattern:
+                return True, f"{path} matches NEWS checker pattern {pattern}"
+        return False, "no NEWS checker input changed"
+
+    if suite == "extension-upgrade" or "upgrade" in target or suite == "cluster-upgrade":
+        direct_globs = UPGRADE_GLOBS
+        if extension == "postgis_raster":
+            direct_globs = UPGRADE_GLOBS + RASTER_GLOBS
+        elif extension == "postgis_topology":
+            direct_globs = UPGRADE_GLOBS + TOPOLOGY_GLOBS
+        elif extension == "postgis_sfcgal":
+            direct_globs = UPGRADE_GLOBS + ("sfcgal/**", "extensions/postgis_sfcgal/**")
+        for path in paths:
+            pattern = match_any(path, direct_globs)
+            if pattern:
+                return True, f"{path} matches upgrade-relevant pattern {pattern}"
+        return False, "no directly upgrade-relevant path changed"
+
+    if suite in {"preinstall", "install", "qa", "github-ci"}:
+        for path in paths:
+            if suite == "github-ci":
+                pattern = match_any(path, UPGRADE_GLOBS)
+                if pattern:
+                    return True, f"{path} matches upgrade-visible platform test pattern {pattern}"
+            for group, globs in (
+                ("core", CORE_GLOBS),
+                ("raster", RASTER_GLOBS),
+                ("topology", TOPOLOGY_GLOBS),
+                ("loader", LOADER_GLOBS),
+            ):
+                pattern = match_any(path, globs)
+                if pattern:
+                    return True, f"{path} matches {group} test pattern {pattern}"
+        return False, "no core, raster, topology, or loader test input changed"
+
+    return True, f"unknown suite {suite!r}; running"
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("suite")
+    parser.add_argument("--target", default=os.environ.get("REGRESS_TARGET", ""))
+    parser.add_argument("--extension", default=os.environ.get("UPGRADE_EXTENSION", "none"))
+    parser.add_argument("--base")
+    parser.add_argument("--head", default="HEAD")
+    parser.add_argument("--upgrade-surface", action="store_true")
+    args = parser.parse_args()
+
+    if os.environ.get("CI_PIPELINE_EVENT") not in (None, "", "pull_request"):
+        print(f"RUN: {os.environ.get('CI_PIPELINE_EVENT')} event runs full CI")
+        return 0
+    if os.environ.get("GITHUB_EVENT_NAME") not in (None, "", "pull_request"):
+        print(f"RUN: {os.environ.get('GITHUB_EVENT_NAME')} event runs full CI")
+        return 0
+
+    try:
+        paths, _ = changed_paths(args.base, args.head)
+    except Exception as exc:
+        print(f"RUN: changed-path discovery failed open: {exc}")
+        return 0
+
+    if not paths:
+        print("RUN: changed-path list is empty; failing open")
+        return 0
+
+    need, reason = need_for_suite(paths, args.suite, args.target, args.extension)
+    if need:
+        print(f"RUN: {reason}")
+        return 0
+
+    if args.upgrade_surface:
+        rc = run_upgrade_surface(args.base, args.head)
+        if rc == 78:
+            return 78
+        return 0
+
+    print(f"SKIP: {reason}")
+    return 78
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

-----------------------------------------------------------------------

Summary of changes:
 .github/workflows/ci-freebsd.yml     |  16 +++
 .github/workflows/ci-macos.yml       |  17 +++
 .github/workflows/ci.yml             |  19 ++-
 .github/workflows/codeql.yml         |  21 ++-
 .github/workflows/msys.yml           |  21 ++-
 .woodpecker/qa.yml                   |   2 +
 .woodpecker/regress.yml              |   8 ++
 ci/ci_changed_paths.py               | 100 +++++++++++++
 ci/ci_need_suite.py                  | 204 +++++++++++++++++++++++++++
 doc/development/testing/_index.md    |   3 +
 doc/development/testing/ci-gating.md | 122 ++++++++++++++++
 doc/development/testing/ci.md        |  10 +-
 utils/check_upgrade_surface.py       | 265 +++++++++++++++++++++++++++++++++++
 13 files changed, 801 insertions(+), 7 deletions(-)
 create mode 100755 ci/ci_changed_paths.py
 create mode 100755 ci/ci_need_suite.py
 create mode 100644 doc/development/testing/ci-gating.md
 create mode 100755 utils/check_upgrade_surface.py


hooks/post-receive
-- 
PostGIS


More information about the postgis-tickets mailing list