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
402 lines
15 KiB
Python
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()
|