Gate release candidates on tested, verifiable artifacts #340
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
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")
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user