Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .github/scripts/create-electron-bridge-manifest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/usr/bin/env node

import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';

const options = parseArguments(process.argv.slice(2));
const assets = fs.readdirSync(options.assets).sort();
const names = [
'Qwen-Code-Desktop-arm64.zip',
'Qwen-Code-Desktop-x64.zip',
'Qwen-Code-Desktop-arm64.dmg',
'Qwen-Code-Desktop-x64.dmg',
];
const artifacts = names.map((name) => readArtifact(assets, name));
const primary = artifacts[0];

const lines = [
`version: ${options.version}`,
'files:',
...artifacts.flatMap((artifact) => [
` - url: ${artifact.name}`,
` sha512: ${artifact.sha512}`,
` size: ${artifact.size}`,
]),
`path: ${primary.name}`,
`sha512: ${primary.sha512}`,
`releaseDate: '${new Date().toISOString()}'`,
];
fs.writeFileSync(options.output, `${lines.join('\n')}\n`);

function readArtifact(assets, name) {
if (!assets.includes(name)) {
throw new Error(`Missing Electron bridge artifact: ${name}`);
}
const file = path.join(options.assets, name);
return {
name,
sha512: crypto
.createHash('sha512')
.update(fs.readFileSync(file))
.digest('base64'),
size: fs.statSync(file).size,
};
}

function parseArguments(args) {
const values = {};
for (let index = 0; index < args.length; index += 2) {
const name = args[index]?.replace(/^--/, '');
const value = args[index + 1];
if (!name || value === undefined) throw new Error('Invalid arguments.');
values[name] = value;
}
for (const required of ['assets', 'version', 'output']) {
if (!values[required]) throw new Error(`Missing --${required}`);
}
if (
!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(
values.version,
)
Comment thread
yiliang114 marked this conversation as resolved.
) {
throw new Error(`Invalid --version: ${values.version}`);
}
return values;
}
122 changes: 101 additions & 21 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ on:
required: true
default: 'main'
type: 'string'
electron_bridge:
description: 'Publish the one-time macOS Electron-to-Tauri update bridge.'
required: true
default: false
type: 'boolean'
dry_run:
description: 'Build unsigned installers without publishing.'
required: true
Expand Down Expand Up @@ -64,6 +69,7 @@ jobs:
id: 'version'
shell: 'bash'
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
INPUT_VERSION: '${{ inputs.version }}'
run: |
set -euo pipefail
Expand All @@ -72,6 +78,14 @@ jobs:
echo "::error::Desktop version must be valid SemVer: $INPUT_VERSION"
exit 1
fi
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
core="${version%%[-+]*}"
IFS='.' read -r major minor patch <<< "$core"
if [ "$major" -eq 0 ] && [ "$minor" -eq 0 ] && [ "$patch" -le 5 ]; then
echo '::error::The Electron bridge version must be newer than desktop-v0.0.5.'
exit 1
fi
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "tag=desktop-v$version" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -109,18 +123,22 @@ jobs:
include:
- name: 'macOS arm64'
os: 'macos-latest'
legacy_arch: 'arm64'
rust_target: 'aarch64-apple-darwin'
tauri_args: '--target aarch64-apple-darwin --bundles app,dmg'
- name: 'macOS x64'
os: 'macos-15'
legacy_arch: 'x64'
rust_target: 'x86_64-apple-darwin'
tauri_args: '--target x86_64-apple-darwin --bundles app,dmg'
- name: 'Windows x64'
os: 'windows-latest'
legacy_arch: ''
rust_target: 'x86_64-pc-windows-msvc'
tauri_args: '--target x86_64-pc-windows-msvc --bundles nsis'
- name: 'Linux x64'
os: 'ubuntu-22.04'
legacy_arch: ''
rust_target: 'x86_64-unknown-linux-gnu'
tauri_args: '--target x86_64-unknown-linux-gnu --bundles appimage,deb'
env:
Expand Down Expand Up @@ -195,21 +213,32 @@ jobs:
env:
APPLE_CERTIFICATE: '${{ secrets.APPLE_CERTIFICATE }}'
APPLE_CERTIFICATE_PASSWORD: '${{ secrets.APPLE_CERTIFICATE_PASSWORD }}'
LEGACY_APPLE_CERTIFICATE: '${{ secrets.MAC_CSC_LINK }}'
LEGACY_APPLE_CERTIFICATE_PASSWORD: '${{ secrets.MAC_CSC_KEY_PASSWORD }}'
KEYCHAIN_PASSWORD: '${{ secrets.APPLE_KEYCHAIN_PASSWORD }}'
run: |
set -euo pipefail
for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD KEYCHAIN_PASSWORD; do
if [ -z "${!name}" ]; then echo "::error::$name is required for published macOS releases."; exit 1; fi
done
if [ -n "$APPLE_CERTIFICATE" ] && [ -n "$APPLE_CERTIFICATE_PASSWORD" ]; then
certificate_data="$APPLE_CERTIFICATE"
certificate_password="$APPLE_CERTIFICATE_PASSWORD"
elif [ -n "$LEGACY_APPLE_CERTIFICATE" ] && [ -n "$LEGACY_APPLE_CERTIFICATE_PASSWORD" ]; then
certificate_data="$LEGACY_APPLE_CERTIFICATE"
certificate_password="$LEGACY_APPLE_CERTIFICATE_PASSWORD"
else
echo '::error::A complete APPLE_CERTIFICATE pair or MAC_CSC pair is required for published macOS releases.'
exit 1
fi
keychain_password="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 32)}"
certificate="$RUNNER_TEMP/qwen-code-desktop.p12"
keychain="$RUNNER_TEMP/qwen-code-desktop.keychain-db"
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$certificate"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain"
certificate_data="${certificate_data#*base64,}"
printf '%s' "$certificate_data" | base64 --decode > "$certificate"
security create-keychain -p "$keychain_password" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain"
security import "$certificate" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
security import "$certificate" -P "$certificate_password" -A -t cert -f pkcs12 -k "$keychain"
security list-keychains -d user -s "$keychain" login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$keychain"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain"
identity="$(security find-identity -v -p codesigning "$keychain" | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)"
if [ -z "$identity" ]; then echo '::error::Developer ID Application identity was not found.'; exit 1; fi
echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV"
Expand All @@ -221,17 +250,29 @@ jobs:
APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER }}'
APPLE_API_KEY: '${{ secrets.APPLE_API_KEY }}'
APPLE_API_KEY_P8: '${{ secrets.APPLE_API_KEY_P8 }}'
LEGACY_APPLE_API_ISSUER: '${{ secrets.APPLE_NOTARY_ISSUER_ID }}'
LEGACY_APPLE_API_KEY: '${{ secrets.APPLE_NOTARY_KEY_ID }}'
LEGACY_APPLE_API_KEY_P8: '${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }}'
run: |
set -euo pipefail
for name in APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_P8; do
if [ -z "${!name}" ]; then echo "::error::$name is required for macOS notarization."; exit 1; fi
done
key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
printf '%s' "$APPLE_API_KEY_P8" > "$key_path"
if [ -n "$APPLE_API_ISSUER" ] && [ -n "$APPLE_API_KEY" ] && [ -n "$APPLE_API_KEY_P8" ]; then
api_issuer="$APPLE_API_ISSUER"
api_key="$APPLE_API_KEY"
key_path="$RUNNER_TEMP/AuthKey_${api_key}.p8"
printf '%s' "$APPLE_API_KEY_P8" > "$key_path"
elif [ -n "$LEGACY_APPLE_API_ISSUER" ] && [ -n "$LEGACY_APPLE_API_KEY" ] && [ -n "$LEGACY_APPLE_API_KEY_P8" ]; then
api_issuer="$LEGACY_APPLE_API_ISSUER"
api_key="$LEGACY_APPLE_API_KEY"
key_path="$RUNNER_TEMP/AuthKey_${api_key}.p8"
printf '%s' "$LEGACY_APPLE_API_KEY_P8" | base64 --decode > "$key_path"
else
echo '::error::A complete APPLE_API notarization set or APPLE_NOTARY set is required.'
exit 1
fi
{
echo "APPLE_API_KEY_PATH=$key_path"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER"
echo "APPLE_API_KEY=$APPLE_API_KEY"
echo "APPLE_API_ISSUER=$api_issuer"
echo "APPLE_API_KEY=$api_key"
} >> "$GITHUB_ENV"

- name: 'Import Windows certificate'
Expand Down Expand Up @@ -302,6 +343,22 @@ jobs:
$signature = Get-AuthenticodeSignature $installer.FullName
if ($signature.Status -ne 'Valid') { throw "Invalid Authenticode signature: $($signature.Status)" }

- name: 'Create Electron bridge archive'
if: "runner.os == 'macOS' && inputs.electron_bridge"
shell: 'bash'
env:
LEGACY_ARCH: '${{ matrix.legacy_arch }}'
run: |
set -euo pipefail
app="$(find packages/desktop-shell/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos -maxdepth 1 -name 'Qwen Code Desktop.app' -print -quit)"
if [ -z "$app" ]; then
echo '::error::The legacy-named macOS app bundle was not produced.'
exit 1
fi
destination="packages/desktop-shell/src-tauri/target/${{ matrix.rust_target }}/release/bundle/electron-bridge"
mkdir -p "$destination"
ditto -c -k --sequesterRsrc --keepParent "$app" "$destination/Qwen-Code-Desktop-$LEGACY_ARCH.zip"

- name: 'Smoke packaged application'
if: "runner.os == 'macOS'"
working-directory: 'packages/desktop-shell'
Expand Down Expand Up @@ -329,6 +386,8 @@ jobs:

- name: 'Collect artifacts'
shell: 'bash'
env:
LEGACY_ARCH: '${{ matrix.legacy_arch }}'
run: |
set -euo pipefail
destination="$RUNNER_TEMP/desktop-artifacts"
Expand All @@ -342,14 +401,17 @@ jobs:
case "$name" in
*.app.tar.gz.sig) stem="${name%.app.tar.gz.sig}"; extension='.app.tar.gz.sig' ;;
*.app.tar.gz) stem="${name%.app.tar.gz}"; extension='.app.tar.gz' ;;
*.dmg) stem="${name%.dmg}"; extension='.dmg' ;;
*.dmg) name="Qwen-Code-Desktop-$LEGACY_ARCH.dmg" ;;
Qwen-Code-Desktop-*.zip) ;;
*) continue ;;
esac
name="${stem}-${{ matrix.rust_target }}${extension}"
if [ -n "$extension" ]; then
name="${stem}-${{ matrix.rust_target }}${extension}"
fi
fi
name="${name// /-}"
cp "$artifact" "$destination/$name"
done < <(find "$bundle_root" -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0)
done < <(find "$bundle_root" -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.zip' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0)
if [ -z "$(find "$destination" -type f -print -quit)" ]; then
echo '::error::No desktop artifacts were produced.'
exit 1
Expand All @@ -371,7 +433,7 @@ jobs:

publish:
name: 'Publish GitHub release'
if: '${{ inputs.dry_run == false && github.repository == ''QwenLM/qwen-code'' }}'
if: "${{ inputs.dry_run == false && github.repository == 'QwenLM/qwen-code' }}"
needs:
- 'prepare'
- 'build'
Expand All @@ -390,6 +452,7 @@ jobs:
- name: 'Generate checksums and updater manifest'
shell: 'bash'
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
GH_REPO: '${{ github.repository }}'
RELEASE_TAG: '${{ needs.prepare.outputs.tag }}'
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
Expand All @@ -404,6 +467,12 @@ jobs:
--tag "$RELEASE_TAG" \
--version "$RELEASE_VERSION" \
--output desktop-latest.json
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
node ../.github/scripts/create-electron-bridge-manifest.mjs \
--assets . \
--version "$RELEASE_VERSION" \
--output latest-mac.yml
fi
sha256sum -- * > SHA256SUMS.txt

- name: 'Create GitHub release'
Expand Down Expand Up @@ -439,14 +508,25 @@ jobs:
- name: 'Update stable updater feed'
if: '${{ inputs.draft == false && inputs.prerelease == false }}'
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
GH_TOKEN: '${{ github.token }}'
FEED_TAG: '${{ env.DESKTOP_FEED_TAG }}'
run: |
set -euo pipefail
feed_assets=(release-assets/desktop-latest.json)
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
feed_assets+=(
release-assets/latest-mac.yml
release-assets/Qwen-Code-Desktop-arm64.zip
release-assets/Qwen-Code-Desktop-x64.zip
release-assets/Qwen-Code-Desktop-arm64.dmg
release-assets/Qwen-Code-Desktop-x64.dmg
)
fi
if gh release view "$FEED_TAG" >/dev/null 2>&1; then
gh release upload "$FEED_TAG" release-assets/desktop-latest.json --clobber
gh release upload "$FEED_TAG" "${feed_assets[@]}" --clobber
else
gh release create "$FEED_TAG" release-assets/desktop-latest.json --title 'Qwen Code Desktop latest' --notes 'Stable desktop updater feed.' --latest=false
gh release create "$FEED_TAG" "${feed_assets[@]}" --title 'Qwen Code Desktop latest' --notes 'Stable desktop updater feed.' --latest=false
fi

- name: 'Publish release summary'
Expand Down
66 changes: 66 additions & 0 deletions docs/design/desktop-electron-to-tauri-update-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Electron-to-Tauri desktop update bridge

## Context

The last published desktop release, `desktop-v0.0.5`, is an Electron app named `Qwen Code Desktop` with bundle identifier `com.alibaba.qwen-code`. Its macOS updater reads `latest-mac.yml` from the fixed `desktop-latest` release and installs a ZIP archive.

The new desktop shell is a Tauri app. It currently uses a different product name and bundle identifier and publishes `desktop-latest.json`, so the existing Electron app cannot discover or replace it.

## Goals

- Let signed macOS Electron `0.0.5` installations update directly to the first stable Tauri release.
- Preserve the existing macOS application identity so the updater replaces the installed app bundle.
- Keep Tauri's signed updater feed for all releases after the migration.
- Make the bridge opt-in and one-time; later releases must not need Electron build tooling.

## Non-goals

- Migrating Electron settings, sessions, or workspace state. The Tauri app may ask for a workspace on first launch.
- Bridging Windows or Linux Electron installations.
- Generating Electron differential blockmaps. Electron updater falls back to the checksum-verified full ZIP.

## Compatibility contract

The Tauri bundle uses the legacy macOS identity:

- product name: `Qwen Code Desktop`
- bundle identifier: `com.alibaba.qwen-code`
- artifact prefix: `Qwen-Code-Desktop`
- signing identity: the existing Developer ID Application certificate

The bridge release must be newer than `0.0.5`. It publishes two updater views over the same signed app bundles:

1. `latest-mac.yml` points legacy Electron clients at `Qwen-Code-Desktop-arm64.zip` or `Qwen-Code-Desktop-x64.zip`.
2. `desktop-latest.json` points Tauri clients at the signed Tauri updater archives.

The ZIP is created from the already signed and notarized `.app`; it is not rebuilt by Electron tooling.

## Release flow

`Desktop Release` gains an `electron_bridge` input, disabled by default.

- All macOS builds continue to produce the Tauri app, DMG, updater archive, and updater signature.
- When `electron_bridge` is enabled, each macOS build also creates a legacy-compatible ZIP.
- The publish job generates `latest-mac.yml` from the two ZIPs and two DMGs.
- A stable bridge release uploads the legacy metadata and payloads to `desktop-latest` together with `desktop-latest.json`.
- Later stable releases leave `electron_bridge` disabled. Updating `desktop-latest.json` does not remove the bridge files, so Electron installations that return later can still cross to Tauri.

Draft and prerelease runs may build and publish bridge artifacts for inspection, but they never update the stable feed.

## Signing credentials

The repository already stores the Electron-era Apple certificate and App Store Connect API key under `MAC_CSC_*` and `APPLE_NOTARY_*` secret names. The workflow accepts those names as fallbacks for the newer Tauri names, so the Developer ID identity remains unchanged.

Tauri updater artifacts additionally require `TAURI_SIGNING_PRIVATE_KEY`; `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` is only needed for an encrypted private key. The private key must match the public key in the Tauri configuration before the first published Tauri release.

## Validation

Automated release-helper tests verify:

- the legacy application identity,
- exact bridge artifact selection,
- SHA-512 and size values in `latest-mac.yml`,
- failure when a required bridge artifact is missing,
- existing Tauri updater manifest and version synchronization behavior.

Before the stable release, install the signed `desktop-v0.0.5` arm64 and x64 builds, point them at an isolated bridge feed, and verify both `0.0.5 -> Tauri bridge` and `Tauri bridge -> newer Tauri` updates.
8 changes: 8 additions & 0 deletions packages/desktop-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,11 @@ npm run dev --workspaces=false
```

Use `QWEN_DESKTOP_WORKSPACE=/absolute/path` to choose the initial workspace. Without it, the app shows a workspace picker on first launch.

## Releases

The `Desktop Release` workflow builds signed updater artifacts when `dry_run` is disabled. Published releases require the Tauri updater private key. macOS releases also require Apple signing and notarization credentials.

The first stable Tauri release may set `electron_bridge=true` to publish the macOS ZIPs and `latest-mac.yml` consumed by Electron `0.0.5`. Leave the input disabled for later releases; the fixed `desktop-latest` release retains the bridge assets while `desktop-latest.json` advances independently.

The macOS workflow accepts either the Tauri-era `APPLE_*` certificate and notarization secrets or the existing `MAC_CSC_*` and `APPLE_NOTARY_*` secrets. `TAURI_SIGNING_PRIVATE_KEY` must match the public key in `src-tauri/tauri.conf.json`.
Loading
Loading