name: Release on: workflow_dispatch: inputs: build_target: description: Build target environment required: true type: choice default: production options: - production - staging release_type: description: Version increment type required: false default: patch type: choice options: - patch - minor - major permissions: contents: write packages: write concurrency: group: release-${{ github.event.inputs.build_target || 'production' }}-main cancel-in-progress: false # --------------------------------------------------------------------------- # Job dependency graph # # prepare-build # │ # ├─── create-release # │ │ # │ ┌────┴────────────┐ # │ │ │ # │ build-desktop build-docker # │ (matrix: macOS, (GHCR image) # │ linux, windows) # │ │ │ # │ └────┬────────────┘ # │ │ # │ publish-release # │ │ # │ notify-discord # │ # └─── cleanup-failed-release (on failure) # --------------------------------------------------------------------------- jobs: # ========================================================================= # Phase 1: Version bump, commit, tag # ========================================================================= prepare-build: name: Prepare build context runs-on: ubuntu-latest environment: Production outputs: version: ${{ steps.resolve.outputs.version }} tag: ${{ steps.resolve.outputs.tag }} sha: ${{ steps.resolve.outputs.sha }} build_ref: ${{ steps.resolve.outputs.build_ref }} release_enabled: ${{ steps.resolve.outputs.release_enabled }} base_url: ${{ steps.resolve.outputs.base_url }} steps: - name: Enforce main branch if: github.ref != 'refs/heads/main' run: | echo "This workflow can only run from main. Current ref: $GITHUB_REF" exit 1 - name: Generate GitHub App token if: inputs.build_target == 'production' id: app-token uses: tibdex/github-app-token@v1 with: app_id: ${{ secrets.XGITHUB_APP_ID }} private_key: ${{ secrets.XGITHUB_APP_PRIVATE_KEY }} - name: Checkout main for production release if: inputs.build_target == 'production' uses: actions/checkout@v4 with: ref: main fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - name: Checkout main for staging build if: inputs.build_target == 'staging' uses: actions/checkout@v4 with: ref: main fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24.x - name: Configure Git if: inputs.build_target == 'production' env: APP_TOKEN: ${{ steps.app-token.outputs.token }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git remote set-url origin https://${APP_TOKEN}@github.com/${GITHUB_REPOSITORY}.git git fetch origin --tags git checkout main git pull origin main --ff-only - name: Compute next version and sync release files if: inputs.build_target == 'production' id: bump run: node scripts/release/bump-version.js "${{ inputs.release_type }}" - name: Verify release version sync if: inputs.build_target == 'production' run: node scripts/release/verify-version-sync.js "${{ steps.bump.outputs.version }}" - name: Ensure tag does not already exist if: inputs.build_target == 'production' env: TAG: ${{ steps.bump.outputs.tag }} run: | if git rev-parse "$TAG" >/dev/null 2>&1; then echo "Tag already exists locally: $TAG" exit 1 fi if git ls-remote --tags origin "refs/tags/$TAG" | grep -q .; then echo "Tag already exists on origin: $TAG" exit 1 fi - name: Commit, push and tag if: inputs.build_target == 'production' id: push env: VERSION: ${{ steps.bump.outputs.version }} TAG: ${{ steps.bump.outputs.tag }} run: | git add app/package.json app/src-tauri/tauri.conf.json app/src-tauri/Cargo.toml Cargo.toml git commit -m "chore(release): v${VERSION}" git push origin main git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Resolve build outputs id: resolve shell: bash run: | if [ "${{ inputs.build_target }}" = "production" ]; then VERSION="${{ steps.bump.outputs.version }}" TAG="${{ steps.bump.outputs.tag }}" SHA="${{ steps.push.outputs.sha }}" BUILD_REF="$TAG" RELEASE_ENABLED="true" BASE_URL="https://api.tinyhumans.ai/" else VERSION="$(node -p "require('./app/package.json').version")" TAG="" SHA="$(git rev-parse HEAD)" BUILD_REF="$SHA" RELEASE_ENABLED="false" BASE_URL="https://staging-api.tinyhumans.ai/" fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "sha=$SHA" >> "$GITHUB_OUTPUT" echo "build_ref=$BUILD_REF" >> "$GITHUB_OUTPUT" echo "release_enabled=$RELEASE_ENABLED" >> "$GITHUB_OUTPUT" echo "base_url=$BASE_URL" >> "$GITHUB_OUTPUT" # ========================================================================= # Phase 2: Create draft GitHub release # ========================================================================= create-release: name: Create GitHub release runs-on: ubuntu-latest environment: Production if: needs.prepare-build.outputs.release_enabled == 'true' needs: prepare-build outputs: release_id: ${{ steps.create.outputs.release_id }} upload_url: ${{ steps.create.outputs.upload_url }} steps: - name: Create draft release with generated notes id: create uses: actions/github-script@v7 with: script: | const tag = '${{ needs.prepare-build.outputs.tag }}'; const version = '${{ needs.prepare-build.outputs.version }}'; const target = '${{ needs.prepare-build.outputs.sha }}'; const { owner, repo } = context.repo; try { await github.rest.repos.getReleaseByTag({ owner, repo, tag }); core.setFailed(`Release already exists for ${tag}`); return; } catch (error) { if (error.status !== 404) { throw error; } } const release = await github.rest.repos.createRelease({ owner, repo, tag_name: tag, target_commitish: target, name: `OpenHuman v${version}`, draft: true, prerelease: false, generate_release_notes: true, }); core.setOutput('release_id', String(release.data.id)); core.setOutput('upload_url', release.data.upload_url); # ========================================================================= # Phase 3a: Build desktop artifacts (parallel matrix) # ========================================================================= build-desktop: name: "Desktop: ${{ matrix.settings.artifact_suffix }}" needs: [prepare-build, create-release] if: >- always() && needs.prepare-build.result == 'success' && (needs.prepare-build.outputs.release_enabled != 'true' || needs.create-release.result == 'success') runs-on: ${{ matrix.settings.platform }} environment: Production strategy: fail-fast: false matrix: settings: - platform: macos-latest args: --target aarch64-apple-darwin target: aarch64-apple-darwin artifact_suffix: aarch64-apple-darwin - platform: macos-latest args: --target x86_64-apple-darwin target: x86_64-apple-darwin artifact_suffix: x86_64-apple-darwin # - platform: ubuntu-22.04 # args: --target x86_64-unknown-linux-gnu # target: x86_64-unknown-linux-gnu # artifact_suffix: ubuntu - platform: windows-latest args: --target x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc artifact_suffix: windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Keep in sync with DEFAULT_TELEGRAM_BOT_USERNAME_* in channels/controllers/ops.rs OPENHUMAN_TELEGRAM_BOT_USERNAME: ${{ inputs.build_target == 'staging' && 'alphahumantest_bot' || 'openhumanaibot' }} VITE_TELEGRAM_BOT_USERNAME: ${{ inputs.build_target == 'staging' && 'alphahumantest_bot' || 'openhumanaibot' }} steps: - name: Checkout build ref uses: actions/checkout@v4 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 submodules: recursive - name: Configure staging app environment if: inputs.build_target == 'staging' shell: bash run: | echo "OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV" echo "VITE_OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV" echo "OPENHUMAN_TELEGRAM_BOT_USERNAME=alphahumantest_bot" >> "$GITHUB_ENV" echo "VITE_TELEGRAM_BOT_USERNAME=alphahumantest_bot" >> "$GITHUB_ENV" - name: Set Xcode version if: matrix.settings.platform == 'macos-latest' uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - name: Setup Node.js 24.x uses: actions/setup-node@v4 with: node-version: 24.x cache: yarn - name: Install Rust (rust-toolchain.toml) uses: dtolnay/rust-toolchain@1.93.0 with: targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Install Tauri dependencies (ubuntu only) if: matrix.settings.platform == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y \ libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev \ patchelf cmake libasound2-dev libxdo-dev libxtst-dev libx11-dev libxi-dev \ libevdev-dev libssl-dev libclang-dev \ libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ libgbm1 libpango-1.0-0 libcairo2 libatspi2.0-0 libxshmfence1 libu2f-udev - name: Dump missing shared libs (debug) if: matrix.settings.platform == 'ubuntu-22.04' shell: bash run: | set +e for BIN in \ app/src-tauri/target/${{ matrix.settings.target }}/release/OpenHuman \ target/${{ matrix.settings.target }}/release/OpenHuman; do if [ -x "$BIN" ]; then echo "ldd $BIN" ldd "$BIN" | grep 'not found' || echo " all resolved" fi done # Skip first 7 lines of Cargo.lock (workspace package version bumps) so the key tracks dependency changes only - name: Cargo.lock fingerprint (deps only) id: cargo-lock-fingerprint shell: bash run: | echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" - name: Cache Cargo registry and git sources uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git key: ${{ runner.os }}-cargo-registry-${{ steps.cargo-lock-fingerprint.outputs.hash }} restore-keys: | ${{ runner.os }}-cargo-registry- # CEF is now the default runtime — cef-dll-sys + vendored tauri-cli # auto-download the ~400MB Chromium distribution on first build. Cache # it per-OS across runs. Default path is `dirs::cache_dir()/tauri-cef` # (macOS: ~/Library/Caches; Linux: ~/.cache; Windows: %LOCALAPPDATA%). - name: Cache CEF binary distribution (unix) if: matrix.settings.platform != 'windows-latest' uses: actions/cache@v4 with: path: | ~/Library/Caches/tauri-cef ~/.cache/tauri-cef key: cef-${{ matrix.settings.target }}-${{ hashFiles('app/src-tauri/Cargo.toml') }} restore-keys: | cef-${{ matrix.settings.target }}- - name: Cache CEF binary distribution (windows) if: matrix.settings.platform == 'windows-latest' uses: actions/cache@v4 with: path: ~/AppData/Local/tauri-cef key: cef-${{ matrix.settings.target }}-${{ hashFiles('app/src-tauri/Cargo.toml') }} restore-keys: | cef-${{ matrix.settings.target }}- # Upstream @tauri-apps/cli (used by tauri-action) doesn't bundle CEF # framework files into the produced installer. Only the fork at # vendor/tauri-cef does. Build and install that CLI so `cargo tauri # build` invokes the cef-aware bundler. - name: Cache vendored tauri-cli binary (unix) if: matrix.settings.platform != 'windows-latest' id: tauri-cli-cache-unix uses: actions/cache@v4 with: path: ~/.cargo/bin/cargo-tauri key: vendored-tauri-cli-${{ runner.os }}-${{ hashFiles('app/src-tauri/vendor/tauri-cef/crates/tauri-cli/Cargo.toml') }} - name: Cache vendored tauri-cli binary (windows) if: matrix.settings.platform == 'windows-latest' id: tauri-cli-cache-windows uses: actions/cache@v4 with: path: ~/.cargo/bin/cargo-tauri.exe key: vendored-tauri-cli-${{ runner.os }}-${{ hashFiles('app/src-tauri/vendor/tauri-cef/crates/tauri-cli/Cargo.toml') }} - name: Install vendored tauri-cli (cef-aware bundler) if: (matrix.settings.platform == 'windows-latest' && steps.tauri-cli-cache-windows.outputs.cache-hit != 'true') || (matrix.settings.platform != 'windows-latest' && steps.tauri-cli-cache-unix.outputs.cache-hit != 'true') shell: bash run: cargo install --locked --path app/src-tauri/vendor/tauri-cef/crates/tauri-cli - name: Install dependencies run: yarn install --frozen-lockfile - name: Validate signing prerequisites shell: bash env: MATRIX_PLATFORM: ${{ matrix.settings.platform }} UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | if [ -z "$UPDATER_PUBLIC_KEY" ]; then echo "Missing UPDATER_PUBLIC_KEY (set as secret or repo/environment variable)." exit 1 fi if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then echo "Missing TAURI_SIGNING_PRIVATE_KEY (or fallback UPDATER_PRIVATE_KEY)." exit 1 fi if [ "$MATRIX_PLATFORM" = "macos-latest" ]; then for var in APPLE_CERTIFICATE_BASE64 APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID; do if [ -z "${!var}" ]; then echo "Missing required macOS signing secret: $var" exit 1 fi done fi - name: Define Tauri configuration overrides id: config-overrides uses: actions/github-script@v7 env: BASE_URL: ${{ needs.prepare-build.outputs.base_url }} UPDATER_PUBLIC_KEY: ${{ secrets.UPDATER_PUBLIC_KEY || vars.UPDATER_PUBLIC_KEY }} UPDATER_ENDPOINT: ${{ vars.UPDATER_ENDPOINT }} UPDATER_REPO: tinyhumansai/openhuman WITH_UPDATER: "true" with: script: | const workspacePath = process.env.GITHUB_WORKSPACE.replace(/\\/g, '/'); const prefix = workspacePath.startsWith('/') ? 'file://' : 'file:///'; const moduleUrl = `${prefix}${workspacePath}/scripts/prepareTauriConfig.js`; const { default: prepareTauriConfig } = await import(moduleUrl); const config = prepareTauriConfig(); core.setOutput('json', JSON.stringify(config)); - name: Build frontend run: yarn build env: NODE_ENV: production VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_DEBUG: ${{ vars.VITE_DEBUG }} # OAuth guardrails (#365): block openhuman://oauth/success on outdated desktop builds. VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} - name: Resolve core manifest and binary names id: core-paths shell: bash run: | if [ -f "openhuman_core/Cargo.toml" ]; then CORE_DIR="openhuman_core" elif [ -f "rust-core/Cargo.toml" ]; then CORE_DIR="rust-core" elif [ -f "Cargo.toml" ] && grep -q '^name = "openhuman"' Cargo.toml; then CORE_DIR="." else echo "No core Cargo manifest found (expected root Cargo.toml with openhuman, openhuman_core/Cargo.toml, or rust-core/Cargo.toml)" exit 1 fi SIDE_CAR_BASE="$(node -e "const fs=require('fs');const c=JSON.parse(fs.readFileSync('app/src-tauri/tauri.conf.json','utf8'));const b=(c.bundle&&Array.isArray(c.bundle.externalBin)&&c.bundle.externalBin[0])||'binaries/openhuman-core';process.stdout.write(String(b).split('/').pop());")" CORE_BIN_NAME="${SIDE_CAR_BASE}" echo "core_dir=$CORE_DIR" >> "$GITHUB_OUTPUT" echo "core_manifest=$CORE_DIR/Cargo.toml" >> "$GITHUB_OUTPUT" # Cargo workspace: release artifacts are under repo root target/, not /target/ echo "core_target_dir=target/$MATRIX_TARGET/release" >> "$GITHUB_OUTPUT" echo "core_bin_name=$CORE_BIN_NAME" >> "$GITHUB_OUTPUT" echo "sidecar_base=$SIDE_CAR_BASE" >> "$GITHUB_OUTPUT" env: MATRIX_TARGET: ${{ matrix.settings.target }} - name: Build sidecar core binary shell: bash run: | cargo build \ --manifest-path "$CORE_MANIFEST" \ --release \ --target "$MATRIX_TARGET" \ --bin "$CORE_BIN_NAME" env: MATRIX_TARGET: ${{ matrix.settings.target }} CORE_MANIFEST: ${{ steps.core-paths.outputs.core_manifest }} CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }} OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} OPENHUMAN_SENTRY_DSN: ${{ vars.OPENHUMAN_SENTRY_DSN }} - name: Stage sidecar for Tauri bundler shell: bash run: | bash scripts/release/stage-sidecar.sh \ "${{ matrix.settings.target }}" \ "${{ steps.core-paths.outputs.core_target_dir }}" \ "${{ steps.core-paths.outputs.core_bin_name }}" \ "${{ steps.core-paths.outputs.sidecar_base }}" - name: Resolve standalone core CLI artifact path id: cli-paths shell: bash run: | BASE_DIR="$CORE_TARGET_DIR" EXE_SUFFIX="" if [[ "$MATRIX_TARGET" == *"windows"* ]]; then EXE_SUFFIX=".exe" fi echo "base_dir=$BASE_DIR" >> "$GITHUB_OUTPUT" echo "cli_path=$BASE_DIR/${CORE_BIN_NAME}${EXE_SUFFIX}" >> "$GITHUB_OUTPUT" env: MATRIX_TARGET: ${{ matrix.settings.target }} CORE_TARGET_DIR: ${{ steps.core-paths.outputs.core_target_dir }} CORE_BIN_NAME: ${{ steps.core-paths.outputs.core_bin_name }} # CEF is the default runtime — we build via the vendored tauri-cli so # the bundler copies Chromium Embedded Framework files into the .app / # .deb / .AppImage / .msi / NSIS setup. macOS signing is intentionally # skipped here and handled by the dedicated re-sign + notarize step # below (hardened runtime + entitlements are required for notarization). - name: Build and package Tauri app (CEF, vendored CLI) id: tauri-build shell: bash working-directory: app env: BASE_URL: ${{ needs.prepare-build.outputs.base_url }} OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} VITE_OPENHUMAN_APP_ENV: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }} VITE_BACKEND_URL: ${{ needs.prepare-build.outputs.base_url }} MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} WITH_UPDATER: "true" VITE_MINIMUM_SUPPORTED_APP_VERSION: ${{ vars.VITE_MINIMUM_SUPPORTED_APP_VERSION }} VITE_LATEST_APP_DOWNLOAD_URL: ${{ vars.VITE_LATEST_APP_DOWNLOAD_URL }} TAURI_CONFIG_OVERRIDE: ${{ steps.config-overrides.outputs.json }} MATRIX_ARGS: ${{ matrix.settings.args }} run: | cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS # tauri-action previously uploaded non-macOS installer assets directly # to the release. Replicate that for Linux + Windows here (macOS goes # through the re-sign + notarize path below and uploads separately). - name: Upload non-macOS installers to release if: needs.prepare-build.outputs.release_enabled == 'true' && matrix.settings.platform != 'macos-latest' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare-build.outputs.tag }} MATRIX_TARGET: ${{ matrix.settings.target }} run: | set -euo pipefail shopt -s nullglob BUNDLE_ROOTS=( "app/src-tauri/target/${MATRIX_TARGET}/release/bundle" "target/${MATRIX_TARGET}/release/bundle" ) UPLOAD=() for root in "${BUNDLE_ROOTS[@]}"; do [ -d "$root" ] || continue for f in \ "$root"/deb/*.deb \ "$root"/appimage/*.AppImage \ "$root"/appimage/*.AppImage.sig \ "$root"/msi/*.msi \ "$root"/msi/*.msi.sig \ "$root"/nsis/*-setup.exe \ "$root"/nsis/*-setup.exe.sig; do [ -e "$f" ] && UPLOAD+=("$f") done done if [ ${#UPLOAD[@]} -eq 0 ]; then echo "No installer artifacts found for ${MATRIX_TARGET}" exit 1 fi echo "Uploading:" printf ' %s\n' "${UPLOAD[@]}" gh release upload "$TAG" "${UPLOAD[@]}" --repo tinyhumansai/openhuman --clobber - name: Locate macOS .app bundle if: matrix.settings.platform == 'macos-latest' id: locate-app shell: bash run: | set -euo pipefail # tauri-action with projectPath: app may output to either location # depending on workspace config — search both APP_PATH="" for candidate in \ "app/src-tauri/target/${{ matrix.settings.target }}/release/bundle/macos/OpenHuman.app" \ "target/${{ matrix.settings.target }}/release/bundle/macos/OpenHuman.app"; do if [ -d "$candidate" ]; then APP_PATH="$candidate" break fi done # Fallback: search for it if [ -z "$APP_PATH" ]; then APP_PATH="$(find . -path '*/release/bundle/macos/OpenHuman.app' -type d 2>/dev/null | head -1)" fi if [ -z "$APP_PATH" ]; then echo "ERROR: Could not find OpenHuman.app bundle anywhere" find . -name 'OpenHuman.app' -type d 2>/dev/null || true exit 1 fi BUNDLE_DIR="$(dirname "$(dirname "$APP_PATH")")" echo "app_path=$APP_PATH" >> "$GITHUB_OUTPUT" echo "bundle_dir=$BUNDLE_DIR" >> "$GITHUB_OUTPUT" echo "Found .app at: $APP_PATH" echo "Bundle dir: $BUNDLE_DIR" - name: Re-sign sidecar with hardened runtime and notarize if: matrix.settings.platform == 'macos-latest' shell: bash env: APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | bash scripts/release/sign-and-notarize-macos.sh \ "${{ steps.locate-app.outputs.app_path }}" \ "app/src-tauri/entitlements.sidecar.plist" - name: Re-package DMG after notarization if: matrix.settings.platform == 'macos-latest' shell: bash env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | bash scripts/release/repackage-dmg.sh \ "${{ steps.locate-app.outputs.app_path }}" \ "${{ steps.locate-app.outputs.bundle_dir }}" - name: Re-upload notarized macOS artifacts to release if: matrix.settings.platform == 'macos-latest' && needs.prepare-build.outputs.release_enabled == 'true' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY || secrets.UPDATER_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }} run: | bash scripts/release/upload-macos-artifacts.sh \ "${{ steps.locate-app.outputs.app_path }}" \ "${{ steps.locate-app.outputs.bundle_dir }}" \ "${{ needs.prepare-build.outputs.version }}" \ "${{ matrix.settings.target }}" - name: Verify macOS app bundle sidecar layout if: matrix.settings.platform == 'macos-latest' shell: bash run: | APP_PATH="${{ steps.locate-app.outputs.app_path }}" echo "Inspecting bundle at: $APP_PATH" ls -la "$APP_PATH/Contents/MacOS" ls -la "$APP_PATH/Contents/Resources" | grep openhuman || true # Sidecar may be in MacOS/ or Resources/ depending on Tauri version FOUND=false if ls "$APP_PATH/Contents/MacOS"/openhuman* 2>/dev/null | grep -qv OpenHuman; then echo "Sidecar found in Contents/MacOS/" FOUND=true fi if ls "$APP_PATH/Contents/Resources"/openhuman-* 2>/dev/null; then echo "Sidecar found in Contents/Resources/" FOUND=true fi if [ "$FOUND" = "false" ]; then echo "WARNING: Sidecar binary not found in expected locations" fi # Verify notarization staple echo "Checking staple..." xcrun stapler validate "$APP_PATH" || echo "WARNING: Staple validation failed" - name: Package CLI tarball and upload to release (unix) if: matrix.settings.platform != 'windows-latest' && needs.prepare-build.outputs.release_enabled == 'true' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | # For macOS, prefer the notarized binary from inside the .app bundle if [[ "${{ matrix.settings.platform }}" == "macos-latest" ]]; then APP_PATH="${{ steps.locate-app.outputs.app_path }}" BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -name "openhuman-core-*" \ ! -name "*.sig" 2>/dev/null | head -1 || true) [[ -z "$BIN" ]] && BIN=$(find "$APP_PATH/Contents/Resources" -maxdepth 1 \ -name "openhuman-core-*" ! -name "*.sig" 2>/dev/null | head -1 || true) if [[ -z "$BIN" ]]; then echo "[pkg] Falling back to target dir binary (no notarized sidecar found)" BIN="${{ steps.cli-paths.outputs.cli_path }}" fi else BIN="${{ steps.cli-paths.outputs.cli_path }}" fi bash scripts/release/package-cli-tarball.sh \ "$BIN" \ "${{ needs.prepare-build.outputs.version }}" \ "${{ matrix.settings.target }}" - name: Package CLI zip and upload to release (windows) if: matrix.settings.platform == 'windows-latest' && needs.prepare-build.outputs.release_enabled == 'true' shell: pwsh env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | $Version = "${{ needs.prepare-build.outputs.version }}" $Target = "${{ matrix.settings.target }}" $BinPath = "${{ steps.cli-paths.outputs.cli_path }}" $ZipName = "openhuman-core-${Version}-${Target}.zip" $Work = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "cli-pkg-$(Get-Random)") Copy-Item $BinPath (Join-Path $Work.FullName "openhuman-core.exe") Compress-Archive -Path (Join-Path $Work.FullName "openhuman-core.exe") -DestinationPath $ZipName $Hash = (Get-FileHash -Path $ZipName -Algorithm SHA256).Hash.ToLowerInvariant() Set-Content -Path "${ZipName}.sha256" -Value $Hash -NoNewline Write-Host "[package-cli] Created $ZipName (sha256: $Hash)" gh release upload "v${Version}" $ZipName "${ZipName}.sha256" --repo tinyhumansai/openhuman --clobber Write-Host "[package-cli] Uploaded $ZipName to v${Version}" - name: Upload staging desktop bundles if: needs.prepare-build.outputs.release_enabled != 'true' uses: actions/upload-artifact@v4 with: name: desktop-bundles-${{ matrix.settings.platform }}-${{ matrix.settings.artifact_suffix }} path: | app/src-tauri/target/${{ matrix.settings.target }}/release/bundle/** target/${{ matrix.settings.target }}/release/bundle/** - name: Upload standalone CLI artifacts uses: actions/upload-artifact@v4 with: name: standalone-bins-${{ matrix.settings.platform }}-${{ matrix.settings.artifact_suffix }} path: | ${{ steps.cli-paths.outputs.cli_path }} # ========================================================================= # Phase 3b: Build & push Docker image (runs parallel with build-desktop) # ========================================================================= build-docker: name: "Docker: build and push" needs: [prepare-build, create-release] if: >- always() && needs.prepare-build.result == 'success' && needs.prepare-build.outputs.release_enabled == 'true' && needs.create-release.result == 'success' runs-on: ubuntu-latest environment: Production env: REGISTRY: ghcr.io IMAGE_NAME: tinyhumansai/openhuman-core steps: - name: Checkout build ref uses: actions/checkout@v4 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # TODO: Uncomment this when we have a staging tag # - name: Build and push staging tag # uses: docker/build-push-action@v6 # with: # context: . # file: Dockerfile # push: true # tags: | # ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging-${{ needs.prepare-release.outputs.tag }} # cache-from: type=gha # cache-to: type=gha,mode=max # labels: | # org.opencontainers.image.title=openhuman-core # org.opencontainers.image.description=OpenHuman core JSON-RPC server # org.opencontainers.image.version=${{ needs.prepare-release.outputs.version }} # org.opencontainers.image.source=https://github.com/tinyhumansai/openhuman # org.opencontainers.image.revision=${{ needs.prepare-release.outputs.sha }} # ========================================================================= # Phase 3c: Generate and upload latest.json for the Tauri auto-updater. # Runs after every platform has uploaded its updater artifact (.sig files # from createUpdaterArtifacts) so the manifest can reference all four # platform entries. Production-only — staging builds don't feed the public # updater endpoint. # ========================================================================= publish-updater-manifest: name: Publish updater manifest (latest.json) needs: [prepare-build, create-release, build-desktop] if: >- needs.prepare-build.outputs.release_enabled == 'true' && needs.build-desktop.result == 'success' runs-on: ubuntu-latest environment: Production steps: - name: Checkout build ref uses: actions/checkout@v4 with: ref: ${{ needs.prepare-build.outputs.build_ref }} fetch-depth: 1 - name: Generate and upload latest.json shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.prepare-build.outputs.tag }} VERSION: ${{ needs.prepare-build.outputs.version }} REPO: tinyhumansai/openhuman run: bash scripts/release/publish-updater-manifest.sh # ========================================================================= # Phase 4: Publish the draft release (waits for ALL build phases) # ========================================================================= publish-release: name: Publish draft release runs-on: ubuntu-latest environment: Production needs: [ prepare-build, create-release, build-desktop, build-docker, publish-updater-manifest, ] if: >- needs.prepare-build.outputs.release_enabled == 'true' && needs.build-desktop.result == 'success' && needs.build-docker.result == 'success' && needs.publish-updater-manifest.result == 'success' steps: - name: Validate required installer assets exist uses: actions/github-script@v7 with: script: | const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); const { owner, repo } = context.repo; const { data: assets } = await github.rest.repos.listReleaseAssets({ owner, repo, release_id: releaseId, per_page: 100, }); const names = assets.map((a) => a.name); const requiredPatterns = [ /OpenHuman_.*_aarch64\.dmg$/, /OpenHuman_.*_x64\.dmg$/, /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/, // Auto-updater manifest — without this, installed clients can't // discover new releases via plugins.updater.endpoints. /^latest\.json$/, ]; const missing = requiredPatterns.filter((pattern) => !names.some((name) => pattern.test(name))); if (missing.length > 0) { core.setFailed(`Missing required installer assets. Got: ${names.join(', ')}`); return; } core.info('All required installer assets are present.'); # - name: Promote Docker image from staging to release tags # env: # REGISTRY: ghcr.io # IMAGE_NAME: tinyhumansai/openhuman-core # TAG: ${{ needs.prepare-release.outputs.tag }} # run: | # # Log in to GHCR # echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin # STAGING="${REGISTRY}/${IMAGE_NAME}:staging-${TAG}" # VERSIONED="${REGISTRY}/${IMAGE_NAME}:${TAG}" # LATEST="${REGISTRY}/${IMAGE_NAME}:latest" # echo "Promoting ${STAGING} → ${VERSIONED}, ${LATEST}" # docker buildx imagetools create --tag "${VERSIONED}" --tag "${LATEST}" "${STAGING}" # - name: Append Docker pull instructions to release notes # env: # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # TAG: ${{ needs.prepare-release.outputs.tag }} # IMAGE_NAME: tinyhumansai/openhuman-core # run: | # gh release view "${TAG}" --repo tinyhumansai/openhuman --json body -q .body > /tmp/body.md # printf '\n---\n\n### Docker\n\n```\ndocker pull ghcr.io/%s:%s\ndocker run -p 7788:7788 --env-file .env ghcr.io/%s:%s\n```\n' \ # "${IMAGE_NAME}" "${TAG}" "${IMAGE_NAME}" "${TAG}" >> /tmp/body.md # gh release edit "${TAG}" --repo tinyhumansai/openhuman --notes-file /tmp/body.md - name: Publish release uses: actions/github-script@v7 with: script: | const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); await github.rest.repos.updateRelease({ owner: context.repo.owner, repo: context.repo.repo, release_id: releaseId, draft: false, }); core.info(`Published release ${releaseId}`); # ========================================================================= # Cleanup: remove draft release + tag if ANY build phase failed # ========================================================================= cleanup-failed-release: name: Remove release and tag if build failed runs-on: ubuntu-latest environment: Production needs: [ prepare-build, create-release, build-desktop, build-docker, publish-updater-manifest, ] if: >- always() && needs.prepare-build.outputs.release_enabled == 'true' && needs.create-release.result == 'success' && (needs.build-desktop.result == 'failure' || needs.build-desktop.result == 'cancelled' || needs.build-docker.result == 'failure' || needs.build-docker.result == 'cancelled' || needs.publish-updater-manifest.result == 'failure' || needs.publish-updater-manifest.result == 'cancelled') steps: - name: Delete GitHub release uses: actions/github-script@v7 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const releaseId = Number('${{ needs.create-release.outputs.release_id }}'); if (!Number.isFinite(releaseId) || releaseId <= 0) { core.setFailed('Invalid or missing release_id; cannot delete release.'); return; } try { await github.rest.repos.deleteRelease({ owner, repo, release_id: releaseId }); core.info(`Deleted release ${releaseId}`); } catch (e) { core.warning(`deleteRelease failed: ${e.message}`); } - name: Delete remote tag uses: actions/github-script@v7 with: script: | const owner = context.repo.owner; const repo = context.repo.repo; const tag = '${{ needs.prepare-build.outputs.tag }}'; try { await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); core.info(`Deleted remote tag ${tag}`); } catch (e) { if (e.status === 404) { core.info(`Tag ${tag} already absent on remote`); } else { throw e; } } - name: Delete staging Docker image continue-on-error: true env: TAG: ${{ needs.prepare-build.outputs.tag }} run: | PACKAGE="openhuman-core" STAGING_TAG="staging-${TAG}" echo "Attempting to delete staging Docker tag: ${STAGING_TAG}" # List package versions and find the staging tag VERSION_ID="$(gh api \ -H "Accept: application/vnd.github+json" \ "/orgs/tinyhumansai/packages/container/${PACKAGE}/versions" \ --paginate --jq ".[] | select(.metadata.container.tags[] == \"${STAGING_TAG}\") | .id" 2>/dev/null | head -1)" if [ -n "$VERSION_ID" ]; then gh api -X DELETE "/orgs/tinyhumansai/packages/container/${PACKAGE}/versions/${VERSION_ID}" || true echo "Deleted staging image version ${VERSION_ID}" else echo "Staging tag ${STAGING_TAG} not found or already deleted" fi