From 2cb92811c9f272e749ac9013f2411ae84d031d84 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 20:35:52 +0000 Subject: [PATCH] ci: gate RCs on verifiable artifacts (#339) --- .gitea/workflows/ci.yml | 66 ++++++++++++++++-- .gitea/workflows/release.yml | 40 ----------- release/manifest.json | 88 ------------------------ scripts/build_release.py | 120 +++++++++++++++++++++++++++++++++ scripts/verify_release.py | 90 +++++++++++++++++++++++++ tests/test_ci_workflow.py | 11 +++ tests/test_release_bundle.py | 104 ++++++++++++++++++++++++++++ tests/test_release_workflow.py | 34 +++++----- 8 files changed, 405 insertions(+), 148 deletions(-) delete mode 100644 .gitea/workflows/release.yml delete mode 100644 release/manifest.json create mode 100644 scripts/build_release.py create mode 100644 scripts/verify_release.py create mode 100644 tests/test_release_bundle.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 253a37b..8e1dd5c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,13 +17,69 @@ jobs: - run: pip install -r requirements.txt - run: python3 -m pytest tests/ -q - build-frontend: + build-release: runs-on: ubuntu-latest needs: lint steps: - uses: actions/checkout@v4 - - name: Pack frontend - run: tar -czf frontend.tar.gz frontend - - name: Upload artifact + - name: Build deterministic release bundle + run: | + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + python3 scripts/build_release.py \ + --root . \ + --output-dir dist \ + --commit "$GITHUB_SHA" \ + --source-date-epoch "$SOURCE_DATE_EPOCH" + - name: Upload tested release bundle uses: actions/upload-artifact@v3 - with: { name: frontend, path: frontend.tar.gz } + with: + name: release-bundle + path: dist/ + + release-candidate: + runs-on: ubuntu-latest + needs: [lint, build-release] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Download tested release bundle + uses: actions/download-artifact@v3 + with: + name: release-bundle + path: dist + - name: Verify and publish release candidate + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TARGET="${{ github.sha }}" + TAG="v0.1.0-rc.${{ github.run_number }}" + RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" + + (cd dist && sha256sum -c ./*.sha256) + 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' \ + "$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json + curl --fail-with-body -sS -X POST "$RELEASE_URL" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/release.json > /tmp/release-response.json + RELEASE_ID="$(python3 -c 'import json; print(json.load(open("/tmp/release-response.json"))["id"])')" + + for ASSET in dist/*; do + NAME="$(basename "$ASSET")" + ENCODED_NAME="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")" + curl --fail-with-body -sS -X POST "$RELEASE_URL/$RELEASE_ID/assets?name=$ENCODED_NAME" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$ASSET" + done + + printf '{"draft":false,"prerelease":true}\n' > /tmp/publish.json + curl --fail-with-body -sS -X PATCH "$RELEASE_URL/$RELEASE_ID" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/publish.json diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml deleted file mode 100644 index d4296ac..0000000 --- a/.gitea/workflows/release.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Release - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - release-candidate: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Create Gitea tag and release candidate - env: - TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - TAG="v0.1.0-rc.${{ github.run_number }}" - TARGET="${{ github.sha }}" - TAG_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/tags" - RELEASE_URL="${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" - - printf '{"tag_name":"%s","target":"%s","message":"Automated release candidate %s"}\n' \ - "$TAG" "$TARGET" "$TAG" > /tmp/tag.json - curl --fail-with-body -sS -X POST "$TAG_URL" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - --data-binary @/tmp/tag.json - - printf '{"tag_name":"%s","target_commitish":"%s","name":"Release Candidate %s","body":"Automated release candidate for commit %s.","draft":false,"prerelease":true}\n' \ - "$TAG" "$TARGET" "$TAG" "$TARGET" > /tmp/release.json - curl --fail-with-body -sS -X POST "$RELEASE_URL" \ - -H "Authorization: token $TOKEN" \ - -H "Content-Type: application/json" \ - --data-binary @/tmp/release.json diff --git a/release/manifest.json b/release/manifest.json deleted file mode 100644 index 963e0a0..0000000 --- a/release/manifest.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "version": "0.0.1", - "commit": "8ff742c0fe0970d43339b8931054d63b1029591d", - "buildDate": "2026-07-12T12:56:54Z", - "artifacts": [ - { - "path": "./.gitea/workflows/ci.yml", - "type": "file", - "bytes": 662, - "sha256": "6ea3766610118ac34d6e9e8996eb13771ba6428ac8df6969f1150c0e719ed915" - },\n { - "path": "./.gitea/workflows/release.yml", - "type": "file", - "bytes": 884, - "sha256": "647c8d8696fe77a04ab44a051816489a94d57caf33b8184a064fe8218193917c" - },\n { - "path": "./README.md", - "type": "file", - "bytes": 94, - "sha256": "00230b3e64de86031df67093173ae57dd38490b6ca43ce188f046e59756d86e2" - },\n { - "path": "./creative-deliverables/widget-board-5.html", - "type": "file", - "bytes": 799, - "sha256": "380da6f03fc3836ea2db2bbf15cd33a54991a48b67c22c6a4a02d58e1c82323a" - },\n { - "path": "./docs/ops-fundamentals.md", - "type": "file", - "bytes": 1123, - "sha256": "1d4cb0620a1ae2576ba0eac7cd3dc45f74771cc69277a9b39c30a953a2aa81ac" - },\n { - "path": "./frontend/index.html", - "type": "file", - "bytes": 19183, - "sha256": "5177c9fcf406ae13bd571f8d2679d075127af0eca0162f27a9021b209028304a" - },\n { - "path": "./frontend/static/index.html", - "type": "file", - "bytes": 15834, - "sha256": "d4a930b6b6604093481c667a16c8bf55e720bbbab9c0f8f15dcb515b6e3fdba1" - },\n { - "path": "./scripts/gitea_sync.py", - "type": "file", - "bytes": 2987, - "sha256": "15be3cf91384e4bc972d227d96868e92a3a0f50c97cd05fa85eb8f131377321b" - },\n { - "path": "./src/__init__.py", - "type": "file", - "bytes": 0, - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - },\n { - "path": "./src/gitea_proxy.py", - "type": "file", - "bytes": 914, - "sha256": "6af2ba285d5c070e9bbc95c81c5366d375c7d3293302a82bbf1812917e2191e4" - },\n { - "path": "./src/main.py", - "type": "file", - "bytes": 2771, - "sha256": "9f0de0f0ca911744bc6ac1796c84de4c11bb7f709f69a672e228117b322319c7" - },\n { - "path": "./src/models.py", - "type": "file", - "bytes": 850, - "sha256": "f0fdde5620006fb05cfe688607fe6815e424bf9a2b9798586a2c86cb7a62ba5a" - },\n { - "path": "./src/suggestion_engine.py", - "type": "file", - "bytes": 1774, - "sha256": "531d0187cfa348995e45ceacc510ac557d5ae4c7ef3d58fa56dc4b39e08ca3f8" - },\n { - "path": "./src/views.py", - "type": "file", - "bytes": 219, - "sha256": "b511ee830c4ae2c1564a81dfb52da9ded643b0612c3f46e0693fecc05d954b63" - },\n { - "path": "./tests/__init__.py", - "type": "file", - "bytes": 0, - "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - },\n { - "path": "./tests/test_suggestion_engine.py", - "type": "file", - "bytes": 810, - "sha256": "d5c0ad9d51a9ec25556af190fe97a806bdfcee7d27e3f7666c584bbea2e3ef9c" - } - ] -} diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..a2ab0e1 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Build a reproducible, verifiable Stackchain Dashboard release bundle.""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import io +import json +import tarfile +from pathlib import Path + + +RUNTIME_DIRECTORIES = ("docs", "frontend", "src") +RUNTIME_FILES = ("README.md", "requirements.txt") + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def runtime_files(root: Path) -> list[Path]: + files = [root / name for name in RUNTIME_FILES if (root / name).is_file()] + for name in RUNTIME_DIRECTORIES: + directory = root / name + if directory.is_dir(): + 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 manifest_for(root: Path, files: list[Path], commit: str) -> dict: + return { + "schema_version": 1, + "commit": commit, + "files": { + path.relative_to(root).as_posix(): { + "sha256": sha256(path.read_bytes()), + "size": path.stat().st_size, + } + for path in files + }, + } + + +def json_bytes(payload: dict) -> bytes: + return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode() + + +def tar_info(name: str, size: int, epoch: int, executable: bool = False) -> tarfile.TarInfo: + info = tarfile.TarInfo(name) + info.size = size + info.mtime = epoch + info.mode = 0o755 if executable else 0o644 + info.uid = info.gid = 0 + info.uname = info.gname = "root" + return info + + +def build(root: Path, output_dir: Path, commit: str, epoch: int) -> tuple[Path, Path, Path]: + root = root.resolve() + 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]] = { + 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) + + output_dir.mkdir(parents=True, exist_ok=True) + stem = f"stackchain-dashboard-{commit[:12]}" + archive_path = output_dir / f"{stem}.tar.gz" + with archive_path.open("wb") as raw: + with gzip.GzipFile(fileobj=raw, mode="wb", filename="", mtime=epoch) as compressed: + with tarfile.open(fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT) as archive: + for name in sorted(archive_members): + data, executable = archive_members[name] + archive.addfile(tar_info(name, len(data), epoch, executable), io.BytesIO(data)) + + archive_data = archive_path.read_bytes() + digest = sha256(archive_data) + public_manifest = { + **embedded_manifest, + "artifact": { + "name": archive_path.name, + "sha256": digest, + "size": len(archive_data), + }, + } + manifest_path = output_dir / f"{stem}.manifest.json" + checksum_path = output_dir / f"{stem}.sha256" + manifest_path.write_bytes(json_bytes(public_manifest)) + checksum_path.write_text(f"{digest} {archive_path.name}\n") + return archive_path, manifest_path, checksum_path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--output-dir", type=Path, default=Path("dist")) + parser.add_argument("--commit", required=True) + parser.add_argument("--source-date-epoch", type=int, required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + for path in build(args.root, args.output_dir, args.commit, args.source_date_epoch): + print(path) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_release.py b/scripts/verify_release.py new file mode 100644 index 0000000..de471f6 --- /dev/null +++ b/scripts/verify_release.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Verify a Stackchain Dashboard release bundle before promotion.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +import tarfile +from pathlib import Path, PurePosixPath + + +def one(directory: Path, pattern: str) -> Path: + matches = sorted(directory.glob(pattern)) + if len(matches) != 1: + raise ValueError(f"expected exactly one {pattern} file, found {len(matches)}") + return matches[0] + + +def digest(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def verify(input_dir: Path, commit: str) -> None: + archive_path = one(input_dir, "*.tar.gz") + manifest_path = one(input_dir, "*.manifest.json") + checksum_path = one(input_dir, "*.sha256") + manifest = json.loads(manifest_path.read_text()) + + if manifest.get("commit") != commit: + raise ValueError("manifest commit does not match the tested commit") + artifact = manifest.get("artifact", {}) + archive_data = archive_path.read_bytes() + archive_digest = digest(archive_data) + if artifact.get("name") != archive_path.name: + raise ValueError("manifest artifact name does not match the bundle") + if artifact.get("size") != len(archive_data) or artifact.get("sha256") != archive_digest: + raise ValueError("bundle does not match its manifest") + + checksum_parts = checksum_path.read_text().strip().split() + if checksum_parts != [archive_digest, archive_path.name]: + raise ValueError("checksum file does not match the bundle") + + expected_files = manifest.get("files") + if not isinstance(expected_files, dict): + raise ValueError("manifest files must be an object") + with tarfile.open(archive_path, "r:gz") as archive: + members = archive.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not member.isfile(): + raise ValueError("bundle contains an unsafe member") + names = {member.name for member in members} + if names != set(expected_files) | {"release-manifest.json"}: + raise ValueError("bundle contents do not match the manifest") + embedded_file = archive.extractfile("release-manifest.json") + if embedded_file is None: + raise ValueError("bundle manifest is missing") + embedded = json.load(embedded_file) + if embedded.get("commit") != commit or embedded.get("files") != expected_files: + raise ValueError("embedded manifest does not match the public manifest") + for name, metadata in expected_files.items(): + bundled = archive.extractfile(name) + if bundled is None: + raise ValueError(f"bundle member is missing: {name}") + data = bundled.read() + if metadata != {"sha256": digest(data), "size": len(data)}: + raise ValueError(f"bundle member failed verification: {name}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input-dir", type=Path, required=True) + parser.add_argument("--commit", required=True) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + try: + verify(args.input_dir, args.commit) + except (OSError, ValueError, json.JSONDecodeError, tarfile.TarError) as error: + print(f"release verification failed: {error}", file=sys.stderr) + raise SystemExit(1) from error + print(f"verified release bundle for {args.commit}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 2a5b2ac..49ad107 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -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") diff --git a/tests/test_release_bundle.py b/tests/test_release_bundle.py new file mode 100644 index 0000000..004cde9 --- /dev/null +++ b/tests/test_release_bundle.py @@ -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("

Stackchain

\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 \ No newline at end of file diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py index ccdc053..4b35293 100644 --- a/tests/test_release_workflow.py +++ b/tests/test_release_workflow.py @@ -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