Skip to content

Commit 7fbc024

Browse files
SecAI-Hubclaude
andcommitted
Epic 1: Appliance-grade installation artifacts in release pipeline
Add ISO, QCOW2, and OVA build jobs to the release workflow: - build-iso: Uses ublue-os/isogenerator on standard ubuntu runner to produce signed bootable ISO. Required artifact — release fails if this job fails. - build-vm-images: Uses existing build-qcow2.sh + build-ova.sh on self-hosted KVM runner. Gated behind vars.HAS_KVM_RUNNER. Optional — release succeeds without this when the variable is unset. Supporting changes: - build-qcow2.sh: add --ci and --image-ref flags for CI automation - Release manifest: install_artifacts section with per-artifact hash, size, and signature file (conditionally populated) - verify-release.sh: Step 5 verifies cosign blob signatures on ISO/QCOW2/OVA if present, skips gracefully if absent - sample-release-bundle.md: document new artifact types, note QCOW2/OVA may be absent, reference release-artifacts.json as source of truth - Release files glob: include secai-os-*.{iso,qcow2,ova} + .sig files Tests: 25 new tests in test_release_artifacts.py covering workflow structure, artifact consistency, docs coverage, and script flags. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cc759ce commit 7fbc024

5 files changed

Lines changed: 335 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,97 @@ jobs:
148148
name: python-sboms
149149
path: dist/
150150

151+
build-iso:
152+
name: Build ISO (isogenerator)
153+
needs: [preflight]
154+
runs-on: ubuntu-latest
155+
steps:
156+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
157+
158+
- name: Build ISO
159+
uses: ublue-os/isogenerator@main
160+
id: isogen
161+
with:
162+
ARCH: x86_64
163+
IMAGE_REPO: ghcr.io/secai-hub
164+
IMAGE_NAME: secai_os
165+
IMAGE_TAG: ${{ github.ref_name }}
166+
VERSION: 42
167+
VARIANT: Silverblue
168+
169+
- name: Install cosign
170+
run: |
171+
COSIGN_VERSION="v2.4.3"
172+
curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \
173+
-o /usr/local/bin/cosign
174+
chmod +x /usr/local/bin/cosign
175+
176+
- name: Rename and sign ISO
177+
run: |
178+
ISO_SRC="${{ steps.isogen.outputs.iso-path }}"
179+
ISO_DST="dist/secai-os-${{ github.ref_name }}-x86_64.iso"
180+
mkdir -p dist
181+
mv "$ISO_SRC" "$ISO_DST"
182+
cosign sign-blob --yes \
183+
--key env://COSIGN_PRIVATE_KEY \
184+
--output-signature "${ISO_DST}.sig" \
185+
"$ISO_DST"
186+
env:
187+
COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }}
188+
189+
- name: Upload ISO artifact
190+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
191+
with:
192+
name: iso-amd64
193+
path: dist/secai-os-*.iso*
194+
195+
build-vm-images:
196+
name: Build VM Images (QCOW2 + OVA)
197+
needs: [preflight]
198+
if: vars.HAS_KVM_RUNNER == 'true'
199+
runs-on: [self-hosted, linux, x64, kvm]
200+
steps:
201+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
202+
203+
- name: Build QCOW2
204+
run: |
205+
bash scripts/vm/build-qcow2.sh --ci \
206+
--image-ref "ghcr.io/secai-hub/secai_os:${{ github.ref_name }}"
207+
208+
- name: Build OVA from QCOW2
209+
run: bash scripts/vm/build-ova.sh
210+
211+
- name: Install cosign
212+
run: |
213+
COSIGN_VERSION="v2.4.3"
214+
curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \
215+
-o /usr/local/bin/cosign
216+
chmod +x /usr/local/bin/cosign
217+
218+
- name: Sign VM artifacts
219+
run: |
220+
mkdir -p dist
221+
cp output/secai-os.qcow2 "dist/secai-os-${{ github.ref_name }}.qcow2"
222+
cp output/secai-os.ova "dist/secai-os-${{ github.ref_name }}.ova"
223+
for f in dist/secai-os-${{ github.ref_name }}.qcow2 dist/secai-os-${{ github.ref_name }}.ova; do
224+
cosign sign-blob --yes \
225+
--key env://COSIGN_PRIVATE_KEY \
226+
--output-signature "${f}.sig" \
227+
"$f"
228+
done
229+
env:
230+
COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }}
231+
232+
- name: Upload VM artifacts
233+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
234+
with:
235+
name: vm-images
236+
path: dist/secai-os-*
237+
151238
provenance:
152239
name: SLSA Provenance & Attestation
153240
runs-on: ubuntu-latest
154-
needs: [build-go, build-python]
241+
needs: [build-go, build-python, build-iso]
155242
steps:
156243
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
157244

@@ -261,6 +348,24 @@ jobs:
261348
}
262349
}' > RELEASE_MANIFEST.json
263350
351+
# Add install artifacts (conditionally present)
352+
INSTALL_JSON="{}"
353+
for artifact in secai-os-*.iso secai-os-*.qcow2 secai-os-*.ova; do
354+
[ -f "$artifact" ] || continue
355+
HASH=$(sha256sum "$artifact" | awk '{print $1}')
356+
SIZE=$(stat -c%s "$artifact" 2>/dev/null || stat -f%z "$artifact" 2>/dev/null || echo 0)
357+
TYPE="${artifact##*.}"
358+
INSTALL_JSON=$(echo "$INSTALL_JSON" | jq \
359+
--arg type "$TYPE" --arg name "$artifact" \
360+
--arg sha256 "$HASH" --arg size "$SIZE" \
361+
--arg sig "${artifact}.sig" \
362+
'. + {($type): {"name": $name, "sha256": $sha256, "size_bytes": ($size | tonumber), "signature": $sig}}')
363+
done
364+
# Merge install_artifacts into manifest
365+
jq --argjson install "$INSTALL_JSON" \
366+
'. + {install_artifacts: $install}' RELEASE_MANIFEST.json > RELEASE_MANIFEST.json.tmp
367+
mv RELEASE_MANIFEST.json.tmp RELEASE_MANIFEST.json
368+
264369
echo "--- RELEASE_MANIFEST.json ---"
265370
cat RELEASE_MANIFEST.json
266371
@@ -309,5 +414,11 @@ jobs:
309414
dist/IMAGE_DIGEST
310415
dist/IMAGE_REF_PINNED
311416
dist/RELEASE_MANIFEST.json
417+
dist/secai-os-*.iso
418+
dist/secai-os-*.iso.sig
419+
dist/secai-os-*.qcow2
420+
dist/secai-os-*.qcow2.sig
421+
dist/secai-os-*.ova
422+
dist/secai-os-*.ova.sig
312423
generate_release_notes: true
313424
fail_on_unmatched_files: false

docs/sample-release-bundle.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,23 @@ v1.0.0/
5050
# Release manifest (machine-readable)
5151
RELEASE_MANIFEST.json # structured JSON: image, binaries, SBOMs, provenance, build metadata
5252
53+
# Install artifacts (bootable images)
54+
secai-os-v1.0.0-x86_64.iso # Bootable ISO (from isogenerator)
55+
secai-os-v1.0.0-x86_64.iso.sig # cosign detached signature
56+
secai-os-v1.0.0.qcow2 # QCOW2 disk image (optional — requires KVM build infra)
57+
secai-os-v1.0.0.qcow2.sig # cosign detached signature
58+
secai-os-v1.0.0.ova # OVA appliance (optional — requires KVM build infra)
59+
secai-os-v1.0.0.ova.sig # cosign detached signature
60+
5361
# Checksums and signature
5462
SHA256SUMS # sha256sum of every artifact above (includes RELEASE_MANIFEST.json)
5563
SHA256SUMS.sig # cosign detached signature over SHA256SUMS
5664
```
5765

66+
> **Note:** QCOW2 and OVA artifacts may be absent if the repository does not have
67+
> a self-hosted KVM runner. The ISO is always produced. See `docs/release-artifacts.json`
68+
> for the machine-readable artifact specification.
69+
5870
## Image Digest
5971

6072
The container image is published to `ghcr.io/secai-hub/secai_os` and is identified by its SHA256 digest:

files/scripts/verify-release.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,43 @@ fi
330330

331331
echo ""
332332

333+
# ---------------------------------------------------------------------------
334+
# Step 5: Verify install artifact signatures (optional)
335+
# ---------------------------------------------------------------------------
336+
info "Step 5: Verifying install artifact signatures (if present)..."
337+
338+
install_artifacts_found=0
339+
for pattern in "secai-os-*.iso" "secai-os-*.qcow2" "secai-os-*.ova"; do
340+
for artifact in $pattern; do
341+
[ -f "$artifact" ] || continue
342+
install_artifacts_found=$((install_artifacts_found + 1))
343+
sig_file="${artifact}.sig"
344+
if [[ -f "$sig_file" ]]; then
345+
if cosign verify-blob \
346+
--key "${COSIGN_PUB_KEY}" \
347+
--signature "$sig_file" \
348+
"$artifact" \
349+
>/dev/null 2>&1; then
350+
pass "Install artifact signature OK: ${artifact}"
351+
record_check 5 "install_sig_${artifact}" "PASS"
352+
else
353+
fail "Install artifact signature FAILED: ${artifact}"
354+
record_check 5 "install_sig_${artifact}" "FAIL"
355+
fi
356+
else
357+
warn "No .sig file for ${artifact} — cannot verify signature"
358+
record_check 5 "install_sig_${artifact}" "SKIP" "no .sig file"
359+
fi
360+
done
361+
done
362+
363+
if [[ $install_artifacts_found -eq 0 ]]; then
364+
info "No install artifacts (ISO/QCOW2/OVA) found — skipping Step 5"
365+
record_check 5 "install_artifacts" "SKIP" "no install artifacts present"
366+
fi
367+
368+
echo ""
369+
333370
# ---------------------------------------------------------------------------
334371
# Summary
335372
# ---------------------------------------------------------------------------

scripts/vm/build-qcow2.sh

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@
1414
#
1515
set -euo pipefail
1616

17-
OUTPUT_DIR="${1:-./output}"
17+
# Parse flags
18+
CI_MODE=false
19+
CUSTOM_IMAGE_REF=""
20+
POSITIONAL_ARGS=()
21+
22+
while [[ $# -gt 0 ]]; do
23+
case "$1" in
24+
--ci) CI_MODE=true; shift ;;
25+
--image-ref) CUSTOM_IMAGE_REF="$2"; shift 2 ;;
26+
--image-ref=*) CUSTOM_IMAGE_REF="${1#*=}"; shift ;;
27+
*) POSITIONAL_ARGS+=("$1"); shift ;;
28+
esac
29+
done
30+
31+
OUTPUT_DIR="${POSITIONAL_ARGS[0]:-./output}"
1832
IMAGE_NAME="secai-os"
1933
DISK_SIZE="64G"
2034
VAULT_SIZE="32G"
2135

22-
# SecAI OS container image
23-
CONTAINER_IMAGE="ghcr.io/secai-hub/secai_os:latest"
36+
# SecAI OS container image (override with --image-ref for CI)
37+
CONTAINER_IMAGE="${CUSTOM_IMAGE_REF:-ghcr.io/secai-hub/secai_os:latest}"
2438

2539
# Generate random passwords for VM build (never hardcoded)
2640
SECAI_VM_PASSWORD="${SECAI_VM_PASSWORD:-$(openssl rand -base64 18)}"

tests/test_release_artifacts.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for Epic 1 — Release Artifact Consistency.
2+
3+
Validates that the release workflow, sample-release-bundle docs, and
4+
release-artifacts.json are all consistent with each other.
5+
"""
6+
7+
import json
8+
import re
9+
from pathlib import Path
10+
11+
import yaml
12+
13+
REPO_ROOT = Path(__file__).parent.parent
14+
RELEASE_YML = REPO_ROOT / ".github" / "workflows" / "release.yml"
15+
ARTIFACTS_JSON = REPO_ROOT / "docs" / "release-artifacts.json"
16+
SAMPLE_BUNDLE = REPO_ROOT / "docs" / "sample-release-bundle.md"
17+
VERIFY_RELEASE = REPO_ROOT / "files" / "scripts" / "verify-release.sh"
18+
19+
20+
def _load_artifacts_json():
21+
return json.loads(ARTIFACTS_JSON.read_text(encoding="utf-8"))
22+
23+
24+
def _read_release_yml():
25+
return RELEASE_YML.read_text(encoding="utf-8")
26+
27+
28+
class TestReleaseArtifactsJson:
29+
def test_file_exists(self):
30+
assert ARTIFACTS_JSON.exists()
31+
32+
def test_valid_json(self):
33+
data = _load_artifacts_json()
34+
assert "schema_version" in data
35+
36+
def test_canonical_image_ref(self):
37+
data = _load_artifacts_json()
38+
assert data["canonical_image_ref"] == "ghcr.io/secai-hub/secai_os"
39+
40+
def test_go_services_match_release_matrix(self):
41+
"""Go services in artifacts.json must match release.yml matrix."""
42+
data = _load_artifacts_json()
43+
release_content = _read_release_yml()
44+
45+
# Extract matrix services from release.yml
46+
match = re.search(r"service: \[([^\]]+)\]", release_content)
47+
assert match, "Cannot find service matrix in release.yml"
48+
release_services = sorted(s.strip() for s in match.group(1).split(","))
49+
artifact_services = sorted(data["go_services"])
50+
51+
assert release_services == artifact_services, (
52+
f"Mismatch: release.yml has {release_services}, "
53+
f"artifacts.json has {artifact_services}"
54+
)
55+
56+
def test_all_nine_go_services(self):
57+
data = _load_artifacts_json()
58+
assert len(data["go_services"]) == 9
59+
60+
def test_all_six_python_services(self):
61+
data = _load_artifacts_json()
62+
assert len(data["python_services"]) == 6
63+
64+
def test_both_architectures(self):
65+
data = _load_artifacts_json()
66+
assert "linux-amd64" in data["architectures"]
67+
assert "linux-arm64" in data["architectures"]
68+
69+
70+
class TestReleaseWorkflowStructure:
71+
def test_has_build_iso_job(self):
72+
content = _read_release_yml()
73+
assert "build-iso:" in content
74+
75+
def test_has_build_vm_images_job(self):
76+
content = _read_release_yml()
77+
assert "build-vm-images:" in content
78+
79+
def test_vm_images_gated_on_kvm_runner(self):
80+
content = _read_release_yml()
81+
assert "HAS_KVM_RUNNER" in content
82+
83+
def test_provenance_needs_build_iso(self):
84+
content = _read_release_yml()
85+
# Provenance job should depend on build-iso
86+
assert "build-iso" in content
87+
88+
def test_release_files_include_iso(self):
89+
content = _read_release_yml()
90+
assert "secai-os-*.iso" in content
91+
92+
def test_release_files_include_vm(self):
93+
content = _read_release_yml()
94+
assert "secai-os-*.qcow2" in content
95+
assert "secai-os-*.ova" in content
96+
97+
def test_release_files_include_signatures(self):
98+
content = _read_release_yml()
99+
assert "*.iso.sig" in content
100+
101+
def test_manifest_includes_install_artifacts(self):
102+
content = _read_release_yml()
103+
assert "install_artifacts" in content
104+
105+
106+
class TestSampleReleaseBundle:
107+
def test_mentions_iso(self):
108+
content = SAMPLE_BUNDLE.read_text(encoding="utf-8")
109+
assert ".iso" in content
110+
111+
def test_mentions_qcow2(self):
112+
content = SAMPLE_BUNDLE.read_text(encoding="utf-8")
113+
assert ".qcow2" in content
114+
115+
def test_mentions_ova(self):
116+
content = SAMPLE_BUNDLE.read_text(encoding="utf-8")
117+
assert ".ova" in content
118+
119+
def test_mentions_optional_vm_artifacts(self):
120+
"""Docs must note that QCOW2/OVA may be absent."""
121+
content = SAMPLE_BUNDLE.read_text(encoding="utf-8")
122+
assert "absent" in content.lower() or "optional" in content.lower()
123+
124+
def test_references_artifacts_json(self):
125+
content = SAMPLE_BUNDLE.read_text(encoding="utf-8")
126+
assert "release-artifacts.json" in content
127+
128+
129+
class TestVerifyReleaseScript:
130+
def test_has_step5_install_artifacts(self):
131+
content = VERIFY_RELEASE.read_text(encoding="utf-8")
132+
assert "Step 5" in content
133+
assert "install artifact" in content.lower()
134+
135+
def test_handles_missing_artifacts_gracefully(self):
136+
content = VERIFY_RELEASE.read_text(encoding="utf-8")
137+
# Must skip gracefully when no install artifacts present
138+
assert "SKIP" in content or "skipping" in content.lower()
139+
140+
def test_verifies_cosign_blob(self):
141+
content = VERIFY_RELEASE.read_text(encoding="utf-8")
142+
# Step 5 should use cosign verify-blob for install artifacts
143+
assert "cosign verify-blob" in content
144+
145+
146+
class TestBuildQcow2Script:
147+
def test_supports_ci_flag(self):
148+
content = (REPO_ROOT / "scripts" / "vm" / "build-qcow2.sh").read_text(
149+
encoding="utf-8"
150+
)
151+
assert "--ci" in content
152+
153+
def test_supports_image_ref_flag(self):
154+
content = (REPO_ROOT / "scripts" / "vm" / "build-qcow2.sh").read_text(
155+
encoding="utf-8"
156+
)
157+
assert "--image-ref" in content

0 commit comments

Comments
 (0)