Skip to content

Build Desktop Portable Packages #4

Build Desktop Portable Packages

Build Desktop Portable Packages #4

name: Build Desktop Portable Packages
# Triggers:
# - workflow_dispatch -> build CI artifacts for testing (choose a target platform)
# - push tag desktop-portable-* -> build all three platforms and publish them
# together into ONE GitHub Release (tag = the pushed tag)
# Plain pushes to branches do NOT build (avoids 3 heavy Rust builds per commit).
"on":
push:
tags:
- "desktop-portable-*"
workflow_dispatch:
inputs:
target:
description: "Which platform(s) to build (artifacts only; release happens on tag push)"
required: false
default: "all"
type: choice
options:
- "all"
- "windows"
- "linux"
- "macos"
permissions:
contents: read
env:
NODE_VERSION: "22.23.2"
RUST_TOOLCHAIN: "1.95.0"
PBS_RELEASE: "20260814"
PBS_PYTHON_VERSION: "3.12.14"
MACOS_PACKAGING_PYTHON_VERSION: "3.12.10"
PYTHONDONTWRITEBYTECODE: "1"
jobs:
# ----------------------------------------------------------------------------
build-windows:
name: Windows portable
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'windows')) }}
runs-on: windows-2025
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
workspaces: frontends/desktop/src-tauri -> target
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build Windows desktop exe
working-directory: frontends/desktop
run: npm run tauri build -- --bundles nsis
- name: Assemble self-contained portable bundle
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
EXE_SRC="$(find frontends/desktop/src-tauri/target/release -maxdepth 1 -type f -name 'ga-desktop.exe' | head -n 1)"
[[ -n "$EXE_SRC" ]] || { echo "ga-desktop.exe not found" >&2; exit 1; }
PKG="artifacts/windows/GenericAgent-Desktop-Windows-Portable"
RUNTIME="$PKG/runtime"
mkdir -p "$RUNTIME"
# exe at the top level, named GenericAgent (typical portable form)
cp "$EXE_SRC" "$PKG/GenericAgent.exe"
cp frontends/desktop/packaging/scripts/windows/install_windows.ps1 "$RUNTIME/install_windows.ps1"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
# Uninstaller: double-click entry at top level, worker script under runtime/.
cp frontends/desktop/packaging/scripts/windows/uninstall.bat "$PKG/uninstall.bat"
cp frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 "$RUNTIME/uninstall_windows.ps1"
# Fixed python-build-standalone Windows x86_64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz"
PBS_SHA256="7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a"
command -v cygpath >/dev/null 2>&1 || { echo "cygpath not found on Windows runner" >&2; exit 1; }
RUNNER_TEMP_POSIX="$(cygpath -u "$RUNNER_TEMP")"
[[ "$RUNNER_TEMP_POSIX" == /* ]] || { echo "cygpath did not return an absolute POSIX path" >&2; exit 1; }
PBS_ARCHIVE="${RUNNER_TEMP_POSIX}/pbs-windows-x86_64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top)
PY="$RUNTIME/python/python.exe"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine().lower() in ("amd64", "x86_64"); print(sys.version)'
# App-local UCRT: python-build-standalone links the Universal CRT dynamically
# and does NOT bundle it. Win10/11 ship UCRT in-box, but stripped/older images
# may not -> python.exe fails to load (missing api-ms-win-crt-*.dll). Copy the
# redistributable UCRT DLLs next to python.exe (Microsoft-supported app-local
# deployment). Pure SDK source, no System32 fallback, hard-fail if absent so we
# never ship a bundle from an unexpected source.
UCRT_SRC="$(ls -d "/c/Program Files (x86)/Windows Kits/10/Redist/"*/ucrt/DLLs/x64 2>/dev/null | sort -V | tail -1)"
[[ -n "$UCRT_SRC" ]] || { echo "Windows SDK UCRT redist dir not found on runner" >&2; exit 1; }
echo "UCRT source: $UCRT_SRC"
cp "$UCRT_SRC"/api-ms-win-crt-*.dll "$RUNTIME/python/"
cp "$UCRT_SRC"/ucrtbase.dll "$RUNTIME/python/"
test -f "$RUNTIME/python/api-ms-win-crt-runtime-l1-1-0.dll" \
|| { echo "UCRT DLLs missing after copy" >&2; exit 1; }
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
--requirement "$RUNTIME/wheels/requirements.txt"
# Runtime source (project root minus heavy/dev/build dirs)
mkdir -p "$RUNTIME/app"
tar \
--exclude='./.git' --exclude='./.github' \
--exclude='./frontends/tests' \
--exclude='./frontends/desktop/src-tauri' \
--exclude='./frontends/desktop/src' \
--exclude='./frontends/desktop/public' \
--exclude='./frontends/desktop/scripts' \
--exclude='./frontends/desktop/release_qualification' \
--exclude='./frontends/desktop/tests' \
--exclude='./frontends/desktop/testing' \
--exclude='./frontends/desktop/spec' \
--exclude='./frontends/desktop/node_modules' \
--exclude='./frontends/desktop/dist' \
--exclude='./frontends/desktop/DESIGN.md' \
--exclude='./frontends/desktop/package.json' \
--exclude='./frontends/desktop/package-lock.json' \
--exclude='./frontends/desktop/.npmrc' \
--exclude='./frontends/desktop/index.html' \
--exclude='./frontends/desktop/loading.html' \
--exclude='./frontends/desktop/setup.html' \
--exclude='./frontends/desktop/tsconfig*.json' \
--exclude='./frontends/desktop/vite.config.ts' \
--exclude='./frontends/desktop/packaging' --exclude='./docs' \
--exclude='./assets/demo' --exclude='./assets/images' \
--exclude='./assets/GenericAgent_Technical_Report.pdf' \
--exclude='./artifacts' \
--exclude='*/node_modules' --exclude='*/target' \
--exclude='*/.venv' --exclude='./.venv' \
--exclude='*/__pycache__' --exclude='*.pyc' \
-cf - . | tar -xf - -C "$RUNTIME/app"
test -f "$RUNTIME/app/agentmain.py"
test -f "$RUNTIME/app/frontends/desktop_bridge.py"
test -f "$RUNTIME/app/frontends/desktop/static/index.html"
test ! -e "$RUNTIME/app/frontends/desktop/dist"
test ! -e "$RUNTIME/app/frontends/desktop/src"
test ! -e "$RUNTIME/app/frontends/desktop/public"
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
# Drop python debug symbols (.pdb) to slim the package (~80MB)
find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true
purge_runtime_bytecode "$RUNTIME"
cat > "$PKG/readme.txt" <<'EOF'
================ 中文 ================
GenericAgent Desktop — Windows 便携版(自包含)
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
前置条件
- Windows 10/11 x64
- Microsoft Edge WebView2 运行时(Win11 一般自带;缺失时首次运行会提示安装)
使用
1. 解压到任意目录(路径建议不含特殊字符)。
2. 双击 GenericAgent.exe。
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
4. 之后启动直接秒进。
说明
- 想真正对话,仍需在程序里配置模型 / API Key。
- 如果 Windows Defender 防火墙拦截 `127.0.0.1` / `localhost` 回环连接,程序可能无法启动或连接本机服务。请允许 `GenericAgent.exe` 和 `runtime\python\python.exe` 通过防火墙,然后重启桌面端。
- 本便携版尚未使用 Windows 代码签名证书;SmartScreen 可能显示警告。运行前请用随包发布的 SHA256SUMS-windows.txt 核对 ZIP。
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime\.prepared 后重启。
- runtime\ 是内置运行环境与源码,正常使用无需改动。
================ English ================
GenericAgent Desktop — Windows Portable (self-contained)
No Python install, no internet for dependencies, no source checkout — everything is bundled.
Requirements
- Windows 10/11 x64
- Microsoft Edge WebView2 runtime (usually preinstalled on Win11; you are prompted if missing)
Usage
1. Extract anywhere (a path without special characters is recommended).
2. Double-click GenericAgent.exe.
3. The first launch verifies and completes the bundled Python runtime offline with a
progress UI, then opens the main window.
4. Subsequent launches start instantly.
Notes
- To actually chat, configure a model / API key inside the app.
- If Windows Defender Firewall blocks the `127.0.0.1` / `localhost` loopback connection, the app may fail to start or reach its local service. Allow `GenericAgent.exe` and `runtime\python\python.exe` through the firewall, then restart the desktop app.
- This portable build is not Windows code-signed, so SmartScreen may warn. Verify the ZIP against the published SHA256SUMS-windows.txt before running it.
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
preparation pass, delete runtime\.prepared and relaunch.
- runtime\ holds the bundled runtime and source; no need to touch it.
EOF
sed -i 's/^ //' "$PKG/readme.txt"
echo "Package tree (top levels):"
find "$PKG" -maxdepth 2 -printf '%y %p\n' | sort | head -40
- name: Zip portable package
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts/windows/out | Out-Null
Compress-Archive -Path artifacts/windows/GenericAgent-Desktop-Windows-Portable `
-DestinationPath artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Force
$h = Get-FileHash artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Algorithm SHA256
"{0} GenericAgent-Desktop-Windows-Portable.zip" -f $h.Hash.ToLowerInvariant() |
Set-Content -Encoding ASCII artifacts/windows/out/SHA256SUMS-windows.txt
Get-ChildItem artifacts/windows/out | Format-Table Name, Length
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
path: |
artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip
artifacts/windows/out/SHA256SUMS-windows.txt
if-no-files-found: error
# ----------------------------------------------------------------------------
build-linux:
name: Linux portable
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'linux')) }}
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
# Keep registry/git downloads, but never restore binaries built on a newer glibc image.
prefix-key: "v1-rust-release-ubuntu-22.04-glibc-2.35"
workspaces: frontends/desktop/src-tauri -> target
cache-targets: "false"
cache-bin: "false"
- name: Install Tauri Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
binutils \
file \
xvfb
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build Linux AppImage
working-directory: frontends/desktop
run: npm run tauri build -- --bundles appimage
- name: Assemble self-contained portable bundle
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
APPIMAGE_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
[[ -n "$APPIMAGE_SRC" ]] || { echo "No AppImage found" >&2; exit 1; }
PKG="artifacts/linux/GenericAgent-Desktop-Linux-Portable"
RUNTIME="$PKG/runtime"
mkdir -p "$RUNTIME"
# Stable internal name so the generated .desktop Exec= never changes on upgrades.
cp "$APPIMAGE_SRC" "$PKG/GenericAgent.AppImage"
chmod +x "$PKG/GenericAgent.AppImage"
# Icon for the generated .desktop launcher (Icon= needs a real image file on Linux).
cp frontends/desktop/src-tauri/icons/icon.png "$PKG/GenericAgent.png"
cp frontends/desktop/packaging/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
chmod +x "$RUNTIME/install_linux.sh"
# Uninstaller: top-level entry next to the AppImage.
cp frontends/desktop/packaging/scripts/linux/uninstall.sh "$PKG/uninstall.sh"
chmod +x "$PKG/uninstall.sh"
# Fixed python-build-standalone Linux x86_64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz"
PBS_SHA256="3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0"
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-linux-x86_64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir
PY="$RUNTIME/python/bin/python3"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "x86_64"; print(sys.version)'
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
--requirement "$RUNTIME/wheels/requirements.txt"
# Runtime source (project root minus heavy/dev/build dirs)
mkdir -p "$RUNTIME/app"
tar \
--exclude='./.git' --exclude='./.github' \
--exclude='./frontends/tests' \
--exclude='./frontends/desktop/src-tauri' \
--exclude='./frontends/desktop/src' \
--exclude='./frontends/desktop/public' \
--exclude='./frontends/desktop/scripts' \
--exclude='./frontends/desktop/release_qualification' \
--exclude='./frontends/desktop/tests' \
--exclude='./frontends/desktop/testing' \
--exclude='./frontends/desktop/spec' \
--exclude='./frontends/desktop/node_modules' \
--exclude='./frontends/desktop/dist' \
--exclude='./frontends/desktop/DESIGN.md' \
--exclude='./frontends/desktop/package.json' \
--exclude='./frontends/desktop/package-lock.json' \
--exclude='./frontends/desktop/.npmrc' \
--exclude='./frontends/desktop/index.html' \
--exclude='./frontends/desktop/loading.html' \
--exclude='./frontends/desktop/setup.html' \
--exclude='./frontends/desktop/tsconfig*.json' \
--exclude='./frontends/desktop/vite.config.ts' \
--exclude='./frontends/desktop/packaging' --exclude='./docs' \
--exclude='./assets/demo' --exclude='./assets/images' \
--exclude='./assets/GenericAgent_Technical_Report.pdf' \
--exclude='./artifacts' \
--exclude='*/node_modules' --exclude='*/target' \
--exclude='*/.venv' --exclude='./.venv' \
--exclude='*/__pycache__' --exclude='*.pyc' \
-cf - . | tar -xf - -C "$RUNTIME/app"
test -f "$RUNTIME/app/agentmain.py"
test -f "$RUNTIME/app/frontends/desktop_bridge.py"
test -f "$RUNTIME/app/frontends/desktop/static/index.html"
test ! -e "$RUNTIME/app/frontends/desktop/dist"
test ! -e "$RUNTIME/app/frontends/desktop/src"
test ! -e "$RUNTIME/app/frontends/desktop/public"
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
purge_runtime_bytecode "$RUNTIME"
cat > "$PKG/readme.txt" <<'EOF'
================ 中文 ================
GenericAgent Desktop — Linux 便携版(自包含)
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
前置条件
- Linux x86_64,glibc 2.35 或更高版本(如 Ubuntu 22.04、Debian 12)
- 桌面环境 + FUSE(运行 AppImage 所需;多数发行版自带)
使用
1. 解压到任意目录(路径建议不含特殊字符)。
2. 给 AppImage 执行权限并运行:
chmod +x GenericAgent.AppImage
./GenericAgent.AppImage
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
4. 之后启动直接秒进。
说明
- 想真正对话,仍需在程序里配置模型 / API Key。
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime/.prepared 后重启。
- runtime/ 是内置运行环境与源码,正常使用无需改动。
================ English ================
GenericAgent Desktop — Linux Portable (self-contained)
No Python install, no internet for dependencies, no source checkout — everything is bundled.
Requirements
- Linux x86_64 with glibc 2.35 or newer (for example Ubuntu 22.04 or Debian 12)
- A desktop environment + FUSE (required to run the AppImage; preinstalled on most distros)
Usage
1. Extract anywhere (a path without special characters is recommended).
2. Make the AppImage executable and run it:
chmod +x GenericAgent.AppImage
./GenericAgent.AppImage
3. The first launch verifies and completes the bundled Python runtime offline with a
progress UI, then opens the main window.
4. Subsequent launches start instantly.
Notes
- To actually chat, configure a model / API key inside the app.
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
preparation pass, delete runtime/.prepared and relaunch.
- runtime/ holds the bundled runtime and source; no need to touch it.
EOF
sed -i 's/^ //' "$PKG/readme.txt"
mkdir -p artifacts/linux/out
tar -C artifacts/linux -czf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz GenericAgent-Desktop-Linux-Portable
( cd artifacts/linux/out && sha256sum GenericAgent-Desktop-Linux-Portable.tar.gz > SHA256SUMS-linux.txt )
tar -tzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz > /tmp/pkglist.txt
echo "Package contents (first 40 of $(wc -l < /tmp/pkglist.txt) entries):"
head -40 /tmp/pkglist.txt
ls -lh artifacts/linux/out
# Exercise the exact archived candidate on the oldest supported builder.
SMOKE_ROOT="${RUNNER_TEMP}/ga-linux-package-smoke"
mkdir -p "$SMOKE_ROOT"
tar -xzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz -C "$SMOKE_ROOT"
SMOKE_PACKAGE="$SMOKE_ROOT/GenericAgent-Desktop-Linux-Portable"
APPIMAGE_ABS="$(realpath "$SMOKE_PACKAGE/GenericAgent.AppImage")"
APPIMAGE_SCAN="$SMOKE_ROOT/appimage-scan"
mkdir -p "$APPIMAGE_SCAN"
(cd "$APPIMAGE_SCAN" && "$APPIMAGE_ABS" --appimage-extract >/dev/null)
MAX_GLIBC=""
ELF_COUNT=0
while IFS= read -r -d '' candidate; do
file -b "$candidate" | grep -q '^ELF' || continue
ELF_COUNT=$((ELF_COUNT + 1))
candidate_max="$(readelf --version-info "$candidate" 2>/dev/null \
| sed -n 's/.*Name: GLIBC_\([0-9][0-9.]*\).*/\1/p' \
| sort -V | tail -n 1)"
[[ -n "$candidate_max" ]] || continue
if [[ -z "$MAX_GLIBC" \
|| "$(printf '%s\n%s\n' "$MAX_GLIBC" "$candidate_max" | sort -V | tail -n 1)" == "$candidate_max" ]]; then
MAX_GLIBC="$candidate_max"
fi
done < <(find "$SMOKE_PACKAGE" "$APPIMAGE_SCAN/squashfs-root" -type f -print0)
[[ "$ELF_COUNT" -gt 0 && -n "$MAX_GLIBC" ]] \
|| { echo "No GLIBC symbol versions found in final Linux package ELFs" >&2; exit 1; }
[[ "$(printf '%s\n%s\n' "$MAX_GLIBC" '2.35' | sort -V | tail -n 1)" == "2.35" ]] \
|| { echo "Final package requires GLIBC_$MAX_GLIBC; maximum allowed is GLIBC_2.35" >&2; exit 1; }
echo "Scanned $ELF_COUNT final package ELFs; maximum symbol is GLIBC_$MAX_GLIBC"
APPIMAGE_EXTRACT_AND_RUN=1 WEBKIT_DISABLE_COMPOSITING_MODE=1 \
xvfb-run -a "$SMOKE_PACKAGE/GenericAgent.AppImage" >"$SMOKE_ROOT/app.log" 2>&1 &
SMOKE_PID=$!
cleanup_smoke() {
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
}
trap cleanup_smoke EXIT
IDENTITY=""
for _ in $(seq 1 120); do
IDENTITY="$(curl --fail --silent --max-time 1 http://127.0.0.1:14168/services/identity || true)"
[[ -n "$IDENTITY" ]] && break
sleep 1
done
[[ -n "$IDENTITY" ]] || { cat "$SMOKE_ROOT/app.log" >&2; exit 1; }
EXPECTED_APP_DIR="$SMOKE_PACKAGE/runtime/app/frontends" \
IDENTITY="$IDENTITY" python3 - <<'PY'
import json, os, pathlib
identity = json.loads(os.environ["IDENTITY"])
actual = pathlib.Path(identity["app_dir"]).resolve()
expected = pathlib.Path(os.environ["EXPECTED_APP_DIR"]).resolve()
assert actual == expected, (actual, expected)
PY
curl --fail --silent --max-time 3 -X POST http://127.0.0.1:14168/services/bridge/exit >/dev/null
cleanup_smoke
trap - EXIT
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
path: |
artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz
artifacts/linux/out/SHA256SUMS-linux.txt
if-no-files-found: error
# ----------------------------------------------------------------------------
build-macos:
name: macOS arm64 DMG
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'macos')) }}
runs-on: macos-15
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
workspaces: frontends/desktop/src-tauri -> target
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Set up Python DMG tooling runtime
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.MACOS_PACKAGING_PYTHON_VERSION }}
- name: Install Python packaging tools
run: >-
python3 -m pip install --require-hashes --only-binary=:all:
--requirement frontends/desktop/packaging/dmg-build-requirements.txt
- name: Assert macOS runner architecture
run: test "$(uname -m)" = arm64
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build macOS .app
working-directory: frontends/desktop
run: npm run tauri build -- --bundles app
- name: Assemble self-contained DMG app
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)"
[[ -n "$APP_SRC" ]] || { echo "No .app found" >&2; exit 1; }
# Build one runtime tree and embed it only in the final DMG application.
RUNTIME_SRC="artifacts/macos/runtime-src"
mkdir -p "$RUNTIME_SRC"
cp frontends/desktop/packaging/scripts/macos/install_macos.sh "$RUNTIME_SRC/install_macos.sh"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME_SRC/merge_desktop_settings.py"
chmod +x "$RUNTIME_SRC/install_macos.sh"
# Fixed python-build-standalone macOS arm64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz"
PBS_SHA256="4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea"
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-macos-aarch64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | shasum -a 256 --check -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME_SRC" # extracts a 'python/' dir
PY="$RUNTIME_SRC/python/bin/python3"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "arm64"; print(sys.version)'
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME_SRC/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME_SRC/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME_SRC/wheels" \
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
# macOS applications must be immutable at first launch. Install from the downloaded
# wheelhouse before the runtime enters the signed .app, then carry a durable marker.
# The binary-only offline repair path needs neither setuptools nor wheel. Remove any
# copies shipped in the base interpreter; bytecode writes are disabled at runtime too.
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels" \
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip uninstall --yes setuptools wheel
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip check
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import aiohttp, fastapi, importlib.util, pydantic, uvicorn, websockets; assert all(importlib.util.find_spec(name) is None for name in ("setuptools", "wheel", "pkg_resources"))'
purge_runtime_bytecode "$RUNTIME_SRC"
printf 'ok\n' > "$RUNTIME_SRC/.prepared"
test -s "$RUNTIME_SRC/.prepared"
# Runtime source (project root minus heavy/dev/build dirs) — bsdtar exclude syntax.
mkdir -p "$RUNTIME_SRC/app"
tar \
--exclude='.git' --exclude='.github' \
--exclude='frontends/tests' \
--exclude='frontends/desktop/src-tauri' \
--exclude='frontends/desktop/src' \
--exclude='frontends/desktop/public' \
--exclude='frontends/desktop/scripts' \
--exclude='frontends/desktop/release_qualification' \
--exclude='frontends/desktop/tests' \
--exclude='frontends/desktop/testing' \
--exclude='frontends/desktop/spec' \
--exclude='frontends/desktop/node_modules' \
--exclude='frontends/desktop/dist' \
--exclude='frontends/desktop/DESIGN.md' \
--exclude='frontends/desktop/package.json' \
--exclude='frontends/desktop/package-lock.json' \
--exclude='frontends/desktop/.npmrc' \
--exclude='frontends/desktop/index.html' \
--exclude='frontends/desktop/loading.html' \
--exclude='frontends/desktop/setup.html' \
--exclude='frontends/desktop/tsconfig*.json' \
--exclude='frontends/desktop/vite.config.ts' \
--exclude='frontends/desktop/packaging' --exclude='docs' \
--exclude='assets/demo' --exclude='assets/images' \
--exclude='assets/GenericAgent_Technical_Report.pdf' \
--exclude='artifacts' \
--exclude='*/node_modules' --exclude='*/target' \
--exclude='*/.venv' --exclude='.venv' \
--exclude='*/__pycache__' --exclude='*.pyc' \
-cf - . | tar -xf - -C "$RUNTIME_SRC/app"
test -f "$RUNTIME_SRC/app/agentmain.py"
test -f "$RUNTIME_SRC/app/frontends/desktop_bridge.py"
test -f "$RUNTIME_SRC/app/frontends/desktop/static/index.html"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/src"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/public"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/node_modules"
DMG_STAGE="artifacts/macos/dmg-stage"
DMG_APP="$DMG_STAGE/GenericAgent.app"
mkdir -p "$DMG_STAGE"
# Standard DMG: GenericAgent.app + Applications alias. The post-build
# repackager below writes the curated two-icon Finder layout.
ditto "$APP_SRC" "$DMG_APP"
mkdir -p "$DMG_APP/Contents/Resources"
ditto "$RUNTIME_SRC" "$DMG_APP/Contents/Resources/runtime"
test -s "$DMG_APP/Contents/Resources/runtime/.prepared"
DMG_RUNTIME="$DMG_APP/Contents/Resources/runtime"
PYTHONDONTWRITEBYTECODE=1 "$DMG_RUNTIME/python/bin/python3" \
-c 'import aiohttp, fastapi, pydantic, uvicorn, websockets'
purge_runtime_bytecode "$DMG_RUNTIME"
# Ad-hoc signing only: this is not Developer ID signing or notarization.
codesign --force --deep --sign - "$DMG_APP"
codesign --verify --deep --strict "$DMG_APP"
ln -s /Applications "$DMG_STAGE/Applications"
mkdir -p artifacts/macos/out
hdiutil create \
-volname "GenericAgent Desktop" \
-srcfolder "$DMG_STAGE" \
-ov \
-format UDZO \
"artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
# Restore the intentionally minimal Finder presentation: only the app
# and Applications shortcut, with the established window/icon layout.
bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
(
cd artifacts/macos/out
shasum -a 256 "GenericAgent-Desktop-macOS-aarch64.dmg" \
> "GenericAgent-Desktop-macOS-aarch64.dmg.sha256"
)
echo "DMG stage tree:"
find "$DMG_STAGE" -maxdepth 2 -print | sort
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
path: |
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg.sha256
if-no-files-found: error
# ----------------------------------------------------------------------------
publish-release:
name: Publish one atomic prerelease
needs: [build-windows, build-linux, build-macos]
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-') && needs.build-windows.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download Windows candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Download Linux candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Download macOS arm64 candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Validate six assets and publish prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.ref_name }}
TARGET_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
ASSET_DIR="${RUNNER_TEMP}/release-assets"
EXPECTED=(
GenericAgent-Desktop-Linux-Portable.tar.gz
GenericAgent-Desktop-Windows-Portable.zip
GenericAgent-Desktop-macOS-aarch64.dmg
GenericAgent-Desktop-macOS-aarch64.dmg.sha256
SHA256SUMS-linux.txt
SHA256SUMS-windows.txt
)
if find "$ASSET_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q .; then
echo "Release staging contains a non-regular entry" >&2
find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -print >&2
exit 1
fi
mapfile -t ACTUAL < <(find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)
if ! diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${ACTUAL[@]}"); then
echo "Release staging must contain exactly the six expected files" >&2
exit 1
fi
for filename in "${EXPECTED[@]}"; do
test -s "$ASSET_DIR/$filename" || { echo "Missing or empty release asset: $filename" >&2; exit 1; }
done
verify_checksum_manifest() {
local manifest="$1"
local payload="$2"
local normalized="${RUNNER_TEMP}/${manifest}.normalized"
tr -d '\r' < "$ASSET_DIR/$manifest" > "$normalized"
awk -v expected="$payload" '
NF == 2 && $1 ~ /^[0-9a-fA-F]{64}$/ && $2 == expected { valid += 1 }
END { exit !(NR == 1 && valid == 1) }
' "$normalized"
(cd "$ASSET_DIR" && sha256sum --check --strict "$normalized")
}
verify_checksum_manifest SHA256SUMS-windows.txt GenericAgent-Desktop-Windows-Portable.zip
verify_checksum_manifest SHA256SUMS-linux.txt GenericAgent-Desktop-Linux-Portable.tar.gz
verify_checksum_manifest GenericAgent-Desktop-macOS-aarch64.dmg.sha256 GenericAgent-Desktop-macOS-aarch64.dmg
[[ "$TAG_NAME" =~ ^desktop-portable-[A-Za-z0-9._-]+$ ]] \
|| { echo "Invalid release tag: $TAG_NAME" >&2; exit 1; }
[[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]] \
|| { echo "Invalid release target SHA" >&2; exit 1; }
TITLE="GenericAgent Desktop ${TAG_NAME}"
NOTES_FILE="${RUNNER_TEMP}/ga-release-notes.md"
cat > "$NOTES_FILE" <<'EOF'
GenericAgent Desktop 桌面版 / Desktop
三个平台产物来自同一提交,并在公开发布前统一验证。All three platform artifacts come from one commit and are validated before publication.
## 安装方法 / Installation
- Windows x64:下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe`。卸载时运行 `uninstall.bat`,再删除解压目录。
Windows x64: extract `GenericAgent-Desktop-Windows-Portable.zip`, then launch `GenericAgent.exe`. Run `uninstall.bat` before deleting the extracted directory.
- Linux x86_64:下载并解压 `GenericAgent-Desktop-Linux-Portable.tar.gz`,执行 `chmod +x GenericAgent.AppImage` 后运行它。卸载时运行 `uninstall.sh`,再删除解压目录。
Linux x86_64: extract `GenericAgent-Desktop-Linux-Portable.tar.gz`, run `chmod +x GenericAgent.AppImage`, then launch it. Run `uninstall.sh` before deleting the extracted directory.
- macOS Apple silicon (arm64):打开 `GenericAgent-Desktop-macOS-aarch64.dmg`,把 `GenericAgent.app` 拖入 Applications。
macOS Apple silicon (arm64): open `GenericAgent-Desktop-macOS-aarch64.dmg` and drag `GenericAgent.app` to Applications.
## 签名状态 / Signing status
Windows 便携版尚未使用代码签名证书,SmartScreen 可能显示警告;运行前请核对 `SHA256SUMS-windows.txt`。
The Windows portable build is not code-signed, so SmartScreen may warn; verify `SHA256SUMS-windows.txt` before running it.
macOS 应用仅使用 ad-hoc 签名,未使用 Apple Developer ID,也未 notarize。首次打开可能需要右键选择“打开”或在“隐私与安全性”中允许。
The macOS app is ad-hoc signed only. It is neither Developer ID signed nor notarized; first launch may require Open from the context menu or approval in Privacy & Security.
SHA-256 清单与各产物一同发布。SHA-256 manifests are included with the artifacts.
EOF
sed -i 's/^ //' "$NOTES_FILE"
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "A release already exists for $TAG_NAME; refusing to overwrite it" >&2
exit 1
fi
ASSETS=()
for filename in "${EXPECTED[@]}"; do
ASSETS+=("$ASSET_DIR/$filename")
done
# Keep the release invisible while all assets upload; only the final edit publishes it.
gh release create "$TAG_NAME" "${ASSETS[@]}" \
--target "$TARGET_SHA" \
--title "$TITLE" \
--notes-file "$NOTES_FILE" \
--draft \
--prerelease
mapfile -t REMOTE_ASSETS < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | LC_ALL=C sort)
diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${REMOTE_ASSETS[@]}")
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = true
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true
gh release edit "$TAG_NAME" --draft=false --prerelease
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = false
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true