Files
voculinux/tests/test_release_integrity.py
T
xavierk ba6462ccf8 refactor(ci): move the xbps job out of the GitHub release workflow
The signed repository is now published by the tag-triggered Gitea workflow,
so release.yml drops the build-xbps job, its place in the checksum job's
needs list, the xbps-repo artifact pattern, and the checksum lines for
files that no longer arrive. The release notes point at the Gitea release
instead of promising attached repository files.

The policy tests now assert the new shape: release.yml carries no xbps
job, and the Gitea workflow is the one place that runs repo.sh.
2026-11-06 18:26:14 +05:30

355 lines
16 KiB
Python

"""Guard what a published release promises about itself.
v0.16.1 shipped four assets — two AppImages of ~100 MB, a wheel and an sdist —
with no checksum, no signature and no provenance, in a project that pins the
digest of all 67 models it downloads. It also built the same version three times
on three runners (`build-and-release`, `build-appimage-arm64`, `publish-pypi`),
so the wheel on PyPI was never the wheel on the GitHub release, and a checksum
for one would not have described the other.
These tests pin the shape of the fix: verify independent builds, publish only
the canonical distributions, and cover every artifact with one manifest.
Parsed as text rather than YAML on purpose — the
sibling workflow guards do the same, and the only YAML parser on hand reaches
the venv transitively through pre-commit.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE = REPO_ROOT / ".github" / "workflows" / "release.yml"
#: The artifact holding the wheel and sdist that every other job consumes.
DIST_ARTIFACT = "python-dist"
#: Container and builder action pins must stay identical to flatpak.yml.
#: The builder runs privileged, so the image must be an immutable digest, not
#: the moving gnome-50 tag. Digest is the gnome-50 OCI index as of 2026-09-06.
_FLATPAK_CI = REPO_ROOT / ".github" / "workflows" / "flatpak.yml"
_FLATPAK_IMAGE = (
"ghcr.io/flathub-infra/flatpak-github-actions:gnome-50"
"@sha256:1fb2df10a57276f90806e1f35454048e30bf1855b7b4ff4808c9ee55887bd852"
)
_FLATPAK_BUILDER = (
"flatpak/flatpak-github-actions/flatpak-builder@79327416609af08178ad73b352877e51450790b3"
)
_FLATPAK_TAG_ONLY = re.compile(
r"image:\s*ghcr\.io/flathub-infra/flatpak-github-actions:gnome-50\s*$",
re.M,
)
def _text() -> str:
return RELEASE.read_text(encoding="utf-8")
def _without_comments(text: str) -> str:
return "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
def _jobs() -> dict:
"""job name -> its block of the workflow, split on the 2-space indent."""
text = _text()
start = text.index("\njobs:\n")
headers = list(re.finditer(r"^ ([a-z0-9][a-z0-9-]*):$", text[start:], re.M))
assert headers, "no jobs found; release.yml's layout moved"
jobs = {}
for index, header in enumerate(headers):
end = headers[index + 1].start() if index + 1 < len(headers) else len(text) - start
jobs[header.group(1)] = text[start:][header.start() : end]
return jobs
def _needs(block: str) -> set:
match = re.search(r"^ needs: (.+)$", block, re.M)
if not match:
return set()
return set(re.findall(r"[a-z0-9][a-z0-9-]*", match.group(1)))
def _permissions(block: str) -> dict:
match = re.search(r"^ permissions:\n((?: \S+: \S+.*\n)+)", block, re.M)
if not match:
return {}
return dict(re.findall(r"^ (\S+): (\S+)", match.group(1), re.M))
def _top_level_permissions() -> dict:
text = _text()
match = re.search(r"^permissions:\n((?: \S+: \S+.*\n)+)", text, re.M)
assert match, "release.yml declares no top-level permissions"
return dict(re.findall(r"^ (\S+): (\S+)", match.group(1), re.M))
def test_only_verified_canonical_distributions_are_published():
"""Independent verification builds must not become additional release payloads."""
block = _jobs()["build-and-release"]
gate = "python scripts/build_reproducible_wheel.py"
assert gate in block
assert block.index(gate) < block.index("name: Upload the built distributions")
assert block.index(gate) < block.index("name: Create Release")
assert "python -m build" not in _without_comments(_text())
assert "pip install" not in _without_comments(block.split(" - name: Create Release")[0])
assert "pip install" not in _without_comments(_jobs()["publish-pypi"])
# Exact uv versions are checked across both workflows in test_dependency_exports.py.
assert "astral-sh/setup-uv@" in block
assert "dist/*.whl" in block and "dist/*.tar.gz" in block
assert "path: build/reproducible-wheel/" in block
def test_pr_ci_runs_the_same_reproducibility_gate():
"""README/license changes can change wheels too: this job is unconditional."""
ci = (REPO_ROOT / ".github/workflows/unified-pipeline.yml").read_text()
block = ci.split("\n reproducible-wheel:\n", 1)[1].split("\n python-lint:", 1)[0]
assert "python scripts/build_reproducible_wheel.py" in block
assert "python-version: '3.11'" in block
assert "astral-sh/setup-uv@" in block
assert not re.search(r"^ if:", block, re.M)
def test_release_publication_cannot_bypass_the_wheel_gate():
"""Even independent Snap builds and always-run checksum jobs must wait."""
jobs = _jobs()
for name in (
"publish-pypi",
"publish-snap",
"publish-aur",
"publish-aur-bin",
"publish-flatpak-remote",
):
assert "build-and-release" in _needs(jobs[name]), name
checksums = jobs["publish-checksums"]
assert "needs.build-and-release.result == 'success'" in checksums
pattern = "'@(python-dist|appimage-*|native-*|flatpak-*|snap-amd64)'"
assert f"pattern: {pattern}" in checksums
def test_aur_bin_publication_depends_only_on_its_appimage_inputs() -> None:
"""Source-gate or unrelated package failures must not block ready AppImages."""
jobs = _jobs()
def prerequisites(name: str) -> set[str]:
direct = _needs(jobs[name])
return direct.union(*(prerequisites(dependency) for dependency in direct))
expected = {"build-and-release", "build-appimage-arm64"}
assert _needs(jobs["publish-aur-bin"]) == expected
assert prerequisites("publish-aur-bin") == expected
def test_every_consumer_downloads_the_build_instead_of_repeating_it():
jobs = _jobs()
for name in ("build-appimage-arm64", "publish-pypi"):
block = jobs[name]
assert "actions/download-artifact" in block, f"{name} does not consume the build"
assert f"name: {DIST_ARTIFACT}" in block, f"{name} downloads some other artifact"
assert "python -m build" not in _without_comments(
block
), f"{name} rebuilds what build-and-release already published"
def _jobs_that_attach_to_the_release() -> set:
return {
name
for name, block in _jobs().items()
if "gh release upload" in block or "softprops/action-gh-release" in block
}
def test_the_manifest_waits_for_every_artifact_it_has_to_cover():
"""A SHA256SUMS listing three of the four files is worse than none: the
missing one is indistinguishable from a tampered one. The aarch64 AppImage
lands in its own job after the release exists, which is what makes this
ordering a real constraint rather than a formality."""
jobs = _jobs()
assert "publish-checksums" in jobs, "nothing generates a checksum manifest"
producers = _jobs_that_attach_to_the_release() - {"publish-checksums"}
missing = producers - _needs(jobs["publish-checksums"])
assert not missing, f"publish-checksums runs before {sorted(missing)} attach their artifacts"
def test_the_manifest_covers_every_kind_of_artifact_we_publish():
block = _jobs()["publish-checksums"]
assert "merge-multiple: true" in block, "it collects one artifact, not all of them"
checksum_line = re.search(r"sha256sum -- (.+)$", block, re.M)
assert checksum_line, "publish-checksums does not generate SHA256SUMS"
for pattern in ("*.whl", "*.tar.gz", "*.AppImage", "*.flatpak", "*.snap"):
assert pattern in checksum_line.group(1), f"{pattern} is published but unchecksummed"
# The signed xbps repository is published by the Gitea tag workflow, not
# attached to this release, so the manifest must not require or hash it:
# a pattern for files that never arrive would fail every release.
assert "xbps" not in _without_comments(block).lower()
for pattern in ("*.x86_64.xbps", "*-repodata*", "FINGERPRINT"):
assert pattern not in block, f"{pattern} is not a GitHub release artifact"
def test_provenance_is_generated_from_the_published_manifest():
"""Attesting a second glob would let the file users check and the set we
attest drift apart."""
block = _jobs()["publish-checksums"]
assert "actions/attest-build-provenance" in block, "no build provenance is generated"
assert (
"subject-checksums: dist/SHA256SUMS" in block
), "provenance is not driven by the manifest we publish"
assert _permissions(block).get("attestations") == "write"
def test_pypi_is_published_without_a_stored_token():
text = _text()
assert "PYPI_API_TOKEN" not in text, "trusted publishing needs no API token"
assert "TWINE_PASSWORD" not in text
block = _jobs()["publish-pypi"]
assert "pypa/gh-action-pypi-publish" in block
assert _permissions(block).get("id-token") == "write"
def test_only_the_jobs_that_need_it_can_mint_an_oidc_token():
"""`id-token: write` at the top level handed one to publish-aur too, which
runs a third-party action holding our AUR signing key."""
assert "id-token" not in _top_level_permissions()
minters = {
name for name, block in _jobs().items() if _permissions(block).get("id-token") == "write"
}
assert minters == {"publish-pypi", "publish-checksums"}, minters
def test_the_release_notes_tell_users_how_to_verify_the_download():
"""Publishing a manifest nobody is told about verifies nothing."""
body = _jobs()["build-and-release"]
assert "SHA256SUMS" in body, "the release notes never mention the manifest"
assert "sha256sum -c" in body, "the notes do not show how to check it"
assert "gh attestation verify" in body, "the notes do not show how to check provenance"
def test_flatpak_release_jobs_reuse_ci_builder_pins():
"""Release bundles must be the same builder image and action commit as CI.
Attach is a separate ubuntu-latest job: the builder image is Freedesktop
SDK and does not ship GitHub CLI, unlike the AppImage runners.
"""
assert "@sha256:" in _FLATPAK_IMAGE, "the builder image pin must be a digest, not a tag"
ci = _FLATPAK_CI.read_text(encoding="utf-8")
assert _FLATPAK_IMAGE in ci, "flatpak.yml image pin moved; update this test"
assert not _FLATPAK_TAG_ONLY.search(ci), "flatpak.yml pins the mutable gnome-50 tag"
assert _FLATPAK_BUILDER in ci, "flatpak.yml action pin moved; update this test"
jobs = _jobs()
for name, arch, runner in (
("build-flatpak-amd64", "x86_64", "ubuntu-latest"),
("build-flatpak-arm64", "aarch64", "ubuntu-24.04-arm"),
):
block = jobs[name]
assert _FLATPAK_IMAGE in block, f"{name} does not use the CI builder image"
assert not _FLATPAK_TAG_ONLY.search(block), f"{name} pins the mutable gnome-50 tag"
assert "options: --privileged" in block, f"{name} is not a privileged container"
assert _FLATPAK_BUILDER in block, f"{name} does not use the pinned builder action"
assert f"arch: {arch}" in block
assert (
f"cache-key: flatpak-builder-{arch}-${{{{ hashFiles('packaging/flatpak/**') }}}}"
in block
), (
f"{name} cache-key must start with flatpak-builder-{arch} so the "
"action's restore-keys prefix matches after it appends -${arch}"
)
assert f"runs-on: {runner}" in block
assert f"Voculinux-${{{{ steps.get_version.outputs.VERSION }}}}-{arch}.flatpak" in block
assert "actions/upload-artifact" in block, f"{name} does not upload a workflow artifact"
assert "gh release upload" not in block, f"{name} cannot run gh in the builder image"
assert "python -m build" not in _without_comments(block)
attach = jobs["attach-flatpak"]
assert _needs(attach) >= {
"build-and-release",
"build-flatpak-amd64",
"build-flatpak-arm64",
}
assert "gh release upload" in attach
assert "--clobber" in attach
assert "dist/*.flatpak" in attach
assert _permissions(attach).get("contents") == "write"
def test_the_release_notes_document_flatpak_bundles():
"""Users need the Flathub runtime, --user install, and the no-store caveats."""
body = _jobs()["build-and-release"]
assert "flatpak install --user" in body
assert "org.gnome.Platform//50" in body
assert "no auto-update" in body
assert "not on Flathub" in body
assert "Voculinux-__VERSION__-x86_64.flatpak" in body
def test_the_remote_install_guidance_only_runs_when_the_remote_publishes():
"""The notes recommend `flatpak install` of the `.flatpakref` only for a
tag the remote will actually carry: stable, with both publish secrets
configured — the same gates publish-flatpak-remote applies. Otherwise the
prominent command would point at a remote that never gets this release."""
body = _jobs()["build-and-release"]
assert (
"steps.flatpak_notes.outputs.flatpak" in body
), "the Flatpak notes must come from the conditional step, not inline text"
for secret in ("FLATPAK_GPG_PRIVATE_KEY", "FLATPAK_REPO_TOKEN"):
assert f"secrets.{secret}" in body, f"the notes do not gate on {secret}"
assert "*alpha*|*beta*|*rc*" in body, "the notes do not detect prerelease tags"
assert "__FLATPAKREF_URL__" in body, "the notes do not publish the .flatpakref path"
# Clean installs without Flathub cannot resolve the GNOME runtime from the
# remote alone: the manual path has to add flathub first.
assert "remote-add --if-not-exists flathub" in body
def test_the_flatpak_remote_publishes_signed_and_secret_gated():
"""#785's self-hosted OSTree remote signs with a provisioned GPG key and
pushes to VocaHQ/vocalinux-flatpak, never to the release — and it skips
itself instead of failing the release while secrets are unconfigured.
"""
block = _jobs()["publish-flatpak-remote"]
assert _needs(block) >= {
"build-and-release",
"build-flatpak-amd64",
"build-flatpak-arm64",
}, "the remote must not publish bundles still building or lead the GitHub Release"
for secret in ("FLATPAK_GPG_PRIVATE_KEY", "FLATPAK_REPO_TOKEN"):
assert f"secrets.{secret}" in block, f"{secret} is not wired into the job"
assert "build-import-bundle" in block
assert "--gpg-sign" in block, "the remote must sign what it publishes"
assert "--generate-static-deltas" in block, "updates would re-download whole apps"
assert "external_repository" in block, "the tap repo gets no push without it"
assert "gh release upload" not in block, "the remote is not a release asset"
assert _permissions(block).get("id-token") != "write"
def test_the_remote_publish_is_stable_gated_and_keeps_history():
"""A prerelease tag must not reach `flatpak update` users, and a transient
checkout failure must not be mistaken for a first publish — the empty-repo
path would drop the objects clients still delta from."""
block = _jobs()["publish-flatpak-remote"]
job_if = re.search(r"^ if: (.+)$", block, re.M)
assert job_if, "the job is not gated to stable tags"
for marker in ("alpha", "beta", "rc"):
assert f"contains(github.ref, '{marker}')" in job_if.group(1)
assert (
"ls-remote --exit-code" in block
), "no explicit check decides whether the remote was published before"
assert (
"continue-on-error" not in block
), "a tolerated checkout failure falls through to the empty-repo path"
assert ".flatpakref" in block, "no app ref is written for one-command installs"
def test_the_remote_publish_recreates_dirs_git_drops() -> None:
"""git does not track empty directories, so a site/repo checked out from
gh-pages comes back without refs/remotes or refs/mirrors — and
`flatpak build-update-repo --generate-static-deltas` aborts on
`opendir(refs/remotes): No such file or directory` when it enumerates refs
for deltas. v0.18.3 hit exactly this on the first real remote publish."""
block = _jobs()["publish-flatpak-remote"]
mkdir = 'mkdir -p "$repo/refs/remotes" "$repo/refs/mirrors"'
assert mkdir in block, "the copied-back repo must re-create the ref dirs git dropped"
assert block.index(mkdir) < block.index(
"flatpak build-update-repo"
), "the dirs must exist before build-update-repo enumerates refs"