Files
openhuman/.github/workflows/release.yml
T
Steven Enamakel 756f2c9c69 chore(release): disable Windows build notifications in release workflow
- Commented out the Windows build notification section in the release workflow to prevent errors during the release process.
- Added a note indicating that the Windows build is currently disabled in the matrix, improving clarity for future updates.
2026-04-01 00:25:22 -07:00

924 lines
36 KiB
YAML

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 <member>/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_SENTRY_DSN: ${{ vars.OPENHUMAN_SENTRY_DSN }}
- 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: 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_.*_amd64\.AppImage$/,
/OpenHuman_.*_amd64\.deb$/,
// Windows build is currently disabled in the matrix
// /(OpenHuman_.*_x64-setup\.exe$|OpenHuman_.*_x64.*\.msi$)/,
];
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: 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}`);
# notify-discord:
# name: Notify Discord about release
# runs-on: ubuntu-latest
# environment: Production
# needs: [prepare-release, create-release, build-artifacts, publish-release]
# if: needs.publish-release.result == 'success' && needs.build-artifacts.result == 'success'
# steps:
# - name: Post release notification to Discord
# continue-on-error: true
# uses: actions/github-script@v7
# env:
# DISCORD_RELEASE_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASE_WEBHOOK_URL }}
# RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
# RELEASE_TAG: ${{ needs.prepare-release.outputs.tag }}
# with:
# script: |
# const webhookUrl = process.env.DISCORD_RELEASE_WEBHOOK_URL;
# const releaseId = Number(process.env.RELEASE_ID);
# const releaseTag = process.env.RELEASE_TAG;
# if (!webhookUrl) {
# core.warning('DISCORD_RELEASE_WEBHOOK_URL is not configured; skipping Discord release notification.');
# return;
# }
# if (!Number.isFinite(releaseId) || releaseId <= 0) {
# core.warning(`Invalid release id "${process.env.RELEASE_ID}"; skipping Discord release notification.`);
# return;
# }
# const { owner, repo } = context.repo;
# const maxDescriptionLength = 3500;
# const maxAssetLines = 8;
# function truncate(text, maxLength) {
# if (!text) return '';
# if (text.length <= maxLength) return text;
# return `${text.slice(0, maxLength - 3)}...`;
# }
# function stripMarkdownHeadings(text) {
# return text
# .replace(/^#{1,6}\s+/gm, '')
# .replace(/\r/g, '')
# .trim();
# }
# const { data: release } = await github.rest.repos.getRelease({
# owner,
# repo,
# release_id: releaseId,
# });
# const releases = await github.paginate(github.rest.repos.listReleases, {
# owner,
# repo,
# per_page: 100,
# });
# const sortedPublished = releases
# .filter((item) => !item.draft)
# .sort((a, b) => new Date(b.published_at || b.created_at || 0) - new Date(a.published_at || a.created_at || 0));
# const currentIndex = sortedPublished.findIndex((item) => item.id === release.id);
# const previousRelease = currentIndex >= 0 ? sortedPublished[currentIndex + 1] : null;
# const compareUrl = previousRelease
# ? `https://github.com/${owner}/${repo}/compare/${previousRelease.tag_name}...${release.tag_name}`
# : null;
# const releaseTitle = release.name || release.tag_name || releaseTag;
# const bodyText = stripMarkdownHeadings(release.body || '');
# const summary = bodyText
# ? truncate(bodyText, maxDescriptionLength)
# : 'GitHub release published. See the release page for full notes.';
# const assetLines = (release.assets || [])
# .slice(0, maxAssetLines)
# .map((asset) => `- [${asset.name}](${asset.browser_download_url})`);
# const extraAssetCount = Math.max((release.assets || []).length - maxAssetLines, 0);
# if (extraAssetCount > 0) {
# assetLines.push(`- ${extraAssetCount} more asset(s) available on the release page`);
# }
# const fields = [
# {
# name: 'Version',
# value: `\`${release.tag_name || releaseTag}\``,
# inline: true,
# },
# {
# name: 'Release',
# value: `[Open on GitHub](${release.html_url})`,
# inline: true,
# },
# ];
# if (compareUrl) {
# fields.push({
# name: 'Compare',
# value: `[${previousRelease.tag_name}...${release.tag_name}](${compareUrl})`,
# inline: true,
# });
# }
# if (assetLines.length > 0) {
# fields.push({
# name: 'Artifacts',
# value: assetLines.join('\n'),
# inline: false,
# });
# }
# const payload = {
# content: `New GitHub release published for \`${owner}/${repo}\`: **${releaseTitle}**`,
# embeds: [
# {
# title: releaseTitle,
# url: release.html_url,
# description: summary,
# color: release.prerelease ? 0xf0ad4e : 0x2ecc71,
# fields,
# footer: {
# text: release.prerelease ? 'Pre-release' : 'Published release',
# },
# timestamp: release.published_at || new Date().toISOString(),
# },
# ],
# };
# const response = await fetch(webhookUrl, {
# method: 'POST',
# headers: { 'Content-Type': 'application/json' },
# body: JSON.stringify(payload),
# });
# if (!response.ok) {
# const body = await response.text();
# core.warning(`Discord notification failed: HTTP ${response.status} ${body.slice(0, 300)}`);
# return;
# }
# core.info(`Posted Discord release notification for ${release.tag_name || releaseTag}.`);
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;
}
}