snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
Some checks failed
Research feed publisher tests / test (push) Failing after 3s
Release / Pre-flight checks (push) Failing after 0s
Release / Research Edition — patch and packaging smoke tests (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been skipped
Release / Build Qt artifacts (false, 22, ubuntu-22.04, 3.9, false, false, false) (push) Has been skipped
Release / Build Qt artifacts (manylinux_2_28 — glibc 2.28 ABI floor) (22, 3.9) (push) Has been skipped
Release / Generate release notes (push) Has been skipped
Release / Create dev release tag (push) Has been skipped
CodeQL / Analyze (javascript) (push) Failing after 1s
CodeQL / Analyze (python) (push) Failing after 1s
Release / Build Qt artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/')) }}, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, ubuntu-24.04-arm, 3.9, false, false, false) (push) Has been cancelled
Release / Build Tauri artifacts (false, 22, windows-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-15-intel, 3.9, false, false, false) (push) Has been cancelled
Release / Build Qt artifacts (false, 22, macos-latest, 3.9, false, false, false) (push) Has been cancelled
Release / Publish draft release (push) Has been cancelled
This commit is contained in:
277
scripts/package/publish_research_feed.py
Normal file
277
scripts/package/publish_research_feed.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.9"
|
||||
# dependencies = ["semver>=3,<4"]
|
||||
# ///
|
||||
"""Validate and prepare a research feed commit; publish only with --publish."""
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from semver import Version
|
||||
|
||||
REPOSITORY = "ActivityWatch/activitywatch"
|
||||
REF = "refs/heads/research-updates"
|
||||
MANIFEST = "latest-research.json"
|
||||
# Keep in step with build-tauri's release matrix. A reduced rollout needs review.
|
||||
TARGETS = {
|
||||
"darwin-aarch64": ("app.tar.gz",),
|
||||
"darwin-x86_64": ("app.tar.gz",),
|
||||
"linux-aarch64": ("AppImage", "AppImage.tar.gz"),
|
||||
"linux-x86_64": ("AppImage", "AppImage.tar.gz"),
|
||||
"windows-x86_64": ("exe", "nsis.zip", "msi", "msi.zip"),
|
||||
}
|
||||
|
||||
|
||||
def public_bytes(url):
|
||||
"""Unauthenticated reads prove the release/signature is publicly available."""
|
||||
request = Request(url, headers={"User-Agent": "ActivityWatch-research-publisher"})
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def validate_manifest(manifest, release, tag, fetch=None):
|
||||
if fetch is None:
|
||||
fetch = public_bytes
|
||||
if set(manifest) != {"version", "notes", "pub_date", "platforms"}:
|
||||
raise ValueError("Expected only the four static updater manifest fields")
|
||||
if not isinstance(manifest["notes"], str):
|
||||
raise ValueError("Manifest notes must be a string")
|
||||
date = manifest["pub_date"]
|
||||
if not isinstance(date, str) or not re.fullmatch(
|
||||
r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])",
|
||||
date,
|
||||
):
|
||||
raise ValueError("Manifest pub_date must be an RFC3339 timestamp")
|
||||
datetime.fromisoformat(date.replace("Z", "+00:00"))
|
||||
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?-research", tag):
|
||||
raise ValueError("Expected a versioned research release tag")
|
||||
raw_version = tag[1 : -len("-research")]
|
||||
expected_version = re.sub(
|
||||
r"(a|b|rc)([0-9]+)$",
|
||||
lambda m: "-" + {"a": "alpha", "b": "beta", "rc": "rc"}[m[1]] + "." + m[2],
|
||||
raw_version,
|
||||
)
|
||||
Version.parse(expected_version)
|
||||
if manifest["version"] != expected_version:
|
||||
raise ValueError("Manifest version must match the normalized research tag")
|
||||
if (
|
||||
release["tag_name"] != tag
|
||||
or release["draft"] is not False
|
||||
or not release["published_at"]
|
||||
or release.get("immutable") is not True
|
||||
):
|
||||
raise ValueError(
|
||||
"Release must be published, immutable, non-draft, and match the tag"
|
||||
)
|
||||
if not isinstance(manifest["platforms"], dict) or set(manifest["platforms"]) != set(
|
||||
TARGETS
|
||||
):
|
||||
raise ValueError(
|
||||
"Manifest must contain exactly the complete required target matrix"
|
||||
)
|
||||
assets = {asset["name"]: asset for asset in release["assets"]}
|
||||
if len(assets) != len(release["assets"]):
|
||||
raise ValueError("Duplicate release asset names")
|
||||
base_url = f"https://github.com/{REPOSITORY}/releases/download/{tag}/"
|
||||
for target, entry in manifest["platforms"].items():
|
||||
if set(entry) != {"url", "signature"}:
|
||||
raise ValueError(f"Expected only url and signature for {target}")
|
||||
prefix = f"activitywatch-tauri-research-{raw_version}-{target}."
|
||||
names = [prefix + ext for ext in TARGETS[target]]
|
||||
matches = [name for name in names if entry["url"] == base_url + name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(
|
||||
f"Wrong edition, version, release, or bundle format for {target}"
|
||||
)
|
||||
name = matches[0]
|
||||
for asset_name in (name, name + ".sig"):
|
||||
asset = assets.get(asset_name)
|
||||
if (
|
||||
not asset
|
||||
or asset["state"] != "uploaded"
|
||||
or asset["size"] <= 0
|
||||
or asset["browser_download_url"] != base_url + asset_name
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing or invalid uploaded release asset: {asset_name}"
|
||||
)
|
||||
signature = entry["signature"]
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or not signature.strip()
|
||||
or signature != fetch(base_url + name + ".sig").decode("utf-8").strip()
|
||||
):
|
||||
raise ValueError(f"Missing or mismatched signature for {target}")
|
||||
|
||||
|
||||
def git(repo, *args, input=None):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
input=input,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def remote_parent(repo, remote):
|
||||
result = git(repo, "ls-remote", "--refs", remote, REF)
|
||||
return result.split()[0] if result else "absent"
|
||||
|
||||
|
||||
def prepare(repo, remote, expected_parent, manifest):
|
||||
"""Create a new isolated checkout, retaining all existing feed branch files."""
|
||||
repo.mkdir(parents=True, exist_ok=False)
|
||||
git(repo, "init", "--quiet")
|
||||
if remote_parent(repo, remote) != expected_parent:
|
||||
raise ValueError("Stale expected parent; reread the feed and retry validation")
|
||||
if expected_parent != "absent":
|
||||
git(repo, "fetch", "--no-tags", remote, REF)
|
||||
if git(repo, "rev-parse", "FETCH_HEAD") != expected_parent:
|
||||
raise ValueError("Feed changed during fetch; reread and retry")
|
||||
git(repo, "checkout", "--detach", expected_parent)
|
||||
current = json.loads(git(repo, "show", f"{expected_parent}:{MANIFEST}"))
|
||||
old, new = Version.parse(current["version"]), Version.parse(manifest["version"])
|
||||
if new < old:
|
||||
raise ValueError("Refusing research feed version regression")
|
||||
if new == old:
|
||||
if manifest != current:
|
||||
raise ValueError("Conflicting content at equal SemVer precedence")
|
||||
return None
|
||||
else:
|
||||
git(repo, "checkout", "--orphan", "research-updates")
|
||||
# Replace any old symlink rather than following a path outside this checkout.
|
||||
path = repo / MANIFEST
|
||||
if path.is_symlink():
|
||||
path.unlink()
|
||||
path.write_text(
|
||||
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
git(repo, "add", "--", MANIFEST)
|
||||
git(
|
||||
repo,
|
||||
"commit",
|
||||
"-m",
|
||||
f"chore(updater): offer research {manifest['version']}",
|
||||
"--",
|
||||
MANIFEST,
|
||||
)
|
||||
return git(repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
def publish(repo, remote, expected_parent, commit):
|
||||
"""Assert the advertised parent in pre-push, then use Git's normal FF/CAS."""
|
||||
if git(repo, "rev-list", "--parents", "-n", "1", commit).split() != (
|
||||
[commit] if expected_parent == "absent" else [commit, expected_parent]
|
||||
):
|
||||
raise ValueError("Candidate must be one commit on the expected parent")
|
||||
original_hook = Path(git(repo, "rev-parse", "--git-path", "hooks/pre-push"))
|
||||
if not original_hook.is_absolute():
|
||||
original_hook = repo / original_hook
|
||||
expected_oid = "0" * len(commit) if expected_parent == "absent" else expected_parent
|
||||
# Git sends the same advertised old OID to receive-pack, which checks it
|
||||
# atomically. Checking only ls-remote before push would leave a race window
|
||||
# for branch deletion/rewind. Preserve the site's existing pre-push policy.
|
||||
with tempfile.TemporaryDirectory(prefix="aw-feed-push-") as directory:
|
||||
hook = Path(directory) / "pre-push"
|
||||
hook.write_text(
|
||||
"#!/bin/sh\nexec "
|
||||
+ shlex.quote(sys.executable)
|
||||
+ " -c "
|
||||
+ shlex.quote(
|
||||
"import os, subprocess, sys\n"
|
||||
"data = sys.stdin.read()\n"
|
||||
"rows = [line.split() for line in data.splitlines()]\n"
|
||||
f"expected = {expected_oid!r}\n"
|
||||
f"if len(rows) != 1 or rows[0][1:] != [{commit!r}, {REF!r}, expected]:\n"
|
||||
" sys.exit('Feed parent changed during push; reread and retry')\n"
|
||||
f"hook = {str(original_hook)!r}\n"
|
||||
"if os.access(hook, os.X_OK):\n"
|
||||
" sys.exit(subprocess.run([hook, *sys.argv[1:]], input=data, text=True).returncode)\n"
|
||||
)
|
||||
+ ' "$@"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
hook.chmod(0o755)
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
f"core.hooksPath={directory}",
|
||||
"-c",
|
||||
"push.followTags=false",
|
||||
"push",
|
||||
"--porcelain",
|
||||
remote,
|
||||
f"{commit}:{REF}",
|
||||
)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--tag", required=True)
|
||||
parser.add_argument(
|
||||
"--expected-parent",
|
||||
required=True,
|
||||
help="Full feed SHA, or 'absent' for provisioning",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="New directory to retain the reviewable commit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--publish",
|
||||
action="store_true",
|
||||
help="Explicitly push after validation and preparation",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
remote = f"https://github.com/{REPOSITORY}.git"
|
||||
try:
|
||||
if args.expected_parent != "absent" and not re.fullmatch(
|
||||
r"[0-9a-f]{40}", args.expected_parent
|
||||
):
|
||||
raise ValueError("Expected a full parent SHA or 'absent'")
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||
release = json.loads(
|
||||
public_bytes(
|
||||
f"https://api.github.com/repos/{REPOSITORY}/releases/tags/{quote(args.tag, safe='')}"
|
||||
)
|
||||
)
|
||||
validate_manifest(manifest, release, args.tag)
|
||||
repo = args.work_dir.resolve()
|
||||
commit = prepare(repo, remote, args.expected_parent, manifest)
|
||||
if commit is None:
|
||||
print("NO-OP: identical manifest already published")
|
||||
elif args.publish:
|
||||
publish(repo, remote, args.expected_parent, commit)
|
||||
print(f"Published {commit} on {REF}")
|
||||
else:
|
||||
print(f"Prepared {commit} in {repo}; remote unchanged (no --publish)")
|
||||
except (
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
OSError,
|
||||
subprocess.CalledProcessError,
|
||||
) as exc:
|
||||
detail = (
|
||||
exc.stderr if isinstance(exc, subprocess.CalledProcessError) else str(exc)
|
||||
)
|
||||
parser.exit(1, f"ERROR: {detail}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user