Files
upstream-activitywatch/scripts/tests/test_publish_research_feed.py
upstream-mirror c5c5af8cde
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
snapshot of https://github.com/ActivityWatch/activitywatch.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 22:29:06 +05:00

402 lines
15 KiB
Python

"""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()