ci: gate RCs on verifiable artifacts (#339)
All checks were successful
CI / lint (pull_request) Successful in 33s
CI / build-release (pull_request) Successful in 4s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-08 20:35:52 +00:00
parent 9e68382bd7
commit 2cb92811c9
8 changed files with 405 additions and 148 deletions

View File

@ -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

View File

@ -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

View File

@ -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"
}
]
}

120
scripts/build_release.py Normal file
View File

@ -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()

90
scripts/verify_release.py Normal file
View File

@ -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()

View File

@ -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")

View File

@ -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

View File

@ -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