[SCM] PostGIS branch master updated. 3.7.0beta1-26-g17935ee71

git at osgeo.org git at osgeo.org
Sat Jul 25 10:02:53 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  17935ee71e12df9b315b171d2d080c7a16aa7193 (commit)
      from  6111c98a95a0799cf0e318247d8630c2acb00863 (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 17935ee71e12df9b315b171d2d080c7a16aa7193
Author: Darafei Praliaskouski <komzpa at gmail.com>
Date:   Sat Jul 25 10:02:52 2026 -0700

    ci: reject NEWS entries in closed releases (!508)
    
    Pull-request branches can currently add release notes to already closed release
    sections because `check_news.sh` validates heading order but does not compare
    new entries with the target branch.
    
    Fetch the pull request's target branch into a dedicated ref and compare its
    `NEWS` file with the proposed tree. Reject new entries in closed sections while
    allowing entries in the current unreleased section, a new unreleased section
    created after a release cut, and unchanged historical entries.
    
    Run the comparison from the Woodpecker tools workflow and add 12 focused unit
    tests covering release cuts, stale branches, shallow checkouts, duplicate
    entries, and historical indentation.
    
    ---------
    
    Co-authored-by: Darafei Praliaskouski <me at komzpa.net>
    Reviewed-on: https://gitea.osgeo.org/postgis/postgis/pulls/508

diff --git a/GNUmakefile.in b/GNUmakefile.in
index 89b1675a0..cbd0fd83b 100644
--- a/GNUmakefile.in
+++ b/GNUmakefile.in
@@ -100,6 +100,11 @@ visual-examples: staged-install
 	$(MAKE) -C regress visual-examples-run
 
 check-news:
+	@if test -z "$(PYTHON3)"; then \
+		echo "SKIP: NEWS unit tests require python3"; \
+	else \
+		$(PYTHON3) -B $(top_srcdir)/utils/test_check_news.py; \
+	fi
 	$(top_srcdir)/utils/check_news.sh $(top_srcdir)
 
 .PHONY: check-contributor-credits
diff --git a/ci/dronie/postgis_tools.sh b/ci/dronie/postgis_tools.sh
index ae3e36ae4..920392862 100644
--- a/ci/dronie/postgis_tools.sh
+++ b/ci/dronie/postgis_tools.sh
@@ -3,6 +3,17 @@
 # Exit on first error
 set -e
 
+if test "${CI_PIPELINE_EVENT:-}" = pull_request; then
+  if test -z "${CI_COMMIT_TARGET_BRANCH:-}"; then
+    echo "CI_COMMIT_TARGET_BRANCH is required for pull-request NEWS checks" >&2
+    exit 1
+  fi
+  git fetch --no-tags origin \
+    "+refs/heads/${CI_COMMIT_TARGET_BRANCH}:refs/news-check/target"
+  NEWS_CHECK_BASE_REF="refs/news-check/target"
+  export NEWS_CHECK_BASE_REF
+fi
+
 sh autogen.sh
 ./configure --without-pgconfig --prefix=/tmp/pgx
 make
diff --git a/utils/check_news.sh b/utils/check_news.sh
index ed7f85096..8379ada08 100755
--- a/utils/check_news.sh
+++ b/utils/check_news.sh
@@ -1,7 +1,7 @@
 #!/bin/sh
 
 usage() {
-  echo "Usage: $0 [-v] [--ticket-refs] [--ticket-refs-skip-commits=<file>] [<sourcedir>]"
+  echo "Usage: $0 [-v] [--base-ref=<ref>] [--ticket-refs] [--ticket-refs-skip-commits=<file>] [<sourcedir>]"
   echo "Sourcedir defaults to one directory above this script"
 }
 
@@ -10,6 +10,7 @@ usage() {
 VERBOSE=no
 TICKET_REFS=no
 RD= # Root source dir
+NEWS_BASE_REF=${NEWS_CHECK_BASE_REF:-}
 TICKET_REFS_SKIP_COMMITS=/dev/null
 while [ $# -gt 0 ]; do
   if [ "$1" = "--help" ]; then
@@ -19,6 +20,16 @@ while [ $# -gt 0 ]; do
     VERBOSE=yes
   elif [ "$1" = "--ticket-refs" ]; then
     TICKET_REFS="yes"
+  elif [ "$1" = "--base-ref" ]; then
+    shift
+    if [ $# -eq 0 ]; then
+      echo "ERROR: --base-ref requires a Git ref" >&2
+      usage >&2
+      exit 1
+    fi
+    NEWS_BASE_REF=$1
+  elif [ "${1#--base-ref=}" != "$1" ]; then
+    NEWS_BASE_REF=${1#--base-ref=}
   elif [ "$1" = "--ticket-refs-skip-commits" ]; then
     shift
     TICKET_REFS_SKIP_COMMITS=$( cd $( dirname $1 ) && pwd )
@@ -70,6 +81,84 @@ grep -B1 '^[0-9]\{4\}/[0-9]\{2\}/[0-9]\{2\}' NEWS |
 test $? = 0 || exit 1
 echo "PASS: NEWS file entries are in good order"
 
+if test -n "${NEWS_BASE_REF}"; then
+  if ! git rev-parse --verify "${NEWS_BASE_REF}^{commit}" >/dev/null 2>&1; then
+    echo "FAIL: NEWS base ref does not resolve to a commit: ${NEWS_BASE_REF}"
+    exit 1
+  fi
+
+  news_target_file=$(mktemp "${TMPDIR:-/tmp}/check-news-target.XXXXXX") || {
+    exit 1
+  }
+  trap 'rm -f "${news_target_file}"' EXIT HUP INT TERM
+  if ! git show "${NEWS_BASE_REF}:NEWS" > "${news_target_file}"; then
+    echo "FAIL: NEWS is unavailable at target ref ${NEWS_BASE_REF}"
+    exit 1
+  fi
+
+  if ! awk '
+    function is_unreleased(date) {
+      return date ~ /^[0-9][0-9][0-9][0-9]\/xx\/xx$/
+    }
+    function section_accepts_new_entries() {
+      target_section_is_open = release == target_first &&
+        is_unreleased(target_dates[target_first])
+      current_section_is_open = release == current_first &&
+        is_unreleased(release_date) &&
+        !(release in target_dates)
+      return target_section_is_open || current_section_is_open
+    }
+    function emit_release() {
+      if (release == "")
+        return
+
+      if (file_number == 1) {
+        target_dates[release] = release_date
+        if (target_first == "")
+          target_first = release
+      }
+    }
+    FNR == 1 {
+      if (file_number != 0)
+        emit_release()
+      file_number++
+      release = ""
+    }
+    /^PostGIS [0-9]/ {
+      emit_release()
+      release = $0
+      release_date = ""
+      if (file_number == 2 && current_first == "")
+        current_first = release
+      next
+    }
+    release != "" && release_date == "" &&
+      /^[0-9][0-9][0-9][0-9]\/([0-9][0-9]|xx)\/([0-9][0-9]|xx)$/ {
+      release_date = $0
+      next
+    }
+    release != "" && /^[[:space:]]+-[[:space:]]/ {
+      entry_key = release SUBSEP $0
+      if (file_number == 1) {
+        target_entries[entry_key]++
+      } else if (file_number == 2 &&
+          !section_accepts_new_entries() &&
+          ++current_entries[entry_key] > target_entries[entry_key]) {
+        print "FAIL: " release \
+          " has a new NEWS entry outside the current unreleased section: " $0
+        failures++
+      }
+    }
+    END {
+      emit_release()
+      exit failures != 0
+    }
+  ' "${news_target_file}" NEWS; then
+    exit 1
+  fi
+  echo "PASS: New NEWS entries are in the current unreleased section"
+fi
+
 if test "${TICKET_REFS}" = "yes"; then
 
   last_news_release=$( grep \
diff --git a/utils/test_check_news.py b/utils/test_check_news.py
new file mode 100644
index 000000000..093126115
--- /dev/null
+++ b/utils/test_check_news.py
@@ -0,0 +1,312 @@
+#!/usr/bin/env python3
+
+import os
+import subprocess
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+
+CHECK_NEWS = Path(__file__).with_name("check_news.sh")
+
+BASE_NEWS = """\
+PostGIS 4.0.0rc1
+2026/xx/xx
+
+* Bug Fixes *
+
+ - Existing unreleased fix
+
+PostGIS 3.9.0
+2020/01/01
+
+* Bug Fixes *
+
+ - Existing released fix
+          with historical detail
+ - Another released fix
+"""
+
+
+class NewsFixture:
+    def __init__(self):
+        self.temporary_directory = tempfile.TemporaryDirectory()
+        self.repo = Path(self.temporary_directory.name)
+        self.git("init", "--initial-branch=main")
+        self.git("config", "user.name", "Fixture Committer")
+        self.git("config", "user.email", "committer at example.com")
+        self.write_news(BASE_NEWS)
+        self.git("add", "NEWS")
+        self.git("commit", "-m", "base NEWS")
+        self.base_commit = self.git("rev-parse", "HEAD").stdout.strip()
+
+    def close(self):
+        self.temporary_directory.cleanup()
+
+    def git(self, *args):
+        return subprocess.run(
+            ["git", *args],
+            cwd=self.repo,
+            check=True,
+            text=True,
+            capture_output=True,
+        )
+
+    def write_news(self, news):
+        (self.repo / "NEWS").write_text(
+            textwrap.dedent(news),
+            encoding="utf-8",
+        )
+
+    def check_news(self, base_ref=None, environment_base_ref=None):
+        command = [str(CHECK_NEWS)]
+        if base_ref is not None:
+            command.append(f"--base-ref={base_ref}")
+        command.append(str(self.repo))
+        environment = os.environ.copy()
+        environment.pop("NEWS_CHECK_BASE_REF", None)
+        if environment_base_ref is not None:
+            environment["NEWS_CHECK_BASE_REF"] = environment_base_ref
+        return subprocess.run(
+            command,
+            env=environment,
+            text=True,
+            capture_output=True,
+        )
+
+
+class NewsValidationTest(unittest.TestCase):
+    def setUp(self):
+        self.fixture = NewsFixture()
+
+    def tearDown(self):
+        self.fixture.close()
+
+    def test_new_entry_in_current_unreleased_section_passes(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing unreleased fix",
+                " - New unreleased fix\n - Existing unreleased fix",
+            )
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+
+    def test_new_entry_in_released_section_fails(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing released fix",
+                " - New misplaced fix\n - Existing released fix",
+            )
+        )
+        result = self.fixture.check_news(
+            environment_base_ref=self.fixture.base_commit
+        )
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn(
+            "PostGIS 3.9.0 has a new NEWS entry",
+            result.stdout,
+        )
+
+    def test_new_two_space_entry_in_released_section_fails(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing released fix",
+                "  - New misplaced legacy-format fix\n"
+                " - Existing released fix",
+            )
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("New misplaced legacy-format fix", result.stdout)
+
+    def test_rewording_released_entry_heading_fails(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing released fix",
+                " - Corrected wording for released fix",
+            )
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("has a new NEWS entry", result.stdout)
+
+    def test_rewording_released_entry_detail_passes(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                "          with historical detail",
+                "          with corrected historical detail",
+            )
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+
+    def test_new_released_entry_cannot_hide_behind_deleted_entry(self):
+        news = BASE_NEWS.replace(
+            " - Existing released fix",
+            " - New misplaced fix\n - Existing released fix",
+        ).replace("\n - Another released fix", "")
+        self.fixture.write_news(news)
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("New misplaced fix", result.stdout)
+
+    def test_duplicate_released_entry_fails(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing released fix",
+                " - Existing released fix\n - Existing released fix",
+            )
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("Existing released fix", result.stdout)
+
+    def test_release_promotion_can_finish_previous_unreleased_section(self):
+        self.fixture.write_news(
+            """\
+            PostGIS 4.1.0dev
+            2026/xx/xx
+
+            * Bug Fixes *
+
+            PostGIS 4.0.0rc1
+            2020/02/01
+
+            * Bug Fixes *
+
+             - Final fix added while preparing the release
+             - Existing unreleased fix
+
+            PostGIS 3.9.0
+            2020/01/01
+
+            * Bug Fixes *
+
+             - Existing released fix
+            """
+        )
+        result = self.fixture.check_news(self.fixture.base_commit)
+        self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+
+    def test_new_unreleased_section_can_follow_released_target(self):
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                "PostGIS 4.0.0rc1\n2026/xx/xx",
+                "PostGIS 4.0.0rc1\n2020/02/01",
+            )
+        )
+        self.fixture.git("add", "NEWS")
+        self.fixture.git("commit", "-m", "release current section")
+        target_commit = self.fixture.git("rev-parse", "HEAD").stdout.strip()
+        self.fixture.write_news(
+            """\
+            PostGIS 4.1.0dev
+            2026/xx/xx
+
+            * Bug Fixes *
+
+             - First fix after the release
+
+            """
+            + (self.fixture.repo / "NEWS").read_text(encoding="utf-8")
+        )
+
+        result = self.fixture.check_news(target_commit)
+        self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+
+    def test_stale_branch_cannot_add_to_section_closed_on_target(self):
+        self.fixture.git("checkout", "-b", "feature")
+        self.fixture.git("checkout", "-b", "target", self.fixture.base_commit)
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                "PostGIS 4.0.0rc1\n2026/xx/xx",
+                "PostGIS 4.1.0dev\n2026/xx/xx\n\n"
+                "* Bug Fixes *\n\n"
+                "PostGIS 4.0.0rc1\n2026/07/25",
+            )
+        )
+        self.fixture.git("add", "NEWS")
+        self.fixture.git("commit", "-m", "close release")
+        target_commit = self.fixture.git("rev-parse", "HEAD").stdout.strip()
+        self.fixture.git("checkout", "feature")
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing unreleased fix",
+                " - Late stale-branch fix\n - Existing unreleased fix",
+            )
+        )
+
+        result = self.fixture.check_news(target_commit)
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("Late stale-branch fix", result.stdout)
+
+    def test_shallow_feature_checkout_uses_target_tip_directly(self):
+        self.fixture.git("checkout", "-b", "feature")
+        self.fixture.write_news(
+            BASE_NEWS.replace(
+                " - Existing unreleased fix",
+                " - New feature fix\n - Existing unreleased fix",
+            )
+        )
+        self.fixture.git("add", "NEWS")
+        self.fixture.git("commit", "-m", "feature NEWS")
+
+        with tempfile.TemporaryDirectory() as clone_directory:
+            clone = Path(clone_directory)
+            subprocess.run(
+                [
+                    "git",
+                    "clone",
+                    "--depth=1",
+                    "--branch=feature",
+                    f"file://{self.fixture.repo}",
+                    str(clone),
+                ],
+                check=True,
+                text=True,
+                capture_output=True,
+            )
+            subprocess.run(
+                [
+                    "git",
+                    "fetch",
+                    "origin",
+                    "refs/heads/main:refs/news-check/target",
+                ],
+                cwd=clone,
+                check=True,
+                text=True,
+                capture_output=True,
+            )
+            self.assertEqual(
+                "true",
+                subprocess.run(
+                    ["git", "rev-parse", "--is-shallow-repository"],
+                    cwd=clone,
+                    check=True,
+                    text=True,
+                    capture_output=True,
+                ).stdout.strip(),
+            )
+            result = subprocess.run(
+                [
+                    str(CHECK_NEWS),
+                    "--base-ref=refs/news-check/target",
+                    str(clone),
+                ],
+                text=True,
+                capture_output=True,
+            )
+
+        self.assertEqual(0, result.returncode, result.stdout + result.stderr)
+
+    def test_missing_explicit_base_ref_fails(self):
+        result = self.fixture.check_news("refs/heads/missing")
+        self.assertNotEqual(0, result.returncode)
+        self.assertIn("does not resolve to a commit", result.stdout)
+
+
+if __name__ == "__main__":
+    unittest.main()

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

Summary of changes:
 GNUmakefile.in             |   5 +
 ci/dronie/postgis_tools.sh |  11 ++
 utils/check_news.sh        |  91 ++++++++++++-
 utils/test_check_news.py   | 312 +++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 418 insertions(+), 1 deletion(-)
 create mode 100644 utils/test_check_news.py


hooks/post-receive
-- 
PostGIS


More information about the postgis-tickets mailing list