diff --git a/.github/workflows/research-feed-publisher-tests.yml b/.github/workflows/research-feed-publisher-tests.yml new file mode 100644 index 000000000..7d71c2fcf --- /dev/null +++ b/.github/workflows/research-feed-publisher-tests.yml @@ -0,0 +1,25 @@ +name: Research feed publisher tests +on: + pull_request: + paths: + - 'scripts/package/publish_research_feed.py' + - 'scripts/tests/test_publish_research_feed.py' + - '.github/workflows/research-feed-publisher-tests.yml' + push: + branches: [master] + paths: + - 'scripts/package/publish_research_feed.py' + - 'scripts/tests/test_publish_research_feed.py' + - '.github/workflows/research-feed-publisher-tests.yml' +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.9' + - run: pip install pytest 'semver>=3,<4' + - run: python3 -m pytest scripts/tests/test_publish_research_feed.py -q diff --git a/scripts/package/RESEARCH_FEED.md b/scripts/package/RESEARCH_FEED.md new file mode 100644 index 000000000..1e2e9417c --- /dev/null +++ b/scripts/package/RESEARCH_FEED.md @@ -0,0 +1,98 @@ +# Research updater feed publisher + +`publish_research_feed.py` prepares an ordinary commit of +`latest-research.json` on the `research-updates` branch. It preserves branch +history and existing files. The default invocation changes only a new local +checkout. `--publish` explicitly enables the remote update. + +This command is manual. There is no release-published trigger. Before live +use, the release owner must verify research signing keys, bundle contents, +installed-app upgrades, and the disposition of older research clients that +still contain the standard updater endpoint/key. The publisher does not prove +those properties or enable an update channel in an installed client. + +## Prepare and review + +Requires Git, a configured Git author, and `uv` (the script declares its +`semver` dependency). Run from the ActivityWatch checkout, using a new output +directory for each attempt: + +```sh +REPO_ROOT=$(git rev-parse --show-toplevel) +TAG=v0.14.0b6-research +MANIFEST=/absolute/path/to/reviewed/latest-research.json +CANDIDATE=/absolute/path/to/new-feed-candidate + +# Read the current remote head. Use the literal 'absent' only if this +# succeeds and returns no matching ref (initial provisioning). +git ls-remote --refs https://github.com/ActivityWatch/activitywatch.git \ + refs/heads/research-updates +PARENT= + +uv run --script "$REPO_ROOT/scripts/package/publish_research_feed.py" \ + --tag "$TAG" --manifest "$MANIFEST" --expected-parent "$PARENT" \ + --work-dir "$CANDIDATE" + +git -C "$CANDIDATE" show HEAD +``` + +The supplied manifest must already use normalized SemVer, for example +`0.14.0-beta.6`. Tags and asset filenames retain their release spelling +(`v0.14.0b6-research` / `activitywatch-tauri-research-0.14.0b6-…`). +This publisher accepts stable and a/b/rc research tags, not development builds. +Build/app version normalization is owned by the release configuration work; +this command validates the tag/version association without modifying builds. + +Validation requires: + +- An unauthenticated GitHub API read of the exact published, immutable, + non-draft research release. +- All five release targets: macOS arm64/x86_64, Linux arm64/x86_64, and Windows + x86_64. A reduced platform rollout requires an explicit code review. +- A supported updater bundle and nonempty uploaded `.sig` asset for each + platform, under the exact selected release and research filename prefix. +- The manifest signature must equal the publicly downloaded `.sig` contents. + Cryptographic signature verification and embedded payload-version/edition + verification remain release-owner gates; this command does not download + the potentially large bundles. +- A valid static updater manifest, with RFC3339 publication date and string + notes. Unknown fields are refused to avoid alternate updater parsing paths. + +## Publish after release acceptance + +After reviewing the candidate and satisfying the release gates, rerun the +same invocation with a **new** work directory and `--publish`. It repeats all +release checks and compares the feed's current SemVer before committing. +Git's configured credentials authorize the push; public release reads do not +use those credentials. Keep the recorded parent unchanged; a stale parent is +an error, not permission to overwrite new work. + +An older version is refused. Equal SemVer precedence with different content +(including metadata/build-identifier changes) is refused. Identical parsed +manifest content is an explicit no-op. Formatting/key order differences alone +do not mint another commit. A changed release needs a higher version. + +The publisher checks the remote parent at fetch and in pre-push against Git's +advertised OID, then pushes normally without force. Git's receive-side old-OID +check closes the remaining race. The wrapper invokes any existing pre-push +hook with the original arguments/stdin and preserves its refusal. Concurrent +creation, advancement, deletion, or rewind requires a fresh invocation with a +reread parent; version ordering is evaluated again, so a late b5 job cannot +replace b6 even after retry. Failed candidates remain on disk for inspection. + +After a successful push, verify the public feed separately: +`https://raw.githubusercontent.com/ActivityWatch/activitywatch/research-updates/latest-research.json`. +A Git push alone does not prove CDN freshness or a successful installed update. +Standard release publication does not invoke this command or change this branch. + +## Tests + +```sh +uv run --with pytest --with 'semver>=3,<4' python3 -m pytest \ + "$REPO_ROOT/scripts/tests/test_publish_research_feed.py" -q +``` + +The tests use release fixtures and temporary local Git remotes. They cover +provisioning, advancement, replay, rollback refusal, racing publishers, +branch deletion/rewind, incomplete releases, metadata validation, and existing +push-hook enforcement. They never publish to GitHub. diff --git a/scripts/package/publish_research_feed.py b/scripts/package/publish_research_feed.py new file mode 100644 index 000000000..42226f2b0 --- /dev/null +++ b/scripts/package/publish_research_feed.py @@ -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() diff --git a/scripts/tests/test_publish_research_feed.py b/scripts/tests/test_publish_research_feed.py new file mode 100644 index 000000000..d6f2d5803 --- /dev/null +++ b/scripts/tests/test_publish_research_feed.py @@ -0,0 +1,401 @@ +"""Release fixtures and real local Git remotes; never contact the public feed.""" + +import importlib.util +import json +from pathlib import Path +import subprocess + +import pytest + +SCRIPT = Path(__file__).parents[1] / "package" / "publish_research_feed.py" +spec = importlib.util.spec_from_file_location("publish_research_feed", SCRIPT) +publisher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(publisher) + + +def fixture(beta=5): + tag = f"v0.14.0b{beta}-research" + base = f"https://github.com/ActivityWatch/activitywatch/releases/download/{tag}/" + manifest = { + "version": f"0.14.0-beta.{beta}", + "notes": tag, + "pub_date": "2026-09-09T00:00:00Z", + "platforms": {}, + } + release = { + "tag_name": tag, + "draft": False, + "immutable": True, + "published_at": "2026-09-09T00:00:00Z", + "assets": [], + } + for target, extensions in publisher.TARGETS.items(): + name = f"activitywatch-tauri-research-0.14.0b{beta}-{target}.{extensions[0]}" + manifest["platforms"][target] = { + "url": base + name, + "signature": "test-signature", + } + for asset in (name, name + ".sig"): + release["assets"].append( + { + "name": asset, + "state": "uploaded", + "size": 42, + "browser_download_url": base + asset, + } + ) + return manifest, release, tag + + +def validate(manifest, release, tag): + publisher.validate_manifest(manifest, release, tag, lambda _: b"test-signature\n") + + +def test_valid_research_release(): + validate(*fixture()) + + +@pytest.mark.parametrize( + "mutation", + [ + lambda m, r: m.update(pub_date="not a date"), + lambda m, r: m.update(pub_date="2026-09-09T00:00:00+00:99"), + lambda m, r: m.update(pub_date="2026-02-30T00:00:00Z"), + lambda m, r: m.update(pub_date="2026-09-09T00:00:00"), + lambda m, r: m.update(notes=123), + lambda m, r: m.update(name="0.14.0-beta.6"), + lambda m, r: m.update(url=123), + lambda m, r: m["platforms"]["darwin-aarch64"].update(extra="unexpected"), + lambda m, r: r.update(immutable=False), + lambda m, r: r.pop("immutable"), + lambda m, r: r.update(draft=True), + lambda m, r: r.update(draft="false"), + lambda m, r: r.update(published_at=None), + lambda m, r: r.update(tag_name="v0.14.0b4-research"), + lambda m, r: r["assets"].pop(0), # bundle absent + lambda m, r: r["assets"].pop(1), # signature absent + lambda m, r: r["assets"][0].update(size=0), + lambda m, r: r["assets"][0].update(state="new"), + lambda m, r: r["assets"][1].update( + browser_download_url="https://example.com/signature" + ), + lambda m, r: r["assets"].append(r["assets"][0]), + lambda m, r: m.update(version="0.14.0b5"), + lambda m, r: m.update(version="0.14.0-beta.6"), + lambda m, r: m["platforms"].pop("darwin-x86_64"), + lambda m, r: m["platforms"].update({"unsupported-platform": {}}), + lambda m, r: m["platforms"]["darwin-aarch64"].update(signature=""), + lambda m, r: m["platforms"]["darwin-aarch64"].update(signature="wrong"), + lambda m, r: m.update(platforms=list(m["platforms"])), + ], +) +def test_invalid_release_or_manifest(mutation): + manifest, release, tag = fixture() + mutation(manifest, release) + with pytest.raises(ValueError): + validate(manifest, release, tag) + + +@pytest.mark.parametrize( + "replacement", + [ + ("v0.14.0b5-research/", "v0.14.0b4-research/"), + ("v0.14.0b5-research/", "v0.14.0b5/"), + ("activitywatch-tauri-research-", "activitywatch-tauri-"), + ("research-0.14.0b5-", "research-0.14.0b4-"), + (".app.tar.gz", ".dmg"), + ("https://github.com/", "https://example.com/"), + ], +) +def test_wrong_edition_release_version_or_extension(replacement): + manifest, release, tag = fixture() + entry = manifest["platforms"]["darwin-aarch64"] + entry["url"] = entry["url"].replace(*replacement) + # Even if metadata claims this asset exists, it is not an allowed URL. + release["assets"][0]["browser_download_url"] = entry["url"] + with pytest.raises(ValueError): + validate(manifest, release, tag) + + +@pytest.mark.parametrize( + "tag", + [ + "v0.14.0b5", + "latest-research", + "v0.14.0b05-research", + "v0.14.0b5.dev-abcdef-research", + "v0.14.0b5-research/other", + ], +) +def test_invalid_release_tag(tag): + manifest, release, _ = fixture() + with pytest.raises(ValueError): + validate(manifest, release, tag) + + +@pytest.fixture +def remote(tmp_path, monkeypatch): + config = tmp_path / "gitconfig" + config.write_text("[user]\nname = Publisher test\nemail = test@example.invalid\n") + monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(config)) + monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1") + # Isolate test repositories from any enclosing harness Git invocation. + for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"): + monkeypatch.delenv(name, raising=False) + remote = tmp_path / "remote.git" + remote.mkdir() + publisher.git(remote, "init", "--bare", "--quiet") + return str(remote) + + +def candidate(tmp_path, remote, name, parent="absent", beta=5): + repo = tmp_path / name + manifest = fixture(beta)[0] + commit = publisher.prepare(repo, remote, parent, manifest) + return repo, commit + + +def test_initial_preparation_does_not_publish(tmp_path, remote): + repo, commit = candidate(tmp_path, remote, "candidate") + assert publisher.remote_parent(repo, remote) == "absent" + assert publisher.git(repo, "rev-list", "--parents", "-n", "1", commit) == commit + assert json.loads((repo / publisher.MANIFEST).read_text()) == fixture()[0] + publisher.publish(repo, remote, "absent", commit) + assert publisher.remote_parent(repo, remote) == commit + + +def test_advance_preserves_history_and_unrelated_files(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "first") + (repo / "README.md").write_text("Feed history\n") + publisher.git(repo, "add", "README.md") + publisher.git(repo, "commit", "-m", "Document feed") + parent = publisher.git(repo, "rev-parse", "HEAD") + publisher.git(repo, "push", remote, f"{parent}:{publisher.REF}") + new_repo, second = candidate(tmp_path, remote, "second", parent, 6) + publisher.publish(new_repo, remote, parent, second) + assert publisher.git(new_repo, "rev-parse", f"{second}^") == parent + assert publisher.git(new_repo, "rev-parse", f"{second}^^") == first + assert (new_repo / "README.md").read_text() == "Feed history\n" + + +def test_replay_is_noop_and_conflicting_equal_version_rejected(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "first") + publisher.publish(repo, remote, "absent", first) + _, repeated = candidate(tmp_path, remote, "replay", first) + assert repeated is None + different = fixture()[0] + different["notes"] = "changed" + with pytest.raises(ValueError, match="Conflicting"): + publisher.prepare(tmp_path / "conflict", remote, first, different) + assert publisher.remote_parent(repo, remote) == first + + +def test_late_b5_cannot_replace_b6(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "b6", beta=6) + publisher.publish(repo, remote, "absent", first) + with pytest.raises(ValueError, match="regression"): + candidate(tmp_path, remote, "late-b5", first, 5) + + +def test_numeric_semver_order(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "b9", beta=9) + publisher.publish(repo, remote, "absent", first) + new_repo, second = candidate(tmp_path, remote, "b10", first, 10) + publisher.publish(new_repo, remote, first, second) + assert publisher.remote_parent(repo, remote) == second + + +@pytest.mark.parametrize("provision", [False, True]) +def test_same_parent_race_and_retry_revalidates_order(tmp_path, remote, provision): + parent = "absent" + if not provision: + repo, parent = candidate(tmp_path, remote, "seed", beta=4) + publisher.publish(repo, remote, "absent", parent) + slow_repo, slow = candidate(tmp_path, remote, "slow", parent, 5) + fast_repo, fast = candidate(tmp_path, remote, "fast", parent, 6) + publisher.publish(fast_repo, remote, parent, fast) + with pytest.raises(subprocess.CalledProcessError): + publisher.publish(slow_repo, remote, parent, slow) + assert publisher.remote_parent(fast_repo, remote) == fast + with pytest.raises(ValueError, match="Stale"): + candidate(tmp_path, remote, "stale", parent, 5) + with pytest.raises(ValueError, match="regression"): + candidate(tmp_path, remote, "retry", fast, 5) + + +def test_deleted_parent_cannot_be_silently_recreated(tmp_path, remote): + repo, parent = candidate(tmp_path, remote, "seed") + publisher.publish(repo, remote, "absent", parent) + new_repo, second = candidate(tmp_path, remote, "second", parent, 6) + # Simulate an administrator's intervening ref deletion, not publisher behavior. + publisher.git(Path(remote), "update-ref", "-d", publisher.REF) + with pytest.raises(subprocess.CalledProcessError, match="returned non-zero"): + publisher.publish(new_repo, remote, parent, second) + assert publisher.remote_parent(repo, remote) == "absent" + + +def test_rewound_parent_is_rejected_even_when_push_would_fast_forward(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "first", beta=4) + publisher.publish(repo, remote, "absent", first) + second_repo, second = candidate(tmp_path, remote, "second", first, 5) + publisher.publish(second_repo, remote, first, second) + third_repo, third = candidate(tmp_path, remote, "third", second, 6) + publisher.git(Path(remote), "update-ref", publisher.REF, first) + with pytest.raises(subprocess.CalledProcessError): + publisher.publish(third_repo, remote, second, third) + assert publisher.remote_parent(repo, remote) == first + + +def test_existing_pre_push_hook_is_preserved(tmp_path, remote): + repo, commit = candidate(tmp_path, remote, "candidate") + hooks = tmp_path / "hooks" + hooks.mkdir() + sentinel = tmp_path / "hook-ran" + hook = hooks / "pre-push" + hook.write_text(f'#!/bin/sh\ncat > "{sentinel}"\nexit 1\n') + hook.chmod(0o755) + publisher.git(repo, "config", "core.hooksPath", str(hooks)) + with pytest.raises(subprocess.CalledProcessError): + publisher.publish(repo, remote, "absent", commit) + assert commit in sentinel.read_text() + assert publisher.remote_parent(repo, remote) == "absent" + + +def test_prepare_rejects_existing_workdir(tmp_path, remote): + repo = tmp_path / "owned" + repo.mkdir() + (repo / "precious").write_text("keep") + with pytest.raises(FileExistsError): + publisher.prepare(repo, remote, "absent", fixture()[0]) + assert (repo / "precious").read_text() == "keep" + + +def test_cli_validates_before_prepare(tmp_path, monkeypatch): + manifest, release, tag = fixture() + manifest["platforms"].pop("linux-aarch64") + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + monkeypatch.setattr( + publisher, "public_bytes", lambda _: json.dumps(release).encode() + ) + work = tmp_path / "candidate" + with pytest.raises(SystemExit) as error: + publisher.main( + [ + "--manifest", + str(path), + "--tag", + tag, + "--expected-parent", + "absent", + "--work-dir", + str(work), + ] + ) + assert error.value.code == 1 + assert not work.exists() + + +def test_receive_side_rejects_race_after_parent_was_advertised(tmp_path, remote): + repo, first = candidate(tmp_path, remote, "seed", beta=4) + publisher.publish(repo, remote, "absent", first) + slow_repo, slow = candidate(tmp_path, remote, "slow", first, 5) + fast_repo, fast = candidate(tmp_path, remote, "fast", first, 6) + # Put the competing commit object on the remote without moving the feed yet. + publisher.git(fast_repo, "push", remote, f"{fast}:refs/heads/competing") + hooks = tmp_path / "race-hooks" + hooks.mkdir() + hook = hooks / "pre-push" + hook.write_text( + f'#!/bin/sh\ngit -C "{remote}" update-ref {publisher.REF} {fast} {first}\n' + ) + hook.chmod(0o755) + publisher.git(slow_repo, "config", "core.hooksPath", str(hooks)) + with pytest.raises(subprocess.CalledProcessError): + publisher.publish(slow_repo, remote, first, slow) + assert publisher.remote_parent(repo, remote) == fast + + +@pytest.mark.parametrize("do_publish", [False, True]) +def test_cli_public_checks_and_explicit_publish_flag( + tmp_path, remote, monkeypatch, capsys, do_publish +): + manifest, release, tag = fixture() + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + urls = [] + + def public(url): + urls.append(url) + return ( + json.dumps(release).encode() + if url.startswith("https://api.github.com/") + else b"test-signature\n" + ) + + real_git = publisher.git + + def local_git(repo, *args, **kwargs): + return real_git( + repo, + *( + remote + if arg == "https://github.com/ActivityWatch/activitywatch.git" + else arg + for arg in args + ), + **kwargs, + ) + + monkeypatch.setattr(publisher, "public_bytes", public) + monkeypatch.setattr(publisher, "git", local_git) + work = tmp_path / "candidate" + args = [ + "--manifest", + str(path), + "--tag", + tag, + "--expected-parent", + "absent", + "--work-dir", + str(work), + ] + if do_publish: + args.append("--publish") + publisher.main(args) + result = capsys.readouterr().out + assert result.startswith("Published" if do_publish else "Prepared") + assert len(urls) == 6 # public release metadata and all five public signatures + assert (publisher.remote_parent(work, remote) != "absent") == do_publish + + +def test_failed_public_signature_read_leaves_no_candidate( + tmp_path, remote, monkeypatch +): + manifest, release, tag = fixture() + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + + def public(url): + if url.startswith("https://api.github.com/"): + return json.dumps(release).encode() + raise OSError("signature HTTP 404") + + monkeypatch.setattr(publisher, "public_bytes", public) + work = tmp_path / "candidate" + with pytest.raises(SystemExit) as error: + publisher.main( + [ + "--manifest", + str(path), + "--tag", + tag, + "--expected-parent", + "absent", + "--work-dir", + str(work), + "--publish", + ] + ) + assert error.value.code == 1 + assert not work.exists()