geist_supervisor 0.1.28

Generic OTA supervisor for field devices
Documentation
name: Release

on:
  push:
    tags:
      - "v*"

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

# contents: write  — create GitHub releases and upload assets
# id-token: write  — GCP Workload Identity Federation OIDC token
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[@]}"