fix(connectors): MCP single-writer enforcement + security hardening (#976) #172
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
| # 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." |