Skip to content

feat(agents): file navigation, web browsing, scratchpad tools, and write security guardrails #155

feat(agents): file navigation, web browsing, scratchpad tools, and write security guardrails

feat(agents): file navigation, web browsing, scratchpad tools, and write security guardrails #155

# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
#
# Builds desktop installers (NSIS .exe, DMG, DEB, AppImage) on release tags
# and uploads them to the GitHub Release.
#
# Trigger: tag push (v*), manual workflow_dispatch, or reusable workflow_call
# from publish.yml (after the single approval gate — integration TBD).
#
# Per docs/plans/desktop-installer.mdx §7 Phase G and §9 output matrix.
#
# Design notes:
# • Runs WITHOUT any secrets — produces unsigned installers for free.
# • Code signing is opt-in via secret presence (env-driven).
# - Windows: SignPath (Phase H) via SIGNPATH_API_TOKEN.
# - macOS: Apple Developer ID via APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID + CSC_LINK + CSC_KEY_PASSWORD.
# • macos-latest is Apple Silicon (arm64). Intel requires macos-13 or older.
# • fail-fast is disabled so one platform failure doesn't abort the others.
# • concurrency.cancel-in-progress=false — mid-build cancels waste CI minutes.
# • latest*.yml + .blockmap files are uploaded alongside installers so
# electron-updater can detect updates and apply delta patches.
# • When triggered by a direct tag push, the GitHub Release is created as
# DRAFT to avoid leaking releases before publish.yml's single approval
# gate passes. The publish.yml integration (follow-up PR) will promote
# the draft to a non-draft release once approval completes.
name: Build Installers
on:
# Note: there is intentionally NO `push: tags: v*` trigger.
# `publish.yml` is the canonical entry point for tagged releases — it
# invokes this workflow via `workflow_call` after the validate step,
# then gates publishing on a single approval. Adding a direct `push`
# trigger here would cause two concurrent runs of this workflow on
# every tag push (one direct, one from publish.yml), racing on the
# same draft GitHub Release.
workflow_dispatch:
inputs:
tag:
description: 'Tag to build (leave blank to build HEAD without publishing to a release)'
required: false
default: ''
publish_to_release:
description: 'Upload artifacts to the GitHub Release matching the tag'
required: false
type: boolean
default: false
workflow_call:
inputs:
tag:
description: 'Tag to build'
required: false
type: string
default: ''
publish_to_release:
description: 'Upload artifacts to the GitHub Release matching the tag'
required: false
type: boolean
default: false
# Explicit secret declarations. Callers (publish.yml) must forward
# each one explicitly via `secrets:` — we intentionally do NOT use
# `secrets: inherit` on the caller side so forks / PRs can't leak
# unrelated repo secrets into this reusable workflow. Each secret
# is optional; missing secrets degrade to an unsigned build.
secrets:
SIGNPATH_API_TOKEN:
required: false
SIGNPATH_ORG_ID:
required: false
APPLE_ID:
required: false
APPLE_APP_SPECIFIC_PASSWORD:
required: false
APPLE_TEAM_ID:
required: false
CSC_LINK:
required: false
CSC_KEY_PASSWORD:
required: false
# Validate installer builds on any PR that touches installer-related files.
# Gated on paths so normal PRs don't pay the ~15min cross-platform build cost.
# This catches electron-builder config drift, entitlement breakage, NSIS
# script errors, etc. BEFORE they land on main.
pull_request:
paths:
- '.github/workflows/build-installers.yml'
- 'installer/**'
- 'src/gaia/apps/webui/electron-builder.yml'
- 'src/gaia/apps/webui/package.json'
- 'src/gaia/apps/webui/package-lock.json'
- 'src/gaia/apps/webui/main.cjs'
- 'src/gaia/apps/webui/bin/**'
- 'src/gaia/apps/webui/services/**'
# Cover the installer-smoke test tree (issue #941) so a PR that
# only touches the smoke-test layer still triggers structural smoke.
# Narrower than `tests/electron/**` so Jest-only edits to e.g.
# test_electron_chat_app.js don't re-run the multi-platform build.
- 'tests/electron/_helpers/**'
- 'tests/electron/*-smoke.test.mjs'
- 'tests/electron/fixtures/**'
- 'src/gaia/version.py'
# Least-privilege default. Only the release-upload step needs `contents:
# write`, and it's gated behind `inputs.publish_to_release` — which is
# false for PR triggers, so fork PRs can never get elevated write access
# via this workflow. The `build` job below redeclares `contents: read`
# explicitly for clarity; `id-token: write` stays because SignPath's
# OIDC handshake needs it even on unsigned PR builds (the step itself
# is still gated on secret presence).
permissions:
contents: read
id-token: write # SignPath OIDC (Phase H)
concurrency:
# PR runs should cancel-in-progress to avoid burning ~45 CI minutes per
# push on stale builds. Tag/release runs and manual dispatches must
# NOT cancel — a mid-build cancel on a release tag leaves the draft
# release in a partial state.
group: build-installers-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
name: Build ${{ matrix.platform }} installer
runs-on: ${{ matrix.runner }}
# Build job is strictly read-only — it produces workflow-run artifacts
# that callers (publish.yml) download and re-upload. The opt-in
# GitHub Release upload step elevates with a per-step token (see the
# `softprops/action-gh-release` step and its `inputs.publish_to_release`
# gate, which is false for any PR trigger).
permissions:
contents: read
id-token: write # SignPath OIDC — step is still secret-gated
strategy:
fail-fast: false
matrix:
include:
- platform: windows
runner: windows-latest
npm_script: package:win
artifacts: |
src/gaia/apps/webui/dist-app/*.exe
src/gaia/apps/webui/dist-app/*.exe.blockmap
src/gaia/apps/webui/dist-app/latest.yml
- platform: macos
runner: macos-latest # Apple Silicon (arm64)
npm_script: package:mac
artifacts: |
src/gaia/apps/webui/dist-app/*.dmg
src/gaia/apps/webui/dist-app/*.dmg.blockmap
src/gaia/apps/webui/dist-app/latest-mac.yml
- platform: linux
runner: ubuntu-latest
npm_script: package:linux
artifacts: |
src/gaia/apps/webui/dist-app/*.deb
src/gaia/apps/webui/dist-app/*.AppImage
src/gaia/apps/webui/dist-app/*.blockmap
src/gaia/apps/webui/dist-app/latest-linux.yml
# Job-level env so that step-level `if:` conditions can reference
# secret-derived values (secrets cannot be used in `if:` directly,
# and step-level `env:` is NOT visible to that step's own `if:` —
# only workflow- and job-level env are). Any secret that gates a
# step via `env.X != ''` MUST be declared here. Empty strings are
# fine when the secret is not set — the step is then silently
# skipped, which is the intended opt-in-by-secret-presence behavior.
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_ORG_ID: ${{ secrets.SIGNPATH_ORG_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # full history for any version calculations
ref: ${{ inputs.tag || github.ref }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: src/gaia/apps/webui/package-lock.json
# electron + electron-builder download sizeable binaries (Electron runtime,
# code-signing helpers, etc.). Caching them saves ~1–2 min per run per
# platform. Cross-runner cache paths are listed together — actions/cache
# silently skips paths that don't exist on the current runner.
- name: Cache electron + electron-builder
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~/Library/Caches/electron
~/Library/Caches/electron-builder
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: ${{ matrix.platform }}-electron-${{ hashFiles('src/gaia/apps/webui/package-lock.json') }}
restore-keys: |
${{ matrix.platform }}-electron-
- name: Install npm dependencies
working-directory: src/gaia/apps/webui
shell: bash
run: npm ci
# ─── Fetch uv binary for Linux AppImage (issue #782) ────────────
# The AppImage bundles a pinned uv binary under build/vendor/uv so the
# after-pack hook and runtime can provision the Python env without
# requiring uv on the host. Pin + sha256 verify for supply-chain safety.
- name: Fetch uv binary (Linux)
if: matrix.platform == 'linux'
shell: bash
run: |
set -euo pipefail
UV_VERSION="0.5.14"
UV_TARBALL="uv-x86_64-unknown-linux-gnu.tar.gz"
UV_SHA256="22034760075b92487b326da5aa1a2a3e1917e2e766c12c0fd466fccda77013c7"
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}"
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/linux-x64"
mkdir -p "${DEST_DIR}"
tmpdir="$(mktemp -d)"
curl -fsSL -o "${tmpdir}/${UV_TARBALL}" "${UV_URL}"
echo "${UV_SHA256} ${tmpdir}/${UV_TARBALL}" | sha256sum -c -
tar -xzf "${tmpdir}/${UV_TARBALL}" -C "${tmpdir}"
cp "${tmpdir}/uv-x86_64-unknown-linux-gnu/uv" "${DEST_DIR}/uv"
chmod 0755 "${DEST_DIR}/uv"
"${DEST_DIR}/uv" --version
# ─── Fetch uv binary for macOS DMG (issue #941) ─────────────────
# Mirror of the Linux step above. Without this, the macOS .app
# ships no bundled uv even though backend-installer.cjs claims
# support for darwin-arm64, so first-launch on a clean Mac (no
# system uv on PATH) hard-fails in ensure-uv. The runtime hashes
# the *extracted* binary against BUNDLED_UV_SHA256["mac-arm64"]
# in src/gaia/apps/webui/services/backend-installer.cjs — those
# two pins MUST be bumped in lockstep with this step.
- name: Fetch uv binary (macOS)
if: matrix.platform == 'macos'
shell: bash
run: |
set -euo pipefail
UV_VERSION="0.5.14"
UV_TARBALL="uv-aarch64-apple-darwin.tar.gz"
UV_SHA256="d548dffc256014c4c8c693e148140a3a21bcc2bf066a35e1d5f0d24c91d32112"
UV_URL="https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_TARBALL}"
DEST_DIR="src/gaia/apps/webui/build/vendor/uv/mac-arm64"
mkdir -p "${DEST_DIR}"
tmpdir="$(mktemp -d)"
# --retry 3/5s matches the Lemonade MSI step below; survives
# transient GitHub Releases CDN flakes on hosted macOS runners.
curl -fsSL --retry 3 --retry-delay 5 -o "${tmpdir}/${UV_TARBALL}" "${UV_URL}"
# macOS shasum -a 256 -c accepts the GNU "hash file" format.
echo "${UV_SHA256} ${tmpdir}/${UV_TARBALL}" | shasum -a 256 -c -
tar -xzf "${tmpdir}/${UV_TARBALL}" -C "${tmpdir}"
cp "${tmpdir}/uv-aarch64-apple-darwin/uv" "${DEST_DIR}/uv"
chmod 0755 "${DEST_DIR}/uv"
"${DEST_DIR}/uv" --version
# Echo the extracted-binary SHA. This is the PRE-codesign digest
# (i.e., the upstream tarball's uv byte-for-byte), useful for
# confirming what feeds into electron-builder's codesign step.
# NOTE: this is NOT directly comparable to BUNDLED_UV_SHA256[mac-arm64],
# which is the POST-codesign digest — see backend-installer.cjs.
# To bump BUNDLED_UV_SHA256[mac-arm64], run the CI build and copy
# the SHA from the dmg-structural-smoke failure message.
shasum -a 256 "${DEST_DIR}/uv"
- name: Build frontend (Vite)
working-directory: src/gaia/apps/webui
shell: bash
run: npm run build
# ─── Bundle Lemonade Server MSI (issue #774) ────────────────────
# The Windows NSIS installer embeds lemonade-server-minimal.msi so
# users get a working Lemonade setup on first launch with no runtime
# download. version.nsh exposes ${LEMONADE_VERSION} to NSIS for the
# DetailPrint message; the MSI itself is downloaded from the pinned
# upstream release. Both steps Windows-only — no impact on mac/linux.
- name: Generate installer/version.nsh (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
# Runs write_version_files() which emits installer/version.nsh containing
# !define LEMONADE_VERSION and !define GAIA_VERSION
python src/gaia/version.py
echo "Generated installer/version.nsh:"
cat installer/version.nsh
- name: Download Lemonade MSI (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
LEMONADE_VERSION=$(grep -oE 'LEMONADE_VERSION = "[^"]+"' src/gaia/version.py | cut -d'"' -f2)
if [ -z "$LEMONADE_VERSION" ]; then
echo "ERROR: Could not parse LEMONADE_VERSION from src/gaia/version.py" >&2
exit 1
fi
URL="https://github.com/lemonade-sdk/lemonade/releases/download/v${LEMONADE_VERSION}/lemonade-server-minimal.msi"
echo "Downloading Lemonade MSI v${LEMONADE_VERSION} from ${URL}"
curl -fsSL --retry 3 --retry-delay 5 "${URL}" -o installer/lemonade-server-minimal.msi
# Sanity check: the -minimal MSI is a bootstrap installer (~4-6MB) that
# fetches the Lemonade runtime on first run, so we guard against an
# obviously-truncated download (<1MB = not even an MSI header) rather
# than pinning to a specific upstream size that can change between
# minor releases.
SIZE=$(wc -c < installer/lemonade-server-minimal.msi)
echo "Downloaded MSI size: ${SIZE} bytes"
if [ "$SIZE" -lt 1048576 ]; then
echo "ERROR: MSI smaller than 1MB; download likely corrupt or 404 HTML body." >&2
exit 1
fi
# ─── Code signing config (opt-in by secret presence) ─────────────
- name: Detect Windows code signing
if: matrix.platform == 'windows' && env.SIGNPATH_API_TOKEN != ''
shell: bash
run: |
echo "SignPath token detected — Windows installer will be signed."
echo "NOTE: SignPath action integration lands in Phase H."
- name: Detect macOS code signing
if: matrix.platform == 'macos' && env.APPLE_ID != ''
shell: bash
run: |
echo "Apple Developer ID detected — electron-builder will sign + notarize."
echo "CSC_LINK / CSC_KEY_PASSWORD are read automatically by electron-builder."
# ─── Build the installer ────────────────────────────────────────
- name: Build installer
working-directory: src/gaia/apps/webui
shell: bash
env:
# GH_TOKEN is needed by electron-builder's publish step when the
# `publish` field is set in electron-builder.yml. We pass the
# workflow token but DO NOT publish here — we upload via
# softprops/action-gh-release in a later step. `publish: never`
# is implied by the CLI flag below.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Always allow identity auto-discovery. When APPLE_ID is set,
# electron-builder finds the real Developer ID cert in the keychain.
# When APPLE_ID is absent, the explicit --config.mac.identity=-
# below overrides auto-discovery and forces ad-hoc signing.
# Setting this to 'false' would suppress signing entirely — even
# when an explicit identity is passed on the CLI.
CSC_IDENTITY_AUTO_DISCOVERY: "true"
# --publish never: we upload artifacts ourselves via action-gh-release.
# On macOS without signing secrets, ad-hoc sign with identity="-".
# This produces a valid code signature (sealed resources, correct
# bundle ID) without a Developer ID cert. Users see "cannot be
# verified" (bypassable via right-click → Open) instead of the
# unrecoverable "is damaged" error that identity=null caused.
run: |
EXTRA_ARGS=""
if [ "${{ matrix.platform }}" = "macos" ] && [ -z "$APPLE_ID" ]; then
echo "No APPLE_ID set — using ad-hoc code signing (identity=-)"
# electron-builder skips signing for pull-request builds unless this
# env var is set. Safe to enable for ad-hoc: there are no real signing
# credentials to leak. Remove this if real Developer ID certs are added
# (the security warning in electron-builder's output only matters when
# CSC_LINK / APPLE_ID secrets are present and fork PRs are allowed).
export CSC_FOR_PULL_REQUEST=true
# CSC_LINK is set to "" at the job level (secrets expand to empty
# strings when unset). @electron/osx-sign resolves "" to the working
# directory and fails with "not a file". Unset it so osx-sign skips
# the certificate-file path entirely and honours identity=- directly.
unset CSC_LINK
EXTRA_ARGS="--config.mac.identity=-"
fi
npm run ${{ matrix.npm_script }} -- --publish never $EXTRA_ARGS
# ─── Inspect build output ───────────────────────────────────────
- name: List build artifacts
if: always()
working-directory: src/gaia/apps/webui
shell: bash
run: |
echo "=== dist-app/ contents ==="
if [ -d dist-app ]; then
ls -lh dist-app/ || true
echo ""
echo "=== Artifact sizes ==="
find dist-app -maxdepth 1 -type f \
\( -name "*.exe" -o -name "*.dmg" -o -name "*.deb" \
-o -name "*.AppImage" -o -name "*.blockmap" \
-o -name "*.yml" \) \
-exec ls -lh {} \; || true
else
echo "(dist-app/ does not exist — build may have failed)"
fi
# ─── Verify Lemonade MSI bundling (issue #774) ───────────────────
# Guards against regressions where the MSI stops being bundled.
# Uses 7z to inspect the NSIS installer archive. NSIS solid
# compression can prevent 7z from listing inner files — in that
# case we print a warning but do NOT fail the build (the NSIS File
# directive already fails compilation if the MSI was missing, so a
# built .exe is strong evidence of a successful bundle). We only
# fail if 7z CAN list contents but the MSI name is absent.
- name: Verify Lemonade MSI embedded in installer (Windows)
if: matrix.platform == 'windows'
shell: bash
run: |
INSTALLER=$(ls src/gaia/apps/webui/dist-app/*.exe 2>/dev/null | head -1)
if [ -z "$INSTALLER" ]; then
echo "ERROR: No installer .exe found in dist-app/" >&2
exit 1
fi
echo "Inspecting: ${INSTALLER}"
LISTING=$(7z l "${INSTALLER}" 2>&1)
STATUS=$?
if [ $STATUS -ne 0 ]; then
echo "WARNING: 7z could not list installer contents (NSIS solid compression?). Build passed — NSIS File directive is the compile-time gate."
elif echo "${LISTING}" | grep -q "lemonade-server-minimal.msi"; then
echo "OK: Lemonade MSI is confirmed bundled in the installer."
else
echo "ERROR: 7z can list the installer but lemonade-server-minimal.msi is NOT present." >&2
exit 1
fi
# ─── Verify macOS code signature ─────────────────────────────────
# Guards against shipping a broken .app bundle (the class of bug
# that caused #745 — identity=null produced a linker-only ad-hoc
# signature with no sealed resources, which macOS reported as
# "damaged"). This step fails the build if the signature is invalid.
- name: Verify macOS code signature
if: matrix.platform == 'macos'
working-directory: src/gaia/apps/webui
shell: bash
run: |
APP=$(find dist-app -name "*.app" -maxdepth 2 -print -quit 2>/dev/null)
if [ -z "$APP" ]; then
echo "No .app found in dist-app/ — skipping verification"
exit 0
fi
echo "=== Verifying: $APP ==="
# --deep validates nested code (frameworks, helpers).
# --strict is omitted: it rejects valid ad-hoc signatures on macOS 13+.
codesign --verify --deep "$APP"
echo ""
echo "=== Signature details ==="
codesign -dv --verbose=4 "$APP" 2>&1
echo ""
echo "=== Sealed Resources check ==="
if codesign -dv --verbose=4 "$APP" 2>&1 | grep -q "Sealed Resources"; then
echo "OK: Resources are sealed"
else
echo "FAIL: No sealed resources — the .app bundle has a broken signature"
exit 1
fi
# ─── Upload to workflow run (always, for debugging) ─────────────
# NOTE: this step MUST run before the SignPath step below, because
# SignPath references its artifact-id output (`steps.upload-artifacts.
# outputs.artifact-id`) to know which artifact to fetch, sign, and
# write back. Reordering these two steps will silently break Windows
# signing.
- name: Upload artifacts to workflow run
id: upload-artifacts
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.platform }}-installer
path: ${{ matrix.artifacts }}
retention-days: 14
if-no-files-found: error
# ─── SignPath signing (Phase H — opt-in via secrets) ────────────
# This step uploads the unsigned NSIS .exe to SignPath for code
# signing, then downloads the signed artifact back over the same
# filename. SignPath OSS is free for open-source projects:
# https://signpath.io/solutions/open-source-community
#
# Required GitHub Action secrets (set up once via SignPath onboarding):
# SIGNPATH_API_TOKEN — issued by SignPath after OSS approval
# SIGNPATH_ORG_ID — your SignPath organization UUID
#
# Until both secrets are set, this step is silently skipped and the
# NSIS installer ships unsigned. End-users see a SmartScreen
# warning the first time they run an unsigned installer; the
# troubleshooting guide documents the bypass step.
#
# Ordering: this step runs AFTER `Upload artifacts to workflow run`
# so that step's `id: upload-artifacts` output is populated before
# we read `steps.upload-artifacts.outputs.artifact-id`.
- name: Sign Windows installer (SignPath)
# Both env vars are sourced from the job-level env: block (which
# reads the secrets). GitHub Actions only exposes workflow- and
# job-level env to a step's `if:` expression — step-level env is
# evaluated AFTER `if:`, which is why we can't inline the secret
# forwarding here.
if: matrix.platform == 'windows' && env.SIGNPATH_API_TOKEN != '' && env.SIGNPATH_ORG_ID != ''
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ env.SIGNPATH_API_TOKEN }}
organization-id: ${{ env.SIGNPATH_ORG_ID }}
project-slug: gaia-agent-ui
signing-policy-slug: release-signing
artifact-configuration-slug: gaia-installer
github-artifact-id: ${{ steps.upload-artifacts.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: src/gaia/apps/webui/dist-app/
# ─── Build Python wheel (Linux release builds only) ─────────────
# The wheel is consumed by the AppImage smoke tests so they can
# install the backend from a local file instead of pulling from PyPI.
# This breaks the circular dependency where smoke tests run before
# PyPI publish in the release pipeline.
- name: Build Python wheel
if: matrix.platform == 'linux'
id: build-wheel
shell: bash
run: |
set -euo pipefail
pip install build --quiet
python -m build --wheel --outdir /tmp/gaia-wheel
WHEEL=$(ls /tmp/gaia-wheel/*.whl | head -n1)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
echo "Built wheel: ${WHEEL}"
- name: Upload Python wheel artifact
if: matrix.platform == 'linux' && steps.build-wheel.outputs.path != ''
uses: actions/upload-artifact@v6
with:
name: gaia-wheel
path: /tmp/gaia-wheel/*.whl
retention-days: 14
# ─── AppImage smoke tests (issue #782) ──────────────────────────────
# Consume the linux-installer artifact produced by the `build` matrix
# and run structural + distro-level smoke checks against the AppImage.
# These jobs MUST NOT modify the artifact — they are read-only consumers.
#
# AC mapping (see plan for issue #782):
# AC1 — Ubuntu 24.04 minimal launches without curl / with libfuse2
# AC2 — Arch-equivalent launch (fedora row covers RPM-family SELinux)
# AC3 — pre-built dist/ ships (structural check)
# AC4 — bundled uv present (structural check)
# AC5 — HTML fallback at / (tested separately by unit tests)
# AC6 — port-manager unit tests (tested separately)
# AC8 — Wayland visibility (DEFERRED — see wayland-visibility stub below)
# AC9 — structural guarantees on every release build
appimage-structural-smoke:
name: AppImage structural smoke
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
- name: Locate AppImage
id: locate
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
echo "Found AppImage: ${APPIMAGE}"
chmod +x "${APPIMAGE}"
echo "appimage=${APPIMAGE}" >> "$GITHUB_OUTPUT"
- name: Structural smoke (chrome-sandbox, uv, dist, app.asar)
env:
GAIA_APPIMAGE: ${{ steps.locate.outputs.appimage }}
run: node --test tests/electron/appimage-smoke.test.mjs
# ─── DMG structural smoke (issue #941) ──────────────────────────────
# Mirrors appimage-structural-smoke for the macOS DMG. Catches the
# failure mode that bit v0.17.5: a darwin-arm64 install that hard-fails
# in ensure-uv on first launch because the bundled uv either was never
# shipped or has the wrong SHA256 against BUNDLED_UV_SHA256[mac-arm64]
# in backend-installer.cjs.
#
# MUST be present in build-complete `needs:` below — without that
# wiring, a failing DMG smoke does not block release-readiness.
dmg-structural-smoke:
name: DMG structural smoke
needs: build
if: always() && needs.build.result == 'success'
runs-on: macos-latest # Apple Silicon (arm64) — matches build matrix
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS installer artifact
uses: actions/download-artifact@v6
with:
name: macos-installer # ${{ matrix.platform }}-installer with platform=macos
path: ${{ runner.temp }}/macos-installer
- name: Locate DMG
id: locate
shell: bash
run: |
set -euo pipefail
DMG=$(ls "${RUNNER_TEMP}/macos-installer"/*.dmg | head -n1)
echo "Found DMG: ${DMG}"
echo "dmg=${DMG}" >> "$GITHUB_OUTPUT"
- name: Structural smoke (uv binary, mode, sha256, --version)
env:
GAIA_DMG: ${{ steps.locate.outputs.dmg }}
run: node --test tests/electron/dmg-smoke.test.mjs
appimage-distro-matrix:
name: AppImage distro matrix
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
# Download the Python wheel built alongside the installer so the
# AppImage smoke test can install from a local file instead of PyPI.
# continue-on-error: the wheel is absent on PR builds (fine — PyPI
# still has the previous release), but required on release builds.
- name: Download Python wheel
id: download-wheel
continue-on-error: true
uses: actions/download-artifact@v6
with:
name: gaia-wheel
path: ${{ runner.temp }}/gaia-wheel
- name: Locate Python wheel
id: locate-wheel
env:
RELEASE_TAG: ${{ inputs.tag }}
shell: bash
run: |
WHEEL=$(ls "${RUNNER_TEMP}/gaia-wheel"/*.whl 2>/dev/null | head -n1 || true)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
if [ -n "${WHEEL}" ]; then
echo "Found wheel: ${WHEEL} — smoke tests will use GAIA_LOCAL_WHEEL"
elif [ -n "${RELEASE_TAG}" ]; then
echo "::error::No Python wheel artifact found for release build (tag=${RELEASE_TAG}). The wheel is required to avoid the PyPI circular dependency — the Build Python wheel step must have failed."
exit 1
else
echo "No wheel artifact — smoke tests will install from PyPI (non-release build)"
fi
- name: Prepare AppImage
id: prep
shell: bash
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
chmod +x "${APPIMAGE}"
# Share a stable path into the container workdir mount.
cp "${APPIMAGE}" "${RUNNER_TEMP}/linux-installer/gaia-agent-ui.AppImage"
echo "appimage=${RUNNER_TEMP}/linux-installer/gaia-agent-ui.AppImage" >> "$GITHUB_OUTPUT"
# All three rows share one runner. Containers are launched in a
# loop so we pay one VM boot for N distro checks (cost discipline
# per the amended T7 in the plan).
- name: Run distro rows (ubuntu24-libfuse, ubuntu24-nofuse, fedora41)
shell: bash
env:
APPIMAGE_HOST: ${{ steps.prep.outputs.appimage }}
WHEEL_PATH: ${{ steps.locate-wheel.outputs.path }}
run: |
set -euo pipefail
# Build the two Ubuntu/Fedora fixtures locally.
docker build -t gaia-test-u24-min \
-f tests/electron/fixtures/Dockerfile.ubuntu-24-minimal \
tests/electron/fixtures
docker build -t gaia-test-fedora41 \
-f tests/electron/fixtures/Dockerfile.fedora-41 \
tests/electron/fixtures
# If a local wheel is available, mount it and set GAIA_LOCAL_WHEEL
# so the AppImage installs the backend from file instead of PyPI.
# This is the release-pipeline path — on PR builds WHEEL_PATH is
# empty and the app falls back to pulling from PyPI normally.
WHEEL_MOUNTS=()
if [ -n "${WHEEL_PATH}" ]; then
WHEEL_BASENAME=$(basename "${WHEEL_PATH}")
WHEEL_DIR=$(dirname "${WHEEL_PATH}")
WHEEL_MOUNTS=(
-v "${WHEEL_DIR}:/work/wheels:ro"
--env "GAIA_LOCAL_WHEEL=/work/wheels/${WHEEL_BASENAME}"
)
fi
DOCKER_RUN_COMMON=(
--rm
--cap-add SYS_ADMIN
--device /dev/fuse
--security-opt seccomp=unconfined
--security-opt apparmor=unconfined
--ipc=host
-v "${APPIMAGE_HOST}:/work/gaia.AppImage:ro"
# Expand to nothing if WHEEL_MOUNTS is empty (set -u compat)
"${WHEEL_MOUNTS[@]+"${WHEEL_MOUNTS[@]}"}"
)
# ── Row 1: Ubuntu 24.04 + libfuse2, curl purged ──────────────
# Must reach "state: ready" AND /api/health must return service
# string "gaia-agent-ui" (proves the app actually served
# requests, not just logged and died). No FATAL on stderr.
echo "::group::Row 1 — ubuntu:24.04 minimal with libfuse2 (no curl)"
docker run "${DOCKER_RUN_COMMON[@]}" gaia-test-u24-min \
bash -c '
set -eo pipefail
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
# Launch under xvfb, background. Capture PID correctly so we
# can health-check and kill cleanly at end.
xvfb-run --auto-servernum /tmp/gaia.AppImage \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
# Poll for readiness up to 300s — fresh install downloads
# Lemonade + a model on first run; 90s was too tight.
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
grep -q "state: ready" /tmp/stdout.log || {
echo "::error::did not reach state: ready"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
}
# Prove the API actually serves: /api/health must echo the
# service string. Re-read the port on every attempt because
# main.cjs logs "Starting backend: ... --ui-port <n>" shortly
# AFTER state: ready — the log line may not exist yet on the
# first iteration. Fall back to 4200 for older builds.
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
# Use bash /dev/tcp instead of curl — curl is purged in this
# container to prove the bundled-uv path eliminates that dep.
# Avoid single quotes inside the outer single-quoted bash -c block
# by using a subshell ( ) with double-quoted printf instead.
RESPONSE=$( (exec 3<>/dev/tcp/127.0.0.1/${PORT} && printf "GET /api/health HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" >&3 && timeout 5 cat <&3) 2>/dev/null || true)
if echo "$RESPONSE" | grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "::error::/api/health did not report service=gaia-agent-ui on port ${PORT}"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
# Process must still be alive (did not silently crash).
if ! kill -0 "$APP_PID" 2>/dev/null; then
echo "::error::main process exited after logging state: ready"
exit 1
fi
# Sanity: no FATAL sandbox in stderr.
if grep -qE "FATAL:sandbox|\[Errno 98\]|GLIBC_2\.(3[89]|4[0-9])" /tmp/stderr.log; then
echo "::error::forbidden error pattern in stderr"
cat /tmp/stderr.log
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
# Clean teardown.
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "Row 1 PASS (state: ready + /api/health 200)"
'
echo "::endgroup::"
# ── Row 2: Ubuntu 24.04 WITHOUT libfuse2 ─────────────────────
# Launching the AppImage directly MUST emit a human-readable
# error message pointing to the missing FUSE dependency, not
# a raw fusermount tracepoint or a silent segfault.
echo "::group::Row 2 — ubuntu:24.04 minimal WITHOUT libfuse2"
docker run "${DOCKER_RUN_COMMON[@]}" ubuntu:24.04 \
bash -c '
set -o pipefail
apt-get update >/dev/null
apt-get install --yes --no-install-recommends file ca-certificates >/dev/null
# Intentionally do NOT install libfuse2.
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
set +e
/tmp/gaia.AppImage >/tmp/out.log 2>&1
rc=$?
set -e
cat /tmp/out.log
if [ "$rc" -eq 0 ]; then
echo "::error::AppImage somehow succeeded without libfuse2 — unexpected"
exit 1
fi
# Require diagnostic text. A silent exit (empty output) or a
# bare segfault is the bug we are guarding against. AppImage
# runtime emits a recognizable message when libfuse2 is
# missing — grep for known keywords (fuse/libfuse/FUSE or
# the AppImage runtime hint) as a humane-error contract.
if [ ! -s /tmp/out.log ]; then
echo "::error::AppImage exited silently without libfuse2 — bug"
exit 1
fi
if ! grep -qiE "fuse|libfuse|AppImage|fusermount" /tmp/out.log; then
echo "::error::error output lacks a humane FUSE hint; expected keyword fuse|libfuse|AppImage|fusermount"
exit 1
fi
echo "Row 2 PASS (humane failure referencing fuse/appimage)"
'
echo "::endgroup::"
# ── Row 3: Fedora 41 (RPM family, SELinux headers present) ───
echo "::group::Row 3 — fedora:41"
docker run "${DOCKER_RUN_COMMON[@]}" gaia-test-fedora41 \
bash -c '
set -eo pipefail
cp /work/gaia.AppImage /tmp/gaia.AppImage
chmod +x /tmp/gaia.AppImage
xvfb-run --auto-servernum /tmp/gaia.AppImage \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
grep -q "state: ready" /tmp/stdout.log || {
echo "::error::fedora did not reach state: ready"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
}
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
if curl -sSf "http://127.0.0.1:${PORT}/api/health" \
| grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "::error::fedora: /api/health did not report service=gaia-agent-ui"
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "Row 3 PASS (state: ready + /api/health 200)"
'
echo "::endgroup::"
appimage-userns-restricted:
# Ubuntu 24.04.1+ defaults: kernel.apparmor_restrict_unprivileged_userns=1
# Validates the appImage.executableArgs: [--no-sandbox] fallback lands.
name: AppImage userns-restricted
needs: build
if: always() && needs.build.result == 'success'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download Linux installer artifact
uses: actions/download-artifact@v6
with:
name: linux-installer
path: ${{ runner.temp }}/linux-installer
- name: Download Python wheel
id: download-wheel
continue-on-error: true
uses: actions/download-artifact@v6
with:
name: gaia-wheel
path: ${{ runner.temp }}/gaia-wheel
- name: Locate Python wheel
id: locate-wheel
env:
RELEASE_TAG: ${{ inputs.tag }}
shell: bash
run: |
WHEEL=$(ls "${RUNNER_TEMP}/gaia-wheel"/*.whl 2>/dev/null | head -n1 || true)
echo "path=${WHEEL}" >> "$GITHUB_OUTPUT"
if [ -n "${WHEEL}" ]; then
echo "Found wheel: ${WHEEL} — will set GAIA_LOCAL_WHEEL"
elif [ -n "${RELEASE_TAG}" ]; then
echo "::error::No Python wheel artifact found for release build (tag=${RELEASE_TAG}). The wheel is required to avoid the PyPI circular dependency — the Build Python wheel step must have failed."
exit 1
else
echo "No wheel artifact — will install from PyPI (non-release build)"
fi
- name: Enable AppArmor userns restriction on host
shell: bash
run: |
set -euo pipefail
# The sysctl may not exist on older kernels — tolerate that.
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=1 || {
echo "::warning::kernel.apparmor_restrict_unprivileged_userns not supported on this runner; test degenerates to a plain launch"
}
- name: Launch AppImage under xvfb with userns restricted
shell: bash
env:
WHEEL_PATH: ${{ steps.locate-wheel.outputs.path }}
run: |
set -euo pipefail
APPIMAGE=$(ls "${RUNNER_TEMP}/linux-installer"/*.AppImage | head -n1)
chmod +x "${APPIMAGE}"
sudo apt-get update
sudo apt-get install --yes --no-install-recommends libfuse2 xvfb xauth
if [ -n "${WHEEL_PATH}" ]; then
export GAIA_LOCAL_WHEEL="${WHEEL_PATH}"
echo "GAIA_LOCAL_WHEEL=${GAIA_LOCAL_WHEEL}"
fi
xvfb-run --auto-servernum "${APPIMAGE}" \
>/tmp/stdout.log 2>/tmp/stderr.log &
APP_PID=$!
# 300s timeout matches the structural and distro-matrix smoke
# jobs — fresh installs download Lemonade + a ~3GB model on
# first run, so 90s starves model-download cases out.
for i in $(seq 1 300); do
if grep -q "state: ready" /tmp/stdout.log 2>/dev/null; then
break
fi
sleep 1
done
if ! grep -q "state: ready" /tmp/stdout.log; then
echo "::error::userns-restricted launch did not reach state: ready — --no-sandbox fallback failed"
tail -n 200 /tmp/stdout.log /tmp/stderr.log || true
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
HEALTH_OK=0
for i in $(seq 1 30); do
PORT=$(grep -oE "ui-port[ =]+([0-9]+)" /tmp/stdout.log | head -n1 | grep -oE "[0-9]+")
PORT=${PORT:-4200}
if curl -sSf "http://127.0.0.1:${PORT}/api/health" \
| grep -q "gaia-agent-ui"; then
HEALTH_OK=1
break
fi
sleep 1
done
if [ "$HEALTH_OK" -ne 1 ]; then
echo "::error::userns-restricted: /api/health did not report service=gaia-agent-ui"
kill -9 "$APP_PID" 2>/dev/null || true
exit 1
fi
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
echo "userns-restricted PASS (--no-sandbox fallback + /api/health 200)"
# ─── Wayland visibility (DEFERRED — issue #782 follow-up) ───────────
# The plan calls for a headless Wayland compositor (cage -s or weston
# --backend=headless-backend.so) plus pixel-diff via grim, to verify
# BrowserWindow actually paints on Wayland (AC8). Implementing this
# needs a runner image or container with cage/weston pre-installed, a
# stable all-black baseline PNG, and tolerant diff thresholds. Holding
# 0.17.5 on this is not worth it — T2/T3/T6 fixes are what users hit.
#
# When picked up, the job skeleton would be:
#
# appimage-wayland-visibility:
# needs: build
# runs-on: ubuntu-24.04
# steps:
# - uses: actions/checkout@v4
# - run: sudo apt-get install --yes cage grim imagemagick libfuse2
# - run: cage -s -- xvfb-run "${APPIMAGE}" &
# - run: sleep 20 && grim -t png /tmp/shot.png
# - run: compare -metric AE /tmp/shot.png baseline.png /tmp/diff.png
#
# Tracking: re-evaluate after T7 lands, if user reports persist.
# ─── Publish job (opt-in, non-PR only) ──────────────────────────────
# Split out from the build job so `contents: write` can be granted at
# the job level to ONLY this job. Fork PRs never reach this job:
# 1. `inputs.publish_to_release` is false for any workflow_call from
# publish.yml, and a pull_request trigger can't set workflow_call
# inputs at all.
# 2. The event-type gate below blocks pull_request runs outright as
# a defense-in-depth measure.
# The previous in-build step required a job-wide `contents: write`
# grant that then applied to every PR matrix run, which is what we're
# fixing here.
publish-release-assets:
name: Publish assets for ${{ matrix.platform }}
needs: build
if: >-
github.event_name != 'pull_request' &&
inputs.publish_to_release == true &&
inputs.tag != ''
runs-on: ubuntu-latest
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [windows, macos, linux]
steps:
- name: Download installer artifact
uses: actions/download-artifact@v6
with:
name: ${{ matrix.platform }}-installer
path: release-assets
- name: Upload to GitHub Release (draft)
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.tag }}
files: release-assets/*
fail_on_unmatched_files: true
draft: true
prerelease: ${{ contains(inputs.tag, '-rc.') || contains(inputs.tag, '-beta.') }}
# ─── Gate job ───────────────────────────────────────────────────────
# Final job that confirms all platform builds succeeded AND the Linux
# AppImage smoke suite passed. This is the single dependency that
# publish.yml (or any caller) should wait on.
#
# The smoke jobs (appimage-structural-smoke, appimage-distro-matrix,
# appimage-userns-restricted) are issue #782 regression guards — a
# broken AppImage that still builds but does not launch would slip past
# if we gated only on `build`.
build-complete:
name: Build complete
runs-on: ubuntu-latest
needs:
- build
- appimage-structural-smoke
- appimage-distro-matrix
- appimage-userns-restricted
- dmg-structural-smoke
if: always()
steps:
- name: Verify all platform builds and smoke tests succeeded
shell: bash
run: |
build_result="${{ needs.build.result }}"
structural_result="${{ needs.appimage-structural-smoke.result }}"
distro_result="${{ needs.appimage-distro-matrix.result }}"
userns_result="${{ needs.appimage-userns-restricted.result }}"
dmg_result="${{ needs.dmg-structural-smoke.result }}"
echo "build: $build_result"
echo "appimage-structural-smoke: $structural_result"
echo "appimage-distro-matrix: $distro_result"
echo "appimage-userns-restricted: $userns_result"
echo "dmg-structural-smoke: $dmg_result"
fail=0
if [ "$build_result" != "success" ]; then
echo "::error::One or more platform installer builds failed"
fail=1
fi
for r in "$structural_result" "$distro_result" "$userns_result" "$dmg_result"; do
if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
echo "::error::Installer smoke job failed (status: $r)"
fail=1
fi
done
if [ "$fail" -eq 1 ]; then
exit 1
fi
echo "All platform installers built and installer smoke suite passed."