[SCM] PostGIS branch master updated. 3.7.0beta1-23-ge44cfd9ac

git at osgeo.org git at osgeo.org
Sat Jul 25 06:03:15 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  e44cfd9ac84eb4df25b6ca0faf155cbae4a11682 (commit)
       via  9636cd98eb49bfab245ffc5e077de6af7ec99fd2 (commit)
      from  a9c631ff91d5ed2f1bd94d4f978674a62d657185 (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 e44cfd9ac84eb4df25b6ca0faf155cbae4a11682
Merge: a9c631ff9 9636cd98e
Author: Darafei Praliaskouski <komzpa at gmail.com>
Date:   Sat Jul 25 06:03:13 2026 -0700

    Merge pull request 'Cache successful CI checks at unchanged branch heads' (!507) from Komzpa/postgis:ci/current-success-cache-20260725 into master
    
    Reviewed-on: https://gitea.osgeo.org/postgis/postgis/pulls/507


commit 9636cd98eb49bfab245ffc5e077de6af7ec99fd2
Author: Darafei Praliaskouski <me at komzpa.net>
Date:   Sat Jul 25 16:46:35 2026 +0400

    ci: cache successful checks at current branch head

diff --git a/utils/ci-status.json b/utils/ci-status.json
index 9f22524b2..7d7f45853 100644
--- a/utils/ci-status.json
+++ b/utils/ci-status.json
@@ -1,5 +1,6 @@
 {
   "stale_after_hours": 168,
+  "cache_head_remote": "https://gitea.osgeo.org/postgis/postgis.git",
   "branches": [
     { "name": "master", "label": "master", "eol": false, "version": "3.7" },
     { "name": "stable-3.6", "label": "3.6", "eol": false, "version": "3.6" },
diff --git a/utils/ci-status.py b/utils/ci-status.py
index 7b12f6ddb..7d8f3f3cd 100755
--- a/utils/ci-status.py
+++ b/utils/ci-status.py
@@ -1119,6 +1119,113 @@ def result_from_exception(check, branch, exc):
     return make_result(check, branch, UNKNOWN, message=str(exc), debug_url=debug_url)
 
 
+def index_status_cache(data):
+    if data is None:
+        return None
+    if not isinstance(data, dict) or not isinstance(data.get("branches"), list):
+        raise ConfigError("status cache must contain a branches array")
+    checks = {}
+    for branch in data["branches"]:
+        if not isinstance(branch, dict):
+            continue
+        branch_name = branch.get("name")
+        for result in branch.get("checks") or []:
+            if not isinstance(result, dict):
+                continue
+            check_name = result.get("check")
+            if branch_name and check_name:
+                checks[(branch_name, check_name)] = result
+    return {
+        "generated_at": data.get("generated_at"),
+        "checks": checks,
+    }
+
+
+def load_status_cache(path):
+    if not path:
+        return None
+    try:
+        with open(path, "r", encoding="utf-8") as handle:
+            return index_status_cache(json.load(handle))
+    except FileNotFoundError:
+        return None
+    except OSError as exc:
+        raise ConfigError(f"cannot read status cache {path}: {exc}") from exc
+    except json.JSONDecodeError as exc:
+        raise ConfigError(f"invalid JSON in status cache {path}: {exc}") from exc
+
+
+def resolve_cache_heads(config, work, cache, timeout):
+    if not cache:
+        return {}
+    remote = config.get("cache_head_remote")
+    if not remote:
+        return {}
+    branch_names = sorted({
+        branch["name"]
+        for branch, _check in work
+        if any(
+            cached.get("status") == SUCCESS
+            for (cached_branch, _cached_check), cached in cache["checks"].items()
+            if cached_branch == branch["name"]
+        )
+    })
+    if not branch_names:
+        return {}
+    refs = [f"refs/heads/{name}" for name in branch_names]
+    try:
+        completed = subprocess.run(
+            ["git", "ls-remote", "--exit-code", "--heads", remote, *refs],
+            check=True,
+            capture_output=True,
+            text=True,
+            timeout=timeout,
+        )
+    except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
+        return {}
+
+    heads = {}
+    wanted = set(refs)
+    for line in completed.stdout.splitlines():
+        fields = line.split()
+        if len(fields) != 2 or fields[1] not in wanted:
+            continue
+        heads[fields[1].removeprefix("refs/heads/")] = fields[0]
+    return heads
+
+
+def cached_success_result(branch, check, cache, cache_heads):
+    if not cache:
+        return None
+    cached = cache["checks"].get((branch["name"], check["name"]))
+    if not cached or cached.get("status") != SUCCESS:
+        return None
+    if cached.get("branch") != branch["name"] or cached.get("check") != check["name"]:
+        return None
+    if cached.get("provider") != check.get("provider"):
+        return None
+    if cached.get("required") != bool(check.get("required", True)):
+        return None
+    revision = cached.get("revision")
+    if not revision or revision != cache_heads.get(branch["name"]):
+        return None
+
+    result = dict(cached)
+    result.update({
+        "branch": branch["name"],
+        "branch_label": branch["label"],
+        "check": check["name"],
+        "provider": check.get("provider"),
+        "required": bool(check.get("required", True)),
+        "cached": True,
+        "cached_at": cache.get("generated_at"),
+    })
+    message = result.get("message") or "successful run"
+    suffix = " (cached; unchanged revision)"
+    result["message"] = message if message.endswith(suffix) else f"{message}{suffix}"
+    return result
+
+
 def stale_after_hours(config, check):
     value = check.get("stale_after_hours", config.get("stale_after_hours"))
     if value is None:
@@ -1191,12 +1298,16 @@ def default_concurrency():
     return min(32, max(1, os.cpu_count() or 1))
 
 
-async def collect_status_async(config, selected_branch=None, include_eol=False, timeout=30):
+async def collect_status_async(config, selected_branch=None, include_eol=False, timeout=30, cache=None):
     work = list(branch_checks(config, selected_branch, include_eol))
+    cache_heads = await asyncio.to_thread(resolve_cache_heads, config, work, cache, timeout)
     semaphore = asyncio.Semaphore(min(default_concurrency(), max(1, len(work))))
 
     def collect_one(item):
         branch, check = item
+        cached = cached_success_result(branch, check, cache, cache_heads)
+        if cached:
+            return apply_staleness(cached, config, check)
         provider = PROVIDERS.get(check.get("provider"))
         if provider is None:
             raise ConfigError(f"unsupported provider for {check['name']}: {check.get('provider')}")
@@ -1213,8 +1324,8 @@ async def collect_status_async(config, selected_branch=None, include_eol=False,
     return aggregate(config, results)
 
 
-def collect_status(config, selected_branch=None, include_eol=False, timeout=30):
-    return asyncio.run(collect_status_async(config, selected_branch, include_eol, timeout))
+def collect_status(config, selected_branch=None, include_eol=False, timeout=30, cache=None):
+    return asyncio.run(collect_status_async(config, selected_branch, include_eol, timeout, cache))
 
 
 def aggregate(config, results):
@@ -2290,6 +2401,10 @@ def parse_args(argv):
     parser.add_argument("--config", default=str(pathlib.Path(__file__).with_suffix(".json")))
     parser.add_argument("--format", choices=("terminal", "json", "html"), default="terminal", help="output format")
     parser.add_argument("--output-dir", default="ci-status")
+    parser.add_argument(
+        "--cache",
+        help="reuse successful results from this status.json when their revision is still the branch head",
+    )
     parser.add_argument(
         "--atomic-switch",
         action="store_true",
@@ -2316,7 +2431,8 @@ def main(argv=None):
     args = parse_args(sys.argv[1:] if argv is None else argv)
     try:
         config = load_config(args.config)
-        data = collect_status(config, args.branch, args.include_eol, args.timeout)
+        cache = load_status_cache(args.cache)
+        data = collect_status(config, args.branch, args.include_eol, args.timeout, cache)
         if args.format == "html":
             write_html_output(data, args.output_dir, atomic_switch=args.atomic_switch)
             return 0
diff --git a/utils/test_ci_status.py b/utils/test_ci_status.py
index 0953686a8..877fbc0ae 100644
--- a/utils/test_ci_status.py
+++ b/utils/test_ci_status.py
@@ -1,6 +1,7 @@
 import importlib.util
 import json
 import pathlib
+import subprocess
 import tempfile
 import unittest
 from unittest import mock
@@ -43,6 +44,232 @@ def html_data():
 
 
 class RequiredFailureHtmlTest(unittest.TestCase):
+    def test_current_success_cache_skips_provider(self):
+        config = {
+            "branches": [{"name": "master", "label": "master"}],
+            "checks": [{
+                "name": "Synthetic CI",
+                "provider": "synthetic",
+                "required": True,
+            }],
+        }
+        cached_result = {
+            "branch": "master",
+            "branch_label": "master",
+            "check": "Synthetic CI",
+            "provider": "synthetic",
+            "required": True,
+            "status": CI_STATUS.SUCCESS,
+            "revision": "a" * 40,
+            "message": "build 7",
+        }
+        cache = CI_STATUS.index_status_cache({
+            "generated_at": "2026-07-25T12:00:00+00:00",
+            "branches": [{
+                "name": "master",
+                "checks": [cached_result],
+            }],
+        })
+        provider = mock.Mock()
+
+        with (
+            mock.patch.dict(CI_STATUS.PROVIDERS, {"synthetic": provider}),
+            mock.patch.object(
+                CI_STATUS,
+                "resolve_cache_heads",
+                return_value={"master": "a" * 40},
+            ),
+        ):
+            data = CI_STATUS.collect_status(config, cache=cache)
+
+        provider.assert_not_called()
+        result = data["branches"][0]["checks"][0]
+        self.assertEqual(CI_STATUS.SUCCESS, result["status"])
+        self.assertTrue(result["cached"])
+        self.assertEqual("2026-07-25T12:00:00+00:00", result["cached_at"])
+        self.assertEqual("build 7 (cached; unchanged revision)", result["message"])
+
+    def test_cache_does_not_hide_new_revision_result(self):
+        config = {
+            "branches": [{"name": "master", "label": "master"}],
+            "checks": [{
+                "name": "Synthetic CI",
+                "provider": "synthetic",
+                "required": True,
+            }],
+        }
+        cache = CI_STATUS.index_status_cache({
+            "generated_at": "2026-07-25T12:00:00+00:00",
+            "branches": [{
+                "name": "master",
+                "checks": [{
+                    "branch": "master",
+                    "branch_label": "master",
+                    "check": "Synthetic CI",
+                    "provider": "synthetic",
+                    "required": True,
+                    "status": CI_STATUS.SUCCESS,
+                    "revision": "a" * 40,
+                }],
+            }],
+        })
+        live_result = check("Synthetic CI", CI_STATUS.FAILURE)
+        live_result.update({"branch": "master", "branch_label": "master"})
+        provider = mock.Mock(return_value=live_result)
+
+        with (
+            mock.patch.dict(CI_STATUS.PROVIDERS, {"synthetic": provider}),
+            mock.patch.object(
+                CI_STATUS,
+                "resolve_cache_heads",
+                return_value={"master": "b" * 40},
+            ),
+        ):
+            data = CI_STATUS.collect_status(config, cache=cache)
+
+        provider.assert_called_once()
+        self.assertEqual(CI_STATUS.FAILURE, data["branches"][0]["checks"][0]["status"])
+
+    def test_cache_rejects_non_success_revisionless_and_changed_provider_results(self):
+        config = {
+            "branches": [{"name": "master", "label": "master"}],
+            "checks": [{
+                "name": "Synthetic CI",
+                "provider": "synthetic",
+                "required": True,
+            }],
+        }
+        branch = config["branches"][0]
+        check_config = config["checks"][0]
+        base = {
+            "branch": "master",
+            "branch_label": "master",
+            "check": "Synthetic CI",
+            "provider": "synthetic",
+            "required": True,
+            "status": CI_STATUS.SUCCESS,
+            "revision": "a" * 40,
+        }
+        rejected = [
+            {**base, "status": CI_STATUS.FAILURE},
+            {**base, "status": CI_STATUS.IN_PROGRESS},
+            {**base, "status": CI_STATUS.UNKNOWN},
+            {key: value for key, value in base.items() if key != "revision"},
+            {**base, "branch": "stable-3.6"},
+            {**base, "check": "Other CI"},
+            {**base, "provider": "other"},
+            {**base, "required": False},
+        ]
+
+        for cached in rejected:
+            with self.subTest(cached=cached):
+                cache = CI_STATUS.index_status_cache({
+                    "branches": [{"name": "master", "checks": [cached]}],
+                })
+                self.assertIsNone(
+                    CI_STATUS.cached_success_result(
+                        branch,
+                        check_config,
+                        cache,
+                        {"master": "a" * 40},
+                    )
+                )
+
+    def test_cache_uses_fresh_remote_head_not_stale_local_ref(self):
+        config = {
+            "branches": [{"name": "master", "label": "master"}],
+            "checks": [{
+                "name": "Synthetic CI",
+                "provider": "synthetic",
+                "required": True,
+            }],
+        }
+        cache = CI_STATUS.index_status_cache({
+            "branches": [{
+                "name": "master",
+                "checks": [{
+                    "branch": "master",
+                    "branch_label": "master",
+                    "check": "Synthetic CI",
+                    "provider": "synthetic",
+                    "required": True,
+                    "status": CI_STATUS.SUCCESS,
+                    "revision": "a" * 40,
+                }],
+            }],
+        })
+        live_result = check("Synthetic CI", CI_STATUS.FAILURE)
+        live_result.update({"branch": "master", "branch_label": "master"})
+        provider = mock.Mock(return_value=live_result)
+
+        with (
+            mock.patch.dict(CI_STATUS.PROVIDERS, {"synthetic": provider}),
+            mock.patch.object(CI_STATUS, "git_commit_distance", return_value=0),
+            mock.patch.object(
+                CI_STATUS,
+                "resolve_cache_heads",
+                return_value={"master": "b" * 40},
+            ),
+        ):
+            data = CI_STATUS.collect_status(config, cache=cache)
+
+        provider.assert_called_once()
+        self.assertEqual(CI_STATUS.FAILURE, data["branches"][0]["checks"][0]["status"])
+
+    def test_cache_head_lookup_queries_remote_once_for_all_branches(self):
+        config = {"cache_head_remote": "https://example.test/postgis.git"}
+        branches = [
+            {"name": "master", "label": "master"},
+            {"name": "stable-3.6", "label": "3.6"},
+        ]
+        work = [(branch, {"name": "Synthetic CI"}) for branch in branches]
+        cache = CI_STATUS.index_status_cache({
+            "branches": [
+                {
+                    "name": branch["name"],
+                    "checks": [{
+                        "check": "Synthetic CI",
+                        "status": CI_STATUS.SUCCESS,
+                    }],
+                }
+                for branch in branches
+            ],
+        })
+        completed = subprocess.CompletedProcess(
+            args=[],
+            returncode=0,
+            stdout=(
+                f"{'a' * 40}\trefs/heads/master\n"
+                f"{'b' * 40}\trefs/heads/stable-3.6\n"
+            ),
+            stderr="",
+        )
+
+        with mock.patch.object(CI_STATUS.subprocess, "run", return_value=completed) as run:
+            heads = CI_STATUS.resolve_cache_heads(config, work, cache, timeout=7)
+
+        self.assertEqual({"master": "a" * 40, "stable-3.6": "b" * 40}, heads)
+        run.assert_called_once_with(
+            [
+                "git",
+                "ls-remote",
+                "--exit-code",
+                "--heads",
+                "https://example.test/postgis.git",
+                "refs/heads/master",
+                "refs/heads/stable-3.6",
+            ],
+            check=True,
+            capture_output=True,
+            text=True,
+            timeout=7,
+        )
+
+    def test_missing_optional_status_cache_starts_empty(self):
+        with tempfile.TemporaryDirectory() as tmpdir:
+            missing = pathlib.Path(tmpdir) / "status.json"
+            self.assertIsNone(CI_STATUS.load_status_cache(missing))
+
     def test_woodpecker_covers_supported_release_branches(self):
         config = json.loads(MODULE_PATH.with_suffix(".json").read_text(encoding="utf-8"))
         woodpecker = next(check for check in config["checks"] if check["name"] == "Woodpecker")

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

Summary of changes:
 utils/ci-status.json    |   1 +
 utils/ci-status.py      | 124 +++++++++++++++++++++++++-
 utils/test_ci_status.py | 227 ++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 348 insertions(+), 4 deletions(-)


hooks/post-receive
-- 
PostGIS


More information about the postgis-tickets mailing list