From 6ec742463a44e70413a8da81f2f2a966ec8c2d0e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 15:36:22 -0700 Subject: [PATCH] Watch the upstream web UI for new versions The console no longer merges upstream, but new upstream versions are still read for features worth writing here. A daily job (06:47 UTC, or by hand) opens a "Review upstream web UI vX" issue for each version tagged since the fork point, v1.0.11, compared with the one before it. The issue lists the changed files grouped by license header, so a reader knows which may be opened: Enterprise-only files and files with Enterprise snippets are named but marked never open. Only metadata is read: tags and the compare from GitHub's API, and each changed file's license lines. No upstream code goes into the issue, only paths, change counts and licenses. Tags rather than releases, since upstream has tagged versions before releasing them. --- .gitea/workflows/upstream-watch.yml | 152 ++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .gitea/workflows/upstream-watch.yml diff --git a/.gitea/workflows/upstream-watch.yml b/.gitea/workflows/upstream-watch.yml new file mode 100644 index 0000000..da6e215 --- /dev/null +++ b/.gitea/workflows/upstream-watch.yml @@ -0,0 +1,152 @@ +# Watch the upstream web UI for new versions, and open an issue for each one +# so it can be read for features worth writing here. +# +# The console no longer merges upstream (README › Upstream): later versions +# include code under a proprietary license. So the issue lists what changed, +# file by file, grouped by each file's license header, for a reader to know +# which files may be opened at all. Enterprise-only files, and files holding +# Enterprise snippets, are named but must never be opened. +# +# Reads metadata only: the tag list and the compare between two tags from +# GitHub's API. Each changed file is fetched just to read its license header +# lines; nothing of upstream's code is kept, printed, or put in the issue, only +# paths, change counts and licenses. Titles and bodies stay free of the +# upstream project's name, as the rest of the fork's user-visible text does. +# +# Each version since the fork point (v1.0.11) gets one issue, compared with +# the version before it. An existing issue with the same title, open or +# closed, stops a second. Tags are watched rather than releases: upstream has +# tagged versions before publishing them as releases. +# +# Daily 06:47 UTC; run it by hand with workflow_dispatch. DRY_RUN=1 prints +# the issues instead of opening them. +name: upstream-watch + +on: + schedule: + - cron: '47 6 * * *' + workflow_dispatch: + +concurrency: + group: upstream-watch + cancel-in-progress: false + +jobs: + upstream-watch: + runs-on: light + container: + image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + steps: + - shell: bash + run: | + python3 - <<'PY' + import json, os, re, sys, urllib.parse, urllib.request + + FORK_BASE = "v1.0.11" + UPSTREAM = "stalwartlabs/webui" + DRY_RUN = os.environ.get("DRY_RUN") == "1" + + api = f"{os.environ.get('CI_SERVER_INTERNAL', '')}/api/v1/repos/{os.environ.get('REPO', '')}" + def call(method, url, body=None, token=None, raw=False): + headers = {"User-Agent": "inbuxa-admin-upstream-watch"} + if body is not None: + headers["Content-Type"] = "application/json" + if token: + headers["Authorization"] = f"token {token}" + req = urllib.request.Request(url, method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + with urllib.request.urlopen(req, timeout=30) as r: + return r.read() if raw else json.load(r) + SEMVER = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + def key(tag): + return tuple(int(x) for x in SEMVER.match(tag).groups()) + def plural(n, word): + return f"{n} {word}" if n == 1 else f"{n} {word}s" + + # Unauthenticated: a public repo, once a day, well inside the limit. + tags = call("GET", f"https://api.github.com/repos/{UPSTREAM}/tags?per_page=100") + newer = sorted((t["name"] for t in tags + if SEMVER.match(t["name"]) and key(t["name"]) > key(FORK_BASE)), key=key) + if not newer: + print(f"Nothing new: {FORK_BASE}, the fork point, is the newest version."); sys.exit(0) + + if DRY_RUN: + existing = set() + else: + existing = {i["title"] for i in call("GET", f"{api}/issues?state=all&type=issues" + "&q=Review+upstream+web+UI&limit=50", token=os.environ["TOKEN"])} + + SPDX = re.compile(r"SPDX-License-Identifier:\s*(.+?)\s*(?:\*/|-->)?\s*$", re.M) + def license_of(tag, path): + """One of: enterprise, snippets, dual, other:, none. Reads only the license lines.""" + url = f"https://raw.githubusercontent.com/{UPSTREAM}/{urllib.parse.quote(tag)}/{urllib.parse.quote(path)}" + text = call("GET", url, raw=True).decode("utf-8", "replace") + ids = SPDX.findall(text) + if not ids: + return "none" + head = ids[0] + if head == "LicenseRef-SEL": + return "enterprise" + if "SPDX-SnippetBegin" in text or "LicenseRef-SEL" in ids[1:]: + return "snippets" + if head == "AGPL-3.0-only OR LicenseRef-SEL": + return "dual" + return f"other:{head}" + + GROUPS = [ + ("enterprise", "Enterprise only: never open"), + ("snippets", "Dual, holding Enterprise snippets: never open"), + ("removed", "Removed"), + ("dual", "Dual (AGPL-3.0-only OR LicenseRef-SEL): may be read"), + ("none", "No license header: check before reading"), + ] + + prev = FORK_BASE + for tag in newer: + title = f"Review upstream web UI {tag}" + if title in existing: + print(f"{tag}: issue already exists."); prev = tag; continue + cmp = call("GET", f"https://api.github.com/repos/{UPSTREAM}/compare/{prev}...{tag}") + files = cmp.get("files", []) + groups = {} + for f in files: + if f["status"] == "removed": + group = "removed" + else: + group = license_of(tag, f["filename"]) + line = f"- `{f['filename']}` ({f['status']}, +{f['additions']} −{f['deletions']})" + if f["status"] == "renamed": + line += f", from `{f['previous_filename']}`" + groups.setdefault(group, []).append(line) + + date = cmp["commits"][-1]["commit"]["committer"]["date"][:10] if cmp.get("commits") else "?" + parts = [ + f"Upstream tagged the web UI {tag} ({date}): {plural(cmp.get('total_commits', 0), 'commit')} and " + f"{plural(len(files), 'changed file')} since {prev}. The console was forked at {FORK_BASE} and " + "no longer merges upstream (README › Upstream).", + "Read what changed and note here anything worth writing ourselves, in our own code. " + "Open only files marked *may be read*. Never open a file marked *never open*, " + "and don't hand one to anyone who writes console code.", + ] + if len(files) >= 300: + parts.append("**The compare stops at 300 files, so this list is incomplete.**") + labels = dict(GROUPS) + order = [g for g, _ in GROUPS] + sorted(g for g in groups if g not in labels) + for group in order: + if group not in groups: + continue + label = labels.get(group, f"Other license ({group[6:]}): check before reading") + parts.append(f"
{label}: {len(groups[group])}\n\n" + + "\n".join(sorted(groups[group])) + "\n\n
") + body = "\n\n".join(parts) + + if DRY_RUN: + print(f"=== {title}\n{body}\n") + else: + issue = call("POST", f"{api}/issues", {"title": title, "body": body}, token=os.environ["TOKEN"]) + print(f"{tag}: opened #{issue['number']}.") + prev = tag + PY -- 2.54.0