[SCM] PostGIS branch master updated. 3.7.0beta1-18-gf6b40a316
git at osgeo.org
git at osgeo.org
Fri Jul 24 09:05:50 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 f6b40a31677b233a80e7543086b8267089c11f62 (commit)
via db2f494af5ce8dbc4f3b474cfe60c19bb0ec7397 (commit)
from 1ba934e1f8a48188d39f88757379885e4ee3ebc5 (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 f6b40a31677b233a80e7543086b8267089c11f62
Merge: 1ba934e1f db2f494af
Author: Darafei Praliaskouski <komzpa at gmail.com>
Date: Fri Jul 24 09:05:49 2026 -0700
Merge pull request 'ci: harden status page publishing and refresh' (!503) from Komzpa/postgis:ci/atomic-status-publish-20260724 into master
Reviewed-on: https://gitea.osgeo.org/postgis/postgis/pulls/503
commit db2f494af5ce8dbc4f3b474cfe60c19bb0ec7397
Author: Darafei Praliaskouski <me at komzpa.net>
Date: Fri Jul 24 19:16:53 2026 +0400
ci: harden status page publishing and refresh
diff --git a/utils/README b/utils/README
index 9bf58d10d..b2a1f876b 100644
--- a/utils/README
+++ b/utils/README
@@ -39,10 +39,17 @@ ci-status.py
python3 utils/ci-status.py --format html --output-dir /var/www/postgis/ci
+ For no-404 live publishing, make the live path a symbolic link to a release
+ directory and switch it with:
+
+ python3 utils/ci-status.py --format html --atomic-switch --output-dir /var/www/postgis/ci
+
The script writes index.html and status.json atomically, so a publishing
- job can run periodically without leaving a half-written page behind. A
- red CI status is valid page content and does not make HTML generation
- fail.
+ job can run periodically without leaving a half-written page behind.
+ `--atomic-switch` writes a complete sibling directory first and then
+ atomically replaces the output symlink, so the web server never observes the
+ live CI path as missing during publication. A red CI status is valid page
+ content and does not make HTML generation fail.
Add new CI by adding a check object to utils/ci-status.json. Retire old
CI by changing the check provider to `disabled`, setting `required` to
diff --git a/utils/ci-status.py b/utils/ci-status.py
index c06c99da8..7b12f6ddb 100755
--- a/utils/ci-status.py
+++ b/utils/ci-status.py
@@ -11,6 +11,7 @@ import http.client
import json
import os
import pathlib
+import shutil
import subprocess
import sys
import tempfile
@@ -1549,6 +1550,45 @@ def write_atomic(path, content, mode="w"):
raise
+def relative_symlink_target(target, link_path):
+ return os.path.relpath(target, start=link_path.parent)
+
+
+def write_html_files(data, output_dir):
+ output_dir = pathlib.Path(output_dir)
+ json_text = json.dumps(data, indent=2, sort_keys=True) + "\n"
+ write_atomic(output_dir / "status.json", json_text)
+ write_atomic(output_dir / "index.html", render_html(data))
+
+
+def switch_html_output(data, output_dir):
+ output_dir = pathlib.Path(output_dir)
+ output_dir.parent.mkdir(parents=True, exist_ok=True)
+ staging = pathlib.Path(tempfile.mkdtemp(prefix=f".{output_dir.name}.", dir=str(output_dir.parent)))
+ link_tmp = None
+ try:
+ write_html_files(data, staging)
+ has_output = output_dir.exists() or output_dir.is_symlink()
+ if has_output and not output_dir.is_symlink():
+ raise ConfigError(f"{output_dir} must be absent or a symbolic link for --atomic-switch")
+ if has_output:
+ link_fd, link_name = tempfile.mkstemp(prefix=f".{output_dir.name}.link.", dir=str(output_dir.parent))
+ os.close(link_fd)
+ os.unlink(link_name)
+ link_tmp = pathlib.Path(link_name)
+ os.symlink(relative_symlink_target(staging, output_dir), link_tmp)
+ os.replace(link_tmp, output_dir)
+ else:
+ os.replace(staging, output_dir)
+ staging = None
+ except Exception:
+ if link_tmp and (link_tmp.exists() or link_tmp.is_symlink()):
+ link_tmp.unlink()
+ if staging and staging.exists():
+ shutil.rmtree(staging)
+ raise
+
+
def overall_status(branches):
statuses = [branch["status"] for branch in branches]
if any(status == FAILURE for status in statuses):
@@ -1778,6 +1818,32 @@ def html_required_failures(branches):
)
+def html_auto_refresh_script():
+ return """
+<script>
+(() => {
+ const page = document.querySelector("[data-generated-at]");
+ const generatedAt = page ? page.dataset.generatedAt : "";
+ const refresh = async () => {
+ try {
+ const url = new URL("status.json", window.location.href);
+ url.searchParams.set("_", Date.now().toString());
+ const response = await fetch(url, { cache: "no-store" });
+ if (!response.ok) return;
+ const data = await response.json();
+ if (data && data.generated_at && data.generated_at !== generatedAt) {
+ window.location.reload();
+ }
+ } catch (error) {
+ // Local file previews and transient publish windows can make fetch fail.
+ }
+ };
+ window.setInterval(refresh, 60000);
+})();
+</script>
+"""
+
+
def render_html(data):
generated = html.escape(data["generated_at"])
page_status = overall_status(data["branches"])
@@ -2158,7 +2224,7 @@ a:hover {{ color: var(--brand-strong); }}
</style>
</head>
<body>
-<main class="page">
+<main class="page" data-generated-at="{generated}">
<header class="masthead">
<div>
<p class="brand">PostGIS</p>
@@ -2181,16 +2247,17 @@ a:hover {{ color: var(--brand-strong); }}
</section>
{''.join(details)}
</main>
+{html_auto_refresh_script()}
</body>
</html>
"""
-def write_html_output(data, output_dir):
- output_dir = pathlib.Path(output_dir)
- json_text = json.dumps(data, indent=2, sort_keys=True) + "\n"
- write_atomic(output_dir / "status.json", json_text)
- write_atomic(output_dir / "index.html", render_html(data))
+def write_html_output(data, output_dir, atomic_switch=False):
+ if atomic_switch:
+ switch_html_output(data, output_dir)
+ else:
+ write_html_files(data, output_dir)
def load_config(path):
@@ -2223,6 +2290,11 @@ 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(
+ "--atomic-switch",
+ action="store_true",
+ help="write HTML output to a complete staging directory, then atomically switch the output symlink",
+ )
parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--no-color", action="store_true")
parser.add_argument("--verbose", action="store_true", help="show all checks, including passing checks")
@@ -2246,7 +2318,7 @@ def main(argv=None):
config = load_config(args.config)
data = collect_status(config, args.branch, args.include_eol, args.timeout)
if args.format == "html":
- write_html_output(data, args.output_dir)
+ write_html_output(data, args.output_dir, atomic_switch=args.atomic_switch)
return 0
if args.format == "json":
print(json.dumps(data, indent=2, sort_keys=True))
diff --git a/utils/test_ci_status.py b/utils/test_ci_status.py
index e27147347..0953686a8 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 tempfile
import unittest
from unittest import mock
@@ -25,6 +26,22 @@ def check(name, status, *, required=True, url=None):
return result
+def html_data():
+ return {
+ "generated_at": "2026-07-24T15:20:00+00:00",
+ "branches": [
+ {
+ "name": "master",
+ "label": "master",
+ "status": CI_STATUS.SUCCESS,
+ "checks": [
+ check("Synthetic CI", CI_STATUS.SUCCESS),
+ ],
+ },
+ ],
+ }
+
+
class RequiredFailureHtmlTest(unittest.TestCase):
def test_woodpecker_covers_supported_release_branches(self):
config = json.loads(MODULE_PATH.with_suffix(".json").read_text(encoding="utf-8"))
@@ -79,6 +96,46 @@ class RequiredFailureHtmlTest(unittest.TestCase):
self.assertEqual(CI_STATUS.SUCCESS, passed["stale_base_status"])
self.assertEqual("Stale passed", passed["status_label"])
+ def test_write_html_output_can_atomically_switch_symlink(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ root = pathlib.Path(tmpdir)
+ old_release = root / "ci-old"
+ old_release.mkdir()
+ (old_release / "index.html").write_text("old page", encoding="utf-8")
+ live = root / "ci"
+ live.symlink_to(old_release.name)
+
+ CI_STATUS.write_html_output(html_data(), live, atomic_switch=True)
+
+ self.assertTrue(live.is_symlink())
+ self.assertTrue(old_release.exists())
+ self.assertEqual("old page", (old_release / "index.html").read_text(encoding="utf-8"))
+ self.assertIn("CI status", (live / "index.html").read_text(encoding="utf-8"))
+ status = json.loads((live / "status.json").read_text(encoding="utf-8"))
+ self.assertEqual("2026-07-24T15:20:00+00:00", status["generated_at"])
+
+ def test_write_html_output_atomic_switch_rejects_real_directory(self):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ live = pathlib.Path(tmpdir) / "ci"
+ live.mkdir()
+ (live / "index.html").write_text("old page", encoding="utf-8")
+
+ with self.assertRaisesRegex(CI_STATUS.ConfigError, "symbolic link"):
+ CI_STATUS.write_html_output(html_data(), live, atomic_switch=True)
+
+ self.assertEqual("old page", (live / "index.html").read_text(encoding="utf-8"))
+
+ def test_rendered_html_refreshes_status_json_without_breaking_on_errors(self):
+ rendered = CI_STATUS.render_html(html_data())
+
+ self.assertIn('data-generated-at="2026-07-24T15:20:00+00:00"', rendered)
+ self.assertIn('new URL("status.json", window.location.href)', rendered)
+ self.assertIn('fetch(url, { cache: "no-store" })', rendered)
+ self.assertIn("if (!response.ok) return;", rendered)
+ self.assertIn("window.location.reload();", rendered)
+ self.assertIn("catch (error)", rendered)
+ self.assertIn("Local file previews", rendered)
+
def test_jenkins_matrix_failure_names_failing_axis(self):
check_config = {
"name": "Jenkins / Winnie",
-----------------------------------------------------------------------
Summary of changes:
utils/README | 13 ++++++--
utils/ci-status.py | 86 +++++++++++++++++++++++++++++++++++++++++++++----
utils/test_ci_status.py | 57 ++++++++++++++++++++++++++++++++
3 files changed, 146 insertions(+), 10 deletions(-)
hooks/post-receive
--
PostGIS
More information about the postgis-tickets
mailing list