Merge pull request 'Build release bundles from the declared Git commit' (#1045) from timmy/1044-declared-git-release-bundles into main
This commit is contained in:
commit
c655cf5861
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user