ci: gate RCs on verifiable artifacts (#339)
This commit is contained in:
1 parent
9e68382bd7
commit
2cb92811c9
8 files changed
+405
-148
No files matched your search
@@ -15,3 +15,14 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||
install = text.index("pip install -r requirements.txt")
|
||||
tests = text.index("python3 -m pytest tests/ -q")
|
||||
assert install < tests
|
||||
|
||||
|
||||
def test_release_promotion_waits_for_tests_and_bundle():
|
||||
text = WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
|
||||
assert "needs: [lint, build-release]" in release
|
||||
assert "github.event_name == 'push'" in release
|
||||
assert "actions/download-artifact@v3" in release
|
||||
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
|
||||
assert release.index("sha256sum -c") < release.index("curl --fail-with-body")
|
||||
@@ -0,0 +1,104 @@
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PACKAGER = ROOT / "scripts" / "build_release.py"
|
||||
VERIFIER = ROOT / "scripts" / "verify_release.py"
|
||||
|
||||
|
||||
def _fixture(root: Path) -> None:
|
||||
(root / "src").mkdir(parents=True)
|
||||
(root / "frontend").mkdir()
|
||||
(root / "src" / "main.py").write_text("print('ready')\n")
|
||||
(root / "frontend" / "index.html").write_text("<h1>Stackchain</h1>\n")
|
||||
(root / "requirements.txt").write_text("fastapi==1.0\n")
|
||||
(root / "README.md").write_text("# Stackchain\n")
|
||||
|
||||
|
||||
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(PACKAGER),
|
||||
"--root",
|
||||
str(source),
|
||||
"--output-dir",
|
||||
str(output),
|
||||
"--commit",
|
||||
"a" * 40,
|
||||
"--source-date-epoch",
|
||||
"1720000000",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def test_release_bundle_is_reproducible(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
_fixture(source)
|
||||
|
||||
first = _build(source, tmp_path / "first")
|
||||
second = _build(source, tmp_path / "second")
|
||||
|
||||
assert first.returncode == 0, first.stderr
|
||||
assert second.returncode == 0, second.stderr
|
||||
first_archive = next((tmp_path / "first").glob("*.tar.gz"))
|
||||
second_archive = next((tmp_path / "second").glob("*.tar.gz"))
|
||||
assert first_archive.read_bytes() == second_archive.read_bytes()
|
||||
|
||||
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
|
||||
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
|
||||
assert manifest["commit"] == "a" * 40
|
||||
assert manifest["artifact"]["sha256"] == checksum
|
||||
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
|
||||
assert sorted(manifest["files"]) == [
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"requirements.txt",
|
||||
"src/main.py",
|
||||
]
|
||||
with tarfile.open(first_archive, "r:gz") as archive:
|
||||
names = archive.getnames()
|
||||
embedded = json.load(archive.extractfile("release-manifest.json"))
|
||||
assert names == [
|
||||
"README.md",
|
||||
"frontend/index.html",
|
||||
"release-manifest.json",
|
||||
"requirements.txt",
|
||||
"src/main.py",
|
||||
]
|
||||
assert embedded["commit"] == "a" * 40
|
||||
assert embedded["files"] == manifest["files"]
|
||||
|
||||
|
||||
def test_release_bundle_verifier_enforces_integrity(tmp_path):
|
||||
source = tmp_path / "source"
|
||||
output = tmp_path / "dist"
|
||||
_fixture(source)
|
||||
built = _build(source, output)
|
||||
assert built.returncode == 0, built.stderr
|
||||
|
||||
valid = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert valid.returncode == 0, valid.stderr
|
||||
|
||||
archive = next(output.glob("*.tar.gz"))
|
||||
archive.write_bytes(archive.read_bytes() + b"tampered")
|
||||
tampered = subprocess.run(
|
||||
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
assert tampered.returncode != 0
|
||||
@@ -1,24 +1,28 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
WORKFLOW = Path(".gitea/workflows/release.yml")
|
||||
LEGACY_WORKFLOW = Path(".gitea/workflows/release.yml")
|
||||
CI_WORKFLOW = Path(".gitea/workflows/ci.yml")
|
||||
STALE_MANIFEST = Path("release/manifest.json")
|
||||
|
||||
|
||||
def test_release_workflow_uses_gitea_api_not_github_cli():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "gh release" not in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/tags" in text
|
||||
assert "/api/v1/repos/${{ github.repository }}/releases" in text
|
||||
def test_independent_release_workflow_is_removed():
|
||||
assert not LEGACY_WORKFLOW.exists()
|
||||
|
||||
|
||||
def test_release_workflow_targets_merge_commit_and_marks_rc():
|
||||
text = WORKFLOW.read_text()
|
||||
assert 'TARGET="${{ github.sha }}"' in text
|
||||
assert '"prerelease":true' in text
|
||||
assert '"draft":false' in text
|
||||
def test_stale_static_release_manifest_is_removed():
|
||||
assert not STALE_MANIFEST.exists()
|
||||
|
||||
|
||||
def test_release_workflow_fails_on_api_error():
|
||||
text = WORKFLOW.read_text()
|
||||
assert "curl --fail-with-body" in text
|
||||
assert "|| true" not in text
|
||||
def test_ci_publishes_a_draft_before_exposing_assets():
|
||||
text = CI_WORKFLOW.read_text()
|
||||
release = text[text.index(" release-candidate:") :]
|
||||
create_draft = release.index('"draft":true')
|
||||
upload_asset = release.index("/assets?name=")
|
||||
publish = release.index('"draft":false')
|
||||
|
||||
assert create_draft < upload_asset < publish
|
||||
assert '"prerelease":true' in release
|
||||
assert 'TARGET="${{ github.sha }}"' in release
|
||||
assert "curl --fail-with-body" in release
|
||||
assert "|| true" not in release
|
||||
Reference in new issue
Block a user