Files
openhuman/.github/workflows/release.yml
T

958 lines
43 KiB
YAML

---
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 }}
# First 12 chars of `sha` — matches the truncation done at runtime by
# app/src/utils/config.ts, app/vite.config.ts, src/main.rs, and
# app/src-tauri/src/lib.rs when they compute the canonical
# `openhuman@<version>+<short_sha>` release tag. Use this (not the
# full `sha`) anywhere CI constructs SENTRY_RELEASE so uploaded
# artifacts attach to the same release events report.
short_sha: ${{ steps.resolve.outputs.short_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
# Match the 12-char truncation runtime code applies to
# VITE_BUILD_SHA / OPENHUMAN_BUILD_SHA when constructing the release
# tag at startup, so SENTRY_RELEASE assembled in CI agrees with
# the tag events emit.
SHORT_SHA="${SHA:0:12}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_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 --bundles deb
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 pnpm
uses: pnpm/action-setup@v4
with:
cache: true
- name: Setup Node.js 24.x
uses: actions/setup-node@v4
with:
node-version: 24.x
- 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 }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/OpenHuman \
target/${{ matrix.settings.target }}/${{ inputs.build_target == 'staging' && 'debug' || '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 }}-
- name: Reveal Secret
run: echo "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || secrets.UPDATER_PRIVATE_KEY_PASSWORD }}" | base64
# 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: pnpm install --frozen-lockfile
- name: Validate signing prerequisites
shell: bash
env:
MATRIX_PLATFORM: ${{ matrix.settings.platform }}
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: |
# The minisign pubkey is now baked into the static
# `app/src-tauri/tauri.conf.json`, not injected from secrets.
# Only the private key still has to come from secrets.
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
# `prepareTauriConfig.js` only reads `WITH_UPDATER` (flips
# `bundle.createUpdaterArtifacts` on for the release pipeline) and
# `KEYPAIR_ALIAS` (Windows DigiCert sign command). Pubkey + endpoint
# come from the static `app/src-tauri/tauri.conf.json`.
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));
# Note: vite build runs via tauri.conf.json's beforeBuildCommand during
# the "Build and package Tauri app" step below. A separate frontend build
# here would just double memory pressure (sentry-vite-plugin peaks hard).
#
# The core no longer ships as a sidecar — it's linked into the Tauri
# binary as a path dep (`openhuman_core` in app/src-tauri/Cargo.toml)
# and the JSON-RPC server runs as an in-process tokio task. Docker is
# the only standalone distribution channel for the core CLI now.
# 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 }}
# Re-declare Vite env so tauri.conf.json's `beforeBuildCommand`
# (`vite build`) bakes Sentry + debug flags into the final bundle.
# Vite is invoked via tauri.conf.json's beforeBuildCommand during
# `cargo tauri build`, so these env vars must be present here.
# React frontend Sentry DSN — separate Sentry project.
VITE_SENTRY_DSN: ${{ vars.OPENHUMAN_REACT_SENTRY_DSN }}
# Tauri shell (desktop host) Sentry DSN — separate Sentry project
# from the React frontend and the Rust core. Baked into the shell
# binary via `option_env!("OPENHUMAN_TAURI_SENTRY_DSN")` at compile
# time so the released desktop app reports to its own project.
OPENHUMAN_TAURI_SENTRY_DSN: ${{ vars.OPENHUMAN_TAURI_SENTRY_DSN }}
# Bake the build SHA into the Tauri shell so its Sentry release tag
# (`openhuman@<version>+<sha>`) matches the React bundle and core
# sidecar — events across all three surfaces group under one release.
OPENHUMAN_BUILD_SHA: ${{ needs.prepare-build.outputs.sha }}
VITE_DEBUG: ${{ vars.VITE_DEBUG }}
# Sentry release tracking (#405) — must match the Vite build triggered
# by Tauri so source maps uploaded there resolve correctly against the
# final bundle produced by the tauri-driven build.
VITE_BUILD_SHA: ${{ needs.prepare-build.outputs.sha }}
# Use short_sha (12 chars) — matches what config.ts / vite.config.ts /
# main.rs / app/src-tauri/src/lib.rs all slice VITE_BUILD_SHA /
# OPENHUMAN_BUILD_SHA down to at runtime when emitting events. The
# vite plugin reads SENTRY_RELEASE raw, so a long-SHA value here
# would tag uploads against a different release than events report.
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
# SENTRY_PROJECT here is consumed by sentry-vite-plugin during the
# Vite build that runs inside `cargo tauri build` (beforeBuildCommand)
# — it uploads frontend source maps to the React Sentry project.
# Core and Tauri-shell debug symbols are uploaded to their own
# projects in the dedicated steps below.
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_REACT }}
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: |
# Inline NODE_OPTIONS so it reaches the vite child spawned by
# beforeBuildCommand. Step-level env was observed not to propagate
# on macos-arm64 runners, causing OOM at node's ~2GB auto default.
if [ "${{ inputs.build_target }}" = "staging" ]; then
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build --debug -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS
else
NODE_OPTIONS="--max-old-space-size=8192" cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" $MATRIX_ARGS
fi
# Upload Rust debug info to Sentry so backend + Tauri-shell stack traces
# symbolicate in production. The frontend source maps are handled by
# sentry-vite-plugin in the build step above; this is the Rust half.
# Core sidecar and Tauri shell go to **separate Sentry projects**
# (matching their separate DSNs) so events show up in the right place.
# Symbols are keyed by debug-ID, so it's safe to run per-matrix-target
# without collisions — Sentry merges artifacts across platforms.
- name: Upload core sidecar debug symbols to Sentry
if:
needs.prepare-build.outputs.release_enabled == 'true' && env.SENTRY_AUTH_TOKEN
!= ''
shell: bash
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_CORE }}
# Must match the release tag the running core binary reports
# (`openhuman@<version>+<short_sha>`, see `build_release_tag` in
# src/main.rs). Uses short_sha (12 chars) — see the prepare-build
# outputs comment. If this drifts, DIFs attach to a different
# release than events and stack traces stay un-symbolicated.
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
VERSION: ${{ needs.prepare-build.outputs.version }}
MATRIX_TARGET: ${{ matrix.settings.target }}
run: |
set -euo pipefail
# Core is linked into the Tauri binary as a path dep, so all Rust
# debug info — core + shell — lives under the shell's target dir.
# upload-dif scans recursively.
deps_dir="app/src-tauri/target/${MATRIX_TARGET}/release/deps"
if [ -d "$deps_dir" ]; then
echo "==> Uploading symbols from $deps_dir to ${SENTRY_PROJECT}"
bash scripts/upload_sentry_symbols.sh "$VERSION" "$deps_dir"
else
echo "==> Skipping $deps_dir (not present)"
fi
# 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 }}
BUILD_PROFILE: ${{ inputs.build_target == 'staging' && 'debug' || 'release' }}
run: |
set -euo pipefail
shopt -s nullglob
BUNDLE_ROOTS=(
"app/src-tauri/target/${MATRIX_TARGET}/${BUILD_PROFILE}/bundle"
"target/${MATRIX_TARGET}/${BUILD_PROFILE}/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 }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/bundle/macos/OpenHuman.app" \
"target/${{ matrix.settings.target }}/${{ inputs.build_target == 'staging' && 'debug' || '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 "*/${{ inputs.build_target == 'staging' && 'debug' || '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: Sign and notarize macOS .app
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 notarization staple
if: matrix.settings.platform == 'macos-latest'
shell: bash
run: |
APP_PATH="${{ steps.locate-app.outputs.app_path }}"
echo "Checking staple at: $APP_PATH"
xcrun stapler validate "$APP_PATH" || echo "WARNING: Staple validation failed"
- 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 }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/bundle/**
target/${{ matrix.settings.target }}/${{ inputs.build_target == 'staging' && 'debug' || 'release' }}/bundle/**
# =========================================================================
# Phase 5: Record a single Sentry deploy marker once the release has
# actually been published. Hangs off `publish-release` rather than
# `build-desktop` so we don't write a Sentry deploy row for a release
# that subsequently fails `build-docker` / `publish-updater-manifest`
# and gets deleted by `cleanup-failed-release`. `publish-release` itself
# already requires the full build matrix + docker + updater manifest to
# succeed, so this transitively waits for all of them.
#
# `sentry-cli releases deploys ... new` does NOT deduplicate by
# (release, env), so this job stays single-runner — running it inside
# the matrix would add one row per platform.
# =========================================================================
record-sentry-deploy:
name: Record Sentry deploy marker
runs-on: ubuntu-latest
environment: Production
needs: [prepare-build, publish-release]
if: needs.prepare-build.outputs.release_enabled == 'true'
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
steps:
- name: Install sentry-cli
if: env.SENTRY_AUTH_TOKEN != ''
shell: bash
run: curl -sSf https://sentry.io/get-cli/ | bash
- name: Record deploy marker
if: env.SENTRY_AUTH_TOKEN != ''
shell: bash
env:
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
# Marker lives on the React project's release; events from all
# surfaces share the same `openhuman@<version>+<short_sha>`
# release tag, so the marker on any single project's release
# shows in Sentry's "Deploys" tab for that release group.
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT_REACT }}
SENTRY_RELEASE:
openhuman@${{ needs.prepare-build.outputs.version }}+${{
needs.prepare-build.outputs.short_sha }}
SENTRY_ENVIRONMENT: ${{ inputs.build_target == 'staging' && 'staging' || 'production' }}
run: |
set -euo pipefail
echo "==> Recording deploy marker: ${SENTRY_RELEASE} -> ${SENTRY_ENVIRONMENT}"
sentry-cli releases deploys "${SENTRY_RELEASE}" new \
-e "${SENTRY_ENVIRONMENT}"
# =========================================================================
# 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 }}
# =========================================================================
# 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