Files
易良 d24198e866 fix(ci): repin the RHEL 10 rpm root to the current stream10 digest (#16261)
The pinned manifest for the RHEL 10 build root no longer resolves on
quay.io: `quay.io/v2/centos/centos/manifests/sha256:99b8fe27…` answers 404,
so every job using that container dies at "Initialize containers" with
`manifest unknown` before a single step runs (run 41759710053, job
126345260531). The tag still exists, and resolves to
sha256:511d3584… today.

Repin to that digest. Staying on a digest keeps the build root fixed for the
life of the release, which is why this was pinned in the first place; only
the value moves.
2026-11-04 03:14:02 +00:00

1141 lines
52 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'
display: 'xvfb'
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:511d358446140e9c4ba16451affb103a14237cbf40059b18adeaef6e3080a72b'
artifact_suffix: 'linux-x64-rpm-rhel10'
glibc_floor: '2.39'
rpm: 'true'
epel: 'true'
display: 'weston'
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-10-9.el10_3.noarch.rpm"
curl -fsSL --connect-timeout 15 --max-time 120 -o "$epel_rpm" 'https://dl.fedoraproject.org/pub/epel/10/Everything/x86_64/Packages/e/epel-release-10-9.el10_3.noarch.rpm'
echo 'f567aa5c3cde55f579ba5c41c79df97da692a391efddd14b123a4eec0824fbc0 '"$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 \
webkit2gtk4.1-devel
# Fedora 44 still ships Xvfb; RHEL 10 removed it from every
# repository (BaseOS, AppStream, CRB, extras and EPEL 10 carry no
# such build). `matrix.display` names the server each rpm root can
# install, so a leg added later picks one instead of inheriting an
# arm by accident. An unknown value fails closed: the weston arm
# needs the EPEL bootstrap above, so guessing it would be wrong on
# every root that has no EPEL.
case '${{ matrix.display }}' in
xvfb)
dnf install -y --setopt=install_weak_deps=False xorg-x11-server-Xvfb
;;
weston)
# weston (EPEL) provides a headless Wayland display and Xwayland
# (AppStream) bridges it to X11 for WebKitGTK. The smoke step
# below starts both.
dnf install -y --setopt=install_weak_deps=False weston xorg-x11-server-Xwayland
;;
*)
echo "matrix.display must be 'xvfb' or 'weston', got '${{ matrix.display }}'" >&2
exit 1
;;
esac
- 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
executable='src-tauri/target/${{ matrix.rust_target }}/release/qwen-code-desktop'
# A timed-out wait must fail here: `[ -S ... ] && break` is exempt
# from `set -e`, so falling through would blame the app instead.
wait_for_socket() {
for _ in $(seq 1 50); do
[ -S "$1" ] && return 0
sleep 0.2
done
echo "::error::$2 never created $1 within 10s" >&2
return 1
}
# WebKitGTK needs a real display: GTK3 has no headless backend.
# Fedora keeps Xvfb; RHEL 10 removed it, so there the display is a
# headless weston compositor with Xwayland on top.
if command -v xvfb-run >/dev/null 2>&1; then
xvfb-run -a npm run smoke:packaged -- "$executable"
else
command -v weston >/dev/null 2>&1 || {
echo '::error::weston is not installed'; exit 1;
}
command -v Xwayland >/dev/null 2>&1 || {
echo '::error::Xwayland is not installed'; exit 1;
}
export XDG_RUNTIME_DIR="$RUNNER_TEMP/xdg-runtime"
mkdir -p "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
# Xwayland reads its compositor from its own environment, so the
# socket name has to be exported before it is launched.
export WAYLAND_DISPLAY=qwen-smoke
weston --backend=headless-backend.so --socket=qwen-smoke --idle-time=0 &
weston_pid=$!
trap 'kill ${xwayland_pid:-} $weston_pid 2>/dev/null || true' EXIT
wait_for_socket "$XDG_RUNTIME_DIR/qwen-smoke" weston
Xwayland :33 -rootless -wm -1 &
xwayland_pid=$!
wait_for_socket /tmp/.X11-unix/X33 Xwayland
export DISPLAY=:33
# GTK3 prefers Wayland when both are exported, which would leave
# the X11 bridge above unused; pin the app to that display.
export GDK_BACKEND=x11
npm run smoke:packaged -- "$executable"
fi
- 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 }}'