Release #12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: ["v*"] | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Dry run (skip publish)" | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| packages: write | |
| id-token: write | |
| attestations: write | |
| checks: read | |
| statuses: read | |
| jobs: | |
| preflight: | |
| name: Release Preflight Check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Verify CI passed on this commit | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| SHA="${{ github.sha }}" | |
| echo "Checking CI status for commit ${SHA}..." | |
| # Query check runs for required CI jobs | |
| REQUIRED_CHECKS=( | |
| "Go Build & Test" | |
| "Python Test & Lint" | |
| "Security Regression Tests" | |
| "Dependency Vulnerability Audit" | |
| "Test Count Drift Check" | |
| "Documentation Validation" | |
| ) | |
| PASS=0 | |
| FAIL=0 | |
| for check in "${REQUIRED_CHECKS[@]}"; do | |
| # Use startswith to handle matrix jobs (e.g. "Go Build & Test (registry)") | |
| RESULT=$(gh api "repos/${{ github.repository }}/commits/${SHA}/check-runs" \ | |
| --jq "[.check_runs[] | select(.name | startswith(\"${check}\")) | .conclusion] | if length == 0 then [\"not_found\"] else . end | if all(. == \"success\") then \"success\" else .[0] end" \ | |
| 2>/dev/null || echo "not_found") | |
| if [ "$RESULT" = "success" ]; then | |
| echo "OK: ${check} = ${RESULT}" | |
| PASS=$((PASS + 1)) | |
| else | |
| echo "::warning::${check}: ${RESULT:-not_found}" | |
| FAIL=$((FAIL + 1)) | |
| fi | |
| done | |
| if [ "$FAIL" -gt 0 ]; then | |
| echo "" | |
| echo "::error::${FAIL} required CI check(s) did not show success." | |
| echo "Cannot create a release from a commit with failing or missing CI checks." | |
| echo "Ensure CI is green before tagging a release." | |
| exit 1 | |
| fi | |
| echo "Preflight complete: ${PASS}/${#REQUIRED_CHECKS[@]} checks confirmed passing" | |
| build-go: | |
| name: Build Go Services | |
| needs: [preflight] | |
| runs-on: ubuntu-latest | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| service: [airlock, registry, tool-firewall, gpu-integrity-watch, mcp-firewall, policy-engine, runtime-attestor, integrity-monitor, incident-recorder] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 | |
| with: | |
| go-version: "1.25" | |
| cache-dependency-path: services/${{ matrix.service }}/go.sum | |
| - name: Build (linux/amd64) | |
| working-directory: services/${{ matrix.service }} | |
| run: | | |
| CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ | |
| go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ | |
| -o ../../dist/${{ matrix.service }}-linux-amd64 . | |
| - name: Build (linux/arm64) | |
| working-directory: services/${{ matrix.service }} | |
| run: | | |
| CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ | |
| go build -ldflags="-s -w -X main.version=${{ github.ref_name }}" \ | |
| -o ../../dist/${{ matrix.service }}-linux-arm64 . | |
| - name: Generate SBOM (Syft) | |
| uses: anchore/sbom-action@57aae528053a48a3f6235f2d9461b05fbcb7366d # v0.23.1 | |
| with: | |
| path: services/${{ matrix.service }} | |
| format: cyclonedx-json | |
| output-file: dist/${{ matrix.service }}-sbom.cdx.json | |
| upload-artifact: false | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: go-${{ matrix.service }} | |
| path: dist/ | |
| build-python: | |
| name: Build Python Service SBOMs | |
| needs: [preflight] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install Syft | |
| run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin | |
| - name: Generate Python SBOMs | |
| run: | | |
| mkdir -p dist | |
| for svc in agent ui quarantine common; do | |
| if [ -d "services/${svc}" ]; then | |
| syft dir:services/${svc} -o cyclonedx-json=dist/${svc}-sbom.cdx.json | |
| fi | |
| done | |
| # Diffusion worker and search mediator | |
| for svc in diffusion-worker search-mediator; do | |
| if [ -d "services/${svc}" ]; then | |
| syft dir:services/${svc} -o cyclonedx-json=dist/${svc}-sbom.cdx.json | |
| fi | |
| done | |
| - name: Upload artifacts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: python-sboms | |
| path: dist/ | |
| build-iso: | |
| name: Build ISO | |
| needs: [preflight] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Free disk space | |
| run: | | |
| sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc | |
| sudo docker image prune -af | |
| - name: Build ISO | |
| uses: jasonn3/build-container-installer@207e927e28c92704c4cdbe10b980643b3771ef01 # main | |
| id: isogen | |
| with: | |
| arch: x86_64 | |
| image_name: secai_os | |
| image_repo: ghcr.io/secai-hub | |
| # Use :latest since BlueBuild tags main pushes as :latest, not :vX.Y.Z. | |
| # The release tag and main HEAD point to the same commit. | |
| image_tag: latest | |
| version: 42 | |
| variant: Silverblue | |
| iso_name: secai-os-${{ github.ref_name }}-x86_64.iso | |
| - name: Install cosign | |
| run: | | |
| COSIGN_VERSION="v2.4.3" | |
| curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \ | |
| -o /usr/local/bin/cosign | |
| chmod +x /usr/local/bin/cosign | |
| - name: Sign ISO | |
| run: | | |
| mkdir -p dist | |
| # Find the ISO — the action may output it in various locations | |
| ISO_FILE=$(find . -maxdepth 2 -name "secai-os-*.iso" -type f | head -1) | |
| if [ -z "$ISO_FILE" ]; then | |
| echo "ERROR: ISO file not found after build" | |
| find . -maxdepth 3 -name "*.iso" -type f | |
| exit 1 | |
| fi | |
| echo "Found ISO: $ISO_FILE" | |
| mv "$ISO_FILE" "dist/" | |
| ISO_BASENAME=$(basename "$ISO_FILE") | |
| cosign sign-blob --yes \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| --output-signature "dist/${ISO_BASENAME}.sig" \ | |
| "dist/${ISO_BASENAME}" | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| - name: Upload ISO artifact | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: iso-amd64 | |
| path: dist/secai-os-*.iso* | |
| build-usb-image: | |
| name: Build Portable USB Image | |
| needs: [preflight] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Free disk space | |
| run: | | |
| sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc | |
| sudo docker image prune -af | |
| - name: Install image build dependencies | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y podman xz-utils | |
| - name: Build portable USB image | |
| run: | | |
| mkdir -p dist | |
| sudo bash scripts/build-usb-image.sh \ | |
| --image-ref "ghcr.io/secai-hub/secai_os:latest" \ | |
| --output-dir ./dist \ | |
| --version "${{ github.ref_name }}" | |
| - name: Install cosign | |
| run: | | |
| COSIGN_VERSION="v2.4.3" | |
| curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \ | |
| -o /usr/local/bin/cosign | |
| chmod +x /usr/local/bin/cosign | |
| - name: Sign portable USB image | |
| run: | | |
| USB_IMAGE=$(find dist -maxdepth 1 -name "secai-os-*-usb.raw.xz" -type f | head -1) | |
| if [ -z "$USB_IMAGE" ]; then | |
| echo "ERROR: portable USB image not found after build" | |
| find dist -maxdepth 2 -type f | |
| exit 1 | |
| fi | |
| cosign sign-blob --yes \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| --output-signature "${USB_IMAGE}.sig" \ | |
| "$USB_IMAGE" | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| - name: Upload portable USB artifact | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: usb-amd64 | |
| path: dist/secai-os-*-usb.raw.xz* | |
| build-vm-images: | |
| name: Build VM Images (QCOW2 + OVA) | |
| needs: [preflight] | |
| if: vars.HAS_KVM_RUNNER == 'true' | |
| runs-on: [self-hosted, linux, x64, kvm] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Build QCOW2 | |
| run: | | |
| bash scripts/vm/build-qcow2.sh --ci \ | |
| --image-ref "ghcr.io/secai-hub/secai_os:${{ github.ref_name }}" | |
| - name: Build OVA from QCOW2 | |
| run: bash scripts/vm/build-ova.sh | |
| - name: Install cosign | |
| run: | | |
| COSIGN_VERSION="v2.4.3" | |
| curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \ | |
| -o /usr/local/bin/cosign | |
| chmod +x /usr/local/bin/cosign | |
| - name: Sign VM artifacts | |
| run: | | |
| mkdir -p dist | |
| cp output/secai-os.qcow2 "dist/secai-os-${{ github.ref_name }}.qcow2" | |
| cp output/secai-os.ova "dist/secai-os-${{ github.ref_name }}.ova" | |
| for f in dist/secai-os-${{ github.ref_name }}.qcow2 dist/secai-os-${{ github.ref_name }}.ova; do | |
| cosign sign-blob --yes \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| --output-signature "${f}.sig" \ | |
| "$f" | |
| done | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| - name: Upload VM artifacts | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: vm-images | |
| path: dist/secai-os-* | |
| provenance: | |
| name: SLSA Provenance & Attestation | |
| runs-on: ubuntu-latest | |
| needs: [build-go, build-python, build-iso, build-usb-image] | |
| steps: | |
| - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| - name: Install cosign | |
| run: | | |
| COSIGN_VERSION="v2.4.3" | |
| curl -sSfL "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \ | |
| -o /usr/local/bin/cosign | |
| chmod +x /usr/local/bin/cosign | |
| cosign version | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| path: dist/ | |
| merge-multiple: true | |
| - name: Record release image digest | |
| run: | | |
| IMAGE_REF="ghcr.io/${{ github.repository }}" | |
| TAG="${{ github.ref_name }}" | |
| DIGEST=$(skopeo inspect "docker://${IMAGE_REF}:${TAG}" 2>/dev/null | jq -r '.Digest' || echo "") | |
| if [ -n "$DIGEST" ] && [ "$DIGEST" != "null" ]; then | |
| echo "${DIGEST}" > dist/IMAGE_DIGEST | |
| echo "${IMAGE_REF}@${DIGEST}" > dist/IMAGE_REF_PINNED | |
| echo "## Install with digest pinning" >> "$GITHUB_STEP_SUMMARY" | |
| echo '```bash' >> "$GITHUB_STEP_SUMMARY" | |
| echo "sudo bash secai-bootstrap.sh --digest ${DIGEST}" >> "$GITHUB_STEP_SUMMARY" | |
| echo '```' >> "$GITHUB_STEP_SUMMARY" | |
| else | |
| echo "WARNING: Could not extract image digest for tag ${TAG}" | |
| echo "unknown" > dist/IMAGE_DIGEST | |
| fi | |
| - name: Generate release manifest | |
| run: | | |
| cd dist | |
| # Collect binary names + SHA256 hashes | |
| BINARIES_JSON="[]" | |
| for bin in *-linux-*; do | |
| [ -f "$bin" ] || continue | |
| HASH=$(sha256sum "$bin" | awk '{print $1}') | |
| BINARIES_JSON=$(echo "$BINARIES_JSON" | jq \ | |
| --arg name "$bin" --arg sha256 "$HASH" \ | |
| '. + [{"name": $name, "sha256": $sha256}]') | |
| done | |
| # Collect SBOM filenames | |
| SBOMS_JSON="[]" | |
| for sbom in *-sbom.cdx.json; do | |
| [ -f "$sbom" ] || continue | |
| SBOMS_JSON=$(echo "$SBOMS_JSON" | jq \ | |
| --arg name "$sbom" \ | |
| '. + [$name]') | |
| done | |
| # Read image digest | |
| IMAGE_DIGEST="unknown" | |
| if [ -f IMAGE_DIGEST ]; then | |
| IMAGE_DIGEST=$(cat IMAGE_DIGEST) | |
| fi | |
| IMAGE_REF_PINNED="" | |
| if [ -f IMAGE_REF_PINNED ]; then | |
| IMAGE_REF_PINNED=$(cat IMAGE_REF_PINNED) | |
| fi | |
| # Build manifest JSON | |
| jq -n \ | |
| --arg schema_version "1" \ | |
| --arg tag "${{ github.ref_name }}" \ | |
| --arg image_ref "ghcr.io/${{ github.repository }}" \ | |
| --arg image_digest "$IMAGE_DIGEST" \ | |
| --arg image_ref_pinned "$IMAGE_REF_PINNED" \ | |
| --argjson binaries "$BINARIES_JSON" \ | |
| --argjson sboms "$SBOMS_JSON" \ | |
| --arg provenance_type "https://slsa.dev/provenance/v1" \ | |
| --arg checksum_file "SHA256SUMS" \ | |
| --arg signature_file "SHA256SUMS.sig" \ | |
| --arg commit_sha "${{ github.sha }}" \ | |
| --arg workflow_run "${{ github.run_id }}" \ | |
| --arg workflow_ref "${{ github.workflow_ref }}" \ | |
| --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| '{ | |
| schema_version: $schema_version, | |
| tag: $tag, | |
| image: { | |
| ref: $image_ref, | |
| digest: $image_digest, | |
| ref_pinned: $image_ref_pinned | |
| }, | |
| binaries: $binaries, | |
| sboms: $sboms, | |
| provenance: { | |
| type: $provenance_type, | |
| attested: true | |
| }, | |
| checksums: { | |
| file: $checksum_file, | |
| signature: $signature_file | |
| }, | |
| build: { | |
| commit_sha: $commit_sha, | |
| workflow_run: $workflow_run, | |
| workflow_ref: $workflow_ref, | |
| timestamp: $timestamp | |
| } | |
| }' > RELEASE_MANIFEST.json | |
| # Add install artifacts (conditionally present) | |
| INSTALL_JSON="{}" | |
| for artifact in secai-os-*.iso secai-os-*.qcow2 secai-os-*.ova secai-os-*-usb.raw.xz; do | |
| [ -f "$artifact" ] || continue | |
| HASH=$(sha256sum "$artifact" | awk '{print $1}') | |
| SIZE=$(stat -c%s "$artifact" 2>/dev/null || stat -f%z "$artifact" 2>/dev/null || echo 0) | |
| TYPE="${artifact##*.}" | |
| if [[ "$artifact" == *.raw.xz ]]; then | |
| TYPE="usb_raw_xz" | |
| fi | |
| INSTALL_JSON=$(echo "$INSTALL_JSON" | jq \ | |
| --arg type "$TYPE" --arg name "$artifact" \ | |
| --arg sha256 "$HASH" --arg size "$SIZE" \ | |
| --arg sig "${artifact}.sig" \ | |
| '. + {($type): {"name": $name, "sha256": $sha256, "size_bytes": ($size | tonumber), "signature": $sig}}') | |
| done | |
| # Merge install_artifacts into manifest | |
| jq --argjson install "$INSTALL_JSON" \ | |
| '. + {install_artifacts: $install}' RELEASE_MANIFEST.json > RELEASE_MANIFEST.json.tmp | |
| mv RELEASE_MANIFEST.json.tmp RELEASE_MANIFEST.json | |
| echo "--- RELEASE_MANIFEST.json ---" | |
| cat RELEASE_MANIFEST.json | |
| - name: Generate SHA256 checksums | |
| run: | | |
| cd dist | |
| sha256sum * > SHA256SUMS | |
| cat SHA256SUMS | |
| - name: Sign checksums with cosign | |
| run: | | |
| cosign sign-blob --yes \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| --output-signature dist/SHA256SUMS.sig \ | |
| dist/SHA256SUMS | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| - name: Attest build provenance | |
| uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 | |
| with: | |
| subject-path: "dist/*-linux-*" | |
| - name: Attest SBOMs | |
| run: | | |
| for sbom in dist/*-sbom.cdx.json; do | |
| service=$(basename "$sbom" -sbom.cdx.json) | |
| cosign attest --yes --type cyclonedx \ | |
| --predicate "$sbom" \ | |
| --key env://COSIGN_PRIVATE_KEY \ | |
| ghcr.io/${{ github.repository }}:${{ github.ref_name }}-${service} || \ | |
| echo "WARN: cosign attest skipped for ${service} (no matching image)" | |
| done | |
| env: | |
| COSIGN_PRIVATE_KEY: ${{ secrets.SIGNING_SECRET }} | |
| - name: Create GitHub Release (binaries + SBOMs + checksums) | |
| if: ${{ !inputs.dry_run }} | |
| uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 | |
| with: | |
| files: | | |
| dist/*-linux-* | |
| dist/*-sbom.cdx.json | |
| dist/SHA256SUMS | |
| dist/SHA256SUMS.sig | |
| dist/IMAGE_DIGEST | |
| dist/IMAGE_REF_PINNED | |
| dist/RELEASE_MANIFEST.json | |
| dist/secai-os-*.iso.sig | |
| dist/secai-os-*-usb.raw.xz.sig | |
| dist/secai-os-*.qcow2.sig | |
| dist/secai-os-*.ova.sig | |
| generate_release_notes: true | |
| fail_on_unmatched_files: false | |
| # Install media files are too large for GitHub Releases (>2GB limit). | |
| # Upload signatures only (above). The full images are available as | |
| # workflow artifacts or should be hosted on external storage. | |
| - name: Note on large artifacts | |
| if: ${{ !inputs.dry_run }} | |
| run: | | |
| echo "## Large Artifacts" >> "$GITHUB_STEP_SUMMARY" | |
| echo "" >> "$GITHUB_STEP_SUMMARY" | |
| echo "Install media files can exceed GitHub Releases' 2GB limit." >> "$GITHUB_STEP_SUMMARY" | |
| echo "Their cosign signatures (.sig) are included in the release." >> "$GITHUB_STEP_SUMMARY" | |
| echo "Full images are available as workflow artifacts (90-day retention)." >> "$GITHUB_STEP_SUMMARY" | |
| for f in dist/secai-os-*.iso dist/secai-os-*-usb.raw.xz dist/secai-os-*.qcow2 dist/secai-os-*.ova; do | |
| [ -f "$f" ] || continue | |
| SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0) | |
| SIZE_MB=$((SIZE / 1048576)) | |
| echo " - $(basename "$f"): ${SIZE_MB} MB" >> "$GITHUB_STEP_SUMMARY" | |
| done |