Skip to content

release: v1.2.1780449609 #110

release: v1.2.1780449609

release: v1.2.1780449609 #110

Workflow file for this run

name: Release
on:
push:
tags: ['v*']
permissions:
contents: write
attestations: write
id-token: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
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 }}
APPLE_INSTALLER_SIGNING_IDENTITY: ${{ secrets.APPLE_INSTALLER_SIGNING_IDENTITY }}
run: |
if [ -z "$APPLE_INSTALLER_SIGNING_IDENTITY" ]; then
echo "::error::APPLE_INSTALLER_SIGNING_IDENTITY secret is not set"
exit 1
fi
case "$APPLE_INSTALLER_SIGNING_IDENTITY" in
"Developer ID Installer:"*) ;;
*)
echo "::error::APPLE_INSTALLER_SIGNING_IDENTITY must name a Developer ID Installer identity"
exit 1
;;
esac
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 "codesigning identities:"
echo "$IDENTITY"
# Installer cert is not a codesigning identity; check with full list.
INSTALLER=$(security find-identity -v "$KEYCHAIN" | grep "Developer ID Installer" || true)
if [ -z "$INSTALLER" ]; then
echo "::error::No Developer ID Installer identity found. productsign (.pkg signing) will fail."
echo "::error::Export a combined p12 containing both Application and Installer certs."
exit 1
fi
echo "installer identity: $INSTALLER"
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: Normalize cargo proxy
run: bash scripts/ci/normalize-cargo.sh
- name: Build VM assets (kernel + rootfs)
run: |
just build-kernel ${{ matrix.arch }}
just build-rootfs ${{ matrix.arch }}
- name: Validate rootfs contains all required artifacts
run: scripts/validate-rootfs.sh assets/${{ matrix.arch }}/rootfs.squashfs
- 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
- name: Normalize cargo proxy
run: bash scripts/ci/normalize-cargo.sh
- uses: Swatinem/rust-cache@v2
with:
key: test
- uses: pnpm/action-setup@v5
with:
version: 10
- 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
# pnpm audit: block on high/critical only. Moderate findings in
# dev-only transitive deps (e.g. postcss via vite) keep appearing
# between releases and don't reflect runtime exposure for Capsem.
cd frontend && pnpm audit --audit-level=high
- 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: Warnings-as-errors (all crates)
run: cargo check --workspace
- 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
test-install:
needs: [preflight, build-assets]
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v5
- uses: actions/download-artifact@v8
with:
name: vm-assets-arm64
path: assets/arm64/
- uses: extractions/setup-just@v3
- name: Install Linux host-build deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libssl-dev \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
pkg-config \
build-essential \
b3sum \
minisign
- name: Build host builder Docker image
run: just build-host-image
- name: Run install e2e tests
run: just test-install
build-app-macos:
needs: [preflight, build-assets, test, test-install]
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/
- uses: actions/download-artifact@v8
with:
name: vm-assets-x86_64
path: assets/x86_64/
# Regenerate unified manifest for both arch dirs.
- 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')
"
- name: Sign package payload manifest
run: |
brew install minisign
echo "$MINISIGN_SECRET_KEY" > /tmp/manifest-sign.key
minisign -S -s /tmp/manifest-sign.key -m assets/manifest.json
rm /tmp/manifest-sign.key
minisign -Vm assets/manifest.json -x assets/manifest.json.minisig -p config/manifest-sign.pub
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
# 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
- name: Normalize cargo proxy
run: bash scripts/ci/normalize-cargo.sh
- uses: Swatinem/rust-cache@v2
with:
key: build-app-macos
- uses: pnpm/action-setup@v5
with:
version: 10
- 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
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 -T /usr/bin/productsign -T /usr/bin/productbuild
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 build context completeness
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 --bundles app --skip-stapling
- name: Build companion binaries
run: |
cargo build --release \
-p capsem \
-p capsem-service \
-p capsem-process \
-p capsem-mcp \
-p capsem-mcp-aggregator \
-p capsem-mcp-builtin \
-p capsem-gateway \
-p capsem-tray
- name: Prepare capsem-admin package payload
run: bash scripts/prepare-admin-cli.sh target/release
- name: Codesign companion binaries
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
for bin in capsem capsem-service capsem-process capsem-mcp capsem-mcp-aggregator capsem-mcp-builtin capsem-gateway capsem-tray; do
codesign --sign "$APPLE_SIGNING_IDENTITY" \
--options runtime \
--timestamp \
--entitlements entitlements.plist \
--force \
"target/release/$bin"
done
- name: Build .pkg installer
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_INSTALLER_SIGNING_IDENTITY: ${{ secrets.APPLE_INSTALLER_SIGNING_IDENTITY }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
export CAPSEM_INSTALL_PROFILE_ASSET_ROOT="https://github.com/google/capsem/releases/download/v${VERSION}/{arch}-{name}"
bash scripts/build-pkg.sh \
"target/release/bundle/macos/Capsem.app" \
"target/release" \
"assets" \
"$VERSION" \
"$APPLE_INSTALLER_SIGNING_IDENTITY"
- name: Verify .pkg payload manifest
run: |
VERSION="${GITHUB_REF_NAME#v}"
EXPANDED="$RUNNER_TEMP/capsem-pkg-expanded"
rm -rf "$EXPANDED"
pkgutil --expand-full "packages/Capsem-$VERSION.pkg" "$EXPANDED"
MANIFEST=$(find "$EXPANDED" -path '*/usr/local/share/capsem/assets/manifest.json' -print -quit)
SIG=$(find "$EXPANDED" -path '*/usr/local/share/capsem/assets/manifest.json.minisig' -print -quit)
if [ -z "$MANIFEST" ] || [ -z "$SIG" ]; then
echo "::error::.pkg payload missing manifest.json or manifest.json.minisig"
exit 1
fi
minisign -Vm "$MANIFEST" -x "$SIG" -p config/manifest-sign.pub
python3 - "$MANIFEST" <<'PY'
import json, sys
data = json.load(open(sys.argv[1]))
arches = data["assets"]["releases"][data["assets"]["current"]]["arches"]
missing = {"arm64", "x86_64"} - set(arches)
if missing:
raise SystemExit(f"manifest missing arch maps: {sorted(missing)}")
PY
- name: Notarize and staple .pkg
env:
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: |
VERSION="${GITHUB_REF_NAME#v}"
xcrun notarytool submit "packages/Capsem-$VERSION.pkg" \
--key "$APPLE_API_KEY_PATH" \
--key-id "$APPLE_API_KEY" \
--issuer "$APPLE_API_ISSUER" \
--wait --timeout 30m
xcrun stapler staple "packages/Capsem-$VERSION.pkg"
xcrun stapler validate "packages/Capsem-$VERSION.pkg"
- name: Verify .pkg signature and Gatekeeper acceptance
run: |
VERSION="${GITHUB_REF_NAME#v}"
pkgutil --check-signature "packages/Capsem-$VERSION.pkg"
spctl -a -vv -t install "packages/Capsem-$VERSION.pkg"
- name: Generate SBOM
run: cargo sbom --output-format spdx_json_2_3 > capsem-sbom.spdx.json
- name: Collect macOS artifacts
run: |
VERSION="${GITHUB_REF_NAME#v}"
mkdir -p release-artifacts
if [ ! -f "packages/Capsem-$VERSION.pkg" ]; then
echo "::error::Capsem-$VERSION.pkg not produced by build-pkg.sh"
exit 1
fi
cp "packages/Capsem-$VERSION.pkg" 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, test, test-install]
strategy:
fail-fast: false
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')
"
- name: Sign package payload manifest
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends minisign zstd
echo "$MINISIGN_SECRET_KEY" > /tmp/manifest-sign.key
minisign -S -s /tmp/manifest-sign.key -m assets/manifest.json
rm /tmp/manifest-sign.key
minisign -Vm assets/manifest.json -x assets/manifest.json.minisig -p config/manifest-sign.pub
env:
MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}
# 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
- name: Normalize cargo proxy
run: bash scripts/ci/normalize-cargo.sh
- 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 \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
xdg-utils \
xvfb
- uses: pnpm/action-setup@v5
with:
version: 10
- 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 rootfs contains all required artifacts
run: scripts/validate-rootfs.sh assets/${{ matrix.arch }}/rootfs.squashfs
- 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: Build companion binaries
run: |
cargo build --release -p capsem -p capsem-service -p capsem-process -p capsem-mcp -p capsem-mcp-aggregator -p capsem-mcp-builtin -p capsem-gateway -p capsem-tray
- name: Prepare capsem-admin package payload
run: bash scripts/prepare-admin-cli.sh target/release
- name: Repack .deb with companion binaries
run: |
VERSION="${GITHUB_REF_NAME#v}"
export CAPSEM_INSTALL_PROFILE_ASSET_ROOT="https://github.com/google/capsem/releases/download/v${VERSION}/{arch}-{name}"
DEB_FILE=$(ls target/release/bundle/deb/*.deb)
bash scripts/repack-deb.sh "$DEB_FILE" "target/release" "assets"
- name: Validate artifacts
run: |
echo "=== Validate deb ==="
dpkg-deb --info target/release/bundle/deb/*.deb
echo "=== Verify companion binaries and signed manifest in deb ==="
VERSION="${GITHUB_REF_NAME#v}"
case "${{ matrix.arch }}" in
arm64) deb_arch=arm64 ;;
x86_64) deb_arch=amd64 ;;
*) echo "::error::unknown release arch ${{ matrix.arch }}" >&2; exit 1 ;;
esac
python3 scripts/verify_deb_payload.py \
target/release/bundle/deb/*.deb \
--version "$VERSION" \
--architecture "$deb_arch" \
--minisign-pubkey config/manifest-sign.pub
- 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, test-install, build-assets, build-app-macos, build-app-linux]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
# Download all platform artifacts. Expected package artifacts are
# release-blocking: missing Linux artifacts must fail before publish.
- 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/
gh release download --pattern manifest.json -D /tmp/prev-manifest 2>/dev/null || true
if [ -f /tmp/prev-manifest/manifest.json ]; then
cp /tmp/prev-manifest/manifest.json unified-assets/manifest.json
fi
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
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Populate v2 manifest binaries.releases[VERSION] with pkg + deb entries
# and merge previous release's assets/binaries so clients can still
# resolve older versions.
- name: Populate and accumulate manifest
run: |
gh release download --pattern manifest.json -D /tmp/prev-manifest 2>/dev/null || true
VERSION="${GITHUB_REF_NAME#v}"
PREV_PATH=""
[ -f /tmp/prev-manifest/manifest.json ] && PREV_PATH=/tmp/prev-manifest/manifest.json
VERSION="$VERSION" PREV_PATH="$PREV_PATH" python3 <<'PY'
import hashlib, json, os
from pathlib import Path
version = os.environ['VERSION']
prev_path = os.environ.get('PREV_PATH') or None
artifacts = Path('release-artifacts')
manifest_path = artifacts / 'manifest.json'
with manifest_path.open() as f:
new = json.load(f)
assert new.get('format') == 2, f"generate_checksums must emit v2 manifest (got {new.get('format')!r})"
new.setdefault('binaries', {}).setdefault('releases', {})
def sha256(p: Path) -> str:
h = hashlib.sha256()
with p.open('rb') as fh:
for chunk in iter(lambda: fh.read(1 << 20), b''):
h.update(chunk)
return h.hexdigest()
binary_files = []
for pattern in ('*.pkg', '*.deb'):
binary_files.extend(sorted(artifacts.glob(pattern)))
if not any(f.suffix == '.pkg' for f in binary_files):
raise SystemExit('No .pkg found in release-artifacts/ -- macOS build must have failed')
debs = [f for f in binary_files if f.suffix == '.deb']
if len(debs) < 2:
raise SystemExit(f'Expected Linux .deb artifacts for both arches, found {len(debs)}')
# Preserve generated metadata (`date`, `deprecated`, `min_assets`)
# while adding package file hashes for the published release.
entry = new['binaries']['releases'].get(version, {})
entry.update({
'version': version,
'files': [
{'name': f.name, 'size': f.stat().st_size, 'sha256': sha256(f)}
for f in binary_files
],
})
new['binaries']['releases'][version] = entry
print(f'Populated binaries.releases[{version}] with {len(entry["files"])} file(s):')
for fd in entry['files']:
print(f' {fd["name"]} {fd["size"]} {fd["sha256"][:16]}...')
if prev_path:
with open(prev_path) as f:
prev = json.load(f)
if prev.get('format') == 2:
for ver, e in prev.get('assets', {}).get('releases', {}).items():
new['assets']['releases'].setdefault(ver, e)
for ver, e in prev.get('binaries', {}).get('releases', {}).items():
new['binaries']['releases'].setdefault(ver, e)
a = len(new['assets']['releases'])
b = len(new['binaries']['releases'])
print(f'Merged: {a} asset releases, {b} binary releases')
else:
print('Previous manifest is v1, skipping merge')
else:
print('No previous manifest, fresh start')
with manifest_path.open('w') as f:
json.dump(new, f, indent=2)
PY
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 (packages, signed manifest, boot assets)
uses: actions/attest-build-provenance@v4
with:
subject-path: |
release-artifacts/*.pkg
release-artifacts/*.deb
release-artifacts/manifest.json
release-artifacts/manifest.json.minisig
release-artifacts/arm64/vmlinuz
release-artifacts/arm64/initrd.img
release-artifacts/arm64/rootfs.squashfs
release-artifacts/x86_64/vmlinuz
release-artifacts/x86_64/initrd.img
release-artifacts/x86_64/rootfs.squashfs
- name: Attest SBOM
uses: actions/attest@v4
with:
subject-path: |
release-artifacts/*.pkg
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}"
PKG=$(ls -1 release-artifacts/*.pkg 2>/dev/null | head -1)
PKG_NAME=$(basename "$PKG" 2>/dev/null || echo "N/A")
PKG_SIZE=$(du -h "$PKG" 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 required Linux 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
if [ -z "$LINUX_ROWS" ]; then
echo "::error::No .deb artifacts found"
exit 1
fi
cat >> "$GITHUB_STEP_SUMMARY" << EOF
## Release $VERSION
### Artifacts
| File | Size |
|------|------|
| $PKG_NAME | $PKG_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), notarized + stapled (.pkg)
- SLSA build provenance attested (pkg + deb + rootfs)
- SBOM attested (SPDX 2.3, pkg)
- 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 the .pkg + manifest, then upload the required
# Linux .deb files.
gh release create ${{ github.ref_name }} \
release-artifacts/*.pkg \
release-artifacts/manifest.json release-artifacts/manifest.json.minisig \
release-artifacts/capsem-sbom.spdx.json \
--title "Capsem ${{ github.ref_name }}" \
--notes "$NOTES"
for deb in release-artifacts/*.deb; do
[ -f "$deb" ] && gh release upload ${{ github.ref_name }} "$deb"
done
# 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
# ---------------------------------------------------------------------------
# Post-release: verify every asset URL the binary will request actually
# resolves on the live GitHub Release, AND that the just-released .deb's
# `capsem update --assets` succeeds end-to-end against real GitHub. This
# is the layer that was missing when v1.0.1777065213 shipped a downloader
# pointing at /v<asset_version>/... (404). If this job had existed it would
# have failed on that release.
# ---------------------------------------------------------------------------
verify-release-downloads:
needs: [create-release]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: Install verification tools
run: |
sudo apt-get update
sudo apt-get install -y minisign zstd
- name: Wait for release assets to be queryable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for i in $(seq 1 30); do
if gh release view "${{ github.ref_name }}" --json assets --jq '.assets | length' >/dev/null 2>&1; then
break
fi
sleep 2
done
- name: Verify every URL in the published manifest is reachable
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p /tmp/verify
gh release download "${{ github.ref_name }}" --pattern manifest.json -D /tmp/verify
gh release download "${{ github.ref_name }}" --pattern manifest.json.minisig -D /tmp/verify
minisign -Vm /tmp/verify/manifest.json \
-x /tmp/verify/manifest.json.minisig \
-p config/manifest-sign.pub
# The URL contract: <base>/v<binary_version>/<arch>-<logical_name>
# where binary_version is the release tag (without leading 'v').
# This MUST match crates/capsem-core/src/asset_manager.rs::asset_download_url.
BASE="https://github.com/google/capsem/releases/download"
TAG="${{ github.ref_name }}"
fail=0
# Iterate every (arch, logical_name) the current asset release declares.
while IFS=$'\t' read -r arch name; do
url="$BASE/$TAG/$arch-$name"
code=$(curl -sIL -o /dev/null -w "%{http_code}" "$url")
if [ "$code" != "200" ]; then
echo "::error::$url -> HTTP $code"
fail=1
else
echo "ok $url"
fi
done < <(python3 -c "
import json
m = json.load(open('/tmp/verify/manifest.json'))
cur = m['assets']['current']
for arch, files in m['assets']['releases'][cur]['arches'].items():
for name in files:
print(f'{arch}\t{name}')
")
[ "$fail" = 0 ] || { echo '::error::one or more asset URLs are unreachable'; exit 1; }
- name: Verify capsem update --assets succeeds against real GitHub
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Pull the just-released .deb for this runner's arch.
arch=$(uname -m)
[ "$arch" = "aarch64" ] && deb_arch=arm64 || deb_arch=amd64
mkdir -p /tmp/deb
gh release download "${{ github.ref_name }}" \
--pattern "Capsem_*_${deb_arch}.deb" -D /tmp/deb
deb=$(ls /tmp/deb/Capsem_*_${deb_arch}.deb | head -1)
version="${GITHUB_REF_NAME#v}"
python3 scripts/verify_deb_payload.py "$deb" \
--version "$version" \
--architecture "$deb_arch" \
--minisign-pubkey config/manifest-sign.pub
# Extract the bundled capsem binary; we don't need to dpkg -i for this.
mkdir -p /tmp/extract && cd /tmp/extract
ar x "$deb"
for d in data.tar.zst data.tar.xz data.tar.gz; do
[ -f "$d" ] && tar xf "$d" && break
done
# Tauri .deb installs the umbrella binary as capsem-app; the CLI is
# bundled separately under /usr/share/capsem/bin or similar. Find it.
CAPSEM_BIN=$(find . -type f -name capsem -perm -u+x | head -1)
if [ -z "$CAPSEM_BIN" ]; then
echo "::error::no 'capsem' CLI inside .deb"
exit 1
fi
echo "Using $CAPSEM_BIN ($("$CAPSEM_BIN" --version 2>&1 | head -1))"
PKG_MANIFEST=$(find . -path '*/usr/share/capsem/assets/manifest.json' -print -quit)
PKG_SIG=$(find . -path '*/usr/share/capsem/assets/manifest.json.minisig' -print -quit)
if [ -z "$PKG_MANIFEST" ] || [ -z "$PKG_SIG" ]; then
echo "::error::.deb payload missing manifest.json or manifest.json.minisig"
exit 1
fi
minisign -Vm "$PKG_MANIFEST" -x "$PKG_SIG" -p "$GITHUB_WORKSPACE/config/manifest-sign.pub"
# Stand up a clean CAPSEM_HOME using the package payload manifest.
export CAPSEM_HOME=/tmp/capsem-home
mkdir -p "$CAPSEM_HOME/assets" "$CAPSEM_HOME/profiles/base"
cp "$PKG_MANIFEST" "$CAPSEM_HOME/assets/manifest.json"
cp "$PKG_SIG" "$CAPSEM_HOME/assets/manifest.json.minisig"
PKG_PROFILES=$(find . -path '*/usr/share/capsem/profiles/base' -type d -print -quit)
if [ -z "$PKG_PROFILES" ]; then
echo "::error::.deb payload missing base profiles"
exit 1
fi
cp "$PKG_PROFILES/"*.profile.toml "$CAPSEM_HOME/profiles/base/"
# No CAPSEM_RELEASE_URL override -- the binary must hit real GitHub.
"$CAPSEM_BIN" update --assets
# Sanity: at least the host arch's three canonical files must now exist.
host_arch=$( [ "$arch" = "aarch64" ] && echo arm64 || echo x86_64 )
for f in vmlinuz initrd.img rootfs.squashfs; do
count=$(find "$CAPSEM_HOME/assets/$host_arch" -name "${f%.*}-*" 2>/dev/null | wc -l)
[ "$count" -ge 1 ] || { echo "::error::no downloaded file for $f"; exit 1; }
done
echo "End-to-end download verified against live GitHub Release."