Compare commits

..

No commits in common. "c655cf5861fee1385b577dc13e95d987f3efa899" and "55156f45c9511ff81c24045afbf358e590314f36" have entirely different histories.

5 changed files with 44 additions and 156 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" --repository . python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"
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 release bundle from an exact Git commit.""" """Build a reproducible, verifiable Stackchain Dashboard release bundle."""
from __future__ import annotations from __future__ import annotations
@ -8,88 +8,37 @@ import gzip
import hashlib import hashlib
import io import io
import json import json
import re
import subprocess
import tarfile import tarfile
from dataclasses import dataclass from pathlib import Path
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 _git(root: Path, *args: str, text: bool = False) -> subprocess.CompletedProcess: def runtime_files(root: Path) -> list[Path]:
return subprocess.run( files = [root / name for name in RUNTIME_FILES if (root / name).is_file()]
["git", "-C", str(root), *args], for name in RUNTIME_DIRECTORIES:
capture_output=True, directory = root / name
check=False, if directory.is_dir():
text=text, files.extend(path for path in directory.rglob("*") if path.is_file())
) return sorted(files, key=lambda path: path.relative_to(root).as_posix())
def runtime_files_at_commit(root: Path, commit: str) -> list[RuntimeFile]: def manifest_for(root: Path, files: list[Path], commit: str) -> dict:
"""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": {
item.name: {"sha256": sha256(item.data), "size": len(item.data)} for item in files path.relative_to(root).as_posix(): {
"sha256": sha256(path.read_bytes()),
"size": path.stat().st_size,
}
for path in files
}, },
} }
@ -109,10 +58,19 @@ 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]:
files = runtime_files_at_commit(root, commit) root = root.resolve()
embedded_manifest = manifest_for(files, commit) files = runtime_files(root)
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]] = {
item.name: (item.data, item.executable) for item in files path.relative_to(root).as_posix(): (
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)
@ -154,11 +112,7 @@ def parse_args() -> argparse.Namespace:
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
try: for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch):
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,8 +10,6 @@ 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))
@ -24,7 +22,7 @@ def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest() return hashlib.sha256(data).hexdigest()
def verify(input_dir: Path, commit: str, repository: Path) -> None: def verify(input_dir: Path, commit: str) -> 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")
@ -47,11 +45,6 @@ def verify(input_dir: Path, commit: str, repository: Path) -> 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:
@ -74,22 +67,19 @@ def verify(input_dir: Path, commit: str, repository: Path) -> 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, args.repository) verify(args.input_dir, args.commit)
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,10 +41,7 @@ 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 ( assert 'python3 scripts/verify_release.py --input-dir dist --commit "$TARGET"' in release
'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,24 +20,7 @@ def _fixture(root: Path) -> None:
(root / "README.md").write_text("# Stackchain\n") (root / "README.md").write_text("# Stackchain\n")
def _commit_fixture(source: Path) -> str: def _build(source: Path, output: Path) -> subprocess.CompletedProcess[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,
@ -47,7 +30,7 @@ def _build(source: Path, output: Path, commit: str) -> subprocess.CompletedProce
"--output-dir", "--output-dir",
str(output), str(output),
"--commit", "--commit",
commit, "a" * 40,
"--source-date-epoch", "--source-date-epoch",
"1720000000", "1720000000",
], ],
@ -57,20 +40,14 @@ def _build(source: Path, output: Path, commit: str) -> subprocess.CompletedProce
) )
def test_release_bundle_is_reproducible_from_declared_commit(tmp_path): def test_release_bundle_is_reproducible(tmp_path):
source = tmp_path / "source" source = tmp_path / "source"
_fixture(source) _fixture(source)
commit = _commit_fixture(source)
first = _build(source, tmp_path / "first", commit) first = _build(source, tmp_path / "first")
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"))
@ -78,7 +55,7 @@ def test_release_bundle_is_reproducible_from_declared_commit(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"] == commit assert manifest["commit"] == "a" * 40
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"]) == [
@ -97,40 +74,19 @@ def test_release_bundle_is_reproducible_from_declared_commit(tmp_path):
"requirements.txt", "requirements.txt",
"src/main.py", "src/main.py",
] ]
assert embedded["commit"] == commit assert embedded["commit"] == "a" * 40
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)
commit = _commit_fixture(source) built = _build(source, output)
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,
@ -140,18 +96,9 @@ 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,
) )
assert tampered.returncode != 0 assert tampered.returncode != 0