[SCM] PostGIS branch master updated. 3.6.0rc2-674-g09f06cab2

git at osgeo.org git at osgeo.org
Sat Jun 27 11:38:23 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  09f06cab2e12cc71c84e082588d903ef47f81e46 (commit)
      from  423a5fd79c2e4d2a1fd88911ffbfb9f906d4a6a8 (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 09f06cab2e12cc71c84e082588d903ef47f81e46
Author: Darafei Praliaskouski <me at komzpa.net>
Date:   Fri Jun 26 10:05:52 2026 +0400

    Add CI status dashboard generator
    
    Add a repository-owned CI inventory and Python status checker for GitHub Actions, Woodpecker, and Jenkins. The helper prints compact terminal output by default and can generate static HTML and JSON output for publishing.
    
    Closes #6087
    
    Closes https://github.com/postgis/postgis/pull/1122

diff --git a/NEWS b/NEWS
index e3d1d2fa9..5ff5a916a 100644
--- a/NEWS
+++ b/NEWS
@@ -85,6 +85,8 @@ To take advantage of all postgis_sfcgal extension features SFCGAL 2.3+ is needed
           GeometryCollections (Darafei Praliaskouski)
  - #5532, Validate the manual against DocBook XMLSchema in check-xml
           (Darafei Praliaskouski)
+ - #6087, Add a repository-owned CI status checker and static dashboard
+          generator (Darafei Praliaskouski)
 
 * Bug Fixes *
 
diff --git a/utils/README b/utils/README
index c8b4cc165..9bf58d10d 100644
--- a/utils/README
+++ b/utils/README
@@ -12,3 +12,39 @@ create_upgrade.pl
 
 profile_intersects.pl
 	compares distance()=0 and intersects() timings.
+
+ci-status.py
+	Query repository-owned CI inventory from utils/ci-status.json and
+	print a compact status report.  It queries GitHub Actions, GitLab,
+	Woodpecker, and Jenkins JSON APIs directly.  The default command checks
+	the configured non-EOL branches:
+
+		python3 utils/ci-status.py
+
+	Use `--branch stable-3.6` to inspect one branch, `--format json` for
+	machine-readable output, `--format html` for static dashboard output,
+	`--verbose` when a passing-check matrix is useful, and `--include-eol` to
+	include configured EOL branches.  The default colored report summarizes
+	passing, failing, running, and unknown checks in a compact branch table,
+	but only expands non-OK details.  Use `--no-color` for plain logs.  HTTP
+	provider checks run concurrently by default through asyncio.
+	GitHub API access works without a token for light local use; frequent cron
+	jobs may set `GITHUB_TOKEN` or `GH_TOKEN` for higher rate limits.  If the
+	GitHub API is unavailable or rate-limited, GitHub workflow checks fall
+	back to the public SVG badge status for a fresher unauthenticated answer.
+	Problem-check detail headings include the provider link, using terminal
+	hyperlinks when available and plain URLs otherwise.
+
+	Generate the static dashboard for publishing with:
+
+		python3 utils/ci-status.py --format html --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.
+
+	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
+	false, and recording the reason in `message`.  Mark unsupported release
+	branches with `"eol": true` so they stay out of the default report.
diff --git a/utils/ci-status.json b/utils/ci-status.json
new file mode 100644
index 000000000..3ea413794
--- /dev/null
+++ b/utils/ci-status.json
@@ -0,0 +1,197 @@
+{
+  "stale_after_hours": 168,
+  "branches": [
+    { "name": "master", "label": "master", "eol": false, "version": "3.7" },
+    { "name": "stable-3.6", "label": "3.6", "eol": false, "version": "3.6" },
+    { "name": "stable-3.5", "label": "3.5", "eol": false, "version": "3.5" },
+    { "name": "stable-3.4", "label": "3.4", "eol": false, "version": "3.4" },
+    { "name": "stable-3.3", "label": "3.3", "eol": false, "version": "3.3" },
+    { "name": "stable-3.2", "label": "3.2", "eol": false, "version": "3.2" },
+    { "name": "stable-3.1", "label": "3.1", "eol": true, "version": "3.1" },
+    { "name": "stable-3.0", "label": "3.0", "eol": true, "version": "3.0" }
+  ],
+  "checks": [
+    {
+      "name": "GitHub Actions / Linux",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "ci.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/ci.yml"
+    },
+    {
+      "name": "GitHub Actions / FreeBSD",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "ci-freebsd.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/ci-freebsd.yml",
+      "branches": [
+        "master"
+      ]
+    },
+    {
+      "name": "GitHub Actions / FreeBSD (stable badge retired)",
+      "provider": "disabled",
+      "required": false,
+      "status": "disabled",
+      "url": "https://github.com/postgis/postgis/actions/workflows/ci-freebsd.yml?query=branch%3A${branch}",
+      "message": "The Trac badge inventory lists stable-branch FreeBSD badges, but ci-freebsd.yml is not present on supported stable branches.",
+      "branches": [
+        "stable-3.6",
+        "stable-3.5",
+        "stable-3.4"
+      ]
+    },
+    {
+      "name": "GitHub Actions / macOS",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "ci-macos.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/ci-macos.yml",
+      "branches": [
+        "master"
+      ]
+    },
+    {
+      "name": "GitHub Actions / macOS (stable badge retired)",
+      "provider": "disabled",
+      "required": false,
+      "status": "disabled",
+      "url": "https://github.com/postgis/postgis/actions/workflows/ci-macos.yml?query=branch%3A${branch}",
+      "message": "The Trac badge inventory lists stable-branch macOS badges, but ci-macos.yml is not present on supported stable branches.",
+      "branches": [
+        "stable-3.6",
+        "stable-3.5",
+        "stable-3.4"
+      ]
+    },
+    {
+      "name": "GitHub Actions / MSYS2",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "msys.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/msys.yml",
+      "branches": [
+        "master"
+      ]
+    },
+    {
+      "name": "GitHub Actions / Codespell",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "codespell.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/codespell.yml",
+      "branches": [
+        "master"
+      ]
+    },
+    {
+      "name": "GitHub Actions / CodeQL",
+      "provider": "github_actions",
+      "required": true,
+      "workflow": "codeql.yml",
+      "url": "https://github.com/postgis/postgis/actions/workflows/codeql.yml",
+      "branches": [
+        "master",
+        "stable-3.6",
+        "stable-3.5"
+      ]
+    },
+    {
+      "name": "Woodpecker",
+      "provider": "woodpecker",
+      "required": true,
+      "api_url": "https://woodie.osgeo.org/api/repos/30/pipelines",
+      "web_url": "https://woodie.osgeo.org/repos/30",
+      "branches": [
+        "master"
+      ]
+    },
+    {
+      "name": "Jenkins / Debbie main",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_${version_or_trunk}/"
+    },
+    {
+      "name": "Jenkins / Make Dist",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_Make_Dist/label=debbie/",
+      "branch_parameter": "reference"
+    },
+    {
+      "name": "Jenkins / Winnie",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://winnie.postgis.net/view/PostGIS/job/PostGIS_${version_or_trunk}/"
+    },
+    {
+      "name": "Jenkins / Bessie",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_Worker_Run/label=bessie/",
+      "branch_parameter": "reference"
+    },
+    {
+      "name": "Jenkins / Bessie32",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_Worker_Run/label=bessie32/",
+      "branch_parameter": "reference"
+    },
+    {
+      "name": "Jenkins / Berrie",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_Worker_Run/label=berrie/",
+      "branch_parameter": "reference"
+    },
+    {
+      "name": "Jenkins / Berrie64",
+      "provider": "jenkins",
+      "required": true,
+      "job_url": "https://debbie.postgis.net/view/PostGIS/job/PostGIS_Worker_Run/label=berrie64/",
+      "branch_parameter": "reference"
+    },
+    {
+      "name": "Cirrus CI",
+      "provider": "disabled",
+      "required": false,
+      "status": "retired",
+      "message": "Cirrus coverage was replaced by repository GitHub Actions FreeBSD and macOS workflows."
+    },
+    {
+      "name": "GitLab mirror",
+      "provider": "gitlab",
+      "required": true,
+      "api_url": "https://gitlab.com/api/v4",
+      "project": "postgis/postgis",
+      "web_url": "https://gitlab.com/postgis/postgis/-/commits/${branch}",
+      "branches": [
+        "master",
+        "stable-3.6",
+        "stable-3.5",
+        "stable-3.4",
+        "stable-3.3",
+        "stable-3.2"
+      ]
+    },
+    {
+      "name": "INAF GitLab mirror",
+      "provider": "gitlab",
+      "required": true,
+      "api_url": "https://www.ict.inaf.it/gitlab/api/v4",
+      "project": "postgis/postgis",
+      "web_url": "https://www.ict.inaf.it/gitlab/postgis/postgis/-/commits/${branch}",
+      "branches": [
+        "master",
+        "stable-3.6",
+        "stable-3.5",
+        "stable-3.4",
+        "stable-3.3",
+        "stable-3.2"
+      ],
+      "message": "The legacy Trac badges use release labels, but the INAF GitLab API exposes pipelines under stable-X.Y refs."
+    }
+  ]
+}
diff --git a/utils/ci-status.py b/utils/ci-status.py
new file mode 100755
index 000000000..92a0bf8ee
--- /dev/null
+++ b/utils/ci-status.py
@@ -0,0 +1,1768 @@
+#!/usr/bin/env python3
+#
+# Query the PostGIS CI providers and print a compact status report, or write a
+# static status page suitable for publishing from cron.
+
+import argparse
+import asyncio
+import datetime as dt
+import html
+import http.client
+import json
+import os
+import pathlib
+import subprocess
+import sys
+import tempfile
+import threading
+import urllib.error
+import urllib.parse
+import urllib.request
+import xml.etree.ElementTree as ET
+
+
+SUCCESS = "success"
+FAILURE = "failure"
+IN_PROGRESS = "in_progress"
+UNKNOWN = "unknown"
+STALE = "stale"
+DISABLED = "disabled"
+NOT_APPLICABLE = "not_applicable"
+
+SYMBOLS = {
+    SUCCESS: ("✅", "OK"),
+    FAILURE: ("❌", "FAIL"),
+    IN_PROGRESS: ("🔄", "RUN"),
+    UNKNOWN: ("⚠️", "UNKNOWN"),
+    STALE: ("⚠️", "STALE"),
+    DISABLED: ("➖", "DISABLED"),
+    NOT_APPLICABLE: ("➖", "N/A"),
+}
+
+COLORS = {
+    SUCCESS: "\033[32m",
+    FAILURE: "\033[31m",
+    IN_PROGRESS: "\033[36m",
+    UNKNOWN: "\033[33m",
+    STALE: "\033[33m",
+    DISABLED: "\033[2m",
+    NOT_APPLICABLE: "\033[2m",
+}
+RESET = "\033[0m"
+BOLD = "\033[1m"
+DIM = "\033[2m"
+_GITHUB_RUNS_CACHE = {}
+_GITHUB_RUNS_INFLIGHT = {}
+_GITHUB_RUNS_LOCK = threading.Lock()
+
+
+class ConfigError(Exception):
+    pass
+
+
+class ProviderContentError(Exception):
+    pass
+
+
+RECOVERABLE_PROVIDER_ERRORS = (
+    OSError,
+    urllib.error.URLError,
+    urllib.error.HTTPError,
+    TimeoutError,
+    ProviderContentError,
+    json.JSONDecodeError,
+    http.client.IncompleteRead,
+)
+
+
+def utc_now():
+    return dt.datetime.now(dt.timezone.utc)
+
+
+def parse_time(value):
+    if value is None:
+        return None
+    if isinstance(value, (int, float)):
+        # Jenkins timestamps are milliseconds; Woodpecker timestamps are seconds.
+        if value > 10_000_000_000:
+            value = value / 1000
+        return dt.datetime.fromtimestamp(value, dt.timezone.utc)
+    if isinstance(value, str):
+        try:
+            parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
+        except ValueError:
+            return None
+        if parsed.tzinfo is None:
+            parsed = parsed.replace(tzinfo=dt.timezone.utc)
+        return parsed.astimezone(dt.timezone.utc)
+    return None
+
+
+def age_text(value, now=None):
+    timestamp = parse_time(value)
+    if not timestamp:
+        return None
+    now = now or utc_now()
+    delta = now - timestamp
+    if delta.total_seconds() < 0:
+        delta = dt.timedelta(0)
+    minutes = int(delta.total_seconds() // 60)
+    if minutes < 90:
+        return f"{minutes}m ago"
+    hours = minutes // 60
+    if hours < 48:
+        return f"{hours}h ago"
+    return f"{hours // 24}d ago"
+
+
+def status_symbol(status, color):
+    glyph, plain = SYMBOLS.get(status, SYMBOLS[UNKNOWN])
+    text = glyph if color else plain
+    if color and status in COLORS:
+        return f"{COLORS[status]}{text}{RESET}"
+    return text
+
+
+def terminal_style(text, color=None, bold=False, dim=False, enabled=True):
+    if not enabled:
+        return text
+    pieces = []
+    if bold:
+        pieces.append(BOLD)
+    if dim:
+        pieces.append(DIM)
+    if color:
+        pieces.append(color)
+    if not pieces:
+        return text
+    return "".join(pieces) + text + RESET
+
+
+def terminal_status_label(status, color):
+    _, plain = SYMBOLS.get(status, SYMBOLS[UNKNOWN])
+    label = plain
+    if status == IN_PROGRESS:
+        label = "RUN"
+    if status == NOT_APPLICABLE:
+        label = "N/A"
+    return terminal_style(f"{label:<8}", COLORS.get(status), bold=status in (FAILURE, SUCCESS), enabled=color)
+
+
+def http_json(url, token=None, timeout=30):
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in ("http", "https") or not parsed.netloc:
+        raise ConfigError(f"unsupported CI API URL: {url}")
+    headers = {
+        "Accept": "application/json",
+        "User-Agent": "postgis-ci-status/1.0",
+    }
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+    request = urllib.request.Request(url, headers=headers)
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        content_type = response.headers.get("Content-Type", "")
+        data = response.read()
+    if "json" not in content_type.lower():
+        raise ProviderContentError(f"expected JSON from {url}, got {content_type or 'unknown content type'}")
+    return json.loads(data.decode("utf-8"))
+
+
+def http_text(url, timeout=30):
+    parsed = urllib.parse.urlparse(url)
+    if parsed.scheme not in ("http", "https") or not parsed.netloc:
+        raise ConfigError(f"unsupported CI URL: {url}")
+    request = urllib.request.Request(url, headers={"User-Agent": "postgis-ci-status/1.0"})
+    with urllib.request.urlopen(request, timeout=timeout) as response:
+        data = response.read()
+    return data.decode("utf-8", errors="replace")
+
+
+def render_template(value, branch):
+    version = branch.get("version") or branch["label"]
+    values = {
+        "branch": branch["name"],
+        "label": branch["label"],
+        "version": version,
+        "version_or_trunk": "trunk" if branch["name"] == "master" else version,
+    }
+    return value.replace("${version_or_trunk}", values["version_or_trunk"]).replace(
+        "${version}", values["version"]
+    ).replace("${branch}", values["branch"]).replace("${label}", values["label"])
+
+
+def normalize_github_status(run):
+    status = run.get("status")
+    conclusion = run.get("conclusion")
+    if status != "completed":
+        if status in ("queued", "in_progress", "waiting", "requested", "pending"):
+            return IN_PROGRESS
+        return UNKNOWN
+    if conclusion == "success":
+        return SUCCESS
+    if conclusion in ("failure", "timed_out", "startup_failure", "action_required"):
+        return FAILURE
+    if conclusion in ("cancelled", "skipped", "neutral"):
+        return UNKNOWN
+    return UNKNOWN
+
+
+def github_workflow_matches(run, workflow):
+    path = run.get("path") or ""
+    return path == workflow or path.endswith(f"/{workflow}")
+
+
+def github_workflow_url(repo, workflow, branch):
+    query = urllib.parse.quote(f"branch:{branch['name']}", safe="")
+    return f"https://github.com/{repo}/actions/workflows/{urllib.parse.quote(workflow)}?query={query}"
+
+
+def github_badge_url(repo, workflow, branch):
+    query = urllib.parse.urlencode({"branch": branch["name"]})
+    return f"https://github.com/{repo}/actions/workflows/{urllib.parse.quote(workflow)}/badge.svg?{query}"
+
+
+def parse_github_badge_status(svg):
+    try:
+        root = ET.fromstring(svg)
+    except ET.ParseError as exc:
+        raise ProviderContentError("cannot parse GitHub workflow badge SVG") from exc
+    title = root.findtext("{http://www.w3.org/2000/svg}title") or root.findtext("title")
+    if not title:
+        raise ProviderContentError("GitHub workflow badge SVG has no status title")
+    status = None
+    for separator in (" - ", ": "):
+        if separator in title:
+            status = title.rsplit(separator, 1)[1].strip().lower()
+            break
+    if status is None:
+        raise ProviderContentError("GitHub workflow badge SVG has no status title")
+    if status == "passing":
+        return SUCCESS, title
+    if status in ("failing", "failure", "failed", "error"):
+        return FAILURE, title
+    if status in ("pending", "queued", "in progress", "waiting", "requested"):
+        return IN_PROGRESS, title
+    return UNKNOWN, title
+
+
+def github_badge_check(check, branch, repo, workflow, timeout, api_error=None):
+    badge_url = github_badge_url(repo, workflow, branch)
+    status, title = parse_github_badge_status(http_text(badge_url, timeout=timeout))
+    message = f"badge: {title}"
+    if api_error:
+        message = f"{message}; GitHub API unavailable: {api_error}"
+    return make_result(
+        check,
+        branch,
+        status,
+        url=github_workflow_url(repo, workflow, branch),
+        debug_url=badge_url,
+        message=message,
+    )
+
+
+def github_runs_for_branch(repo, branch, token, timeout):
+    cache_key = (repo, branch["name"], token or "")
+    with _GITHUB_RUNS_LOCK:
+        cached = _GITHUB_RUNS_CACHE.get(cache_key)
+        if cached is not None:
+            runs, url, exc = cached
+            if exc is not None:
+                raise exc
+            return runs, url
+
+        event = _GITHUB_RUNS_INFLIGHT.get(cache_key)
+        if event is None:
+            event = threading.Event()
+            _GITHUB_RUNS_INFLIGHT[cache_key] = event
+            owner = True
+        else:
+            owner = False
+
+    if not owner:
+        event.wait()
+        with _GITHUB_RUNS_LOCK:
+            runs, url, exc = _GITHUB_RUNS_CACHE[cache_key]
+        if exc is not None:
+            raise exc
+        return runs, url
+
+    query = urllib.parse.urlencode({"branch": branch["name"], "event": "push", "per_page": "100"})
+    url = f"https://api.github.com/repos/{repo}/actions/runs?{query}"
+    try:
+        data = http_json(url, token=token, timeout=timeout)
+    except Exception as exc:
+        cached = ([], url, exc)
+    else:
+        cached = (data.get("workflow_runs", []), url, None)
+
+    with _GITHUB_RUNS_LOCK:
+        _GITHUB_RUNS_CACHE[cache_key] = cached
+        _GITHUB_RUNS_INFLIGHT.pop(cache_key, None)
+        event.set()
+
+    runs, url, exc = cached
+    if exc is not None:
+        raise exc
+    return runs, url
+
+
+def github_actions_check(check, branch, timeout):
+    workflow = check["workflow"]
+    repo = check.get("repo", "postgis/postgis")
+    token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+    try:
+        runs, debug_url = github_runs_for_branch(repo, branch, token, timeout)
+    except RECOVERABLE_PROVIDER_ERRORS as exc:
+        return github_badge_check(check, branch, repo, workflow, timeout, api_error=exc)
+    runs = [run for run in runs if github_workflow_matches(run, workflow)]
+    if not runs:
+        try:
+            return github_badge_check(check, branch, repo, workflow, timeout)
+        except RECOVERABLE_PROVIDER_ERRORS:
+            return make_result(
+                check,
+                branch,
+                UNKNOWN,
+                message="no workflow runs found",
+                debug_url=debug_url,
+                url=github_workflow_url(repo, workflow, branch),
+            )
+
+    current = runs[0]
+    previous = next((run for run in runs[1:] if run.get("status") == "completed"), None)
+    current_status = normalize_github_status(current)
+    result = make_result(
+        check,
+        branch,
+        current_status,
+        url=current.get("html_url"),
+        debug_url=debug_url,
+        revision=current.get("head_sha"),
+        completed_at=current.get("updated_at") or current.get("created_at"),
+        message=current.get("display_title"),
+    )
+    if previous:
+        result.update(previous_fields(normalize_github_status(previous), previous))
+    return result
+
+
+def normalize_woodpecker_status(value):
+    mapping = {
+        "success": SUCCESS,
+        "failure": FAILURE,
+        "error": FAILURE,
+        "killed": FAILURE,
+        "blocked": IN_PROGRESS,
+        "declined": FAILURE,
+        "running": IN_PROGRESS,
+        "pending": IN_PROGRESS,
+        "started": IN_PROGRESS,
+        "created": IN_PROGRESS,
+        "skipped": UNKNOWN,
+    }
+    return mapping.get(str(value).lower(), UNKNOWN)
+
+
+def woodpecker_check(check, branch, timeout):
+    query = urllib.parse.urlencode({"branch": branch["name"], "limit": "20"})
+    api_url = check["api_url"]
+    separator = "&" if "?" in api_url else "?"
+    url = f"{api_url}{separator}{query}"
+    builds = http_json(url, timeout=timeout)
+    if isinstance(builds, dict):
+        builds = builds.get("builds") or builds.get("pipelines") or builds.get("data") or []
+    builds = [build for build in builds if build.get("branch") == branch["name"]]
+    if not builds:
+        return make_result(check, branch, UNKNOWN, message="no Woodpecker builds found", debug_url=url)
+
+    current = builds[0]
+    previous = next((build for build in builds[1:] if normalize_woodpecker_status(build.get("status")) not in (IN_PROGRESS, UNKNOWN)), None)
+    web_url = check.get("web_url")
+    run_url = current.get("link") or current.get("url")
+    if not run_url and web_url and current.get("number"):
+        run_url = f"{web_url}/pipeline/{current['number']}"
+    result = make_result(
+        check,
+        branch,
+        normalize_woodpecker_status(current.get("status")),
+        url=run_url or web_url,
+        debug_url=url,
+        revision=current.get("commit"),
+        completed_at=current.get("finished") or current.get("updated") or current.get("created"),
+        message=current.get("message"),
+    )
+    if previous:
+        result.update(previous_fields(normalize_woodpecker_status(previous.get("status")), previous))
+    return result
+
+
+def normalize_gitlab_status(value):
+    mapping = {
+        "success": SUCCESS,
+        "failed": FAILURE,
+        "canceled": UNKNOWN,
+        "skipped": UNKNOWN,
+        "manual": IN_PROGRESS,
+        "scheduled": IN_PROGRESS,
+        "created": IN_PROGRESS,
+        "waiting_for_resource": IN_PROGRESS,
+        "preparing": IN_PROGRESS,
+        "pending": IN_PROGRESS,
+        "running": IN_PROGRESS,
+    }
+    return mapping.get(str(value).lower(), UNKNOWN)
+
+
+def gitlab_jobs_url(api_url, project, pipeline):
+    pipeline_id = pipeline.get("id")
+    if pipeline_id is None:
+        return None
+    query = urllib.parse.urlencode({"per_page": "100"})
+    return f"{api_url.rstrip('/')}/projects/{project}/pipelines/{pipeline_id}/jobs?{query}"
+
+
+def gitlab_job_summary(jobs):
+    if not jobs:
+        return None
+    parts = []
+    for job in sorted(jobs, key=lambda item: item.get("id") or 0):
+        name = job.get("name") or f"job {job.get('id')}"
+        status = job.get("status") or "unknown"
+        reason = job.get("failure_reason")
+        text = f"{name} {status}"
+        if reason:
+            if reason == "ci_quota_exceeded":
+                reason = "no more compute minutes available"
+            text = f"{text} ({reason})"
+        parts.append(text)
+    return "; ".join(parts)
+
+
+def gitlab_status_from_jobs(status, jobs):
+    if status != FAILURE:
+        return status
+    failed = [job for job in jobs if job.get("status") == "failed"]
+    if failed and all(job.get("failure_reason") == "ci_quota_exceeded" for job in failed):
+        return UNKNOWN
+    return status
+
+
+def git_ref_exists(ref):
+    try:
+        subprocess.run(
+            ["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
+            check=True,
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+    except (OSError, subprocess.CalledProcessError):
+        return False
+    return True
+
+
+def git_commit_distance(revision, ref):
+    if not revision or not ref or not git_ref_exists(ref):
+        return None
+    try:
+        ancestor = subprocess.run(
+            ["git", "merge-base", "--is-ancestor", revision, ref],
+            stdout=subprocess.DEVNULL,
+            stderr=subprocess.DEVNULL,
+        )
+        if ancestor.returncode != 0:
+            return None
+        count = subprocess.check_output(
+            ["git", "rev-list", "--count", f"{revision}..{ref}"],
+            stderr=subprocess.DEVNULL,
+            text=True,
+        )
+    except (OSError, subprocess.CalledProcessError):
+        return None
+    try:
+        return int(count.strip())
+    except ValueError:
+        return None
+
+
+def branch_compare_refs(branch):
+    name = branch["name"]
+    return [f"upstream/{name}", f"gitea/{name}", f"gh/{name}", f"origin/{name}", name]
+
+
+def revision_distance_text(count, ref_name):
+    if count is None:
+        return None
+    if count == 0:
+        return f"at {ref_name}"
+    return f"{plural(count, 'commit')} behind {ref_name}"
+
+
+def gitlab_revision_distance(check, branch, revision):
+    compare_ref = check.get("compare_ref")
+    ref_candidates = [render_template(compare_ref, branch)] if compare_ref else branch_compare_refs(branch)
+    for candidate in ref_candidates:
+        count = git_commit_distance(revision, candidate)
+        if count is not None:
+            return count, branch["name"]
+    return None, None
+
+
+def result_revision_distance(config, result):
+    revision = result.get("revision")
+    if not revision:
+        return None, None
+    branch = next((item for item in config["branches"] if item["name"] == result["branch"]), None)
+    if branch is None:
+        return None, None
+    for candidate in branch_compare_refs(branch):
+        count = git_commit_distance(revision, candidate)
+        if count is not None:
+            return count, branch["name"]
+    return None, None
+
+
+def gitlab_check(check, branch, timeout):
+    api_url = check.get("api_url", "https://gitlab.com/api/v4")
+    project = urllib.parse.quote(check.get("project", "postgis/postgis"), safe="")
+    ref = render_template(check.get("ref", "${branch}"), branch)
+    query = urllib.parse.urlencode({
+        "ref": ref,
+        "per_page": check.get("per_page", 20),
+        "order_by": "id",
+        "sort": "desc",
+    })
+    url = f"{api_url.rstrip('/')}/projects/{project}/pipelines?{query}"
+    pipelines = http_json(url, timeout=timeout)
+    if isinstance(pipelines, dict):
+        pipelines = pipelines.get("pipelines") or pipelines.get("data") or []
+    pipelines = [pipeline for pipeline in pipelines if pipeline.get("ref") == ref]
+    if not pipelines:
+        return make_result(check, branch, UNKNOWN, message=f"no GitLab pipelines found for {ref}", debug_url=url)
+
+    current = pipelines[0]
+    previous = next((pipeline for pipeline in pipelines[1:] if normalize_gitlab_status(pipeline.get("status")) not in (IN_PROGRESS, UNKNOWN)), None)
+    web_url = current.get("web_url") or render_template(check.get("web_url", ""), branch) or None
+    current_status = normalize_gitlab_status(current.get("status"))
+    message = f"pipeline {current.get('iid') or current.get('id')}"
+    jobs_url = gitlab_jobs_url(api_url, project, current)
+    if jobs_url:
+        try:
+            jobs = http_json(jobs_url, timeout=timeout)
+        except RECOVERABLE_PROVIDER_ERRORS as exc:
+            message = f"{message}; job details unavailable: {exc}"
+        else:
+            if isinstance(jobs, dict):
+                jobs = jobs.get("jobs") or jobs.get("data") or []
+            current_status = gitlab_status_from_jobs(current_status, jobs)
+            summary = gitlab_job_summary(jobs)
+            if summary:
+                message = f"{message}; {summary}"
+    result = make_result(
+        check,
+        branch,
+        current_status,
+        url=web_url,
+        debug_url=url,
+        revision=current.get("sha"),
+        completed_at=current.get("updated_at") or current.get("created_at"),
+        message=message,
+    )
+    distance_count, distance_ref = gitlab_revision_distance(check, branch, current.get("sha"))
+    distance_text = revision_distance_text(distance_count, distance_ref)
+    if distance_text:
+        result["revision_commits_behind"] = distance_count
+        result["revision_compare_ref"] = distance_ref
+        result["revision_distance"] = distance_text
+    if previous:
+        result.update(previous_fields(normalize_gitlab_status(previous.get("status")), previous))
+    return result
+
+
+def normalize_jenkins_status(build):
+    if build.get("building"):
+        return IN_PROGRESS
+    result = build.get("result")
+    if result == "SUCCESS":
+        return SUCCESS
+    if result in ("FAILURE", "UNSTABLE"):
+        return FAILURE
+    if result in ("ABORTED", "NOT_BUILT"):
+        return UNKNOWN
+    return UNKNOWN
+
+
+def jenkins_parameters(build):
+    found = {}
+    for action in build.get("actions") or []:
+        for param in action.get("parameters") or []:
+            name = param.get("name")
+            if name:
+                found[name] = str(param.get("value", ""))
+    return found
+
+
+def jenkins_revision(build):
+    for action in build.get("actions") or []:
+        revision = action.get("lastBuiltRevision") or {}
+        sha1 = revision.get("SHA1")
+        if sha1:
+            return sha1
+    return None
+
+
+def jenkins_matches_branch(build, check, branch):
+    branch_parameter = check.get("branch_parameter")
+    if not branch_parameter:
+        return True
+    expected = f"refs/heads/{branch['name']}"
+    params = jenkins_parameters(build)
+    return any(value == expected for name, value in params.items() if name == branch_parameter or name.lower() == branch_parameter.lower())
+
+
+def jenkins_builds(job_url, check, timeout):
+    try:
+        limit = int(check.get("build_scan_limit", 200 if check.get("branch_parameter") else 25))
+    except (TypeError, ValueError) as exc:
+        raise ConfigError(f"invalid build_scan_limit for {check['name']}") from exc
+    if limit <= 0:
+        raise ConfigError(f"invalid build_scan_limit for {check['name']}: {limit}")
+    page_size = min(50, limit)
+    builds = []
+    for offset in range(0, limit, page_size):
+        page_limit = min(page_size, limit - offset)
+        page_end = offset + page_limit
+        tree = (
+            "builds[number,url,result,building,timestamp,duration,"
+            f"actions[parameters[name,value],lastBuiltRevision[SHA1,branch[name]]]]{{{offset},{page_end}}}"
+        )
+        url = job_url + "api/json?" + urllib.parse.urlencode({"tree": tree})
+        data = http_json(url, timeout=timeout)
+        page = data.get("builds", [])
+        builds.extend(page)
+        if len(page) < page_limit:
+            break
+    return builds
+
+
+def jenkins_badge_url(job_url, check, branch):
+    parsed = urllib.parse.urlparse(job_url)
+    if parsed.scheme not in ("http", "https") or not parsed.netloc or "/job/" not in parsed.path:
+        raise ConfigError(f"cannot build Jenkins badge URL for {job_url}")
+    job_path = parsed.path.split("/job/", 1)[1].strip("/")
+    query = {"job": job_path}
+    branch_parameter = check.get("branch_parameter")
+    if branch_parameter:
+        query["build"] = f"last:${{params.{branch_parameter}=refs/heads/{branch['name']}}}"
+    return urllib.parse.urlunparse((parsed.scheme, parsed.netloc, "/buildStatus/icon", "", urllib.parse.urlencode(query), ""))
+
+
+def parse_jenkins_badge_status(svg):
+    try:
+        root = ET.fromstring(svg)
+    except ET.ParseError as exc:
+        raise ProviderContentError("cannot parse Jenkins badge SVG") from exc
+    texts = [
+        "".join(element.itertext()).strip().lower()
+        for element in root.findall(".//{http://www.w3.org/2000/svg}text")
+    ]
+    labels = [text for text in texts if text and text != "build"]
+    label = labels[-1] if labels else ""
+    if label == "passing":
+        return SUCCESS, label
+    if label in ("failing", "failure", "failed", "error"):
+        return FAILURE, label
+    if label in ("pending", "queued", "in progress", "running"):
+        return IN_PROGRESS, label
+    if label in ("not run", "not built"):
+        return UNKNOWN, label
+    return UNKNOWN, label or "unknown"
+
+
+def jenkins_badge_check(check, branch, job_url, timeout, api_error=None):
+    badge_url = jenkins_badge_url(job_url, check, branch)
+    status, label = parse_jenkins_badge_status(http_text(badge_url, timeout=timeout))
+    message = f"badge: {label}"
+    if api_error:
+        message = f"{message}; Jenkins API unavailable: {api_error}"
+    extra = {}
+    if label in ("not run", "not built"):
+        extra["status_label"] = "Not run"
+    return make_result(
+        check,
+        branch,
+        status,
+        url=job_url,
+        debug_url=badge_url,
+        message=message,
+        **extra,
+    )
+
+
+def jenkins_check(check, branch, timeout):
+    job_url = render_template(check["job_url"], branch).rstrip("/") + "/"
+    try:
+        builds = [
+            build for build in jenkins_builds(job_url, check, timeout)
+            if jenkins_matches_branch(build, check, branch)
+        ]
+    except RECOVERABLE_PROVIDER_ERRORS as exc:
+        return jenkins_badge_check(check, branch, job_url, timeout, api_error=exc)
+    if not builds:
+        badge_result = jenkins_badge_check(check, branch, job_url, timeout)
+        if badge_result["status"] != UNKNOWN or badge_result.get("status_label") == "Not run":
+            return badge_result
+        return make_result(check, branch, UNKNOWN, url=job_url, debug_url=job_url + "api/json", message="no matching Jenkins builds found")
+
+    current = builds[0]
+    previous = next((build for build in builds[1:] if not build.get("building")), None)
+    result = make_result(
+        check,
+        branch,
+        normalize_jenkins_status(current),
+        url=current.get("url") or job_url,
+        debug_url=job_url + "api/json",
+        revision=jenkins_revision(current),
+        completed_at=current.get("timestamp"),
+        message=f"build {current.get('number')}",
+    )
+    if previous:
+        result.update(previous_fields(normalize_jenkins_status(previous), previous))
+    return result
+
+
+def disabled_check(check, branch, timeout):
+    status = DISABLED if check.get("status") == "disabled" else NOT_APPLICABLE
+    url = render_template(check.get("url", ""), branch) or None
+    return make_result(check, branch, status, url=url, message=check.get("message"))
+
+
+PROVIDERS = {
+    "github_actions": github_actions_check,
+    "gitlab": gitlab_check,
+    "woodpecker": woodpecker_check,
+    "jenkins": jenkins_check,
+    "disabled": disabled_check,
+}
+
+
+def make_result(check, branch, status, **extra):
+    result = {
+        "branch": branch["name"],
+        "branch_label": branch["label"],
+        "check": check["name"],
+        "provider": check["provider"],
+        "required": bool(check.get("required", True)),
+        "status": status,
+    }
+    for key, value in extra.items():
+        if value is not None:
+            result[key] = value
+    return result
+
+
+def previous_fields(status, item):
+    return {
+        "previous_completed_status": status,
+        "previous_completed_url": item.get("html_url") or item.get("web_url") or item.get("url") or item.get("link"),
+        "previous_completed_revision": item.get("head_sha") or item.get("sha") or item.get("commit"),
+    }
+
+
+def result_from_exception(check, branch, exc):
+    debug_url = check.get("url") or check.get("api_url") or check.get("job_url")
+    if debug_url:
+        debug_url = render_template(debug_url, branch)
+    return make_result(check, branch, UNKNOWN, message=str(exc), debug_url=debug_url)
+
+
+def stale_after_hours(config, check):
+    value = check.get("stale_after_hours", config.get("stale_after_hours"))
+    if value is None:
+        return None
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        raise ConfigError(f"invalid stale_after_hours for {check['name']}: {value}")
+
+
+def apply_staleness(result, config, check):
+    if result["status"] != SUCCESS:
+        return result
+    threshold = stale_after_hours(config, check)
+    completed_at = parse_time(result.get("completed_at"))
+    if threshold is None or completed_at is None:
+        return result
+    age = utc_now() - completed_at
+    if age.total_seconds() <= threshold * 3600:
+        return result
+    distance_count, distance_ref = result_revision_distance(config, result)
+    if distance_count == 0:
+        return result
+    stale = dict(result)
+    distance_text = revision_distance_text(distance_count, distance_ref)
+    if distance_text:
+        stale["revision_commits_behind"] = distance_count
+        stale["revision_compare_ref"] = distance_ref
+        stale["revision_distance"] = distance_text
+    stale["status"] = STALE
+    stale["message"] = f"{result.get('message', 'successful run')} (older than {threshold:g}h)"
+    return stale
+
+
+def branch_checks(config, selected_branch, include_eol=False):
+    branches = config["branches"]
+    if selected_branch:
+        branches = [branch for branch in branches if branch["name"] == selected_branch or branch["label"] == selected_branch]
+        if not branches:
+            raise ConfigError(f"unknown branch: {selected_branch}")
+    elif not include_eol:
+        branches = [branch for branch in branches if not branch.get("eol")]
+
+    for branch in branches:
+        for check in config["checks"]:
+            only = check.get("branches")
+            except_branches = check.get("except_branches", [])
+            if only and branch["name"] not in only and branch["label"] not in only:
+                continue
+            if branch["name"] in except_branches or branch["label"] in except_branches:
+                continue
+            yield branch, check
+
+
+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):
+    work = list(branch_checks(config, selected_branch, include_eol))
+    semaphore = asyncio.Semaphore(min(default_concurrency(), max(1, len(work))))
+
+    def collect_one(item):
+        branch, check = item
+        provider = PROVIDERS.get(check.get("provider"))
+        if provider is None:
+            raise ConfigError(f"unsupported provider for {check['name']}: {check.get('provider')}")
+        try:
+            return apply_staleness(provider(check, branch, timeout), config, check)
+        except RECOVERABLE_PROVIDER_ERRORS as exc:
+            return result_from_exception(check, branch, exc)
+
+    async def collect_one_async(item):
+        async with semaphore:
+            return await asyncio.to_thread(collect_one, item)
+
+    results = await asyncio.gather(*(collect_one_async(item) for item in work))
+    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 aggregate(config, results):
+    branches = []
+    by_branch = {}
+    branch_order = {branch["name"]: branch for branch in config["branches"]}
+    for result in results:
+        by_branch.setdefault(result["branch"], []).append(result)
+
+    for branch_name, checks in by_branch.items():
+        required = [check for check in checks if check.get("required") and check["status"] not in (DISABLED, NOT_APPLICABLE)]
+        if any(check["status"] == FAILURE for check in required):
+            status = FAILURE
+        elif any(check["status"] in (UNKNOWN, STALE) for check in required):
+            status = UNKNOWN
+        elif any(check["status"] == IN_PROGRESS for check in required):
+            status = IN_PROGRESS
+        elif required:
+            status = SUCCESS
+        else:
+            status = NOT_APPLICABLE
+
+        failures = sum(1 for check in required if check["status"] == FAILURE)
+        label = branch_order.get(branch_name, {}).get("label", branch_name)
+        branches.append({
+            "name": branch_name,
+            "label": label,
+            "status": status,
+            "failures": failures,
+            "checks": checks,
+        })
+
+    order = {branch["name"]: index for index, branch in enumerate(config["branches"])}
+    branches.sort(key=lambda item: order.get(item["name"], 999))
+    return {
+        "generated_at": utc_now().isoformat(timespec="seconds"),
+        "branches": branches,
+    }
+
+
+def check_counts(branch):
+    required = [
+        check for check in branch["checks"]
+        if check.get("required") and check["status"] not in (DISABLED, NOT_APPLICABLE)
+    ]
+    counts = {
+        SUCCESS: 0,
+        FAILURE: 0,
+        IN_PROGRESS: 0,
+        UNKNOWN: 0,
+        STALE: 0,
+    }
+    for check in required:
+        counts[check["status"]] = counts.get(check["status"], 0) + 1
+    counts["required"] = len(required)
+    counts["informational"] = len(branch["checks"]) - len(required)
+    return counts
+
+
+def plural(count, word):
+    return f"{count} {word}" + ("" if count == 1 else "s")
+
+
+def nonzero_parts(*items):
+    return [text for count, text in items if count]
+
+
+def summary_text(branch):
+    status = branch["status"]
+    counts = check_counts(branch)
+    unknown = counts[UNKNOWN] + counts[STALE]
+    if status == SUCCESS:
+        return f"all {counts['required']} required CI checks OK"
+    if status == FAILURE:
+        parts = nonzero_parts(
+            (counts[SUCCESS], f"{counts[SUCCESS]} OK"),
+            (counts[FAILURE], plural(counts[FAILURE], "failure")),
+            (counts[IN_PROGRESS], f"{counts[IN_PROGRESS]} running"),
+            (unknown, f"{unknown} unknown/stale"),
+        )
+        return "; ".join(parts)
+    if status == IN_PROGRESS:
+        previous = previous_summary(branch["checks"])
+        parts = nonzero_parts(
+            (counts[SUCCESS], f"{counts[SUCCESS]} OK"),
+            (counts[IN_PROGRESS], f"{counts[IN_PROGRESS]} running"),
+            (unknown, f"{unknown} unknown/stale"),
+        )
+        prefix = "no known failures; " + ", ".join(parts)
+        return f"{prefix}; {previous}" if previous else prefix
+    if status == UNKNOWN:
+        parts = nonzero_parts(
+            (counts[SUCCESS], f"{counts[SUCCESS]} OK"),
+            (counts[IN_PROGRESS], f"{counts[IN_PROGRESS]} running"),
+            (unknown, f"{unknown} unknown/stale"),
+        )
+        return "no known failures; " + ", ".join(parts)
+    return "no required CI configured"
+
+
+def previous_summary(checks):
+    running = [check for check in checks if check["status"] == IN_PROGRESS and check.get("required")]
+    if not running:
+        return None
+    previous = {check.get("previous_completed_status") for check in running if check.get("previous_completed_status")}
+    if previous == {SUCCESS}:
+        return "previous OK, new run in progress"
+    if previous == {FAILURE}:
+        return "previous failed, new run in progress"
+    if previous:
+        return "previous result mixed, new run in progress"
+    return "in progress, no previous result known"
+
+
+def interesting_checks(branch, verbose=False):
+    if verbose:
+        return branch["checks"]
+    has_visible_jenkins_problem = any(
+        check.get("provider") == "jenkins"
+        and check["status"] in (FAILURE, UNKNOWN, STALE, IN_PROGRESS, NOT_APPLICABLE)
+        for check in branch["checks"]
+    )
+    status_order = {
+        FAILURE: 0,
+        UNKNOWN: 1,
+        STALE: 1,
+        IN_PROGRESS: 2,
+        SUCCESS: 3,
+        NOT_APPLICABLE: 4,
+    }
+    visible = []
+    for index, check in enumerate(branch["checks"]):
+        if (
+            check["status"] in (FAILURE, UNKNOWN, STALE, IN_PROGRESS)
+            or (check["status"] == NOT_APPLICABLE and check.get("provider") == "jenkins")
+            or (
+                has_visible_jenkins_problem
+                and check.get("provider") == "jenkins"
+                and check["status"] == SUCCESS
+            )
+        ) and check["status"] != DISABLED:
+            visible.append((index, check))
+    visible.sort(key=lambda item: (status_order.get(item[1]["status"], 99), item[0]))
+    return [check for index, check in visible]
+
+
+def safe_http_href(value):
+    if not value:
+        return ""
+    parsed = urllib.parse.urlparse(value)
+    if parsed.scheme in ("http", "https") and parsed.netloc:
+        return value
+    return ""
+
+
+def result_url(check):
+    return safe_http_href(check.get("url") or check.get("debug_url") or "")
+
+
+def terminal_link(text, url, enabled):
+    if enabled and url:
+        return f"\033]8;;{url}\033\\{text}\033]8;;\033\\"
+    return text
+
+
+def terminal_count(value, status, color):
+    text = f"{value:>3}"
+    if not value:
+        return terminal_style(text, dim=True, enabled=color)
+    return terminal_style(text, COLORS.get(status), bold=status in (FAILURE, SUCCESS), enabled=color)
+
+
+def terminal_header(data, use_color):
+    generated = data.get("generated_at", "")
+    print(terminal_style("PostGIS CI status", bold=True, enabled=use_color))
+    if generated:
+        print(terminal_style(f"generated {generated}", dim=True, enabled=use_color))
+    print()
+
+
+def print_branch_table(branches, use_color):
+    print(terminal_style("Branches", bold=True, enabled=use_color))
+    header = f"{'branch':<10} {'status':<8} {'ok':>3} {'fail':>4} {'run':>3} {'unk':>3}  summary"
+    print(terminal_style(header, dim=True, enabled=use_color))
+    print(terminal_style("-" * len(header), dim=True, enabled=use_color))
+    for branch in branches:
+        counts = check_counts(branch)
+        unknown = counts[UNKNOWN] + counts[STALE]
+        label = f"{branch['label']:<10}"
+        print(
+            f"{label} "
+            f"{terminal_status_label(branch['status'], use_color)} "
+            f"{terminal_count(counts[SUCCESS], SUCCESS, use_color)} "
+            f"{terminal_count(counts[FAILURE], FAILURE, use_color)} "
+            f"{terminal_count(counts[IN_PROGRESS], IN_PROGRESS, use_color)} "
+            f"{terminal_count(unknown, UNKNOWN, use_color)}  "
+            f"{summary_text(branch)}"
+        )
+
+
+def terminal_field(label, value, use_color):
+    label_text = f"{label + ':':<10}"
+    return f"  {terminal_style(label_text, dim=True, enabled=use_color)} {value}"
+
+
+def print_terminal(data, use_color=True, verbose=False):
+    terminal_header(data, use_color)
+    print_branch_table(data["branches"], use_color)
+
+    details = [(branch, check) for branch in data["branches"] for check in interesting_checks(branch, verbose)]
+    if not details:
+        return
+    print()
+    title = "Checks" if verbose else "Problem checks"
+    print(terminal_style(title, bold=True, enabled=use_color))
+    if verbose:
+        print(terminal_style("showing all checks, including passing and informational checks", dim=True, enabled=use_color))
+    else:
+        print(terminal_style("use --verbose to show passing and informational checks", dim=True, enabled=use_color))
+    for branch, check in details:
+        url = result_url(check)
+        glyph = status_symbol(check["status"], use_color)
+        heading_text = f"{glyph} {branch['label']} / {check['check']}"
+        heading = terminal_link(heading_text, url, use_color)
+        print(heading)
+        print(terminal_field("status", check["status"], use_color))
+        if url:
+            print(terminal_field("url", url, use_color))
+        if check["status"] == IN_PROGRESS and check.get("previous_completed_status"):
+            print(terminal_field("previous", check["previous_completed_status"], use_color))
+        if check.get("revision"):
+            revision = check["revision"]
+            if check.get("revision_distance"):
+                revision = f"{revision} ({check['revision_distance']})"
+            print(terminal_field("revision", revision, use_color))
+        if check.get("completed_at"):
+            age = age_text(check["completed_at"])
+            if age:
+                print(terminal_field("age", age, use_color))
+        if check.get("message"):
+            message = " ".join(str(check["message"]).split())
+            print(terminal_field("message", message, use_color))
+        print()
+
+
+def exit_code_for_terminal(data):
+    statuses = [branch["status"] for branch in data["branches"]]
+    if any(status == FAILURE for status in statuses):
+        return 1
+    if any(status in (IN_PROGRESS, UNKNOWN, STALE) for status in statuses):
+        return 2
+    return 0
+
+
+def write_atomic(path, content, mode="w"):
+    path = pathlib.Path(path)
+    path.parent.mkdir(parents=True, exist_ok=True)
+    target_mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
+    fd, tmpname = tempfile.mkstemp(prefix=f".{path.name}.", dir=str(path.parent), text="b" not in mode)
+    try:
+        with os.fdopen(fd, mode) as handle:
+            handle.write(content)
+        os.chmod(tmpname, target_mode)
+        os.replace(tmpname, path)
+    except Exception:
+        try:
+            os.unlink(tmpname)
+        finally:
+            raise
+
+
+def overall_status(branches):
+    statuses = [branch["status"] for branch in branches]
+    if any(status == FAILURE for status in statuses):
+        return FAILURE
+    if any(status in (UNKNOWN, STALE) for status in statuses):
+        return UNKNOWN
+    if any(status == IN_PROGRESS for status in statuses):
+        return IN_PROGRESS
+    if statuses:
+        return SUCCESS
+    return NOT_APPLICABLE
+
+
+def html_status_label(status):
+    return {
+        SUCCESS: "Passing",
+        FAILURE: "Failing",
+        IN_PROGRESS: "Running",
+        UNKNOWN: "Unknown",
+        STALE: "Stale",
+        DISABLED: "Disabled",
+        NOT_APPLICABLE: "Not applicable",
+    }.get(status, "Unknown")
+
+
+def status_mark(status):
+    return {
+        SUCCESS: "✓",
+        FAILURE: "✕",
+        IN_PROGRESS: "↻",
+        UNKNOWN: "?",
+        STALE: "!",
+        DISABLED: "–",
+        NOT_APPLICABLE: "–",
+    }.get(status, "?")
+
+
+def html_status_pill(status, label=None):
+    label = html.escape(label or html_status_label(status))
+    mark = html.escape(status_mark(status))
+    return f"<span class='status-pill'><span aria-hidden='true'>{mark} </span><span>{label}</span></span>"
+
+
+def html_branch_progress(branch):
+    counts = check_counts(branch)
+    total = counts["required"]
+    if not total:
+        return ""
+    segments = [
+        (SUCCESS, counts[SUCCESS], "OK"),
+        (FAILURE, counts[FAILURE], "failing"),
+        (IN_PROGRESS, counts[IN_PROGRESS], "running"),
+        (UNKNOWN, counts[UNKNOWN] + counts[STALE], "unknown or stale"),
+    ]
+    labels = [f"{count} {label}" for status, count, label in segments if count]
+    pieces = []
+    for status, count, label in segments:
+        if not count:
+            continue
+        width = count / total * 100
+        pieces.append(
+            f"<span class='progress-segment segment-{html.escape(status)}' "
+            f"style='width:{width:.3f}%' title='{html.escape(f'{count} {label}')}'></span>"
+        )
+    aria = html.escape("; ".join(labels) or "no required checks")
+    return f"<div class='branch-progress' role='img' aria-label='{aria}'>{''.join(pieces)}</div>"
+
+
+def html_branch_summary(branch):
+    counts = check_counts(branch)
+    unknown = counts[UNKNOWN] + counts[STALE]
+    parts = []
+    if counts[SUCCESS]:
+        parts.append((SUCCESS, f"{counts[SUCCESS]} OK"))
+    if counts[FAILURE]:
+        parts.append((FAILURE, plural(counts[FAILURE], "failure")))
+    if counts[IN_PROGRESS]:
+        parts.append((IN_PROGRESS, f"{counts[IN_PROGRESS]} running"))
+    if unknown:
+        parts.append((UNKNOWN, f"{unknown} unknown/stale"))
+    if not parts:
+        return html.escape(summary_text(branch))
+    return " ".join(
+        f"<span class='summary-chip chip-{html.escape(status)}'>{html.escape(text)}</span>"
+        for status, text in parts
+    )
+
+
+def html_id(value):
+    return "branch-" + "".join(char if char.isalnum() else "-" for char in value.lower()).strip("-")
+
+
+def html_time(value):
+    age = age_text(value)
+    if not age:
+        return ""
+    timestamp = parse_time(value)
+    if not timestamp:
+        return html.escape(age)
+    iso = timestamp.isoformat(timespec="seconds")
+    return f"<time datetime='{html.escape(iso)}' title='{html.escape(iso)}'>{html.escape(age)}</time>"
+
+
+def revision_url(revision):
+    if not revision:
+        return ""
+    return f"https://gitea.osgeo.org/postgis/postgis/commit/{urllib.parse.quote(revision)}"
+
+
+def html_revision(check):
+    revision = check.get("revision") or ""
+    if not revision:
+        return ""
+    text = revision[:12]
+    if check.get("revision_distance"):
+        text = f"{text} ({check['revision_distance']})"
+    href = revision_url(revision)
+    return f"<a class='revision-link' href='{html.escape(href)}'>{html.escape(text)}</a>"
+
+
+def terminal_pad(text, width):
+    pad = max(1, width - len(text))
+    return "<span class='terminal-pad'>" + (" " * pad) + "</span>"
+
+
+def html_check_rows(checks):
+    detail_rows = []
+    terminal_sep = "<span class='terminal-sep'> | </span>"
+    for check in checks:
+        link = result_url(check)
+        check_name = html.escape(check["check"])
+        check_html = f"<a href='{html.escape(link)}'>{check_name}</a>" if link else check_name
+        status_label = check.get("status_label") or ("Passing" if check["status"] == SUCCESS else None)
+        status_text = status_label or html_status_label(check["status"])
+        status_text = f"{status_mark(check['status'])} {status_text}"
+        status = html_status_pill(check["status"], status_label)
+        previous = check.get("previous_completed_status")
+        if previous and check["status"] == IN_PROGRESS:
+            status_text = f"{status_text} previous: {previous}"
+            status = f"{status} <span class='previous-note'>previous: {html.escape(previous)}</span>"
+        message = " ".join(str(check.get("message") or "").split())
+        check_status = html.escape(check["status"])
+        revision = html_revision(check)
+        revision_text = check.get("revision") or ""
+        if revision_text:
+            revision_text = revision_text[:12]
+            if check.get("revision_distance"):
+                revision_text = f"{revision_text} ({check['revision_distance']})"
+        age = html_time(check.get("completed_at"))
+        age_text_value = age_text(check.get("completed_at")) if check.get("completed_at") else ""
+        detail_rows.append(
+            f"<tr class='status-{check_status}'>"
+            f"<td class='check-name'>{check_html}{terminal_pad(check['check'], 27)}</td>"
+            f"<td>{terminal_sep}{status}{terminal_pad(status_text, 30)}</td>"
+            f"<td class='revision-cell'>{terminal_sep}{revision}{terminal_pad(revision_text, 44)}</td>"
+            f"<td class='age-cell'>{terminal_sep}{age}{terminal_pad(age_text_value, 8)}</td>"
+            f"<td class='message-cell'>{terminal_sep}{html.escape(message)}</td>"
+            "</tr>"
+        )
+    return "".join(detail_rows)
+
+
+def html_check_table(problem_checks, passing_checks=None):
+    rows = [html_check_rows(problem_checks)]
+    if passing_checks:
+        rows.append(html_check_rows(passing_checks))
+    return (
+        "<div class='table-wrap'><table class='check-table'>"
+        "<thead><tr><th>Check</th><th><span class='terminal-sep'> | </span>Status</th>"
+        "<th><span class='terminal-sep'> | </span>Revision</th><th><span class='terminal-sep'> | </span>Age</th>"
+        "<th><span class='terminal-sep'> | </span>Message</th></tr></thead><tbody>"
+        + "".join(rows)
+        + "</tbody></table></div>"
+    )
+
+
+def render_html(data):
+    generated = html.escape(data["generated_at"])
+    page_status = overall_status(data["branches"])
+    page_summary = html.escape(summary_text({"status": page_status, "checks": [
+        check for branch in data["branches"] for check in branch["checks"]
+    ]}))
+    rows = []
+    details = []
+    for branch in data["branches"]:
+        branch_status = html.escape(branch["status"])
+        branch_label = html.escape(branch["label"])
+        branch_id = html.escape(html_id(branch["name"]))
+        branch_summary = html_branch_summary(branch)
+        rows.append(
+            f"<tr class='component-row status-{branch_status}' role='link' tabindex='0' "
+            f"onclick=\"location.hash='{branch_id}'\" "
+            f"onkeydown=\"if(event.key==='Enter'||event.key===' '){{event.preventDefault();location.hash='{branch_id}';}}\">"
+            f"<td><span class='status-dot' aria-hidden='true'></span><strong>{branch_label}</strong></td>"
+            f"<td><span class='terminal-sep'> | </span>{html_status_pill(branch['status'])}</td>"
+            f"<td><span class='terminal-sep'> | </span><div class='summary-metrics'>"
+            f"{html_branch_progress(branch)}<span class='branch-summary'>{branch_summary}</span></div></td>"
+            "</tr>"
+        )
+        branch_details = interesting_checks(branch)
+        passing_checks = [
+            check for check in branch["checks"]
+            if check["status"] == SUCCESS and check not in branch_details
+        ]
+        if branch_details or passing_checks:
+            details.append(
+                f"<details id='{branch_id}' class='detail-section status-{branch_status}' open><summary>"
+                "<span class='status-dot' aria-hidden='true'></span>"
+                f"<span>{branch_label} checks</span>"
+                "<span class='disclosure-mark' aria-hidden='true'></span>"
+                f"</summary>{html_check_table(branch_details, passing_checks)}</details>"
+            )
+
+    return f"""<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>PostGIS CI status</title>
+<link rel="icon" href="data:,">
+<style>
+:root {{
+  color-scheme: light;
+  --bg: #f6f8f7;
+  --panel: #ffffff;
+  --text: #24292e;
+  --muted: #667085;
+  --line: #dfe5e2;
+  --header: #f3f6f4;
+  --brand: #336791;
+  --brand-strong: #2f5f47;
+  --success: #1a7f37;
+  --failure: #cf222e;
+  --running: #0969da;
+  --unknown: #9a6700;
+  --disabled: #6e7781;
+}}
+* {{ box-sizing: border-box; }}
+body {{
+  margin: 0;
+  background: var(--bg);
+  color: var(--text);
+  font: 15px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}}
+.page {{
+  max-width: 1040px;
+  margin: 0 auto;
+  padding: 32px 20px 40px;
+}}
+.masthead {{
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 18px;
+}}
+.brand {{
+  color: var(--brand);
+  font-weight: 700;
+  margin: 0 0 4px;
+}}
+h1 {{
+  font-size: 28px;
+  line-height: 1.2;
+  margin: 0;
+}}
+.generated {{
+  color: var(--muted);
+  margin: 6px 0 0;
+  font-size: 13px;
+}}
+.status-banner {{
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-left: 5px solid var(--unknown);
+  border-radius: 8px;
+  padding: 14px 16px;
+  margin-bottom: 16px;
+}}
+.status-banner.status-success {{ border-left-color: var(--success); }}
+.status-banner.status-failure {{ border-left-color: var(--failure); }}
+.status-banner.status-in_progress {{ border-left-color: var(--running); }}
+.status-banner.status-unknown, .status-banner.status-stale {{ border-left-color: var(--unknown); }}
+.status-banner.status-disabled, .status-banner.status-not_applicable {{ border-left-color: var(--disabled); }}
+.banner-title {{
+  font-size: 17px;
+  font-weight: 650;
+}}
+.banner-summary {{
+  color: var(--muted);
+  font-size: 14px;
+}}
+.panel {{
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  margin-top: 16px;
+  overflow: hidden;
+}}
+.panel-heading {{
+  background: var(--header);
+  border-bottom: 1px solid var(--line);
+  padding: 12px 16px;
+  font-weight: 650;
+}}
+.component-list {{ width: 100%; }}
+.component-row {{
+  border-bottom: 1px solid var(--line);
+  cursor: pointer;
+}}
+.component-row:hover {{
+  background: #fbfcfb;
+}}
+.component-row:focus {{
+  outline: 2px solid var(--brand);
+  outline-offset: -2px;
+}}
+.component-row:last-child {{ border-bottom: 0; }}
+.component-row td {{
+  padding: 12px 16px;
+  vertical-align: middle;
+}}
+.component-row td:first-child {{
+  width: 24%;
+  white-space: nowrap;
+}}
+.component-row td:nth-child(2) {{
+  width: 120px;
+  white-space: nowrap;
+}}
+.component-row strong {{
+  margin-left: 10px;
+}}
+.summary-metrics {{
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}}
+.branch-summary {{
+  white-space: nowrap;
+}}
+.summary-chip {{
+  display: inline-block;
+  border-radius: 4px;
+  padding: 1px 5px;
+  margin: 1px 2px 1px 0;
+  font-size: 13px;
+  line-height: 1.35;
+}}
+.chip-success {{
+  color: var(--success);
+  background: #f0fff4;
+}}
+.chip-failure {{
+  color: var(--failure);
+  background: #fff5f6;
+}}
+.chip-in_progress {{
+  color: var(--running);
+  background: #f0f7ff;
+}}
+.chip-unknown, .chip-stale {{
+  color: var(--unknown);
+  background: #fff8e5;
+}}
+.branch-progress {{
+  display: flex;
+  height: 5px;
+  width: 170px;
+  flex: 0 0 170px;
+  overflow: hidden;
+  border-radius: 999px;
+  background: #eef2f1;
+  opacity: 0.72;
+}}
+.progress-segment {{
+  min-width: 2px;
+}}
+.segment-success {{
+  background: #3f9d61;
+}}
+.segment-failure {{
+  background: #d8757d;
+}}
+.segment-in_progress {{
+  background: #70a7db;
+}}
+.segment-unknown, .segment-stale {{
+  background: #c8a24b;
+}}
+.status-dot {{
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--unknown);
+  display: inline-block;
+}}
+.status-success .status-dot {{ background: var(--success); }}
+.status-failure .status-dot {{ background: var(--failure); }}
+.status-in_progress .status-dot {{ background: var(--running); }}
+.status-unknown .status-dot, .status-stale .status-dot {{ background: var(--unknown); }}
+.status-disabled .status-dot, .status-not_applicable .status-dot {{ background: var(--disabled); }}
+.status-pill {{
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  width: max-content;
+  border: 1px solid var(--line);
+  border-radius: 999px;
+  padding: 2px 9px;
+  color: var(--muted);
+  background: #ffffff;
+  font-size: 12px;
+  font-weight: 650;
+  white-space: nowrap;
+}}
+.status-success .status-pill {{ color: var(--success); border-color: #b7dfc1; background: #f0fff4; }}
+.status-failure .status-pill {{ color: var(--failure); border-color: #ffc9cf; background: #fff5f6; }}
+.status-in_progress .status-pill {{ color: var(--running); border-color: #bfdbfe; background: #f0f7ff; }}
+.status-unknown .status-pill, .status-stale .status-pill {{ color: var(--unknown); border-color: #f1d08a; background: #fff8e5; }}
+tr.status-success .status-pill {{ color: var(--success); border-color: #b7dfc1; background: #f0fff4; }}
+tr.status-failure .status-pill {{ color: var(--failure); border-color: #ffc9cf; background: #fff5f6; }}
+tr.status-in_progress .status-pill {{ color: var(--running); border-color: #bfdbfe; background: #f0f7ff; }}
+tr.status-unknown .status-pill, tr.status-stale .status-pill {{ color: var(--unknown); border-color: #f1d08a; background: #fff8e5; }}
+.detail-section {{
+  background: var(--panel);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  margin-top: 14px;
+  scroll-margin-top: 18px;
+}}
+.detail-section summary {{
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  list-style: none;
+  padding: 12px 16px;
+  font-weight: 650;
+}}
+.detail-section summary::-webkit-details-marker {{ display: none; }}
+.disclosure-mark {{
+  margin-left: auto;
+  color: var(--muted);
+  font-size: 18px;
+  line-height: 1;
+}}
+.detail-section[open] .disclosure-mark::before {{ content: "⌃"; }}
+.detail-section:not([open]) .disclosure-mark::before {{ content: "⌄"; }}
+.table-wrap {{ overflow-x: auto; border-top: 1px solid var(--line); }}
+table {{
+  border-collapse: collapse;
+  width: 100%;
+  font-size: 13px;
+}}
+.check-table {{
+  min-width: 940px;
+  table-layout: fixed;
+}}
+.check-table th:nth-child(1), .check-table td:nth-child(1) {{
+  width: 180px;
+}}
+.check-table th:nth-child(2), .check-table td:nth-child(2) {{
+  width: 140px;
+}}
+.check-table th:nth-child(3), .check-table td:nth-child(3) {{
+  width: 220px;
+}}
+.check-table th:nth-child(4), .check-table td:nth-child(4) {{
+  width: 80px;
+}}
+th, td {{
+  border-bottom: 1px solid var(--line);
+  padding: 9px 12px;
+  text-align: left;
+  vertical-align: top;
+}}
+tr:last-child td {{ border-bottom: 0; }}
+th {{
+  background: var(--header);
+  color: var(--muted);
+  font-weight: 650;
+}}
+.check-name {{ font-weight: 600; }}
+.previous-note {{
+  display: block;
+  margin-top: 4px;
+  color: var(--muted);
+  font-size: 12px;
+  white-space: nowrap;
+}}
+.revision-link {{
+  white-space: normal;
+  overflow-wrap: anywhere;
+}}
+.revision-cell {{
+  max-width: 210px;
+}}
+.age-cell {{
+  white-space: nowrap;
+}}
+.message-cell {{
+  min-width: 260px;
+}}
+.terminal-sep {{
+  display: none;
+}}
+.terminal-pad {{
+  display: none;
+}}
+time {{
+  white-space: nowrap;
+}}
+a {{ color: var(--brand); text-decoration-thickness: 1px; text-underline-offset: 2px; }}
+a:hover {{ color: var(--brand-strong); }}
+.footer {{
+  margin-top: 18px;
+  color: var(--muted);
+  font-size: 13px;
+}}
+ at media (max-width: 700px) {{
+  .page {{ padding: 24px 14px 32px; }}
+  .masthead {{ display: block; }}
+  h1 {{ font-size: 24px; }}
+  .component-row td {{ padding: 10px 12px; }}
+  .status-banner {{ align-items: flex-start; }}
+}}
+</style>
+</head>
+<body>
+<main class="page">
+<header class="masthead">
+<div>
+<p class="brand">PostGIS</p>
+<h1>CI status</h1>
+<p class="generated">Generated {generated} · <a class="raw-json-link" href="status.json">Raw JSON</a></p>
+</div>
+</header>
+<section class="status-banner status-{html.escape(page_status)}" aria-label="Overall CI status">
+<span class="status-dot" aria-hidden="true"></span>
+<div>
+<div class="banner-title"><span aria-hidden="true">{html.escape(status_mark(page_status))}</span> {html.escape(html_status_label(page_status))}</div>
+<div class="banner-summary">{page_summary}</div>
+</div>
+</section>
+<section class="panel" aria-label="Supported branch status">
+<div class="panel-heading">Supported branches</div>
+<table class="component-list"><thead><tr><th>Branch</th><th><span class="terminal-sep"> | </span>Status</th><th><span class="terminal-sep"> | </span>Summary</th></tr></thead><tbody>
+{''.join(rows)}
+</tbody></table>
+</section>
+{''.join(details)}
+</main>
+</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 load_config(path):
+    try:
+        with open(path, "r", encoding="utf-8") as handle:
+            config = json.load(handle)
+    except OSError as exc:
+        raise ConfigError(f"cannot read configuration {path}: {exc}") from exc
+    except json.JSONDecodeError as exc:
+        raise ConfigError(f"invalid JSON in configuration {path}: {exc}") from exc
+    if "branches" not in config or "checks" not in config:
+        raise ConfigError("configuration must contain branches and checks")
+    return config
+
+
+def positive_int(value):
+    try:
+        parsed = int(value)
+    except ValueError as exc:
+        raise argparse.ArgumentTypeError("must be an integer") from exc
+    if parsed <= 0:
+        raise argparse.ArgumentTypeError("must be greater than 0")
+    return parsed
+
+
+def parse_args(argv):
+    parser = argparse.ArgumentParser(description="Report PostGIS CI status")
+    parser.add_argument("legacy_mode", nargs="?", choices=("html",), help=argparse.SUPPRESS)
+    parser.add_argument("--branch", help="check one branch name or label")
+    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("--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")
+    parser.add_argument("--include-eol", action="store_true", help="include configured EOL branches")
+    parser.add_argument("--timeout", type=positive_int, default=30, help="per-request timeout in seconds")
+    args = parser.parse_args(argv)
+    if args.legacy_mode and args.format != "terminal":
+        parser.error("legacy html mode cannot be combined with --format")
+    if args.json and args.format != "terminal":
+        parser.error("--json cannot be combined with --format")
+    if args.legacy_mode:
+        args.format = "html"
+    if args.json:
+        args.format = "json"
+    return args
+
+
+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)
+        if args.format == "html":
+            write_html_output(data, args.output_dir)
+            return 0
+        if args.format == "json":
+            print(json.dumps(data, indent=2, sort_keys=True))
+        else:
+            use_color = not args.no_color and sys.stdout.isatty()
+            print_terminal(data, use_color=use_color, verbose=args.verbose)
+        return exit_code_for_terminal(data)
+    except ConfigError as exc:
+        print(f"ci-status: {exc}", file=sys.stderr)
+        return 3
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

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

Summary of changes:
 NEWS                 |    2 +
 utils/README         |   36 +
 utils/ci-status.json |  197 ++++++
 utils/ci-status.py   | 1768 ++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 2003 insertions(+)
 create mode 100644 utils/ci-status.json
 create mode 100755 utils/ci-status.py


hooks/post-receive
-- 
PostGIS


More information about the postgis-tickets mailing list