Files
voculinux/tests/test_verify_release.py
T
Sergey SilaevandGitHub 923bc42708 fix(ci): verify a release whose run failed after publishing (#842)
* fix(ci): verify a release whose run failed after publishing

* fix(ci): only forgive a confirmed 404 when verifying a published release
2026-09-20 13:03:23 -07:00

328 lines
12 KiB
Python

"""Guards for scripts/verify_release.py.
Only the comparisons that carry real logic, plus the one property of the
workflow that is worth pinning. Asserting that the yml says what the yml says
is the antipattern verify_release.py exists to work around.
"""
import importlib
import importlib.util
import json
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "verify-release.yml"
SCRIPT = REPO_ROOT / "scripts" / "verify_release.py"
_spec = importlib.util.spec_from_file_location("verify_release", SCRIPT)
verify = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(verify)
def test_the_dispatch_input_never_reaches_the_shell():
"""Interpolating it into `run:` splices caller text into a script that
holds a token. Through the environment it stays a value."""
text = WORKFLOW.read_text(encoding="utf-8")
run_lines = [line for line in text.splitlines() if re.match(r"^\s*-?\s*run:", line)]
assert run_lines, "found no run: step, so this guard is scanning nothing"
for line in run_lines:
assert "${{" not in line, f"a run: step interpolates a template expression: {line.strip()}"
assert 'python3 scripts/verify_release.py "$TAG"' in text
def test_the_manifest_parses_as_sha256sum_writes_it():
parsed = verify.parse_manifest(
"0ee0d8e6 Vocalinux-0.16.2-x86_64.AppImage\n22deb51a *vocalinux-0.16.2.whl\n\n"
)
assert parsed == {
"Vocalinux-0.16.2-x86_64.AppImage": "0ee0d8e6",
"vocalinux-0.16.2.whl": "22deb51a",
}
def test_an_asset_missing_from_the_manifest_fails():
"""The case that reads as tampering to anyone running `sha256sum -c`."""
problems = verify.check_manifest(
{"a.AppImage": "aa", "b.flatpak": "bb", verify.MANIFEST: "cc"}, {"a.AppImage": "aa"}, []
)
assert any("b.flatpak is published but absent" in problem for problem in problems)
def test_a_manifest_line_with_no_asset_behind_it_fails():
"""How a hand edit adds a promise nothing keeps."""
problems = verify.check_manifest(
{"a.AppImage": "aa", verify.MANIFEST: "cc"},
{"a.AppImage": "aa", "ghost.flatpak": "bb"},
[],
)
assert any("which is not an asset" in problem for problem in problems)
def test_a_digest_that_disagrees_with_the_stored_bytes_fails():
problems = verify.check_manifest({"a.AppImage": "aa"}, {"a.AppImage": "bb"}, [])
assert any("GitHub stores aa" in problem for problem in problems)
def test_an_asset_with_no_digest_fails_rather_than_being_skipped():
"""The manifest's own digest is never checked, so it is not reported."""
problems = verify.check_manifest({}, {}, ["mystery.snap", verify.MANIFEST])
assert problems == ["GitHub reports no digest for mystery.snap"]
def test_a_manifest_that_matches_the_release_passes():
assert not verify.check_manifest(
{"a.AppImage": "aa", "b.flatpak": "bb", verify.MANIFEST: "cc"},
{"a.AppImage": "aa", "b.flatpak": "bb"},
[],
)
def test_notes_without_the_verification_block_fail():
"""v0.16.2 published a manifest and told nobody it existed."""
assert verify.check_notes("Download the AppImage and run it.")
assert not verify.check_notes(
"Check it with `sha256sum -c --ignore-missing SHA256SUMS`, then `gh attestation verify`."
)
def test_provenance_asks_only_for_slsa_build_provenance(monkeypatch):
"""Unfiltered, an SBOM would read as the provenance users are told to expect."""
class Done:
returncode = 0
stdout = '{"attestations": [{}]}'
asked = []
def fake_gh(*args):
asked.append(args[-1])
return Done()
monkeypatch.setattr(verify, "_gh", fake_gh)
assert not verify.check_provenance("owner/repo", {"a.whl": "aa"})
assert f"predicate_type={verify.SLSA_PROVENANCE}" in asked[0]
def test_a_release_missing_a_distribution_names_it():
"""Comparing only what is left would pass while PyPI serves an unchecked file."""
assert verify.check_pypi("0.16.2", {"Vocalinux-0.16.2-x86_64.AppImage": "aa"}) == [
"the release carries no .whl to compare against PyPI",
"the release carries no .tar.gz to compare against PyPI",
]
def test_an_extra_pypi_distribution_fails(monkeypatch):
"""A differently-named wheel on PyPI would otherwise go unchecked."""
import io
import sys
# urllib.request imports tempfile. test_recognition_manager.py and
# test_speech_recognition.py leave a MagicMock in sys.modules, which
# makes that import raise a metaclass conflict. Drop the mock first:
# import_module would otherwise return it.
sys.modules.pop("tempfile", None)
sys.modules["tempfile"] = importlib.import_module("tempfile")
for name in [key for key in sys.modules if key == "urllib" or key.startswith("urllib.")]:
del sys.modules[name]
import urllib.request
wheel = "vocalinux-0.16.2-py3-none-any.whl"
sdist = "vocalinux-0.16.2.tar.gz"
extra = "vocalinux-0.16.2-extra.whl"
payload = {
"urls": [
{"filename": wheel, "digests": {"sha256": "aa"}},
{"filename": sdist, "digests": {"sha256": "bb"}},
{"filename": extra, "digests": {"sha256": "cc"}},
]
}
def fake_urlopen(url, timeout=None):
return io.StringIO(json.dumps(payload))
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
problems = verify.check_pypi("0.16.2", {wheel: "aa", sdist: "bb"})
assert problems == [f"{extra} is on PyPI but not on the release"]
def test_github_digests_are_read_without_their_algorithm_prefix():
stored, undigested = verify.asset_digests(
[{"name": "a.AppImage", "digest": "sha256:AABB"}, {"name": "b.snap"}]
)
assert stored == {"a.AppImage": "aabb"}
assert undigested == ["b.snap"]
def test_a_null_release_body_fails_notes_without_typeerror():
"""GitHub serves JSON null for an empty body; that must be notes FAIL."""
problems = verify.check_notes(None)
assert problems
assert all("never mention" in problem for problem in problems)
def test_fetch_release_reads_digests_from_the_rest_api(monkeypatch):
"""`gh release view --json assets` omits digest on gh 2.46; REST does not."""
class Done:
returncode = 0
stdout = ""
stderr = ""
def __init__(self, stdout=""):
self.stdout = stdout
calls = []
def fake_gh(*args):
calls.append(args)
assert args[:2] == ("api", "/repos/VocaHQ/vocalinux/releases/tags/v0.16.2")
payload = {
"tag_name": "v0.16.2",
"body": "sha256sum -c and gh attestation verify via SHA256SUMS",
"draft": False,
"prerelease": False,
"assets": [
{"name": "a.AppImage", "digest": "sha256:AABB"},
{"name": "b.snap", "digest": None},
],
}
return Done(stdout=json.dumps(payload))
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "_gh", fake_gh)
release = verify.fetch_release("v0.16.2")
assert release["tagName"] == "v0.16.2"
assert release["body"]
assert release["isDraft"] is False
assert release["isPrerelease"] is False
stored, undigested = verify.asset_digests(release["assets"])
assert stored == {"a.AppImage": "aabb"}
assert undigested == ["b.snap"]
assert all(args[0] == "api" for args in calls)
assert not any(args[0] == "release" for args in calls)
def test_fetch_release_tries_v_prefix_then_surfaces_api_errors(monkeypatch):
class Done:
returncode = 1
stdout = ""
stderr = "HTTP 404: Not Found"
paths = []
def fake_gh(*args):
paths.append(args[1])
return Done()
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "_gh", fake_gh)
try:
verify.fetch_release("0.16.2")
except SystemExit as error:
message = str(error)
else:
raise AssertionError("expected SystemExit")
assert paths == [
"/repos/VocaHQ/vocalinux/releases/tags/0.16.2",
"/repos/VocaHQ/vocalinux/releases/tags/v0.16.2",
]
assert "HTTP 404: Not Found" in message
assert "no release found for 0.16.2 or v0.16.2" in message
def test_pypi_version_uses_removeprefix_not_lstrip():
"""lstrip('v') eats every leading v; removeprefix only the tag prefix."""
import inspect
source = inspect.getsource(verify.main)
assert 'removeprefix("v")' in source
assert 'lstrip("v")' not in source
def test_the_job_is_not_skipped_when_the_release_run_fails() -> None:
"""v0.17.0 published seven assets from a run that failed on publish-snap.
The conclusion guard skipped it, and a skipped job is green.
"""
text = WORKFLOW.read_text(encoding="utf-8")
assert "workflow_run.conclusion" not in text, "the job is gated on the release run's conclusion"
assert not re.search(r"^\s*if:", text, re.M), "the verify job carries a job-level if:"
assert "--if-published" in text, "nothing tells the script a missing release is not a failure"
def test_only_the_workflow_run_trigger_forgives_a_missing_release() -> None:
"""A person naming a tag by hand should get an error, not a pass."""
text = WORKFLOW.read_text(encoding="utf-8")
flag = re.search(r"IF_PUBLISHED: \$\{\{ (?P<expr>.+?) \}\}", text)
assert flag, "no IF_PUBLISHED expression to check"
assert "github.event_name == 'workflow_run'" in flag.group("expr")
def test_a_tag_that_published_nothing_passes_under_if_published(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
def fake_fetch(_tag: str | None) -> dict:
raise verify.ReleaseNotFound("no release found for v9.9.9")
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "fetch_release", fake_fetch)
assert verify.main(["verify_release.py", "v9.9.9", "--if-published"]) == 0
assert "nothing to verify" in capsys.readouterr().out
def test_the_same_tag_fails_without_the_flag(monkeypatch: pytest.MonkeyPatch) -> None:
"""`just verify-release v9.9.9` must still say the release is not there."""
def fake_fetch(_tag: str | None) -> dict:
raise verify.ReleaseNotFound("no release found for v9.9.9")
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "fetch_release", fake_fetch)
with pytest.raises(verify.ReleaseNotFound):
verify.main(["verify_release.py", "v9.9.9"])
def test_the_flag_is_not_mistaken_for_a_tag(monkeypatch: pytest.MonkeyPatch) -> None:
"""`--if-published` alone must still mean the latest stable release."""
seen: list[str | None] = []
def fake_fetch(tag: str | None) -> dict:
seen.append(tag)
raise verify.ReleaseNotFound("no release found")
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "fetch_release", fake_fetch)
assert verify.main(["verify_release.py", "", "--if-published"]) == 0
assert seen == [None], f"the flag or an empty tag was read as a tag: {seen}"
def test_an_api_error_that_is_not_a_404_is_not_forgiven(monkeypatch: pytest.MonkeyPatch) -> None:
"""A rate limit must not read as a tag that published nothing.
Otherwise --if-published turns every API outage into a green check over a
release nothing has read.
"""
class Done:
returncode = 1
stdout = ""
stderr = "gh: API rate limit exceeded (HTTP 403)"
monkeypatch.setenv("GITHUB_REPOSITORY", "VocaHQ/vocalinux")
monkeypatch.setattr(verify, "_gh", lambda *args: Done())
with pytest.raises(SystemExit) as raised:
verify.main(["verify_release.py", "v0.17.0", "--if-published"])
assert not isinstance(raised.value, verify.ReleaseNotFound)
assert "HTTP 403" in str(raised.value)