Skip to content

chore(public-sync): apply scrub rules from .public-sync #174

chore(public-sync): apply scrub rules from .public-sync

chore(public-sync): apply scrub rules from .public-sync #174

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:
verify-version:
name: verify version pin (tag == pyproject == all six files)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ env.RELEASE_REF }}
- name: Assert tag matches all six version-pin files
shell: bash
run: |
TAG="${{ env.RELEASE_TAG }}"
# Strip leading 'v'; also strip any pre-release suffix so
# v0.0.28-alpha.1 → 0.0.28 for comparison against the six
# files (which stay at the base minor for alpha cycles).
TAG_BASE="${TAG#v}"
TAG_BASE="${TAG_BASE%%-*}" # e.g. 0.0.28-alpha.1 → 0.0.28
# Extract versions from each of the six pin files.
v_py=$(grep -E '^version = "[0-9]' pyproject.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
v_cargo=$(grep -E '^version = "[0-9]' app/src-tauri/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
v_tauri=$(grep '"version"' app/src-tauri/tauri.conf.json | head -1 | sed 's/.*"\([0-9][^"]*\)".*/\1/')
v_pkg=$(grep '"version"' app/package.json | head -1 | sed 's/.*"\([0-9][^"]*\)".*/\1/')
v_lock_uv=$(awk '/name = "companion-emergence"/ {getline; print}' uv.lock | sed 's/.*"\(.*\)".*/\1/')
v_lock_cargo=$(awk '/^name = "nellface"$/ {getline; print}' app/src-tauri/Cargo.lock | sed 's/.*"\(.*\)".*/\1/')
# uv normalises pre-release identifiers (0.0.28-alpha.1 → 0.0.28a1).
# Normalise for comparison: strip separators, map alpha→a, beta→b.
normalize() { echo "$1" | sed 's/[-.]//g; s/alpha/a/g; s/beta/b/g'; }
v_py_norm=$(normalize "$v_py")
v_lock_uv_norm=$(normalize "$v_lock_uv")
tag_norm=$(normalize "$TAG_BASE")
echo "Tag (base): $TAG_BASE (normalised: $tag_norm)"
echo "pyproject.toml: $v_py (normalised: $v_py_norm)"
echo "Cargo.toml: $v_cargo"
echo "tauri.conf.json: $v_tauri"
echo "package.json: $v_pkg"
echo "uv.lock: $v_lock_uv (normalised: $v_lock_uv_norm)"
echo "Cargo.lock: $v_lock_cargo"
fail=0
# (a) tag base must equal pyproject version (normalised for alpha cycles).
if [[ "$tag_norm" != "$v_py_norm" ]]; then
echo "ERROR: tag $TAG_BASE does not match pyproject.toml version $v_py" >&2
fail=1
fi
# (b) all six files must agree with each other.
for label_ver in "Cargo.toml:$v_cargo" "tauri.conf.json:$v_tauri" "package.json:$v_pkg" "Cargo.lock:$v_lock_cargo"; do
label="${label_ver%%:*}"
ver="${label_ver#*:}"
if [[ "$v_py" != "$ver" ]]; then
echo "ERROR: $label ($ver) disagrees with pyproject.toml ($v_py)" >&2
fail=1
fi
done
# uv.lock compared normalised.
if [[ "$v_py_norm" != "$v_lock_uv_norm" ]]; then
echo "ERROR: uv.lock ($v_lock_uv, normalised $v_lock_uv_norm) disagrees with pyproject.toml ($v_py, normalised $v_py_norm)" >&2
fail=1
fi
if [[ "$fail" -ne 0 ]]; then
echo ""
echo "Six-file version pin check FAILED. Fix all six files before tagging." >&2
exit 1
fi
echo "Version pin OK — tag $TAG matches all six files at $v_py"
validate:
name: validate (tests + lint + frontend + cargo)
needs: verify-version
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