ci: build releases from declared Git commits (Closes #1044)
All checks were successful
CI / lint (pull_request) Successful in 2m38s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 2m24s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-17 22:25:38 +00:00
parent 55156f45c9
commit 2082fbd5eb
5 changed files with 156 additions and 44 deletions

View File

@ -82,7 +82,7 @@ jobs:
RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
(cd dist && sha256sum -c ./*.sha256) (cd dist && sha256sum -c ./*.sha256)
python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .
printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \ printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"CI-tested release candidate for commit %s. Verify downloads with the attached SHA-256 checksum.","draft":true,"prerelease":true}\n' \
"$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json "$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json

View File

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Build a reproducible, verifiable Stackchain Dashboard release bundle.""" """Build a reproducible release bundle from an exact Git commit."""
from __future__ import annotations from __future__ import annotations
@ -8,37 +8,88 @@ import gzip
import hashlib import hashlib
import io import io
import json import json
import re
import subprocess
import tarfile import tarfile
from pathlib import Path from dataclasses import dataclass
from pathlib import Path, PurePosixPath
RUNTIME_DIRECTORIES = ("docs", "frontend", "src") RUNTIME_DIRECTORIES = ("docs", "frontend", "src")
RUNTIME_FILES = ("README.md", "requirements.txt") RUNTIME_FILES = ("README.md", "requirements.txt")
EXCLUDED_PARTS = {"__pycache__", ".pytest_cache"}
EXCLUDED_SUFFIXES = (".pyc", ".pyo")
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}")
@dataclass(frozen=True)
class RuntimeFile:
name: str
data: bytes
executable: bool
def sha256(data: bytes) -> str: def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
def runtime_files(root: Path) -> list[Path]: def _git(root: Path, *args: str, text: bool = False) -> subprocess.CompletedProcess:
files = [root / name for name in RUNTIME_FILES if (root / name).is_file()] return subprocess.run(
for name in RUNTIME_DIRECTORIES: ["git", "-C", str(root), *args],
directory = root / name capture_output=True,
if directory.is_dir(): check=False,
files.extend(path for path in directory.rglob("*") if path.is_file()) text=text,
return sorted(files, key=lambda path: path.relative_to(root).as_posix()) )
def manifest_for(root: Path, files: list[Path], commit: str) -> dict: def runtime_files_at_commit(root: Path, commit: str) -> list[RuntimeFile]:
"""Read the runtime allowlist directly from the declared Git tree."""
root = root.resolve()
if not COMMIT_PATTERN.fullmatch(commit):
raise ValueError("declared commit must be a full 40-character Git object ID")
resolved = _git(root, "rev-parse", "--verify", f"{commit}^{{commit}}", text=True)
if resolved.returncode != 0 or resolved.stdout.strip() != commit:
raise ValueError("declared commit is unavailable")
listing = _git(root, "ls-tree", "-rz", "--full-tree", commit)
if listing.returncode != 0:
raise ValueError("could not inspect the declared commit")
files: list[RuntimeFile] = []
for record in listing.stdout.split(b"\0"):
if not record:
continue
metadata, raw_name = record.split(b"\t", 1)
mode, object_type, object_id = metadata.decode("ascii").split()
name = raw_name.decode("utf-8")
path = PurePosixPath(name)
selected = name in RUNTIME_FILES or (path.parts and path.parts[0] in RUNTIME_DIRECTORIES)
if not selected or object_type != "blob":
continue
if mode not in {"100644", "100755"}:
raise ValueError(f"release member is not a regular file: {name}")
if EXCLUDED_PARTS.intersection(path.parts) or name.endswith(EXCLUDED_SUFFIXES):
raise ValueError(f"declared commit contains generated release member: {name}")
blob = _git(root, "cat-file", "blob", object_id)
if blob.returncode != 0:
raise ValueError(f"could not read release member: {name}")
files.append(RuntimeFile(name, blob.stdout, mode == "100755"))
names = {item.name for item in files}
missing = [name for name in RUNTIME_FILES if name not in names]
if missing or not any(name.startswith("src/") for name in names) or not any(
name.startswith("frontend/") for name in names
):
raise ValueError("declared commit is missing required runtime files")
return sorted(files, key=lambda item: item.name)
def manifest_for(files: list[RuntimeFile], commit: str) -> dict:
return { return {
"schema_version": 1, "schema_version": 1,
"commit": commit, "commit": commit,
"files": { "files": {
path.relative_to(root).as_posix(): { item.name: {"sha256": sha256(item.data), "size": len(item.data)} for item in files
"sha256": sha256(path.read_bytes()),
"size": path.stat().st_size,
}
for path in files
}, },
} }
@ -58,19 +109,10 @@ def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarf
def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]: def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]:
root = root.resolve() files = runtime_files_at_commit(root, commit)
files = runtime_files(root) embedded_manifest = manifest_for(files, commit)
missing = [name for name in RUNTIME_FILES if not (root / name).is_file()]
if missing or not (root / "src").is_dir() or not (root / "frontend").is_dir():
raise ValueError("release root is missing required runtime files")
embedded_manifest = manifest_for(root, files, commit)
archive_members: dict[str, tuple[bytes, bool]] = { archive_members: dict[str, tuple[bytes, bool]] = {
path.relative_to(root).as_posix(): ( item.name: (item.data, item.executable) for item in files
path.read_bytes(),
bool(path.stat().st_mode & 0o111),
)
for path in files
} }
archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False) archive_members["release-manifest.json"] = (json_bytes(embedded_manifest), False)
@ -112,7 +154,11 @@ def parse_args() -> argparse.Namespace:
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch): try:
paths = build(args.root, args.output_dir, args.commit, args.source_date_epoch)
except (OSError, ValueError) as error:
raise SystemExit(f"release build failed: {error}") from error
for path in paths:
print(path) print(path)

View File

@ -10,6 +10,8 @@ import sys
import tarfile import tarfile
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from build_release import manifest_for, runtime_files_at_commit
def one(directory: Path, pattern: str) -> Path: def one(directory: Path, pattern: str) -> Path:
matches = sorted(directory.glob(pattern)) matches = sorted(directory.glob(pattern))
@ -22,7 +24,7 @@ def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
def verify(input_dir: Path, commit: str) -> None: def verify(input_dir: Path, commit: str, repository: Path) -> None:
archive_path = one(input_dir, "*.tar.gz") archive_path = one(input_dir, "*.tar.gz")
manifest_path = one(input_dir, "*.manifest.json") manifest_path = one(input_dir, "*.manifest.json")
checksum_path = one(input_dir, "*.sha256") checksum_path = one(input_dir, "*.sha256")
@ -45,6 +47,11 @@ def verify(input_dir: Path, commit: str) -> None:
expected_files = manifest.get("files") expected_files = manifest.get("files")
if not isinstance(expected_files, dict): if not isinstance(expected_files, dict):
raise ValueError("manifest files must be an object") raise ValueError("manifest files must be an object")
source_files = runtime_files_at_commit(repository, commit)
source_manifest = manifest_for(source_files, commit)["files"]
if expected_files != source_manifest:
raise ValueError("bundle manifest does not match the declared Git commit")
source_by_name = {item.name: item for item in source_files}
with tarfile.open(archive_path, "r:gz") as archive: with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers() members = archive.getmembers()
for member in members: for member in members:
@ -67,19 +74,22 @@ def verify(input_dir: Path, commit: str) -> None:
data = bundled.read() data = bundled.read()
if metadata != {"sha256": digest(data), "size": len(data)}: if metadata != {"sha256": digest(data), "size": len(data)}:
raise ValueError(f"bundle member failed verification: {name}") raise ValueError(f"bundle member failed verification: {name}")
if data != source_by_name[name].data:
raise ValueError(f"bundle member differs from the declared Git commit: {name}")
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input-dir", type=Path, required=True) parser.add_argument("--input-dir", type=Path, required=True)
parser.add_argument("--commit", required=True) parser.add_argument("--commit", required=True)
parser.add_argument("--repository", type=Path, default=Path.cwd())
return parser.parse_args() return parser.parse_args()
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
try: try:
verify(args.input_dir, args.commit) verify(args.input_dir, args.commit, args.repository)
except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error: except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error:
print(f"release verification failed: {error}", file=sys.stderr) print(f"release verification failed: {error}", file=sys.stderr)
raise SystemExit(1) from error raise SystemExit(1) from error

View File

@ -41,7 +41,10 @@ def test_release_promotion_waits_for_tests_and_bundle():
assert "needs: [lint, build-release, browser-journey]" in release assert "needs: [lint, build-release, browser-journey]" in release
assert "github.event_name == 'push'" in release assert "github.event_name == 'push'" in release
assert "actions/download-artifact@v3" in release assert "actions/download-artifact@v3" in release
assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release assert (
'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET" --repository .'
in release
)
assert release.index("sha256sum -c") < release.index("curl --fail-with-body") assert release.index("sha256sum -c") < release.index("curl --fail-with-body")

View File

@ -20,7 +20,24 @@ def _fixture(root: Path) -> None:
(root / "README.md").write_text("# Stackchain\n") (root / "README.md").write_text("# Stackchain\n")
def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]: def _commit_fixture(source: Path) -> str:
subprocess.run(["git", "init", "-q", str(source)], check=True)
subprocess.run(["git", "-C", str(source), "config", "user.name", "Release Test"], check=True)
subprocess.run(
["git", "-C", str(source), "config", "user.email", "release-test@example.invalid"],
check=True,
)
subprocess.run(["git", "-C", str(source), "add", "."], check=True)
subprocess.run(["git", "-C", str(source), "commit", "-qm", "fixture"], check=True)
return subprocess.run(
["git", "-C", str(source), "rev-parse", "HEAD"],
text=True,
capture_output=True,
check=True,
).stdout.strip()
def _build(source: Path, output: Path, commit: str) -> subprocess.CompletedProcess[str]:
return subprocess.run( return subprocess.run(
[ [
sys.executable, sys.executable,
@ -30,7 +47,7 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
"--output-dir", "--output-dir",
str(output), str(output),
"--commit", "--commit",
"a" * 40, commit,
"--source-date-epoch", "--source-date-epoch",
"1720000000", "1720000000",
], ],
@ -40,14 +57,20 @@ def _build(source: Path, output: Path) -> subprocess.CompletedProcess[str]:
) )
def test_release_bundle_is_reproducible(tmp_path): def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
source = tmp_path / "source" source = tmp_path / "source"
_fixture(source) _fixture(source)
commit = _commit_fixture(source)
first = _build(source, tmp_path / "first") first = _build(source, tmp_path / "first", commit)
second = _build(source, tmp_path / "second")
assert first.returncode == 0, first.stderr assert first.returncode == 0, first.stderr
(source / "src" / "__pycache__").mkdir()
(source / "src" / "__pycache__" / "main.cpython-311.pyc").write_bytes(b"bytecode")
(source / "src" / "untracked-secret.txt").write_text("must not ship")
(source / "src" / "main.py").write_text("print('dirty worktree')\n")
second = _build(source, tmp_path / "second", commit)
assert second.returncode == 0, second.stderr assert second.returncode == 0, second.stderr
first_archive = next((tmp_path / "first").glob("*.tar.gz")) first_archive = next((tmp_path / "first").glob("*.tar.gz"))
second_archive = next((tmp_path / "second").glob("*.tar.gz")) second_archive = next((tmp_path / "second").glob("*.tar.gz"))
@ -55,7 +78,7 @@ def test_release_bundle_is_reproducible(tmp_path):
manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text()) manifest = json.loads(next((tmp_path / "first").glob("*.manifest.json")).read_text())
checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0] checksum = next((tmp_path / "first").glob("*.sha256")).read_text().split()[0]
assert manifest["commit"] == "a" * 40 assert manifest["commit"] == commit
assert manifest["artifact"]["sha256"] == checksum assert manifest["artifact"]["sha256"] == checksum
assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest() assert checksum == hashlib.sha256(first_archive.read_bytes()).hexdigest()
assert sorted(manifest["files"]) == [ assert sorted(manifest["files"]) == [
@ -74,19 +97,40 @@ def test_release_bundle_is_reproducible(tmp_path):
"requirements.txt", "requirements.txt",
"src/main.py", "src/main.py",
] ]
assert embedded["commit"] == "a" * 40 assert embedded["commit"] == commit
assert embedded["files"] == manifest["files"] assert embedded["files"] == manifest["files"]
def test_release_bundle_rejects_unknown_commit(tmp_path):
source = tmp_path / "source"
_fixture(source)
_commit_fixture(source)
built = _build(source, tmp_path / "dist", "a" * 40)
assert built.returncode != 0
assert "declared commit is unavailable" in built.stderr
def test_release_bundle_verifier_enforces_integrity(tmp_path): def test_release_bundle_verifier_enforces_integrity(tmp_path):
source = tmp_path / "source" source = tmp_path / "source"
output = tmp_path / "dist" output = tmp_path / "dist"
_fixture(source) _fixture(source)
built = _build(source, output) commit = _commit_fixture(source)
built = _build(source, output, commit)
assert built.returncode == 0, built.stderr assert built.returncode == 0, built.stderr
valid = subprocess.run( valid = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40], [
sys.executable,
str(VERIFIER),
"--input-dir",
str(output),
"--commit",
commit,
"--repository",
str(source),
],
text=True, text=True,
capture_output=True, capture_output=True,
check=False, check=False,
@ -96,7 +140,16 @@ def test_release_bundle_verifier_enforces_integrity(tmp_path):
archive = next(output.glob("*.tar.gz")) archive = next(output.glob("*.tar.gz"))
archive.write_bytes(archive.read_bytes() + b"tampered") archive.write_bytes(archive.read_bytes() + b"tampered")
tampered = subprocess.run( tampered = subprocess.run(
[sys.executable, str(VERIFIER), "--input-dir", str(output), "--commit", "a" * 40], [
sys.executable,
str(VERIFIER),
"--input-dir",
str(output),
"--commit",
commit,
"--repository",
str(source),
],
text=True, text=True,
capture_output=True, capture_output=True,
check=False, check=False,