name: Release on: workflow_dispatch: inputs: release_type: description: Version increment type required: true type: choice options: - patch - minor - major permissions: contents: write concurrency: group: release-main cancel-in-progress: false jobs: prepare-release: name: Prepare release commit and tag runs-on: ubuntu-latest environment: Production outputs: version: ${{ steps.bump.outputs.version }} tag: ${{ steps.bump.outputs.tag }} sha: ${{ steps.push.outputs.sha }} 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 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 uses: actions/checkout@v4 with: ref: main fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 24.x - name: Configure Git 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 id: bump env: RELEASE_TYPE: ${{ inputs.release_type }} run: | node <<'NODE' const fs = require('fs'); const releaseType = process.env.RELEASE_TYPE; const allowed = new Set(['patch', 'minor', 'major']); if (!allowed.has(releaseType)) { throw new Error(`Invalid release_type: ${releaseType}`); } const packagePath = 'app/package.json'; const tauriPath = 'app/src-tauri/tauri.conf.json'; const cargoPath = 'app/src-tauri/Cargo.toml'; const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); const match = String(pkg.version || '').match(/^(\d+)\.(\d+)\.(\d+)$/); if (!match) { throw new Error(`package.json version must be SemVer X.Y.Z, found: ${pkg.version}`); } let major = Number(match[1]); let minor = Number(match[2]); let patch = Number(match[3]); if (releaseType === 'major') { major += 1; minor = 0; patch = 0; } else if (releaseType === 'minor') { minor += 1; patch = 0; } else { patch += 1; } const nextVersion = `${major}.${minor}.${patch}`; pkg.version = nextVersion; fs.writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); const tauri = JSON.parse(fs.readFileSync(tauriPath, 'utf8')); tauri.version = nextVersion; fs.writeFileSync(tauriPath, `${JSON.stringify(tauri, null, 2)}\n`); const cargo = fs.readFileSync(cargoPath, 'utf8'); const updatedCargo = cargo.replace( /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m, `$1${nextVersion}$3`, ); if (updatedCargo === cargo) { throw new Error('Failed to update [package].version in app/src-tauri/Cargo.toml'); } fs.writeFileSync(cargoPath, updatedCargo); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${nextVersion}\n`); fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=v${nextVersion}\n`); NODE - name: Ensure tag does not already exist 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 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 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" create-release: name: Create GitHub release runs-on: ubuntu-latest environment: Production needs: prepare-release 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-release.outputs.tag }}'; const version = '${{ needs.prepare-release.outputs.version }}'; const target = '${{ needs.prepare-release.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); build-artifacts: name: Build and upload artifacts needs: [prepare-release, create-release] 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 }} steps: - name: Checkout tag uses: actions/checkout@v4 with: ref: ${{ needs.prepare-release.outputs.tag }} fetch-depth: 1 submodules: true - 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 libappindicator3-dev librsvg2-dev patchelf # 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- - 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: ${{ vars.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_BACKEND_URL: ${{ vars.BASE_URL }} VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }} VITE_DEBUG: ${{ vars.VITE_DEBUG }} - 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 }} - name: Stage sidecar for Tauri bundler shell: bash run: | mkdir -p app/src-tauri/binaries EXE_SUFFIX="" if [[ "$MATRIX_TARGET" == *"windows"* ]]; then EXE_SUFFIX=".exe" fi SOURCE="$CORE_TARGET_DIR/${CORE_BIN_NAME}${EXE_SUFFIX}" DEST="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}${EXE_SUFFIX}" cp "$SOURCE" "$DEST" if [[ "$MATRIX_TARGET" != *"windows"* ]]; then chmod +x "$DEST" fi 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 }} SIDECAR_BASE: ${{ steps.core-paths.outputs.sidecar_base }} - name: Verify staged sidecar for bundler (all platforms) shell: bash run: | EXE_SUFFIX="" if [[ "$MATRIX_TARGET" == *"windows"* ]]; then EXE_SUFFIX=".exe" fi SIDE_CAR_PATH="app/src-tauri/binaries/${SIDECAR_BASE}-${MATRIX_TARGET}${EXE_SUFFIX}" echo "Checking staged sidecar: $SIDE_CAR_PATH" if [ ! -f "$SIDE_CAR_PATH" ]; then echo "Missing staged sidecar binary: $SIDE_CAR_PATH" exit 1 fi if [ "$MATRIX_PLATFORM" != "windows-latest" ] && [ ! -x "$SIDE_CAR_PATH" ]; then echo "Staged sidecar is not executable: $SIDE_CAR_PATH" exit 1 fi env: MATRIX_PLATFORM: ${{ matrix.settings.platform }} MATRIX_TARGET: ${{ matrix.settings.target }} SIDECAR_BASE: ${{ 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 }} - name: Build, package and upload to release uses: tauri-apps/tauri-action@v0.6.2 id: tauri-build env: # NOTE: APPLE_CERTIFICATE, APPLE_SIGNING_IDENTITY, APPLE_ID, etc. are # intentionally omitted so tauri-action builds WITHOUT signing. # Signing + notarization are handled in a dedicated step afterwards # where we sign everything with hardened runtime + entitlements # (required by Apple notarization). BASE_URL: ${{ vars.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" with: projectPath: app args: -c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }} includeDebug: false includeRelease: true releaseId: ${{ needs.create-release.outputs.release_id }} owner: tinyhumansai repo: openhuman - 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: | set -euo pipefail APP_PATH="${{ steps.locate-app.outputs.app_path }}" ENTITLEMENTS="app/src-tauri/entitlements.sidecar.plist" # Ensure signing identity is available — re-import cert into a keychain # in case tauri-action's keychain was cleaned up KEYCHAIN="resign-$$.keychain-db" KEYCHAIN_PW="$(openssl rand -base64 24)" CERT_FILE="$(mktemp /tmp/cert-XXXXXX.p12)" echo "$APPLE_CERTIFICATE_BASE64" | base64 --decode > "$CERT_FILE" security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" security import "$CERT_FILE" -k "$KEYCHAIN" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') rm -f "$CERT_FILE" echo "Signing identity imported into $KEYCHAIN" echo "=== Signing .app contents and bundle ===" echo "Bundle contents:" ls -la "$APP_PATH/Contents/MacOS/" MAIN_EXE="$(defaults read "$APP_PATH/Contents/Info.plist" CFBundleExecutable 2>/dev/null || echo "OpenHuman")" echo "Main executable (from plist): $MAIN_EXE" # Sign all non-main binaries (sidecars) first for bin in "$APP_PATH/Contents/MacOS/"*; do [ -f "$bin" ] && [ -x "$bin" ] || continue BASENAME="$(basename "$bin")" [ "$BASENAME" = "$MAIN_EXE" ] && continue echo " Signing sidecar: $BASENAME" codesign --force --options runtime \ --entitlements "$ENTITLEMENTS" \ --sign "$APPLE_SIGNING_IDENTITY" \ --timestamp \ "$bin" done # Sign sidecars in Resources/ if any for bin in "$APP_PATH/Contents/Resources/"openhuman-core-*; do [ -f "$bin" ] || continue echo " Signing resource sidecar: $(basename "$bin")" codesign --force --options runtime \ --entitlements "$ENTITLEMENTS" \ --sign "$APPLE_SIGNING_IDENTITY" \ --timestamp \ "$bin" done # Sign the .app bundle (signs main exe + updates seal) echo " Signing .app bundle..." codesign --force --options runtime \ --entitlements "$ENTITLEMENTS" \ --sign "$APPLE_SIGNING_IDENTITY" \ --timestamp \ "$APP_PATH" echo "=== Verifying signatures ===" codesign --verify --deep --strict --verbose=2 "$APP_PATH" echo "=== Notarizing ===" NOTARIZE_ZIP="$(mktemp /tmp/OpenHuman-notarize-XXXXXX.zip)" ditto -c -k --keepParent "$APP_PATH" "$NOTARIZE_ZIP" xcrun notarytool submit "$NOTARIZE_ZIP" \ --apple-id "$APPLE_ID" \ --password "$APPLE_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait rm -f "$NOTARIZE_ZIP" echo "=== Stapling ===" xcrun stapler staple "$APP_PATH" echo "=== Notarization complete ===" - 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: | set -euo pipefail APP_PATH="${{ steps.locate-app.outputs.app_path }}" BUNDLE_DIR="${{ steps.locate-app.outputs.bundle_dir }}" DMG_PATH="$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -maxdepth 1 2>/dev/null | head -1)" if [ -z "$DMG_PATH" ]; then echo "No DMG found — skipping DMG re-package" exit 0 fi echo "Re-creating DMG with notarized .app..." DMG_TEMP="$(mktemp /tmp/OpenHuman-XXXXXX.dmg)" hdiutil create -volname "OpenHuman" -srcfolder "$APP_PATH" -ov -format UDZO "$DMG_TEMP" mv "$DMG_TEMP" "$DMG_PATH" echo "Notarizing DMG..." xcrun notarytool submit "$DMG_PATH" \ --apple-id "$APPLE_ID" \ --password "$APPLE_PASSWORD" \ --team-id "$APPLE_TEAM_ID" \ --wait xcrun stapler staple "$DMG_PATH" echo "DMG notarization complete: $DMG_PATH" - name: Re-upload notarized macOS artifacts to release if: matrix.settings.platform == 'macos-latest' shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_ID: ${{ needs.create-release.outputs.release_id }} run: | set -euo pipefail BUNDLE_DIR="${{ steps.locate-app.outputs.bundle_dir }}" APP_PATH="${{ steps.locate-app.outputs.app_path }}" VERSION="${{ needs.prepare-release.outputs.version }}" ARCH="${{ matrix.settings.target }}" # Re-upload the DMG (overwrite the one tauri-action uploaded) DMG_PATH="$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -maxdepth 1 2>/dev/null | head -1)" if [ -n "$DMG_PATH" ]; then DMG_NAME="$(basename "$DMG_PATH")" echo "Deleting old DMG asset from release..." ASSET_ID="$(gh api "repos/tinyhumansai/openhuman/releases/$RELEASE_ID/assets" \ --jq ".[] | select(.name == \"$DMG_NAME\") | .id" 2>/dev/null || true)" if [ -n "$ASSET_ID" ]; then gh api -X DELETE "repos/tinyhumansai/openhuman/releases/assets/$ASSET_ID" || true fi echo "Uploading notarized DMG..." gh release upload "v${VERSION}" "$DMG_PATH" --repo tinyhumansai/openhuman --clobber fi # Re-upload the .app as a zip if tauri-action uploaded one if [ -n "$APP_PATH" ]; then APP_ZIP="/tmp/OpenHuman_${VERSION}_${ARCH}.app.tar.gz" tar -czf "$APP_ZIP" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" gh release upload "v${VERSION}" "$APP_ZIP" --repo tinyhumansai/openhuman --clobber rm -f "$APP_ZIP" fi - 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: 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 }} publish-release: name: Publish draft release runs-on: ubuntu-latest environment: Production needs: [create-release, build-artifacts] if: needs.build-artifacts.result == 'success' steps: - 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-failed-release: name: Remove release and tag if build failed runs-on: ubuntu-latest environment: Production needs: [prepare-release, create-release, build-artifacts] if: always() && needs.create-release.result == 'success' && (needs.build-artifacts.result == 'failure' || needs.build-artifacts.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-release.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; } }