Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
65076eb
ci(desktop): publish the desktop app when the CLI releases
yiliang114 Sep 10, 2026
956b0b8
ci(desktop): fix release-following gates so the desktop publish runs
yiliang114 Sep 11, 2026
2b01523
Merge remote-tracking branch 'origin/main' into prmerge-11575
yiliang114 Sep 11, 2026
a11675f
fix(ci): run prettier on desktop-oss workflow test
yiliang114 Sep 11, 2026
412ecfa
test(ci): tighten desktop release sync workflow assertions
yiliang114 Sep 11, 2026
668ef60
test(ci): pin cancel-in-progress false in desktop-oss workflow test
yiliang114 Sep 11, 2026
7c89930
ci(desktop): say what the release arm's ancestry check establishes
yiliang114 Sep 14, 2026
11754dc
Merge main and reclaim the desktop-release size baseline
yiliang114 Sep 14, 2026
9419b3a
ci(desktop): name the operand the ancestry guard actually checks
yiliang114 Sep 14, 2026
7f62b95
Merge remote-tracking branch 'origin/main' into fix-11575-wording
yiliang114 Sep 14, 2026
491cea9
test(ci): pin that the release arm emits the tag commit, not its parent
yiliang114 Sep 14, 2026
f96a12a
fix(ci): thread the release signal into the desktop release callees
yiliang114 Sep 14, 2026
091b90f
Merge origin/main into ci/desktop-follow-cli-release
yiliang114 Sep 14, 2026
359a079
fix(ci): gate the desktop publish job and correct the peel comment
yiliang114 Sep 14, 2026
ab9cdb0
docs(ci): record the manual route for a tag the ancestry check rejects
yiliang114 Sep 14, 2026
6b76f73
ci(desktop): report a release-following publish that never happened
yiliang114 Sep 14, 2026
c37db89
chore(ci): record the sync caller's new size in the workflow baseline
yiliang114 Sep 14, 2026
74dba5d
fix(ci): correct the desktop failure reporter's runbook text and chec…
yiliang114 Sep 15, 2026
11966e7
chore(ci): retrigger the lint gate after a reverted foreign push froz…
yiliang114 Sep 15, 2026
a7eb6fc
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Sep 15, 2026
433782c
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Sep 15, 2026
0428a35
Merge remote-tracking branch 'origin/main' into HEAD
yiliang114 Sep 15, 2026
81cd523
Merge remote-tracking branch 'origin/main' into ci/desktop-follow-cli…
yiliang114 Sep 15, 2026
416249d
ci(desktop): handle release events in one workflow
yiliang114 Sep 15, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/.size-baseline
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
1482 codeql.yml
9389 comment-attachment-guard.yml
1634 desktop-packaging-check.yml
31677 desktop-release.yml
35684 desktop-release.yml
2038 docs-page-action.yml
10005 dsw-swe-verified-release.yml
23879 e2e.yml
Expand Down Expand Up @@ -60,7 +60,7 @@
6777 serve-ab-publish.yml
17013 serve-ab.yml
2641 stale.yml
10920 sync-desktop-to-oss.yml
11328 sync-desktop-to-oss.yml
10018 sync-live-host-to-oss.yml
10988 sync-release-to-oss.yml
2508 tui-parity.yml
Expand Down
74 changes: 51 additions & 23 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
name: 'Desktop Release'

run-name: 'Desktop release ${{ inputs.version }}'
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, tag, or commit to bundle.'
description: 'Qwen Code branch or commit to bundle, reachable from main. 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'
Expand Down Expand Up @@ -65,7 +67,6 @@ on:
clobber:
required: true
type: 'boolean'

permissions:
contents: 'read'

Expand All @@ -81,6 +82,16 @@ env:
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
)
}}
runs-on: 'ubuntu-latest'
timeout-minutes: 10
outputs:
Expand All @@ -97,7 +108,7 @@ jobs:
shell: 'bash'
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
INPUT_VERSION: '${{ inputs.version }}'
INPUT_VERSION: '${{ github.event.release.tag_name || inputs.version }}'
IS_DRAFT: '${{ inputs.draft }}'
IS_DRY_RUN: '${{ inputs.dry_run }}'
IS_PRERELEASE: '${{ inputs.prerelease }}'
Expand All @@ -112,7 +123,7 @@ jobs:
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" = 'false' ] && [ "$IS_DRAFT" = 'false' ] && [ "$IS_PRERELEASE" = 'false' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
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
Expand All @@ -131,20 +142,32 @@ jobs:
id: 'source'
shell: 'bash'
env:
INPUT_REF: '${{ inputs.qwen_code_ref }}'
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" = 'false' ]; then
if [ "$GITHUB_REF_NAME" != 'main' ]; then
echo '::error::Published desktop releases must run from main.'
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}^")"
Comment thread
yiliang114 marked this conversation as resolved.
fi
git fetch origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$sha" refs/remotes/origin/main; then
echo '::error::Published desktop releases may only bundle commits reachable from 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
Expand Down Expand Up @@ -248,7 +271,7 @@ jobs:
run: 'node scripts/version.js "${{ needs.prepare.outputs.version }}"'

- name: 'Require updater signing key for publishing'
if: '${{ inputs.dry_run == false }}'
if: "${{ github.event_name == 'release' || inputs.dry_run == false }}"
shell: 'bash'
env:
TAURI_SIGNING_PRIVATE_KEY: '${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}'
Expand All @@ -260,7 +283,7 @@ jobs:
fi

- name: 'Import macOS certificate'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
env:
APPLE_CERTIFICATE: '${{ secrets.APPLE_CERTIFICATE }}'
Expand Down Expand Up @@ -296,7 +319,7 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV"

- name: 'Configure macOS notarization'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
env:
APPLE_API_ISSUER: '${{ secrets.APPLE_API_ISSUER }}'
Expand Down Expand Up @@ -328,7 +351,7 @@ jobs:
} >> "$GITHUB_ENV"

- name: 'Import Windows certificate'
if: "runner.os == 'Windows' && inputs.dry_run == false"
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'pwsh'
env:
WINDOWS_CERTIFICATE: '${{ secrets.WINDOWS_CERTIFICATE }}'
Expand Down Expand Up @@ -382,7 +405,7 @@ jobs:
run: 'npm run test:release'

- name: 'Sign bundled vendor binaries (macOS)'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
working-directory: 'packages/desktop-shell'
shell: 'bash'
env:
Expand Down Expand Up @@ -435,7 +458,7 @@ jobs:
echo "Signed and verified ${#mach_o[@]} Mach-O binaries under $runtime_dir."

- name: 'Refresh bundled runtime checksums after signing (macOS)'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
working-directory: 'packages/desktop-shell'
run: 'node scripts/prepare-runtime.js --refresh-checksums'

Expand All @@ -447,7 +470,7 @@ jobs:
working-directory: 'packages/desktop-shell'
shell: 'bash'
env:
DRY_RUN: '${{ inputs.dry_run }}'
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 }}'
Expand All @@ -462,7 +485,7 @@ jobs:
npm run tauri -- build "${args[@]}"

- name: 'Verify macOS signature'
if: "runner.os == 'macOS' && inputs.dry_run == false"
if: "runner.os == 'macOS' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'bash'
run: |
set -euo pipefail
Expand All @@ -475,7 +498,7 @@ jobs:
test "$(/usr/libexec/PlistBuddy -c 'Print :com.apple.security.device.audio-input' "$entitlements")" = true

- name: 'Verify Windows signature'
if: "runner.os == 'Windows' && inputs.dry_run == false"
if: "runner.os == 'Windows' && (github.event_name == 'release' || inputs.dry_run == false)"
shell: 'pwsh'
env:
WINDOWS_CONFIG: '${{ env.WINDOWS_CONFIG }}'
Expand Down Expand Up @@ -592,12 +615,16 @@ jobs:

publish:
name: 'Publish GitHub release'
if: "${{ inputs.dry_run == false && github.repository == 'QwenLM/qwen-code' }}"
if: "${{ (github.event_name == 'release' || inputs.dry_run == false) && github.repository == 'QwenLM/qwen-code' }}"
needs:
- 'prepare'
- 'build'
runs-on: '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:
Expand Down Expand Up @@ -670,7 +697,7 @@ jobs:
echo "url=$release_url" >> "$GITHUB_OUTPUT"

- name: 'Update stable updater feed'
if: '${{ inputs.draft == false && inputs.prerelease == false }}'
if: "${{ github.event_name == 'release' || (inputs.draft == false && inputs.prerelease == false) }}"
env:
ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}'
GH_TOKEN: '${{ github.token }}'
Expand Down Expand Up @@ -738,7 +765,7 @@ jobs:

sync-oss:
name: 'Mirror stable Desktop release to Aliyun OSS'
if: "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}"
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'
- 'build'
Expand All @@ -750,6 +777,7 @@ jobs:
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 }}'
9 changes: 8 additions & 1 deletion .github/workflows/sync-desktop-to-oss.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ on:
source:
required: true
type: 'string'
# Deliberately not required. This workflow is also a reusable callee, so
# github.event_name here is always workflow_call and can never observe the
# release that triggered the chain; the caller states it instead. Omitting
# the input means "not following a release", the safe default.
follows_release:
default: false
type: 'boolean'
secrets:
ALIYUN_OSS_ACCESS_KEY_ID:
required: true
Expand All @@ -35,7 +42,7 @@ concurrency:
jobs:
sync:
name: 'Mirror Qwen Code Desktop to Aliyun OSS'
if: "${{ github.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main' }}"
if: "${{ github.repository == 'QwenLM/qwen-code' && (github.ref == 'refs/heads/main' || inputs.follows_release) }}"
runs-on: 'ubuntu-latest'
timeout-minutes: 90
environment:
Expand Down
86 changes: 85 additions & 1 deletion scripts/tests/desktop-oss-workflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ describe('Desktop OSS mirror workflow', () => {

const syncOss = getWorkflowJob(releaseWorkflow, 'sync-oss');
expect(syncOss).toContain(
"if: \"${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == false && inputs.draft == false && inputs.prerelease == false && github.repository == 'QwenLM/qwen-code' }}\"",
"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)) }}\"",
);
expect(syncOss).toContain("- 'publish'");
expect(syncOss).toContain("source: 'artifact'");
expect(syncOss).toContain(
'follows_release: "${{ github.event_name == \'release\' }}"',
);
expect(syncOss).not.toContain('secrets: inherit');
});

Expand Down Expand Up @@ -154,4 +157,85 @@ describe('Desktop OSS mirror workflow', () => {
const firstEndpoint = tauriConfig.plugins.updater.endpoints[0];
expect(syncWorkflow).toContain(new URL(firstEndpoint).origin);
});

it('admits the release path in the reusable sync job gate', () => {
const sync = getWorkflowJob(syncWorkflow, 'sync');
expect(sync).toContain(
"if: \"${{ github.repository == 'QwenLM/qwen-code' && (github.ref == 'refs/heads/main' || inputs.follows_release) }}\"",
);
expect(syncWorkflow).toContain(
" follows_release:\n default: false\n type: 'boolean'",
);
expect(syncWorkflow).not.toContain(
' follows_release:\n required: true',
);
});

it('resolves the release tag parent before the main ancestry check', () => {
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
const source = getWorkflowStep(
getWorkflowJob(releaseWorkflow, 'prepare'),
'Resolve Qwen Code source',
);
expect(source).toContain(
'if [ "$GITHUB_REF_NAME" != \'main\' ] && [ "$GITHUB_EVENT_NAME" != \'release\' ]; then',
);
expect(source).toContain(
'::error::Published desktop releases must run from main or follow a published release.',
);
expect(source).toContain('ancestor="$sha"');
expect(source).toContain('if [ "$GITHUB_EVENT_NAME" = \'release\' ]; then');
expect(source).toContain('ancestor="$(git rev-parse "${sha}^")"');
expect(source).toContain(
'git merge-base --is-ancestor "$ancestor" refs/remotes/origin/main',
);
expect(
source.indexOf('ancestor="$(git rev-parse "${sha}^")"'),
).toBeGreaterThan(source.indexOf('ancestor="$sha"'));
expect(
source.indexOf('ancestor="$(git rev-parse "${sha}^")"'),
).toBeLessThan(source.indexOf('git merge-base --is-ancestor "$ancestor"'));
expect(source).toContain('sha="$(git rev-parse FETCH_HEAD)"');
expect(source).toContain('echo "sha=$sha" >> "$GITHUB_OUTPUT"');
expect(source).not.toContain('sha="$ancestor"');
});

it('puts the feed-clobbering publish behind the deployment gate', () => {
const publish = getWorkflowJob(releaseWorkflow, 'publish');
expect(publish).toContain("environment:\n name: 'production-release'");
});
});

describe('Desktop release event', () => {
it('gates automatic publishing like the VS Code release workflow', () => {
expect(releaseWorkflow).toContain("release:\n types: ['published']");
const prepare = getWorkflowJob(releaseWorkflow, 'prepare');
expect(prepare).toContain(
"github.event_name != 'release' ||\n" +
' (\n' +
" github.repository == 'QwenLM/qwen-code' &&\n" +
" vars.RELEASE_DESKTOP_SYNC_PUBLISH == 'true' &&\n" +
" startsWith(github.event.release.tag_name, 'v') &&\n" +
' github.event.release.prerelease == false',
);
expect(prepare).toContain(
"INPUT_VERSION: '${{ github.event.release.tag_name || inputs.version }}'",
);
expect(prepare).toContain(
"INPUT_REF: '${{ github.event.release.tag_name || inputs.qwen_code_ref }}'",
);
expect(releaseWorkflow).not.toContain(
' follows_release:\n default:',
);
expect(prepare).toContain('$GITHUB_EVENT_NAME');
expect(prepare).toContain('if [ "$IS_DRY_RUN" != \'true\' ]; then');
expect(getWorkflowJob(releaseWorkflow, 'build')).toContain(
"github.event_name == 'release' || inputs.dry_run == false",
);
expect(getWorkflowJob(releaseWorkflow, 'publish')).toContain(
"github.event_name == 'release' || inputs.dry_run == false",
);
expect(getWorkflowJob(releaseWorkflow, 'sync-oss')).toContain(
'follows_release: "${{ github.event_name == \'release\' }}"',
);
});
});
Loading