diff --git a/.github/actions/desktop-setup/action.yml b/.github/actions/desktop-setup/action.yml new file mode 100644 index 000000000..f3e84c62f --- /dev/null +++ b/.github/actions/desktop-setup/action.yml @@ -0,0 +1,75 @@ +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: + - name: Verify source commit + shell: bash + env: + SOURCE_SHA: ${{ inputs.source-sha }} + run: node apps/desktop/scripts/desktop-source.mjs + - uses: pnpm/action-setup@v5 + with: + version: 10.17.0 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + 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@v6 + 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" + # 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 }}-${{ 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@v7 + with: + name: desktop-web + path: apps/web/out + - name: Download catalog snapshots + uses: actions/download-artifact@v7 + 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/ci.yml b/.github/workflows/ci.yml index f9506c232..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 @@ -36,9 +35,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 +76,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 +112,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 +139,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 }} + node-version-file: .nvmrc 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 +158,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 +177,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 +212,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 +226,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,13 +245,13 @@ 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 }} + node-version-file: .nvmrc cache: pnpm - uses: dtolnay/rust-toolchain@stable - name: Install 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 new file mode 100644 index 000000000..40bf966ec --- /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@v5 + with: + ref: ${{ github.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@v6 + 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@v6 + 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..fe5f019eb --- /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-26 + 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@v5 + with: + ref: ${{ github.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@v6 + 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@v6 + 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..328395692 --- /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: ${{ steps.source.outputs.release-tag }} + channel: ${{ steps.source.outputs.channel }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ 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 --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(); + if (tag && tag !== `v${version}`) throw new Error(`Tag ${tag} does not match VERSION ${version}`); + const channel = version.includes('-') ? 'beta' : 'stable'; + 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 + + web: + name: Build frontend once + needs: resolve + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.sha }} + - uses: pnpm/action-setup@v5 + with: + version: 10.17.0 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + 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@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@v6 + 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@v5 + with: + ref: ${{ github.sha }} + - 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@v6 + 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..e7c311dad 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -1,574 +1,130 @@ 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 in the source ref's +# context. Tag code must never write caches in the default branch's scope. 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 dispatch; leave empty to build the selected ref" + 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 its tag + if: inputs.release_tag != '' && github.ref_type != 'tag' 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 }} + RELEASE_TAG: ${{ inputs.release_tag }} 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 "refs/tags/$RELEASE_TAG" + echo "Release $RELEASE_TAG dispatched in its tag context." >> "$GITHUB_STEP_SUMMARY" + + prepare: + name: Prepare shared inputs + if: inputs.release_tag == '' || github.ref_type == 'tag' + uses: ./.github/workflows/desktop-prepare.yml + with: + release-tag: ${{ inputs.release_tag || (github.ref_type == 'tag' && github.ref_name) || '' }} + + 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 - with: - version: 10.17.0 - - - name: Set up Node.js - 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. + - uses: actions/checkout@v5 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 + ref: ${{ github.sha }} + - name: Verify publication source 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 + 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@v5 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 + node-version-file: .nvmrc + package-manager-cache: false + - name: Download verified release assets + uses: actions/download-artifact@v7 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..2aa0f8708 --- /dev/null +++ b/.github/workflows/desktop-windows.yml @@ -0,0 +1,188 @@ +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@v5 + with: + ref: ${{ github.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 and checked-out source share the run's immutable 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@v6 + 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@v6 + with: + name: desktop-timings-windows-x64 + path: apps/desktop/src-tauri/target/cargo-timings + overwrite: true + retention-days: 7 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 1757b8e80..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 @@ -53,7 +53,8 @@ jobs: if: steps.tag.outputs.created == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + 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 + gh workflow run desktop-release.yml --ref "refs/tags/$RELEASE_TAG" + gh workflow run image.yml --ref "$RELEASE_TAG" 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 5151480eb..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,11 +40,10 @@ 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 -FROM golang:1.26.8-bookworm AS backend-builder +FROM golang:1.26.10-bookworm AS backend-builder WORKDIR /src/backend 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 16b1db662..ae57f3f16 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- English | 简体中文 + English | 简体中文

@@ -152,9 +152,9 @@ 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. -- Go 1.26.8 for backend development. +- 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. 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. @@ -225,13 +225,42 @@ 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. 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 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 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. + ### 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 85% rename from docs/README.zh-CN.md rename to README.zh-CN.md index 95a4d1205..8cde2b1cc 100644 --- a/docs/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,7 @@

- - DEEIX Chat + + DEEIX Chat

@@ -10,7 +10,7 @@

- English | 简体中文 + English | 简体中文

@@ -30,7 +30,7 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定 系统围绕简单部署、高效静态分发和低资源的运行时占用设计,轻量而不简陋、克制而不缺能力、开放而不失秩序。 -![DEEIX Chat 工作区](./assets/screenshots/DEEIX-Chat.jpg) +![DEEIX Chat 工作区](./docs/assets/screenshots/DEEIX-Chat.jpg) ## 核心能力 @@ -48,14 +48,14 @@ DEEIX Chat 是一款开源可部署的 AI 平台,面向需要长期、稳定 | 部署与运维 | 支持单运行时托管前端与 API、Docker 部署、SQLite 或 PostgreSQL、内存缓存或 Redis、S3 兼容存储、Swagger、结构化日志、版本接口、GeoIP 和 OpenTelemetry。 |

- DEEIX Chat 图片生成 - DEEIX Chat 深色模式 + DEEIX Chat 图片生成 + DEEIX Chat 深色模式

- DEEIX Chat 用量与计费 - DEEIX Chat Artifacts - DEEIX Chat HTML 渲染 + DEEIX Chat 用量与计费 + DEEIX Chat Artifacts + DEEIX Chat HTML 渲染

## 系统架构与技术栈 @@ -148,13 +148,13 @@ 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)。 ## 开发前置条件 -- 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 方案会自动启动这两个服务。 @@ -223,15 +223,40 @@ 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 直接用它们。 前端以静态资源形式导出,`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 草稿**。手动发布草稿后,已安装的客户端才会收到更新。 + +发布任务在目标标签的运行上下文中执行,所有任务始终 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 与构建脚本): + +```bash +gh workflow run desktop-release.yml --ref refs/tags/v1.2.3 +``` + +在 Actions 页面选择分支且留空 `release_tag` 时,只构建所选分支并上传 workflow 附件;选择标签则创建发布草稿。在分支上填写 `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/VERSION b/VERSION index 0bfccb080..ef52a6480 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.5 +0.4.6 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2aa5bb695..1e9a3938b 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -316,25 +316,86 @@ 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. +Before tagging, update `VERSION`, run `make version` and commit the synchronized +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 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) | 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 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. | + +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 **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 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: + +```bash +gh workflow run desktop-release.yml --ref refs/tags/v1.2.3 +``` + +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 | Tag | Channel | What the app polls | @@ -435,8 +496,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 +523,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 +535,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 @@ -475,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/package.json b/apps/desktop/package.json index 739aec93e..16742e4a3 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", "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/scripts/build-sidecar.mjs b/apps/desktop/scripts/build-sidecar.mjs index 1fc0c9bc9..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 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 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 ] 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..ea2ac7c83 --- /dev/null +++ b/apps/desktop/scripts/desktop-provenance.mjs @@ -0,0 +1,43 @@ +// 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 = "" }) { + 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}`; + 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}@${tag ? `refs/tags/${tag}` : 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 { 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 new file mode 100644 index 000000000..20ab39e2b --- /dev/null +++ b/apps/desktop/scripts/desktop-provenance.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { desktopProvenance } from "./desktop-provenance.mjs"; + +const sourceSha = "a".repeat(40); +function environment(ref) { + return { + GITHUB_SERVER_URL: "https://github.com", GITHUB_REPOSITORY: "o/r", + 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", + }; +} + +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/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/tags/v1.2.3"); + assert.equal(predicate.runDetails.metadata.invocationId, "https://github.com/o/r/actions/runs/789/attempts/2"); +}); + +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"]); +}); 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..2bb489aa0 --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-release.mjs @@ -0,0 +1,123 @@ +// 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 }); + // 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); + 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) { + // 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, 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..59949d56f --- /dev/null +++ b/apps/desktop/scripts/release-assets.test.mjs @@ -0,0 +1,222 @@ +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 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); + 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/')) { + // 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, 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_MISSING_TAG: "", ...extraEnv }, + }); + const calls = readFileSync(log, "utf8").trim().split("\n").map(JSON.parse); + 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 [ + ["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, 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(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"))); + 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"); + 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); +}); 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); +}); diff --git a/apps/desktop/scripts/sync-version.test.mjs b/apps/desktop/scripts/sync-version.test.mjs new file mode 100644 index 000000000..41da7a718 --- /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: pnpm api:generate\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/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); +}); diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 2fe9073e4..1a49d17cc 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" @@ -804,7 +795,7 @@ dependencies = [ [[package]] name = "deeix-chat-desktop" -version = "0.4.5" +version = "0.4.6" dependencies = [ "dunce", "gtk", @@ -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" @@ -3479,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", @@ -4422,7 +4402,6 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", ] [[package]] @@ -5244,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", ] @@ -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 07a23981b..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.5" +version = "0.4.6" description = "DEEIX Chat desktop shell" authors = ["DEEIX"] edition = "2021" @@ -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. diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index be1b748ad..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.5", + "version": "0.4.6", "identifier": "com.deeix.chat.desktop", "build": { "frontendDist": "../../web/out", 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/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 ( - +