From 280c493c607d904aa8724b9119531f73cf14dd65 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 14:47:19 +0800
Subject: [PATCH 01/19] chore: improve desktop build workflow
---
.github/actions/desktop-setup/action.yml | 70 ++
.github/workflows/desktop-linux.yml | 93 +++
.github/workflows/desktop-macos.yml | 114 ++++
.github/workflows/desktop-prepare.yml | 109 +++
.github/workflows/desktop-release.yml | 636 +++---------------
.github/workflows/desktop-windows.yml | 189 ++++++
.github/workflows/release-tag.yml | 9 +-
README.md | 28 +-
docs/README.zh-CN.md => README.zh-CN.md | 66 +-
apps/desktop/README.md | 80 ++-
.../scripts/collect-release-assets.mjs | 89 +++
.../scripts/configure-apple-signing.sh | 48 ++
apps/desktop/scripts/desktop-provenance.mjs | 49 ++
.../scripts/desktop-provenance.test.mjs | 25 +
apps/desktop/scripts/package-macos.sh | 121 ++++
apps/desktop/scripts/package-macos.test.mjs | 178 +++++
.../scripts/publish-desktop-release.mjs | 120 ++++
apps/desktop/scripts/release-assets.test.mjs | 202 ++++++
apps/desktop/scripts/release-config.mjs | 27 +-
apps/desktop/scripts/release-config.test.mjs | 18 +
20 files changed, 1690 insertions(+), 581 deletions(-)
create mode 100644 .github/actions/desktop-setup/action.yml
create mode 100644 .github/workflows/desktop-linux.yml
create mode 100644 .github/workflows/desktop-macos.yml
create mode 100644 .github/workflows/desktop-prepare.yml
create mode 100644 .github/workflows/desktop-windows.yml
rename docs/README.zh-CN.md => README.zh-CN.md (86%)
create mode 100644 apps/desktop/scripts/collect-release-assets.mjs
create mode 100644 apps/desktop/scripts/configure-apple-signing.sh
create mode 100644 apps/desktop/scripts/desktop-provenance.mjs
create mode 100644 apps/desktop/scripts/desktop-provenance.test.mjs
create mode 100644 apps/desktop/scripts/package-macos.sh
create mode 100644 apps/desktop/scripts/package-macos.test.mjs
create mode 100644 apps/desktop/scripts/publish-desktop-release.mjs
create mode 100644 apps/desktop/scripts/release-assets.test.mjs
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
new file mode 100644
index 000000000..55621dbcf
--- /dev/null
+++ b/.github/actions/desktop-setup/action.yml
@@ -0,0 +1,70 @@
+name: Desktop build setup
+description: Restore target-specific toolchains and caches, and consume the shared web/catalog artifacts.
+
+inputs:
+ target:
+ description: Rust target triple; also separates cross-compiled Go and Rust caches.
+ required: true
+ source-sha:
+ description: Immutable source commit used to refresh the Go compilation cache.
+ required: true
+
+runs:
+ using: composite
+ steps:
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.17.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: 1.99.1
+ targets: ${{ inputs.target }}
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: apps/desktop/src-tauri
+ shared-key: desktop-release-${{ inputs.target }}
+ - uses: actions/setup-go@v5
+ id: go
+ with:
+ go-version-file: backend/go.mod
+ cache: false
+ - name: Locate Go caches
+ id: go-paths
+ shell: bash
+ run: |
+ echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
+ echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
+ # setup-go keys by the host architecture. Both macOS targets run on arm64,
+ # so their compiled packages need separate keys and source refreshes.
+ - name: Cache Go sidecar build
+ uses: actions/cache@v4
+ with:
+ path: |
+ ${{ steps.go-paths.outputs.build }}
+ ${{ steps.go-paths.outputs.modules }}
+ key: desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
+ restore-keys: |
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
+ - name: Install desktop CLI
+ shell: bash
+ run: pnpm --filter @deeix/desktop install --frozen-lockfile
+ - name: Download frontend
+ uses: actions/download-artifact@v4
+ with:
+ name: desktop-web
+ path: apps/web/out
+ - name: Download catalog snapshots
+ uses: actions/download-artifact@v4
+ with:
+ name: desktop-catalog
+ path: backend/internal/infra/catalogdata/data
+ - name: Check C compiler and frontend
+ shell: bash
+ run: |
+ gcc --version
+ test -f apps/web/out/index.html
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
new file mode 100644
index 000000000..f875a99eb
--- /dev/null
+++ b/.github/workflows/desktop-linux.yml
@@ -0,0 +1,93 @@
+name: Desktop · Linux x64
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ CARGO_INCREMENTAL: 0
+ TARGET: x86_64-unknown-linux-gnu
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.source-sha }}
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Install system dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Configure desktop build
+ run: |
+ node apps/desktop/scripts/release-config.mjs --channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" \
+ --prebuilt-frontend --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Exclude Wayland libraries from AppImage
+ run: bash apps/desktop/scripts/prepare-linuxdeploy-wrapper.sh
+ - name: Bundle deb and AppImage
+ env:
+ LINUXDEPLOY_DEBUG_LOG: ${{ runner.temp }}/linuxdeploy.log
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles deb,appimage \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Show linuxdeploy log
+ if: always()
+ run: cat "$RUNNER_TEMP/linuxdeploy.log" 2>/dev/null || echo "(no linuxdeploy log)"
+ - name: Verify AppImage
+ run: |
+ shopt -s nullglob
+ images=(apps/desktop/src-tauri/target/"$TARGET"/release/bundle/appimage/*.AppImage)
+ ((${#images[@]} == 1)) || { echo "::error::expected one AppImage"; exit 1; }
+ workdir="$(mktemp -d)"
+ trap 'rm -rf "$workdir"' EXIT
+ (cd "$workdir" && "$GITHUB_WORKSPACE/${images[0]}" --appimage-extract >/dev/null 2>&1)
+ test -d "$workdir/squashfs-root/usr/lib"
+ if find "$workdir/squashfs-root/usr/lib" -name 'libwayland*' -print -quit | grep -q .; then
+ echo "::error::AppImage still bundles Wayland libraries"
+ exit 1
+ fi
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform linux-x64 \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-release-linux-x64
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-timings-linux-x64
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
new file mode 100644
index 000000000..278eab189
--- /dev/null
+++ b/.github/workflows/desktop-macos.yml
@@ -0,0 +1,114 @@
+name: Desktop · macOS
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+ arch:
+ description: arm64 or x64
+ required: true
+ type: string
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+ runs-on: macos-15
+ timeout-minutes: 60
+ env:
+ CARGO_INCREMENTAL: 0
+ ARCH: ${{ inputs.arch }}
+ TARGET: ${{ inputs.arch == 'arm64' && 'aarch64-apple-darwin' || 'x86_64-apple-darwin' }}
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.source-sha }}
+ - name: Validate architecture
+ run: '[[ "$ARCH" == arm64 || "$ARCH" == x64 ]]'
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Configure Apple signing
+ env:
+ APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+ APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/configure-apple-signing.sh
+ - name: Configure desktop build
+ run: |
+ node apps/desktop/scripts/release-config.mjs --channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" \
+ --prebuilt-frontend --defer-updater --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Bundle app
+ # With Apple credentials Tauri also signs/notarizes here. The external
+ # mode uses ad-hoc signing until the next step, avoiding two notarizations.
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles app \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Sign and notarize app (external service)
+ if: env.DESKTOP_SIGNING_MODE == 'external'
+ timeout-minutes: 20
+ env:
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/package-macos.sh sign-app "$TARGET"
+ - name: Create final DMG
+ run: bash apps/desktop/scripts/package-macos.sh dmg "$TARGET"
+ - name: Sign and notarize DMG
+ if: env.DESKTOP_SIGNING_MODE != 'adhoc'
+ timeout-minutes: 20
+ env:
+ SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
+ SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
+ SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
+ SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
+ run: bash apps/desktop/scripts/package-macos.sh sign-dmg "$TARGET"
+ - name: Create signed updater archive
+ env:
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: bash apps/desktop/scripts/package-macos.sh updater "$TARGET"
+ - name: Verify DMG and updater archive
+ run: bash apps/desktop/scripts/package-macos.sh verify "$TARGET"
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform "macos-$ARCH" \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-release-macos-${{ inputs.arch }}
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-timings-macos-${{ inputs.arch }}
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-prepare.yml b/.github/workflows/desktop-prepare.yml
new file mode 100644
index 000000000..acc21a30c
--- /dev/null
+++ b/.github/workflows/desktop-prepare.yml
@@ -0,0 +1,109 @@
+name: Desktop · Shared inputs
+
+on:
+ workflow_call:
+ inputs:
+ release-tag:
+ type: string
+ default: ""
+ outputs:
+ source-sha:
+ value: ${{ jobs.resolve.outputs.source-sha }}
+ release-tag:
+ value: ${{ jobs.resolve.outputs.release-tag }}
+ channel:
+ value: ${{ jobs.resolve.outputs.channel }}
+
+permissions:
+ contents: read
+
+jobs:
+ resolve:
+ name: Resolve source and version
+ runs-on: ubuntu-24.04
+ outputs:
+ source-sha: ${{ steps.source.outputs.sha }}
+ release-tag: ${{ inputs.release-tag }}
+ channel: ${{ steps.source.outputs.channel }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.release-tag && format('refs/tags/{0}', inputs.release-tag) || github.sha }}
+ - name: Verify source
+ id: source
+ env:
+ RELEASE_TAG: ${{ inputs.release-tag }}
+ run: |
+ if [[ ! -f .github/actions/desktop-setup/action.yml || ! -f apps/desktop/scripts/collect-release-assets.mjs ]]; then
+ echo "::error::This source predates the reusable desktop build. Rerun its original workflow, or choose a tag containing the new build scripts."
+ exit 1
+ fi
+ node scripts/sync-version.mjs --check
+ node --input-type=module <<'JS'
+ import { appendFileSync, readFileSync } from 'node:fs';
+ import { execFileSync } from 'node:child_process';
+ const version = readFileSync('VERSION', 'utf8').trim();
+ const tag = process.env.RELEASE_TAG;
+ if (tag && tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`);
+ const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
+ const channel = version.includes('-') ? 'beta' : 'stable';
+ appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\nchannel=${channel}\n`);
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Source: ${sha}\n\nVersion: ${version} (${channel})\n`);
+ JS
+
+ web:
+ name: Build frontend once
+ needs: resolve
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.resolve.outputs.source-sha }}
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.17.0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ # Same namespace as the web CI job on the default branch.
+ - name: Cache Next.js build
+ uses: actions/cache@v4
+ with:
+ path: apps/web/.next/cache
+ key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.[jt]s', 'apps/web/**/*.[jt]sx', 'packages/**/*.ts', '!**/node_modules/**', '!apps/web/.next/**', '!apps/web/out/**') }}
+ restore-keys: |
+ next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
+ - run: pnpm turbo run build --filter=@deeix/web
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-web
+ path: apps/web/out
+ include-hidden-files: true
+ if-no-files-found: error
+ overwrite: true
+ retention-days: 7
+
+ catalog:
+ name: Refresh catalog once
+ needs: resolve
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.resolve.outputs.source-sha }}
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: backend/go.mod
+ cache-dependency-path: backend/go.sum
+ - name: Refresh bundled catalog snapshots
+ working-directory: backend
+ run: go run ./cmd/catalog-snapshot -keep-on-error
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-catalog
+ path: backend/internal/infra/catalogdata/data
+ if-no-files-found: error
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index e6e6439d0..a252fc08e 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -1,574 +1,126 @@
name: Desktop Release
-# Builds and publishes the Tauri desktop app for macOS, Windows and Linux.
-#
-# Repository secrets:
-#
-# Updater (required — the build fails without them)
-# TAURI_SIGNING_PRIVATE_KEY output of `tauri signer generate`
-# TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password ("" if none)
-#
-# macOS signing + notarization (optional; set all of them or none)
-# APPLE_CERTIFICATE base64 of the Developer ID Application .p12
-# APPLE_CERTIFICATE_PASSWORD .p12 password
-# APPLE_ID Apple ID used for notarization
-# APPLE_PASSWORD app-specific password for that Apple ID
-# (signing identity and team id are read from the certificate)
-#
-# Windows code signing (optional; unsigned builds when absent)
-# WINDOWS_CERTIFICATE base64 of the code-signing .pfx
-# WINDOWS_CERTIFICATE_PASSWORD .pfx password
-#
-# Repository variables (optional):
-# WEBVIEW2_FIXED_RUNTIME_URL x64 WebView2 Fixed Version .cab from
-# Microsoft; when set, the Windows leg also
-# builds the -portable-offline.zip
-# WEBVIEW2_FIXED_RUNTIME_SHA256 expected SHA-256 of that .cab (pinning)
-#
-# Signing switches on by itself once the secrets exist. Without them the
-# installers are unsigned: macOS users must allow the app under
-# System Settings → Privacy & Security, Windows shows a SmartScreen prompt.
-# A partial set of the APPLE_* secrets fails the build instead.
-#
-# Windows ships three forms from one set of binaries: the per-user NSIS setup
-# (auto-updates), the per-machine MSI for IT (no in-app updates; its
-# upgradeCode in tauri.conf.json must never change) and a portable zip
-# (scripts/package-portable.mjs). Every release also gets SHA256SUMS, and the
-# Windows files get GitHub build provenance (`gh attestation verify`).
-
-# Release channels are decided by the tag:
-# v1.2.3 stable → normal release; the app polls releases/latest/download/latest.json
-# v1.2.3-beta.1 beta → prerelease; the app polls releases/download/desktop-beta/latest.json,
-# which desktop-channel.yml refreshes when a prerelease is published.
-# Both channels are signed with the same updater key: a channel is a distribution
-# lane, not a trust boundary. A beta build only differs in the endpoint it polls.
-
+# Coordinate shared inputs, native builds and publication. Release runs live
+# on the default branch so successive tags can reuse caches; every build still
+# checks out the immutable commit resolved from release_tag.
on:
push:
tags:
- "v*.*.*"
- "v*.*.*-*"
- # Manual runs build every target and upload the installers as workflow
- # artifacts; only tag pushes create a GitHub Release.
workflow_dispatch:
+ inputs:
+ release_tag:
+ description: "Existing tag to release; leave empty for workflow artifacts only"
+ type: string
+ default: ""
permissions:
- contents: write
+ contents: read
-env:
- # Incremental artefacts are useless across CI runs and bloat the Rust cache.
- CARGO_INCREMENTAL: 0
+concurrency:
+ group: desktop-release-${{ inputs.release_tag || github.ref_name }}
+ cancel-in-progress: false
jobs:
- # The draft is created exactly once, here, before any leg starts. Each build
- # leg then uploads into it by id. Letting the legs create it is unsafe: a
- # draft is invisible to a tag lookup, so two legs finishing together both see
- # "no release" and each create their own, which is how one tag ends up with
- # two half-filled drafts.
- release:
- name: Create draft release
- if: github.ref_type == 'tag'
+ dispatch:
+ name: Dispatch release on the default branch
+ if: github.ref_type == 'tag' || (inputs.release_tag != '' && github.ref_name != github.event.repository.default_branch)
runs-on: ubuntu-24.04
permissions:
- contents: write
- outputs:
- id: ${{ steps.release.outputs.id }}
+ actions: write
steps:
- - name: Create draft release
- id: release
+ - name: Dispatch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
run: |
- set -euo pipefail
- tag="${{ github.ref_name }}"
- # tauri-action needs the numeric REST id, so the REST API is used throughout.
- # Drafts have no tag yet, so they are matched on tag_name in the list.
- ids="$(gh api "repos/$GH_REPO/releases?per_page=100" \
- --jq ".[] | select(.tag_name == \"$tag\") | .id")"
- count="$(printf '%s' "$ids" | grep -c . || true)"
- if [[ "$count" -gt 1 ]]; then
- echo "::error::$tag already has $count releases ($(echo $ids)); delete the extra drafts and re-run"
- exit 1
- fi
- id="$ids"
- if [[ -z "$id" ]]; then
- # Release notes list the asset names the assets job produces.
- notes="$RUNNER_TEMP/release-notes.md"
- cat > "$notes" <<'BODY'
- ### ⏬ Download
-
- | Platform | Download | Verified | Available |
- | --- | --- | --- | --- |
- | macOS · Apple silicon | `macos-arm64.dmg` | ✅ Verified | ✅ Available |
- | macOS · Intel | `macos-x64.dmg` | ✅ Verified | ✅ Available |
- | Linux · x86_64 | `linux-x64.AppImage` or `.deb` | ✅ Verified | ✅ Available |
- | Windows · x86_64 | `windows-x64-setup.exe` | ✅ Verified | ✅ Available |
- | Windows · x86_64 · MSI | `windows-x64.msi` | ✅ Verified | ✅ Available |
- | Windows · x86_64 · Portable | `windows-x64-portable.zip` | ✅ Verified | ✅ Available |
-
- Checksums are in `SHA256SUMS`. `-updater.tar.gz`, `.sig` and `latest.json` belong to the built-in updater.
- BODY
- # Beta tags (v1.2.3-beta.1) are prereleases; stable tags are not.
- if [[ "$tag" == *-* ]]; then prerelease=true; else prerelease=false; fi
- # Draft until a human publishes it: publishing is the act that ships.
- id="$(gh api -X POST "repos/$GH_REPO/releases" \
- -f tag_name="$tag" -f name="$tag" -F draft=true -F prerelease="$prerelease" \
- -F body=@"$notes" --jq .id)"
- fi
- echo "id=$id" >> "$GITHUB_OUTPUT"
- echo "Draft release $tag has id $id"
-
- build:
- name: Build (${{ matrix.label }})
- needs: release
- # On a manual run (no tag) no release is created and the legs only upload
- # workflow artifacts, so a skipped release job is fine.
- if: ${{ !cancelled() && needs.release.result != 'failure' }}
- runs-on: ${{ matrix.runner }}
+ gh workflow run desktop-release.yml --ref "$DEFAULT_BRANCH" -f release_tag="$RELEASE_TAG"
+ echo "Release $RELEASE_TAG dispatched on $DEFAULT_BRANCH for shared caches." >> "$GITHUB_STEP_SUMMARY"
+
+ prepare:
+ name: Prepare shared inputs
+ if: github.ref_type != 'tag' && (inputs.release_tag == '' || github.ref_name == github.event.repository.default_branch)
+ uses: ./.github/workflows/desktop-prepare.yml
+ with:
+ release-tag: ${{ inputs.release_tag || '' }}
+
+ macos-arm64:
+ name: macOS arm64
+ needs: prepare
+ uses: ./.github/workflows/desktop-macos.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ arch: arm64
+ secrets: inherit
+
+ macos-x64:
+ name: macOS x64
+ needs: prepare
+ uses: ./.github/workflows/desktop-macos.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ arch: x64
+ secrets: inherit
+
+ linux-x64:
+ name: Linux x64
+ needs: prepare
+ uses: ./.github/workflows/desktop-linux.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ secrets: inherit
+
+ windows-x64:
+ name: Windows x64
+ needs: prepare
permissions:
- contents: write # upload assets into the draft release
- id-token: write # sign build provenance (Sigstore)
- attestations: write # store build provenance
- strategy:
- fail-fast: false
- matrix:
- include:
- - label: macOS arm64
- platform: macOS
- runner: macos-15
- args: "--target aarch64-apple-darwin"
- - label: macOS x64
- platform: macOS
- runner: macos-15
- args: "--target x86_64-apple-darwin"
- - label: Linux x64
- platform: Linux
- runner: ubuntu-24.04
- args: ""
- - label: Windows x64
- platform: Windows
- runner: windows-2022
- args: ""
-
+ contents: read
+ id-token: write
+ attestations: write
+ uses: ./.github/workflows/desktop-windows.yml
+ with:
+ source-sha: ${{ needs.prepare.outputs.source-sha }}
+ channel: ${{ needs.prepare.outputs.channel }}
+ secrets: inherit
+
+ publish:
+ name: Assemble draft release
+ needs: [prepare, macos-arm64, macos-x64, linux-x64, windows-x64]
+ if: needs.prepare.outputs.release-tag != ''
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Set up pnpm
- uses: pnpm/action-setup@v4
+ - uses: actions/checkout@v4
with:
- version: 10.17.0
-
- - name: Set up Node.js
- uses: actions/setup-node@v4
+ ref: ${{ needs.prepare.outputs.source-sha }}
+ - uses: actions/setup-node@v4
with:
node-version: 24
- cache: pnpm
-
- - name: Set up Rust
- uses: dtolnay/rust-toolchain@stable
- with:
- targets: ${{ matrix.platform == 'macOS' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
-
- - name: Cache Rust build
- uses: Swatinem/rust-cache@v2
- with:
- workspaces: apps/desktop/src-tauri
-
- - name: Install Linux system dependencies
- if: matrix.platform == 'Linux'
- run: |
- sudo apt-get update
- sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: backend/go.mod
- cache-dependency-path: backend/go.sum
-
- - name: Install workspace dependencies
- run: pnpm install --frozen-lockfile
-
- # Windows needs a C compiler for cgo; fail early with a clear message
- # rather than inside the Go build.
- - name: Check C compiler
- shell: bash
- run: gcc --version | head -1 || { echo "::error::no C compiler for cgo"; exit 1; }
-
- # Version drift between VERSION, package.json, tauri.conf.json and
- # Cargo.toml must fail the release rather than ship a mislabelled build.
- - name: Verify version consistency
- run: node scripts/sync-version.mjs --check
-
- # The Go server ships inside the app as a sidecar (local mode). It links
- # SQLite through cgo, so it is built natively on each matrix leg; macOS
- # x86_64 is cross-built from the arm64 runner with Apple's toolchain
- # (verify-macos-bundle.sh below checks the architecture it ends up with).
- - name: Build server sidecar
- shell: bash
- # Refresh the bundled catalog snapshots (models.dev, OpenRouter pricing)
- # before compiling; a failed fetch keeps the committed snapshot.
- env:
- DEEIX_REFRESH_CATALOG_SNAPSHOTS: "1"
- run: |
- target="${{ matrix.args }}"
- target="${target#--target }"
- if [[ -n "$target" ]]; then
- node apps/desktop/scripts/build-sidecar.mjs --target "$target"
- else
- node apps/desktop/scripts/build-sidecar.mjs
- fi
-
- # Tauri treats a signing variable that merely exists as configured, so
- # the Apple credentials are exported only when the secrets are set;
- # identity and team id are then read from the certificate. A partial set
- # fails here: falling back to ad-hoc would ship an unnotarized app from
- # what looks like a signed release.
- - name: Configure Apple signing
- if: matrix.platform == 'macOS'
- shell: bash
- env:
- APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
- APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- APPLE_ID: ${{ secrets.APPLE_ID }}
- APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
- run: |
- set -euo pipefail
- # The .p12 password may legitimately be empty, so it only counts
- # towards "something is configured".
- present=() missing=()
- for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
- if [[ -n "${!name}" ]]; then
- present+=("$name")
- elif [[ "$name" != APPLE_CERTIFICATE_PASSWORD ]]; then
- missing+=("$name")
- fi
- done
- if ((${#present[@]} == 0)); then
- # Ad-hoc: Tauri skips codesign without an identity, and the linker's
- # own signature is not a valid bundle signature ("damaged" on macOS).
- echo "APPLE_SIGNING_IDENTITY=-" >> "$GITHUB_ENV"
- echo "::warning::APPLE_* secrets not set; the macOS app is ad-hoc signed and not notarized"
- exit 0
- fi
- if ((${#missing[@]} > 0)); then
- echo "::error::Apple signing is only partly configured: ${present[*]} set, ${missing[*]} missing. Set them all to sign and notarize, or remove them all for an ad-hoc build."
- exit 1
- fi
- for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
- printf '%s<<__EOF__\n%s\n__EOF__\n' "$name" "${!name}" >> "$GITHUB_ENV"
- done
- bash apps/desktop/scripts/apple-signing-env.sh >> "$GITHUB_ENV"
-
- # Tauri signs Windows binaries with a certificate from the user store,
- # selected by thumbprint. tauri-cli only reads it from
- # bundle.windows.certificateThumbprint (no environment variable), so the
- # next step passes it with --config.
- - name: Import Windows code-signing certificate
- if: matrix.platform == 'Windows'
- shell: pwsh
- env:
- WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
- WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
- run: |
- if (-not $env:WINDOWS_CERTIFICATE) {
- Write-Host "::warning::WINDOWS_CERTIFICATE not set; building an unsigned Windows installer"
- exit 0
- }
- $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
- [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
- $secure = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
- $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $secure
- Remove-Item $pfx
- # A thumbprint is a public hash of the certificate, not a secret.
- "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
-
- # Build-time config (scripts/release-config.mjs): the beta updater
- # endpoint, the MSI's numeric ProductVersion and the signing thumbprint.
- - name: Resolve release channel
- id: channel
- shell: bash
- run: |
- set -euo pipefail
- tag="${{ github.ref_type == 'tag' && github.ref_name || '' }}"
- if [[ "$tag" == *-* ]]; then channel=beta; else channel=stable; fi
- echo "name=$channel" >> "$GITHUB_OUTPUT"
- args=(--channel "$channel" --repository "${{ github.repository }}")
- if [[ -n "${WINDOWS_CERTIFICATE_THUMBPRINT:-}" ]]; then
- args+=(--thumbprint "$WINDOWS_CERTIFICATE_THUMBPRINT")
- fi
- # One `--config ` without spaces: tauri-action splits `args` on whitespace.
- echo "tauri_args=$(node apps/desktop/scripts/release-config.mjs "${args[@]}")" >> "$GITHUB_OUTPUT"
-
- # Swap tauri-cli's cached linuxdeploy for a wrapper that excludes
- # libwayland-* from the AppImage bundle (blank window on newer
- # Wayland distros otherwise); see prepare-linuxdeploy-wrapper.sh.
- - name: Exclude Wayland libraries from the AppImage bundle
- if: matrix.platform == 'Linux'
- shell: bash
- run: bash apps/desktop/scripts/prepare-linuxdeploy-wrapper.sh
-
- - name: Build desktop bundles
- uses: tauri-apps/tauri-action@v0
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
- TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- # Captured by the linuxdeploy wrapper so failures are diagnosable.
- LINUXDEPLOY_DEBUG_LOG: ${{ runner.temp }}/linuxdeploy.log
- # APPLE_* and the Windows certificate thumbprint arrive through
- # GITHUB_ENV from the steps above, only when configured.
+ - name: Download verified release assets
+ uses: actions/download-artifact@v4
with:
- projectPath: apps/desktop
- tauriScript: pnpm tauri
- # Beta endpoint, MSI version and signing thumbprint; see the channel step.
- args: ${{ matrix.args }} ${{ steps.channel.outputs.tauri_args }}
- # Upload into the one draft the release job created. Without a
- # releaseId each leg looks the draft up by tag and creates it when it
- # finds none; legs that finish together all miss and each create one,
- # leaving duplicate drafts that each hold only part of the assets.
- releaseId: ${{ needs.release.outputs.id }}
- # Still needed with a releaseId: it is not used to create anything here
- # (that is skipped when a releaseId is present) but it builds the
- # updater's asset URLs, which a prerelease must point at its own tag.
- tagName: ${{ github.ref_type == 'tag' && github.ref_name || '' }}
- includeUpdaterJson: true
- # With both NSIS and MSI built, point the generic `windows-x86_64`
- # entry at the per-user installer (the auto-updating form). Installed
- # apps look up `windows-x86_64-nsis` / `-msi` first by bundle type.
- updaterJsonPreferNsis: true
-
- # tauri-action swallows linuxdeploy's output; print it so bundling
- # failures are diagnosable.
- - name: Show linuxdeploy log
- if: ${{ always() && matrix.platform == 'Linux' }}
- shell: bash
- run: cat "$RUNNER_TEMP/linuxdeploy.log" 2>/dev/null || echo "(no linuxdeploy log)"
-
- # Fail loudly if the AppImage still bundles Wayland libraries (e.g. a
- # future tauri-cli stopped reusing the cached wrapper).
- - name: Verify no bundled Wayland libraries in the AppImage
- if: matrix.platform == 'Linux'
- shell: bash
- run: |
- set -euo pipefail
- shopt -s nullglob
- for appimage in apps/desktop/src-tauri/target/release/bundle/appimage/*.AppImage; do
- workdir="$(mktemp -d)"
- (cd "$workdir" && "$GITHUB_WORKSPACE/$appimage" --appimage-extract >/dev/null 2>&1)
- if ls "$workdir/squashfs-root/usr/lib" 2>/dev/null | grep -q '^libwayland'; then
- echo "::error::$appimage still bundles libwayland-*; the linuxdeploy wrapper was not used"
- exit 1
- fi
- rm -rf "$workdir"
- done
- echo "AppImage does not bundle Wayland libraries"
-
- # Same post-processing as a local build (scripts/package-dmg.sh). The
- # app is then checked as it ships, inside the final DMG and inside the
- # updater archive (scripts/verify-macos-bundle.sh), and only a verified
- # DMG replaces the asset tauri-action uploaded. tauri-action has already
- # put its own files into the draft, but a failed leg also skips the
- # assets job, so the draft never gets final names or SHA256SUMS.
- - name: Package and verify macOS bundles
- if: matrix.platform == 'macOS'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SIGN_AND_NOTARIZE_SCRIPT_URL: ${{ secrets.SIGN_AND_NOTARIZE_SCRIPT_URL }}
- SIGN_AND_NOTARIZE_BASE_URL: ${{ secrets.SIGN_AND_NOTARIZE_BASE_URL }}
- SIGN_AND_NOTARIZE_API_TOKEN: ${{ secrets.SIGN_AND_NOTARIZE_API_TOKEN }}
- SIGN_AND_NOTARIZE_HELPER_URL: ${{ secrets.SIGN_AND_NOTARIZE_HELPER_URL }}
- run: |
- set -euo pipefail
- shopt -s nullglob
- target="${{ matrix.args }}"
- target="${target#--target }"
- case "$target" in
- aarch64-apple-darwin) arch=arm64 ;;
- x86_64-apple-darwin) arch=x86_64 ;;
- *) echo "::error::unexpected macOS target '$target'"; exit 1 ;;
- esac
- # This leg's target only, not a target/* glob that could also pick up
- # leftovers of the other macOS target from a restored cache.
- bundle="apps/desktop/src-tauri/target/$target/release/bundle"
- dmgs=("$bundle"/dmg/*.dmg)
- archives=("$bundle"/macos/*.app.tar.gz)
- if ((${#dmgs[@]} != 1 || ${#archives[@]} != 1)); then
- echo "::error::expected one DMG and one updater archive under $bundle, found ${#dmgs[@]} and ${#archives[@]}"
- exit 1
- fi
- bash apps/desktop/scripts/package-dmg.sh "${dmgs[0]}"
- bash apps/desktop/scripts/verify-macos-bundle.sh --arch "$arch" "${dmgs[0]}" "${archives[0]}"
- if [[ "${{ github.ref_type }}" == "tag" ]]; then
- gh release upload "${{ github.ref_name }}" "${dmgs[0]}" --clobber --repo "${{ github.repository }}"
- fi
-
- # ---------- Windows: portable zip, signatures, provenance ----------
-
- # The bundler signs (and patches the bundle type into) the copy of the
- # app it packs into each installer, then restores the original unsigned
- # binary in target/release. That original is what the portable zip
- # ships, so it is signed here with the same certificate and settings.
- - name: Sign portable executable
- if: matrix.platform == 'Windows' && env.WINDOWS_CERTIFICATE_THUMBPRINT != ''
- shell: pwsh
- run: |
- $ErrorActionPreference = 'Stop'
- $conf = Get-Content apps/desktop/src-tauri/tauri.conf.json -Raw | ConvertFrom-Json
- $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
- Where-Object { $_.Directory.Parent.Name -as [version] } |
- Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
- Select-Object -First 1
- if (-not $signtool) { throw "signtool.exe not found" }
- $exe = "apps/desktop/src-tauri/target/release/deeix-chat-desktop.exe"
- & $signtool.FullName sign /fd $conf.bundle.windows.digestAlgorithm /sha1 $env:WINDOWS_CERTIFICATE_THUMBPRINT `
- /t $conf.bundle.windows.timestampUrl /d "DEEIX Chat" $exe
- if ($LASTEXITCODE -ne 0) { throw "signtool failed with $LASTEXITCODE" }
-
- # Optional offline variant: Microsoft's WebView2 Fixed Version runtime,
- # checked for Microsoft's Authenticode signature (and a pinned hash when
- # configured) before it is expanded into the package.
- - name: Fetch WebView2 Fixed Version runtime
- id: webview2
- if: matrix.platform == 'Windows' && vars.WEBVIEW2_FIXED_RUNTIME_URL != ''
- shell: pwsh
- env:
- WEBVIEW2_URL: ${{ vars.WEBVIEW2_FIXED_RUNTIME_URL }}
- WEBVIEW2_SHA256: ${{ vars.WEBVIEW2_FIXED_RUNTIME_SHA256 }}
- run: |
- $ErrorActionPreference = 'Stop'
- $cab = Join-Path $env:RUNNER_TEMP "webview2-fixed.cab"
- $dir = Join-Path $env:RUNNER_TEMP "webview2-fixed"
- Invoke-WebRequest -Uri $env:WEBVIEW2_URL -OutFile $cab -UseBasicParsing
- $hash = (Get-FileHash $cab -Algorithm SHA256).Hash
- Write-Host "WebView2 runtime .cab SHA-256: $hash"
- if ($env:WEBVIEW2_SHA256 -and $hash -ne $env:WEBVIEW2_SHA256.ToUpper()) {
- throw "WebView2 runtime hash mismatch: expected $($env:WEBVIEW2_SHA256), got $hash"
- }
- $sig = Get-AuthenticodeSignature $cab
- if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
- throw "WebView2 runtime .cab is not validly signed by Microsoft: $($sig.Status) $($sig.SignerCertificate.Subject)"
- }
- New-Item -ItemType Directory -Force -Path $dir | Out-Null
- expand.exe $cab -F:* $dir | Out-Null
- if ($LASTEXITCODE -ne 0) { throw "expand failed with $LASTEXITCODE" }
- "dir=$dir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
-
- - name: Package portable
- id: portable
- if: matrix.platform == 'Windows'
- shell: bash
- env:
- WEBVIEW2_DIR: ${{ steps.webview2.outputs.dir }}
- run: |
- set -euo pipefail
- out=apps/desktop/src-tauri/target/release/bundle/portable
- rm -rf "$out"
- node apps/desktop/scripts/package-portable.mjs --out-dir "$out"
- if [[ -n "$WEBVIEW2_DIR" ]]; then
- node apps/desktop/scripts/package-portable.mjs --out-dir "$out" --webview2 "$WEBVIEW2_DIR"
- fi
- echo "dir=$out" >> "$GITHUB_OUTPUT"
-
- # With a certificate every shipped Windows binary must carry a valid
- # signature; without one, report what is unsigned.
- - name: Verify Windows signatures
- if: matrix.platform == 'Windows'
- shell: pwsh
- run: |
- $ErrorActionPreference = 'Stop'
- $root = "apps/desktop/src-tauri"
- $files = @(
- "$root/target/release/deeix-chat-desktop.exe"
- "$root/binaries/deeix-chat-server-x86_64-pc-windows-msvc.exe"
- )
- $files += Get-ChildItem "$root/target/release/bundle/nsis/*-setup.exe", "$root/target/release/bundle/msi/*.msi" |
- ForEach-Object FullName
- $required = [bool]$env:WINDOWS_CERTIFICATE_THUMBPRINT
- $failed = $false
- foreach ($file in $files) {
- $sig = Get-AuthenticodeSignature -FilePath $file
- $line = "$($sig.Status): $file ($($sig.SignerCertificate.Subject))"
- if ($sig.Status -eq 'Valid') {
- Write-Host $line
- } elseif ($required) {
- Write-Host "::error::$line"
- $failed = $true
- } else {
- Write-Host "::warning::$line"
- }
- }
- if ($failed) { exit 1 }
-
- - name: Attest Windows build provenance
- if: matrix.platform == 'Windows'
- uses: actions/attest-build-provenance@v4
- with:
- subject-path: |
- apps/desktop/src-tauri/target/release/bundle/nsis/*-setup.exe
- apps/desktop/src-tauri/target/release/bundle/msi/*.msi
- apps/desktop/src-tauri/target/release/bundle/portable/*.zip
-
- # Uploaded under their final names; the assets job leaves them alone.
- - name: Upload portable packages to the release
- if: matrix.platform == 'Windows' && github.ref_type == 'tag'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: gh release upload "${{ github.ref_name }}" "${{ steps.portable.outputs.dir }}"/*.zip --clobber --repo "${{ github.repository }}"
-
- - name: Upload bundles as workflow artifacts
- if: github.ref_type != 'tag'
- uses: actions/upload-artifact@v4
- with:
- name: desktop-${{ matrix.label }}
- path: |
- apps/desktop/src-tauri/target/**/release/bundle/**/*.dmg
- apps/desktop/src-tauri/target/**/release/bundle/**/*.AppImage
- apps/desktop/src-tauri/target/**/release/bundle/**/*.deb
- apps/desktop/src-tauri/target/**/release/bundle/**/*.exe
- apps/desktop/src-tauri/target/**/release/bundle/**/*.msi
- apps/desktop/src-tauri/target/**/release/bundle/portable/*.zip
- if-no-files-found: warn
-
- assets:
- name: Rename release assets
- # Tauri's asset names do not say which platform a file belongs to. Runs
- # once every leg has uploaded; the updater manifest references assets by
- # file name and is rewritten to match. SHA256SUMS is then written over
- # the final names.
- needs: build
- if: github.ref_type == 'tag'
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v4
- - name: Rename
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_REPO: ${{ github.repository }}
- run: node apps/desktop/scripts/rename-release-assets.mjs "${{ github.ref_name }}"
- - name: Checksums
+ pattern: desktop-release-*
+ path: release-assets
+ merge-multiple: true
+ # One writer creates the draft, latest.json and SHA256SUMS only after all
+ # four targets pass verification. Publishing the draft remains a human step.
+ - name: Upload assets and updater manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- run: node apps/desktop/scripts/release-checksums.mjs "${{ github.ref_name }}"
-
- channel:
- name: Refresh beta manifest
- # A release created from the Releases page is already published when the
- # tag arrives, so Desktop Channel ran before latest.json existed. Now that
- # every leg has uploaded and the assets carry their final names, point the
- # beta channel at it if applicable.
- needs: [build, assets]
- if: github.ref_type == 'tag'
- runs-on: ubuntu-24.04
- steps:
- - uses: actions/checkout@v4
- - name: Refresh
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }}
+ run: node apps/desktop/scripts/publish-desktop-release.mjs --tag "$RELEASE_TAG" --sha "$SOURCE_SHA" --dir release-assets
+ - name: Refresh beta manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- run: bash apps/desktop/scripts/refresh-beta-manifest.sh "${{ github.ref_name }}"
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ run: bash apps/desktop/scripts/refresh-beta-manifest.sh "$RELEASE_TAG"
diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml
new file mode 100644
index 000000000..179ebe64d
--- /dev/null
+++ b/.github/workflows/desktop-windows.yml
@@ -0,0 +1,189 @@
+name: Desktop · Windows x64
+
+on:
+ workflow_call:
+ inputs:
+ source-sha:
+ required: true
+ type: string
+ channel:
+ required: true
+ type: string
+
+permissions:
+ contents: read
+ id-token: write
+ attestations: write
+
+jobs:
+ build:
+ runs-on: windows-2022
+ timeout-minutes: 45
+ env:
+ CARGO_INCREMENTAL: 0
+ TARGET: x86_64-pc-windows-msvc
+ CHANNEL: ${{ inputs.channel }}
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.source-sha }}
+ - uses: ./.github/actions/desktop-setup
+ with:
+ target: ${{ env.TARGET }}
+ source-sha: ${{ inputs.source-sha }}
+ - name: Build server sidecar
+ run: node apps/desktop/scripts/build-sidecar.mjs --target "$TARGET"
+ - name: Import Windows code-signing certificate
+ shell: pwsh
+ env:
+ WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
+ WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
+ run: |
+ if (-not $env:WINDOWS_CERTIFICATE) {
+ Write-Host "::warning::WINDOWS_CERTIFICATE not set; building an unsigned Windows installer"
+ exit 0
+ }
+ $pfx = Join-Path $env:RUNNER_TEMP "codesign.pfx"
+ [IO.File]::WriteAllBytes($pfx, [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE))
+ $secure = ConvertTo-SecureString $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force
+ $cert = Import-PfxCertificate -FilePath $pfx -CertStoreLocation Cert:\CurrentUser\My -Password $secure
+ Remove-Item $pfx
+ # A thumbprint is a public hash of the certificate, not a secret.
+ "WINDOWS_CERTIFICATE_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append
+
+ - name: Configure desktop build
+ run: |
+ args=(--channel "$CHANNEL" --repository "$GITHUB_REPOSITORY" --prebuilt-frontend)
+ if [[ -n "${WINDOWS_CERTIFICATE_THUMBPRINT:-}" ]]; then
+ args+=(--thumbprint "$WINDOWS_CERTIFICATE_THUMBPRINT")
+ fi
+ node apps/desktop/scripts/release-config.mjs "${args[@]}" --output "$RUNNER_TEMP/desktop-config.json"
+ - name: Compile desktop executable
+ run: |
+ pnpm --filter @deeix/desktop exec tauri build --no-bundle --target "$TARGET" \
+ --config "$RUNNER_TEMP/desktop-config.json" -- --locked --timings
+ - name: Bundle NSIS and MSI installers
+ env:
+ TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+ TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+ run: |
+ pnpm --filter @deeix/desktop exec tauri bundle --target "$TARGET" --bundles nsis,msi \
+ --config "$RUNNER_TEMP/desktop-config.json"
+ - name: Sign portable executable
+ if: env.WINDOWS_CERTIFICATE_THUMBPRINT != ''
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $conf = Get-Content apps/desktop/src-tauri/tauri.conf.json -Raw | ConvertFrom-Json
+ $signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" |
+ Where-Object { $_.Directory.Parent.Name -as [version] } |
+ Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
+ Select-Object -First 1
+ if (-not $signtool) { throw "signtool.exe not found" }
+ $exe = "apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/deeix-chat-desktop.exe"
+ & $signtool.FullName sign /fd $conf.bundle.windows.digestAlgorithm /sha1 $env:WINDOWS_CERTIFICATE_THUMBPRINT `
+ /t $conf.bundle.windows.timestampUrl /d "DEEIX Chat" $exe
+ if ($LASTEXITCODE -ne 0) { throw "signtool failed with $LASTEXITCODE" }
+ - name: Fetch WebView2 Fixed Version runtime
+ id: webview2
+ if: vars.WEBVIEW2_FIXED_RUNTIME_URL != ''
+ shell: pwsh
+ env:
+ WEBVIEW2_URL: ${{ vars.WEBVIEW2_FIXED_RUNTIME_URL }}
+ WEBVIEW2_SHA256: ${{ vars.WEBVIEW2_FIXED_RUNTIME_SHA256 }}
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $cab = Join-Path $env:RUNNER_TEMP "webview2-fixed.cab"
+ $dir = Join-Path $env:RUNNER_TEMP "webview2-fixed"
+ Invoke-WebRequest -Uri $env:WEBVIEW2_URL -OutFile $cab -UseBasicParsing
+ $hash = (Get-FileHash $cab -Algorithm SHA256).Hash
+ Write-Host "WebView2 runtime .cab SHA-256: $hash"
+ if ($env:WEBVIEW2_SHA256 -and $hash -ne $env:WEBVIEW2_SHA256.ToUpper()) {
+ throw "WebView2 runtime hash mismatch: expected $($env:WEBVIEW2_SHA256), got $hash"
+ }
+ $sig = Get-AuthenticodeSignature $cab
+ if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') {
+ throw "WebView2 runtime .cab is not validly signed by Microsoft: $($sig.Status) $($sig.SignerCertificate.Subject)"
+ }
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
+ expand.exe $cab -F:* $dir | Out-Null
+ if ($LASTEXITCODE -ne 0) { throw "expand failed with $LASTEXITCODE" }
+ "dir=$dir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
+ - name: Package portable
+ id: portable
+ shell: bash
+ env:
+ WEBVIEW2_DIR: ${{ steps.webview2.outputs.dir }}
+ run: |
+ set -euo pipefail
+ out=apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/portable
+ rm -rf "$out"
+ node apps/desktop/scripts/package-portable.mjs --target "$TARGET" --out-dir "$out"
+ if [[ -n "$WEBVIEW2_DIR" ]]; then
+ node apps/desktop/scripts/package-portable.mjs --target "$TARGET" --out-dir "$out" --webview2 "$WEBVIEW2_DIR"
+ fi
+ echo "dir=$out" >> "$GITHUB_OUTPUT"
+ - name: Verify Windows signatures
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $root = "apps/desktop/src-tauri"
+ $files = @(
+ "$root/target/x86_64-pc-windows-msvc/release/deeix-chat-desktop.exe"
+ "$root/binaries/deeix-chat-server-x86_64-pc-windows-msvc.exe"
+ )
+ $files += Get-ChildItem "$root/target/x86_64-pc-windows-msvc/release/bundle/nsis/*-setup.exe", "$root/target/x86_64-pc-windows-msvc/release/bundle/msi/*.msi" |
+ ForEach-Object FullName
+ $required = [bool]$env:WINDOWS_CERTIFICATE_THUMBPRINT
+ $failed = $false
+ foreach ($file in $files) {
+ $sig = Get-AuthenticodeSignature -FilePath $file
+ $line = "$($sig.Status): $file ($($sig.SignerCertificate.Subject))"
+ if ($sig.Status -eq 'Valid') {
+ Write-Host $line
+ } elseif ($required) {
+ Write-Host "::error::$line"
+ $failed = $true
+ } else {
+ Write-Host "::warning::$line"
+ }
+ }
+ if ($failed) { exit 1 }
+
+ - name: Stage release assets
+ run: |
+ node apps/desktop/scripts/collect-release-assets.mjs --platform windows-x64 \
+ --bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
+ - name: Record workflow and source commits
+ env:
+ SOURCE_SHA: ${{ inputs.source-sha }}
+ run: node apps/desktop/scripts/desktop-provenance.mjs > "$RUNNER_TEMP/desktop-provenance.json"
+ - name: Attest Windows build provenance
+ # The workflow runs on the default branch; attest the checked-out
+ # release commit as well as the workflow commit.
+ uses: actions/attest-build-provenance@v4
+ with:
+ predicate-type: https://slsa.dev/provenance/v1
+ predicate-path: ${{ runner.temp }}/desktop-provenance.json
+ subject-path: |
+ ${{ runner.temp }}/release-assets/*.exe
+ ${{ runner.temp }}/release-assets/*.msi
+ ${{ runner.temp }}/release-assets/*.zip
+ - uses: actions/upload-artifact@v4
+ with:
+ name: desktop-release-windows-x64
+ path: ${{ runner.temp }}/release-assets
+ compression-level: 0
+ if-no-files-found: error
+ overwrite: true
+ - name: Upload compilation timings
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: desktop-timings-windows-x64
+ path: apps/desktop/src-tauri/target/cargo-timings
+ overwrite: true
+ retention-days: 7
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 1757b8e80..2d847670f 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -53,7 +53,10 @@ jobs:
if: steps.tag.outputs.created == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_REPO: ${{ github.repository }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
- for workflow in desktop-release.yml image.yml; do
- gh workflow run "$workflow" --ref "${{ steps.tag.outputs.tag }}" --repo "${{ github.repository }}"
- done
+ # Keep the desktop cache in the default branch's scope across tags.
+ gh workflow run desktop-release.yml --ref "$DEFAULT_BRANCH" -f release_tag="$RELEASE_TAG"
+ gh workflow run image.yml --ref "$RELEASE_TAG"
diff --git a/README.md b/README.md
index 16b1db662..e95ce5245 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
- English | 简体中文
+ English | 简体中文
@@ -232,6 +232,32 @@ tooling that prefers them; CI uses those directly.
The frontend is exported as static files, so `pnpm build` writes the browser artifact to `apps/web/out`. The Go service can serve that directory in the production image or through `server.frontend_dist_dir`.
+### Desktop Releases
+
+Merging a `VERSION` bump into `main` creates `v` and starts
+[`desktop-release.yml`](.github/workflows/desktop-release.yml). Tag pushes also
+trigger it. The parent workflow delegates to reusable workflows:
+
+| Workflow | Responsibility |
+| --- | --- |
+| [`desktop-prepare.yml`](.github/workflows/desktop-prepare.yml) | Resolve the source commit, validate versions, build the frontend and refresh catalog snapshots once. |
+| [`desktop-macos.yml`](.github/workflows/desktop-macos.yml) | Separate arm64 and x64 invocations build, sign, notarize and verify the DMG/updater packages. |
+| [`desktop-linux.yml`](.github/workflows/desktop-linux.yml) | Build x64 deb/AppImage packages and verify Wayland library exclusions. |
+| [`desktop-windows.yml`](.github/workflows/desktop-windows.yml) | Build x64 NSIS/MSI/portable packages, verify signatures and attest their provenance. |
+
+The four native jobs run in parallel after preparation and reuse the same
+frontend/catalog artifacts. Releases run on the default branch with a
+`release_tag` input so successive tags share a cache scope; builds still check
+out the tag's immutable commit. Rust is pinned, Rust/Go caches are separated by
+target, and the fat LTO release profile is unchanged. macOS packages are created
+from the same final signed app, avoiding DMG recompression.
+
+Only after every target passes does the parent assemble `latest.json` and
+`SHA256SUMS` and upload a **draft** release. Publishing the draft ships the update.
+Manual runs with an empty `release_tag` only upload workflow artifacts. See the
+[desktop release guide](apps/desktop/README.md#release-workflows) for manual tag
+builds, signing secrets, cache behavior and compilation timing reports.
+
### Docker Deployment
All Docker assets live in [`deploy/`](deploy/README.md); copy that directory to your server, the application source is not needed. Run the following commands from inside `deploy/`. Choose one installation profile first, then copy the matching config template to `deploy/config.yaml`. All profiles expose the app at `http://localhost:8080` by default and mount `./config.yaml` (relative to `deploy/`) to `/app/config.yaml` inside the container.
diff --git a/docs/README.zh-CN.md b/README.zh-CN.md
similarity index 86%
rename from docs/README.zh-CN.md
rename to README.zh-CN.md
index 95a4d1205..cec9ea53c 100644
--- a/docs/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -1,7 +1,7 @@
-
-
+
+
@@ -10,7 +10,7 @@
- English | 简体中文
+ English | 简体中文
@@ -30,7 +30,7 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定
系统围绕简单部署、高效静态分发和低资源的运行时占用设计,轻量而不简陋、克制而不缺能力、开放而不失秩序。
-
+
## 核心能力
@@ -48,14 +48,14 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定
| 部署与运维 | 支持单运行时托管前端与 API、Docker 部署、SQLite 或 PostgreSQL、内存缓存或 Redis、S3 兼容存储、Swagger、结构化日志、版本接口、GeoIP 和 OpenTelemetry。 |
-
-
+
+
-
-
-
+
+
+
## 系统架构与技术栈
@@ -148,7 +148,7 @@ flowchart TB
└── Dockerfile # 前后端生产镜像
```
-`apps/web/` 和 `backend/` 是由 pnpm 与 Turborepo 管理的独立工作区。`packages/api-contract/` 通过生成的 Swagger 类型连接两者;修改 HTTP 契约时先更新 Go 传输层,再重新生成共享包。`packages/core/` 存放必须在各端保持一致的客户端逻辑,禁止引入任何 UI 或平台框架。平台边界与规则见 [docs/ARCHITECTURE.md](ARCHITECTURE.md)。
+`apps/web/` 和 `backend/` 是由 pnpm 与 Turborepo 管理的独立工作区。`packages/api-contract/` 通过生成的 Swagger 类型连接两者;修改 HTTP 契约时先更新 Go 传输层,再重新生成共享包。`packages/core/` 存放必须在各端保持一致的客户端逻辑,禁止引入任何 UI 或平台框架。平台边界与规则见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
## 开发前置条件
@@ -229,9 +229,34 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
前端以静态资源形式导出,`pnpm build` 会把浏览器产物写入 `apps/web/out`。生产镜像中的 Go 服务,或配置了 `server.frontend_dist_dir` 的 Go 服务,可以直接托管该目录。
+### 桌面端发布
+
+把 `VERSION` 的版本更新合并到 `main` 后,[`release-tag.yml`](.github/workflows/release-tag.yml) 创建 `v` 标签并启动桌面端发布。直接推送同名格式的标签也会触发构建。[`desktop-release.yml`](.github/workflows/desktop-release.yml) 负责总编排,平台逻辑拆在可复用的子 workflow 中:
+
+| 子 workflow | 职责 |
+| --- | --- |
+| [`desktop-prepare.yml`](.github/workflows/desktop-prepare.yml) | 校验版本、解析标签对应的提交;并行构建静态前端和刷新模型目录快照,各执行一次。 |
+| [`desktop-macos.yml`](.github/workflows/desktop-macos.yml) | arm64、x64 分别调用,各自编译内置 Go 服务和桌面程序,完成签名、公证、DMG 与更新包验证。 |
+| [`desktop-linux.yml`](.github/workflows/desktop-linux.yml) | 构建 Linux x64 的 deb、AppImage,验证 AppImage 不携带会与宿主冲突的 Wayland 库。 |
+| [`desktop-windows.yml`](.github/workflows/desktop-windows.yml) | 构建 Windows x64 的 NSIS、MSI 和便携包,完成签名检查及构建来源证明。 |
+
+公共准备完成后,四个 OS + arch 任务并行运行,Node.js 侧只安装桌面 CLI 所需的依赖,并下载同一份前端和模型目录产物。四个平台验证通过后,主 workflow 统一生成 `latest.json`、`SHA256SUMS`,上传安装包和更新签名到一个 **GitHub Release 草稿**。手动发布草稿后,已安装的客户端才会收到更新。
+
+发布任务在仓库的**默认分支**上运行,通过 `release_tag` 指定源码标签。构建始终 checkout 已解析的固定提交,上传前再次确认标签没有移动;这样不同版本可以复用默认分支缓存。公共 [desktop-setup action](.github/actions/desktop-setup/action.yml) 固定 Rust 版本,并按目标平台和架构隔离 Rust、Go 缓存,避免两个 macOS 任务争用缓存。首次运行仍需要预热;Rust 的 fat LTO 等发布优化参数保持不变。
+
+macOS 先完成 `.app` 的签名和公证,再从同一份最终应用生成 ULFO DMG 和更新压缩包,避免先生成 DMG 再解压、替换应用和重新压缩。编译、打包、签名、公证与验证分别显示为独立步骤;`desktop-timings-*` 附件保留七天,可用于查看 Rust 编译耗时。外部签名服务和 Apple 公证的等待时间仍计入相应步骤。
+
+手动构建已有标签(默认分支和目标标签均需包含这些 workflow 与构建脚本):
+
+```bash
+gh workflow run desktop-release.yml --ref "$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -f release_tag=v1.2.3
+```
+
+在 Actions 页面手动运行且留空 `release_tag` 时,只构建所选分支并上传 workflow 附件。公共产物保留七天,期间可以重跑失败的平台任务。签名密钥、更新通道和平台打包细节见 [桌面端说明](apps/desktop/README.md#release-workflows)。
+
### Docker 部署
-所有 Docker 部署资源都在 [`deploy/`](../deploy/README.md) 目录;把这个目录整体拷到服务器即可,不需要应用源码。以下命令均在 `deploy/` 目录内执行。先选择安装方案,再把对应的配置模板复制为 `deploy/config.yaml`。三套 compose 方案都默认将应用暴露在 `http://localhost:8080`,并把 `./config.yaml`(相对 `deploy/`)挂载到容器内 `/app/config.yaml`。
+所有 Docker 部署资源都在 [`deploy/`](deploy/README.md) 目录;把这个目录整体拷到服务器即可,不需要应用源码。以下命令均在 `deploy/` 目录内执行。先选择安装方案,再把对应的配置模板复制为 `deploy/config.yaml`。三套 compose 方案都默认将应用暴露在 `http://localhost:8080`,并把 `./config.yaml`(相对 `deploy/`)挂载到容器内 `/app/config.yaml`。
| 方案 | 适合场景 | 配置文件 | Compose 文件 | 内置依赖 |
| --- | --- | --- | --- | --- |
@@ -392,7 +417,7 @@ docker compose logs app
后端启动时会按运行目录解析默认配置文件:从仓库根目录启动读取 `config.yaml`,从 `backend/` 目录启动读取 `../config.yaml`。Docker 部署将 `deploy/config.yaml` 只读挂载到容器内 `/app/config.yaml`;如果配置文件放在其他位置,请使用 `CONFIG_FILE` 指向实际运行环境可访问的路径。最终生效优先级是 `环境变量 > config.yaml > 代码内置默认值`。
-前端品牌同样属于运行时配置。在 `config.yaml` 中设置 `branding` 后重启应用即可生效,无需重新构建前端或 Docker 镜像。详见[自定义品牌资源](./BRANDING.md)。
+前端品牌同样属于运行时配置。在 `config.yaml` 中设置 `branding` 后重启应用即可生效,无需重新构建前端或 Docker 镜像。详见[自定义品牌资源](./docs/BRANDING.md)。
静态配置环境变量:
@@ -469,7 +494,7 @@ docker compose logs app
生产环境启用 SSRF 防护后,管理员保存的模型、MCP、Embedding、OIDC/OAuth2 和自定义 Turnstile endpoint 均按精确 origin(协议、主机和端口)获得局部授权,不需要加入全局白名单。模型、MCP 与 Embedding 保留标准重定向兼容性:跨 origin 的公网目标可以继续访问,跨 origin 的私网目标必须命中 `SSRF_ALLOWED_HOSTS` 或 `SSRF_ALLOWED_CIDRS`;OIDC/OAuth2 与 Turnstile 继续维持更严格的身份边界。模型生成的图片或视频由后端下载、校验并转存:私网制品 URL 只有与本次选中的模型 endpoint 同 origin 时才继承该局部信任;跨 origin 的公网制品仍按严格公网策略下载,跨 origin 的私网制品会被拦截。全局白名单也继续用于无法绑定管理员保存 endpoint 的部署级集成,例如部分 GeoIP 或提取服务部署。链路本地、组播、未指定地址和已知云元数据目标始终禁止。白名单配置不合法会阻止后端启动;全局白名单修改后需重启生效。
-出站请求遵守标准代理环境变量 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY`,开启 SSRF 防护时同样生效。代理地址本身视为部署方授权;选择代理前仍会按 SSRF 策略校验目标 URL,元数据地址等禁止目标照样拒绝;不走代理的请求(命中 `NO_PROXY` 或回环地址)仍走完整的 DNS 校验拨号流程。请求经代理发出时,目标域名由代理解析;交给代理前,后端会拒绝 `127.1`、`2130706433` 这类数字主机名,并在本地预解析一次域名,任一解析结果被策略禁止即拒绝,本地解析失败则交由代理处理。这无法防御代理侧的 DNS rebinding,DNS 层面的出口控制仍由部署方的代理负责。`config.yaml` 中没有代理配置项,代理地址与凭据只放在部署环境变量里。Go 不读取 `ALL_PROXY`,使用 SOCKS 代理时请把 `HTTP_PROXY` 和 `HTTPS_PROXY` 设为 `socks5://` 地址;只设置了 `ALL_PROXY` 时,后端启动会记录 `outbound_proxy_ignored` 告警并直连。修改代理环境变量需要重启后端。桌面端本地模式下,这些变量由桌面应用根据「设置 › 网络」中的选择(默认跟随系统代理)为内置服务设置,详见 [apps/desktop/README.md](../apps/desktop/README.md#network-proxy)。
+出站请求遵守标准代理环境变量 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY`,开启 SSRF 防护时同样生效。代理地址本身视为部署方授权;选择代理前仍会按 SSRF 策略校验目标 URL,元数据地址等禁止目标照样拒绝;不走代理的请求(命中 `NO_PROXY` 或回环地址)仍走完整的 DNS 校验拨号流程。请求经代理发出时,目标域名由代理解析;交给代理前,后端会拒绝 `127.1`、`2130706433` 这类数字主机名,并在本地预解析一次域名,任一解析结果被策略禁止即拒绝,本地解析失败则交由代理处理。这无法防御代理侧的 DNS rebinding,DNS 层面的出口控制仍由部署方的代理负责。`config.yaml` 中没有代理配置项,代理地址与凭据只放在部署环境变量里。Go 不读取 `ALL_PROXY`,使用 SOCKS 代理时请把 `HTTP_PROXY` 和 `HTTPS_PROXY` 设为 `socks5://` 地址;只设置了 `ALL_PROXY` 时,后端启动会记录 `outbound_proxy_ignored` 告警并直连。修改代理环境变量需要重启后端。桌面端本地模式下,这些变量由桌面应用根据「设置 › 网络」中的选择(默认跟随系统代理)为内置服务设置,详见 [apps/desktop/README.md](apps/desktop/README.md#network-proxy)。
### Web、App 与桌面端 OAuth 回调
@@ -504,12 +529,13 @@ Web、App 与桌面端的登录、注册和账号身份绑定都走这一个回
- [用户指南](https://deeix.com/zh/docs/deeix-chat/new-chat)
- [管理指南](https://deeix.com/zh/docs/deeix-chat/admin-accounts)
- [进阶指南](https://deeix.com/zh/docs/deeix-chat/advanced-capabilities-passthrough-tools)
-- 后端说明:[backend/README.md](../backend/README.md)
-- API 文档索引:[backend/docs/README.md](../backend/docs/README.md)
-- 前端说明:[apps/web/README.md](../apps/web/README.md)
-- API 契约包:[packages/api-contract/README.md](../packages/api-contract/README.md)
-- 贡献指南:[CONTRIBUTING.md](../.github/CONTRIBUTING.md)
-- 安全策略:[SECURITY.md](../.github/SECURITY.md)
+- 后端说明:[backend/README.md](backend/README.md)
+- API 文档索引:[backend/docs/README.md](backend/docs/README.md)
+- 前端说明:[apps/web/README.md](apps/web/README.md)
+- 桌面端说明:[apps/desktop/README.md](apps/desktop/README.md)
+- API 契约包:[packages/api-contract/README.md](packages/api-contract/README.md)
+- 贡献指南:[CONTRIBUTING.md](.github/CONTRIBUTING.md)
+- 安全策略:[SECURITY.md](.github/SECURITY.md)
- Swagger UI(仅开发环境):`http://localhost:8080/swagger/index.html`
## 鸣谢
@@ -530,4 +556,4 @@ DEEIX Chat 基于开源生态构建,感谢所有 AI 工具生态中的维护
## 开源协议
-DEEIX Chat 使用 [Apache License 2.0](../LICENSE) 授权。
+DEEIX Chat 使用 [Apache License 2.0](LICENSE) 授权。
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 2aa5bb695..3c552f6dd 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -316,18 +316,21 @@ The JS updater permission is not in the static capability: it is granted at
runtime only in `auto` mode, so a portable or MSI build cannot install updates
even if the web layer asked.
-Release assets are renamed by `scripts/rename-release-assets.mjs` to
+Release assets are staged by `scripts/collect-release-assets.mjs` as
`DEEIX-Chat---[-setup|-updater].`, e.g.
`DEEIX-Chat-0.4.4-beta.1-macos-arm64.dmg`, `-linux-x64.AppImage`,
-`-windows-x64-setup.exe`, `-macos-arm64-updater.tar.gz`. The plan is
-validated for collisions before anything is renamed, and the updater manifest,
-which references assets by file name, is reconciled from its own URLs, so the
-job can be re-run after an interruption. Only the artifact bytes are signed, so
-verification is unaffected. `pnpm test` covers the mapping.
+`-windows-x64-setup.exe`, `-macos-arm64-updater.tar.gz`. Every target must have
+exactly one of each required package and its updater signature before staging.
+After all targets pass, `scripts/publish-desktop-release.mjs` assembles
+`latest.json` and `SHA256SUMS` from those final files and uploads them to one
+draft. It preserves the updater platform keys used by existing installs,
+resumes a single existing draft and refuses to replace a published release.
+Only the artifact bytes are signed, so staging under a new name does not change
+their signatures. `pnpm test` covers staging, updater compatibility and hashes.
Release flow: merging a `VERSION` bump into `main` creates the tag
`v` (`release-tag.yml`), which builds every target and opens a
-**draft** GitHub Release with the installers and a signed `latest.json`.
+**draft** GitHub Release with the installers and `latest.json` containing their signatures.
Publishing the draft is the step that ships the update; until then existing
installs see nothing. Pushing the tag by hand does the same.
@@ -335,6 +338,50 @@ Windows assets: `-windows-x64-setup.exe`, `-windows-x64.msi`,
`-windows-x64-portable.zip` (and `-portable-offline.zip` when built). Every
release also carries `SHA256SUMS` and GitHub build provenance attestations.
+### Release workflows
+
+[`desktop-release.yml`](../../.github/workflows/desktop-release.yml) coordinates
+the following reusable workflows. Each native job receives the same immutable
+source commit and channel; platform-specific installation, signing and
+verification live in that platform's file.
+
+| Workflow | Responsibility |
+| --- | --- |
+| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Resolve the requested tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
+| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Called separately for arm64 and x64; build the sidecar/app, sign it, create the final DMG and updater archive, then verify both. |
+| [`desktop-linux.yml`](../../.github/workflows/desktop-linux.yml) | Build x64 sidecar/executable, bundle deb/AppImage and verify the Wayland library exclusion. |
+| [`desktop-windows.yml`](../../.github/workflows/desktop-windows.yml) | Build x64 sidecar/executable, sign and verify NSIS/MSI/portable packages, and attest their provenance. |
+
+The four native jobs run in parallel after preparation. Shared toolchain/cache
+setup lives in [the desktop-setup action](../../.github/actions/desktop-setup/action.yml).
+The frontend and catalog are downloaded as workflow artifacts; native jobs
+disable Tauri's frontend build hook, so they never rebuild the shared frontend.
+Compilation, bundling, notarization and verification have separate steps. Rust
+`--timings` reports are retained as `desktop-timings-*` artifacts for seven days.
+The shipped Rust optimization profile, including fat LTO, is unchanged.
+
+Release-tag runs dispatch `desktop-release.yml` on the repository's **default
+branch**, with the tag supplied as `release_tag`. This gives successive releases
+the same GitHub cache scope instead of creating a separate scope per tag. The
+jobs still check out the tag's resolved commit, and publication verifies the tag
+has not moved. Windows provenance records both the workflow commit and the
+actual desktop source commit. Rust is pinned in the setup action, and Rust/Go
+cache keys include the target triple so the two macOS architectures cannot
+overwrite one another's cache. The first run after a cache/toolchain change is
+still cold; dependency changes or cache eviction can also cause rebuilds.
+
+To build an existing tag, after these workflows exist on the default branch:
+
+```bash
+gh workflow run desktop-release.yml --ref "$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -f release_tag=v1.2.3
+```
+
+The tag must contain the reusable build scripts and setup action. A manual run
+with an empty `release_tag` builds the selected branch and only uploads workflow
+artifacts. A release run creates the draft only after all four targets succeed;
+publishing the draft remains a separate human step. Failed jobs can be retried
+while the shared artifacts are retained (seven days).
+
### Channels
| Tag | Channel | What the app polls |
@@ -435,8 +482,13 @@ What a local `pnpm build` costs once caches are warm, and where it goes:
| `.app` | ~1 s | | |
| `.dmg` | ~25 s | | `hdiutil` + Finder layout; `scripts/package-dmg.sh` then re-encodes with lzfse and (when signing is configured) re-signs and notarizes |
-`pnpm build:signed` produces exactly what CI ships. Use `pnpm build:app` while
-iterating: it relaxes LTO (~17 s link) and stops at the `.app`.
+`pnpm build:signed` uses the local Tauri DMG/repackaging path above. CI uses
+`scripts/package-macos.sh`: it creates the `.app` first, completes its signing
+and notarization, then creates an ULFO DMG directly from that app. It avoids
+building a compressed DMG only to decompress, replace the app and recompress it.
+The updater tarball is created from the same final app and then signed with the
+updater key. Both packages go through `verify-macos-bundle.sh` before staging.
+Use `pnpm build:app` while iterating: it relaxes LTO (~17 s link) and stops at the `.app`.
Every `no*` tag has an `*_off.go` counterpart that returns a clear error if the
config selects a driver that was compiled out.
@@ -457,6 +509,7 @@ app icon.
| Platform | What is needed | CI secrets |
| --- | --- | --- |
| macOS | Developer ID Application certificate (.p12) + Apple ID app-specific password for notarization; identity and team id are derived from the certificate (`scripts/apple-signing-env.sh`) | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_PASSWORD` |
+| macOS, external service | Alternative app/DMG signing and notarization provider | `SIGN_AND_NOTARIZE_SCRIPT_URL`, `SIGN_AND_NOTARIZE_BASE_URL`, `SIGN_AND_NOTARIZE_API_TOKEN`, `SIGN_AND_NOTARIZE_HELPER_URL` |
| Windows | Code-signing certificate (.pfx) | `WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD` |
| All | Updater keypair (`tauri signer generate`); the private key is kept git-ignored in `deploy/secrets/` (see its README) | `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` |
@@ -468,6 +521,15 @@ signs the exe inside each installer and the sidecar; the portable exe is signed
separately with signtool. A CI step then requires a valid Authenticode signature
on the setup, MSI, portable exe and sidecar whenever a certificate is configured.
+CI chooses the external macOS provider when all four `SIGN_AND_NOTARIZE_*`
+secrets are set, even if Apple credentials also exist. Tauri only ad-hoc signs
+in that mode; the external service signs/notarizes the app once and then the
+final DMG. The updater archive is created afterwards, from the signed app.
+A partial external configuration fails immediately. Without the external
+provider, the existing `APPLE_*` credentials enable Tauri app signing and
+notarization; DMG signing imports its own temporary keychain because Tauri
+removes its keychain when app bundling finishes.
+
On macOS, `scripts/verify-macos-bundle.sh` checks the app as it ships, inside the
final DMG and inside the updater archive, before CI uploads the DMG: the app
binary and the sidecar must be built for the leg's architecture and must not
diff --git a/apps/desktop/scripts/collect-release-assets.mjs b/apps/desktop/scripts/collect-release-assets.mjs
new file mode 100644
index 000000000..0e2b62a76
--- /dev/null
+++ b/apps/desktop/scripts/collect-release-assets.mjs
@@ -0,0 +1,89 @@
+// Stage one target's verified files under their final release names. Publication
+// consumes these artifacts, so no platform uploads or edits latest.json itself.
+import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+import { msiVersion } from "./release-config.mjs";
+
+const specifications = {
+ "macos-arm64": [
+ ["dmg", ".dmg", ".dmg"],
+ ["macos", ".app.tar.gz", "-updater.tar.gz", ["darwin-aarch64", "darwin-aarch64-app"]],
+ ],
+ "macos-x64": [
+ ["dmg", ".dmg", ".dmg"],
+ ["macos", ".app.tar.gz", "-updater.tar.gz", ["darwin-x86_64", "darwin-x86_64-app"]],
+ ],
+ "linux-x64": [
+ ["deb", ".deb", ".deb", ["linux-x86_64-deb"]],
+ ["appimage", ".AppImage", ".AppImage", ["linux-x86_64", "linux-x86_64-appimage"]],
+ ],
+ "windows-x64": [
+ ["nsis", "-setup.exe", "-setup.exe", ["windows-x86_64", "windows-x86_64-nsis"]],
+ ["msi", ".msi", ".msi", ["windows-x86_64-msi"]],
+ ["portable", "-portable.zip", "-portable.zip"],
+ ["portable", "-portable-offline.zip", "-portable-offline.zip", [], true],
+ ],
+};
+
+export const releasePlatforms = Object.keys(specifications);
+
+export function releaseAssets(platform, version) {
+ msiVersion(version);
+ const specs = specifications[platform];
+ if (!specs) throw new Error(`unsupported release platform: ${platform}`);
+ return specs.map(([directory, extension, suffix, updater = [], optional = false]) => ({
+ directory, extension, updater, optional,
+ name: `DEEIX-Chat-${version}-${platform}${suffix}`,
+ }));
+}
+
+export function requireFile(path) {
+ if (!existsSync(path) || !statSync(path).isFile() || statSync(path).size === 0) {
+ throw new Error(`missing or empty release asset: ${path}`);
+ }
+}
+
+export function collectReleaseAssets({ platform, version, bundle, out }) {
+ const plan = [];
+ for (const spec of releaseAssets(platform, version)) {
+ const directory = join(bundle, spec.directory);
+ const matches = existsSync(directory)
+ ? readdirSync(directory).filter((name) => name.endsWith(spec.extension)) : [];
+ if (spec.optional && matches.length === 0) continue;
+ if (matches.length !== 1) throw new Error(`expected one ${spec.extension} in ${directory}, found ${matches.length}`);
+ const source = matches[0];
+ // The updater archive is the only Tauri filename without a version.
+ if (spec.extension !== ".app.tar.gz" && source !== spec.name && !source.includes(`_${version}_`)) {
+ throw new Error(`asset ${source} does not match version ${version}`);
+ }
+ plan.push({ from: join(directory, source), to: join(out, spec.name) });
+ if (spec.updater.length) plan.push({ from: join(directory, `${source}.sig`), to: join(out, `${spec.name}.sig`) });
+ }
+ // Validate the whole target before copying any of it.
+ for (const { from, to } of plan) {
+ requireFile(from);
+ if (existsSync(to)) throw new Error(`release asset already staged: ${to}`);
+ }
+ mkdirSync(out, { recursive: true });
+ for (const { from, to } of plan) copyFileSync(from, to);
+ return plan.map(({ to }) => to);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ try {
+ const args = process.argv.slice(2);
+ const value = (flag) => args[args.indexOf(flag) + 1];
+ for (const flag of ["--platform", "--bundle", "--out"]) {
+ if (!args.includes(flag) || !value(flag)) throw new Error(`missing ${flag}`);
+ }
+ const here = dirname(fileURLToPath(import.meta.url));
+ const version = readFileSync(resolve(here, "../../../VERSION"), "utf8").trim();
+ const files = collectReleaseAssets({ platform: value("--platform"), version, bundle: value("--bundle"), out: value("--out") });
+ console.log(files.join("\n"));
+ } catch (error) {
+ console.error(error.message);
+ process.exitCode = 1;
+ }
+}
diff --git a/apps/desktop/scripts/configure-apple-signing.sh b/apps/desktop/scripts/configure-apple-signing.sh
new file mode 100644
index 000000000..29b0eef00
--- /dev/null
+++ b/apps/desktop/scripts/configure-apple-signing.sh
@@ -0,0 +1,48 @@
+#!/usr/bin/env bash
+# Select one signing provider and export only the credentials Tauri should use.
+set -euo pipefail
+
+: "${GITHUB_ENV:?GITHUB_ENV is required}"
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+
+remote_count=0
+for name in SIGN_AND_NOTARIZE_SCRIPT_URL SIGN_AND_NOTARIZE_BASE_URL SIGN_AND_NOTARIZE_API_TOKEN SIGN_AND_NOTARIZE_HELPER_URL; do
+ [[ -z "${!name:-}" ]] || remote_count=$((remote_count + 1))
+done
+if ((remote_count > 0 && remote_count < 4)); then
+ echo "::error::External signing is partly configured; set all four SIGN_AND_NOTARIZE_* secrets."
+ exit 1
+fi
+if ((remote_count == 4)); then
+ # The remote service signs the final app. Tauri only supplies an ad-hoc app,
+ # even when local Apple credentials also exist, so it does not notarize twice.
+ printf '%s\n' 'DESKTOP_SIGNING_MODE=external' 'APPLE_SIGNING_IDENTITY=-' >> "$GITHUB_ENV"
+ exit 0
+fi
+
+present=0
+missing=()
+for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
+ if [[ -n "${!name:-}" ]]; then
+ present=$((present + 1))
+ elif [[ "$name" != APPLE_CERTIFICATE_PASSWORD ]]; then
+ missing+=("$name")
+ fi
+done
+if ((present == 0)); then
+ printf '%s\n' 'DESKTOP_SIGNING_MODE=adhoc' 'APPLE_SIGNING_IDENTITY=-' >> "$GITHUB_ENV"
+ echo "::warning::No Apple signing provider configured; building an ad-hoc signed app."
+ exit 0
+fi
+if ((${#missing[@]} > 0)); then
+ echo "::error::Apple signing is partly configured; missing ${missing[*]}."
+ exit 1
+fi
+
+echo 'DESKTOP_SIGNING_MODE=apple' >> "$GITHUB_ENV"
+for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_ID APPLE_PASSWORD; do
+ delimiter=__DESKTOP_SIGNING__
+ while [[ "${!name:-}" == *"$delimiter"* ]]; do delimiter+=_; done
+ printf '%s<<%s\n%s\n%s\n' "$name" "$delimiter" "${!name:-}" "$delimiter" >> "$GITHUB_ENV"
+done
+bash "$script_dir/apple-signing-env.sh" >> "$GITHUB_ENV"
diff --git a/apps/desktop/scripts/desktop-provenance.mjs b/apps/desktop/scripts/desktop-provenance.mjs
new file mode 100644
index 000000000..3801a8e23
--- /dev/null
+++ b/apps/desktop/scripts/desktop-provenance.mjs
@@ -0,0 +1,49 @@
+// GitHub's automatic predicate records the workflow commit. Releases execute
+// on the default branch but check out a tag: retain both commits as inputs.
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { pathToFileURL } from "node:url";
+
+export function desktopProvenance({ env, sourceSha, releaseTag = "" }) {
+ for (const sha of [sourceSha, env.GITHUB_SHA]) {
+ if (!/^[0-9a-f]{40}$/.test(sha ?? "")) throw new Error("provenance requires immutable source and workflow commits");
+ }
+ const repository = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`;
+ const prefix = `${env.GITHUB_REPOSITORY}/`;
+ const suffix = `@${env.GITHUB_REF}`;
+ const workflowRef = env.GITHUB_WORKFLOW_REF;
+ if (!workflowRef?.startsWith(prefix) || !workflowRef.endsWith(suffix)) throw new Error("invalid workflow reference");
+ return {
+ buildDefinition: {
+ buildType: "https://actions.github.io/buildtypes/workflow/v1",
+ externalParameters: {
+ workflow: { repository, ref: env.GITHUB_REF, path: workflowRef.slice(prefix.length, -suffix.length) },
+ },
+ internalParameters: {
+ github: {
+ event_name: env.GITHUB_EVENT_NAME,
+ repository_id: env.GITHUB_REPOSITORY_ID,
+ repository_owner_id: env.GITHUB_REPOSITORY_OWNER_ID,
+ runner_environment: env.RUNNER_ENVIRONMENT,
+ },
+ },
+ resolvedDependencies: [
+ { name: "workflow", uri: `git+${repository}@${env.GITHUB_REF}`, digest: { gitCommit: env.GITHUB_SHA } },
+ { name: "desktop-source", uri: `git+${repository}@${releaseTag ? `refs/tags/${releaseTag}` : sourceSha}`, digest: { gitCommit: sourceSha } },
+ ],
+ },
+ runDetails: {
+ // This reusable workflow is called locally from desktop-release.yml, so
+ // its definition is at the same ref as the calling workflow.
+ builder: { id: `${repository}/.github/workflows/desktop-windows.yml@${env.GITHUB_REF}` },
+ metadata: { invocationId: `${repository}/actions/runs/${env.GITHUB_RUN_ID}/attempts/${env.GITHUB_RUN_ATTEMPT}` },
+ },
+ };
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
+ if (sourceSha !== process.env.SOURCE_SHA) throw new Error("checkout does not match the requested source commit");
+ const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
+ console.log(JSON.stringify(desktopProvenance({ env: process.env, sourceSha, releaseTag: event.inputs?.release_tag })));
+}
diff --git a/apps/desktop/scripts/desktop-provenance.test.mjs b/apps/desktop/scripts/desktop-provenance.test.mjs
new file mode 100644
index 000000000..65fa0af25
--- /dev/null
+++ b/apps/desktop/scripts/desktop-provenance.test.mjs
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { desktopProvenance } from "./desktop-provenance.mjs";
+
+test("provenance distinguishes the release source from the default-branch workflow", () => {
+ const env = {
+ GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "o/r",
+ GITHUB_SHA: "a".repeat(40), GITHUB_REF: "refs/heads/release@next",
+ GITHUB_WORKFLOW_REF: "o/r/.github/workflows/desktop-release.yml@refs/heads/release@next",
+ GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REPOSITORY_ID: "123", GITHUB_REPOSITORY_OWNER_ID: "456",
+ RUNNER_ENVIRONMENT: "github-hosted", GITHUB_RUN_ID: "789", GITHUB_RUN_ATTEMPT: "2",
+ };
+ const sourceSha = "b".repeat(40);
+ const predicate = desktopProvenance({ env, sourceSha, releaseTag: "v1.2.3" });
+ assert.deepEqual(predicate.buildDefinition.resolvedDependencies, [
+ { name: "workflow", uri: "git+https://github.com/o/r@refs/heads/release@next", digest: { gitCommit: env.GITHUB_SHA } },
+ { name: "desktop-source", uri: "git+https://github.com/o/r@refs/tags/v1.2.3", digest: { gitCommit: sourceSha } },
+ ]);
+ assert.equal(predicate.buildDefinition.externalParameters.workflow.path, ".github/workflows/desktop-release.yml");
+ assert.equal(predicate.runDetails.builder.id, "https://github.com/o/r/.github/workflows/desktop-windows.yml@refs/heads/release@next");
+ assert.equal(predicate.runDetails.metadata.invocationId, "https://github.com/o/r/actions/runs/789/attempts/2");
+ const preview = desktopProvenance({ env, sourceSha });
+ assert.equal(preview.buildDefinition.resolvedDependencies[1].uri, `git+https://github.com/o/r@${sourceSha}`);
+ assert.throws(() => desktopProvenance({ env, sourceSha: "dev" }), /immutable/);
+});
diff --git a/apps/desktop/scripts/package-macos.sh b/apps/desktop/scripts/package-macos.sh
new file mode 100644
index 000000000..2e812b1af
--- /dev/null
+++ b/apps/desktop/scripts/package-macos.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# CI packaging phases, each exposed as a separate workflow step. The app is
+# signed once; both the DMG and updater are made from that same final app.
+# Usage: package-macos.sh sign-app|dmg|sign-dmg|updater|verify [src-tauri]
+set -euo pipefail
+
+phase="${1:?a packaging phase is required}"
+target="${2:?a Rust target triple is required}"
+script_dir="$(cd "$(dirname "$0")" && pwd)"
+tauri_dir="${3:-$script_dir/../src-tauri}"
+tauri_dir="$(cd "$tauri_dir" && pwd)"
+case "$target" in
+ aarch64-apple-darwin) arch=arm64; dmg_arch=aarch64; binary_arch=arm64 ;;
+ x86_64-apple-darwin) arch=x64; dmg_arch=x64; binary_arch=x86_64 ;;
+ *) echo "unsupported macOS target: $target" >&2; exit 1 ;;
+esac
+product="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).productName' "$tauri_dir/tauri.conf.json")"
+version="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).version' "$tauri_dir/tauri.conf.json")"
+bundle="$tauri_dir/target/$target/release/bundle"
+app="$bundle/macos/$product.app"
+dmg="$bundle/dmg/${product}_${version}_${dmg_arch}.dmg"
+archive="$bundle/macos/$product.app.tar.gz"
+release_stem="DEEIX-Chat-$version-macos-$arch"
+mode="${DESKTOP_SIGNING_MODE:-adhoc}"
+workdir=""
+keychain=""
+cleanup() {
+ [[ -z "$keychain" ]] || security delete-keychain "$keychain" || true
+ [[ -z "$workdir" ]] || rm -rf "$workdir"
+}
+trap cleanup EXIT
+
+fail() { echo "package-macos: $*" >&2; exit 1; }
+make_workdir() { workdir="$(mktemp -d "$bundle/.package-macos.XXXXXX")"; }
+
+sign_external() {
+ # Fetch once per job so app and DMG use exactly the same helper revision.
+ local helper="${RUNNER_TEMP:?}/desktop-signing-$target.sh"
+ if [[ ! -s "$helper" ]]; then
+ curl -fsSL --connect-timeout 20 --max-time 120 --retry 2 -- "$SIGN_AND_NOTARIZE_SCRIPT_URL" > "$helper"
+ [[ -s "$helper" ]] || fail "the signing helper is empty"
+ fi
+ API_BASE_URL="$SIGN_AND_NOTARIZE_BASE_URL" \
+ API_TOKEN="$SIGN_AND_NOTARIZE_API_TOKEN" \
+ RESULT_HELPER_URL="$SIGN_AND_NOTARIZE_HELPER_URL" \
+ bash -c "$(cat "$helper")" -- "$1" "$2"
+}
+
+case "$phase" in
+ sign-app)
+ [[ "$mode" == external ]] || fail "sign-app requires the external signing provider"
+ [[ -d "$app" ]] || fail "missing $app"
+ make_workdir
+ ditto "$app" "$workdir/$release_stem.app"
+ mkdir -p "$workdir/signed"
+ signed_app="$workdir/signed/$product.app"
+ sign_external "$workdir/$release_stem.app" "$signed_app"
+ [[ -d "$signed_app" ]] || fail "the signing helper did not produce an app"
+ codesign --verify --deep --strict "$signed_app"
+ team="$(codesign -dv "$signed_app" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
+ [[ "$team" =~ ^[A-Z0-9]{10}$ ]] || fail "the signing service returned an app without a Developer ID team"
+ # verify-macos-bundle requires this same team and a stapled notarization
+ # ticket on both containers, including the external signing path.
+ printf 'APPLE_TEAM_ID=%s\n' "$team" >> "${GITHUB_ENV:?}"
+ rm -rf "$app"
+ ditto "$signed_app" "$app"
+ ;;
+ dmg)
+ [[ -d "$app" ]] || fail "missing $app"
+ make_workdir
+ stage="$workdir/image"
+ mkdir -p "$stage" "$bundle/dmg"
+ ditto "$app" "$stage/$product.app"
+ ln -s /Applications "$stage/Applications"
+ if [[ -f "$tauri_dir/icons/icon.icns" ]]; then
+ cp "$tauri_dir/icons/icon.icns" "$stage/.VolumeIcon.icns"
+ if command -v SetFile >/dev/null; then SetFile -a C "$stage"; fi
+ fi
+ # Encode directly to the final format, avoiding Tauri's zlib DMG followed
+ # by a writable conversion, app replacement and a second compression.
+ hdiutil create -volname "$product" -srcfolder "$stage" -fs HFS+ -format ULFO -ov "$dmg"
+ ;;
+ sign-dmg)
+ [[ -s "$dmg" ]] || fail "missing $dmg"
+ if [[ "$mode" == external ]]; then
+ make_workdir
+ cp "$dmg" "$workdir/$release_stem.dmg"
+ sign_external "$workdir/$release_stem.dmg" "$workdir/signed.dmg"
+ [[ -s "$workdir/signed.dmg" ]] || fail "the signing helper did not produce a DMG"
+ mv -f "$workdir/signed.dmg" "$dmg"
+ elif [[ "$mode" == apple ]]; then
+ # Tauri deletes its temporary keychain when app bundling exits. Import
+ # the certificate for this phase without changing the user's search list.
+ make_workdir
+ keychain="$workdir/dmg-signing.keychain-db"
+ password="$(openssl rand -hex 24)"
+ printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$workdir/certificate.p12"
+ security create-keychain -p "$password" "$keychain"
+ security unlock-keychain -p "$password" "$keychain"
+ security import "$workdir/certificate.p12" -k "$keychain" -P "${APPLE_CERTIFICATE_PASSWORD:-}" -T /usr/bin/codesign
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" >/dev/null
+ codesign --force --sign "$APPLE_SIGNING_IDENTITY" --keychain "$keychain" --timestamp "$dmg"
+ xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
+ xcrun stapler staple "$dmg"
+ else
+ fail "sign-dmg requires an Apple signing provider"
+ fi
+ ;;
+ updater)
+ [[ -d "$app" ]] || fail "missing $app"
+ [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]] || fail "updater signing key is required"
+ export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-}"
+ tar -czf "$archive" -C "$bundle/macos" "$product.app"
+ pnpm --dir "$tauri_dir/.." exec tauri signer sign --app-version "$version" "$archive"
+ [[ -s "$archive.sig" ]] || fail "the updater signature was not created"
+ ;;
+ verify)
+ bash "$script_dir/verify-macos-bundle.sh" --arch "$binary_arch" "$dmg" "$archive"
+ ;;
+ *) fail "unknown packaging phase: $phase" ;;
+esac
diff --git a/apps/desktop/scripts/package-macos.test.mjs b/apps/desktop/scripts/package-macos.test.mjs
new file mode 100644
index 000000000..06fe1d0ce
--- /dev/null
+++ b/apps/desktop/scripts/package-macos.test.mjs
@@ -0,0 +1,178 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const scripts = dirname(fileURLToPath(import.meta.url));
+// These shell integration tests run on the Linux desktop CI job and on macOS.
+const test = process.platform === "win32" ? nodeTest.skip : nodeTest;
+const target = "aarch64-apple-darwin";
+const product = "DEEIX Chat";
+const version = "1.2.3-beta.4";
+const apple = { APPLE_CERTIFICATE: "Y2VydGlmaWNhdGU=", APPLE_CERTIFICATE_PASSWORD: "", APPLE_ID: "test@example.invalid", APPLE_PASSWORD: "test-password" };
+const remote = {
+ SIGN_AND_NOTARIZE_SCRIPT_URL: "https://example.invalid/sign.sh", SIGN_AND_NOTARIZE_BASE_URL: "https://example.invalid",
+ SIGN_AND_NOTARIZE_API_TOKEN: "test-token", SIGN_AND_NOTARIZE_HELPER_URL: "https://example.invalid/result.sh",
+};
+
+function fixture(t) {
+ const root = mkdtempSync(join(tmpdir(), "macos-packaging-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const bin = join(root, "bin");
+ const tauri = join(root, "desktop project", "src-tauri");
+ const bundle = join(tauri, "target", target, "release", "bundle");
+ const app = join(bundle, "macos", `${product}.app`);
+ const dmg = join(bundle, "dmg", `${product}_${version}_aarch64.dmg`);
+ const archive = `${app}.tar.gz`;
+ const image = join(root, "dmg contents");
+ const log = join(root, "calls.jsonl");
+ const githubEnv = join(root, "github-env");
+ mkdirSync(bin);
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
+ writeFileSync(join(tauri, "tauri.conf.json"), JSON.stringify({ productName: product, version }));
+ writeFileSync(join(app, "Contents", "MacOS", "app"), "unsigned");
+ chmodSync(join(app, "Contents", "MacOS", "app"), 0o755);
+ symlinkSync("app", join(app, "Contents", "MacOS", "alias"));
+ writeFileSync(githubEnv, "");
+ const helper = `set -euo pipefail
+printf '%s\\n' "$(basename "$1")" >> "$TEST_SIGN_REQUESTS"
+if [[ -d "$1" ]]; then
+ ditto "$1" "$2"
+ printf 'signed-app' > "$2/Contents/MacOS/app"
+else
+ cp "$1" "$2"
+ printf ':signed-dmg' >> "$2"
+fi
+`;
+ const helperPath = join(root, "remote-helper.sh");
+ writeFileSync(helperPath, helper);
+ const stub = `#!${process.execPath}
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const tool = path.basename(process.argv[1]);
+const args = process.argv.slice(2);
+const env = process.env;
+fs.appendFileSync(env.TEST_LOG, JSON.stringify({ tool, args }) + '\\n');
+switch (tool) {
+ case 'curl': process.stdout.write(fs.readFileSync(env.TEST_HELPER)); break;
+ case 'ditto': fs.cpSync(args[0], args[1], { recursive: true, verbatimSymlinks: true }); break;
+ case 'codesign':
+ if (args.includes('-dv')) console.error('TeamIdentifier=ABCDE12345');
+ if (args.includes('--verify') && env.TEST_BAD_SIGNATURE) process.exit(1);
+ if (args.includes('--keychain') && !fs.existsSync(args[args.indexOf('--keychain') + 1])) process.exit(2);
+ break;
+ case 'hdiutil':
+ if (args[0] !== 'create') throw new Error('unexpected extra DMG conversion');
+ fs.cpSync(args[args.indexOf('-srcfolder') + 1], env.TEST_IMAGE, { recursive: true, verbatimSymlinks: true });
+ fs.writeFileSync(args.at(-1), 'dmg');
+ break;
+ case 'pnpm':
+ if (!env.TAURI_SIGNING_PRIVATE_KEY) throw new Error('updater key missing');
+ fs.writeFileSync(args.at(-1) + '.sig', crypto.createHash('sha256').update(fs.readFileSync(args.at(-1))).digest('hex'));
+ break;
+ case 'openssl':
+ if (args[0] === 'rand') console.log('0123456789abcdef');
+ else if (args[0] === 'pkcs12') console.log('certificate');
+ else console.log(' CN=Developer ID Application: Test (ABCDE12345)\\n OU=ABCDE12345');
+ break;
+ case 'security':
+ if (args[0] === 'create-keychain') fs.writeFileSync(args.at(-1), 'keychain');
+ if (args[0] === 'delete-keychain') fs.rmSync(args.at(-1));
+ break;
+ case 'xcrun': if (env.TEST_NOTARY_FAILURE) process.exit(1); break;
+ default: throw new Error('unexpected tool: ' + tool);
+}
+`;
+ for (const name of ["curl", "ditto", "codesign", "hdiutil", "pnpm", "openssl", "security", "xcrun"]) {
+ writeFileSync(join(bin, name), stub, { mode: 0o755 });
+ }
+ const env = {
+ ...process.env,
+ ...Object.fromEntries([...Object.keys(apple), ...Object.keys(remote), "APPLE_TEAM_ID", "APPLE_CERTIFICATE_PATH", "TAURI_SIGNING_PRIVATE_KEY"].map((name) => [name, ""])),
+ PATH: `${bin}:${process.env.PATH}`, GITHUB_ENV: githubEnv, RUNNER_TEMP: root,
+ TEST_HELPER: helperPath, TEST_LOG: log, TEST_IMAGE: image, TEST_SIGN_REQUESTS: join(root, "requests"),
+ };
+ const configure = (extra = {}) => spawnSync("bash", [join(scripts, "configure-apple-signing.sh")], { env: { ...env, ...extra }, encoding: "utf8" });
+ const phase = (name, extra = {}) => spawnSync("bash", [join(scripts, "package-macos.sh"), name, target, tauri], { env: { ...env, ...extra }, encoding: "utf8" });
+ const calls = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
+ return { root, app, dmg, archive, bundle, githubEnv, env, image, configure, phase, calls };
+}
+
+function passed(result) {
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+}
+
+test("signing configuration selects one provider and rejects partial credentials", (t) => {
+ const f = fixture(t);
+ passed(f.configure());
+ assert.match(readFileSync(f.githubEnv, "utf8"), /DESKTOP_SIGNING_MODE=adhoc/);
+ writeFileSync(f.githubEnv, "");
+ passed(f.configure({ ...apple, ...remote }));
+ assert.equal(readFileSync(f.githubEnv, "utf8"), "DESKTOP_SIGNING_MODE=external\nAPPLE_SIGNING_IDENTITY=-\n");
+ assert.equal(f.calls().length, 0, "external signing must not import or inspect the Apple certificate");
+ assert.notEqual(f.configure({ SIGN_AND_NOTARIZE_SCRIPT_URL: remote.SIGN_AND_NOTARIZE_SCRIPT_URL }).status, 0);
+ assert.notEqual(f.configure({ APPLE_ID: apple.APPLE_ID }).status, 0);
+ writeFileSync(f.githubEnv, "");
+ passed(f.configure(apple));
+ const exported = readFileSync(f.githubEnv, "utf8");
+ assert.match(exported, /DESKTOP_SIGNING_MODE=apple/);
+ assert.match(exported, /APPLE_SIGNING_IDENTITY=Developer ID Application: Test \(ABCDE12345\)/);
+ assert.match(exported, /APPLE_TEAM_ID=ABCDE12345/);
+});
+
+test("DMG and updater contain the same final signed app with its executable mode and symlink", (t) => {
+ const f = fixture(t);
+ const env = { ...remote, DESKTOP_SIGNING_MODE: "external", TAURI_SIGNING_PRIVATE_KEY: "test-updater-key" };
+ for (const phase of ["sign-app", "dmg", "sign-dmg", "updater"]) passed(f.phase(phase, env));
+ const unpacked = join(f.root, "updater contents");
+ mkdirSync(unpacked);
+ execFileSync("tar", ["-xzf", f.archive, "-C", unpacked]);
+ for (const app of [f.app, join(f.image, `${product}.app`), join(unpacked, `${product}.app`)]) {
+ assert.equal(readFileSync(join(app, "Contents", "MacOS", "app"), "utf8"), "signed-app");
+ assert.equal(readlinkSync(join(app, "Contents", "MacOS", "alias")), "app");
+ execFileSync("test", ["-x", join(app, "Contents", "MacOS", "app")]);
+ }
+ assert.equal(readlinkSync(join(f.image, "Applications")), "/Applications");
+ assert.equal(readFileSync(f.dmg, "utf8"), "dmg:signed-dmg");
+ assert.equal(existsSync(`${f.archive}.sig`), true);
+ assert.match(readFileSync(f.githubEnv, "utf8"), /APPLE_TEAM_ID=ABCDE12345/);
+ assert.equal(f.calls().filter(({ tool }) => tool === "curl").length, 1);
+ const images = f.calls().filter(({ tool }) => tool === "hdiutil");
+ assert.equal(images.length, 1);
+ assert.equal(images[0].args[images[0].args.indexOf("-format") + 1], "ULFO");
+ const signer = f.calls().find(({ tool }) => tool === "pnpm");
+ assert.deepEqual(signer.args.slice(2), ["exec", "tauri", "signer", "sign", "--app-version", version, f.archive]);
+ assert.deepEqual(readFileSync(f.env.TEST_SIGN_REQUESTS, "utf8").trim().split("\n"), [
+ `DEEIX-Chat-${version}-macos-arm64.app`, `DEEIX-Chat-${version}-macos-arm64.dmg`,
+ ]);
+ assert.equal(readdirSync(f.bundle).some((name) => name.startsWith(".package-macos.")), false);
+});
+
+test("an invalid service signature never replaces the app and a missing updater key fails", (t) => {
+ const f = fixture(t);
+ assert.notEqual(f.phase("sign-app", { ...remote, DESKTOP_SIGNING_MODE: "external", TEST_BAD_SIGNATURE: "1" }).status, 0);
+ assert.equal(readFileSync(join(f.app, "Contents", "MacOS", "app"), "utf8"), "unsigned");
+ assert.notEqual(f.phase("updater").status, 0);
+ assert.equal(existsSync(f.archive), false);
+});
+
+test("local Apple DMG signing imports a fresh keychain and cleans it up on notarization failure", (t) => {
+ const f = fixture(t);
+ passed(f.phase("dmg"));
+ const result = f.phase("sign-dmg", {
+ ...apple, DESKTOP_SIGNING_MODE: "apple", APPLE_SIGNING_IDENTITY: "Developer ID Application: Test (ABCDE12345)",
+ APPLE_TEAM_ID: "ABCDE12345", TEST_NOTARY_FAILURE: "1",
+ });
+ assert.notEqual(result.status, 0);
+ const calls = f.calls();
+ const sign = calls.find(({ tool, args }) => tool === "codesign" && args.includes("--keychain"));
+ assert.ok(sign);
+ const keychain = sign.args[sign.args.indexOf("--keychain") + 1];
+ assert.ok(calls.some(({ tool, args }) => tool === "security" && args[0] === "delete-keychain" && args[1] === keychain));
+ assert.equal(existsSync(keychain), false);
+ assert.equal(readdirSync(f.bundle).some((name) => name.startsWith(".package-macos.")), false);
+});
diff --git a/apps/desktop/scripts/publish-desktop-release.mjs b/apps/desktop/scripts/publish-desktop-release.mjs
new file mode 100644
index 000000000..460ce1b8b
--- /dev/null
+++ b/apps/desktop/scripts/publish-desktop-release.mjs
@@ -0,0 +1,120 @@
+// Assemble all four verified targets, then upload once into one draft. The
+// updater keys and public filenames match the previous tauri-action releases.
+import { execFileSync } from "node:child_process";
+import { appendFileSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { pathToFileURL } from "node:url";
+
+import { releaseAssets, releasePlatforms, requireFile } from "./collect-release-assets.mjs";
+import { formatChecksums, hashFile } from "./release-checksums.mjs";
+
+export function releaseManifest({ version, tag, repository, directory, date = new Date().toISOString() }) {
+ if (tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`);
+ if (!/^[\w.-]+\/[\w.-]+$/.test(repository ?? "")) throw new Error("expected repository owner/name");
+ const platforms = {};
+ const files = [];
+ const present = new Set(readdirSync(directory));
+ for (const platform of releasePlatforms) {
+ for (const spec of releaseAssets(platform, version)) {
+ if (spec.optional && !present.has(spec.name)) continue;
+ requireFile(join(directory, spec.name));
+ files.push(spec.name);
+ if (!spec.updater.length) continue;
+ const signatureName = `${spec.name}.sig`;
+ requireFile(join(directory, signatureName));
+ const signature = readFileSync(join(directory, signatureName), "utf8").trim();
+ if (!signature) throw new Error(`empty updater signature: ${signatureName}`);
+ files.push(signatureName);
+ const entry = {
+ signature,
+ url: `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(spec.name)}`,
+ };
+ for (const key of spec.updater) platforms[key] = entry;
+ }
+ }
+ const allowed = new Set([...files, "latest.json", "SHA256SUMS"]);
+ for (const name of present) {
+ if (!allowed.has(name)) throw new Error(`unexpected release asset: ${name}`);
+ }
+ return { manifest: { version, notes: "", pub_date: date, platforms }, files: files.sort() };
+}
+
+export async function assembleRelease(options) {
+ const { manifest, files } = releaseManifest(options);
+ writeFileSync(join(options.directory, "latest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
+ files.push("latest.json");
+ const entries = await Promise.all(files.map(async (name) => ({ name, sha256: await hashFile(join(options.directory, name)) })));
+ writeFileSync(join(options.directory, "SHA256SUMS"), formatChecksums(entries));
+ return [...files, "SHA256SUMS"];
+}
+
+export function existingDraft(releases, tag) {
+ const matches = releases.filter((release) => release.tag_name === tag);
+ if (matches.length > 1) throw new Error(`${tag} has multiple releases; remove the duplicate drafts before retrying`);
+ if (matches[0] && !matches[0].draft) throw new Error(`${tag} is already published; refusing to replace its assets`);
+ return matches[0];
+}
+
+function releaseNotes(version) {
+ const stem = `DEEIX-Chat-${version}`;
+ return `### ⏬ Download
+
+| Platform | Download |
+| --- | --- |
+| macOS · Apple silicon | \`${stem}-macos-arm64.dmg\` |
+| macOS · Intel | \`${stem}-macos-x64.dmg\` |
+| Linux · x86_64 | \`${stem}-linux-x64.AppImage\` or \`.deb\` |
+| Windows · x86_64 | \`${stem}-windows-x64-setup.exe\` |
+| Windows · x86_64 · MSI | \`${stem}-windows-x64.msi\` |
+| Windows · x86_64 · Portable | \`${stem}-windows-x64-portable.zip\` |
+
+Checksums are in \`SHA256SUMS\`. \`-updater.tar.gz\`, \`.sig\` and \`latest.json\` belong to the built-in updater.
+`;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const value = (flag) => args[args.indexOf(flag) + 1];
+ for (const flag of ["--tag", "--sha", "--dir"]) {
+ if (!args.includes(flag) || !value(flag)) throw new Error(`missing ${flag}`);
+ }
+ const tag = value("--tag");
+ const sha = value("--sha");
+ if (!/^[0-9a-f]{40}$/.test(sha)) throw new Error("--sha must be an immutable Git commit");
+ const repository = process.env.GH_REPO;
+ const directory = value("--dir");
+ const version = readFileSync("VERSION", "utf8").trim();
+ const files = await assembleRelease({ version, tag, repository, directory });
+ // A dry run exercises manifest/checksum generation without a GitHub write.
+ if (args.includes("--dry-run")) {
+ console.log(files.join("\n"));
+ return;
+ }
+ const gh = (args, input) => execFileSync("gh", args, { encoding: "utf8", input, maxBuffer: 16 * 1024 * 1024 });
+ const commit = JSON.parse(gh(["api", `repos/${repository}/commits/${encodeURIComponent(tag)}`]));
+ if (commit.sha !== sha) throw new Error(`${tag} moved since the build started (${sha} -> ${commit.sha})`);
+ const pages = JSON.parse(gh(["api", `repos/${repository}/releases?per_page=100`, "--paginate", "--slurp"]));
+ let release = existingDraft(pages.flat(), tag);
+ const unexpected = (release?.assets ?? []).filter(({ name }) => !files.includes(name));
+ if (unexpected.length) {
+ throw new Error(`draft contains assets outside this build; remove or reconcile them before retrying: ${unexpected.map(({ name }) => name).join(", ")}`);
+ }
+ if (!release) {
+ release = JSON.parse(gh(["api", "--method", "POST", `repos/${repository}/releases`, "--input", "-"], JSON.stringify({
+ tag_name: tag, target_commitish: sha, name: tag, draft: true,
+ prerelease: version.includes("-"), body: releaseNotes(version),
+ })));
+ }
+ gh(["release", "upload", tag, ...files.map((name) => join(directory, name)), "--repo", repository, "--clobber"]);
+ console.log(`Draft release ready: ${release.html_url}`);
+ if (process.env.GITHUB_STEP_SUMMARY) {
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Draft release: ${release.html_url}\n\nBuilt from ${sha}; uploaded ${files.length} files.\n`);
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/apps/desktop/scripts/release-assets.test.mjs b/apps/desktop/scripts/release-assets.test.mjs
new file mode 100644
index 000000000..beab0db8c
--- /dev/null
+++ b/apps/desktop/scripts/release-assets.test.mjs
@@ -0,0 +1,202 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+import { collectReleaseAssets } from "./collect-release-assets.mjs";
+import { assembleRelease, existingDraft, releaseManifest } from "./publish-desktop-release.mjs";
+
+const script = join(dirname(fileURLToPath(import.meta.url)), "publish-desktop-release.mjs");
+const version = "1.2.3-beta.4";
+const tag = `v${version}`;
+const repository = "DEEIX-AI/DEEIX-Chat";
+const sha = "a".repeat(40);
+const prefix = `DEEIX-Chat-${version}`;
+
+// Examples of the actual Tauri/portable filenames, independent of the staging
+// implementation. The manifest must retain the keys used by existing installs.
+const bundles = {
+ "macos-arm64": [
+ [`dmg/DEEIX Chat_${version}_aarch64.dmg`, "macos-arm64.dmg"],
+ ["macos/DEEIX Chat.app.tar.gz", "macos-arm64-updater.tar.gz", ["darwin-aarch64", "darwin-aarch64-app"]],
+ ],
+ "macos-x64": [
+ [`dmg/DEEIX Chat_${version}_x64.dmg`, "macos-x64.dmg"],
+ ["macos/DEEIX Chat.app.tar.gz", "macos-x64-updater.tar.gz", ["darwin-x86_64", "darwin-x86_64-app"]],
+ ],
+ "linux-x64": [
+ [`deb/DEEIX Chat_${version}_amd64.deb`, "linux-x64.deb", ["linux-x86_64-deb"]],
+ [`appimage/DEEIX Chat_${version}_amd64.AppImage`, "linux-x64.AppImage", ["linux-x86_64", "linux-x86_64-appimage"]],
+ ],
+ "windows-x64": [
+ [`nsis/DEEIX Chat_${version}_x64-setup.exe`, "windows-x64-setup.exe", ["windows-x86_64", "windows-x86_64-nsis"]],
+ [`msi/DEEIX Chat_${version}_x64_en-US.msi`, "windows-x64.msi", ["windows-x86_64-msi"]],
+ [`portable/${prefix}-windows-x64-portable.zip`, "windows-x64-portable.zip"],
+ ],
+};
+
+function fixture(t, { offline = false } = {}) {
+ const root = mkdtempSync(join(tmpdir(), "desktop-assets-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const out = join(root, "release");
+ const write = (path, bytes) => {
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(path, bytes);
+ };
+ for (const [platform, entries] of Object.entries(bundles)) {
+ for (const [source, final, updater] of entries) {
+ write(join(root, platform, source), `bytes:${final}`);
+ if (updater) write(join(root, platform, `${source}.sig`), `signature:${final}\n`);
+ }
+ }
+ if (offline) write(join(root, "windows-x64", "portable", `${prefix}-windows-x64-portable-offline.zip`), "offline runtime");
+ const collect = (platform) => collectReleaseAssets({ platform, version, bundle: join(root, platform), out });
+ const collectAll = () => Object.keys(bundles).forEach(collect);
+ return { root, out, write, collect, collectAll, options: { version, tag, repository, directory: out } };
+}
+
+test("stages all targets without changing signed bytes and preserves the installed updater keys", (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ const { manifest, files } = releaseManifest(f.options);
+ const expectedKeys = [];
+ for (const entries of Object.values(bundles)) {
+ for (const [, final, updater] of entries) {
+ const name = `${prefix}-${final}`;
+ assert.equal(readFileSync(join(f.out, name), "utf8"), `bytes:${final}`);
+ for (const key of updater ?? []) {
+ expectedKeys.push(key);
+ assert.deepEqual(manifest.platforms[key], {
+ signature: `signature:${final}`,
+ url: `https://github.com/${repository}/releases/download/${tag}/${name}`,
+ });
+ }
+ }
+ }
+ assert.deepEqual(Object.keys(manifest.platforms).sort(), expectedKeys.sort());
+ assert.equal(manifest.platforms["windows-x86_64"].url.endsWith("-setup.exe"), true);
+ assert.equal(manifest.version, version);
+ assert.equal(files.length, 15);
+});
+
+test("hashes every final download, signature and manifest, including the optional offline portable", async (t) => {
+ const f = fixture(t, { offline: true });
+ f.collectAll();
+ const files = await assembleRelease(f.options);
+ assert.equal(files.length, 18);
+ const lines = readFileSync(join(f.out, "SHA256SUMS"), "utf8").trim().split("\n");
+ const hashes = new Map(lines.map((line) => [line.slice(66), line.slice(0, 64)]));
+ assert.deepEqual([...hashes.keys()].sort(), files.filter((name) => name !== "SHA256SUMS").sort());
+ for (const [name, hash] of hashes) {
+ assert.equal(hash, createHash("sha256").update(readFileSync(join(f.out, name))).digest("hex"));
+ }
+ // A retry can rebuild generated metadata without treating it as an extra asset.
+ assert.deepEqual(await assembleRelease(f.options), files);
+});
+
+test("a missing updater signature prevents staging any files for that platform", (t) => {
+ const f = fixture(t);
+ rmSync(join(f.root, "linux-x64", `${bundles["linux-x64"][1][0]}.sig`));
+ assert.throws(() => f.collect("linux-x64"), /missing or empty/);
+ assert.equal(existsSync(f.out), false);
+});
+
+test("rejects stale versions and multiple bundles instead of guessing which installer to ship", (t) => {
+ const f = fixture(t);
+ const directory = join(f.root, "macos-arm64", "dmg");
+ f.write(join(directory, "DEEIX Chat_0.0.1_aarch64.dmg"), "stale");
+ assert.throws(() => f.collect("macos-arm64"), /expected one/);
+ rmSync(join(f.root, "macos-arm64", bundles["macos-arm64"][0][0]));
+ assert.throws(() => f.collect("macos-arm64"), /does not match version/);
+ assert.equal(existsSync(f.out), false);
+});
+
+test("incomplete or mixed releases cannot produce a manifest", (t) => {
+ const f = fixture(t);
+ f.collect("macos-arm64");
+ assert.throws(() => releaseManifest(f.options), /missing or empty/);
+ for (const platform of ["macos-x64", "linux-x64", "windows-x64"]) f.collect(platform);
+ assert.throws(() => releaseManifest({ ...f.options, tag: "v9.0.0" }), /does not match/);
+ f.write(join(f.out, "stale.exe"), "other version");
+ assert.throws(() => releaseManifest(f.options), /unexpected release asset/);
+ rmSync(join(f.out, "stale.exe"));
+ f.write(join(f.out, `${prefix}-linux-x64.deb.sig`), " \n");
+ assert.throws(() => releaseManifest(f.options), /empty updater signature/);
+ assert.equal(existsSync(join(f.out, "latest.json")), false);
+});
+
+test("publication only resumes a single draft and never replaces a published release", () => {
+ const draft = { id: 123, tag_name: tag, draft: true };
+ assert.equal(existingDraft([draft, { tag_name: "v0.1.0", draft: false }], tag), draft);
+ assert.equal(existingDraft([], tag), undefined);
+ assert.throws(() => existingDraft([draft, { ...draft, id: 456 }], tag), /multiple releases/);
+ assert.throws(() => existingDraft([{ ...draft, draft: false }], tag), /already published/);
+});
+
+test("dry-run publication assembles metadata without invoking GitHub", (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ f.write(join(f.root, "VERSION"), version);
+ const output = execFileSync(process.execPath, [script, "--tag", tag, "--sha", sha, "--dir", f.out, "--dry-run"], {
+ cwd: f.root, encoding: "utf8", env: { ...process.env, GH_REPO: repository, GH_TOKEN: "", PATH: "" },
+ });
+ assert.equal(output.trim().split("\n").length, 17);
+ assert.equal(readdirSync(f.out).length, 17);
+ assert.equal(JSON.parse(readFileSync(join(f.out, "latest.json"), "utf8")).version, version);
+});
+
+test("publication creates one draft after validation and blocks moved tags, published or stale assets", { skip: process.platform === "win32" }, (t) => {
+ const f = fixture(t);
+ f.collectAll();
+ f.write(join(f.root, "VERSION"), version);
+ const bin = join(f.root, "bin");
+ const log = join(f.root, "github-calls.jsonl");
+ mkdirSync(bin);
+ writeFileSync(join(bin, "gh"), `#!${process.execPath}
+const fs = require('node:fs');
+const args = process.argv.slice(2);
+const input = args.includes('--input') ? JSON.parse(fs.readFileSync(0, 'utf8')) : null;
+fs.appendFileSync(process.env.TEST_LOG, JSON.stringify({ args, input }) + '\\n');
+if (args[0] === 'release') process.exit(0);
+if (args[1].includes('/commits/')) console.log(JSON.stringify({ sha: process.env.TEST_COMMIT }));
+else if (args.includes('POST')) console.log(JSON.stringify({ id: 123, html_url: 'https://example.invalid/draft', ...input }));
+else console.log(JSON.stringify([JSON.parse(process.env.TEST_RELEASES)]));
+`, { mode: 0o755 });
+ const run = (commit, releases) => {
+ writeFileSync(log, "");
+ const result = spawnSync(process.execPath, [script, "--tag", tag, "--sha", sha, "--dir", f.out], {
+ cwd: f.root, encoding: "utf8",
+ env: { ...process.env, GH_REPO: repository, GITHUB_STEP_SUMMARY: "", PATH: `${bin}:${process.env.PATH}`,
+ TEST_COMMIT: commit, TEST_RELEASES: JSON.stringify(releases), TEST_LOG: log },
+ });
+ const calls = readFileSync(log, "utf8").trim().split("\n").map(JSON.parse);
+ return { result, writes: calls.filter(({ args }) => args[0] === "release" || args.includes("POST")) };
+ };
+ const draft = { tag_name: tag, draft: true, assets: [] };
+ for (const [commit, releases, message] of [
+ ["b".repeat(40), [], /moved/],
+ [sha, [{ ...draft, draft: false }], /already published/],
+ [sha, [{ ...draft, assets: [{ name: "old-installer.exe" }] }], /outside this build/],
+ ]) {
+ const { result, writes } = run(commit, releases);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, message);
+ assert.deepEqual(writes, []);
+ }
+ const { result, writes } = run(sha, []);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(writes.length, 2, "one draft creation and one asset upload");
+ assert.equal(writes[0].input.tag_name, tag);
+ assert.equal(writes[0].input.target_commitish, sha);
+ assert.equal(writes[0].input.draft, true);
+ assert.equal(writes[0].input.prerelease, true);
+ assert.ok(writes[1].args.includes(join(f.out, "SHA256SUMS")));
+ assert.ok(writes[1].args.includes(join(f.out, "latest.json")));
+ const retry = run(sha, [draft]);
+ assert.equal(retry.result.status, 0, retry.result.stderr);
+ assert.equal(retry.writes.length, 1, "retry uploads to the existing draft without creating another one");
+});
diff --git a/apps/desktop/scripts/release-config.mjs b/apps/desktop/scripts/release-config.mjs
index 2c02fce69..aa756ce64 100644
--- a/apps/desktop/scripts/release-config.mjs
+++ b/apps/desktop/scripts/release-config.mjs
@@ -1,6 +1,5 @@
-// Build-time Tauri config for a release, printed as one `--config `
-// argument for tauri-action (which splits `args` on whitespace, so the JSON
-// must not contain spaces):
+// Build-time Tauri config for a release. CI writes it with --output; local
+// callers can still use the printed `--config ` argument:
//
// - beta builds poll the rolling desktop-beta manifest instead of
// releases/latest (see desktop-release.yml);
@@ -21,8 +20,9 @@
//
// Usage: release-config.mjs --channel stable|beta [--repository owner/name]
// [--version x.y.z] [--thumbprint ]
+// [--prebuilt-frontend] [--defer-updater] [--output path]
-import { readFileSync } from "node:fs";
+import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -53,11 +53,19 @@ export function msiVersion(version) {
}
/** The config object merged over tauri.conf.json for a release build. */
-export function releaseConfig({ version, channel, repository, thumbprint }) {
+export function releaseConfig({ version, channel, repository, thumbprint, prebuiltFrontend = false, deferUpdater = false }) {
if (channel !== "stable" && channel !== "beta") {
throw new Error(`unknown channel: ${channel}`);
}
const config = { bundle: { windows: { wix: { version: msiVersion(version) } } } };
+ if (prebuiltFrontend) {
+ config.build = { beforeBuildCommand: "" };
+ }
+ // macOS archives must contain the final signed/notarized app. CI creates
+ // and signs that archive after the external signing service has returned.
+ if (deferUpdater) {
+ config.bundle.createUpdaterArtifacts = false;
+ }
if (thumbprint) {
if (!/^[0-9A-Fa-f]{40}$/.test(thumbprint)) {
throw new Error("--thumbprint must be a 40-digit SHA-1 certificate thumbprint");
@@ -87,8 +95,15 @@ function main() {
channel: value("--channel"),
repository: value("--repository"),
thumbprint: value("--thumbprint"),
+ prebuiltFrontend: args.includes("--prebuilt-frontend"),
+ deferUpdater: args.includes("--defer-updater"),
});
- process.stdout.write(`--config ${JSON.stringify(config)}\n`);
+ const output = value("--output");
+ if (output) {
+ writeFileSync(output, `${JSON.stringify(config)}\n`);
+ } else {
+ process.stdout.write(`--config ${JSON.stringify(config)}\n`);
+ }
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
diff --git a/apps/desktop/scripts/release-config.test.mjs b/apps/desktop/scripts/release-config.test.mjs
index 33fa939d9..f7093c8b0 100644
--- a/apps/desktop/scripts/release-config.test.mjs
+++ b/apps/desktop/scripts/release-config.test.mjs
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
@@ -65,3 +67,19 @@ test("prints one whitespace-free --config argument", () => {
assert.deepEqual(rest, []);
assert.equal(JSON.parse(json).bundle.windows.wix.version, "1.0.0.1");
});
+
+test("CI consumes the shared frontend and signs the updater after app notarization", (t) => {
+ const directory = mkdtempSync(join(tmpdir(), "desktop-config-"));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ const output = join(directory, "release config.json");
+ const stdout = execFileSync(process.execPath, [script, "--channel", "beta", "--repository", "o/r",
+ "--version", "1.2.3-beta.4", "--prebuilt-frontend", "--defer-updater", "--output", output], { encoding: "utf8" });
+ assert.equal(stdout, "");
+ const config = JSON.parse(readFileSync(output, "utf8"));
+ assert.equal(config.build.beforeBuildCommand, "");
+ assert.equal(config.bundle.createUpdaterArtifacts, false);
+ assert.equal(config.bundle.windows.wix.version, "1.2.3.4");
+ assert.deepEqual(config.plugins.updater.endpoints, ["https://github.com/o/r/releases/download/desktop-beta/latest.json"]);
+ // Other platforms still let Tauri create and sign their updater packages.
+ assert.equal(releaseConfig({ version: "1.2.3", channel: "stable", prebuiltFrontend: true }).bundle.createUpdaterArtifacts, undefined);
+});
From af0219780c1da714837651202db8234634c56ada Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 15:22:06 +0800
Subject: [PATCH 02/19] fix: run releases in the source tag context
---
.github/actions/desktop-setup/action.yml | 5 +
.github/workflows/desktop-linux.yml | 2 +-
.github/workflows/desktop-macos.yml | 2 +-
.github/workflows/desktop-prepare.yml | 18 +-
.github/workflows/desktop-release.yml | 29 ++--
.github/workflows/desktop-windows.yml | 5 +-
.github/workflows/release-tag.yml | 4 +-
README.md | 11 +-
README.zh-CN.md | 8 +-
apps/desktop/README.md | 42 +++--
apps/desktop/scripts/desktop-provenance.mjs | 18 +-
.../scripts/desktop-provenance.test.mjs | 37 ++--
apps/desktop/scripts/desktop-source.mjs | 24 +++
.../desktop/scripts/desktop-workflow.test.mjs | 163 ++++++++++++++++++
14 files changed, 290 insertions(+), 78 deletions(-)
create mode 100644 apps/desktop/scripts/desktop-source.mjs
create mode 100644 apps/desktop/scripts/desktop-workflow.test.mjs
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
index 55621dbcf..6d885bb57 100644
--- a/.github/actions/desktop-setup/action.yml
+++ b/.github/actions/desktop-setup/action.yml
@@ -12,6 +12,11 @@ inputs:
runs:
using: composite
steps:
+ - name: Verify source commit
+ shell: bash
+ env:
+ SOURCE_SHA: ${{ inputs.source-sha }}
+ run: node apps/desktop/scripts/desktop-source.mjs
- uses: pnpm/action-setup@v4
with:
version: 10.17.0
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
index f875a99eb..f8fa491c3 100644
--- a/.github/workflows/desktop-linux.yml
+++ b/.github/workflows/desktop-linux.yml
@@ -27,7 +27,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ inputs.source-sha }}
+ ref: ${{ github.sha }}
- uses: ./.github/actions/desktop-setup
with:
target: ${{ env.TARGET }}
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
index 278eab189..7a01ff80a 100644
--- a/.github/workflows/desktop-macos.yml
+++ b/.github/workflows/desktop-macos.yml
@@ -32,7 +32,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ inputs.source-sha }}
+ ref: ${{ github.sha }}
- name: Validate architecture
run: '[[ "$ARCH" == arm64 || "$ARCH" == x64 ]]'
- uses: ./.github/actions/desktop-setup
diff --git a/.github/workflows/desktop-prepare.yml b/.github/workflows/desktop-prepare.yml
index acc21a30c..3cea314b9 100644
--- a/.github/workflows/desktop-prepare.yml
+++ b/.github/workflows/desktop-prepare.yml
@@ -23,12 +23,12 @@ jobs:
runs-on: ubuntu-24.04
outputs:
source-sha: ${{ steps.source.outputs.sha }}
- release-tag: ${{ inputs.release-tag }}
+ release-tag: ${{ steps.source.outputs.release-tag }}
channel: ${{ steps.source.outputs.channel }}
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ inputs.release-tag && format('refs/tags/{0}', inputs.release-tag) || github.sha }}
+ ref: ${{ github.sha }}
- name: Verify source
id: source
env:
@@ -38,16 +38,16 @@ jobs:
echo "::error::This source predates the reusable desktop build. Rerun its original workflow, or choose a tag containing the new build scripts."
exit 1
fi
- node scripts/sync-version.mjs --check
node --input-type=module <<'JS'
import { appendFileSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
+ import { currentDesktopSource } from './apps/desktop/scripts/desktop-source.mjs';
+ const { sha, releaseTag: tag } = currentDesktopSource();
+ execFileSync(process.execPath, ['scripts/sync-version.mjs', '--check'], { stdio: 'inherit' });
const version = readFileSync('VERSION', 'utf8').trim();
- const tag = process.env.RELEASE_TAG;
if (tag && tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`);
- const sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
const channel = version.includes('-') ? 'beta' : 'stable';
- appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\nchannel=${channel}\n`);
+ appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\nrelease-tag=${tag}\nchannel=${channel}\n`);
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Source: ${sha}\n\nVersion: ${version} (${channel})\n`);
JS
@@ -58,7 +58,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ needs.resolve.outputs.source-sha }}
+ ref: ${{ github.sha }}
- uses: pnpm/action-setup@v4
with:
version: 10.17.0
@@ -67,7 +67,7 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- # Same namespace as the web CI job on the default branch.
+ # Restore compatible default-branch CI caches; new entries stay ref-scoped.
- name: Cache Next.js build
uses: actions/cache@v4
with:
@@ -92,7 +92,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ needs.resolve.outputs.source-sha }}
+ ref: ${{ github.sha }}
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index a252fc08e..28f19590c 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -1,8 +1,7 @@
name: Desktop Release
-# Coordinate shared inputs, native builds and publication. Release runs live
-# on the default branch so successive tags can reuse caches; every build still
-# checks out the immutable commit resolved from release_tag.
+# Coordinate shared inputs, native builds and publication in the source ref's
+# context. Tag code must never write caches in the default branch's scope.
on:
push:
tags:
@@ -11,7 +10,7 @@ on:
workflow_dispatch:
inputs:
release_tag:
- description: "Existing tag to release; leave empty for workflow artifacts only"
+ description: "Existing tag to dispatch; leave empty to build the selected ref"
type: string
default: ""
@@ -24,8 +23,8 @@ concurrency:
jobs:
dispatch:
- name: Dispatch release on the default branch
- if: github.ref_type == 'tag' || (inputs.release_tag != '' && github.ref_name != github.event.repository.default_branch)
+ name: Dispatch release on its tag
+ if: inputs.release_tag != '' && github.ref_type != 'tag'
runs-on: ubuntu-24.04
permissions:
actions: write
@@ -34,18 +33,17 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
- RELEASE_TAG: ${{ inputs.release_tag || github.ref_name }}
+ RELEASE_TAG: ${{ inputs.release_tag }}
run: |
- gh workflow run desktop-release.yml --ref "$DEFAULT_BRANCH" -f release_tag="$RELEASE_TAG"
- echo "Release $RELEASE_TAG dispatched on $DEFAULT_BRANCH for shared caches." >> "$GITHUB_STEP_SUMMARY"
+ gh workflow run desktop-release.yml --ref "refs/tags/$RELEASE_TAG"
+ echo "Release $RELEASE_TAG dispatched in its tag context." >> "$GITHUB_STEP_SUMMARY"
prepare:
name: Prepare shared inputs
- if: github.ref_type != 'tag' && (inputs.release_tag == '' || github.ref_name == github.event.repository.default_branch)
+ if: inputs.release_tag == '' || github.ref_type == 'tag'
uses: ./.github/workflows/desktop-prepare.yml
with:
- release-tag: ${{ inputs.release_tag || '' }}
+ release-tag: ${{ inputs.release_tag || (github.ref_type == 'tag' && github.ref_name) || '' }}
macos-arm64:
name: macOS arm64
@@ -99,7 +97,12 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ needs.prepare.outputs.source-sha }}
+ ref: ${{ github.sha }}
+ - name: Verify publication source
+ env:
+ SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }}
+ RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
+ run: node apps/desktop/scripts/desktop-source.mjs
- uses: actions/setup-node@v4
with:
node-version: 24
diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml
index 179ebe64d..fba5bcfc4 100644
--- a/.github/workflows/desktop-windows.yml
+++ b/.github/workflows/desktop-windows.yml
@@ -29,7 +29,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- ref: ${{ inputs.source-sha }}
+ ref: ${{ github.sha }}
- uses: ./.github/actions/desktop-setup
with:
target: ${{ env.TARGET }}
@@ -162,8 +162,7 @@ jobs:
SOURCE_SHA: ${{ inputs.source-sha }}
run: node apps/desktop/scripts/desktop-provenance.mjs > "$RUNNER_TEMP/desktop-provenance.json"
- name: Attest Windows build provenance
- # The workflow runs on the default branch; attest the checked-out
- # release commit as well as the workflow commit.
+ # The workflow and checked-out source share the run's immutable commit.
uses: actions/attest-build-provenance@v4
with:
predicate-type: https://slsa.dev/provenance/v1
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 2d847670f..72e855aed 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -54,9 +54,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
- DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
- # Keep the desktop cache in the default branch's scope across tags.
- gh workflow run desktop-release.yml --ref "$DEFAULT_BRANCH" -f release_tag="$RELEASE_TAG"
+ gh workflow run desktop-release.yml --ref "refs/tags/$RELEASE_TAG"
gh workflow run image.yml --ref "$RELEASE_TAG"
diff --git a/README.md b/README.md
index e95ce5245..cfa1abcef 100644
--- a/README.md
+++ b/README.md
@@ -246,15 +246,18 @@ trigger it. The parent workflow delegates to reusable workflows:
| [`desktop-windows.yml`](.github/workflows/desktop-windows.yml) | Build x64 NSIS/MSI/portable packages, verify signatures and attest their provenance. |
The four native jobs run in parallel after preparation and reuse the same
-frontend/catalog artifacts. Releases run on the default branch with a
-`release_tag` input so successive tags share a cache scope; builds still check
-out the tag's immutable commit. Rust is pinned, Rust/Go caches are separated by
+frontend/catalog artifacts. Releases run in their tag's context and every job
+checks out the run's immutable commit. Tag caches remain isolated from the
+default branch and other tags, while compatible default-branch CI caches can
+still be restored. Rust is pinned, Rust/Go caches are separated by
target, and the fat LTO release profile is unchanged. macOS packages are created
from the same final signed app, avoiding DMG recompression.
Only after every target passes does the parent assemble `latest.json` and
`SHA256SUMS` and upload a **draft** release. Publishing the draft ships the update.
-Manual runs with an empty `release_tag` only upload workflow artifacts. See the
+Manual branch runs with an empty `release_tag` only upload workflow artifacts;
+manual tag runs create a draft release. A branch run with `release_tag` set
+dispatches the build onto that tag. See the
[desktop release guide](apps/desktop/README.md#release-workflows) for manual tag
builds, signing secrets, cache behavior and compilation timing reports.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index cec9ea53c..44c48b739 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -242,17 +242,17 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
公共准备完成后,四个 OS + arch 任务并行运行,Node.js 侧只安装桌面 CLI 所需的依赖,并下载同一份前端和模型目录产物。四个平台验证通过后,主 workflow 统一生成 `latest.json`、`SHA256SUMS`,上传安装包和更新签名到一个 **GitHub Release 草稿**。手动发布草稿后,已安装的客户端才会收到更新。
-发布任务在仓库的**默认分支**上运行,通过 `release_tag` 指定源码标签。构建始终 checkout 已解析的固定提交,上传前再次确认标签没有移动;这样不同版本可以复用默认分支缓存。公共 [desktop-setup action](.github/actions/desktop-setup/action.yml) 固定 Rust 版本,并按目标平台和架构隔离 Rust、Go 缓存,避免两个 macOS 任务争用缓存。首次运行仍需要预热;Rust 的 fat LTO 等发布优化参数保持不变。
+发布任务在目标标签的运行上下文中执行,所有任务始终 checkout 本次运行的固定提交,并在上传前再次确认标签没有移动。标签写入的缓存与默认分支及其他标签隔离,仍可恢复默认分支 CI 生成的兼容缓存;同一标签重跑可以复用自己的缓存,不同标签首次运行可能需要重新编译。公共 [desktop-setup action](.github/actions/desktop-setup/action.yml) 固定 Rust 版本,并按目标平台和架构隔离 Rust、Go 缓存,避免两个 macOS 任务争用缓存。Rust 的 fat LTO 等发布优化参数保持不变。
macOS 先完成 `.app` 的签名和公证,再从同一份最终应用生成 ULFO DMG 和更新压缩包,避免先生成 DMG 再解压、替换应用和重新压缩。编译、打包、签名、公证与验证分别显示为独立步骤;`desktop-timings-*` 附件保留七天,可用于查看 Rust 编译耗时。外部签名服务和 Apple 公证的等待时间仍计入相应步骤。
-手动构建已有标签(默认分支和目标标签均需包含这些 workflow 与构建脚本):
+手动构建已有标签(目标标签需包含这些 workflow 与构建脚本):
```bash
-gh workflow run desktop-release.yml --ref "$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -f release_tag=v1.2.3
+gh workflow run desktop-release.yml --ref refs/tags/v1.2.3
```
-在 Actions 页面手动运行且留空 `release_tag` 时,只构建所选分支并上传 workflow 附件。公共产物保留七天,期间可以重跑失败的平台任务。签名密钥、更新通道和平台打包细节见 [桌面端说明](apps/desktop/README.md#release-workflows)。
+在 Actions 页面选择分支且留空 `release_tag` 时,只构建所选分支并上传 workflow 附件;选择标签则创建发布草稿。在分支上填写 `release_tag` 会把构建调度到该标签,标签运行中填写的值必须与当前标签一致。旧标签沿用其原始 workflow 的重跑方式。公共产物保留七天,期间可以重跑失败的平台任务。签名密钥、更新通道和平台打包细节见 [桌面端说明](apps/desktop/README.md#release-workflows)。
### Docker 部署
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 3c552f6dd..28e8956d1 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -347,7 +347,7 @@ verification live in that platform's file.
| Workflow | Responsibility |
| --- | --- |
-| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Resolve the requested tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
+| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Verify the run's source commit and tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Called separately for arm64 and x64; build the sidecar/app, sign it, create the final DMG and updater archive, then verify both. |
| [`desktop-linux.yml`](../../.github/workflows/desktop-linux.yml) | Build x64 sidecar/executable, bundle deb/AppImage and verify the Wayland library exclusion. |
| [`desktop-windows.yml`](../../.github/workflows/desktop-windows.yml) | Build x64 sidecar/executable, sign and verify NSIS/MSI/portable packages, and attest their provenance. |
@@ -360,27 +360,33 @@ Compilation, bundling, notarization and verification have separate steps. Rust
`--timings` reports are retained as `desktop-timings-*` artifacts for seven days.
The shipped Rust optimization profile, including fat LTO, is unchanged.
-Release-tag runs dispatch `desktop-release.yml` on the repository's **default
-branch**, with the tag supplied as `release_tag`. This gives successive releases
-the same GitHub cache scope instead of creating a separate scope per tag. The
-jobs still check out the tag's resolved commit, and publication verifies the tag
-has not moved. Windows provenance records both the workflow commit and the
-actual desktop source commit. Rust is pinned in the setup action, and Rust/Go
-cache keys include the target triple so the two macOS architectures cannot
-overwrite one another's cache. The first run after a cache/toolchain change is
-still cold; dependency changes or cache eviction can also cause rebuilds.
-
-To build an existing tag, after these workflows exist on the default branch:
+Release-tag runs dispatch `desktop-release.yml` on the **release tag**. Every
+job checks out the run's immutable `github.sha`; source inputs are verified
+against that commit and cannot select code from a different ref. Tag code writes
+only to its own cache scope, keeping the default branch and other tags isolated.
+Compatible caches from default-branch CI can still be restored, and reruns of
+the same tag reuse its caches. Successive tags cannot restore caches written by
+each other, so their first native builds may need recompilation. Publication
+verifies the tag has not moved, and Windows provenance records the matching
+workflow and desktop source commits. Rust is pinned in the setup action, and
+Rust/Go cache keys include the target triple so the two macOS architectures
+cannot overwrite one another's cache. Dependency changes or cache eviction can
+also cause rebuilds.
+
+To build an existing tag containing these workflows and scripts:
```bash
-gh workflow run desktop-release.yml --ref "$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name)" -f release_tag=v1.2.3
+gh workflow run desktop-release.yml --ref refs/tags/v1.2.3
```
-The tag must contain the reusable build scripts and setup action. A manual run
-with an empty `release_tag` builds the selected branch and only uploads workflow
-artifacts. A release run creates the draft only after all four targets succeed;
-publishing the draft remains a separate human step. Failed jobs can be retried
-while the shared artifacts are retained (seven days).
+The tag must contain the reusable workflows, build scripts and setup action;
+rerun the original workflow for historical tags. A manual branch run with an
+empty `release_tag` only uploads workflow artifacts; a manual tag run creates a
+draft. Setting `release_tag` on a branch dispatches the workflow onto that tag
+without building it in the branch's context. On a tag run, a nonempty input must
+match the current tag. A release run creates the draft only after all four
+targets succeed; publishing the draft remains a separate human step. Failed
+jobs can be retried while the shared artifacts are retained (seven days).
### Channels
diff --git a/apps/desktop/scripts/desktop-provenance.mjs b/apps/desktop/scripts/desktop-provenance.mjs
index 3801a8e23..ea2ac7c83 100644
--- a/apps/desktop/scripts/desktop-provenance.mjs
+++ b/apps/desktop/scripts/desktop-provenance.mjs
@@ -1,13 +1,9 @@
-// GitHub's automatic predicate records the workflow commit. Releases execute
-// on the default branch but check out a tag: retain both commits as inputs.
-import { execFileSync } from "node:child_process";
-import { readFileSync } from "node:fs";
+// Record the workflow and source at the same immutable branch or tag commit.
import { pathToFileURL } from "node:url";
+import { currentDesktopSource, desktopSource } from "./desktop-source.mjs";
export function desktopProvenance({ env, sourceSha, releaseTag = "" }) {
- for (const sha of [sourceSha, env.GITHUB_SHA]) {
- if (!/^[0-9a-f]{40}$/.test(sha ?? "")) throw new Error("provenance requires immutable source and workflow commits");
- }
+ const { releaseTag: tag } = desktopSource({ env, sourceSha, releaseTag });
const repository = `${env.GITHUB_SERVER_URL}/${env.GITHUB_REPOSITORY}`;
const prefix = `${env.GITHUB_REPOSITORY}/`;
const suffix = `@${env.GITHUB_REF}`;
@@ -29,7 +25,7 @@ export function desktopProvenance({ env, sourceSha, releaseTag = "" }) {
},
resolvedDependencies: [
{ name: "workflow", uri: `git+${repository}@${env.GITHUB_REF}`, digest: { gitCommit: env.GITHUB_SHA } },
- { name: "desktop-source", uri: `git+${repository}@${releaseTag ? `refs/tags/${releaseTag}` : sourceSha}`, digest: { gitCommit: sourceSha } },
+ { name: "desktop-source", uri: `git+${repository}@${tag ? `refs/tags/${tag}` : sourceSha}`, digest: { gitCommit: sourceSha } },
],
},
runDetails: {
@@ -42,8 +38,6 @@ export function desktopProvenance({ env, sourceSha, releaseTag = "" }) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
- if (sourceSha !== process.env.SOURCE_SHA) throw new Error("checkout does not match the requested source commit");
- const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
- console.log(JSON.stringify(desktopProvenance({ env: process.env, sourceSha, releaseTag: event.inputs?.release_tag })));
+ const { sha: sourceSha, releaseTag } = currentDesktopSource();
+ console.log(JSON.stringify(desktopProvenance({ env: process.env, sourceSha, releaseTag })));
}
diff --git a/apps/desktop/scripts/desktop-provenance.test.mjs b/apps/desktop/scripts/desktop-provenance.test.mjs
index 65fa0af25..20ab39e2b 100644
--- a/apps/desktop/scripts/desktop-provenance.test.mjs
+++ b/apps/desktop/scripts/desktop-provenance.test.mjs
@@ -2,24 +2,41 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { desktopProvenance } from "./desktop-provenance.mjs";
-test("provenance distinguishes the release source from the default-branch workflow", () => {
- const env = {
+const sourceSha = "a".repeat(40);
+function environment(ref) {
+ return {
GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "o/r",
- GITHUB_SHA: "a".repeat(40), GITHUB_REF: "refs/heads/release@next",
- GITHUB_WORKFLOW_REF: "o/r/.github/workflows/desktop-release.yml@refs/heads/release@next",
+ GITHUB_SHA: sourceSha, GITHUB_REF: ref,
+ GITHUB_WORKFLOW_REF: `o/r/.github/workflows/desktop-release.yml@${ref}`,
GITHUB_EVENT_NAME: "workflow_dispatch", GITHUB_REPOSITORY_ID: "123", GITHUB_REPOSITORY_OWNER_ID: "456",
RUNNER_ENVIRONMENT: "github-hosted", GITHUB_RUN_ID: "789", GITHUB_RUN_ATTEMPT: "2",
};
- const sourceSha = "b".repeat(40);
- const predicate = desktopProvenance({ env, sourceSha, releaseTag: "v1.2.3" });
+}
+
+test("tag provenance binds the workflow and release source to the same commit", () => {
+ const env = environment("refs/tags/v1.2.3");
+ // Direct tag dispatches have no release_tag input; the ref supplies identity.
+ const predicate = desktopProvenance({ env, sourceSha });
assert.deepEqual(predicate.buildDefinition.resolvedDependencies, [
- { name: "workflow", uri: "git+https://github.com/o/r@refs/heads/release@next", digest: { gitCommit: env.GITHUB_SHA } },
+ { name: "workflow", uri: "git+https://github.com/o/r@refs/tags/v1.2.3", digest: { gitCommit: sourceSha } },
{ name: "desktop-source", uri: "git+https://github.com/o/r@refs/tags/v1.2.3", digest: { gitCommit: sourceSha } },
]);
assert.equal(predicate.buildDefinition.externalParameters.workflow.path, ".github/workflows/desktop-release.yml");
- assert.equal(predicate.runDetails.builder.id, "https://github.com/o/r/.github/workflows/desktop-windows.yml@refs/heads/release@next");
+ assert.equal(predicate.runDetails.builder.id, "https://github.com/o/r/.github/workflows/desktop-windows.yml@refs/tags/v1.2.3");
assert.equal(predicate.runDetails.metadata.invocationId, "https://github.com/o/r/actions/runs/789/attempts/2");
- const preview = desktopProvenance({ env, sourceSha });
- assert.equal(preview.buildDefinition.resolvedDependencies[1].uri, `git+https://github.com/o/r@${sourceSha}`);
+});
+
+test("branch artifact provenance preserves branch names containing at signs", () => {
+ const env = environment("refs/heads/release@next");
+ const predicate = desktopProvenance({ env, sourceSha });
+ assert.equal(predicate.buildDefinition.externalParameters.workflow.path, ".github/workflows/desktop-release.yml");
+ assert.equal(predicate.buildDefinition.resolvedDependencies[1].uri, `git+https://github.com/o/r@${sourceSha}`);
+});
+
+test("provenance rejects cross-ref source commits and misleading release tags", () => {
+ const env = environment("refs/tags/v1.2.3");
+ assert.throws(() => desktopProvenance({ env, sourceSha: "b".repeat(40) }), /workflow commit/);
+ assert.throws(() => desktopProvenance({ env, sourceSha, releaseTag: "v9.9.9" }), /workflow ref/);
+ assert.throws(() => desktopProvenance({ env: environment("refs/heads/dev"), sourceSha, releaseTag: "v1.2.3" }), /workflow ref/);
assert.throws(() => desktopProvenance({ env, sourceSha: "dev" }), /immutable/);
});
diff --git a/apps/desktop/scripts/desktop-source.mjs b/apps/desktop/scripts/desktop-source.mjs
new file mode 100644
index 000000000..babe90fb5
--- /dev/null
+++ b/apps/desktop/scripts/desktop-source.mjs
@@ -0,0 +1,24 @@
+import { execFileSync } from "node:child_process";
+import { pathToFileURL } from "node:url";
+
+// Source inputs describe the run's commit; they must never select another ref
+// whose code would execute with this run's cache and signing capabilities.
+export function desktopSource({ env, sourceSha, releaseTag = env.RELEASE_TAG ?? "" }) {
+ if ([env.GITHUB_SHA, sourceSha].some((sha) => !/^[0-9a-f]{40}$/.test(sha ?? ""))) throw new Error("workflow requires an immutable source commit");
+ if (sourceSha !== env.GITHUB_SHA) throw new Error("checkout does not match the workflow commit");
+ if (env.SOURCE_SHA !== undefined && env.SOURCE_SHA !== sourceSha) throw new Error("requested source does not match the workflow commit");
+ const ref = env.GITHUB_REF ?? "";
+ if (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) throw new Error("expected a branch or tag workflow ref");
+ const tag = ref.startsWith("refs/tags/") ? ref.slice("refs/tags/".length) : "";
+ if (releaseTag && releaseTag !== tag) throw new Error("release tag does not match the workflow ref; dispatch the workflow on that tag");
+ return { sha: sourceSha, releaseTag: tag };
+}
+
+export function currentDesktopSource(env = process.env) {
+ const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
+ return desktopSource({ env, sourceSha });
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ currentDesktopSource();
+}
diff --git a/apps/desktop/scripts/desktop-workflow.test.mjs b/apps/desktop/scripts/desktop-workflow.test.mjs
new file mode 100644
index 000000000..2183115ed
--- /dev/null
+++ b/apps/desktop/scripts/desktop-workflow.test.mjs
@@ -0,0 +1,163 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+import { runInNewContext } from "node:vm";
+
+const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
+const workflow = (name) => readFileSync(join(root, ".github", "workflows", `${name}.yml`), "utf8");
+const release = workflow("desktop-release");
+const prepare = workflow("desktop-prepare");
+const shellTest = process.platform === "win32" ? nodeTest.skip : nodeTest;
+
+// Read the actual workflow blocks, so the integration tests exercise the shell
+// steps and routing conditions that Actions runs without duplicating them here.
+function block(text, marker) {
+ const lines = text.split("\n");
+ const start = lines.findIndex((line) => line.trim() === marker);
+ assert.notEqual(start, -1, `missing workflow block: ${marker}`);
+ const indent = lines[start].search(/\S/);
+ let end = start + 1;
+ while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > indent)) end++;
+ return lines.slice(start + 1, end);
+}
+
+function shellStep(text, name) {
+ return block(block(text, `- name: ${name}`).join("\n"), "run: |").map((line) => line.slice(10)).join("\n");
+}
+
+function evaluate(expression, context) {
+ const source = expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")
+ .replace(/\.([\w]+-[\w-]+)/g, (_, name) => `[${JSON.stringify(name)}]`);
+ return runInNewContext(source, context, { timeout: 1000 });
+}
+
+function jobCondition(name, context) {
+ const line = block(release, `${name}:`).find((value) => value.trim().startsWith("if:"));
+ assert.ok(line, `missing ${name} condition`);
+ return evaluate(line.trim().slice(3).trim(), context);
+}
+
+nodeTest("tag releases build in their own context; branch requests dispatch without building", () => {
+ for (const [refType, refName, input, dispatch, build, tag] of [
+ ["tag", "v1.2.3", "", false, true, "v1.2.3"],
+ ["tag", "v1.2.3-beta.4", "", false, true, "v1.2.3-beta.4"],
+ ["tag", "v1.2.3", "v1.2.3", false, true, "v1.2.3"],
+ ["branch", "dev", "", false, true, ""],
+ ["branch", "feature/desktop", "", false, true, ""],
+ ["branch", "dev", "v1.2.3", true, false, "v1.2.3"],
+ ["branch", "main", "v1.2.3-beta.4", true, false, "v1.2.3-beta.4"],
+ ]) {
+ const context = { github: { ref_type: refType, ref_name: refName }, inputs: { release_tag: input } };
+ assert.equal(jobCondition("dispatch", context), dispatch, `${refType} ${refName} ${input}`);
+ assert.equal(jobCondition("prepare", context), build, `${refType} ${refName} ${input}`);
+ const expression = block(release, "prepare:").find((line) => line.trim().startsWith("release-tag:")).trim().slice("release-tag:".length).trim();
+ assert.equal(evaluate(expression, context), tag);
+ const needs = { prepare: { outputs: { "release-tag": build ? tag : "" } } };
+ assert.equal(jobCondition("publish", { needs }), build && tag !== "");
+ }
+});
+
+nodeTest("every desktop checkout uses the event commit, never a supplied tag or job output", () => {
+ for (const name of ["desktop-release", "desktop-prepare", "desktop-macos", "desktop-linux", "desktop-windows"]) {
+ const text = workflow(name);
+ const checkouts = text.split("\n").filter((line) => line.trim().startsWith("- uses: actions/checkout@"));
+ assert.ok(checkouts.length > 0, name);
+ const refs = [...text.matchAll(/- uses: actions\/checkout@[^\n]+\n\s+with:\n\s+ref: ([^\n]+)/g)].map((match) => match[1]);
+ assert.equal(refs.length, checkouts.length, name);
+ assert.ok(refs.every((ref) => ref === "${{ github.sha }}"), name);
+ }
+});
+
+function fixture(t) {
+ const directory = mkdtempSync(join(tmpdir(), "desktop-workflow-"));
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
+ const write = (path, content, options) => {
+ mkdirSync(dirname(join(directory, path)), { recursive: true });
+ writeFileSync(join(directory, path), content, options);
+ };
+ write("VERSION", "1.2.3-beta.4\n");
+ write(".github/actions/desktop-setup/action.yml", "name: fixture\n");
+ write("apps/desktop/scripts/collect-release-assets.mjs", "");
+ copyFileSync(join(root, "apps/desktop/scripts/desktop-source.mjs"), join(directory, "apps/desktop/scripts/desktop-source.mjs"));
+ write("scripts/sync-version.mjs", "import { writeFileSync } from 'node:fs'; writeFileSync('sync-ran', 'yes');\n");
+ const git = (args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
+ git(["init", "-q"]);
+ git(["add", "."]);
+ git(["-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "fixture"]);
+ const sha = git(["rev-parse", "HEAD"]);
+ const env = { ...process.env, GITHUB_SHA: sha, GITHUB_REF: "refs/tags/v1.2.3-beta.4", RELEASE_TAG: "",
+ GITHUB_OUTPUT: join(directory, "outputs"), GITHUB_STEP_SUMMARY: join(directory, "summary") };
+ delete env.SOURCE_SHA;
+ const run = (script, extra = {}) => spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
+ cwd: directory, env: { ...env, ...extra }, encoding: "utf8",
+ });
+ return { directory, sha, env, write, run };
+}
+
+shellTest("source verification resolves stable, beta and artifact-only runs from their actual ref", (t) => {
+ for (const [version, ref, requested, tag, channel] of [
+ ["1.2.3", "refs/tags/v1.2.3", "", "v1.2.3", "stable"],
+ ["1.2.3-beta.4", "refs/tags/v1.2.3-beta.4", "v1.2.3-beta.4", "v1.2.3-beta.4", "beta"],
+ ["1.2.3-beta.4", "refs/heads/dev", "", "", "beta"],
+ ]) {
+ const f = fixture(t);
+ f.write("VERSION", `${version}\n`);
+ const result = f.run(shellStep(prepare, "Verify source"), { GITHUB_REF: ref, RELEASE_TAG: requested });
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(existsSync(join(f.directory, "sync-ran")), true);
+ assert.equal(readFileSync(f.env.GITHUB_OUTPUT, "utf8"), `sha=${f.sha}\nrelease-tag=${tag}\nchannel=${channel}\n`);
+ }
+});
+
+shellTest("cross-ref inputs and source commits fail before running selected-source scripts", (t) => {
+ for (const extra of [
+ { GITHUB_REF: "refs/heads/dev", RELEASE_TAG: "v1.2.3-beta.4" },
+ { RELEASE_TAG: "v9.9.9" },
+ { RELEASE_TAG: "v1.2.3-beta.4%2Fother" },
+ { GITHUB_SHA: "b".repeat(40) },
+ { SOURCE_SHA: "b".repeat(40) },
+ ]) {
+ const f = fixture(t);
+ const result = f.run(shellStep(prepare, "Verify source"), extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /does not match the workflow/);
+ assert.equal(existsSync(join(f.directory, "sync-ran")), false);
+ assert.equal(existsSync(f.env.GITHUB_OUTPUT), false);
+ }
+});
+
+shellTest("native setup and publication reject a mismatched source before consuming it", (t) => {
+ const f = fixture(t);
+ const action = readFileSync(join(root, ".github/actions/desktop-setup/action.yml"), "utf8");
+ for (const [text, name] of [[action, "Verify source commit"], [release, "Verify publication source"]]) {
+ const command = block(text, `- name: ${name}`).find((line) => line.trim().startsWith("run:")).trim().slice(4).trim();
+ assert.equal(f.run(command, { SOURCE_SHA: f.sha }).status, 0);
+ const result = f.run(command, { SOURCE_SHA: "b".repeat(40) });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /requested source does not match/);
+ }
+});
+
+shellTest("both dispatchers target the tag without forwarding an input that could redispatch", (t) => {
+ const f = fixture(t);
+ f.write("bin/gh", `#!${process.execPath}\nimport('node:fs').then(fs => {\n const args = process.argv.slice(2);\n fs.appendFileSync(process.env.TEST_GH_CALLS, JSON.stringify(args) + '\\n');\n if (process.env.TEST_MISSING_TAG === args[args.indexOf('--ref') + 1]) process.exit(1);\n});\n`, { mode: 0o755 });
+ const calls = join(f.directory, "gh-calls");
+ const env = { PATH: `${join(f.directory, "bin")}:${process.env.PATH}`, TEST_GH_CALLS: calls, RELEASE_TAG: "v1.2.3-beta.4" };
+ for (const [text, name] of [[release, "Dispatch"], [workflow("release-tag"), "Start release builds for the tag"]]) {
+ f.write("gh-calls", "");
+ const result = f.run(shellStep(text, name), env);
+ assert.equal(result.status, 0, result.stderr);
+ const commands = readFileSync(calls, "utf8").trim().split("\n").map(JSON.parse);
+ assert.deepEqual(commands[0], ["workflow", "run", "desktop-release.yml", "--ref", `refs/tags/${env.RELEASE_TAG}`]);
+ if (commands.length > 1) assert.deepEqual(commands[1], ["workflow", "run", "image.yml", "--ref", env.RELEASE_TAG]);
+ }
+ // A branch-only input must request a missing tag, never fall back to running
+ // that branch. GitHub rejects the fully qualified tag ref when it is absent.
+ f.write("gh-calls", "");
+ assert.notEqual(f.run(shellStep(release, "Dispatch"), { ...env, RELEASE_TAG: "dev", TEST_MISSING_TAG: "refs/tags/dev" }).status, 0);
+ assert.deepEqual(JSON.parse(readFileSync(calls, "utf8")), ["workflow", "run", "desktop-release.yml", "--ref", "refs/tags/dev"]);
+});
From 3e524692b06732987ab841bc0e1eafdbec2301a2 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 15:38:31 +0800
Subject: [PATCH 03/19] chore: bump project version to 0.4.6-beta.1
---
VERSION | 2 +-
apps/desktop/package.json | 2 +-
apps/desktop/src-tauri/Cargo.toml | 2 +-
apps/desktop/src-tauri/tauri.conf.json | 2 +-
apps/web/package.json | 2 +-
backend/cmd/server/main.go | 2 +-
backend/package.json | 2 +-
package.json | 2 +-
packages/api-contract/package.json | 2 +-
packages/core/package.json | 2 +-
10 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/VERSION b/VERSION
index 0bfccb080..da1cd5a6f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.5
+0.4.6-beta.1
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 739aec93e..4f4b5192a 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/desktop",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"type": "module",
"description": "DEEIX Chat desktop shell (Tauri). Loads the apps/web static build and adds tray, deep links and OS keychain storage.",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 07a23981b..eb35fe6e5 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deeix-chat-desktop"
-version = "0.4.5"
+version = "0.4.6-beta.1"
description = "DEEIX Chat desktop shell"
authors = ["DEEIX"]
edition = "2021"
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index be1b748ad..f386291a9 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "DEEIX Chat",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"identifier": "com.deeix.chat.desktop",
"build": {
"frontendDist": "../../web/out",
diff --git a/apps/web/package.json b/apps/web/package.json
index 7e745a653..3290bc10a 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/web",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"sideEffects": [
"*.css",
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 9117414be..bc0343b12 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -13,7 +13,7 @@ import (
)
// @title DEEIX Chat API
-// @version 0.4.5
+// @version 0.4.6-beta.1
// @description DEEIX Chat 后端 API 文档
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
diff --git a/backend/package.json b/backend/package.json
index e6d7d62b9..583c3c829 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"scripts": {
"dev": "make run",
diff --git a/package.json b/package.json
index c10201f69..0f2c552b2 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/chat-monorepo",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"packageManager": "pnpm@10.17.0",
"scripts": {
diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json
index 0a7df1963..f5d4d356c 100644
--- a/packages/api-contract/package.json
+++ b/packages/api-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api-contract",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"type": "module",
"types": "./src/index.ts",
diff --git a/packages/core/package.json b/packages/core/package.json
index 67a617789..ee0343525 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/core",
- "version": "0.4.5",
+ "version": "0.4.6-beta.1",
"private": true,
"type": "module",
"description": "Platform-agnostic client logic shared by web, desktop and mobile. No React, no DOM, no React Native.",
From f1648eaaaa31131a1e1c0e880ba1769f75923b25 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 15:49:10 +0800
Subject: [PATCH 04/19] fix: sync Cargo.lock root package version for
consistent --locked builds
---
apps/desktop/README.md | 5 ++
apps/desktop/scripts/sync-version.test.mjs | 92 ++++++++++++++++++++++
apps/desktop/src-tauri/Cargo.lock | 2 +-
scripts/sync-version.mjs | 23 +++++-
4 files changed, 118 insertions(+), 4 deletions(-)
create mode 100644 apps/desktop/scripts/sync-version.test.mjs
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 28e8956d1..ce96de21e 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -334,6 +334,11 @@ Release flow: merging a `VERSION` bump into `main` creates the tag
Publishing the draft is the step that ships the update; until then existing
installs see nothing. Pushing the tag by hand does the same.
+Before tagging, update `VERSION`, run `make version` and commit the synchronized
+version files, including the desktop root package entry in `Cargo.lock`.
+`make version-check` checks that entry too, so a stale lockfile fails before
+the native release builds use `--locked`; dependency versions stay pinned.
+
Windows assets: `-windows-x64-setup.exe`, `-windows-x64.msi`,
`-windows-x64-portable.zip` (and `-portable-offline.zip` when built). Every
release also carries `SHA256SUMS` and GitHub build provenance attestations.
diff --git a/apps/desktop/scripts/sync-version.test.mjs b/apps/desktop/scripts/sync-version.test.mjs
new file mode 100644
index 000000000..cc8a2af6a
--- /dev/null
+++ b/apps/desktop/scripts/sync-version.test.mjs
@@ -0,0 +1,92 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { test } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const repository = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
+const version = "0.4.6-beta.1";
+
+function fixture(t, { eol = "\n", name = "deeix-chat-desktop" } = {}) {
+ const root = mkdtempSync(join(tmpdir(), "sync-version-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const write = (path, content) => {
+ mkdirSync(dirname(join(root, path)), { recursive: true });
+ writeFileSync(join(root, path), content.replace(/\r?\n/g, eol));
+ };
+ const read = (path) => readFileSync(join(root, path), "utf8");
+ const lockPath = "apps/desktop/src-tauri/Cargo.lock";
+ write("VERSION", `${version}\n`);
+ for (const area of ["", "packages/api-contract/", "packages/core/", "apps/web/", "apps/desktop/", "backend/"]) {
+ write(`${area}package.json`, `${JSON.stringify({ name: area || "root", version }, null, 2)}\n`);
+ }
+ write("backend/cmd/server/main.go", `package main\n// @version ${version}\n`);
+ write("apps/desktop/src-tauri/tauri.conf.json", `${JSON.stringify({ version }, null, 2)}\n`);
+ write("apps/desktop/src-tauri/Cargo.toml", `[package]\nname = "${name}"\nversion = "${version}"\n\n[dependencies]\nfixture = "1"\n`);
+ const rootEntry = `[[package]]\nname = "${name}"\nversion = "0.4.5"\ndependencies = [\n "fixture",\n]\n`;
+ write(lockPath, `# Generated by Cargo.\nversion = 3\n\n[[package]]\nname = "fixture"\nversion = "0.4.5"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "unchanged-dependency-checksum"\n\n${rootEntry}\n[[package]]\nname = "${name}"\nversion = "7.8.9"\nsource = "registry+https://github.com/rust-lang/crates.io-index"\nchecksum = "unchanged-same-name-checksum"\n`);
+ const script = join(root, "scripts", "sync-version.mjs");
+ mkdirSync(dirname(script), { recursive: true });
+ copyFileSync(join(repository, "scripts", "sync-version.mjs"), script);
+ const run = (...args) => spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: "utf8" });
+ return { root, write, read, run, lockPath, rootEntry: rootEntry.replace(/\n/g, eol), eol };
+}
+
+test("desktop version checks reject a stale Cargo root entry without changing it", (t) => {
+ const f = fixture(t);
+ const before = f.read(f.lockPath);
+ const result = f.run("--check", "desktop");
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /VERSION is not synchronized with 1 file/);
+ assert.match(result.stderr, /Cargo\.lock/);
+ assert.match(result.stderr, /node scripts\/sync-version\.mjs desktop/);
+ assert.equal(f.read(f.lockPath), before);
+});
+
+test("sync updates only the local package, preserving dependency pins and line endings", (t) => {
+ for (const options of [{ eol: "\n" }, { eol: "\r\n", name: "renamed-desktop" }]) {
+ const f = fixture(t, options);
+ const before = f.read(f.lockPath);
+ const result = f.run("desktop");
+ assert.equal(result.status, 0, result.stderr);
+ const expected = before.replace(f.rootEntry, f.rootEntry.replace('version = "0.4.5"', `version = "${version}"`));
+ assert.equal(f.read(f.lockPath), expected);
+ assert.equal(f.run("--check", "desktop").status, 0);
+ assert.equal(f.run("desktop").status, 0);
+ assert.equal(f.read(f.lockPath), expected, "a repeated sync is idempotent");
+ }
+});
+
+test("the normal all-target version command also checks and synchronizes Cargo.lock", (t) => {
+ const f = fixture(t);
+ const check = f.run("--check");
+ assert.notEqual(check.status, 0);
+ assert.match(check.stderr, /Cargo\.lock/);
+ assert.match(check.stderr, /Run: node scripts\/sync-version\.mjs\s*$/);
+ const sync = f.run();
+ assert.equal(sync.status, 0, sync.stderr);
+ assert.equal(f.run("--check").status, 0);
+});
+
+test("missing or ambiguous local package records fail without rewriting dependencies", (t) => {
+ for (const duplicate of [false, true]) {
+ const f = fixture(t);
+ const before = f.read(f.lockPath);
+ f.write(f.lockPath, duplicate ? before + `\n${f.rootEntry}` : before.replace(f.rootEntry, ""));
+ const invalid = f.read(f.lockPath);
+ const result = f.run("desktop");
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /Expected one local/);
+ assert.equal(f.read(f.lockPath), invalid);
+ }
+});
+
+test("version checks for other areas remain independent of the desktop lockfile", (t) => {
+ const f = fixture(t);
+ for (const area of ["workspace", "web", "backend"]) {
+ const result = f.run("--check", area);
+ assert.equal(result.status, 0, result.stderr);
+ }
+});
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 2fe9073e4..5461010b6 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -804,7 +804,7 @@ dependencies = [
[[package]]
name = "deeix-chat-desktop"
-version = "0.4.5"
+version = "0.4.6-beta.1"
dependencies = [
"dunce",
"gtk",
diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs
index d09f7917f..a97643965 100644
--- a/scripts/sync-version.mjs
+++ b/scripts/sync-version.mjs
@@ -21,7 +21,7 @@ const targets = {
desktop: () => {
syncPackageVersion("apps", "desktop");
- // The Tauri manifest and the Rust crate carry their own version fields.
+ // Keep the Rust root package's lockfile entry in sync for --locked builds.
syncTauriConfig("apps", "desktop", "src-tauri", "tauri.conf.json");
syncCargoVersion("apps", "desktop", "src-tauri", "Cargo.toml");
},
@@ -99,12 +99,29 @@ function syncCargoVersion(...pathSegments) {
const content = readFileSync(manifestFile, "utf8");
const packageSection = content.indexOf("[package]");
const versionLine = /^version = ".*"/mu.exec(content.slice(packageSection));
- if (packageSection === -1 || !versionLine) {
+ const packageName = /^name = "([^"]+)"/mu.exec(content.slice(packageSection))?.[1];
+ if (packageSection === -1 || !versionLine || !packageName) {
throw new Error(`Unable to update ${manifestFile} package version`);
}
const start = packageSection + versionLine.index;
const next = `${content.slice(0, start)}version = "${version}"${content.slice(start + versionLine[0].length)}`;
writeIfChanged(manifestFile, next);
+ syncCargoLockVersion(join(dirname(manifestFile), "Cargo.lock"), packageName);
+}
+
+function syncCargoLockVersion(lockFile, packageName) {
+ const content = readFileSync(lockFile, "utf8");
+ let matches = 0;
+ const next = content.split(/(?=^\[\[package\]\])/mu).map((entry) => {
+ const name = /^name = "([^"]+)"/mu.exec(entry)?.[1];
+ // A registry or git dependency may share the root package's name. Its
+ // version and every dependency checksum must remain pinned as before.
+ if (name !== packageName || /^source\s*=/mu.test(entry)) return entry;
+ matches++;
+ return replaceOrThrow(entry, /^version = "[^"]+"/mu, `version = "${version}"`, `${lockFile} root package version`);
+ }).join("");
+ if (matches !== 1) throw new Error(`Expected one local ${packageName} package in ${lockFile}`);
+ writeIfChanged(lockFile, next);
}
for (const [name, sync] of Object.entries(targets)) {
@@ -118,7 +135,7 @@ if (checkOnly && mismatches.length > 0) {
for (const filePath of mismatches) {
console.error(`- ${filePath}`);
}
- const fixCommand = mode === "all" ? "pnpm api:generate" : `node scripts/sync-version.mjs ${mode}`;
+ const fixCommand = `node scripts/sync-version.mjs${mode === "all" ? "" : ` ${mode}`}`;
console.error(`Run: ${fixCommand}`);
process.exit(1);
}
From 3d8487755d8d59ce088e380cf0bfd33c4d7eefc4 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Fri, 16 Oct 2026 15:57:10 +0800
Subject: [PATCH 05/19] fix: keep binary tool output from leaving tool steps
stuck as calling
---
.../components/sections/logs/admin-logs.tsx | 7 ++-
.../message/message-agent-tool-step.tsx | 17 ++++--
.../message/message-agent-trace.tsx | 4 ++
.../postgres/conversation/repository.go | 19 +++---
.../repository_postgres_integration_test.go | 59 +++++++++++++++++++
backend/internal/pkg/textutil/text.go | 12 ++++
backend/internal/pkg/textutil/text_test.go | 11 ++++
7 files changed, 114 insertions(+), 15 deletions(-)
diff --git a/apps/web/features/admin/components/sections/logs/admin-logs.tsx b/apps/web/features/admin/components/sections/logs/admin-logs.tsx
index c5e3bbc06..d46123fd2 100644
--- a/apps/web/features/admin/components/sections/logs/admin-logs.tsx
+++ b/apps/web/features/admin/components/sections/logs/admin-logs.tsx
@@ -953,7 +953,12 @@ export function AdminLogsPage() {
-
+ {/* Seven tabs outgrow a phone and the admin shell clips horizontal overflow,
+ so the list scrolls on its own (scrollbar hidden, as in the admin nav). */}
+
{visibleTabs.map((tab) => (
{t(`tabs.${tab.id}`)}
))}
diff --git a/apps/web/features/chat/components/message/message-agent-tool-step.tsx b/apps/web/features/chat/components/message/message-agent-tool-step.tsx
index bb8dacb67..49bafff0b 100644
--- a/apps/web/features/chat/components/message/message-agent-tool-step.tsx
+++ b/apps/web/features/chat/components/message/message-agent-tool-step.tsx
@@ -574,12 +574,12 @@ function isToolStepDone(step: ToolChainStep): boolean {
return status === "success" || status === "completed" || status === "reused";
}
-function ToolStepStatusIcon({ step }: { step: ToolChainStep }) {
+function ToolStepStatusIcon({ step, active }: { step: ToolChainStep; active: boolean }) {
return (
@@ -827,19 +827,26 @@ export function AgentToolStepRow({
step,
labels,
autoExpand,
+ messageStreaming,
runID,
allowFullResult = false,
}: {
step: ToolChainStep;
labels: ProcessTraceLabels;
autoExpand: boolean;
+ messageStreaming: boolean;
runID?: string;
allowFullResult?: boolean;
}) {
- const active = isToolChainStepActive(step);
+ // A finished reply can still carry a step whose last saved status is "calling" (its final
+ // write was lost); counting from startedAt would then show days. Only a live reply runs.
+ const reportedActive = isToolChainStepActive(step);
+ const active = messageStreaming && reportedActive;
const { open, onOpenChange } = useAutoExpandDisclosure({ active, autoExpand });
const failed = step.failed;
- const statusText = isToolStepDone(step) ? "" : toolStatusLabel(step.toolCall?.status ?? step.toolStatus, labels);
+ const statusText = isToolStepDone(step) || (reportedActive && !active)
+ ? ""
+ : toolStatusLabel(step.toolCall?.status ?? step.toolStatus, labels);
const expandable = Boolean(step.toolCall || step.detail);
const liveDurationMS = useChatElapsedDurationMS(active, step.startedAt);
const durationText = formatDurationMS(active ? liveDurationMS : step.latencyMS);
@@ -847,7 +854,7 @@ export function AgentToolStepRow({
return (
}
+ icon={ }
title={labels.tool.names.generic}
status={statusText || undefined}
duration={durationText}
diff --git a/apps/web/features/chat/components/message/message-agent-trace.tsx b/apps/web/features/chat/components/message/message-agent-trace.tsx
index f8dd6f5fd..2bc575bff 100644
--- a/apps/web/features/chat/components/message/message-agent-trace.tsx
+++ b/apps/web/features/chat/components/message/message-agent-trace.tsx
@@ -315,6 +315,7 @@ function AgentTraceTimeline({
labels,
autoExpandThinking,
autoExpandToolCalls,
+ messageStreaming,
runID,
allowFullToolResults,
}: {
@@ -322,6 +323,7 @@ function AgentTraceTimeline({
labels: ProcessTraceLabels;
autoExpandThinking: boolean;
autoExpandToolCalls: boolean;
+ messageStreaming: boolean;
runID?: string;
allowFullToolResults: boolean;
}) {
@@ -345,6 +347,7 @@ function AgentTraceTimeline({
step={item.step}
labels={labels}
autoExpand={autoExpandToolCalls}
+ messageStreaming={messageStreaming}
runID={runID}
allowFullResult={allowFullToolResults}
/>
@@ -543,6 +546,7 @@ export function MessageAgentTrace({
labels={labels}
autoExpandThinking={autoExpandThinking}
autoExpandToolCalls={autoExpandToolCalls}
+ messageStreaming={messageStreaming}
runID={runID}
allowFullToolResults={allowFullToolResults}
/>
diff --git a/backend/internal/infra/persistence/postgres/conversation/repository.go b/backend/internal/infra/persistence/postgres/conversation/repository.go
index 11a6db4b7..ba477144f 100644
--- a/backend/internal/infra/persistence/postgres/conversation/repository.go
+++ b/backend/internal/infra/persistence/postgres/conversation/repository.go
@@ -15,6 +15,7 @@ import (
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/sqlitevec"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/vectorutil"
+ "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/pkg/textutil"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -4318,9 +4319,9 @@ func toConversationMessageTraceModel(item *domainconversation.MessageTrace) mode
RoundID: item.RoundID,
ParentEventID: item.ParentEventID,
Title: item.Title,
- Summary: item.Summary,
- ContentMarkdown: item.ContentMarkdown,
- PayloadJSON: item.PayloadJSON,
+ Summary: textutil.SanitizeForTextColumn(item.Summary),
+ ContentMarkdown: textutil.SanitizeForTextColumn(item.ContentMarkdown),
+ PayloadJSON: textutil.SanitizeForTextColumn(item.PayloadJSON),
Seq: item.Seq,
StartedAt: startedAt,
EndedAt: item.EndedAt,
@@ -4379,9 +4380,9 @@ func toConversationMessageTraceEventModel(item *domainconversation.MessageTraceE
ParentEventID: item.ParentEventID,
Status: item.Status,
Title: item.Title,
- Summary: item.Summary,
- ContentMarkdown: item.ContentMarkdown,
- PayloadJSON: item.PayloadJSON,
+ Summary: textutil.SanitizeForTextColumn(item.Summary),
+ ContentMarkdown: textutil.SanitizeForTextColumn(item.ContentMarkdown),
+ PayloadJSON: textutil.SanitizeForTextColumn(item.PayloadJSON),
Seq: item.Seq,
StartedAt: startedAt,
EndedAt: item.EndedAt,
@@ -4414,9 +4415,9 @@ func toConversationToolCallModel(item *domainconversation.ToolCall) models.ChatR
MCPServerName: item.MCPServerName,
Status: item.Status,
LatencyMS: item.LatencyMS,
- InputJSON: item.InputJSON,
- OutputJSON: item.OutputJSON,
- ErrorJSON: item.ErrorJSON,
+ InputJSON: textutil.SanitizeForTextColumn(item.InputJSON),
+ OutputJSON: textutil.SanitizeForTextColumn(item.OutputJSON),
+ ErrorJSON: textutil.SanitizeForTextColumn(item.ErrorJSON),
StartedAt: startedAt,
}
}
diff --git a/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go b/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
index 3d32330aa..7bd02134c 100644
--- a/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
+++ b/backend/internal/infra/persistence/postgres/conversation/repository_postgres_integration_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ domainconversation "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/infra/persistence/models"
"github.com/DEEIX-AI/DEEIX-Chat/backend/internal/repository"
"gorm.io/driver/postgres"
@@ -138,3 +139,61 @@ func openConversationPostgresIntegrationDB(t *testing.T, dsn string) (*gorm.DB,
}
return db, cleanup
}
+
+// 工具抓到二进制内容时,渲染后的轨迹正文会带上 NUL 与非法 UTF-8;PostgreSQL 会拒绝整行,
+// 轨迹就停在「进行中」。写入前必须清理,且后续的终态更新要能覆盖这一行。
+func TestUpsertConversationTraceRowsAcceptBinaryToolOutputOnPostgres(t *testing.T) {
+ dsn := strings.TrimSpace(os.Getenv("DEEIX_TEST_DATABASE_DSN"))
+ if dsn == "" {
+ t.Skip("set DEEIX_TEST_DATABASE_DSN to run PostgreSQL trace persistence integration test")
+ }
+
+ db, cleanup := openConversationPostgresIntegrationDB(t, dsn)
+ t.Cleanup(cleanup)
+ if err := db.AutoMigrate(&models.ChatRunEvent{}); err != nil {
+ t.Fatalf("migrate chat run events: %v", err)
+ }
+ repo := NewRepo(db)
+ ctx := context.Background()
+ binary := "**web_fetch_exa**:已完成\nGIF89a\x00\x02\x00\xff\xfe"
+
+ event := &domainconversation.MessageTraceEventRow{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary",
+ EventID: "tools_3", EventType: "tool", Phase: "tools", Status: "streaming",
+ ContentMarkdown: "**web_fetch_exa**:进行中", PayloadJSON: "{}", StartedAt: time.Now(),
+ }
+ if err := repo.UpsertConversationMessageTraceEvent(ctx, event); err != nil {
+ t.Fatalf("insert streaming event: %v", err)
+ }
+ endedAt := time.Now()
+ event.Status = "completed"
+ event.ContentMarkdown = binary
+ event.Summary = "1 次工具调用已完成\x00"
+ event.EndedAt = &endedAt
+ if err := repo.UpsertConversationMessageTraceEvent(ctx, event); err != nil {
+ t.Fatalf("complete event with binary tool output: %v", err)
+ }
+ if err := repo.UpsertConversationMessageTrace(ctx, &domainconversation.MessageTrace{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary", TraceType: "tools",
+ Status: "completed", ContentMarkdown: binary, PayloadJSON: "{}", StartedAt: time.Now(), EndedAt: &endedAt,
+ }); err != nil {
+ t.Fatalf("upsert trace block with binary tool output: %v", err)
+ }
+ if err := repo.CreateConversationToolCall(ctx, &domainconversation.ToolCall{
+ MessageID: 1, ConversationID: 1, UserID: 1, RunID: "run_binary", ToolCallID: "call_binary",
+ ToolName: "web_fetch_exa", Status: "success", OutputJSON: "GIF89a\x00\xff",
+ }); err != nil {
+ t.Fatalf("create tool call with binary output: %v", err)
+ }
+
+ rows, err := repo.ListConversationMessageTraceEventsByMessageIDs(ctx, []uint{1})
+ if err != nil {
+ t.Fatalf("list trace events: %v", err)
+ }
+ if len(rows) != 1 || rows[0].Status != "completed" {
+ t.Fatalf("expected the completed event to replace the streaming one, got %+v", rows)
+ }
+ if strings.ContainsRune(rows[0].ContentMarkdown, 0) || !strings.Contains(rows[0].ContentMarkdown, "GIF89a") {
+ t.Fatalf("unexpected stored markdown: %q", rows[0].ContentMarkdown)
+ }
+}
diff --git a/backend/internal/pkg/textutil/text.go b/backend/internal/pkg/textutil/text.go
index ca7d9d4b4..717e4b183 100644
--- a/backend/internal/pkg/textutil/text.go
+++ b/backend/internal/pkg/textutil/text.go
@@ -58,3 +58,15 @@ func TruncateTrimmed(value string, maxRunes int) string {
}
return string(runes[:maxRunes])
}
+
+// SanitizeForTextColumn 让可能来自工具输出的文本可安全写入数据库文本列。
+//
+// 工具可能返回二进制内容(例如抓取到的 GIF),其字节解码成字符串后会带来
+// NUL 与非法 UTF-8;PostgreSQL 的 text 列会整体拒绝写入(SQLSTATE 22021),
+// 一次写入失败就会丢掉整条事件(例如轨迹停留在「进行中」)。
+func SanitizeForTextColumn(value string) string {
+ if !strings.ContainsRune(value, 0) {
+ return strings.ToValidUTF8(value, "\uFFFD")
+ }
+ return strings.ToValidUTF8(strings.ReplaceAll(value, "\x00", ""), "\uFFFD")
+}
diff --git a/backend/internal/pkg/textutil/text_test.go b/backend/internal/pkg/textutil/text_test.go
index 7c148914d..388dfc7ca 100644
--- a/backend/internal/pkg/textutil/text_test.go
+++ b/backend/internal/pkg/textutil/text_test.go
@@ -36,3 +36,14 @@ func TestTruncateTrimmed(t *testing.T) {
t.Fatalf("TruncateTrimmed() = %q, want %q", got, "你好世")
}
}
+
+func TestSanitizeForTextColumn(t *testing.T) {
+ clean := "普通文本 123"
+ if got := SanitizeForTextColumn(clean); got != clean {
+ t.Fatalf("clean text changed: %q", got)
+ }
+ // 抓取到二进制内容时解码出的 NUL 与非法 UTF-8 都不能进入 text 列;连续的非法字节合并为一个替换符。
+ if got := SanitizeForTextColumn("GIF89a\x00\xff\xfe tail"); got != "GIF89a\uFFFD tail" {
+ t.Fatalf("unexpected sanitized value: %q", got)
+ }
+}
From a15f12b11d435f1f9919455f882b366a12273285 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 16:07:13 +0800
Subject: [PATCH 06/19] ci: upgrade workflow action versions
---
.github/actions/desktop-setup/action.yml | 12 +++----
.github/workflows/ci.yml | 40 +++++++++++-----------
.github/workflows/codeql.yml | 2 +-
.github/workflows/desktop-channel.yml | 2 +-
.github/workflows/desktop-linux.yml | 6 ++--
.github/workflows/desktop-macos.yml | 6 ++--
.github/workflows/desktop-prepare.yml | 18 +++++-----
.github/workflows/desktop-release.yml | 7 ++--
.github/workflows/desktop-windows.yml | 6 ++--
.github/workflows/image.yml | 14 ++++----
.github/workflows/release-tag.yml | 2 +-
Makefile | 8 ++---
README.md | 2 +-
README.zh-CN.md | 2 +-
apps/desktop/README.md | 9 +++--
apps/desktop/scripts/sync-version.test.mjs | 2 +-
backend/docs/docs.go | 2 +-
backend/docs/swagger.json | 2 +-
backend/docs/swagger.yaml | 2 +-
packages/api-contract/README.md | 4 +++
scripts/sync-version.mjs | 6 ++--
21 files changed, 81 insertions(+), 73 deletions(-)
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
index 6d885bb57..d9009d6d5 100644
--- a/.github/actions/desktop-setup/action.yml
+++ b/.github/actions/desktop-setup/action.yml
@@ -17,10 +17,10 @@ runs:
env:
SOURCE_SHA: ${{ inputs.source-sha }}
run: node apps/desktop/scripts/desktop-source.mjs
- - uses: pnpm/action-setup@v4
+ - uses: pnpm/action-setup@v5
with:
version: 10.17.0
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
@@ -32,7 +32,7 @@ runs:
with:
workspaces: apps/desktop/src-tauri
shared-key: desktop-release-${{ inputs.target }}
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
id: go
with:
go-version-file: backend/go.mod
@@ -46,7 +46,7 @@ runs:
# setup-go keys by the host architecture. Both macOS targets run on arm64,
# so their compiled packages need separate keys and source refreshes.
- name: Cache Go sidecar build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
${{ steps.go-paths.outputs.build }}
@@ -59,12 +59,12 @@ runs:
shell: bash
run: pnpm --filter @deeix/desktop install --frozen-lockfile
- name: Download frontend
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: desktop-web
path: apps/web/out
- name: Download catalog snapshots
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: desktop-catalog
path: backend/internal/infra/catalogdata/data
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f9506c232..668213458 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -36,9 +36,9 @@ jobs:
docker: ${{ steps.filter.outputs.docker }}
lockfiles: ${{ steps.filter.outputs.lockfiles }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- id: filter
- uses: dorny/paths-filter@v3
+ uses: dorny/paths-filter@v4
with:
filters: |
shared: &shared
@@ -77,7 +77,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check commit subjects
@@ -113,8 +113,8 @@ jobs:
run:
working-directory: backend
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/checkout@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
@@ -140,15 +140,15 @@ jobs:
if: needs.changes.outputs.web == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
with:
version: ${{ env.PNPM_VERSION }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
@@ -159,7 +159,7 @@ jobs:
- name: Test
run: pnpm turbo run test --filter=./packages/*
- name: Cache Next.js build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: apps/web/.next/cache
# Source hash excludes installed and generated trees, which exist by the time this step runs.
@@ -178,7 +178,7 @@ jobs:
run:
working-directory: apps/desktop/src-tauri
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
@@ -213,11 +213,11 @@ jobs:
if: needs.changes.outputs.python == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: astral-sh/ruff-action@v3
+ - uses: actions/checkout@v5
+ - uses: astral-sh/ruff-action@v4.1.0
with:
args: check deploy/services backend/tools
- - uses: astral-sh/ruff-action@v3
+ - uses: astral-sh/ruff-action@v4.1.0
with:
args: format --check deploy/services backend/tools
@@ -227,13 +227,13 @@ jobs:
if: needs.changes.outputs.docker == 'true'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: docker/setup-buildx-action@v3
+ - uses: actions/checkout@v5
+ - uses: docker/setup-buildx-action@v4
# Builds the default Dockerfile target, the path users take; release images use the prebuilt
# web assets (see .github/workflows/image.yml). mode=min keeps this job from filling the
# repository-wide cache quota with the multi-GB install and compile layers.
- name: Build image (no push)
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
push: false
@@ -246,11 +246,11 @@ jobs:
if: needs.changes.outputs.lockfiles == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
+ - uses: actions/checkout@v5
+ - uses: pnpm/action-setup@v5
with:
version: ${{ env.PNPM_VERSION }}
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: pnpm
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 01ed2dff0..8a294d559 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -35,7 +35,7 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
diff --git a/.github/workflows/desktop-channel.yml b/.github/workflows/desktop-channel.yml
index 3bb207a19..6f06f36f6 100644
--- a/.github/workflows/desktop-channel.yml
+++ b/.github/workflows/desktop-channel.yml
@@ -24,7 +24,7 @@ jobs:
if: github.event.release.tag_name != 'desktop-beta'
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Refresh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/desktop-linux.yml b/.github/workflows/desktop-linux.yml
index f8fa491c3..40bf966ec 100644
--- a/.github/workflows/desktop-linux.yml
+++ b/.github/workflows/desktop-linux.yml
@@ -25,7 +25,7 @@ jobs:
run:
shell: bash
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- uses: ./.github/actions/desktop-setup
@@ -76,7 +76,7 @@ jobs:
run: |
node apps/desktop/scripts/collect-release-assets.mjs --platform linux-x64 \
--bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v6
with:
name: desktop-release-linux-x64
path: ${{ runner.temp }}/release-assets
@@ -85,7 +85,7 @@ jobs:
overwrite: true
- name: Upload compilation timings
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: desktop-timings-linux-x64
path: apps/desktop/src-tauri/target/cargo-timings
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
index 7a01ff80a..e3c5e36f0 100644
--- a/.github/workflows/desktop-macos.yml
+++ b/.github/workflows/desktop-macos.yml
@@ -30,7 +30,7 @@ jobs:
run:
shell: bash
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- name: Validate architecture
@@ -97,7 +97,7 @@ jobs:
run: |
node apps/desktop/scripts/collect-release-assets.mjs --platform "macos-$ARCH" \
--bundle "apps/desktop/src-tauri/target/$TARGET/release/bundle" --out "$RUNNER_TEMP/release-assets"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v6
with:
name: desktop-release-macos-${{ inputs.arch }}
path: ${{ runner.temp }}/release-assets
@@ -106,7 +106,7 @@ jobs:
overwrite: true
- name: Upload compilation timings
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: desktop-timings-macos-${{ inputs.arch }}
path: apps/desktop/src-tauri/target/cargo-timings
diff --git a/.github/workflows/desktop-prepare.yml b/.github/workflows/desktop-prepare.yml
index 3cea314b9..3edb55c07 100644
--- a/.github/workflows/desktop-prepare.yml
+++ b/.github/workflows/desktop-prepare.yml
@@ -26,7 +26,7 @@ jobs:
release-tag: ${{ steps.source.outputs.release-tag }}
channel: ${{ steps.source.outputs.channel }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- name: Verify source
@@ -56,27 +56,27 @@ jobs:
needs: resolve
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- - uses: pnpm/action-setup@v4
+ - uses: pnpm/action-setup@v5
with:
version: 10.17.0
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
# Restore compatible default-branch CI caches; new entries stay ref-scoped.
- name: Cache Next.js build
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: apps/web/.next/cache
key: next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.[jt]s', 'apps/web/**/*.[jt]sx', 'packages/**/*.ts', '!**/node_modules/**', '!apps/web/.next/**', '!apps/web/out/**') }}
restore-keys: |
next-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
- run: pnpm turbo run build --filter=@deeix/web
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v6
with:
name: desktop-web
path: apps/web/out
@@ -90,17 +90,17 @@ jobs:
needs: resolve
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Refresh bundled catalog snapshots
working-directory: backend
run: go run ./cmd/catalog-snapshot -keep-on-error
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v6
with:
name: desktop-catalog
path: backend/internal/infra/catalogdata/data
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index 28f19590c..4112571e6 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -95,7 +95,7 @@ jobs:
permissions:
contents: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- name: Verify publication source
@@ -103,11 +103,12 @@ jobs:
SOURCE_SHA: ${{ needs.prepare.outputs.source-sha }}
RELEASE_TAG: ${{ needs.prepare.outputs.release-tag }}
run: node apps/desktop/scripts/desktop-source.mjs
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: 24
+ package-manager-cache: false
- name: Download verified release assets
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
pattern: desktop-release-*
path: release-assets
diff --git a/.github/workflows/desktop-windows.yml b/.github/workflows/desktop-windows.yml
index fba5bcfc4..2aa0f8708 100644
--- a/.github/workflows/desktop-windows.yml
+++ b/.github/workflows/desktop-windows.yml
@@ -27,7 +27,7 @@ jobs:
run:
shell: bash
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
- uses: ./.github/actions/desktop-setup
@@ -171,7 +171,7 @@ jobs:
${{ runner.temp }}/release-assets/*.exe
${{ runner.temp }}/release-assets/*.msi
${{ runner.temp }}/release-assets/*.zip
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@v6
with:
name: desktop-release-windows-x64
path: ${{ runner.temp }}/release-assets
@@ -180,7 +180,7 @@ jobs:
overwrite: true
- name: Upload compilation timings
if: always()
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: desktop-timings-windows-x64
path: apps/desktop/src-tauri/target/cargo-timings
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml
index 96bfa4f9b..6701ff7d9 100644
--- a/.github/workflows/image.yml
+++ b/.github/workflows/image.yml
@@ -44,11 +44,11 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Set up Buildx
id: buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
# The download and compile caches are the expensive part of a cold build (measured without
# them: pnpm install 157s, go mod download 104s, go build 32s). The Turbopack cache is left out:
@@ -57,7 +57,7 @@ jobs:
# content-addressed, so restoring an older one still saves most of the compile.
- name: Cache BuildKit cache mounts
id: mounts
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
buildkit-cache/pnpm-store
@@ -105,7 +105,7 @@ jobs:
- name: Docker metadata
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.images }}
tags: |
@@ -129,7 +129,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and publish
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
@@ -151,7 +151,7 @@ jobs:
steps:
- name: Set up Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Resolve image names
id: image
@@ -176,7 +176,7 @@ jobs:
- name: Docker metadata
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@v6
with:
images: ${{ steps.image.outputs.images }}
tags: |
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 72e855aed..a17e2c861 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -25,7 +25,7 @@ jobs:
name: Tag and dispatch
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
diff --git a/Makefile b/Makefile
index 490b025c4..84148cfad 100644
--- a/Makefile
+++ b/Makefile
@@ -68,11 +68,11 @@ fmt: ## Format Go and JS sources
api-docs: ## Regenerate Swagger docs and the TypeScript API contract
$(PNPM) api:generate
-version: ## Propagate VERSION to every package manifest
- node scripts/sync-version.mjs
+version: ## Synchronize VERSION, manifests, Cargo.lock and generated API contracts
+ $(PNPM) api:generate
-version-check: ## Fail if any manifest drifted from VERSION
- node scripts/sync-version.mjs --check
+version-check: ## Check version consistency and generated API contracts
+ $(PNPM) api:check
clean: ## Remove build outputs
$(PNPM) clean
diff --git a/README.md b/README.md
index cfa1abcef..ff77457f5 100644
--- a/README.md
+++ b/README.md
@@ -225,7 +225,7 @@ repository root and routes to pnpm / turbo / cargo / go underneath.
| `make build-desktop` / `make release-desktop` | Quick local `.app` / signed release bundle. |
| `make verify` | check + test + build, what CI runs. |
| `make api-docs` | Regenerate Swagger artifacts and TypeScript API types. |
-| `make version` | Propagate `VERSION` to every package manifest. |
+| `make version` / `make version-check` | Synchronize/check versions, Cargo.lock and generated Swagger/TypeScript API contracts. |
The same verbs exist as pnpm scripts (`pnpm dev:api`, `pnpm check`, …) for
tooling that prefers them; CI uses those directly.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 44c48b739..1a22063bc 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -223,7 +223,7 @@ NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
| `make build-desktop` / `make release-desktop` | 本地快速 `.app` / 签名发版包。 |
| `make verify` | check + test + build,即 CI 跑的内容。 |
| `make api-docs` | 重新生成 Swagger 与 TypeScript API 类型。 |
-| `make version` | 把 `VERSION` 同步到所有包清单。 |
+| `make version` / `make version-check` | 同步或检查版本、Cargo.lock 和生成的 Swagger/TypeScript API 契约。 |
同一组动作也以 pnpm 脚本存在(`pnpm dev:api`、`pnpm check` …),CI 直接用它们。
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index ce96de21e..a3cb0febc 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -335,9 +335,12 @@ Publishing the draft is the step that ships the update; until then existing
installs see nothing. Pushing the tag by hand does the same.
Before tagging, update `VERSION`, run `make version` and commit the synchronized
-version files, including the desktop root package entry in `Cargo.lock`.
-`make version-check` checks that entry too, so a stale lockfile fails before
-the native release builds use `--locked`; dependency versions stay pinned.
+version files, including the desktop root package entry in `Cargo.lock` and the
+generated Swagger/TypeScript API contracts. This uses the same generation
+pipeline as `pnpm api:generate`; Go and workspace JS dependencies are required.
+`make version-check` runs the full API contract drift check as well as version
+checks, so stale manifests, lock metadata or generated API versions fail before
+release. Dependency versions stay pinned.
Windows assets: `-windows-x64-setup.exe`, `-windows-x64.msi`,
`-windows-x64-portable.zip` (and `-portable-offline.zip` when built). Every
diff --git a/apps/desktop/scripts/sync-version.test.mjs b/apps/desktop/scripts/sync-version.test.mjs
index cc8a2af6a..41da7a718 100644
--- a/apps/desktop/scripts/sync-version.test.mjs
+++ b/apps/desktop/scripts/sync-version.test.mjs
@@ -64,7 +64,7 @@ test("the normal all-target version command also checks and synchronizes Cargo.l
const check = f.run("--check");
assert.notEqual(check.status, 0);
assert.match(check.stderr, /Cargo\.lock/);
- assert.match(check.stderr, /Run: node scripts\/sync-version\.mjs\s*$/);
+ assert.match(check.stderr, /Run: pnpm api:generate\s*$/);
const sync = f.run();
assert.equal(sync.status, 0, sync.stderr);
assert.equal(f.run("--check").status, 0);
diff --git a/backend/docs/docs.go b/backend/docs/docs.go
index f44943a0d..15518d15b 100644
--- a/backend/docs/docs.go
+++ b/backend/docs/docs.go
@@ -35322,7 +35322,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
- Version: "0.4.5",
+ Version: "0.4.6-beta.1",
Host: "",
BasePath: "/api/v1",
Schemes: []string{},
diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json
index f3de77728..98498aa31 100644
--- a/backend/docs/swagger.json
+++ b/backend/docs/swagger.json
@@ -4,7 +4,7 @@
"description": "DEEIX Chat 后端 API 文档",
"title": "DEEIX Chat API",
"contact": {},
- "version": "0.4.5"
+ "version": "0.4.6-beta.1"
},
"basePath": "/api/v1",
"paths": {
diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml
index b3319ceec..b1835dd10 100644
--- a/backend/docs/swagger.yaml
+++ b/backend/docs/swagger.yaml
@@ -12091,7 +12091,7 @@ info:
contact: {}
description: DEEIX Chat 后端 API 文档
title: DEEIX Chat API
- version: "0.4.5"
+ version: "0.4.6-beta.1"
paths:
/admin/announcements:
get:
diff --git a/packages/api-contract/README.md b/packages/api-contract/README.md
index c21d3f73e..03051e0fe 100644
--- a/packages/api-contract/README.md
+++ b/packages/api-contract/README.md
@@ -13,6 +13,10 @@ pnpm api:check
`pnpm api:generate` updates `backend/docs/{docs.go,swagger.json,swagger.yaml}` and `packages/api-contract/src/types.generated.ts` together. `cd backend && make swagger` invokes this same pipeline.
+Version bumps use it too: after updating `VERSION`, run `make version` and then
+`make version-check` from the workspace root. These commands synchronize package
+manifests and desktop lock metadata together with the generated API versions.
+
DTO field conventions:
- Fields serialized on every response are required by default; do not repeat their requiredness in frontend types.
diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs
index a97643965..0278c5412 100644
--- a/scripts/sync-version.mjs
+++ b/scripts/sync-version.mjs
@@ -7,8 +7,8 @@ const args = process.argv.slice(2);
const checkOnly = args.includes("--check");
const mode = args.find((arg) => !arg.startsWith("--")) ?? "all";
-// Every versioned target in the workspace. Adding a new app (desktop, mobile)
-// means adding one entry here; nothing else in the release pipeline changes.
+// Low-level manifest synchronization, also called by the API generator.
+// `make version` completes it by regenerating Swagger and TypeScript contracts.
const targets = {
workspace: () => {
syncPackageVersion();
@@ -135,7 +135,7 @@ if (checkOnly && mismatches.length > 0) {
for (const filePath of mismatches) {
console.error(`- ${filePath}`);
}
- const fixCommand = `node scripts/sync-version.mjs${mode === "all" ? "" : ` ${mode}`}`;
+ const fixCommand = mode === "all" || mode === "backend" ? "pnpm api:generate" : `node scripts/sync-version.mjs ${mode}`;
console.error(`Run: ${fixCommand}`);
process.exit(1);
}
From 240919ef6ecd7c318288b07641ccdd261237da47 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 16:22:09 +0800
Subject: [PATCH 07/19] fix: verify DMG signer certificate team ID
---
apps/desktop/README.md | 3 +
apps/desktop/scripts/verify-macos-bundle.sh | 15 +-
.../scripts/verify-macos-bundle.test.mjs | 164 ++++++++++++++++++
3 files changed, 180 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/scripts/verify-macos-bundle.test.mjs
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index a3cb0febc..3d9b17e91 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -551,6 +551,9 @@ require a newer macOS than the app declares, and the bundle must pass
`codesign --verify --deep --strict`. With a Developer ID it also requires the
certificate's team id on the app and the DMG, a stapled notarization ticket on
both, and a Gatekeeper assessment of `Notarized Developer ID`.
+The DMG signer is checked against Apple's Developer ID Application certificate
+chain and its certificate's Team ID (`subject.OU`), because a signed disk image
+can report `TeamIdentifier=not set` in `codesign -dv` output.
`pnpm build:signed` runs the same check.
Only the updater key is mandatory. Platform signing switches on by itself once
diff --git a/apps/desktop/scripts/verify-macos-bundle.sh b/apps/desktop/scripts/verify-macos-bundle.sh
index 86d98026e..bf8fa6edd 100755
--- a/apps/desktop/scripts/verify-macos-bundle.sh
+++ b/apps/desktop/scripts/verify-macos-bundle.sh
@@ -81,6 +81,18 @@ check_team() {
[[ "$team" == "$APPLE_TEAM_ID" ]] || fail "$1 is signed by team '${team:-none}', expected $APPLE_TEAM_ID"
}
+verify_dmg_signature() {
+ local requirement
+ # Disk images can report TeamIdentifier=not set even with a valid Developer
+ # ID signature. Validate the signing certificate's OU (the Team ID) instead
+ # of the CodeDirectory field, and require Apple's Developer ID chain.
+ [[ "$APPLE_TEAM_ID" =~ ^[A-Z0-9]{10}$ ]] || fail "APPLE_TEAM_ID must contain 10 uppercase letters or digits"
+ requirement="=anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists \
+and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"$APPLE_TEAM_ID\""
+ codesign --verify --strict --verbose=2 --test-requirement "$requirement" "$1" \
+ || fail "$1 must be signed by a Developer ID Application certificate for team $APPLE_TEAM_ID"
+}
+
verify_app() {
local app="$1" plist minimum executable binary archs minos
plist="$app/Contents/Info.plist"
@@ -122,8 +134,7 @@ for artifact in "${artifacts[@]}"; do
case "$artifact" in
*.dmg)
if $signed; then
- codesign --verify --strict --verbose=2 "$artifact"
- check_team "$artifact"
+ verify_dmg_signature "$artifact"
xcrun stapler validate "$artifact"
assess --type open --context context:primary-signature "$artifact"
fi
diff --git a/apps/desktop/scripts/verify-macos-bundle.test.mjs b/apps/desktop/scripts/verify-macos-bundle.test.mjs
new file mode 100644
index 000000000..bf46c36e5
--- /dev/null
+++ b/apps/desktop/scripts/verify-macos-bundle.test.mjs
@@ -0,0 +1,164 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { test as nodeTest } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const script = join(dirname(fileURLToPath(import.meta.url)), "verify-macos-bundle.sh");
+const test = process.platform === "win32" ? nodeTest.skip : nodeTest;
+// Exercise macOS's system Bash 3.2 locally; Linux CI also runs these tests.
+const bash = process.platform === "darwin" ? "/bin/bash" : "bash";
+
+function fixture(t, arch = "x86_64") {
+ const root = mkdtempSync(join(tmpdir(), "macos-verification-"));
+ t.after(() => rmSync(root, { recursive: true, force: true }));
+ const bin = join(root, "bin");
+ const source = join(root, "source");
+ const app = join(source, "DEEIX Chat.app");
+ const dmg = join(root, "DEEIX Chat.dmg");
+ const archive = join(root, "DEEIX Chat.app.tar.gz");
+ const log = join(root, "calls.jsonl");
+ const temp = join(root, "temp");
+ mkdirSync(bin);
+ mkdirSync(temp);
+ mkdirSync(join(app, "Contents", "MacOS"), { recursive: true });
+ writeFileSync(join(app, "Contents", "Info.plist"), JSON.stringify({ LSMinimumSystemVersion: "12.0", CFBundleExecutable: "app" }));
+ for (const name of ["app", "deeix-chat-server"]) writeFileSync(join(app, "Contents", "MacOS", name), "fixture");
+ writeFileSync(dmg, "fixture");
+ execFileSync("tar", ["-czf", archive, "-C", source, "DEEIX Chat.app"]);
+ const stub = `#!${process.execPath}
+const fs = require('node:fs');
+const path = require('node:path');
+const tool = path.basename(process.argv[1]);
+const args = process.argv.slice(2);
+const env = process.env;
+const file = args.at(-1);
+const kind = file?.endsWith('.dmg') ? 'dmg' : 'app';
+fs.appendFileSync(env.TEST_LOG, JSON.stringify({ tool, args }) + '\\n');
+switch (tool) {
+ case 'codesign':
+ if (args.includes('-dv')) {
+ // Real-world regression: the DMG has a valid certificate but no
+ // TeamIdentifier in its CodeDirectory. Apps still expose that field.
+ console.error('TeamIdentifier=' + (kind === 'dmg' ? 'not set' : env.TEST_APP_TEAM));
+ } else if (args.includes('--verify')) {
+ if (env.TEST_BAD_SIGNATURE === kind) process.exit(1);
+ if (args.includes('--test-requirement')) {
+ const requirement = args[args.indexOf('--test-requirement') + 1];
+ for (const term of ['=anchor apple generic',
+ 'certificate 1[field.1.2.840.113635.100.6.2.6] exists',
+ 'certificate leaf[field.1.2.840.113635.100.6.1.13] exists']) {
+ if (!requirement.includes(term)) throw new Error('missing Developer ID constraint: ' + term);
+ }
+ if (!requirement.includes('certificate leaf[subject.OU] = "' + env.TEST_DMG_TEAM + '"')
+ || env.TEST_BAD_DMG_CERTIFICATE) process.exit(3);
+ }
+ } else throw new Error('unexpected codesign operation');
+ break;
+ case 'plutil': console.log(JSON.parse(fs.readFileSync(file, 'utf8'))[args[args.indexOf('-extract') + 1]]); break;
+ case 'lipo': console.log(file.endsWith('/deeix-chat-server') ? env.TEST_SIDECAR_ARCH || env.TEST_ARCH : env.TEST_ARCH); break;
+ case 'vtool': console.log(' minos ' + (file.endsWith('/deeix-chat-server') ? env.TEST_SIDECAR_MINIMUM || '12.0' : '12.0')); break;
+ case 'xcrun':
+ if (env.TEST_MISSING_TICKET === kind) { console.error('missing notarization ticket'); process.exit(65); }
+ break;
+ case 'spctl':
+ if (env.TEST_GATEKEEPER_REJECT === kind) { console.error('rejected'); process.exit(3); }
+ console.error('accepted\\nsource=Notarized Developer ID');
+ break;
+ case 'hdiutil':
+ if (args[0] === 'attach') fs.cpSync(env.TEST_SOURCE_APP, path.join(args[args.indexOf('-mountpoint') + 1], 'DEEIX Chat.app'), { recursive: true });
+ else if (args[0] === 'detach') fs.rmSync(path.join(file, 'DEEIX Chat.app'), { recursive: true });
+ else throw new Error('unexpected disk image operation');
+ break;
+ default: throw new Error('unexpected tool: ' + tool);
+}
+`;
+ for (const tool of ["codesign", "plutil", "lipo", "vtool", "hdiutil", "xcrun", "spctl"]) {
+ writeFileSync(join(bin, tool), stub, { mode: 0o755 });
+ }
+ const env = { ...process.env, PATH: `${bin}:${process.env.PATH}`, TMPDIR: temp,
+ APPLE_TEAM_ID: "ABCDE12345", TEST_APP_TEAM: "ABCDE12345", TEST_DMG_TEAM: "ABCDE12345",
+ TEST_ARCH: arch, TEST_SOURCE_APP: app, TEST_LOG: log };
+ const run = (extra = {}, artifacts = [dmg, archive]) => spawnSync(bash, [script, "--arch", arch, ...artifacts], {
+ env: { ...env, ...extra }, encoding: "utf8",
+ });
+ const calls = () => existsSync(log) ? readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
+ return { run, calls, dmg, archive };
+}
+
+test("a Developer ID DMG without TeamIdentifier verifies together with its updater on both architectures", (t) => {
+ for (const arch of ["x86_64", "arm64"]) {
+ const f = fixture(t, arch);
+ const result = f.run();
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+ assert.match(result.stdout, /verified 2 macOS artifact\(s\)/);
+ const calls = f.calls();
+ assert.equal(calls.filter(({ tool }) => tool === "spctl").length, 3, "assess the DMG and both extracted apps");
+ assert.equal(calls.filter(({ tool }) => tool === "xcrun").length, 3, "validate all three stapled tickets");
+ assert.equal(calls.filter(({ tool, args }) => tool === "codesign" && args.includes("-dv") && args.at(-1) === f.dmg).length, 0);
+ assert.ok(calls.some(({ tool, args }) => tool === "codesign" && args.includes("--test-requirement") && args.at(-1) === f.dmg));
+ assert.ok(calls.some(({ tool, args }) => tool === "hdiutil" && args[0] === "detach"));
+ }
+});
+
+test("wrong-team, ad-hoc and non-Developer-ID DMG certificates fail before mounting", (t) => {
+ for (const extra of [
+ { TEST_DMG_TEAM: "OTHER12345" },
+ { TEST_BAD_DMG_CERTIFICATE: "adhoc" },
+ { TEST_BAD_DMG_CERTIFICATE: "development" },
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /Developer ID Application certificate for team ABCDE12345/);
+ assert.equal(f.calls().some(({ tool }) => tool === "hdiutil"), false);
+ }
+});
+
+test("the expected Team ID cannot inject a different code requirement", (t) => {
+ const f = fixture(t);
+ const result = f.run({ APPLE_TEAM_ID: 'ABCDE12345" or always' });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /APPLE_TEAM_ID must contain 10 uppercase letters or digits/);
+ assert.equal(f.calls().length, 0);
+});
+
+test("signature integrity, app team, stapled tickets and Gatekeeper failures still block release", (t) => {
+ for (const [extra, expected] of [
+ [{ TEST_BAD_SIGNATURE: "dmg" }, /Developer ID Application certificate/],
+ [{ TEST_BAD_SIGNATURE: "app" }, undefined],
+ [{ TEST_APP_TEAM: "OTHER12345" }, /signed by team 'OTHER12345'/],
+ [{ TEST_MISSING_TICKET: "dmg" }, /missing notarization ticket/],
+ [{ TEST_MISSING_TICKET: "app" }, /missing notarization ticket/],
+ [{ TEST_GATEKEEPER_REJECT: "dmg" }, /Gatekeeper rejected/],
+ [{ TEST_GATEKEEPER_REJECT: "app" }, /Gatekeeper rejected/],
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0, JSON.stringify(extra));
+ if (expected) assert.match(result.stderr, expected);
+ }
+});
+
+test("the shipped sidecar must still match the architecture and declared minimum macOS", (t) => {
+ for (const [extra, expected] of [
+ [{ TEST_SIDECAR_ARCH: "arm64" }, /expected x86_64/],
+ [{ TEST_SIDECAR_MINIMUM: "13.0" }, /requires macOS 13.0, but the app declares 12.0/],
+ ]) {
+ const f = fixture(t);
+ const result = f.run(extra);
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, expected);
+ }
+});
+
+test("ad-hoc builds still check the shipped apps without requiring Developer ID credentials", (t) => {
+ const f = fixture(t);
+ const result = f.run({ APPLE_TEAM_ID: "" });
+ assert.equal(result.status, 0, result.stderr + result.stdout);
+ const calls = f.calls();
+ assert.equal(calls.some(({ tool }) => ["xcrun", "spctl"].includes(tool)), false);
+ assert.equal(calls.filter(({ tool, args }) => tool === "codesign" && args.includes("--verify")).length, 2);
+});
From 3650cc7278925356dc324457e09b759da6b91150 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 16:35:57 +0800
Subject: [PATCH 08/19] ci: build macOS targets on native runners
---
.github/actions/desktop-setup/action.yml | 12 ++++++------
.github/workflows/desktop-macos.yml | 2 +-
apps/desktop/README.md | 8 ++++----
apps/desktop/scripts/build-sidecar.mjs | 8 ++++----
4 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
index d9009d6d5..5fe77dbc7 100644
--- a/.github/actions/desktop-setup/action.yml
+++ b/.github/actions/desktop-setup/action.yml
@@ -3,7 +3,7 @@ description: Restore target-specific toolchains and caches, and consume the shar
inputs:
target:
- description: Rust target triple; also separates cross-compiled Go and Rust caches.
+ description: Rust target triple; also separates Go and Rust caches.
required: true
source-sha:
description: Immutable source commit used to refresh the Go compilation cache.
@@ -43,18 +43,18 @@ runs:
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- # setup-go keys by the host architecture. Both macOS targets run on arm64,
- # so their compiled packages need separate keys and source refreshes.
+ # Separate host toolchains and target packages; the source SHA refreshes
+ # compiled sidecar packages while retaining reusable dependency caches.
- name: Cache Go sidecar build
uses: actions/cache@v5
with:
path: |
${{ steps.go-paths.outputs.build }}
${{ steps.go-paths.outputs.modules }}
- key: desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
+ key: desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
restore-keys: |
- desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
- desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
+ desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
+ desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
- name: Install desktop CLI
shell: bash
run: pnpm --filter @deeix/desktop install --frozen-lockfile
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
index e3c5e36f0..b4d33b9c1 100644
--- a/.github/workflows/desktop-macos.yml
+++ b/.github/workflows/desktop-macos.yml
@@ -19,7 +19,7 @@ permissions:
jobs:
build:
- runs-on: macos-15
+ runs-on: ${{ inputs.arch == 'arm64' && 'macos-26' || 'macos-26-intel' }}
timeout-minutes: 60
env:
CARGO_INCREMENTAL: 0
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 3d9b17e91..1bc69bcc0 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -356,7 +356,7 @@ verification live in that platform's file.
| Workflow | Responsibility |
| --- | --- |
| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Verify the run's source commit and tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
-| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Called separately for arm64 and x64; build the sidecar/app, sign it, create the final DMG and updater archive, then verify both. |
+| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Build arm64 on `macos-26` and x64 on `macos-26-intel` natively; sign the app, create the final DMG and updater archive, then verify both. |
| [`desktop-linux.yml`](../../.github/workflows/desktop-linux.yml) | Build x64 sidecar/executable, bundle deb/AppImage and verify the Wayland library exclusion. |
| [`desktop-windows.yml`](../../.github/workflows/desktop-windows.yml) | Build x64 sidecar/executable, sign and verify NSIS/MSI/portable packages, and attest their provenance. |
@@ -377,9 +377,9 @@ the same tag reuse its caches. Successive tags cannot restore caches written by
each other, so their first native builds may need recompilation. Publication
verifies the tag has not moved, and Windows provenance records the matching
workflow and desktop source commits. Rust is pinned in the setup action, and
-Rust/Go cache keys include the target triple so the two macOS architectures
-cannot overwrite one another's cache. Dependency changes or cache eviction can
-also cause rebuilds.
+Rust/Go cache keys include the target triple, and Go keys also include the runner
+architecture, so different hosts and targets cannot overwrite one another's
+cache. Dependency changes or cache eviction can also cause rebuilds.
To build an existing tag containing these workflows and scripts:
diff --git a/apps/desktop/scripts/build-sidecar.mjs b/apps/desktop/scripts/build-sidecar.mjs
index 1fc0c9bc9..bea819be2 100644
--- a/apps/desktop/scripts/build-sidecar.mjs
+++ b/apps/desktop/scripts/build-sidecar.mjs
@@ -2,10 +2,10 @@
//
// Tauri's `bundle.externalBin` expects `-` next to
// the config (e.g. binaries/deeix-chat-server-aarch64-apple-darwin) and strips
-// the suffix when bundling. The server links SQLite through cgo, so it is
-// always built natively on the platform that bundles it; CI runs this once per
-// matrix leg. Pass --target when cross-building on macOS (x86_64 on an
-// arm64 runner works because Apple ships both toolchains).
+// the suffix when bundling. The server links SQLite through cgo, so CI builds
+// it natively on each target's runner. Pass --target for local
+// cross-builds on macOS (x86_64 on an arm64 host works because Apple ships both
+// toolchains).
//
// Usage: node scripts/build-sidecar.mjs [--target ]
From 08885787874dc4f15f713e630c571934686d110b Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 18:05:08 +0800
Subject: [PATCH 09/19] docs: clarify macOS build process and caching logic
---
.github/actions/desktop-setup/action.yml | 12 ++++++------
.github/workflows/desktop-macos.yml | 2 +-
apps/desktop/README.md | 8 ++++----
apps/desktop/scripts/build-sidecar.mjs | 8 ++++----
4 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
index 5fe77dbc7..7e7cfb881 100644
--- a/.github/actions/desktop-setup/action.yml
+++ b/.github/actions/desktop-setup/action.yml
@@ -3,7 +3,7 @@ description: Restore target-specific toolchains and caches, and consume the shar
inputs:
target:
- description: Rust target triple; also separates Go and Rust caches.
+ description: Rust target triple; also separates cross-compiled Go and Rust caches.
required: true
source-sha:
description: Immutable source commit used to refresh the Go compilation cache.
@@ -43,18 +43,18 @@ runs:
run: |
echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "modules=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- # Separate host toolchains and target packages; the source SHA refreshes
- # compiled sidecar packages while retaining reusable dependency caches.
+ # Both macOS targets run on arm64, so key compiled packages by target
+ # triple; the source SHA refreshes builds while retaining dependency caches.
- name: Cache Go sidecar build
uses: actions/cache@v5
with:
path: |
${{ steps.go-paths.outputs.build }}
${{ steps.go-paths.outputs.modules }}
- key: desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
+ key: desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-${{ inputs.source-sha }}
restore-keys: |
- desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
- desktop-go-${{ runner.os }}-${{ runner.arch }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-${{ hashFiles('backend/go.sum') }}-
+ desktop-go-${{ runner.os }}-${{ inputs.target }}-${{ steps.go.outputs.go-version }}-
- name: Install desktop CLI
shell: bash
run: pnpm --filter @deeix/desktop install --frozen-lockfile
diff --git a/.github/workflows/desktop-macos.yml b/.github/workflows/desktop-macos.yml
index b4d33b9c1..fe5f019eb 100644
--- a/.github/workflows/desktop-macos.yml
+++ b/.github/workflows/desktop-macos.yml
@@ -19,7 +19,7 @@ permissions:
jobs:
build:
- runs-on: ${{ inputs.arch == 'arm64' && 'macos-26' || 'macos-26-intel' }}
+ runs-on: macos-26
timeout-minutes: 60
env:
CARGO_INCREMENTAL: 0
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 1bc69bcc0..1e9a3938b 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -356,7 +356,7 @@ verification live in that platform's file.
| Workflow | Responsibility |
| --- | --- |
| [`desktop-prepare.yml`](../../.github/workflows/desktop-prepare.yml) | Verify the run's source commit and tag, check version consistency, then build the frontend and refresh catalog snapshots once, in parallel. |
-| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Build arm64 on `macos-26` and x64 on `macos-26-intel` natively; sign the app, create the final DMG and updater archive, then verify both. |
+| [`desktop-macos.yml`](../../.github/workflows/desktop-macos.yml) | Build arm64 natively and cross-compile x64 on ARM `macos-26` runners; sign the app, create the final DMG and updater archive, then verify both. |
| [`desktop-linux.yml`](../../.github/workflows/desktop-linux.yml) | Build x64 sidecar/executable, bundle deb/AppImage and verify the Wayland library exclusion. |
| [`desktop-windows.yml`](../../.github/workflows/desktop-windows.yml) | Build x64 sidecar/executable, sign and verify NSIS/MSI/portable packages, and attest their provenance. |
@@ -377,9 +377,9 @@ the same tag reuse its caches. Successive tags cannot restore caches written by
each other, so their first native builds may need recompilation. Publication
verifies the tag has not moved, and Windows provenance records the matching
workflow and desktop source commits. Rust is pinned in the setup action, and
-Rust/Go cache keys include the target triple, and Go keys also include the runner
-architecture, so different hosts and targets cannot overwrite one another's
-cache. Dependency changes or cache eviction can also cause rebuilds.
+Rust/Go cache keys include the target triple so the two macOS targets sharing
+ARM runners cannot overwrite one another's cache. Dependency changes or cache
+eviction can also cause rebuilds.
To build an existing tag containing these workflows and scripts:
diff --git a/apps/desktop/scripts/build-sidecar.mjs b/apps/desktop/scripts/build-sidecar.mjs
index bea819be2..c91a16313 100644
--- a/apps/desktop/scripts/build-sidecar.mjs
+++ b/apps/desktop/scripts/build-sidecar.mjs
@@ -2,10 +2,10 @@
//
// Tauri's `bundle.externalBin` expects `-` next to
// the config (e.g. binaries/deeix-chat-server-aarch64-apple-darwin) and strips
-// the suffix when bundling. The server links SQLite through cgo, so CI builds
-// it natively on each target's runner. Pass --target for local
-// cross-builds on macOS (x86_64 on an arm64 host works because Apple ships both
-// toolchains).
+// the suffix when bundling. The server links SQLite through cgo, so builds
+// need a C toolchain for the target. CI builds both macOS targets on arm64
+// runners. Pass --target for cross-builds on macOS (x86_64 on an
+// arm64 host works because Apple ships both toolchains).
//
// Usage: node scripts/build-sidecar.mjs [--target ]
From 9c170bb2fe00fd2159d9428fee2ba192dae771c2 Mon Sep 17 00:00:00 2001
From: Noxiven <41963680+Noxiven@users.noreply.github.com>
Date: Fri, 16 Oct 2026 18:17:56 +0800
Subject: [PATCH 10/19] fix: ensure release publication verifies tag and avoids
workflow-write dependency
---
.../scripts/publish-desktop-release.mjs | 7 ++--
apps/desktop/scripts/release-assets.test.mjs | 36 ++++++++++++++-----
2 files changed, 33 insertions(+), 10 deletions(-)
diff --git a/apps/desktop/scripts/publish-desktop-release.mjs b/apps/desktop/scripts/publish-desktop-release.mjs
index 460ce1b8b..2bb489aa0 100644
--- a/apps/desktop/scripts/publish-desktop-release.mjs
+++ b/apps/desktop/scripts/publish-desktop-release.mjs
@@ -91,7 +91,8 @@ async function main() {
return;
}
const gh = (args, input) => execFileSync("gh", args, { encoding: "utf8", input, maxBuffer: 16 * 1024 * 1024 });
- const commit = JSON.parse(gh(["api", `repos/${repository}/commits/${encodeURIComponent(tag)}`]));
+ // Resolve the actual tag, including annotated tags, never a same-named branch.
+ const commit = JSON.parse(gh(["api", `repos/${repository}/commits/${encodeURIComponent(`refs/tags/${tag}`)}`]));
if (commit.sha !== sha) throw new Error(`${tag} moved since the build started (${sha} -> ${commit.sha})`);
const pages = JSON.parse(gh(["api", `repos/${repository}/releases?per_page=100`, "--paginate", "--slurp"]));
let release = existingDraft(pages.flat(), tag);
@@ -100,8 +101,10 @@ async function main() {
throw new Error(`draft contains assets outside this build; remove or reconcile them before retrying: ${unexpected.map(({ name }) => name).join(", ")}`);
}
if (!release) {
+ // The tag already exists and was verified above. An explicit target_commitish
+ // can require workflow-write permission, which GITHUB_TOKEN cannot grant.
release = JSON.parse(gh(["api", "--method", "POST", `repos/${repository}/releases`, "--input", "-"], JSON.stringify({
- tag_name: tag, target_commitish: sha, name: tag, draft: true,
+ tag_name: tag, name: tag, draft: true,
prerelease: version.includes("-"), body: releaseNotes(version),
})));
}
diff --git a/apps/desktop/scripts/release-assets.test.mjs b/apps/desktop/scripts/release-assets.test.mjs
index beab0db8c..59949d56f 100644
--- a/apps/desktop/scripts/release-assets.test.mjs
+++ b/apps/desktop/scripts/release-assets.test.mjs
@@ -149,7 +149,7 @@ test("dry-run publication assembles metadata without invoking GitHub", (t) => {
assert.equal(JSON.parse(readFileSync(join(f.out, "latest.json"), "utf8")).version, version);
});
-test("publication creates one draft after validation and blocks moved tags, published or stale assets", { skip: process.platform === "win32" }, (t) => {
+test("publication uses a verified tag without workflow-write permission and blocks unsafe retries", { skip: process.platform === "win32" }, (t) => {
const f = fixture(t);
f.collectAll();
f.write(join(f.root, "VERSION"), version);
@@ -162,19 +162,33 @@ const args = process.argv.slice(2);
const input = args.includes('--input') ? JSON.parse(fs.readFileSync(0, 'utf8')) : null;
fs.appendFileSync(process.env.TEST_LOG, JSON.stringify({ args, input }) + '\\n');
if (args[0] === 'release') process.exit(0);
-if (args[1].includes('/commits/')) console.log(JSON.stringify({ sha: process.env.TEST_COMMIT }));
-else if (args.includes('POST')) console.log(JSON.stringify({ id: 123, html_url: 'https://example.invalid/draft', ...input }));
+if (args[1].includes('/commits/')) {
+ // A same-named branch can still resolve when the actual tag is missing.
+ if (args[1].includes('/commits/refs%2Ftags%2F') && process.env.TEST_MISSING_TAG === '1') {
+ console.error('gh: Not Found (HTTP 404)');
+ process.exit(1);
+ }
+ console.log(JSON.stringify({ sha: process.env.TEST_COMMIT }));
+} else if (args.includes('POST')) {
+ // GitHub checks workflow-write permission for an explicit target commit
+ // with workflow changes, even when contents: write has been granted.
+ if (input.target_commitish) {
+ console.error('gh: Resource not accessible by integration (HTTP 403)');
+ process.exit(1);
+ }
+ console.log(JSON.stringify({ id: 123, html_url: 'https://example.invalid/draft', ...input }));
+}
else console.log(JSON.stringify([JSON.parse(process.env.TEST_RELEASES)]));
`, { mode: 0o755 });
- const run = (commit, releases) => {
+ const run = (commit, releases, extraEnv = {}) => {
writeFileSync(log, "");
const result = spawnSync(process.execPath, [script, "--tag", tag, "--sha", sha, "--dir", f.out], {
cwd: f.root, encoding: "utf8",
env: { ...process.env, GH_REPO: repository, GITHUB_STEP_SUMMARY: "", PATH: `${bin}:${process.env.PATH}`,
- TEST_COMMIT: commit, TEST_RELEASES: JSON.stringify(releases), TEST_LOG: log },
+ TEST_COMMIT: commit, TEST_RELEASES: JSON.stringify(releases), TEST_LOG: log, TEST_MISSING_TAG: "", ...extraEnv },
});
const calls = readFileSync(log, "utf8").trim().split("\n").map(JSON.parse);
- return { result, writes: calls.filter(({ args }) => args[0] === "release" || args.includes("POST")) };
+ return { result, calls, writes: calls.filter(({ args }) => args[0] === "release" || args.includes("POST")) };
};
const draft = { tag_name: tag, draft: true, assets: [] };
for (const [commit, releases, message] of [
@@ -187,11 +201,12 @@ else console.log(JSON.stringify([JSON.parse(process.env.TEST_RELEASES)]));
assert.match(result.stderr, message);
assert.deepEqual(writes, []);
}
- const { result, writes } = run(sha, []);
+ const { result, calls, writes } = run(sha, []);
assert.equal(result.status, 0, result.stderr);
+ assert.deepEqual(calls[0].args, ["api", `repos/${repository}/commits/${encodeURIComponent(`refs/tags/${tag}`)}`]);
assert.equal(writes.length, 2, "one draft creation and one asset upload");
assert.equal(writes[0].input.tag_name, tag);
- assert.equal(writes[0].input.target_commitish, sha);
+ assert.equal(Object.hasOwn(writes[0].input, "target_commitish"), false);
assert.equal(writes[0].input.draft, true);
assert.equal(writes[0].input.prerelease, true);
assert.ok(writes[1].args.includes(join(f.out, "SHA256SUMS")));
@@ -199,4 +214,9 @@ else console.log(JSON.stringify([JSON.parse(process.env.TEST_RELEASES)]));
const retry = run(sha, [draft]);
assert.equal(retry.result.status, 0, retry.result.stderr);
assert.equal(retry.writes.length, 1, "retry uploads to the existing draft without creating another one");
+ const missing = run(sha, [], { TEST_MISSING_TAG: "1" });
+ assert.notEqual(missing.result.status, 0);
+ assert.match(missing.result.stderr, /Not Found/);
+ assert.deepEqual(missing.writes, [], "a missing tag must never create a release or a new tag");
+ assert.equal(missing.calls.length, 1);
});
From fdc6a161e762c6c21ba4d78d51227204c8cd765f Mon Sep 17 00:00:00 2001
From: Stella-sea <18220822672@163.com>
Date: Mon, 19 Oct 2026 00:50:50 +0800
Subject: [PATCH 11/19] fix: add per-model preference for Base64 image results
---
.../models/models-capabilities-config.tsx | 27 +++
.../sections/models/models-sheet.tsx | 26 +++
.../web/i18n/messages/en-US/admin-models.json | 2 +
.../web/i18n/messages/zh-CN/admin-models.json | 2 +
backend/README.md | 7 +
.../conversation/service_media_generation.go | 13 ++
.../service_media_generation_test.go | 218 ++++++++++++++++++
backend/internal/infra/llm/openai_images.go | 43 ++--
.../internal/infra/llm/openai_images_test.go | 152 +++++++++++-
.../internal/infra/llm/openai_responses.go | 4 +-
backend/internal/infra/llm/xai_images.go | 15 +-
backend/internal/infra/llm/xai_images_test.go | 2 +-
backend/internal/ports/llm/types.go | 2 +
13 files changed, 481 insertions(+), 32 deletions(-)
diff --git a/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx b/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
index 23be7c787..7f907b59a 100644
--- a/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
+++ b/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
@@ -249,6 +249,33 @@ export function setImageStreamEnabledInCapabilities(raw: string, enabled: boolea
return stringifyCapabilitiesObject(payload);
}
+export function imagePreferBase64FromCapabilities(raw: string): boolean {
+ const payload = parseCapabilitiesObject(raw);
+ return isRecord(payload?.image) && payload.image.prefer_base64 === true;
+}
+
+export function setImagePreferBase64InCapabilities(raw: string, enabled: boolean): string | null {
+ const payload = parseCapabilitiesObject(raw);
+ if (!payload) {
+ return null;
+ }
+ if (enabled) {
+ payload.image = {
+ ...(isRecord(payload.image) ? payload.image : {}),
+ prefer_base64: true,
+ };
+ } else {
+ const image = payload.image;
+ if (isRecord(image)) {
+ delete image.prefer_base64;
+ if (Object.keys(image).length === 0) {
+ delete payload.image;
+ }
+ }
+ }
+ return stringifyCapabilitiesObject(payload);
+}
+
function optionPathSegments(path: string): string[] {
return path
.split(".")
diff --git a/apps/web/features/admin/components/sections/models/models-sheet.tsx b/apps/web/features/admin/components/sections/models/models-sheet.tsx
index 39ed421f4..b33cddc3f 100644
--- a/apps/web/features/admin/components/sections/models/models-sheet.tsx
+++ b/apps/web/features/admin/components/sections/models/models-sheet.tsx
@@ -96,11 +96,13 @@ import {
} from "@/features/admin/types/llm";
import { JsonCodeEditor } from "@/shared/components/json-code-editor";
import {
+ imagePreferBase64FromCapabilities,
imageStreamEnabledFromCapabilities,
MODEL_CAPABILITIES_PLACEHOLDER,
ModelCapabilitiesGuideButton,
ModelCapabilitiesQuickConfig,
normalizeModelCapabilitiesJSON,
+ setImagePreferBase64InCapabilities,
setImageStreamEnabledInCapabilities,
} from "@/features/admin/components/sections/models/models-capabilities-config";
import {
@@ -553,6 +555,9 @@ export function ModelSheet({ open, mode, target, models, vendors, displayGroups,
}
const imageStreamEnabled = imageStreamEnabledFromCapabilities(form.capabilitiesJSON);
const showImageStreamControl = routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()));
+ const imagePreferBase64 = imagePreferBase64FromCapabilities(form.capabilitiesJSON);
+ const showImageResultPreferenceControl = form.kinds.some((kind) => kind === "image_gen" || kind === "image_edit")
+ && routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()) || protocol.trim() === "gemini_interactions");
const showPermissionGroupUnassigned =
!permissionGroupsLoading && permissionGroupsUnassigned && effectivePermissionGroupIDs.length === 0;
@@ -877,6 +882,27 @@ export function ModelSheet({ open, mode, target, models, vendors, displayGroups,
) : null}
+ {showImageResultPreferenceControl ? (
+
+
+ applyCapabilitiesJSON(setImagePreferBase64InCapabilities(form.capabilitiesJSON, checked === true))}
+ />
+ {t("sheet.imagePreferBase64")}
+
+
+ {t("sheet.imagePreferBase64Description")}
+
+
+ ) : null}
0 {
- applyOpenAIImageCompletedPayload(response, outputFormat, result)
+ applyOpenAIImageCompletedPayload(response, outputFormat, preferBase64, result)
return
}
}
if len(data) == 0 {
- if image, ok := parseOpenAIImagePayload(parsed, outputFormat); ok {
+ if image, ok := parseOpenAIImagePayload(parsed, outputFormat, preferBase64); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -697,7 +697,7 @@ func applyOpenAIImageCompletedPayload(parsed map[string]any, outputFormat string
result.Citations = appendUniqueStrings(result.Citations, citations...)
}
-func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portllm.GeneratedImage, bool) {
+func parseOpenAIImagePayload(payload map[string]any, outputFormat string, preferBase64 bool) (portllm.GeneratedImage, bool) {
if len(payload) == 0 {
return portllm.GeneratedImage{}, false
}
@@ -706,14 +706,15 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portl
if declared := declaredImageMediaType(payload); declared != "" {
mimeType = declared
}
- if url := strings.TrimSpace(getString(payload["url"])); url != "" {
+ b64 := strings.TrimSpace(getString(payload["b64_json"]))
+ if url := strings.TrimSpace(getString(payload["url"])); url != "" && (!preferBase64 || b64 == "") {
return portllm.GeneratedImage{
URL: url,
MIMEType: mimeType,
RevisedPrompt: strings.TrimSpace(getString(payload["revised_prompt"])),
}, true
}
- if b64 := strings.TrimSpace(getString(payload["b64_json"])); b64 != "" {
+ if b64 != "" {
return portllm.GeneratedImage{
B64JSON: b64,
MIMEType: mimeType,
@@ -721,7 +722,7 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portl
}, true
}
if nested := asMap(payload["image"]); len(nested) > 0 {
- image, ok := parseOpenAIImagePayload(nested, outputFormat)
+ image, ok := parseOpenAIImagePayload(nested, outputFormat, preferBase64)
if !ok {
return portllm.GeneratedImage{}, false
}
@@ -736,6 +737,7 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portl
func consumeOpenAIImageStream(
reader io.Reader,
outputFormat string,
+ preferBase64 bool,
result *portllm.GenerateOutput,
onEvent func(portllm.GenerateStreamEvent) error,
) error {
@@ -802,9 +804,9 @@ func consumeOpenAIImageStream(
switch {
case strings.Contains(eventType, "partial_image"):
- return emitOpenAIImagePartial(parsed, outputFormat, onEvent)
+ return emitOpenAIImagePartial(parsed, outputFormat, preferBase64, onEvent)
case strings.Contains(eventType, "completed") || strings.Contains(eventType, "final"):
- applyOpenAIImageCompletedPayload(parsed, outputFormat, result)
+ applyOpenAIImageCompletedPayload(parsed, outputFormat, preferBase64, result)
}
return nil
}
@@ -857,7 +859,7 @@ func consumeOpenAIImageStream(
result.RawJSON += "\n"
}
result.RawJSON += payloadText
- applyOpenAIImageCompletedPayload(parsed, outputFormat, result)
+ applyOpenAIImageCompletedPayload(parsed, outputFormat, preferBase64, result)
if result.Usage != (portllm.Usage{}) && onEvent != nil {
if err := onEvent(portllm.GenerateStreamEvent{Usage: result.Usage}); err != nil {
return err
@@ -870,12 +872,13 @@ func consumeOpenAIImageStream(
func emitOpenAIImagePartial(
parsed map[string]any,
outputFormat string,
+ preferBase64 bool,
onEvent func(portllm.GenerateStreamEvent) error,
) error {
if onEvent == nil {
return nil
}
- image, ok := parseOpenAIImagePayload(parsed, outputFormat)
+ image, ok := parseOpenAIImagePayload(parsed, outputFormat, preferBase64)
if !ok {
return nil
}
diff --git a/backend/internal/infra/llm/openai_images_test.go b/backend/internal/infra/llm/openai_images_test.go
index fa6d61fe0..8bad7c78a 100644
--- a/backend/internal/infra/llm/openai_images_test.go
+++ b/backend/internal/infra/llm/openai_images_test.go
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
portllm "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/ports/llm"
@@ -270,7 +272,7 @@ func TestParseOpenAIImageGenerationOutput(t *testing.T) {
"input_tokens_details": {"text_tokens": 8, "image_tokens": 4},
"output_tokens_details": {"image_tokens": 40}
}
- }`), "webp")
+ }`), "webp", false)
if err != nil {
t.Fatalf("parse image output: %v", err)
}
@@ -297,7 +299,7 @@ func TestParseOpenAIImageGenerationOutput(t *testing.T) {
func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) {
output, err := parseOpenAIImageOutput([]byte(`{
"data": [{"b64_json": "aGVsbG8="}]
- }`), "png")
+ }`), "png", false)
if err != nil {
t.Fatalf("parse image output: %v", err)
}
@@ -306,6 +308,152 @@ func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) {
}
}
+func TestOpenAIImageResultSourcePreference(t *testing.T) {
+ for _, preferBase64 := range []bool{false, true} {
+ for _, test := range []struct {
+ name string
+ payload string
+ wantB64 bool
+ }{
+ {"both", `{"url":"https://images.example.test/result.png","b64_json":"aW1hZ2U="}`, preferBase64},
+ {"url only", `{"url":"https://images.example.test/result.png"}`, false},
+ {"base64 only", `{"b64_json":"aW1hZ2U="}`, true},
+ {"empty base64", `{"url":"https://images.example.test/result.png","b64_json":" \t"}`, false},
+ {"invalid base64 is retained", `{"url":"https://images.example.test/result.png","b64_json":"invalid!"}`, preferBase64},
+ } {
+ for _, format := range []string{"json", "sse", "stream json"} {
+ t.Run(fmt.Sprintf("%s/%s/prefer=%t", test.name, format, preferBase64), func(t *testing.T) {
+ body := `{"response":{"data":[{"image":` + test.payload + `,"revised_prompt":"revised"}]}}`
+ var output *portllm.GenerateOutput
+ var err error
+ if format == "json" {
+ output, err = parseOpenAIImageOutput([]byte(body), "webp", preferBase64)
+ } else {
+ output = &portllm.GenerateOutput{}
+ if format == "sse" {
+ body = "event: image_generation.completed\ndata: " + body + "\n\n"
+ }
+ err = consumeOpenAIImageStream(strings.NewReader(body), "webp", preferBase64, output, nil)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("parse result: err=%v", err)
+ }
+ image := output.GeneratedImages[0]
+ if (image.B64JSON != "") != test.wantB64 || (image.URL != "") == test.wantB64 {
+ t.Fatal("unexpected image source selection")
+ }
+ if image.MIMEType != "image/webp" || image.RevisedPrompt != "revised" {
+ t.Fatal("image metadata was not preserved")
+ }
+ })
+ }
+ }
+ }
+}
+
+func TestResponsesImageResultsKeepURLPriority(t *testing.T) {
+ image, ok := parseResponsesGeneratedImage(map[string]any{
+ "type": "image_generation_call",
+ "result": map[string]any{
+ "url": "https://images.example.test/result.png",
+ "b64_json": "aW1hZ2U=",
+ },
+ })
+ if !ok || image.URL == "" || image.B64JSON != "" {
+ t.Fatal("Responses image source priority changed")
+ }
+}
+
+func TestImageAdaptersUseRouteResultPreference(t *testing.T) {
+ for _, protocol := range []string{
+ portllm.AdapterOpenAIImageGenerations, portllm.AdapterOpenAIImageEdits,
+ portllm.AdapterOpenRouterImages, portllm.AdapterXAIImage, portllm.AdapterXAIImageEdits,
+ } {
+ for _, stream := range []bool{false, true} {
+ if stream && !portllm.SupportsImageGenerationStream(protocol, "gpt-image-1") {
+ continue
+ }
+ for _, jsonFallback := range []bool{false, true} {
+ if !stream && jsonFallback {
+ continue
+ }
+ t.Run(fmt.Sprintf("%s/stream=%t/json=%t", protocol, stream, jsonFallback), func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/") {
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Errorf("parse multipart: %v", err)
+ return
+ }
+ defer r.MultipartForm.RemoveAll() //nolint:errcheck
+ if r.FormValue("response_format") != "" {
+ t.Error("response preference changed request response_format")
+ }
+ if (r.FormValue("stream") == "true") != stream {
+ t.Error("response preference changed request stream")
+ }
+ } else {
+ var payload map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ t.Errorf("decode request: %v", err)
+ return
+ }
+ if _, ok := payload["response_format"]; ok {
+ t.Error("response preference changed request response_format")
+ }
+ if (payload["stream"] == true) != stream {
+ t.Error("response preference changed request stream")
+ }
+ }
+ entry := `{"url":"https://images.example.test/unavailable","b64_json":"aW1hZ2U="}`
+ if stream && !jsonFallback {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = fmt.Fprintf(w, "event: image_generation.partial_image\ndata: %s\n\nevent: image_generation.completed\ndata: %s\n\n", entry, entry)
+ } else {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = fmt.Fprintf(w, `{"data":[%s]}`, entry)
+ }
+ }))
+ defer server.Close()
+ client := newTestClient()
+ defer client.CloseIdleConnections()
+ input := portllm.GenerateInput{Messages: []portllm.Message{{Role: "user", Parts: []portllm.ContentPart{
+ {Kind: portllm.ContentPartText, Text: "draw"},
+ {Kind: portllm.ContentPartImage, MimeType: "image/png", Data: []byte("source")},
+ }}}}
+ for _, preferBase64 := range []bool{true, false, true} {
+ route := portllm.RouteConfig{Protocol: protocol, BaseURL: server.URL, UpstreamModel: "gpt-image-1", PreferBase64Images: preferBase64}
+ var output *portllm.GenerateOutput
+ var err error
+ partials := 0
+ if stream {
+ output, err = client.GenerateStream(t.Context(), route, input, func(event portllm.GenerateStreamEvent) error {
+ if event.GeneratedImage != nil {
+ partials++
+ if (event.GeneratedImage.B64JSON != "") != preferBase64 {
+ t.Error("partial image ignored result preference")
+ }
+ }
+ return nil
+ })
+ } else {
+ output, err = client.Generate(t.Context(), route, input)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("generate: err=%v", err)
+ }
+ if (output.GeneratedImages[0].B64JSON != "") != preferBase64 {
+ t.Fatal("final image ignored route result preference")
+ }
+ if stream && !jsonFallback && partials != 1 {
+ t.Fatalf("partial image count = %d", partials)
+ }
+ }
+ })
+ }
+ }
+ }
+}
+
func TestOpenAIImageGenerationStream(t *testing.T) {
var requestPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/backend/internal/infra/llm/openai_responses.go b/backend/internal/infra/llm/openai_responses.go
index 75c91f618..2ea23a3bd 100644
--- a/backend/internal/infra/llm/openai_responses.go
+++ b/backend/internal/infra/llm/openai_responses.go
@@ -909,9 +909,9 @@ func parseResponsesGeneratedImage(item map[string]any) (portllm.GeneratedImage,
RevisedPrompt: revisedPrompt,
}, true
}
- image, ok := parseOpenAIImagePayload(asMap(item["result"]), outputFormat)
+ image, ok := parseOpenAIImagePayload(asMap(item["result"]), outputFormat, false)
if !ok {
- image, ok = parseOpenAIImagePayload(item, outputFormat)
+ image, ok = parseOpenAIImagePayload(item, outputFormat, false)
}
if !ok {
return portllm.GeneratedImage{}, false
diff --git a/backend/internal/infra/llm/xai_images.go b/backend/internal/infra/llm/xai_images.go
index 18ceeba89..ffb6e215e 100644
--- a/backend/internal/infra/llm/xai_images.go
+++ b/backend/internal/infra/llm/xai_images.go
@@ -129,7 +129,7 @@ func (c *Client) generateXAIImage(ctx context.Context, route portllm.RouteConfig
}
debug := upstreamDebugSnapshot(req, debugPayload, resp, body)
- output, err := parseXAIImageOutput(body, protocol)
+ output, err := parseXAIImageOutput(body, protocol, route.PreferBase64Images)
if err != nil {
return nil, portllm.MarkRequestAccepted(attachUpstreamDebug(err, debug))
}
@@ -248,7 +248,7 @@ func xAIImageResponseFormat(options map[string]any) string {
}
// parseXAIImageOutput 解析 xAI 图片响应;图片字节只进入 GeneratedImages。
-func parseXAIImageOutput(body []byte, protocol string) (*portllm.GenerateOutput, error) {
+func parseXAIImageOutput(body []byte, protocol string, preferBase64 bool) (*portllm.GenerateOutput, error) {
parsed := make(map[string]any)
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
@@ -265,7 +265,7 @@ func parseXAIImageOutput(body []byte, protocol string) (*portllm.GenerateOutput,
data := asSlice(parsed["data"])
citations := make([]string, 0, len(data))
for _, item := range data {
- if image, ok := parseXAIImagePayload(asMap(item)); ok {
+ if image, ok := parseXAIImagePayload(asMap(item), preferBase64); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -273,7 +273,7 @@ func parseXAIImageOutput(body []byte, protocol string) (*portllm.GenerateOutput,
}
}
if len(data) == 0 {
- if image, ok := parseXAIImagePayload(parsed); ok {
+ if image, ok := parseXAIImagePayload(parsed, preferBase64); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -284,7 +284,7 @@ func parseXAIImageOutput(body []byte, protocol string) (*portllm.GenerateOutput,
return result, nil
}
-func parseXAIImagePayload(payload map[string]any) (portllm.GeneratedImage, bool) {
+func parseXAIImagePayload(payload map[string]any, preferBase64 bool) (portllm.GeneratedImage, bool) {
if len(payload) == 0 {
return portllm.GeneratedImage{}, false
}
@@ -293,14 +293,15 @@ func parseXAIImagePayload(payload map[string]any) (portllm.GeneratedImage, bool)
if revisedPrompt == "" {
revisedPrompt = strings.TrimSpace(getString(payload["revisedPrompt"]))
}
- if url := strings.TrimSpace(getString(payload["url"])); url != "" {
+ b64 := strings.TrimSpace(getString(payload["b64_json"]))
+ if url := strings.TrimSpace(getString(payload["url"])); url != "" && (!preferBase64 || b64 == "") {
return portllm.GeneratedImage{
URL: url,
MIMEType: mimeType,
RevisedPrompt: revisedPrompt,
}, true
}
- if b64 := strings.TrimSpace(getString(payload["b64_json"])); b64 != "" {
+ if b64 != "" {
return portllm.GeneratedImage{
B64JSON: b64,
MIMEType: mimeType,
diff --git a/backend/internal/infra/llm/xai_images_test.go b/backend/internal/infra/llm/xai_images_test.go
index 388ec3eaf..92316554e 100644
--- a/backend/internal/infra/llm/xai_images_test.go
+++ b/backend/internal/infra/llm/xai_images_test.go
@@ -279,7 +279,7 @@ func TestParseXAIImageOutput(t *testing.T) {
{"url": "https://example.com/a.jpg", "mime_type": "image/webp"},
{"b64_json": "aGVsbG8=", "mime_type": "image/png", "revised_prompt": "A revised render"}
]
- }`), portllm.AdapterXAIImage)
+ }`), portllm.AdapterXAIImage, false)
if err != nil {
t.Fatalf("parse xAI image output: %v", err)
}
diff --git a/backend/internal/ports/llm/types.go b/backend/internal/ports/llm/types.go
index f2d2c5ab1..7cfe78c57 100644
--- a/backend/internal/ports/llm/types.go
+++ b/backend/internal/ports/llm/types.go
@@ -40,6 +40,8 @@ type RouteConfig struct {
UpstreamModel string
AttributionReferer string
AttributionTitle string
+ // PreferBase64Images selects inline image results only for image generation/editing tasks.
+ PreferBase64Images bool
// UntrustedEndpoint 为 true 表示端点由普通用户配置(自带 Key):
// 请求只走强制 SSRF 防护、不授予端点信任、不跟随重定向的客户端。
UntrustedEndpoint bool
From 584f3635afcb0e5f9ce37fdc115a1dfcfb9f1f17 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Mon, 19 Oct 2026 18:01:53 +0800
Subject: [PATCH 12/19] chore: remove unused dependencies and update
tauri-plugin-updater configuration
---
apps/desktop/src-tauri/Cargo.lock | 33 -------------------------------
apps/desktop/src-tauri/Cargo.toml | 6 +++++-
2 files changed, 5 insertions(+), 34 deletions(-)
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index 5461010b6..e2529bd52 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -69,15 +69,6 @@ dependencies = [
"security-framework",
]
-[[package]]
-name = "arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
-dependencies = [
- "derive_arbitrary",
-]
-
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -869,17 +860,6 @@ dependencies = [
"serde_core",
]
-[[package]]
-name = "derive_arbitrary"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -4422,7 +4402,6 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
- "zip",
]
[[package]]
@@ -6101,18 +6080,6 @@ dependencies = [
"syn 3.0.6",
]
-[[package]]
-name = "zip"
-version = "4.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
-dependencies = [
- "arbitrary",
- "crc32fast",
- "indexmap 2.14.2",
- "memchr",
-]
-
[[package]]
name = "zlib-rs"
version = "0.6.8"
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index eb35fe6e5..2b1e7d303 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -17,7 +17,11 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png", "unstable"] }
-tauri-plugin-updater = "2"
+# Without the default `zip` feature: it only unpacks Windows `.zip` and Linux
+# `.AppImage.tar.gz` updates, and releases ship `.exe` / `.msi` / bare `.AppImage`
+# (macOS unpacks its `.app.tar.gz` regardless). It also pulls in `zip` 4, which has
+# extraction advisories (RUSTSEC-2026-0367/0368/0369) and no fixed 4.x release.
+tauri-plugin-updater = { version = "2", default-features = false, features = ["rustls-tls", "system-proxy"] }
tauri-plugin-process = "2"
tauri-plugin-opener = "2"
# OS credential store: macOS Keychain, Windows Credential Manager, Linux Secret Service.
From a36be2abcf98e67ba5ee7c97dd4469008fd7f7d9 Mon Sep 17 00:00:00 2001
From: "Q.A.zh" <40236765+QAbot-zh@users.noreply.github.com>
Date: Mon, 19 Oct 2026 19:44:32 +0800
Subject: [PATCH 13/19] fix: allow mouse wheel scrolling in admin protocol
dropdown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #858 实现了菜单高度限制和滚动条样式,但协议菜单默认使用非模态 Popover,并通过 Portal 挂载到 body 下。外层 Dialog 的滚动锁会将菜单中的滚轮事件视为弹窗外部事件并取消,**实际表现为鼠标滚轮无法触发滚动条响应,键盘方向键可以触发**。
将 ProtocolsDropdown 改为,使协议菜单能够正常接收鼠标滚轮事件。
关联 #853、#858。
---
.../components/sections/upstreams/upstreams-models-dialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx b/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
index b8d59da29..c5c5dc462 100644
--- a/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
+++ b/apps/web/features/admin/components/sections/upstreams/upstreams-models-dialog.tsx
@@ -204,7 +204,7 @@ function ProtocolsDropdown({
}
return (
-
+
Date: Wed, 21 Oct 2026 20:58:04 +0800
Subject: [PATCH 14/19] refactor: remove image prefer base64 functionality and
related UI components
---
.../models/models-capabilities-config.tsx | 27 ----
.../sections/models/models-sheet.tsx | 26 ---
.../web/i18n/messages/en-US/admin-models.json | 2 -
.../web/i18n/messages/zh-CN/admin-models.json | 2 -
backend/README.md | 7 -
.../conversation/service_media_generation.go | 13 --
.../service_media_generation_test.go | 66 ++------
backend/internal/infra/llm/openai_images.go | 49 +++---
.../internal/infra/llm/openai_images_test.go | 151 +++++++++---------
.../internal/infra/llm/openai_responses.go | 4 +-
backend/internal/infra/llm/xai_images.go | 46 +++---
backend/internal/infra/llm/xai_images_test.go | 24 ++-
backend/internal/ports/llm/types.go | 2 -
13 files changed, 159 insertions(+), 260 deletions(-)
diff --git a/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx b/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
index 7f907b59a..23be7c787 100644
--- a/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
+++ b/apps/web/features/admin/components/sections/models/models-capabilities-config.tsx
@@ -249,33 +249,6 @@ export function setImageStreamEnabledInCapabilities(raw: string, enabled: boolea
return stringifyCapabilitiesObject(payload);
}
-export function imagePreferBase64FromCapabilities(raw: string): boolean {
- const payload = parseCapabilitiesObject(raw);
- return isRecord(payload?.image) && payload.image.prefer_base64 === true;
-}
-
-export function setImagePreferBase64InCapabilities(raw: string, enabled: boolean): string | null {
- const payload = parseCapabilitiesObject(raw);
- if (!payload) {
- return null;
- }
- if (enabled) {
- payload.image = {
- ...(isRecord(payload.image) ? payload.image : {}),
- prefer_base64: true,
- };
- } else {
- const image = payload.image;
- if (isRecord(image)) {
- delete image.prefer_base64;
- if (Object.keys(image).length === 0) {
- delete payload.image;
- }
- }
- }
- return stringifyCapabilitiesObject(payload);
-}
-
function optionPathSegments(path: string): string[] {
return path
.split(".")
diff --git a/apps/web/features/admin/components/sections/models/models-sheet.tsx b/apps/web/features/admin/components/sections/models/models-sheet.tsx
index b33cddc3f..39ed421f4 100644
--- a/apps/web/features/admin/components/sections/models/models-sheet.tsx
+++ b/apps/web/features/admin/components/sections/models/models-sheet.tsx
@@ -96,13 +96,11 @@ import {
} from "@/features/admin/types/llm";
import { JsonCodeEditor } from "@/shared/components/json-code-editor";
import {
- imagePreferBase64FromCapabilities,
imageStreamEnabledFromCapabilities,
MODEL_CAPABILITIES_PLACEHOLDER,
ModelCapabilitiesGuideButton,
ModelCapabilitiesQuickConfig,
normalizeModelCapabilitiesJSON,
- setImagePreferBase64InCapabilities,
setImageStreamEnabledInCapabilities,
} from "@/features/admin/components/sections/models/models-capabilities-config";
import {
@@ -555,9 +553,6 @@ export function ModelSheet({ open, mode, target, models, vendors, displayGroups,
}
const imageStreamEnabled = imageStreamEnabledFromCapabilities(form.capabilitiesJSON);
const showImageStreamControl = routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()));
- const imagePreferBase64 = imagePreferBase64FromCapabilities(form.capabilitiesJSON);
- const showImageResultPreferenceControl = form.kinds.some((kind) => kind === "image_gen" || kind === "image_edit")
- && routeProtocols.some((protocol) => IMAGE_MEDIA_PROTOCOLS.has(protocol.trim()) || protocol.trim() === "gemini_interactions");
const showPermissionGroupUnassigned =
!permissionGroupsLoading && permissionGroupsUnassigned && effectivePermissionGroupIDs.length === 0;
@@ -882,27 +877,6 @@ export function ModelSheet({ open, mode, target, models, vendors, displayGroups,
) : null}
- {showImageResultPreferenceControl ? (
-
-
- applyCapabilitiesJSON(setImagePreferBase64InCapabilities(form.capabilitiesJSON, checked === true))}
- />
- {t("sheet.imagePreferBase64")}
-
-
- {t("sheet.imagePreferBase64Description")}
-
-
- ) : null}
0 {
- applyOpenAIImageCompletedPayload(response, outputFormat, preferBase64, result)
+ applyOpenAIImageCompletedPayload(response, outputFormat, result)
return
}
}
if len(data) == 0 {
- if image, ok := parseOpenAIImagePayload(parsed, outputFormat, preferBase64); ok {
+ if image, ok := parseOpenAIImagePayload(parsed, outputFormat); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -697,7 +697,7 @@ func applyOpenAIImageCompletedPayload(parsed map[string]any, outputFormat string
result.Citations = appendUniqueStrings(result.Citations, citations...)
}
-func parseOpenAIImagePayload(payload map[string]any, outputFormat string, preferBase64 bool) (portllm.GeneratedImage, bool) {
+func parseOpenAIImagePayload(payload map[string]any, outputFormat string) (portllm.GeneratedImage, bool) {
if len(payload) == 0 {
return portllm.GeneratedImage{}, false
}
@@ -706,23 +706,20 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string, prefer
if declared := declaredImageMediaType(payload); declared != "" {
mimeType = declared
}
+ // 兼容网关可能在同一条目里同时返回 url 与 b64_json:两者都保留。下游先用内联字节,
+ // 只在没有字节时才下载 url,避免一次多余且可能失败的下载让已经成功的生成失败。
+ url := strings.TrimSpace(getString(payload["url"]))
b64 := strings.TrimSpace(getString(payload["b64_json"]))
- if url := strings.TrimSpace(getString(payload["url"])); url != "" && (!preferBase64 || b64 == "") {
+ if url != "" || b64 != "" {
return portllm.GeneratedImage{
URL: url,
- MIMEType: mimeType,
- RevisedPrompt: strings.TrimSpace(getString(payload["revised_prompt"])),
- }, true
- }
- if b64 != "" {
- return portllm.GeneratedImage{
B64JSON: b64,
MIMEType: mimeType,
RevisedPrompt: strings.TrimSpace(getString(payload["revised_prompt"])),
}, true
}
if nested := asMap(payload["image"]); len(nested) > 0 {
- image, ok := parseOpenAIImagePayload(nested, outputFormat, preferBase64)
+ image, ok := parseOpenAIImagePayload(nested, outputFormat)
if !ok {
return portllm.GeneratedImage{}, false
}
@@ -737,7 +734,6 @@ func parseOpenAIImagePayload(payload map[string]any, outputFormat string, prefer
func consumeOpenAIImageStream(
reader io.Reader,
outputFormat string,
- preferBase64 bool,
result *portllm.GenerateOutput,
onEvent func(portllm.GenerateStreamEvent) error,
) error {
@@ -804,9 +800,9 @@ func consumeOpenAIImageStream(
switch {
case strings.Contains(eventType, "partial_image"):
- return emitOpenAIImagePartial(parsed, outputFormat, preferBase64, onEvent)
+ return emitOpenAIImagePartial(parsed, outputFormat, onEvent)
case strings.Contains(eventType, "completed") || strings.Contains(eventType, "final"):
- applyOpenAIImageCompletedPayload(parsed, outputFormat, preferBase64, result)
+ applyOpenAIImageCompletedPayload(parsed, outputFormat, result)
}
return nil
}
@@ -859,7 +855,7 @@ func consumeOpenAIImageStream(
result.RawJSON += "\n"
}
result.RawJSON += payloadText
- applyOpenAIImageCompletedPayload(parsed, outputFormat, preferBase64, result)
+ applyOpenAIImageCompletedPayload(parsed, outputFormat, result)
if result.Usage != (portllm.Usage{}) && onEvent != nil {
if err := onEvent(portllm.GenerateStreamEvent{Usage: result.Usage}); err != nil {
return err
@@ -872,13 +868,12 @@ func consumeOpenAIImageStream(
func emitOpenAIImagePartial(
parsed map[string]any,
outputFormat string,
- preferBase64 bool,
onEvent func(portllm.GenerateStreamEvent) error,
) error {
if onEvent == nil {
return nil
}
- image, ok := parseOpenAIImagePayload(parsed, outputFormat, preferBase64)
+ image, ok := parseOpenAIImagePayload(parsed, outputFormat)
if !ok {
return nil
}
diff --git a/backend/internal/infra/llm/openai_images_test.go b/backend/internal/infra/llm/openai_images_test.go
index 8bad7c78a..4bf732205 100644
--- a/backend/internal/infra/llm/openai_images_test.go
+++ b/backend/internal/infra/llm/openai_images_test.go
@@ -272,7 +272,7 @@ func TestParseOpenAIImageGenerationOutput(t *testing.T) {
"input_tokens_details": {"text_tokens": 8, "image_tokens": 4},
"output_tokens_details": {"image_tokens": 40}
}
- }`), "webp", false)
+ }`), "webp")
if err != nil {
t.Fatalf("parse image output: %v", err)
}
@@ -299,7 +299,7 @@ func TestParseOpenAIImageGenerationOutput(t *testing.T) {
func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) {
output, err := parseOpenAIImageOutput([]byte(`{
"data": [{"b64_json": "aGVsbG8="}]
- }`), "png", false)
+ }`), "png")
if err != nil {
t.Fatalf("parse image output: %v", err)
}
@@ -308,50 +308,53 @@ func TestParseOpenAIImageGenerationOutputDoesNotInventUsage(t *testing.T) {
}
}
-func TestOpenAIImageResultSourcePreference(t *testing.T) {
- for _, preferBase64 := range []bool{false, true} {
- for _, test := range []struct {
- name string
- payload string
- wantB64 bool
- }{
- {"both", `{"url":"https://images.example.test/result.png","b64_json":"aW1hZ2U="}`, preferBase64},
- {"url only", `{"url":"https://images.example.test/result.png"}`, false},
- {"base64 only", `{"b64_json":"aW1hZ2U="}`, true},
- {"empty base64", `{"url":"https://images.example.test/result.png","b64_json":" \t"}`, false},
- {"invalid base64 is retained", `{"url":"https://images.example.test/result.png","b64_json":"invalid!"}`, preferBase64},
- } {
- for _, format := range []string{"json", "sse", "stream json"} {
- t.Run(fmt.Sprintf("%s/%s/prefer=%t", test.name, format, preferBase64), func(t *testing.T) {
- body := `{"response":{"data":[{"image":` + test.payload + `,"revised_prompt":"revised"}]}}`
- var output *portllm.GenerateOutput
- var err error
- if format == "json" {
- output, err = parseOpenAIImageOutput([]byte(body), "webp", preferBase64)
- } else {
- output = &portllm.GenerateOutput{}
- if format == "sse" {
- body = "event: image_generation.completed\ndata: " + body + "\n\n"
- }
- err = consumeOpenAIImageStream(strings.NewReader(body), "webp", preferBase64, output, nil)
- }
- if err != nil || len(output.GeneratedImages) != 1 {
- t.Fatalf("parse result: err=%v", err)
- }
- image := output.GeneratedImages[0]
- if (image.B64JSON != "") != test.wantB64 || (image.URL != "") == test.wantB64 {
- t.Fatal("unexpected image source selection")
- }
- if image.MIMEType != "image/webp" || image.RevisedPrompt != "revised" {
- t.Fatal("image metadata was not preserved")
+// 兼容网关可能在同一条目里同时返回 url 与 b64_json:两者都要保留,下游才能直接用内联字节,
+// 不必再下载 url。JSON、SSE 与流式请求收到的普通 JSON 走同一套解析。
+func TestOpenAIImageResultKeepsURLAndBase64(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ payload string
+ wantURL bool
+ wantB64 bool
+ }{
+ {"both", `{"url":"https://images.example.test/result.png","b64_json":"aW1hZ2U="}`, true, true},
+ {"url only", `{"url":"https://images.example.test/result.png"}`, true, false},
+ {"base64 only", `{"b64_json":"aW1hZ2U="}`, false, true},
+ {"blank base64", `{"url":"https://images.example.test/result.png","b64_json":" \t"}`, true, false},
+ // 无效的 Base64 原样保留,由下游解码时报错,不在解析阶段悄悄换成下载。
+ {"invalid base64 is retained", `{"url":"https://images.example.test/result.png","b64_json":"invalid!"}`, true, true},
+ } {
+ for _, format := range []string{"json", "sse", "stream json"} {
+ t.Run(test.name+"/"+format, func(t *testing.T) {
+ body := `{"response":{"data":[{"image":` + test.payload + `,"revised_prompt":"revised"}]}}`
+ var output *portllm.GenerateOutput
+ var err error
+ if format == "json" {
+ output, err = parseOpenAIImageOutput([]byte(body), "webp")
+ } else {
+ output = &portllm.GenerateOutput{}
+ if format == "sse" {
+ body = "event: image_generation.completed\ndata: " + body + "\n\n"
}
- })
- }
+ err = consumeOpenAIImageStream(strings.NewReader(body), "webp", output, nil)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("parse result: err=%v", err)
+ }
+ image := output.GeneratedImages[0]
+ if (image.URL != "") != test.wantURL || (image.B64JSON != "") != test.wantB64 {
+ t.Fatalf("unexpected image sources: URL=%t base64=%t", image.URL != "", image.B64JSON != "")
+ }
+ if image.MIMEType != "image/webp" || image.RevisedPrompt != "revised" {
+ t.Fatal("image metadata was not preserved")
+ }
+ })
}
}
}
-func TestResponsesImageResultsKeepURLPriority(t *testing.T) {
+// Responses 的内嵌生图共用同一个解析函数,同样两者都保留。
+func TestResponsesImageResultKeepsURLAndBase64(t *testing.T) {
image, ok := parseResponsesGeneratedImage(map[string]any{
"type": "image_generation_call",
"result": map[string]any{
@@ -359,12 +362,14 @@ func TestResponsesImageResultsKeepURLPriority(t *testing.T) {
"b64_json": "aW1hZ2U=",
},
})
- if !ok || image.URL == "" || image.B64JSON != "" {
- t.Fatal("Responses image source priority changed")
+ if !ok || image.URL == "" || image.B64JSON == "" {
+ t.Fatalf("expected both image sources, got URL=%t base64=%t", image.URL != "", image.B64JSON != "")
}
}
-func TestImageAdaptersUseRouteResultPreference(t *testing.T) {
+// 每个图片适配器在非流式、SSE 与流式请求收到普通 JSON 时,最终图与流式中间图都带上内联字节,
+// 且不改动请求的 response_format 与 stream。
+func TestImageAdaptersKeepInlineImageBytes(t *testing.T) {
for _, protocol := range []string{
portllm.AdapterOpenAIImageGenerations, portllm.AdapterOpenAIImageEdits,
portllm.AdapterOpenRouterImages, portllm.AdapterXAIImage, portllm.AdapterXAIImageEdits,
@@ -386,10 +391,10 @@ func TestImageAdaptersUseRouteResultPreference(t *testing.T) {
}
defer r.MultipartForm.RemoveAll() //nolint:errcheck
if r.FormValue("response_format") != "" {
- t.Error("response preference changed request response_format")
+ t.Error("request response_format changed")
}
if (r.FormValue("stream") == "true") != stream {
- t.Error("response preference changed request stream")
+ t.Error("request stream changed")
}
} else {
var payload map[string]any
@@ -398,10 +403,10 @@ func TestImageAdaptersUseRouteResultPreference(t *testing.T) {
return
}
if _, ok := payload["response_format"]; ok {
- t.Error("response preference changed request response_format")
+ t.Error("request response_format changed")
}
if (payload["stream"] == true) != stream {
- t.Error("response preference changed request stream")
+ t.Error("request stream changed")
}
}
entry := `{"url":"https://images.example.test/unavailable","b64_json":"aW1hZ2U="}`
@@ -420,33 +425,31 @@ func TestImageAdaptersUseRouteResultPreference(t *testing.T) {
{Kind: portllm.ContentPartText, Text: "draw"},
{Kind: portllm.ContentPartImage, MimeType: "image/png", Data: []byte("source")},
}}}}
- for _, preferBase64 := range []bool{true, false, true} {
- route := portllm.RouteConfig{Protocol: protocol, BaseURL: server.URL, UpstreamModel: "gpt-image-1", PreferBase64Images: preferBase64}
- var output *portllm.GenerateOutput
- var err error
- partials := 0
- if stream {
- output, err = client.GenerateStream(t.Context(), route, input, func(event portllm.GenerateStreamEvent) error {
- if event.GeneratedImage != nil {
- partials++
- if (event.GeneratedImage.B64JSON != "") != preferBase64 {
- t.Error("partial image ignored result preference")
- }
+ route := portllm.RouteConfig{Protocol: protocol, BaseURL: server.URL, UpstreamModel: "gpt-image-1"}
+ var output *portllm.GenerateOutput
+ var err error
+ partials := 0
+ if stream {
+ output, err = client.GenerateStream(t.Context(), route, input, func(event portllm.GenerateStreamEvent) error {
+ if event.GeneratedImage != nil {
+ partials++
+ if event.GeneratedImage.B64JSON == "" {
+ t.Error("partial image dropped its inline bytes")
}
- return nil
- })
- } else {
- output, err = client.Generate(t.Context(), route, input)
- }
- if err != nil || len(output.GeneratedImages) != 1 {
- t.Fatalf("generate: err=%v", err)
- }
- if (output.GeneratedImages[0].B64JSON != "") != preferBase64 {
- t.Fatal("final image ignored route result preference")
- }
- if stream && !jsonFallback && partials != 1 {
- t.Fatalf("partial image count = %d", partials)
- }
+ }
+ return nil
+ })
+ } else {
+ output, err = client.Generate(t.Context(), route, input)
+ }
+ if err != nil || len(output.GeneratedImages) != 1 {
+ t.Fatalf("generate: err=%v", err)
+ }
+ if image := output.GeneratedImages[0]; image.B64JSON == "" || image.URL == "" {
+ t.Fatalf("expected both image sources, got URL=%t base64=%t", image.URL != "", image.B64JSON != "")
+ }
+ if stream && !jsonFallback && partials != 1 {
+ t.Fatalf("partial image count = %d", partials)
}
})
}
diff --git a/backend/internal/infra/llm/openai_responses.go b/backend/internal/infra/llm/openai_responses.go
index 2ea23a3bd..75c91f618 100644
--- a/backend/internal/infra/llm/openai_responses.go
+++ b/backend/internal/infra/llm/openai_responses.go
@@ -909,9 +909,9 @@ func parseResponsesGeneratedImage(item map[string]any) (portllm.GeneratedImage,
RevisedPrompt: revisedPrompt,
}, true
}
- image, ok := parseOpenAIImagePayload(asMap(item["result"]), outputFormat, false)
+ image, ok := parseOpenAIImagePayload(asMap(item["result"]), outputFormat)
if !ok {
- image, ok = parseOpenAIImagePayload(item, outputFormat, false)
+ image, ok = parseOpenAIImagePayload(item, outputFormat)
}
if !ok {
return portllm.GeneratedImage{}, false
diff --git a/backend/internal/infra/llm/xai_images.go b/backend/internal/infra/llm/xai_images.go
index ffb6e215e..1a89be75b 100644
--- a/backend/internal/infra/llm/xai_images.go
+++ b/backend/internal/infra/llm/xai_images.go
@@ -129,7 +129,7 @@ func (c *Client) generateXAIImage(ctx context.Context, route portllm.RouteConfig
}
debug := upstreamDebugSnapshot(req, debugPayload, resp, body)
- output, err := parseXAIImageOutput(body, protocol, route.PreferBase64Images)
+ output, err := parseXAIImageOutput(body, protocol)
if err != nil {
return nil, portllm.MarkRequestAccepted(attachUpstreamDebug(err, debug))
}
@@ -248,7 +248,7 @@ func xAIImageResponseFormat(options map[string]any) string {
}
// parseXAIImageOutput 解析 xAI 图片响应;图片字节只进入 GeneratedImages。
-func parseXAIImageOutput(body []byte, protocol string, preferBase64 bool) (*portllm.GenerateOutput, error) {
+func parseXAIImageOutput(body []byte, protocol string) (*portllm.GenerateOutput, error) {
parsed := make(map[string]any)
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
@@ -265,7 +265,7 @@ func parseXAIImageOutput(body []byte, protocol string, preferBase64 bool) (*port
data := asSlice(parsed["data"])
citations := make([]string, 0, len(data))
for _, item := range data {
- if image, ok := parseXAIImagePayload(asMap(item), preferBase64); ok {
+ if image, ok := parseXAIImagePayload(asMap(item)); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -273,7 +273,7 @@ func parseXAIImageOutput(body []byte, protocol string, preferBase64 bool) (*port
}
}
if len(data) == 0 {
- if image, ok := parseXAIImagePayload(parsed, preferBase64); ok {
+ if image, ok := parseXAIImagePayload(parsed); ok {
if url := strings.TrimSpace(image.URL); url != "" {
citations = append(citations, url)
}
@@ -284,7 +284,7 @@ func parseXAIImageOutput(body []byte, protocol string, preferBase64 bool) (*port
return result, nil
}
-func parseXAIImagePayload(payload map[string]any, preferBase64 bool) (portllm.GeneratedImage, bool) {
+func parseXAIImagePayload(payload map[string]any) (portllm.GeneratedImage, bool) {
if len(payload) == 0 {
return portllm.GeneratedImage{}, false
}
@@ -293,29 +293,21 @@ func parseXAIImagePayload(payload map[string]any, preferBase64 bool) (portllm.Ge
if revisedPrompt == "" {
revisedPrompt = strings.TrimSpace(getString(payload["revisedPrompt"]))
}
+ // url 与 b64_json 同时出现时两者都保留,下游先用内联字节,见 parseOpenAIImagePayload。
+ url := strings.TrimSpace(getString(payload["url"]))
+ if url == "" {
+ url = strings.TrimSpace(getString(asMap(payload["file_output"])["public_url"]))
+ }
b64 := strings.TrimSpace(getString(payload["b64_json"]))
- if url := strings.TrimSpace(getString(payload["url"])); url != "" && (!preferBase64 || b64 == "") {
- return portllm.GeneratedImage{
- URL: url,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- if b64 != "" {
- return portllm.GeneratedImage{
- B64JSON: b64,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- if publicURL := strings.TrimSpace(getString(asMap(payload["file_output"])["public_url"])); publicURL != "" {
- return portllm.GeneratedImage{
- URL: publicURL,
- MIMEType: mimeType,
- RevisedPrompt: revisedPrompt,
- }, true
- }
- return portllm.GeneratedImage{}, false
+ if url == "" && b64 == "" {
+ return portllm.GeneratedImage{}, false
+ }
+ return portllm.GeneratedImage{
+ URL: url,
+ B64JSON: b64,
+ MIMEType: mimeType,
+ RevisedPrompt: revisedPrompt,
+ }, true
}
// xAIImageMIMEType 优先采用官方响应中的 MIME;旧代理未返回时回退为 JPEG。
diff --git a/backend/internal/infra/llm/xai_images_test.go b/backend/internal/infra/llm/xai_images_test.go
index 92316554e..230167755 100644
--- a/backend/internal/infra/llm/xai_images_test.go
+++ b/backend/internal/infra/llm/xai_images_test.go
@@ -279,7 +279,7 @@ func TestParseXAIImageOutput(t *testing.T) {
{"url": "https://example.com/a.jpg", "mime_type": "image/webp"},
{"b64_json": "aGVsbG8=", "mime_type": "image/png", "revised_prompt": "A revised render"}
]
- }`), portllm.AdapterXAIImage, false)
+ }`), portllm.AdapterXAIImage)
if err != nil {
t.Fatalf("parse xAI image output: %v", err)
}
@@ -299,3 +299,25 @@ func TestParseXAIImageOutput(t *testing.T) {
t.Fatalf("expected URL citation, got %#v", output.Citations)
}
}
+
+// url(或 file_output.public_url)与 b64_json 同时出现时两者都保留,下游先用内联字节。
+func TestParseXAIImageOutputKeepsURLAndBase64Together(t *testing.T) {
+ output, err := parseXAIImageOutput([]byte(`{
+ "data": [
+ {"url": "https://example.com/a.jpg", "b64_json": "aGVsbG8="},
+ {"file_output": {"public_url": "https://example.com/b.jpg"}, "b64_json": "d29ybGQ="}
+ ]
+ }`), portllm.AdapterXAIImage)
+ if err != nil {
+ t.Fatalf("parse xAI image output: %v", err)
+ }
+ if len(output.GeneratedImages) != 2 {
+ t.Fatalf("expected two generated images, got %#v", output.GeneratedImages)
+ }
+ if image := output.GeneratedImages[0]; image.URL != "https://example.com/a.jpg" || image.B64JSON != "aGVsbG8=" {
+ t.Fatalf("expected both the URL and the inline bytes, got %#v", image)
+ }
+ if image := output.GeneratedImages[1]; image.URL != "https://example.com/b.jpg" || image.B64JSON != "d29ybGQ=" {
+ t.Fatalf("expected both the public URL and the inline bytes, got %#v", image)
+ }
+}
diff --git a/backend/internal/ports/llm/types.go b/backend/internal/ports/llm/types.go
index 7cfe78c57..f2d2c5ab1 100644
--- a/backend/internal/ports/llm/types.go
+++ b/backend/internal/ports/llm/types.go
@@ -40,8 +40,6 @@ type RouteConfig struct {
UpstreamModel string
AttributionReferer string
AttributionTitle string
- // PreferBase64Images selects inline image results only for image generation/editing tasks.
- PreferBase64Images bool
// UntrustedEndpoint 为 true 表示端点由普通用户配置(自带 Key):
// 请求只走强制 SSRF 防护、不授予端点信任、不跟随重定向的客户端。
UntrustedEndpoint bool
From dad45abf7a9fddc9657d876323b8d13bd464bd7d Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Wed, 21 Oct 2026 21:03:42 +0800
Subject: [PATCH 15/19] fix: update Popover to modal for improved mouse wheel
scrolling in permission group selector
---
.../admin/components/shared/permission-group-selector.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/features/admin/components/shared/permission-group-selector.tsx b/apps/web/features/admin/components/shared/permission-group-selector.tsx
index c21fe3a34..8eedc18e2 100644
--- a/apps/web/features/admin/components/shared/permission-group-selector.tsx
+++ b/apps/web/features/admin/components/shared/permission-group-selector.tsx
@@ -64,7 +64,7 @@ export function PermissionGroupSelector({
return (
-
+
Date: Thu, 22 Oct 2026 03:32:55 +0800
Subject: [PATCH 16/19] fix: update Go and x/net security patches
---
Dockerfile | 2 +-
README.md | 2 +-
backend/README.md | 2 +-
backend/go.mod | 4 ++--
backend/go.sum | 4 ++--
5 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 5151480eb..89e2ebdb9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -44,7 +44,7 @@ RUN --mount=type=cache,id=next-cache,target=/src/apps/web/.next/cache \
pnpm build
-FROM golang:1.26.8-bookworm AS backend-builder
+FROM golang:1.26.10-bookworm AS backend-builder
WORKDIR /src/backend
diff --git a/README.md b/README.md
index ff77457f5..6c9a007ab 100644
--- a/README.md
+++ b/README.md
@@ -154,7 +154,7 @@ The backend keeps clear internal boundaries: `backend/cmd/server` is the executa
- Node.js 20.9 or newer (the repository Docker image and CI use Node.js 24).
- pnpm 10.17.0, normally enabled through Corepack.
-- Go 1.26.8 for backend development.
+- Go 1.26.10 for backend development.
- Docker Engine with Docker Compose v2 for container deployments or local PostgreSQL/Redis services.
The SQLite profile does not require a separately managed database or cache. The default development configuration uses PostgreSQL and Redis; the full Compose profile starts both services for you.
diff --git a/backend/README.md b/backend/README.md
index 253e8bcad..0616254b3 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -4,7 +4,7 @@ DEEIX Chat 后端是 Go API 服务,负责认证、用户、对话、模型渠
## 技术栈
-- Go 1.26.8
+- Go 1.26.10
- Gin
- Gorm
- PostgreSQL + pgvector 或 SQLite + sqlite-vec
diff --git a/backend/go.mod b/backend/go.mod
index 6f7d9f9d0..2d011772a 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,6 +1,6 @@
module github.com/DEEIX-AI/DEEIX-Chat/backend
-go 1.26.8
+go 1.26.10
require (
github.com/asg017/sqlite-vec-go-bindings v0.1.6
@@ -35,7 +35,7 @@ require (
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.57.0
golang.org/x/image v0.45.0
- golang.org/x/net v0.59.0
+ golang.org/x/net v0.60.0
golang.org/x/sync v0.23.0
golang.org/x/text v0.42.0
gopkg.in/yaml.v3 v3.0.1
diff --git a/backend/go.sum b/backend/go.sum
index e42091aef..457c5cc36 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -348,8 +348,8 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
-golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
-golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
+golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
+golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
From 5933def5acab8b16c655051880c97fd8b5601648 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:30:19 +0800
Subject: [PATCH 17/19] chore: update Node.js version to 26.12.1 and adjust
dependencies for compatibility
---
.github/actions/desktop-setup/action.yml | 2 +-
.github/workflows/ci.yml | 5 ++--
.github/workflows/desktop-prepare.yml | 2 +-
.github/workflows/desktop-release.yml | 2 +-
.nvmrc | 1 +
Dockerfile | 9 +++---
README.md | 4 +--
README.zh-CN.md | 6 ++--
apps/web/package.json | 2 +-
package.json | 3 ++
packages/core/package.json | 2 +-
pnpm-lock.yaml | 38 ++++++++++++------------
12 files changed, 39 insertions(+), 37 deletions(-)
create mode 100644 .nvmrc
diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml
index 7e7cfb881..f3e84c62f 100644
--- a/.github/actions/desktop-setup/action.yml
+++ b/.github/actions/desktop-setup/action.yml
@@ -22,7 +22,7 @@ runs:
version: 10.17.0
- uses: actions/setup-node@v5
with:
- node-version: 24
+ node-version-file: .nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
with:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 668213458..c197d54c6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,6 @@ concurrency:
env:
PNPM_VERSION: 10.17.0
- NODE_VERSION: 24
# Incremental artefacts are useless across CI runs and bloat the Rust cache.
CARGO_INCREMENTAL: 0
@@ -146,7 +145,7 @@ jobs:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v5
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version-file: .nvmrc
cache: pnpm
- uses: actions/setup-go@v6
with:
@@ -252,7 +251,7 @@ jobs:
version: ${{ env.PNPM_VERSION }}
- uses: actions/setup-node@v5
with:
- node-version: ${{ env.NODE_VERSION }}
+ node-version-file: .nvmrc
cache: pnpm
- uses: dtolnay/rust-toolchain@stable
- name: Install
diff --git a/.github/workflows/desktop-prepare.yml b/.github/workflows/desktop-prepare.yml
index 3edb55c07..328395692 100644
--- a/.github/workflows/desktop-prepare.yml
+++ b/.github/workflows/desktop-prepare.yml
@@ -64,7 +64,7 @@ jobs:
version: 10.17.0
- uses: actions/setup-node@v5
with:
- node-version: 24
+ node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
# Restore compatible default-branch CI caches; new entries stay ref-scoped.
diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml
index 4112571e6..e7c311dad 100644
--- a/.github/workflows/desktop-release.yml
+++ b/.github/workflows/desktop-release.yml
@@ -105,7 +105,7 @@ jobs:
run: node apps/desktop/scripts/desktop-source.mjs
- uses: actions/setup-node@v5
with:
- node-version: 24
+ node-version-file: .nvmrc
package-manager-cache: false
- name: Download verified release assets
uses: actions/download-artifact@v7
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 000000000..63f87c20e
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+26.12.1
diff --git a/Dockerfile b/Dockerfile
index 89e2ebdb9..b84e13c34 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -4,13 +4,12 @@
# so `docker build .` and the release workflow (.github/workflows/image.yml) produce the same image.
# Dependency and compiler caches live in BuildKit cache mounts; CI persists them between runs.
-FROM node:24-bookworm-slim AS frontend-builder
+FROM node:26.12.1-bookworm-slim AS frontend-builder
WORKDIR /src
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
-ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ARG NEXT_PUBLIC_API_BASE_URL=""
ENV NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
@@ -23,10 +22,11 @@ COPY packages/core/package.json ./packages/core/package.json
COPY apps/web/scripts ./apps/web/scripts
COPY apps/web/public/pwa ./apps/web/public/pwa
-RUN corepack enable
+# Node 26 images no longer bundle Corepack, so pnpm comes from npm and its version
+# still comes from the workspace's `packageManager` field.
+RUN npm install -g "$(node -p "require('./package.json').packageManager")"
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
- --mount=type=cache,id=corepack,target=/root/.cache/node/corepack \
pnpm config set store-dir /pnpm/store \
&& pnpm install --frozen-lockfile --prefer-offline --filter @deeix/web
@@ -40,7 +40,6 @@ WORKDIR /src/apps/web
# Turbopack keeps its build cache in .next/cache (enabled by default since Next.js 16).
RUN --mount=type=cache,id=next-cache,target=/src/apps/web/.next/cache \
- --mount=type=cache,id=corepack,target=/root/.cache/node/corepack \
pnpm build
diff --git a/README.md b/README.md
index 6c9a007ab..ae57f3f16 100644
--- a/README.md
+++ b/README.md
@@ -152,8 +152,8 @@ The backend keeps clear internal boundaries: `backend/cmd/server` is the executa
## Prerequisites
-- Node.js 20.9 or newer (the repository Docker image and CI use Node.js 24).
-- pnpm 10.17.0, normally enabled through Corepack.
+- Node.js 22.23.4, 24.22.1 or 26.12.1 — the first patched release of each maintained line (`engines` allows all three; Node 20 and older are end-of-life). `.nvmrc`, CI and the Docker image all use 26.12.1.
+- pnpm 10.17.0 (`npm install -g pnpm@10.17.0`; Node 26 no longer bundles Corepack).
- Go 1.26.10 for backend development.
- Docker Engine with Docker Compose v2 for container deployments or local PostgreSQL/Redis services.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1a22063bc..8cde2b1cc 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -152,9 +152,9 @@ flowchart TB
## 开发前置条件
-- Node.js 20.9 或更高版本(仓库 Docker 镜像和 CI 使用 Node.js 24)。
-- pnpm 10.17.0,通常通过 Corepack 启用。
-- Go 1.26.8,用于后端开发。
+- Node.js 22.23.4、24.22.1 或 26.12.1,即每条维护线第一个修复安全公告的版本(`engines` 允许这三条线;Node 20 及更早已停止维护)。`.nvmrc`、CI 与 Docker 镜像统一使用 26.12.1。
+- pnpm 10.17.0(`npm install -g pnpm@10.17.0`;Node 26 起不再随附 Corepack)。
+- Go 1.26.10,用于后端开发。
- Docker Engine 与 Docker Compose v2,用于容器部署或启动本地 PostgreSQL/Redis。
SQLite 方案不要求单独管理数据库或缓存。默认开发配置使用 PostgreSQL 和 Redis;完整 Compose 方案会自动启动这两个服务。
diff --git a/apps/web/package.json b/apps/web/package.json
index 3290bc10a..0719ab43d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -93,7 +93,7 @@
"@biomejs/biome": "2.5.11",
"@deeix/api-contract": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
- "@types/node": "^25.6.0",
+ "@types/node": "^26.6.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/turndown": "^5.0.6",
diff --git a/package.json b/package.json
index 0f2c552b2..deb5938d4 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,9 @@
"version": "0.4.6-beta.1",
"private": true,
"packageManager": "pnpm@10.17.0",
+ "engines": {
+ "node": "^22.23.4 || ^24.22.1 || >=26.12.1"
+ },
"scripts": {
"dev": "turbo run dev",
"dev:api": "turbo run dev --filter=@deeix/api",
diff --git a/packages/core/package.json b/packages/core/package.json
index ee0343525..c93e5ab7e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -25,7 +25,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.5.11",
- "@types/node": "^25.6.0",
+ "@types/node": "^26.6.5",
"typescript": "7.0.2"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8037b0f67..abe45ddad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,10 +157,10 @@ importers:
version: 12.42.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next:
specifier: 16.3.8
- version: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ version: 16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl:
specifier: ^4.12.0
- version: 4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
+ version: 4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -214,8 +214,8 @@ importers:
specifier: ^4.3.3
version: 4.3.3
'@types/node':
- specifier: ^25.6.0
- version: 25.9.5
+ specifier: ^26.6.5
+ version: 26.6.5
'@types/react':
specifier: ^19.2.18
version: 19.2.18
@@ -256,8 +256,8 @@ importers:
specifier: 2.5.11
version: 2.5.11
'@types/node':
- specifier: ^25.6.0
- version: 25.9.5
+ specifier: ^26.6.5
+ version: 26.6.5
typescript:
specifier: 7.0.2
version: 7.0.2
@@ -2064,8 +2064,8 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
- '@types/node@25.9.5':
- resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==}
+ '@types/node@26.6.5':
+ resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
'@types/react-dom@19.2.5':
resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==}
@@ -3642,8 +3642,8 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
- undici-types@7.24.6:
- resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==}
+ undici-types@8.9.0:
+ resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -5487,9 +5487,9 @@ snapshots:
'@types/ms@2.1.0': {}
- '@types/node@25.9.5':
+ '@types/node@26.6.5':
dependencies:
- undici-types: 7.24.6
+ undici-types: 8.9.0
'@types/react-dom@19.2.5(@types/react@19.2.18)':
dependencies:
@@ -6729,14 +6729,14 @@ snapshots:
next-intl-swc-plugin-extractor@4.13.2: {}
- next-intl@4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
+ next-intl@4.13.2(@swc/helpers@0.5.23)(next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2):
dependencies:
'@formatjs/intl-localematcher': 0.8.13
'@parcel/watcher': 2.5.6
'@swc/core': 1.15.43(@swc/helpers@0.5.23)
icu-minify: 4.13.2
negotiator: 1.0.0
- next: 16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ next: 16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
next-intl-swc-plugin-extractor: 4.13.2
po-parser: 2.1.1
react: 19.2.8
@@ -6751,7 +6751,7 @@ snapshots:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
- next@16.3.8(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
+ next@16.3.8(@types/node@26.6.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 16.3.8
'@swc/helpers': 0.5.23
@@ -6770,7 +6770,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.3.8
'@next/swc-win32-arm64-msvc': 16.3.8
'@next/swc-win32-x64-msvc': 16.3.8
- sharp: 0.35.5(@types/node@25.9.5)
+ sharp: 0.35.5(@types/node@26.6.5)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
@@ -7192,7 +7192,7 @@ snapshots:
setimmediate@1.0.5: {}
- sharp@0.35.5(@types/node@25.9.5):
+ sharp@0.35.5(@types/node@26.6.5):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
@@ -7223,7 +7223,7 @@ snapshots:
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
- '@types/node': 25.9.5
+ '@types/node': 26.6.5
optional: true
shiki@4.5.0:
@@ -7445,7 +7445,7 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
- undici-types@7.24.6: {}
+ undici-types@8.9.0: {}
unified@11.0.5:
dependencies:
From 3baead8c3e13d70ccc4bd6d76debfa064d496d83 Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:38:06 +0800
Subject: [PATCH 18/19] fix: bump rustls-webpki and webpki-root-certs for
RUSTSEC-2026-0382/0383
---
apps/desktop/src-tauri/Cargo.lock | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index e2529bd52..db085224d 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -3459,9 +3459,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -5223,9 +5223,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.9"
+version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
From 319acca58ee2a4caa32738683fcc3733b9222d0f Mon Sep 17 00:00:00 2001
From: Chenyme <118253778+chenyme@users.noreply.github.com>
Date: Thu, 22 Oct 2026 11:45:31 +0800
Subject: [PATCH 19/19] chore: bump version to 0.4.6
---
VERSION | 2 +-
apps/desktop/package.json | 2 +-
apps/desktop/src-tauri/Cargo.lock | 2 +-
apps/desktop/src-tauri/Cargo.toml | 2 +-
apps/desktop/src-tauri/tauri.conf.json | 2 +-
apps/web/package.json | 2 +-
backend/cmd/server/main.go | 2 +-
backend/docs/docs.go | 2 +-
backend/docs/swagger.json | 2 +-
backend/docs/swagger.yaml | 2 +-
backend/package.json | 2 +-
package.json | 2 +-
packages/api-contract/package.json | 2 +-
packages/core/package.json | 2 +-
14 files changed, 14 insertions(+), 14 deletions(-)
diff --git a/VERSION b/VERSION
index da1cd5a6f..ef52a6480 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.6-beta.1
+0.4.6
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 4f4b5192a..16742e4a3 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/desktop",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"type": "module",
"description": "DEEIX Chat desktop shell (Tauri). Loads the apps/web static build and adds tray, deep links and OS keychain storage.",
diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock
index db085224d..1a49d17cc 100644
--- a/apps/desktop/src-tauri/Cargo.lock
+++ b/apps/desktop/src-tauri/Cargo.lock
@@ -795,7 +795,7 @@ dependencies = [
[[package]]
name = "deeix-chat-desktop"
-version = "0.4.6-beta.1"
+version = "0.4.6"
dependencies = [
"dunce",
"gtk",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 2b1e7d303..5657fd968 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "deeix-chat-desktop"
-version = "0.4.6-beta.1"
+version = "0.4.6"
description = "DEEIX Chat desktop shell"
authors = ["DEEIX"]
edition = "2021"
diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json
index f386291a9..b269fe6d4 100644
--- a/apps/desktop/src-tauri/tauri.conf.json
+++ b/apps/desktop/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "DEEIX Chat",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"identifier": "com.deeix.chat.desktop",
"build": {
"frontendDist": "../../web/out",
diff --git a/apps/web/package.json b/apps/web/package.json
index 0719ab43d..79e4e1656 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/web",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"sideEffects": [
"*.css",
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index bc0343b12..d4de311c2 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -13,7 +13,7 @@ import (
)
// @title DEEIX Chat API
-// @version 0.4.6-beta.1
+// @version 0.4.6
// @description DEEIX Chat 后端 API 文档
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
diff --git a/backend/docs/docs.go b/backend/docs/docs.go
index 15518d15b..4e0ed54f4 100644
--- a/backend/docs/docs.go
+++ b/backend/docs/docs.go
@@ -35322,7 +35322,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
- Version: "0.4.6-beta.1",
+ Version: "0.4.6",
Host: "",
BasePath: "/api/v1",
Schemes: []string{},
diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json
index 98498aa31..17f5622c6 100644
--- a/backend/docs/swagger.json
+++ b/backend/docs/swagger.json
@@ -4,7 +4,7 @@
"description": "DEEIX Chat 后端 API 文档",
"title": "DEEIX Chat API",
"contact": {},
- "version": "0.4.6-beta.1"
+ "version": "0.4.6"
},
"basePath": "/api/v1",
"paths": {
diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml
index b1835dd10..57ce68b53 100644
--- a/backend/docs/swagger.yaml
+++ b/backend/docs/swagger.yaml
@@ -12091,7 +12091,7 @@ info:
contact: {}
description: DEEIX Chat 后端 API 文档
title: DEEIX Chat API
- version: "0.4.6-beta.1"
+ version: "0.4.6"
paths:
/admin/announcements:
get:
diff --git a/backend/package.json b/backend/package.json
index 583c3c829..e3b00a164 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"scripts": {
"dev": "make run",
diff --git a/package.json b/package.json
index deb5938d4..cc10917a5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/chat-monorepo",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"packageManager": "pnpm@10.17.0",
"engines": {
diff --git a/packages/api-contract/package.json b/packages/api-contract/package.json
index f5d4d356c..406772855 100644
--- a/packages/api-contract/package.json
+++ b/packages/api-contract/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/api-contract",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"type": "module",
"types": "./src/index.ts",
diff --git a/packages/core/package.json b/packages/core/package.json
index c93e5ab7e..e4c5fa654 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@deeix/core",
- "version": "0.4.6-beta.1",
+ "version": "0.4.6",
"private": true,
"type": "module",
"description": "Platform-agnostic client logic shared by web, desktop and mobile. No React, no DOM, no React Native.",