Skip to content

release: v0.16.2

release: v0.16.2 #83

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
attestations: write
id-token: write
jobs:
preflight:
runs-on: macos-14
steps:
- uses: actions/checkout@v5
- name: Select Xcode 16.2
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- name: Import Apple certificate (preflight)
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "$APPLE_CERTIFICATE" | base64 --decode > cert.p12
KEYCHAIN="preflight-$$.keychain"
security create-keychain -p "" "$KEYCHAIN"
security import cert.p12 -k "$KEYCHAIN" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -k "" "$KEYCHAIN"
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN" | grep "Developer ID" || true)
if [ -z "$IDENTITY" ]; then
echo "::error::No Developer ID signing identity found. Check APPLE_CERTIFICATE secret."
echo "::error::If the p12 was created with OpenSSL 3.x, it uses PBES2/AES encryption."
echo "::error::macOS requires legacy 3DES. Re-export with: scripts/fix_p12_legacy.sh"
exit 1
fi
echo "Signing identity: $IDENTITY"
security delete-keychain "$KEYCHAIN"
rm cert.p12
- name: Verify Tauri signing key
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then
echo "::error::TAURI_SIGNING_PRIVATE_KEY secret is not set"
exit 1
fi
echo "Tauri signing key is set"
- name: Verify notarization credentials
env:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_PATH }}
run: |
if [ -z "$APPLE_API_ISSUER" ] || [ -z "$APPLE_API_KEY" ] || [ -z "$APPLE_API_KEY_CONTENT" ]; then
echo "::error::Notarization secrets missing (APPLE_API_ISSUER, APPLE_API_KEY, or APPLE_API_KEY_PATH)"
exit 1
fi
mkdir -p "$RUNNER_TEMP/private_keys"
echo "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8"
xcrun notarytool history \
--key "$RUNNER_TEMP/private_keys/AuthKey_${APPLE_API_KEY}.p8" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
>/dev/null 2>&1 \
&& echo "Notarization credentials verified (notarytool history succeeded)" \
|| { echo "::error::notarytool history failed -- check APPLE_API_ISSUER, APPLE_API_KEY, and APPLE_API_KEY_PATH secrets"; exit 1; }
rm -rf "$RUNNER_TEMP/private_keys"
build-assets:
needs: preflight
strategy:
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
rust-target: aarch64-unknown-linux-musl
- arch: x86_64
runner: ubuntu-24.04
rust-target: x86_64-unknown-linux-musl
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- uses: docker/setup-buildx-action@v4
- uses: astral-sh/setup-uv@v5
- run: uv sync
- uses: extractions/setup-just@v3
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.rust-target }}
- name: Build VM assets (kernel + rootfs)
run: |
just build-kernel ${{ matrix.arch }}
just build-rootfs ${{ matrix.arch }}
- uses: actions/upload-artifact@v7
with:
name: vm-assets-${{ matrix.arch }}
path: assets/${{ matrix.arch }}/
test:
needs: preflight
runs-on: macos-14
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools
- uses: Swatinem/rust-cache@v2
with:
key: test
- uses: pnpm/action-setup@v5
with:
version: 9
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- run: cd frontend && pnpm install --frozen-lockfile
- name: Dependency audit
run: |
cargo install cargo-audit --locked
cargo audit
cd frontend && pnpm audit
- name: Frontend type-check, test, and build
run: cd frontend && pnpm run check && pnpm run test && pnpm run build
- name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov --locked
- name: Create stub assets for Tauri build.rs
run: |
mkdir -p assets/current
touch assets/current/vmlinuz assets/current/initrd.img
echo '{"releases":{}}' > assets/manifest.json
- name: Unit tests with coverage
run: |
cargo llvm-cov --workspace --no-cfg-coverage --codecov --output-path codecov.json 2>&1 | tee test-output.txt
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
files: codecov.json
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Test summary
if: always()
run: |
# Parse "test result: ok. X passed; Y failed" lines from cargo output
TESTS=$(grep -o '[0-9]* passed' test-output.txt 2>/dev/null | awk '{s+=$1} END {print s+0}')
FAILED=$(grep -o '[0-9]* failed' test-output.txt 2>/dev/null | awk '{s+=$1} END {print s+0}')
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## Test Results
| Metric | Result |
|--------|--------|
| Rust tests passed | $TESTS |
| Rust tests failed | $FAILED |
| Frontend tests | vitest |
| Audit | cargo audit + pnpm audit |
EOF
build-app-macos:
needs: [preflight, build-assets]
runs-on: macos-14
steps:
- uses: actions/checkout@v5
- name: Select Xcode 16.2
run: sudo xcode-select -s /Applications/Xcode_16.2.app
- uses: actions/download-artifact@v8
with:
name: vm-assets-arm64
path: assets/arm64/
# Regenerate manifest for this arch (creates assets/current symlink).
- uses: astral-sh/setup-uv@v5
- run: uv sync
- name: Generate manifest
run: |
VERSION="${GITHUB_REF_NAME#v}"
uv run python3 -c "
from pathlib import Path
from capsem.builder.docker import generate_checksums
generate_checksums(Path('assets'), '$VERSION')
"
# Replace symlink with real copy -- GitHub Actions strips symlinks
# and Tauri build.rs needs assets/current/ to exist as a real dir.
- name: Copy assets/current
run: |
rm -rf assets/current
cp -r assets/arm64 assets/current
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: build-app-macos
- uses: pnpm/action-setup@v5
with:
version: 9
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: cd frontend && pnpm build
- name: Install cargo tools
run: cargo install tauri-cli cargo-auditable cargo-sbom --locked
- name: Import Apple certificate
if: env.APPLE_CERTIFICATE != ''
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "$APPLE_CERTIFICATE" | base64 --decode > cert.p12
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
security set-keychain-settings -t 3600 build.keychain
security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -k "" build.keychain
security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"')
- name: Write Apple API key file
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_PATH }}
run: |
mkdir -p "$RUNNER_TEMP/private_keys"
echo "$APPLE_API_KEY_CONTENT" > "$RUNNER_TEMP/private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8"
- name: Verify assets layout
run: |
echo "=== assets/ ==="
ls -la assets/
echo "=== assets/arm64/ ==="
ls -la assets/arm64/
echo "=== assets/current/ ==="
ls -la assets/current/
echo "=== assets/manifest.json ==="
cat assets/manifest.json | head -5
- name: Validate source artifacts (pre-build gate)
run: |
uv run python3 -c "
from capsem.builder.doctor import check_source_files
from pathlib import Path
result = check_source_files(Path('.'))
if not result.passed:
raise SystemExit(f'Source file check failed: {result.detail}')
print(f'OK: {result.detail}')
"
- name: Build and sign app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/private_keys/AuthKey_${{ secrets.APPLE_API_KEY }}.p8
run: |
cd crates/capsem-app
cargo tauri build --skip-stapling
- name: Generate SBOM
run: cargo sbom --output-format spdx_json_2_3 > capsem-sbom.spdx.json
- name: Collect macOS artifacts
run: |
mkdir -p release-artifacts
cp target/release/bundle/dmg/*.dmg release-artifacts/
cp capsem-sbom.spdx.json release-artifacts/
ls -lh release-artifacts/
- uses: actions/upload-artifact@v7
with:
name: release-macos
path: release-artifacts/
build-app-linux:
needs: [preflight, build-assets]
strategy:
matrix:
include:
- arch: arm64
runner: ubuntu-24.04-arm
bundles: deb
- arch: x86_64
runner: ubuntu-24.04
bundles: deb
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
name: vm-assets-${{ matrix.arch }}
path: assets/${{ matrix.arch }}/
# Regenerate manifest for this arch (creates assets/current symlink).
- uses: astral-sh/setup-uv@v5
- run: uv sync
- name: Generate manifest
run: |
VERSION="${GITHUB_REF_NAME#v}"
uv run python3 -c "
from pathlib import Path
from capsem.builder.docker import generate_checksums
generate_checksums(Path('assets'), '$VERSION')
"
# Replace symlink with real copy -- GitHub Actions strips symlinks
# and Tauri build.rs needs assets/current/ to exist as a real dir.
- name: Copy assets/current
run: |
rm -rf assets/current
cp -r assets/${{ matrix.arch }} assets/current
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: build-app-linux-${{ matrix.arch }}
- name: Install Tauri system deps
run: |
sudo apt-get update
sudo apt-get install -y libssl-dev libgtk-3-dev libwebkit2gtk-4.1-dev xdg-utils xvfb
- uses: pnpm/action-setup@v5
with:
version: 9
- uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- run: cd frontend && pnpm install --frozen-lockfile
- name: Build frontend
run: cd frontend && pnpm build
- name: Install cargo tools
run: cargo install tauri-cli cargo-auditable --locked
- name: Verify assets layout
run: |
echo "=== assets/ ==="
ls -la assets/
echo "=== assets/${{ matrix.arch }}/ ==="
ls -la assets/${{ matrix.arch }}/
echo "=== assets/current/ ==="
ls -la assets/current/
echo "=== assets/manifest.json ==="
cat assets/manifest.json | head -5
- name: Validate source artifacts (pre-build gate)
run: |
uv run python3 -c "
from capsem.builder.doctor import check_source_files
from pathlib import Path
result = check_source_files(Path('.'))
if not result.passed:
raise SystemExit(f'Source file check failed: {result.detail}')
print(f'OK: {result.detail}')
"
- name: Validate rootfs contains all required binaries
run: |
ROOTFS="assets/${{ matrix.arch }}/rootfs.squashfs"
MOUNT=$(mktemp -d)
sudo mount -t squashfs -o loop,ro "$ROOTFS" "$MOUNT"
MISSING=""
for bin in capsem-pty-agent capsem-net-proxy capsem-mcp-server \
capsem-doctor capsem-bench snapshots; do
if [ ! -f "$MOUNT/usr/local/bin/$bin" ]; then
MISSING="$MISSING $bin"
fi
done
sudo umount "$MOUNT"
rmdir "$MOUNT"
if [ -n "$MISSING" ]; then
echo "::error::rootfs is missing required binaries:$MISSING"
exit 1
fi
echo "All required binaries present in rootfs"
- name: Build app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
cd crates/capsem-app
cargo tauri build --bundles ${{ matrix.bundles }}
- name: Validate artifacts
run: |
echo "=== Validate deb ==="
dpkg-deb --info target/release/bundle/deb/*.deb
- name: Boot test (x86_64)
if: matrix.arch == 'x86_64'
run: |
ls -l /dev/kvm || echo "KVM MISSING (pre-udev)"
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
ls -l /dev/kvm
# Probe KVM capability: nested/restricted KVM may lack CPUID support
# which makes full VM boot impossible. Skip gracefully.
if python3 -c "
import fcntl, os, struct, array
kvm = os.open('/dev/kvm', os.O_RDWR)
vm = fcntl.ioctl(kvm, 0xAE01) # KVM_CREATE_VM
buf = struct.pack('II', 256, 0) + b'\x00' * (256 * 40)
try:
fcntl.ioctl(vm, 0xC008AE05, buf, True) # KVM_GET_SUPPORTED_CPUID
print('KVM supports CPUID -- boot test will run')
except OSError as e:
print(f'KVM lacks CPUID support ({e}) -- skipping boot test')
os._exit(1)
finally:
os.close(vm); os.close(kvm)
"; then
sudo dpkg -i target/release/bundle/deb/*.deb || sudo apt-get install -f -y
xvfb-run timeout 120 capsem run "capsem-doctor"
else
echo "::warning::Skipping x86_64 boot test -- KVM on this runner lacks full VM support"
fi
- name: Collect Linux artifacts
run: |
mkdir -p release-artifacts
cp target/release/bundle/deb/*.deb release-artifacts/
ls -lh release-artifacts/
- uses: actions/upload-artifact@v7
with:
name: release-linux-${{ matrix.arch }}
path: release-artifacts/
create-release:
needs: [test, build-app-macos, build-app-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Download all platform artifacts.
- uses: actions/download-artifact@v8
with:
name: release-macos
path: release-artifacts/
- uses: actions/download-artifact@v8
with:
name: release-linux-arm64
path: release-artifacts/
- uses: actions/download-artifact@v8
with:
name: release-linux-x86_64
path: release-artifacts/
# Download per-arch VM assets for the release.
- uses: actions/download-artifact@v8
with:
name: vm-assets-arm64
path: release-artifacts/arm64/
- uses: actions/download-artifact@v8
with:
name: vm-assets-x86_64
path: release-artifacts/x86_64/
# Regenerate unified manifest.json from both arch dirs.
- uses: astral-sh/setup-uv@v5
- run: uv sync
- name: Generate unified manifest
run: |
# Set up a temp assets dir with both arch subdirs for checksums.
mkdir -p unified-assets/arm64 unified-assets/x86_64
cp release-artifacts/arm64/* unified-assets/arm64/
cp release-artifacts/x86_64/* unified-assets/x86_64/
VERSION="${GITHUB_REF_NAME#v}"
uv run python3 -c "
from pathlib import Path
from capsem.builder.docker import generate_checksums
generate_checksums(Path('unified-assets'), '$VERSION')
"
cp unified-assets/manifest.json release-artifacts/manifest.json
# Accumulate manifest.json: merge new version's entry with previous releases.
- name: Build accumulated manifest
run: |
gh release download --pattern manifest.json -D /tmp/prev-manifest 2>/dev/null || true
if [ -f /tmp/prev-manifest/manifest.json ]; then
python3 -c "
import json, sys
with open('release-artifacts/manifest.json') as f:
new = json.load(f)
with open('/tmp/prev-manifest/manifest.json') as f:
prev = json.load(f)
for ver, entry in prev.get('releases', {}).items():
if ver not in new['releases']:
new['releases'][ver] = entry
with open('release-artifacts/manifest.json', 'w') as f:
json.dump(new, f, indent=2)
print(f'Merged manifest: {len(new[\"releases\"])} releases')
"
else
echo "No previous manifest found, using fresh manifest"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Sign manifest
run: |
sudo apt-get update && sudo apt-get install -y minisign
echo "$MINISIGN_SECRET_KEY" > /tmp/manifest-sign.key
minisign -S -s /tmp/manifest-sign.key -m release-artifacts/manifest.json
rm /tmp/manifest-sign.key
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
- name: Attest build provenance (DMG + deb + rootfs per arch)
uses: actions/attest-build-provenance@v4
with:
subject-path: |
release-artifacts/*.dmg
release-artifacts/*.deb
release-artifacts/arm64/rootfs.squashfs
release-artifacts/x86_64/rootfs.squashfs
- name: Attest SBOM
uses: actions/attest@v4
with:
subject-path: |
release-artifacts/*.dmg
release-artifacts/*.deb
predicate-type: https://spdx.dev/Document/v2.3
predicate-path: release-artifacts/capsem-sbom.spdx.json
- name: Build summary
run: |
VERSION="${GITHUB_REF_NAME#v}"
DMG=$(ls -1 release-artifacts/*.dmg 2>/dev/null | head -1)
DMG_NAME=$(basename "$DMG" 2>/dev/null || echo "N/A")
DMG_SIZE=$(du -h "$DMG" 2>/dev/null | cut -f1 || echo "N/A")
ARM64_ROOTFS=$(du -h release-artifacts/arm64/rootfs.squashfs 2>/dev/null | cut -f1 || echo "N/A")
X86_ROOTFS=$(du -h release-artifacts/x86_64/rootfs.squashfs 2>/dev/null | cut -f1 || echo "N/A")
SBOM_PKGS=$(python3 -c "import json; d=json.load(open('release-artifacts/capsem-sbom.spdx.json')); print(len(d.get('packages',[])))" 2>/dev/null || echo "?")
# Build artifact table rows for all debs
LINUX_ROWS=""
for f in release-artifacts/*.deb; do
[ -f "$f" ] || continue
NAME=$(basename "$f")
SIZE=$(du -h "$f" | cut -f1)
LINUX_ROWS="${LINUX_ROWS}| ${NAME} | ${SIZE} |
"
done
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## Release $VERSION
### Artifacts
| File | Size |
|------|------|
| $DMG_NAME | $DMG_SIZE |
${LINUX_ROWS}| rootfs.squashfs (arm64) | $ARM64_ROOTFS |
| rootfs.squashfs (x86_64) | $X86_ROOTFS |
| manifest.json | signed (minisign) |
| capsem-sbom.spdx.json | $SBOM_PKGS packages |
### Security
- Apple codesigned (Developer ID)
- Notarization submitted (skip-stapling)
- SLSA build provenance attested (DMG + deb + rootfs)
- SBOM attested (SPDX 2.3, DMG + deb)
- Manifest signed (minisign)
EOF
- name: Create GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Build release notes with tool versions
NOTES="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
NOTES="${NOTES}
### VM Environment
"
if [ -f release-artifacts/arm64/tool-versions.txt ]; then
NOTES="${NOTES}| Tool | Version |
|------|---------|
"
while IFS='=' read -r tool version; do
[ -n "$tool" ] && NOTES="${NOTES}| ${tool} | ${version} |
"
done < release-artifacts/arm64/tool-versions.txt
fi
# Create release with platform-independent artifacts first
gh release create ${{ github.ref_name }} \
release-artifacts/*.dmg release-artifacts/*.deb \
release-artifacts/manifest.json release-artifacts/manifest.json.minisig \
release-artifacts/capsem-sbom.spdx.json \
--title "Capsem ${{ github.ref_name }}" \
--notes "$NOTES"
# Upload per-arch VM assets with arch prefix (both have vmlinuz, initrd.img, etc.)
# gh release upload uses the filename as the asset name, so we must
# rename files to ${arch}-${base} before uploading to avoid collisions.
for arch in arm64 x86_64; do
for f in release-artifacts/$arch/*; do
[ -f "$f" ] || continue
base=$(basename "$f")
mv "$f" "release-artifacts/$arch/${arch}-${base}"
gh release upload ${{ github.ref_name }} "release-artifacts/$arch/${arch}-${base}"
done
done