name: Release
on:
push:
tags:
- "v*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: write
id-token: write
env:
CARGO_TERM_COLOR: always
GCS_BUCKET: geist-supervisor-releases
TARGET: aarch64-unknown-linux-gnu
jobs:
build-and-release:
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
key: release-aarch64
- name: Install cross
uses: taiki-e/install-action@cross
- name: Determine version info
id: version
run: |
VERSION="${GITHUB_REF_NAME}"
GIT_HASH=$(git rev-parse --short HEAD)
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
if [[ "$VERSION" =~ -(dev|rc|alpha|beta) ]]; then
IS_PRODUCTION=false
else
IS_PRODUCTION=true
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "git_hash=$GIT_HASH" >> "$GITHUB_OUTPUT"
echo "build_date=$BUILD_DATE" >> "$GITHUB_OUTPUT"
echo "is_production=$IS_PRODUCTION" >> "$GITHUB_OUTPUT"
- name: Build for aarch64
run: cross build --release --target ${{ env.TARGET }}
- name: Package release
id: package
run: |
VERSION="${{ steps.version.outputs.version }}"
GIT_HASH="${{ steps.version.outputs.git_hash }}"
BUILD_DATE="${{ steps.version.outputs.build_date }}"
IS_PRODUCTION="${{ steps.version.outputs.is_production }}"
# Strip v prefix for GCS paths (matches geist_supervisor's normalize_version)
NORMALIZED="${VERSION#v}"
RELEASE_DIR="release_output"
BUNDLE_DIR="${RELEASE_DIR}/ota_bundle"
mkdir -p "$BUNDLE_DIR"
# Copy binary
cp "target/${{ env.TARGET }}/release/geist_supervisor" "$BUNDLE_DIR/"
chmod 755 "$BUNDLE_DIR/geist_supervisor"
# Create metadata (same schema as roc_camera ota_metadata.json)
if [ "$IS_PRODUCTION" = "true" ]; then
BUILD_TYPE="production"
IS_DEV="false"
else
BUILD_TYPE="development"
IS_DEV="true"
fi
cat > "$BUNDLE_DIR/ota_metadata.json" << EOF
{
"version": "$VERSION",
"git_hash": "$GIT_HASH",
"build_date": "$BUILD_DATE",
"target_platform": "linux-arm64",
"build_type": "$BUILD_TYPE",
"is_development_build": $IS_DEV,
"binary_name": "geist_supervisor",
"service_name": "geist_supervisor",
"signature_algorithm": "Ed25519-SHA256"
}
EOF
# Generate per-file checksums inside bundle
cd "$BUNDLE_DIR"
sha256sum geist_supervisor ota_metadata.json > checksums.txt
cd - > /dev/null
# Create reproducible tarball
ARCHIVE_NAME="geist-supervisor-${NORMALIZED}-linux-arm64.tar.gz"
ARCHIVE="${RELEASE_DIR}/${ARCHIVE_NAME}"
tar --sort=name --owner=root --group=root --mtime="@0" \
-czf "$ARCHIVE" -C "$BUNDLE_DIR" .
echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT"
echo "archive_name=$ARCHIVE_NAME" >> "$GITHUB_OUTPUT"
echo "normalized=$NORMALIZED" >> "$GITHUB_OUTPUT"
- name: Sign release
env:
OTA_SIGNING_KEY: ${{ secrets.OTA_SIGNING_KEY }}
run: |
if [ -z "$OTA_SIGNING_KEY" ]; then
echo "::warning::OTA_SIGNING_KEY not set — skipping signature"
exit 0
fi
ARCHIVE="${{ steps.package.outputs.archive }}"
KEY_FILE=$(mktemp)
DIGEST_FILE=$(mktemp)
trap 'rm -f "$KEY_FILE" "$DIGEST_FILE"' EXIT
printf '%s' "$OTA_SIGNING_KEY" > "$KEY_FILE"
chmod 600 "$KEY_FILE"
# SHA-256 digest -> Ed25519 sign (same as roc_camera release.sh)
sha256sum "$ARCHIVE" | cut -d' ' -f1 | xxd -r -p > "$DIGEST_FILE"
openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in "$DIGEST_FILE" -out "${ARCHIVE}.sig"
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@v2
- name: Upload to GCS
run: |
NORMALIZED="${{ steps.package.outputs.normalized }}"
IS_PRODUCTION="${{ steps.version.outputs.is_production }}"
ARCHIVE="${{ steps.package.outputs.archive }}"
ARCHIVE_NAME="${{ steps.package.outputs.archive_name }}"
GCS_PREFIX="gs://${{ env.GCS_BUCKET }}/releases/${NORMALIZED}"
# Upload archive + plaintext sha256 for integrity without signature toolchain
gsutil cp "$ARCHIVE" "${GCS_PREFIX}/${ARCHIVE_NAME}"
sha256sum "$ARCHIVE" | cut -d' ' -f1 > /tmp/archive.sha256
gsutil cp /tmp/archive.sha256 "${GCS_PREFIX}/${ARCHIVE_NAME}.sha256"
# Upload signature (if exists)
if [ -f "${ARCHIVE}.sig" ]; then
gsutil cp "${ARCHIVE}.sig" "${GCS_PREFIX}/${ARCHIVE_NAME}.sig"
fi
# Upload checksums and metadata from bundle
gsutil cp release_output/ota_bundle/checksums.txt "${GCS_PREFIX}/checksums.txt"
gsutil cp release_output/ota_bundle/ota_metadata.json "${GCS_PREFIX}/ota_metadata.json"
echo "Uploaded to ${GCS_PREFIX}/"
# Production only: update latest-version.txt (with semver guard)
if [ "$IS_PRODUCTION" = "true" ]; then
CURRENT=$(gsutil cat "gs://${{ env.GCS_BUCKET }}/latest-version.txt" 2>/dev/null || echo "v0.0.0")
NEW="${{ steps.version.outputs.version }}"
# Only update if new version is strictly greater (sort -V puts older first)
HIGHEST=$(printf '%s\n%s\n' "$CURRENT" "$NEW" | sort -V | tail -1)
if [ "$HIGHEST" != "$NEW" ] || [ "$CURRENT" = "$NEW" ]; then
echo "::warning::Skipping latest-version.txt update: $NEW <= $CURRENT"
else
echo "$NEW" > /tmp/latest-version.txt
gsutil cp /tmp/latest-version.txt "gs://${{ env.GCS_BUCKET }}/latest-version.txt"
# Verify
UPLOADED=$(gsutil cat "gs://${{ env.GCS_BUCKET }}/latest-version.txt")
if [ "$UPLOADED" != "$NEW" ]; then
echo "::error::latest-version.txt verification failed (expected $NEW, got $UPLOADED)"
exit 1
fi
echo "latest-version.txt updated to $NEW"
fi
else
echo "Development build — skipping latest-version.txt update"
fi
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ steps.version.outputs.version }}"
IS_PRODUCTION="${{ steps.version.outputs.is_production }}"
# Generate changelog
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
{
echo "# geist_supervisor ${VERSION}"
echo ""
if [ -n "$LAST_TAG" ]; then
echo "## Changes since ${LAST_TAG}"
echo ""
git log --pretty=format:"- %s (%h)" "${LAST_TAG}..HEAD"
fi
} > release_notes.md
PRERELEASE_FLAG=""
if [ "$IS_PRODUCTION" != "true" ]; then
PRERELEASE_FLAG="--prerelease"
fi
# Attach the tarball (and sig if present) to the GitHub release
ASSETS=("${{ steps.package.outputs.archive }}")
if [ -f "${{ steps.package.outputs.archive }}.sig" ]; then
ASSETS+=("${{ steps.package.outputs.archive }}.sig")
fi
gh release create "$VERSION" \
--title "$VERSION" \
--notes-file release_notes.md \
$PRERELEASE_FLAG \
"${ASSETS[@]}"