Skip to content

release

release #157

Workflow file for this run

name: release
permissions:
contents: write
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Existing version tag to build and attach to the release (e.g. v0.1.0)'
required: true
debug:
description: 'Build with devtools enabled (for Windows testers to see console errors)'
type: boolean
default: false
# Cross-platform Phase 7 release pipeline. Builds Companion Emergence.app /
# .deb / .msi by running app/build_python_runtime.sh as part of
# tauri-build's beforeBuildCommand on each runner. The alpha hosted
# runner matrix intentionally omits macOS x86_64: GitHub's Intel macOS
# runner has been stuck queued indefinitely for this private repo. Intel
# Mac users are source-build-only until we have a reliable runner.
#
# Audit 2026-05-07 P3-10: validation job runs Python tests + Ruff +
# frontend tests + frontend build + Rust check before any matrix
# build runs. A release tag should never produce bundles from a
# commit that fails normal validation.
#
# Audit 2026-05-07 P2-6: bundles get attached to a real GitHub
# Release from each platform build job, avoiding an extra publish runner
# that can be blocked by private-repo billing limits after heavy builds.
# Release notes are generated only by macos-arm64 so parallel platform
# uploads do not append duplicate generated notes. Previously the workflow
# only used upload-artifact (14-day retention), which contradicted
# README/INSTALL pointing users at "the Releases page".
#
# Manual dispatch builds the supplied existing tag rather than whatever
# happens to be at the default branch tip, and publishes to that tag's
# GitHub Release. This keeps manual retries equivalent to tag-push runs.
#
# Signing + notarization are NOT included here — those need
# Apple Developer / Microsoft / Linux distro keys, and the manual
# release-checklist walks through the steps.
env:
RELEASE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
jobs:
validate:
name: validate (tests + lint + frontend + cargo)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_REF }}
- name: Validate manual release tag
if: github.event_name == 'workflow_dispatch'
run: |
case "$RELEASE_TAG" in
v*.*.*) ;;
*) echo "manual release tag must look like vX.Y.Z (got '$RELEASE_TAG')" >&2; exit 1 ;;
esac
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
enable-cache: true
- name: Set up Python 3.12
run: uv python install 3.12
- name: Install Python deps
run: uv sync --all-extras --locked
- name: Run pytest
run: uv run pytest -q
- name: Lint with ruff
run: uv run ruff check .
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 9
- name: Install Node 22
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: app/pnpm-lock.yaml
- name: Frontend deps
working-directory: app
run: pnpm install --frozen-lockfile
- name: Frontend tests (vitest)
working-directory: app
run: pnpm test
- name: Frontend build (tsc + vite)
working-directory: app
run: pnpm build
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Tauri system deps (validate runs on ubuntu)
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev
- name: cargo check
working-directory: app/src-tauri
run: cargo check
- name: cargo test
working-directory: app/src-tauri
run: cargo test
- name: Wheel/sdist smoke
run: bash scripts/smoke_test_wheel.sh
build:
name: ${{ matrix.platform.label }}
needs: validate
strategy:
fail-fast: false
matrix:
platform:
- { label: macos-arm64, os: macos-14, bundles: 'app,dmg' }
- { label: linux-x86_64, os: ubuntu-22.04, bundles: 'deb,appimage' }
- { label: windows-x86_64, os: windows-2022, bundles: 'msi,nsis' }
runs-on: ${{ matrix.platform.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_REF }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
with:
enable-cache: true
- name: Install Python 3.13 (for `uv build`)
run: uv python install 3.13
- name: Install pnpm
uses: pnpm/action-setup@v6
with:
version: 9
- name: Install Node 22
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: app/pnpm-lock.yaml
- name: Linux — install Tauri system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
patchelf \
libssl-dev
- name: Frontend dependencies
working-directory: app
run: pnpm install --frozen-lockfile
- name: Build Python wheel + sync uv env
run: uv sync --all-extras --locked
- name: Enable devtools (debug build)
if: ${{ inputs.debug }}
working-directory: app/src-tauri
run: |
# Add devtools to tauri features so release builds show a
# console on Windows.
if [[ "$RUNNER_OS" == "macOS" ]]; then
sed -i '' 's/features = \["macos-private-api"\]/features = ["macos-private-api", "devtools"]/' Cargo.toml
else
sed -i 's/features = \["macos-private-api"\]/features = ["macos-private-api", "devtools"]/' Cargo.toml
fi
echo "=== Cargo.toml tauri line ==="
grep '^tauri =' Cargo.toml || true
shell: bash
- name: Build Tauri bundle (runs build_python_runtime.sh
via beforeBuildCommand)
working-directory: app
run: pnpm tauri build --bundles ${{ matrix.platform.bundles }}
shell: bash
- name: Validate bundled Python runtime
working-directory: app/src-tauri
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
PY="python-runtime/python.exe"
else
PY="python-runtime/bin/python3"
file "$PY" || true
fi
"$PY" -c "import platform, sys; import brain, fastapi, uvicorn; print(sys.executable); print(platform.platform(), platform.machine())"
shell: bash
- name: Smoke bundled brain CLI
working-directory: app/src-tauri
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
PY="python-runtime/python.exe"
# Git Bash on Windows can't exec the bundled nell.bat
# through a Unix-style relative path. Mimic what the .bat
# does at runtime — invoke python.exe directly with the
# same -c incantation. Functionally equivalent for the
# smoke check.
NELL=("$PY" "-c" "from brain.cli import main; main()")
else
PY="python-runtime/bin/python3"
NELL=("python-runtime/bin/nell")
fi
TMP_HOME="$("$PY" -c "import tempfile; print(tempfile.mkdtemp(prefix='nell-smoke-'))")"
cleanup() {
NELLBRAIN_HOME="$TMP_HOME" "$PY" -c "import os, shutil; shutil.rmtree(os.environ['NELLBRAIN_HOME'], ignore_errors=True)"
}
trap cleanup EXIT
"${NELL[@]}" --version
NELLBRAIN_HOME="$TMP_HOME" "${NELL[@]}" init \
--persona smoke_persona \
--user-name Smoke \
--voice-template default \
--fresh
NELLBRAIN_HOME="$TMP_HOME" "$PY" -c "import os; from pathlib import Path; assert (Path(os.environ['NELLBRAIN_HOME']) / 'personas' / 'smoke_persona' / 'persona_config.json').is_file()"
NELLBRAIN_HOME="$TMP_HOME" "${NELL[@]}" status --persona smoke_persona
shell: bash
- name: Prepare release artifacts and checksums
working-directory: app/src-tauri
env:
PLATFORM_LABEL: ${{ matrix.platform.label }}
run: |
if [ "$RUNNER_OS" = "Windows" ]; then
PY="python-runtime/python.exe"
else
PY="python-runtime/bin/python3"
fi
"$PY" - <<'PY'
import hashlib
import os
import shutil
from pathlib import Path
label = os.environ["PLATFORM_LABEL"]
root = Path("target/release/bundle")
out_dir = Path("target/release/release-assets")
out_dir.mkdir(parents=True, exist_ok=True)
suffixes = {".dmg", ".deb", ".AppImage", ".msi", ".exe"}
packages = sorted(
path for path in root.rglob("*")
if path.is_file() and path.suffix in suffixes
)
if not packages:
raise SystemExit(f"no release package files found under {root}")
files = []
for path in packages:
# GitHub Release URLs are easier to document and copy/paste
# when the product-name space is replaced consistently.
target = out_dir / path.name.replace("Companion Emergence", "Companion.Emergence")
shutil.copy2(path, target)
files.append(target)
out = out_dir / f"SHA256SUMS-{label}.txt"
with out.open("w", encoding="utf-8", newline="\n") as handle:
for path in files:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
handle.write(f"{digest} {path.name}\n")
print(out)
print(out.read_text(encoding="utf-8"))
PY
shell: bash
- name: Sign update artifact
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_UPDATER_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_UPDATER_KEY_PASSWORD }}
UPDATER_PLATFORM_KEY: ${{ matrix.platform.label == 'macos-arm64' && 'darwin-aarch64' || matrix.platform.label == 'linux-x86_64' && 'linux-x86_64' || 'windows-x86_64' }}
working-directory: app
run: |
# Find the main update asset for this platform.
# pnpm runs from app/ (where package.json is), so all paths
# are relative to app/.
case "$RUNNER_OS" in
macOS) asset_glob="src-tauri/target/release/release-assets/*.dmg" ;;
Linux) asset_glob="src-tauri/target/release/release-assets/*.AppImage" ;;
Windows) asset_glob="src-tauri/target/release/release-assets/*.msi" ;;
esac
asset="$(ls $asset_glob 2>/dev/null | head -1)"
if [ -z "$asset" ]; then
echo "ERROR: no update asset found for $RUNNER_OS" >&2
exit 1
fi
asset_name="$(basename "$asset")"
asset_url="https://github.com/hanamorix/companion-emergence/releases/download/${RELEASE_TAG}/${asset_name}"
sig="$(pnpm tauri signer sign -k "$TAURI_SIGNING_PRIVATE_KEY" -p "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" "$asset" | sed -n '/^dW50/p')"
mkdir -p src-tauri/target/release/updater-fragments
cat > "src-tauri/target/release/updater-fragments/${UPDATER_PLATFORM_KEY}.json" <<JSON
{"platform":"${UPDATER_PLATFORM_KEY}","url":"${asset_url}","signature":"${sig}"}
JSON
echo "Signed $asset_name → ${UPDATER_PLATFORM_KEY}"
shell: bash
- name: Upload updater fragment
uses: actions/upload-artifact@v7
with:
name: updater-fragment-${{ matrix.platform.label }}
path: app/src-tauri/target/release/updater-fragments/*.json
retention-days: 1
- name: Upload bundle artifacts (workflow retention)
uses: actions/upload-artifact@v7
with:
name: nellface-${{ matrix.platform.label }}
path: |
app/src-tauri/target/release/bundle/macos/*.app
app/src-tauri/target/release/bundle/dmg/*.dmg
app/src-tauri/target/release/bundle/deb/*.deb
app/src-tauri/target/release/bundle/appimage/*.AppImage
app/src-tauri/target/release/bundle/msi/*.msi
app/src-tauri/target/release/bundle/nsis/*.exe
app/src-tauri/target/release/release-assets/*
if-no-files-found: ignore
retention-days: 14
- name: Publish to GitHub Release
if: ${{ startsWith(inputs.tag || github.ref_name, 'v') }}
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.RELEASE_TAG }}
prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
make_latest: ${{ contains(env.RELEASE_TAG, '-') && 'false' || 'legacy' }}
fail_on_unmatched_files: true
overwrite_files: true
files: |
app/src-tauri/target/release/release-assets/*
generate_release_notes: ${{ matrix.platform.label == 'macos-arm64' }}
append_body: false
publish-update-manifest:
name: publish update manifest (latest.json)
needs: build
runs-on: ubuntu-22.04
steps:
- name: Download all updater fragments
uses: actions/download-artifact@v7
with:
pattern: updater-fragment-*
merge-multiple: true
path: fragments
- name: Assemble latest.json
env:
RELEASE_TAG: ${{ env.RELEASE_TAG || inputs.tag }}
run: |
export VERSION="${RELEASE_TAG#v}"
export PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 -c "
import json, glob, os, sys
version = os.environ.get('VERSION', '0.0.0')
pub_date = os.environ.get('PUB_DATE', '')
platforms = {}
for frag_path in sorted(glob.glob('fragments/*.json')):
with open(frag_path) as fh:
frag = json.load(fh)
platforms[frag['platform']] = {
'signature': frag['signature'],
'url': frag['url'],
}
manifest = {
'version': version,
'notes': 'See the release page for changes.',
'pub_date': pub_date,
'platforms': platforms,
}
with open(os.environ.get('RUNNER_TEMP', '/tmp') + '/latest.json', 'w') as fh:
json.dump(manifest, fh, indent=2)
print(json.dumps(manifest, indent=2))
"
echo "=== latest.json ==="
cat "$RUNNER_TEMP/latest.json"
- name: Publish latest.json to release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.RELEASE_TAG }}
prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
files: ${{ runner.temp }}/latest.json
fail_on_unmatched_files: true
overwrite_files: true
- name: Checkout repo for latest-release push
uses: actions/checkout@v6
with:
ref: latest-release
token: ${{ secrets.GITHUB_TOKEN }}
- name: Update latest-release branch
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
cp "$RUNNER_TEMP/latest.json" latest.json
git add latest.json
git diff --staged --quiet || git commit -m "latest.json for ${RELEASE_TAG}"
git push origin latest-release