Build Desktop Portable Packages #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: 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 |