release #168
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |