mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(desktop): build and publish rpm installers
Add Fedora 44 and RHEL 10 release roots, strict RPM smoke checks, and artifact naming that keeps each root distinct.
Co-authored-by: Qwen-Coder <[email protected]>
* fix(desktop): declare rpm runtime requirements and clear the yaml gate
The rpm legs built artifacts with no `Requires:` entries: Tauri's rpm bundler
writes them only from `bundle.linux.rpm.depends`, which nothing set, so
`verifyRpmDependencies` had no webkit soname and no glibc floor to find and the
release could not pass its own gate.
- Add `rpm-fedora44.conf.json` / `rpm-rhel10.conf.json` overlays declaring the
webkit soname and each root's glibc floor (2.43 vs 2.39 cannot share one
config), and pass the per-leg overlay to `tauri build --config`.
- Quote-free `container: ${{ matrix.container || null }}` stays as is with an
inline yamllint disable: quoting the expression would turn a null container
into an empty string. The shape test pinning that literal moves with it.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvy1jgjo76
* ci(desktop): record the rpm legs in the workflow size baseline
`desktop-release.yml` is now 49858 bytes, past its recorded 45089 plus the
4096 allowance, so `Lint & Static` fails on the ratchet before any linter runs.
The growth is the two rpm legs: their matrix entries, the per-leg overlay
wiring, and the roots' comments. Moving that prose out would not pay for it —
the legs themselves are the bytes — so the number moves with the file in the
same PR, as the gate asks.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvy1jgjo76
* style(desktop): apply prettier to the rpm legs and realign the workflow shape pin
Prettier flagged .github/workflows/desktop-release.yml and
packages/desktop/scripts/smoke-linux-installers.js on head 10acaadeba.
test-release.js pinned that container line with two spaces before the inline
yamllint disable comment, which prettier rewrites to one; .yamllint.yml has
min-spaces-from-content: 1, so the pin now follows the formatter instead of
blocking it.
Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmvy3ombr79
---------
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
1076 lines
49 KiB
YAML
1076 lines
49 KiB
YAML
name: 'Desktop Release'
|
|
|
|
run-name: 'Desktop release ${{ github.event.release.tag_name || inputs.version }}'
|
|
|
|
on:
|
|
release:
|
|
types: ['published']
|
|
workflow_dispatch:
|
|
inputs:
|
|
version:
|
|
description: 'Desktop version, for example 0.1.0 or v0.1.0.'
|
|
required: true
|
|
type: 'string'
|
|
qwen_code_ref:
|
|
description: 'Qwen Code branch or commit to bundle, reachable from main. Must contain the desktop crate (packages/desktop/src-tauri). A release tag whose own commit is off main is refused here: dispatch checks the ref itself, while only the release event peels to its parent.'
|
|
required: true
|
|
default: 'main'
|
|
type: 'string'
|
|
electron_bridge:
|
|
description: 'Publish the one-time Electron-to-Tauri update bridge for macOS, Windows, and Linux.'
|
|
required: true
|
|
default: false
|
|
type: 'boolean'
|
|
dry_run:
|
|
description: 'Build unsigned installers without publishing.'
|
|
required: true
|
|
default: true
|
|
type: 'boolean'
|
|
draft:
|
|
description: 'Create a draft GitHub release.'
|
|
required: true
|
|
default: true
|
|
type: 'boolean'
|
|
prerelease:
|
|
description: 'Mark the release as a prerelease.'
|
|
required: true
|
|
default: false
|
|
type: 'boolean'
|
|
clobber:
|
|
description: 'Replace same-named assets when uploading to an existing release.'
|
|
required: true
|
|
default: false
|
|
type: 'boolean'
|
|
# Kept callable so a scheduled dry run can exercise the packaging path
|
|
# itself — building the runtime and the installers is the only thing that
|
|
# catches a bundled-runtime regression introduced outside this package.
|
|
workflow_call:
|
|
inputs:
|
|
version:
|
|
required: true
|
|
type: 'string'
|
|
qwen_code_ref:
|
|
required: true
|
|
type: 'string'
|
|
electron_bridge:
|
|
required: true
|
|
type: 'boolean'
|
|
dry_run:
|
|
required: true
|
|
type: 'boolean'
|
|
draft:
|
|
required: true
|
|
type: 'boolean'
|
|
prerelease:
|
|
required: true
|
|
type: 'boolean'
|
|
clobber:
|
|
required: true
|
|
type: 'boolean'
|
|
permissions:
|
|
contents: 'read'
|
|
|
|
concurrency:
|
|
group: "desktop-release-${{ inputs.dry_run && inputs.version || 'publish' }}"
|
|
cancel-in-progress: false
|
|
|
|
env:
|
|
BUN_VERSION: '1.3.9'
|
|
NODE_VERSION: '22.20.0'
|
|
DESKTOP_FEED_TAG: 'desktop-latest'
|
|
|
|
jobs:
|
|
prepare:
|
|
name: 'Prepare release metadata'
|
|
if: |-
|
|
${{
|
|
github.event_name != 'release' ||
|
|
(
|
|
github.repository == 'QwenLM/qwen-code' &&
|
|
vars.RELEASE_DESKTOP_SYNC_PUBLISH == 'true' &&
|
|
startsWith(github.event.release.tag_name, 'v') &&
|
|
github.event.release.prerelease == false
|
|
)
|
|
}}
|
|
# Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED.
|
|
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
|
timeout-minutes: 10
|
|
outputs:
|
|
already_published: '${{ steps.published.outputs.already_published }}'
|
|
qwen_code_sha: '${{ steps.source.outputs.sha }}'
|
|
tag: '${{ steps.version.outputs.tag }}'
|
|
version: '${{ steps.version.outputs.version }}'
|
|
steps:
|
|
- name: 'Recreate runner Git metadata'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
rm -rf -- "$GITHUB_WORKSPACE/.git"
|
|
|
|
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: 'Resolve version'
|
|
id: 'version'
|
|
shell: 'bash'
|
|
env:
|
|
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
|
|
INPUT_VERSION: '${{ github.event.release.tag_name || inputs.version }}'
|
|
IS_DRAFT: '${{ inputs.draft }}'
|
|
IS_DRY_RUN: '${{ inputs.dry_run }}'
|
|
IS_PRERELEASE: '${{ inputs.prerelease }}'
|
|
run: |
|
|
set -euo pipefail
|
|
version="${INPUT_VERSION#v}"
|
|
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then
|
|
echo "::error::Desktop version must be valid SemVer: $INPUT_VERSION"
|
|
exit 1
|
|
fi
|
|
if [ "$IS_PRERELEASE" = 'true' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+- ]]; then
|
|
echo "::error::Desktop prereleases must use a SemVer prerelease suffix, for example 0.2.1-rc.1: $INPUT_VERSION"
|
|
exit 1
|
|
fi
|
|
if [ "$IS_DRY_RUN" != 'true' ] && [ "$IS_DRAFT" != 'true' ] && [ "$IS_PRERELEASE" != 'true' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "::error::Published stable Desktop versions must use X.Y.Z: $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"
|
|
|
|
- name: 'Check whether stable release is already published'
|
|
id: 'published'
|
|
shell: 'bash'
|
|
env:
|
|
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
|
|
FEED_TAG: '${{ env.DESKTOP_FEED_TAG }}'
|
|
GH_TOKEN: '${{ github.token }}'
|
|
RELEASE_CLOBBER: '${{ inputs.clobber }}'
|
|
RELEASE_DRAFT: '${{ inputs.draft }}'
|
|
RELEASE_DRY_RUN: '${{ inputs.dry_run }}'
|
|
RELEASE_ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
|
|
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
|
|
RELEASE_TAG: '${{ steps.version.outputs.tag }}'
|
|
RELEASE_VERSION: '${{ steps.version.outputs.version }}'
|
|
run: |
|
|
set -euo pipefail
|
|
echo 'already_published=false' >> "$GITHUB_OUTPUT"
|
|
if [ "$GITHUB_EVENT_NAME" != 'release' ] && {
|
|
[ "$RELEASE_DRY_RUN" = 'true' ] ||
|
|
[ "$RELEASE_DRAFT" = 'true' ] ||
|
|
[ "$RELEASE_PRERELEASE" = 'true' ] ||
|
|
[ "$RELEASE_CLOBBER" = 'true' ] ||
|
|
[ "$RELEASE_ELECTRON_BRIDGE" = 'true' ];
|
|
}; then
|
|
exit 0
|
|
fi
|
|
|
|
release="$(gh release view "$RELEASE_TAG" --json isDraft,isPrerelease 2>/dev/null)" || exit 0
|
|
if ! jq -e '.isDraft == false and .isPrerelease == false' <<< "$release" >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
|
|
directory="$(mktemp -d)"
|
|
trap 'rm -rf "$directory"' EXIT
|
|
if ! gh release download "$FEED_TAG" --dir "$directory" --pattern 'desktop-latest.json' >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
if ! jq -e --arg version "$RELEASE_VERSION" '.version == $version' "$directory/desktop-latest.json" >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
|
|
if ! curl -fsSL --connect-timeout 15 --max-time 300 "$ALIYUN_OSS_PUBLIC_BASE_URL/desktop/latest/desktop-latest.json" -o "$directory/oss-desktop-latest.json" >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
if ! jq -e --arg version "$RELEASE_VERSION" '.version == $version' "$directory/oss-desktop-latest.json" >/dev/null 2>&1; then
|
|
exit 0
|
|
fi
|
|
|
|
echo "::notice::Desktop $RELEASE_VERSION is already published; skipping build, publish and sync-oss. Dispatch this workflow with clobber=true to force a rebuild."
|
|
echo 'already_published=true' >> "$GITHUB_OUTPUT"
|
|
|
|
- name: 'Resolve Qwen Code source'
|
|
id: 'source'
|
|
shell: 'bash'
|
|
env:
|
|
INPUT_REF: '${{ github.event.release.tag_name || inputs.qwen_code_ref }}'
|
|
IS_DRY_RUN: '${{ inputs.dry_run }}'
|
|
run: |
|
|
set -euo pipefail
|
|
git fetch origin "$INPUT_REF"
|
|
sha="$(git rev-parse FETCH_HEAD)"
|
|
if [ "$IS_DRY_RUN" != 'true' ]; then
|
|
if [ "$GITHUB_REF_NAME" != 'main' ] && [ "$GITHUB_EVENT_NAME" != 'release' ]; then
|
|
echo '::error::Published desktop releases must run from main or follow a published release.'
|
|
exit 1
|
|
fi
|
|
# CLI release tags usually point at an off-main version-bump commit
|
|
# whose parent is on main. Verify that parent, while still building
|
|
# the tag itself; tag-publishing permission remains the trust anchor
|
|
# for the version-bump commit.
|
|
ancestor="$sha"
|
|
if [ "$GITHUB_EVENT_NAME" = 'release' ]; then
|
|
ancestor="$(git rev-parse "${sha}^")"
|
|
fi
|
|
git fetch origin main:refs/remotes/origin/main
|
|
if ! git merge-base --is-ancestor "$ancestor" refs/remotes/origin/main; then
|
|
if [ "$GITHUB_EVENT_NAME" = 'release' ]; then
|
|
echo "::error::Release $INPUT_REF: the tag commit's first parent $ancestor is not reachable from main, so this step cannot confirm the release was cut from main. That shape is a non-main source ref, or commits added to the release branch after the version bump; the dispatch arm refuses it too."
|
|
else
|
|
echo '::error::Published desktop releases may only bundle commits reachable from main.'
|
|
fi
|
|
exit 1
|
|
fi
|
|
fi
|
|
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
|
|
|
desktop_shell:
|
|
name: 'Desktop Shell (${{ matrix.os }})'
|
|
needs: 'prepare'
|
|
if: "${{ needs.prepare.outputs.already_published != 'true' }}"
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: ['ubuntu-22.04', 'windows-2022']
|
|
runs-on: '${{ matrix.os }}'
|
|
timeout-minutes: 45
|
|
defaults:
|
|
run:
|
|
shell: 'bash'
|
|
permissions:
|
|
contents: 'read'
|
|
steps:
|
|
- name: 'Checkout'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: '${{ needs.prepare.outputs.qwen_code_sha }}'
|
|
fetch-depth: 1
|
|
|
|
- name: 'Check desktop crate'
|
|
id: 'filter'
|
|
run: |-
|
|
changed=true
|
|
if [[ ! -f packages/desktop/src-tauri/Cargo.toml ]]; then
|
|
echo "::error::packages/desktop/src-tauri is absent from the release commit."
|
|
exit 1
|
|
fi
|
|
echo "changed=${changed}" >> "${GITHUB_OUTPUT}"
|
|
|
|
# cargo test links the Tauri/wry webview, so the WebKit/GTK dev headers
|
|
# must be present (mirrors the Linux build job in desktop-release.yml).
|
|
- name: 'Install Linux dependencies'
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils
|
|
|
|
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
with:
|
|
node-version: '22.x'
|
|
|
|
- uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
|
|
- uses: 'Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae' # v2
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
with:
|
|
workspaces: 'packages/desktop/src-tauri -> target'
|
|
|
|
- name: 'Compile and test the desktop crate'
|
|
if: "${{ steps.filter.outputs.changed == 'true' }}"
|
|
working-directory: 'packages/desktop'
|
|
run: 'cargo test --manifest-path src-tauri/Cargo.toml'
|
|
|
|
- name: 'Run desktop release tests'
|
|
if: "${{ steps.filter.outputs.changed == 'true' && runner.os == 'Linux' }}"
|
|
working-directory: 'packages/desktop'
|
|
run: 'node scripts/test-release.js'
|
|
|
|
build:
|
|
name: 'Build ${{ matrix.name }}'
|
|
if: "${{ needs.prepare.outputs.already_published != 'true' }}"
|
|
needs: 'prepare'
|
|
# desktop_shell runs in parallel with build, not ahead of it: build
|
|
# itself compiles and tests the same pinned revision on every matrix
|
|
# entry, so serializing the shell lane would only add up to 45 minutes
|
|
# before the build. publish joins on both, so nothing is released
|
|
# unchecked.
|
|
runs-on: '${{ matrix.os }}'
|
|
container: ${{ matrix.container || null }} # yamllint disable-line rule:quoted-strings
|
|
timeout-minutes: 120
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- name: 'macOS arm64'
|
|
os: 'macos-latest'
|
|
container: ''
|
|
artifact_suffix: 'macos-arm64'
|
|
glibc_floor: ''
|
|
rpm: 'false'
|
|
epel: 'false'
|
|
legacy_arch: 'arm64'
|
|
rust_target: 'aarch64-apple-darwin'
|
|
tauri_config: ''
|
|
tauri_args: '--target aarch64-apple-darwin --bundles app,dmg'
|
|
- name: 'macOS x64'
|
|
os: 'macos-15'
|
|
container: ''
|
|
artifact_suffix: 'macos-x64'
|
|
glibc_floor: ''
|
|
rpm: 'false'
|
|
epel: 'false'
|
|
legacy_arch: 'x64'
|
|
rust_target: 'x86_64-apple-darwin'
|
|
tauri_config: ''
|
|
tauri_args: '--target x86_64-apple-darwin --bundles app,dmg'
|
|
- name: 'Windows x64'
|
|
os: 'windows-latest'
|
|
container: ''
|
|
artifact_suffix: 'windows-x64'
|
|
glibc_floor: ''
|
|
rpm: 'false'
|
|
epel: 'false'
|
|
legacy_arch: ''
|
|
rust_target: 'x86_64-pc-windows-msvc'
|
|
tauri_config: ''
|
|
tauri_args: '--target x86_64-pc-windows-msvc --bundles nsis'
|
|
- name: 'Linux x64'
|
|
os: 'ubuntu-22.04'
|
|
container: ''
|
|
artifact_suffix: 'linux-x64'
|
|
glibc_floor: '2.35'
|
|
rpm: 'false'
|
|
epel: 'false'
|
|
legacy_arch: ''
|
|
rust_target: 'x86_64-unknown-linux-gnu'
|
|
tauri_config: ''
|
|
tauri_args: '--target x86_64-unknown-linux-gnu --bundles appimage,deb'
|
|
# 22.04's ARM image, not 24.04's, so both Linux legs share a glibc
|
|
# floor (2.35): an arm64 artifact built on 24.04 would need 2.39 and
|
|
# refuse to start on Ubuntu 22.04 / Debian 12 arm64, which the x64
|
|
# artifact still supports.
|
|
- name: 'Linux arm64'
|
|
os: 'ubuntu-22.04-arm'
|
|
container: ''
|
|
artifact_suffix: 'linux-arm64'
|
|
glibc_floor: '2.35'
|
|
rpm: 'false'
|
|
epel: 'false'
|
|
legacy_arch: ''
|
|
rust_target: 'aarch64-unknown-linux-gnu'
|
|
tauri_config: ''
|
|
tauri_args: '--target aarch64-unknown-linux-gnu --bundles appimage,deb'
|
|
# RPM is a per-root artifact: it links against the glibc and
|
|
# WebKitGTK of the root that builds it, so these two legs cannot
|
|
# reuse the 22.04 image the AppImage and .deb legs are pinned to.
|
|
# Their contract is recorded in `glibc_floor` and checked below
|
|
# instead of being inferred from `os:`.
|
|
- name: 'Linux x64 (rpm, Fedora 44)'
|
|
os: 'ubuntu-22.04'
|
|
container: 'docker.io/library/fedora@sha256:43b29f65a41eb9c35e1cd5323e3bdf3b655c2357a9f4f1ff2f9c2798e5045d80'
|
|
artifact_suffix: 'linux-x64-rpm-fedora44'
|
|
glibc_floor: '2.43'
|
|
rpm: 'true'
|
|
epel: 'false'
|
|
legacy_arch: ''
|
|
rust_target: 'x86_64-unknown-linux-gnu'
|
|
tauri_config: 'src-tauri/rpm-fedora44.conf.json'
|
|
tauri_args: '--target x86_64-unknown-linux-gnu --bundles rpm'
|
|
# RHEL 9 is deliberately absent: its webkit2gtk3 provides only the
|
|
# legacy 4.0 API, and Tauri needs libwebkit2gtk-4.1.so.0.
|
|
- name: 'Linux x64 (rpm, RHEL 10)'
|
|
os: 'ubuntu-22.04'
|
|
container: 'quay.io/centos/centos@sha256:99b8fe27dd4958b5dc2630ce35f5f6a7ff7bf9b2b1a72078102fde50d3277550'
|
|
artifact_suffix: 'linux-x64-rpm-rhel10'
|
|
glibc_floor: '2.39'
|
|
rpm: 'true'
|
|
epel: 'true'
|
|
legacy_arch: ''
|
|
rust_target: 'x86_64-unknown-linux-gnu'
|
|
tauri_config: 'src-tauri/rpm-rhel10.conf.json'
|
|
tauri_args: '--target x86_64-unknown-linux-gnu --bundles rpm'
|
|
env:
|
|
QWEN_CODE_COMMIT: '${{ needs.prepare.outputs.qwen_code_sha }}'
|
|
QWEN_DESKTOP_TARGET: '${{ matrix.rust_target }}'
|
|
steps:
|
|
- name: 'Bootstrap job container'
|
|
if: "matrix.container != ''"
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
packages=(
|
|
ca-certificates git tar gzip xz findutils which libstdc++ libatomic
|
|
)
|
|
if ! command -v curl >/dev/null 2>&1; then
|
|
packages+=(curl)
|
|
fi
|
|
dnf install -y "${packages[@]}"
|
|
|
|
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: '${{ needs.prepare.outputs.qwen_code_sha }}'
|
|
- name: 'Configure source directory'
|
|
shell: 'bash'
|
|
run: 'echo "QWEN_CODE_ROOT=$RUNNER_TEMP/qwen-code-source" >> "$GITHUB_ENV"'
|
|
|
|
- name: 'Check out Qwen Code source'
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
git init "$QWEN_CODE_ROOT"
|
|
git -C "$QWEN_CODE_ROOT" remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY.git"
|
|
git -C "$QWEN_CODE_ROOT" fetch --depth=1 origin "$QWEN_CODE_COMMIT"
|
|
git -C "$QWEN_CODE_ROOT" checkout --detach "$QWEN_CODE_COMMIT"
|
|
|
|
- uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
|
|
with:
|
|
node-version: '${{ env.NODE_VERSION }}'
|
|
cache: 'npm'
|
|
cache-dependency-path: 'packages/desktop/package-lock.json'
|
|
|
|
- name: 'Configure bundled Node.js cache'
|
|
id: 'node-cache-path'
|
|
shell: 'bash'
|
|
run: |
|
|
cache_path="$RUNNER_TEMP/qwen-desktop-node-cache"
|
|
echo "QWEN_DESKTOP_NODE_CACHE_DIR=$cache_path" >> "$GITHUB_ENV"
|
|
echo "path=$cache_path" >> "$GITHUB_OUTPUT"
|
|
|
|
- uses: 'actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830' # v4
|
|
id: 'node-cache'
|
|
with:
|
|
path: '${{ steps.node-cache-path.outputs.path }}'
|
|
key: 'desktop-node-v2-${{ matrix.artifact_suffix }}-${{ env.NODE_VERSION }}-${{ inputs.dry_run }}'
|
|
|
|
- uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable
|
|
with:
|
|
targets: '${{ matrix.rust_target }}'
|
|
|
|
- uses: 'Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae' # v2
|
|
with:
|
|
workspaces: 'packages/desktop/src-tauri -> target'
|
|
key: '${{ matrix.artifact_suffix }}'
|
|
|
|
- name: 'Install Linux dependencies'
|
|
if: "runner.os == 'Linux' && matrix.container == ''"
|
|
run: |
|
|
sudo apt-get update
|
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libatk-bridge2.0-0 at-spi2-core dbus-x11 patchelf libfuse2 xdg-utils xvfb
|
|
|
|
- name: 'Install rpm build-root dependencies'
|
|
if: "matrix.container != ''"
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
if [ '${{ matrix.epel }}' = 'true' ]; then
|
|
dnf install -y dnf-plugins-core
|
|
dnf config-manager --set-enabled crb
|
|
epel_rpm="$RUNNER_TEMP/epel-release-latest-10.noarch.rpm"
|
|
curl -fsSL --connect-timeout 15 --max-time 120 -o "$epel_rpm" 'https://dl.fedoraproject.org/pub/epel/epel-release-latest-10.noarch.rpm'
|
|
echo '8090214eb8b95617118478304aa5a8f796d4c19c625bc09e0c740cce0e682a34 '"$epel_rpm" | sha256sum -c -
|
|
dnf install -y "$epel_rpm"
|
|
fi
|
|
dnf install -y --setopt=install_weak_deps=False \
|
|
gcc gcc-c++ make cmake pkgconf-pkg-config python3 openssl-devel \
|
|
rpm cpio \
|
|
libappindicator-gtk3-devel librsvg2-devel \
|
|
gtk3 mesa-libgbm libdrm at-spi2-core dbus-x11 xdg-utils \
|
|
xorg-x11-server-Xvfb webkit2gtk4.1-devel
|
|
|
|
- name: 'Install Qwen Code dependencies'
|
|
shell: 'bash'
|
|
working-directory: '${{ runner.temp }}/qwen-code-source'
|
|
env:
|
|
QWEN_SKIP_PREPARE: '1'
|
|
run: |-
|
|
if [ -f pnpm-lock.yaml ]; then
|
|
corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only
|
|
else
|
|
npm ci --no-audit --progress=false
|
|
fi
|
|
|
|
- name: 'Install desktop dependencies'
|
|
working-directory: 'packages/desktop'
|
|
run: 'npm ci'
|
|
|
|
- name: 'Set desktop version'
|
|
working-directory: 'packages/desktop'
|
|
run: 'node scripts/version.js "${{ needs.prepare.outputs.version }}"'
|
|
|
|
- name: 'Require updater signing key for publishing'
|
|
if: "${{ github.event_name == 'release' || inputs.dry_run == false }}"
|
|
shell: 'bash'
|
|
env:
|
|
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}'
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then
|
|
echo '::error::TAURI_SIGNING_PRIVATE_KEY is required for published releases.'
|
|
exit 1
|
|
fi
|
|
|
|
- name: 'Import macOS certificate'
|
|
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
shell: 'bash'
|
|
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
|
|
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"
|
|
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 "$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"
|
|
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"
|
|
|
|
- name: 'Configure macOS notarization'
|
|
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
shell: 'bash'
|
|
env:
|
|
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
|
|
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=$api_issuer"
|
|
echo "APPLE_API_KEY=$api_key"
|
|
} >> "$GITHUB_ENV"
|
|
|
|
- name: 'Import Windows certificate'
|
|
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
shell: 'pwsh'
|
|
env:
|
|
WINDOWS_CERTIFICATE: '${{ secrets.WINDOWS_CERTIFICATE }}'
|
|
WINDOWS_CERTIFICATE_PASSWORD: '${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}'
|
|
LEGACY_WIN_CSC_LINK: '${{ secrets.WIN_CSC_LINK }}'
|
|
LEGACY_WIN_CSC_KEY_PASSWORD: '${{ secrets.WIN_CSC_KEY_PASSWORD }}'
|
|
run: |
|
|
# Prefer the Tauri-style WINDOWS_CERTIFICATE pair; fall back to the
|
|
# legacy electron-builder WIN_CSC_LINK pair if only that exists.
|
|
$pfx = $null
|
|
$pfxPassword = $null
|
|
$primaryIncomplete = ([bool]$env:WINDOWS_CERTIFICATE) -ne ([bool]$env:WINDOWS_CERTIFICATE_PASSWORD)
|
|
$legacyIncomplete = ([bool]$env:LEGACY_WIN_CSC_LINK) -ne ([bool]$env:LEGACY_WIN_CSC_KEY_PASSWORD)
|
|
if ($primaryIncomplete -or $legacyIncomplete) {
|
|
throw 'Incomplete Windows signing configuration: provide a complete WINDOWS_CERTIFICATE/WINDOWS_CERTIFICATE_PASSWORD pair or WIN_CSC_LINK/WIN_CSC_KEY_PASSWORD pair.'
|
|
}
|
|
if ($env:WINDOWS_CERTIFICATE -and $env:WINDOWS_CERTIFICATE_PASSWORD) {
|
|
$pfx = $env:WINDOWS_CERTIFICATE
|
|
$pfxPassword = $env:WINDOWS_CERTIFICATE_PASSWORD
|
|
} elseif ($env:LEGACY_WIN_CSC_LINK -and $env:LEGACY_WIN_CSC_KEY_PASSWORD) {
|
|
$pfx = $env:LEGACY_WIN_CSC_LINK
|
|
$pfxPassword = $env:LEGACY_WIN_CSC_KEY_PASSWORD
|
|
}
|
|
if ($pfx) {
|
|
$path = Join-Path $env:RUNNER_TEMP 'qwen-code-desktop.pfx'
|
|
[IO.File]::WriteAllBytes($path, [Convert]::FromBase64String($pfx))
|
|
$password = ConvertTo-SecureString $pfxPassword -AsPlainText -Force
|
|
$certificate = Import-PfxCertificate -FilePath $path -CertStoreLocation Cert:\CurrentUser\My -Password $password
|
|
$windowsConfig = @{ bundle = @{ windows = @{ certificateThumbprint = $certificate.Thumbprint } } } | ConvertTo-Json -Compress -Depth 3
|
|
"WINDOWS_CONFIG=$windowsConfig" | Out-File -FilePath $env:GITHUB_ENV -Append
|
|
} else {
|
|
Write-Output "::warning::Windows signing certificate is not configured. Windows artifacts will be unsigned and may trigger SmartScreen warnings."
|
|
}
|
|
|
|
- name: 'Prepare bundled runtime'
|
|
working-directory: 'packages/desktop'
|
|
run: 'npm run build:runtime'
|
|
|
|
- uses: 'actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830' # v4
|
|
if: "${{ steps.node-cache.outputs.cache-hit != 'true' }}"
|
|
with:
|
|
path: '${{ steps.node-cache-path.outputs.path }}'
|
|
key: 'desktop-node-v2-${{ matrix.artifact_suffix }}-${{ env.NODE_VERSION }}-${{ inputs.dry_run }}'
|
|
|
|
- name: 'Run desktop tests'
|
|
working-directory: 'packages/desktop'
|
|
run: 'npm test'
|
|
|
|
- name: 'Run desktop release tests'
|
|
working-directory: 'packages/desktop'
|
|
run: 'npm run test:release'
|
|
|
|
- name: 'Sign bundled vendor binaries (macOS)'
|
|
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
working-directory: 'packages/desktop'
|
|
shell: 'bash'
|
|
env:
|
|
APPLE_SIGNING_IDENTITY: '${{ env.APPLE_SIGNING_IDENTITY }}'
|
|
run: |
|
|
set -euo pipefail
|
|
# Sign every native macOS binary in the bundled runtime so
|
|
# notarization does not reject them. Tauri only signs the main app
|
|
# binary; the runtime is embedded verbatim and must be signed
|
|
# beforehand.
|
|
#
|
|
# Enumerate the Mach-O files instead of listing the known ones. The
|
|
# runtime is a copy of the CLI's dist tree, so its native payload
|
|
# changes whenever a CLI dependency does, without anything in this
|
|
# package changing. An unsigned renderer library arrived exactly
|
|
# that way and failed release 0.2.3-preview.0 in the notary service,
|
|
# twenty minutes into the job, against a list that had covered every
|
|
# binary the runtime carried when it was written.
|
|
runtime_dir="runtime/qwen-code"
|
|
mach_o=()
|
|
while IFS= read -r -d '' file; do
|
|
if [ "$(file -b --mime-type "$file")" = 'application/x-mach-binary' ]; then
|
|
mach_o+=("$file")
|
|
fi
|
|
done < <(find "$runtime_dir" -type f -print0)
|
|
if [ "${#mach_o[@]}" -eq 0 ]; then
|
|
echo "::error::No Mach-O binary found under $runtime_dir. The runtime is not staged as expected."
|
|
exit 1
|
|
fi
|
|
# Node.js runs the CLI's JIT; nothing else in the runtime needs an
|
|
# entitlement of its own, and libraries inherit the host process's.
|
|
node_bin="$runtime_dir/node/bin/node"
|
|
for file in "${mach_o[@]}"; do
|
|
if [ "$file" = "$node_bin" ]; then
|
|
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
|
--options runtime --timestamp \
|
|
--entitlements src-tauri/NodeEntitlements.plist "$file"
|
|
else
|
|
codesign --force --sign "$APPLE_SIGNING_IDENTITY" \
|
|
--options runtime --timestamp "$file"
|
|
fi
|
|
done
|
|
if [ ! -f "$node_bin" ]; then
|
|
echo "::warning::Node.js runtime binary not found at $node_bin."
|
|
fi
|
|
# Verify here rather than learning it from the notary service.
|
|
for file in "${mach_o[@]}"; do
|
|
codesign --verify --strict "$file"
|
|
done
|
|
echo "Signed and verified ${#mach_o[@]} Mach-O binaries under $runtime_dir."
|
|
|
|
- name: 'Refresh bundled runtime checksums after signing (macOS)'
|
|
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
working-directory: 'packages/desktop'
|
|
run: 'node scripts/prepare-runtime.js --refresh-checksums'
|
|
|
|
- name: 'Verify bundled runtime'
|
|
working-directory: 'packages/desktop'
|
|
run: 'npm run smoke:runtime'
|
|
|
|
- name: 'Build desktop installers'
|
|
working-directory: 'packages/desktop'
|
|
shell: 'bash'
|
|
env:
|
|
DRY_RUN: "${{ github.event_name == 'release' && 'false' || inputs.dry_run }}"
|
|
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}'
|
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}'
|
|
WINDOWS_CONFIG: '${{ env.WINDOWS_CONFIG }}'
|
|
run: |
|
|
set -euo pipefail
|
|
if [ "$RUNNER_OS" = 'Linux' ]; then
|
|
QWEN_DESKTOP_PATCHELF="$(command -v patchelf || true)"
|
|
PATCHELF="$GITHUB_WORKSPACE/packages/desktop/scripts/patchelf-immutable-runtime.sh"
|
|
export QWEN_DESKTOP_PATCHELF PATCHELF
|
|
fi
|
|
args=( ${{ matrix.tauri_args }} )
|
|
if [ "$DRY_RUN" = 'true' ]; then
|
|
args+=(--no-sign)
|
|
elif [ "$RUNNER_OS" = 'Windows' ] && [ -n "$WINDOWS_CONFIG" ]; then
|
|
args+=(--config "$WINDOWS_CONFIG")
|
|
fi
|
|
# The rpm legs carry their runtime dependencies and glibc floor in a
|
|
# per-leg overlay: Tauri's rpm bundler writes `Requires:` only from
|
|
# `bundle.linux.rpm.depends`, and the two roots cannot share one floor.
|
|
if [ -n '${{ matrix.tauri_config }}' ]; then
|
|
args+=(--config '${{ matrix.tauri_config }}')
|
|
fi
|
|
npm run tauri -- build "${args[@]}"
|
|
|
|
- name: 'Verify Linux installer runtimes'
|
|
if: "runner.os == 'Linux'"
|
|
working-directory: 'packages/desktop'
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
args=(src-tauri/target/${{ matrix.rust_target }}/release/bundle)
|
|
if [ '${{ matrix.rpm }}' = 'true' ]; then
|
|
args+=(--require rpm --expect-glibc '${{ matrix.glibc_floor }}')
|
|
fi
|
|
npm run smoke:linux-installers -- "${args[@]}"
|
|
|
|
- name: 'Verify macOS signature'
|
|
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
app="$(find packages/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos -maxdepth 1 -name '*.app' -print -quit)"
|
|
codesign --verify --deep --strict --verbose=2 "$app"
|
|
spctl --assess --type execute --verbose=2 "$app"
|
|
entitlements="$(mktemp)"
|
|
trap 'rm -f "$entitlements"' EXIT
|
|
codesign -d --entitlements - --xml "$app" > "$entitlements" 2>/dev/null
|
|
test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true
|
|
|
|
- name: 'Verify Windows signature'
|
|
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
|
|
shell: 'pwsh'
|
|
env:
|
|
WINDOWS_CONFIG: '${{ env.WINDOWS_CONFIG }}'
|
|
run: |
|
|
$installer = Get-ChildItem packages/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/nsis/*.exe | Select-Object -First 1
|
|
$signature = Get-AuthenticodeSignature $installer.FullName
|
|
if ($signature.Status -eq 'Valid') {
|
|
Write-Output "Windows installer is Authenticode-signed."
|
|
} elseif ($signature.Status -eq 'NotSigned' -and -not $env:WINDOWS_CONFIG) {
|
|
Write-Output "::warning::Windows installer is unsigned (no code signing certificate configured). SmartScreen will warn users on first install."
|
|
} else {
|
|
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/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/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: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
executable="$(find src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos -path '*.app/Contents/MacOS/*' -type f -perm -111 -print -quit)"
|
|
info_plist="$(dirname "$(dirname "$executable")")/Info.plist"
|
|
/usr/libexec/PlistBuddy -c 'Print :NSMicrophoneUsageDescription' "$info_plist" >/dev/null
|
|
npm run smoke:packaged -- "$executable"
|
|
|
|
- name: 'Smoke packaged application'
|
|
if: "runner.os == 'Windows'"
|
|
working-directory: 'packages/desktop'
|
|
shell: 'pwsh'
|
|
run: |
|
|
$executable = Get-ChildItem src-tauri/target/${{ matrix.rust_target }}/release/qwen-code-desktop.exe | Select-Object -First 1
|
|
npm run smoke:packaged -- $executable.FullName
|
|
|
|
- name: 'Smoke packaged application'
|
|
if: "runner.os == 'Linux'"
|
|
working-directory: 'packages/desktop'
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
xvfb-run -a npm run smoke:packaged -- src-tauri/target/${{ matrix.rust_target }}/release/qwen-code-desktop
|
|
|
|
- name: 'Collect artifacts'
|
|
shell: 'bash'
|
|
env:
|
|
LEGACY_ARCH: '${{ matrix.legacy_arch }}'
|
|
run: |
|
|
set -euo pipefail
|
|
destination="$RUNNER_TEMP/desktop-artifacts"
|
|
mkdir -p "$destination"
|
|
bundle_root="packages/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle"
|
|
while IFS= read -r -d '' artifact; do
|
|
name="$(basename "$artifact")"
|
|
if [ "$RUNNER_OS" = 'macOS' ]; then
|
|
extension=''
|
|
stem="$name"
|
|
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) name="Qwen-Code-Desktop-$LEGACY_ARCH.dmg" ;;
|
|
Qwen-Code-Desktop-*.zip) ;;
|
|
*) continue ;;
|
|
esac
|
|
if [ -n "$extension" ]; then
|
|
name="${stem}-${{ matrix.rust_target }}${extension}"
|
|
fi
|
|
elif [ "$RUNNER_OS" = 'Windows' ]; then
|
|
case "$name" in
|
|
*-setup.exe|*-setup.exe.sig) ;;
|
|
*) continue ;;
|
|
esac
|
|
elif [ "$RUNNER_OS" = 'Linux' ]; then
|
|
case "$name" in
|
|
*.rpm) name="${name%.rpm}-${{ matrix.artifact_suffix }}.rpm" ;;
|
|
*.AppImage|*.AppImage.sig|*.deb|*.deb.sig) ;;
|
|
*) continue ;;
|
|
esac
|
|
fi
|
|
name="${name// /-}"
|
|
cp "$artifact" "$destination/$name"
|
|
done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' -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
|
|
fi
|
|
|
|
- if: "${{ github.repository == 'QwenLM/qwen-code' }}"
|
|
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
|
|
with:
|
|
name: 'desktop-${{ matrix.artifact_suffix }}'
|
|
path: '${{ runner.temp }}/desktop-artifacts/*'
|
|
if-no-files-found: 'error'
|
|
retention-days: 14
|
|
- name: 'List artifacts for fork dry run'
|
|
if: "${{ github.repository != 'QwenLM/qwen-code' }}"
|
|
shell: 'bash'
|
|
run: |
|
|
set -euo pipefail
|
|
find "$RUNNER_TEMP/desktop-artifacts" -maxdepth 1 -type f -print
|
|
|
|
publish:
|
|
name: 'Publish GitHub release'
|
|
if: "${{ (github.event_name == 'release' || inputs.dry_run == false) && github.repository == 'QwenLM/qwen-code' && needs.prepare.outputs.already_published != 'true' }}"
|
|
needs:
|
|
- 'prepare'
|
|
- 'desktop_shell'
|
|
- 'build'
|
|
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.ECS_RELEASE_HOST_RUNNER_ENABLED == ''true'') && fromJSON(''["self-hosted", "linux", "ecs-qwen-release-host"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
|
timeout-minutes: 20
|
|
# The feed move below overwrites desktop-latest.json with --clobber, so it
|
|
# sits behind the same deployment gate as the OSS mirror job.
|
|
environment:
|
|
name: 'production-release'
|
|
permissions:
|
|
contents: 'write'
|
|
steps:
|
|
- uses: '$/.github/actions/prepare-release-runner'
|
|
|
|
- uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
ref: '${{ needs.prepare.outputs.qwen_code_sha }}'
|
|
|
|
- uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
|
|
with:
|
|
path: 'release-assets'
|
|
merge-multiple: true
|
|
|
|
- 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 }}'
|
|
run: |
|
|
set -euo pipefail
|
|
cd release-assets
|
|
# Generate the updater manifest before checksumming so SHA256SUMS.txt
|
|
# also covers desktop-latest.json (fetched unauthenticated by clients).
|
|
node ../.github/scripts/create-desktop-update-manifest.mjs \
|
|
--assets . \
|
|
--repository "$GH_REPO" \
|
|
--tag "$RELEASE_TAG" \
|
|
--version "$RELEASE_VERSION" \
|
|
--output desktop-latest.json
|
|
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
|
|
for manifest in macos:latest-mac.yml windows:latest.yml linux:latest-linux.yml; do
|
|
platform="${manifest%%:*}"
|
|
output="${manifest#*:}"
|
|
node ../.github/scripts/create-electron-bridge-manifest.mjs \
|
|
--assets . \
|
|
--platform "$platform" \
|
|
--version "$RELEASE_VERSION" \
|
|
--output "$output"
|
|
done
|
|
fi
|
|
sha256sum -- * > SHA256SUMS.txt
|
|
|
|
- name: 'Create GitHub release'
|
|
id: 'release'
|
|
env:
|
|
GH_TOKEN: '${{ github.token }}'
|
|
RELEASE_TAG: '${{ needs.prepare.outputs.tag }}'
|
|
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
|
RELEASE_DRAFT: '${{ inputs.draft }}'
|
|
RELEASE_PRERELEASE: '${{ inputs.prerelease }}'
|
|
RELEASE_CLOBBER: '${{ inputs.clobber }}'
|
|
run: |
|
|
set -euo pipefail
|
|
# `gh release create --generate-notes` lets the Release Notes API pick
|
|
# its own start tag. This repo carries several tag families (v*,
|
|
# desktop-v*, sdk-typescript-v*, cua-driver-rs-v*), so the API anchors
|
|
# on an unrelated release (observed: cua-driver-rs-v*) and every
|
|
# Desktop release re-lists the PRs since then. Anchor the notes at the
|
|
# previous stable Desktop release instead. The API needs an existing
|
|
# release tag, so resolve it from the release list, not `git tag`.
|
|
previous_tag="$(
|
|
gh release list --limit 100 --exclude-drafts --json tagName,isPrerelease \
|
|
--jq '.[] | select(.isPrerelease | not) | .tagName' \
|
|
| grep -x 'desktop-v[0-9].*' \
|
|
| grep -vF -x "$RELEASE_TAG" \
|
|
| head -n1 || true
|
|
)"
|
|
args=("$RELEASE_TAG" release-assets/* --target "$GITHUB_SHA" --title "Qwen Code Desktop v$RELEASE_VERSION" --generate-notes --latest=false)
|
|
if [ -n "$previous_tag" ]; then args+=(--notes-start-tag "$previous_tag"); fi
|
|
if [ "$RELEASE_DRAFT" = 'true' ]; then args+=(--draft); fi
|
|
if [ "$RELEASE_PRERELEASE" = 'true' ]; then args+=(--prerelease); fi
|
|
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
|
if [ "$RELEASE_CLOBBER" != 'true' ]; then
|
|
echo "::error::Release $RELEASE_TAG already exists. Re-run with clobber=true to replace its assets."
|
|
exit 1
|
|
fi
|
|
gh release upload "$RELEASE_TAG" release-assets/* --clobber
|
|
edit_args=()
|
|
if [ "$RELEASE_DRAFT" = 'true' ]; then edit_args+=(--draft); else edit_args+=(--draft=false); fi
|
|
if [ "$RELEASE_PRERELEASE" = 'true' ]; then edit_args+=(--prerelease); else edit_args+=(--prerelease=false); fi
|
|
gh release edit "$RELEASE_TAG" "${edit_args[@]}"
|
|
release_url="$(gh release view "$RELEASE_TAG" --json url --jq '.url')"
|
|
else
|
|
release_url="$(gh release create "${args[@]}")"
|
|
fi
|
|
echo "url=$release_url" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: 'Update stable updater feed'
|
|
if: "${{ github.event_name == 'release' || (inputs.draft == false && inputs.prerelease == false) }}"
|
|
env:
|
|
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
|
|
GH_TOKEN: '${{ github.token }}'
|
|
FEED_TAG: '${{ env.DESKTOP_FEED_TAG }}'
|
|
RELEASE_TAG: '${{ needs.prepare.outputs.tag }}'
|
|
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
|
run: |
|
|
set -euo pipefail
|
|
# The README links here, so every stable publish rewrites the page title
|
|
# and notes to name the current release rather than the assets below.
|
|
title="Qwen Code Desktop latest (v$RELEASE_VERSION)"
|
|
notes="$(printf '%s\n\n%s' \
|
|
"Download Qwen Code Desktop v$RELEASE_VERSION for macOS, Windows, and Linux: https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG" \
|
|
'This release is the stable auto-update feed. Files attached here serve the auto-updater; download installers from the link above.')"
|
|
feed_assets=(release-assets/desktop-latest.json)
|
|
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
|
|
shopt -s nullglob
|
|
windows_installers=(release-assets/*-setup.exe)
|
|
# Pinned to the x64 AppImage: the bridge is x64-only on Linux, and
|
|
# a bare *.AppImage glob now matches the arm64 artifact too.
|
|
linux_appimages=(release-assets/*_amd64.AppImage)
|
|
if [ "${#windows_installers[@]}" -ne 1 ] || [ "${#linux_appimages[@]}" -ne 1 ]; then
|
|
echo '::error::The Electron bridge requires one Windows installer and one Linux AppImage.'
|
|
exit 1
|
|
fi
|
|
feed_assets+=(
|
|
release-assets/latest-mac.yml
|
|
release-assets/latest.yml
|
|
release-assets/latest-linux.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
|
|
"${windows_installers[0]}"
|
|
"${linux_appimages[0]}"
|
|
)
|
|
fi
|
|
if gh release view "$FEED_TAG" >/dev/null 2>&1; then
|
|
directory="$(mktemp -d)"
|
|
trap 'rm -rf "$directory"' EXIT
|
|
gh release download "$FEED_TAG" --dir "$directory" --pattern 'desktop-latest.json'
|
|
current="$(jq -r '.version' "$directory/desktop-latest.json")"
|
|
if [[ ! "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
echo "::error::Current Desktop stable feed has an invalid version: $current"
|
|
exit 1
|
|
fi
|
|
newest="$(printf '%s\n%s\n' "$RELEASE_VERSION" "$current" | sort -V | tail -n 1)"
|
|
if [ "$current" != "$RELEASE_VERSION" ] && [ "$newest" = "$current" ]; then
|
|
if [ "$ELECTRON_BRIDGE" = 'true' ]; then
|
|
echo "::error::Electron bridge $RELEASE_VERSION cannot replace newer stable feed $current."
|
|
exit 1
|
|
fi
|
|
echo "::notice::Desktop $RELEASE_VERSION will not replace newer stable feed $current."
|
|
exit 0
|
|
fi
|
|
gh release upload "$FEED_TAG" "${feed_assets[@]}" --clobber
|
|
# Page text is cosmetic; it must not fail the job that gates the OSS mirror.
|
|
gh release edit "$FEED_TAG" --title "$title" --notes "$notes" --latest=false ||
|
|
echo "::warning::Could not update the $FEED_TAG release title and notes; the feed itself was published."
|
|
else
|
|
gh release create "$FEED_TAG" "${feed_assets[@]}" --title "$title" --notes "$notes" --latest=false
|
|
fi
|
|
|
|
- name: 'Publish release summary'
|
|
env:
|
|
RELEASE_TAG: '${{ needs.prepare.outputs.tag }}'
|
|
RELEASE_VERSION: '${{ needs.prepare.outputs.version }}'
|
|
RELEASE_URL: '${{ steps.release.outputs.url }}'
|
|
run: |
|
|
{
|
|
echo '## Desktop release'
|
|
echo
|
|
echo "Version: $RELEASE_VERSION"
|
|
echo "Tag: $RELEASE_TAG"
|
|
echo "Release: $RELEASE_URL"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
sync-oss:
|
|
name: 'Mirror stable Desktop release to Aliyun OSS'
|
|
if: "${{ github.repository == 'QwenLM/qwen-code' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false)) && needs.prepare.outputs.already_published != 'true' }}"
|
|
needs:
|
|
- 'prepare'
|
|
- 'build'
|
|
- 'publish'
|
|
permissions:
|
|
actions: 'read'
|
|
contents: 'read'
|
|
uses: './.github/workflows/sync-desktop-to-oss.yml'
|
|
with:
|
|
version: '${{ needs.prepare.outputs.version }}'
|
|
source: 'artifact'
|
|
follows_release: "${{ github.event_name == 'release' }}"
|
|
secrets:
|
|
ALIYUN_OSS_ACCESS_KEY_ID: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}'
|
|
ALIYUN_OSS_ACCESS_KEY_SECRET: '${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}'
|