ci: gate RCs on verifiable artifacts (#339)
This commit is contained in:
parent
9e68382bd7
commit
2cb92811c9
|
|
@ -17,13 +17,69 @@ jobs:
|
||||||
- run: pip install -r requirements.txt
|
- run: pip install -r requirements.txt
|
||||||
- run: python3 -m pytest tests/ -q
|
- run: python3 -m pytest tests/ -q
|
||||||
|
|
||||||
build-frontend:
|
build-release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: lint
|
needs: lint
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Pack frontend
|
- name: Build deterministic release bundle
|
||||||
run: tar -czf frontend.tar.gz frontend
|
run: |
|
||||||
- name: Upload artifact
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
120
scripts/build_release.py
Normal 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
90
scripts/verify_release.py
Normal 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()
|
||||||
|
|
@ -15,3 +15,14 @@ def test_ci_installs_declared_requirements_before_tests():
|
||||||
install = text.index("pip install -r requirements.txt")
|
install = text.index("pip install -r requirements.txt")
|
||||||
tests = text.index("python3 -m pytest tests/ -q")
|
tests = text.index("python3 -m pytest tests/ -q")
|
||||||
assert install < tests
|
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")
|
||||||
|
|
|
||||||
104
tests/test_release_bundle.py
Normal file
104
tests/test_release_bundle.py
Normal 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
|
||||||
|
|
@ -1,24 +1,28 @@
|
||||||
from pathlib import Path
|
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():
|
def test_independent_release_workflow_is_removed():
|
||||||
text = WORKFLOW.read_text()
|
assert not LEGACY_WORKFLOW.exists()
|
||||||
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_release_workflow_targets_merge_commit_and_marks_rc():
|
def test_stale_static_release_manifest_is_removed():
|
||||||
text = WORKFLOW.read_text()
|
assert not STALE_MANIFEST.exists()
|
||||||
assert 'TARGET="${{ github.sha }}"' in text
|
|
||||||
assert '"prerelease":true' in text
|
|
||||||
assert '"draft":false' in text
|
|
||||||
|
|
||||||
|
|
||||||
def test_release_workflow_fails_on_api_error():
|
def test_ci_publishes_a_draft_before_exposing_assets():
|
||||||
text = WORKFLOW.read_text()
|
text = CI_WORKFLOW.read_text()
|
||||||
assert "curl --fail-with-body" in text
|
release = text[text.index(" release-candidate:") :]
|
||||||
assert "|| true" not in text
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user