From 7921ae2016fee3492810f47f34764ee1e50deec5 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 20:38:21 +0800 Subject: [PATCH 1/2] fix(desktop): harden release pipeline --- .github/workflows/desktop-release.yml | 25 ++- .github/workflows/sync-desktop-to-oss.yml | 4 + docs/design/desktop-release-hardening.md | 7 + .../desktop-shell/scripts/prepare-runtime.js | 142 +++++++++++++----- .../desktop-shell/scripts/test-release.js | 139 +++++++++++++++++ scripts/tests/desktop-oss-workflow.test.js | 8 + 6 files changed, 284 insertions(+), 41 deletions(-) create mode 100644 docs/design/desktop-release-hardening.md diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 95d592f3b30..98836469782 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -82,6 +82,10 @@ jobs: 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" = 'false' ] && [ "$IS_DRAFT" = 'false' ] && [ "$IS_PRERELEASE" = 'false' ] && [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::Published stable Desktop versions must use X.Y.Z: $INPUT_VERSION" exit 1 @@ -175,6 +179,15 @@ jobs: package-lock.json packages/desktop-shell/package-lock.json + - uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # v4 + with: + path: '${{ runner.temp }}/qwen-desktop-node-cache' + key: 'desktop-node-${{ matrix.rust_target }}-${{ env.NODE_VERSION }}' + + - name: 'Configure bundled Node.js cache' + shell: 'bash' + run: 'echo "QWEN_DESKTOP_NODE_CACHE_DIR=$RUNNER_TEMP/qwen-desktop-node-cache" >> "$GITHUB_ENV"' + - uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable with: targets: '${{ matrix.rust_target }}' @@ -487,10 +500,20 @@ jobs: 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 + *.AppImage|*.AppImage.sig|*.deb|*.deb.sig) ;; + *) continue ;; + esac fi name="${name// /-}" cp "$artifact" "$destination/$name" - done < <(find "$bundle_root" -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.zip' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0) + done < <(find "$bundle_root" -mindepth 2 -maxdepth 2 -type f \( -name '*.dmg' -o -name '*.AppImage' -o -name '*.deb' -o -name '*.exe' -o -name '*.zip' -o -name '*.app.tar.gz' -o -name '*.sig' \) -print0) if [ -z "$(find "$destination" -type f -print -quit)" ]; then echo '::error::No desktop artifacts were produced.' exit 1 diff --git a/.github/workflows/sync-desktop-to-oss.yml b/.github/workflows/sync-desktop-to-oss.yml index 451639587bb..afd69baf779 100644 --- a/.github/workflows/sync-desktop-to-oss.yml +++ b/.github/workflows/sync-desktop-to-oss.yml @@ -182,6 +182,7 @@ jobs: id: 'latest' env: GH_TOKEN: '${{ github.token }}' + SOURCE: '${{ steps.release.outputs.source }}' run: | set -euo pipefail directory="$(mktemp -d)" @@ -191,6 +192,9 @@ jobs: actual="$(jq -r '.version' "$directory/desktop-latest.json")" if [ "$actual" = "$expected" ]; then echo 'matches=true' >> "$GITHUB_OUTPUT" + elif [ "$SOURCE" = 'artifact' ]; then + echo "::error::GitHub stable feed is $actual after publishing Desktop $expected." + exit 1 else echo 'matches=false' >> "$GITHUB_OUTPUT" echo "::notice::GitHub stable feed is $actual; mirrored version $expected will not replace the OSS latest feed." diff --git a/docs/design/desktop-release-hardening.md b/docs/design/desktop-release-hardening.md new file mode 100644 index 00000000000..7450d9760c9 --- /dev/null +++ b/docs/design/desktop-release-hardening.md @@ -0,0 +1,7 @@ +# Desktop release hardening + +The Desktop release should preserve the last complete bundled runtime until a replacement is fully assembled, reuse a verified Node.js archive across local builds, and publish only installer/updater artifacts. Published prereleases must use a SemVer prerelease suffix so a later stable build has a strictly newer updater version. + +Stable releases continue to mirror versioned assets to Aliyun OSS before advancing the OSS latest manifest. A normal release run now fails if the GitHub stable feed does not match the version it just published; manual backfills of older releases still leave the latest feed unchanged. + +Verification covers the release workflow contracts, the Desktop release helpers, runtime smoke checks, and a dry-run installer build. diff --git a/packages/desktop-shell/scripts/prepare-runtime.js b/packages/desktop-shell/scripts/prepare-runtime.js index ff5b15d8af0..2cfab62cd56 100755 --- a/packages/desktop-shell/scripts/prepare-runtime.js +++ b/packages/desktop-shell/scripts/prepare-runtime.js @@ -17,16 +17,19 @@ const sourceRoot = process.env.QWEN_CODE_ROOT ? path.resolve(process.env.QWEN_CODE_ROOT) : repoRoot; const runtimeDir = path.join(packageDir, 'runtime'); -const packageRoot = path.join(runtimeDir, 'qwen-code'); +const finalPackageRoot = path.join(runtimeDir, 'qwen-code'); const refreshChecksums = process.argv.indexOf('--refresh-checksums'); if (refreshChecksums !== -1) { const root = process.argv[refreshChecksums + 1] ? path.resolve(process.argv[refreshChecksums + 1]) - : packageRoot; + : finalPackageRoot; writeChecksums(root); console.log(`Refreshed desktop runtime checksums at ${root}`); process.exit(0); } +fs.mkdirSync(runtimeDir, { recursive: true }); +const stagingRoot = fs.mkdtempSync(path.join(runtimeDir, '.prepare-')); +const packageRoot = path.join(stagingRoot, 'qwen-code'); const libDir = path.join(packageRoot, 'lib'); const nodeDir = path.join(packageRoot, 'node'); const qwenCodeVersion = JSON.parse( @@ -87,44 +90,48 @@ for (const required of [ } } -fs.rmSync(runtimeDir, { recursive: true, force: true }); -fs.mkdirSync(libDir, { recursive: true }); -fs.writeFileSync(path.join(packageRoot, '.gitkeep'), ''); -fs.mkdirSync(binDir, { recursive: true }); -copyDirectory(distDir, libDir); -await installNodeRuntime(nodeDir, target); -writeLaunchers(target); -copyRequiredFile( - path.join(sourceRoot, 'LICENSE'), - path.join(packageRoot, 'LICENSE'), -); -copyRequiredFile( - path.join(packageDir, 'NOTICE'), - path.join(packageRoot, 'NOTICE'), -); -const nodeLicense = path.join(nodeDir, 'LICENSE'); -if (!fs.existsSync(nodeLicense)) { - throw new Error(`Bundled Node.js license is missing: ${nodeLicense}`); +try { + fs.mkdirSync(libDir, { recursive: true }); + fs.writeFileSync(path.join(packageRoot, '.gitkeep'), ''); + fs.mkdirSync(binDir, { recursive: true }); + copyDirectory(distDir, libDir); + await installNodeRuntime(nodeDir, target); + writeLaunchers(target); + copyRequiredFile( + path.join(sourceRoot, 'LICENSE'), + path.join(packageRoot, 'LICENSE'), + ); + copyRequiredFile( + path.join(packageDir, 'NOTICE'), + path.join(packageRoot, 'NOTICE'), + ); + const nodeLicense = path.join(nodeDir, 'LICENSE'); + if (!fs.existsSync(nodeLicense)) { + throw new Error(`Bundled Node.js license is missing: ${nodeLicense}`); + } + fs.writeFileSync( + path.join(packageRoot, 'manifest.json'), + `${JSON.stringify( + { + name: '@qwen-code/qwen-code', + desktopVersion, + qwenCodeVersion, + qwenCodeCommit: process.env.QWEN_CODE_COMMIT || gitCommit(sourceRoot), + target, + node: `v${process.versions.node}`, + builtAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + writeChecksums(); + replaceRuntime(); +} finally { + fs.rmSync(stagingRoot, { recursive: true, force: true }); } -fs.writeFileSync( - path.join(packageRoot, 'manifest.json'), - `${JSON.stringify( - { - name: '@qwen-code/qwen-code', - desktopVersion, - qwenCodeVersion, - qwenCodeCommit: process.env.QWEN_CODE_COMMIT || gitCommit(sourceRoot), - target, - node: `v${process.versions.node}`, - builtAt: new Date().toISOString(), - }, - null, - 2, - )}\n`, -); -writeChecksums(); console.log( - `Prepared desktop runtime at ${path.relative(repoRoot, packageRoot)}`, + `Prepared desktop runtime at ${path.relative(repoRoot, finalPackageRoot)}`, ); async function installNodeRuntime(destination, desktopTarget) { @@ -138,14 +145,36 @@ async function installNodeRuntime(destination, desktopTarget) { } const archiveName = nodeArchiveName(nodeVersion, desktopTarget); const downloadRoot = `https://nodejs.org/dist/v${nodeVersion}`; + const cacheRoot = process.env.QWEN_DESKTOP_NODE_CACHE_DIR + ? path.resolve(process.env.QWEN_DESKTOP_NODE_CACHE_DIR) + : path.join(os.tmpdir(), 'qwen-desktop-node-cache'); + const cacheDir = path.join(cacheRoot, `v${nodeVersion}`); + const cachedChecksumsPath = path.join(cacheDir, 'SHASUMS256.txt'); + const cachedArchivePath = path.join(cacheDir, archiveName); const temporaryRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'qwen-desktop-node-'), ); try { const checksumsPath = path.join(temporaryRoot, 'SHASUMS256.txt'); const archivePath = path.join(temporaryRoot, archiveName); - await download(`${downloadRoot}/SHASUMS256.txt`, checksumsPath); - await download(`${downloadRoot}/${archiveName}`, archivePath); + if ( + validCachedArchive(cachedArchivePath, cachedChecksumsPath, archiveName) + ) { + console.log(`Using cached Node.js runtime ${archiveName}`); + fs.copyFileSync(cachedChecksumsPath, checksumsPath); + fs.copyFileSync(cachedArchivePath, archivePath); + } else { + await download(`${downloadRoot}/SHASUMS256.txt`, checksumsPath); + await download(`${downloadRoot}/${archiveName}`, archivePath); + verifyChecksum( + archivePath, + archiveName, + fs.readFileSync(checksumsPath, 'utf8'), + ); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.copyFileSync(checksumsPath, cachedChecksumsPath); + fs.copyFileSync(archivePath, cachedArchivePath); + } verifyChecksum( archivePath, archiveName, @@ -165,6 +194,24 @@ async function installNodeRuntime(destination, desktopTarget) { } } +function validCachedArchive(archivePath, checksumsPath, archiveName) { + if (!fs.existsSync(archivePath) || !fs.existsSync(checksumsPath)) { + return false; + } + try { + verifyChecksum( + archivePath, + archiveName, + fs.readFileSync(checksumsPath, 'utf8'), + ); + return true; + } catch { + fs.rmSync(archivePath, { force: true }); + fs.rmSync(checksumsPath, { force: true }); + return false; + } +} + function desktopTarget() { const target = process.env.QWEN_DESKTOP_TARGET || `${process.platform}-${process.arch}`; @@ -291,3 +338,18 @@ function copyDirectory(source, destination) { filter: (entry) => path.basename(entry) !== '.DS_Store', }); } + +function replaceRuntime() { + const previousRoot = path.join(stagingRoot, 'previous'); + if (fs.existsSync(finalPackageRoot)) { + fs.renameSync(finalPackageRoot, previousRoot); + } + try { + fs.renameSync(packageRoot, finalPackageRoot); + } catch (error) { + if (fs.existsSync(previousRoot)) { + fs.renameSync(previousRoot, finalPackageRoot); + } + throw error; + } +} diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index 4951f0dd604..52560fa4048 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -42,12 +42,14 @@ try { testLegacyApplicationIdentity(); testElectronBridgeWorkflow(); testDesktopReleaseSigningWorkflow(); + testDesktopReleaseHardening(); testUpdaterMirrorConfiguration(); testResolveLogRoot(); testSliceNewLog(); testUpdateManifest(path.join(root, 'manifest')); testElectronBridgeManifest(path.join(root, 'electron-bridge')); testVersionSynchronization(path.join(root, 'version')); + testRuntimePreparation(path.join(root, 'runtime')); console.log('Desktop release helper checks passed.'); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -199,6 +201,143 @@ function testDesktopReleaseSigningWorkflow() { ); } +function testDesktopReleaseHardening() { + const workflow = fs.readFileSync( + path.join(repoRoot, '.github', 'workflows', 'desktop-release.yml'), + 'utf8', + ); + assert.match( + workflow, + /Desktop prereleases must use a SemVer prerelease suffix/, + 'prerelease builds must not reuse a stable Desktop version', + ); + assert.match( + workflow, + /\*-setup\.exe\|\*-setup\.exe\.sig/, + 'Windows release collection must allow only installer executables', + ); + assert.doesNotMatch( + workflow.slice( + workflow.indexOf('elif [ "$RUNNER_OS" = \'Windows\' ]'), + workflow.indexOf('elif [ "$RUNNER_OS" = \'Linux\' ]'), + ), + /\*\.exe\)/, + 'Windows release collection must not include embedded executables', + ); + + const prepareRuntime = fs.readFileSync( + path.join(packageDir, 'scripts', 'prepare-runtime.js'), + 'utf8', + ); + assert.match( + prepareRuntime, + /QWEN_DESKTOP_NODE_CACHE_DIR/, + 'runtime preparation must cache verified Node.js archives', + ); + assert.match( + workflow, + /desktop-node-\$\{\{ matrix\.rust_target \}\}-\$\{\{ env\.NODE_VERSION \}\}/, + 'release builds must persist the bundled Node.js archive cache', + ); + assert.ok( + prepareRuntime.indexOf('replaceRuntime();') > + prepareRuntime.indexOf('writeChecksums();'), + 'runtime replacement must happen only after assembly and checksums finish', + ); +} + +function testRuntimePreparation(directory) { + const testPackageDir = path.join(directory, 'packages', 'desktop-shell'); + const testScript = path.join(testPackageDir, 'scripts', 'prepare-runtime.js'); + const sourceRoot = path.join(directory, 'source'); + const runtimeDir = path.join(testPackageDir, 'runtime'); + const cacheRoot = path.join(directory, 'cache'); + const nodeVersion = process.versions.node; + const archiveName = `node-v${nodeVersion}-darwin-arm64.tar.gz`; + const cacheDir = path.join(cacheRoot, `v${nodeVersion}`); + const archivePath = path.join(cacheDir, archiveName); + const extractedRoot = path.join( + directory, + `node-v${nodeVersion}-darwin-arm64`, + ); + + fs.mkdirSync(path.join(sourceRoot, 'dist', 'web-shell', 'assets'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourceRoot, 'package.json'), + JSON.stringify({ version: '0.0.0-test' }), + ); + fs.mkdirSync(path.dirname(testScript), { recursive: true }); + fs.copyFileSync( + path.join(packageDir, 'scripts', 'prepare-runtime.js'), + testScript, + ); + fs.writeFileSync(path.join(directory, '.nvmrc'), '22\n'); + fs.writeFileSync( + path.join(testPackageDir, 'package.json'), + JSON.stringify({ version: '0.0.0-test' }), + ); + fs.writeFileSync(path.join(testPackageDir, 'NOTICE'), 'test notice'); + fs.writeFileSync(path.join(sourceRoot, 'LICENSE'), 'test license'); + for (const file of [ + 'cli.js', + 'cli-entry.js', + path.join('web-shell', 'index.html'), + path.join('web-shell', 'assets', 'app.js'), + ]) { + fs.writeFileSync(path.join(sourceRoot, 'dist', file), 'test'); + } + fs.mkdirSync(path.join(extractedRoot, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(extractedRoot, 'bin', 'node'), 'node'); + fs.writeFileSync(path.join(extractedRoot, 'LICENSE'), 'node license'); + fs.mkdirSync(cacheDir, { recursive: true }); + execFileSync('tar', [ + '-czf', + archivePath, + '-C', + directory, + path.basename(extractedRoot), + ]); + const archiveHash = crypto + .createHash('sha256') + .update(fs.readFileSync(archivePath)) + .digest('hex'); + fs.writeFileSync( + path.join(cacheDir, 'SHASUMS256.txt'), + `${archiveHash} ${archiveName}\n`, + ); + + const env = { + ...process.env, + QWEN_CODE_COMMIT: 'test-commit', + QWEN_CODE_ROOT: sourceRoot, + QWEN_DESKTOP_NODE_CACHE_DIR: cacheRoot, + QWEN_DESKTOP_SKIP_BUILD: '1', + QWEN_DESKTOP_TARGET: 'darwin-arm64', + npm_execpath: process.env.npm_execpath || process.argv[1], + }; + const first = spawnSync(process.execPath, [testScript], { + encoding: 'utf8', + env, + }); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stdout, /Using cached Node\.js runtime/); + assert.ok( + fs.existsSync(path.join(runtimeDir, 'qwen-code', 'checksums.json')), + ); + + const marker = path.join(runtimeDir, 'qwen-code', 'complete-marker'); + fs.writeFileSync(marker, 'preserve me'); + fs.rmSync(path.join(sourceRoot, 'LICENSE')); + const failed = spawnSync(process.execPath, [testScript], { + encoding: 'utf8', + env, + }); + assert.notEqual(failed.status, 0); + assert.equal(fs.readFileSync(marker, 'utf8'), 'preserve me'); +} + function testUpdaterMirrorConfiguration() { assert.deepEqual(tauriConfig.plugins?.updater?.endpoints, [ 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/desktop/latest/desktop-latest.json', diff --git a/scripts/tests/desktop-oss-workflow.test.js b/scripts/tests/desktop-oss-workflow.test.js index 63bf6e96502..7ff6b19d8dc 100644 --- a/scripts/tests/desktop-oss-workflow.test.js +++ b/scripts/tests/desktop-oss-workflow.test.js @@ -33,6 +33,9 @@ describe('Desktop OSS mirror workflow', () => { expect(prepare).toContain( 'Published stable Desktop versions must use X.Y.Z', ); + expect(prepare).toContain( + 'Desktop prereleases must use a SemVer prerelease suffix', + ); const syncOss = getWorkflowJob(releaseWorkflow, 'sync-oss'); expect(syncOss).toContain( @@ -102,6 +105,11 @@ describe('Desktop OSS mirror workflow', () => { 'Check whether release matches GitHub stable feed', ); expect(check).toContain("gh release download 'desktop-latest'"); + expect(check).toContain("SOURCE: '${{ steps.release.outputs.source }}'"); + expect(check).toContain('elif [ "$SOURCE" = \'artifact\' ]; then'); + expect(check).toContain( + 'GitHub stable feed is $actual after publishing Desktop $expected', + ); expect(check).toContain('echo \'matches=true\' >> "$GITHUB_OUTPUT"'); expect(check).toContain('echo \'matches=false\' >> "$GITHUB_OUTPUT"'); expect(check).toContain( From 895ca61aa1e388fa516fd0f45420d7957130f4c7 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Wed, 12 Aug 2026 23:13:02 +0800 Subject: [PATCH 2/2] fix(desktop): resolve release hardening review --- .github/workflows/desktop-release.yml | 39 +++++-- .github/workflows/sync-desktop-to-oss.yml | 3 + docs/design/desktop-release-hardening.md | 2 +- .../desktop-shell/scripts/prepare-runtime.js | 67 +++++++----- .../desktop-shell/scripts/test-release.js | 100 ++++++++++++++++-- scripts/tests/desktop-oss-workflow.test.js | 12 +++ 6 files changed, 179 insertions(+), 44 deletions(-) diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 98836469782..01fc39d1f52 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -44,7 +44,7 @@ permissions: contents: 'read' concurrency: - group: 'desktop-release-${{ inputs.version }}' + group: "desktop-release-${{ inputs.dry_run && inputs.version || 'publish' }}" cancel-in-progress: false env: @@ -179,14 +179,19 @@ jobs: package-lock.json packages/desktop-shell/package-lock.json - - uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # v4 - with: - path: '${{ runner.temp }}/qwen-desktop-node-cache' - key: 'desktop-node-${{ matrix.rust_target }}-${{ env.NODE_VERSION }}' - - name: 'Configure bundled Node.js cache' + id: 'node-cache-path' shell: 'bash' - run: 'echo "QWEN_DESKTOP_NODE_CACHE_DIR=$RUNNER_TEMP/qwen-desktop-node-cache" >> "$GITHUB_ENV"' + 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.rust_target }}-${{ env.NODE_VERSION }}-${{ inputs.dry_run }}' - uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable with: @@ -336,6 +341,12 @@ jobs: working-directory: 'packages/desktop-shell' 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.rust_target }}-${{ env.NODE_VERSION }}-${{ inputs.dry_run }}' + - name: 'Run desktop tests' working-directory: 'packages/desktop-shell' run: 'npm test' @@ -613,6 +624,7 @@ jobs: ELECTRON_BRIDGE: '${{ inputs.electron_bridge }}' GH_TOKEN: '${{ github.token }}' FEED_TAG: '${{ env.DESKTOP_FEED_TAG }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.version }}' run: | set -euo pipefail feed_assets=(release-assets/desktop-latest.json) @@ -626,6 +638,19 @@ jobs: ) 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 + echo "::notice::Desktop $RELEASE_VERSION will not replace newer stable feed $current." + exit 0 + fi gh release upload "$FEED_TAG" "${feed_assets[@]}" --clobber else gh release create "$FEED_TAG" "${feed_assets[@]}" --title 'Qwen Code Desktop latest' --notes 'Stable desktop updater feed.' --latest=false diff --git a/.github/workflows/sync-desktop-to-oss.yml b/.github/workflows/sync-desktop-to-oss.yml index afd69baf779..1a21943fa46 100644 --- a/.github/workflows/sync-desktop-to-oss.yml +++ b/.github/workflows/sync-desktop-to-oss.yml @@ -192,6 +192,9 @@ jobs: actual="$(jq -r '.version' "$directory/desktop-latest.json")" if [ "$actual" = "$expected" ]; then echo 'matches=true' >> "$GITHUB_OUTPUT" + elif [[ "$actual" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] && [ "$(printf '%s\n%s\n' "$expected" "$actual" | sort -V | tail -n 1)" = "$actual" ]; then + echo 'matches=false' >> "$GITHUB_OUTPUT" + echo "::notice::GitHub stable feed is already newer at $actual; mirrored version $expected will not replace the OSS latest feed." elif [ "$SOURCE" = 'artifact' ]; then echo "::error::GitHub stable feed is $actual after publishing Desktop $expected." exit 1 diff --git a/docs/design/desktop-release-hardening.md b/docs/design/desktop-release-hardening.md index 7450d9760c9..0be2e0822fd 100644 --- a/docs/design/desktop-release-hardening.md +++ b/docs/design/desktop-release-hardening.md @@ -1,6 +1,6 @@ # Desktop release hardening -The Desktop release should preserve the last complete bundled runtime until a replacement is fully assembled, reuse a verified Node.js archive across local builds, and publish only installer/updater artifacts. Published prereleases must use a SemVer prerelease suffix so a later stable build has a strictly newer updater version. +The Desktop release should preserve the last complete bundled runtime until a replacement is fully assembled, recover that runtime on the next run if a swap is interrupted, reuse a Node.js archive verified against a fresh official checksum, and publish only installer/updater artifacts. Published prereleases must use a SemVer prerelease suffix so a later stable build has a strictly newer updater version. Stable releases continue to mirror versioned assets to Aliyun OSS before advancing the OSS latest manifest. A normal release run now fails if the GitHub stable feed does not match the version it just published; manual backfills of older releases still leave the latest feed unchanged. diff --git a/packages/desktop-shell/scripts/prepare-runtime.js b/packages/desktop-shell/scripts/prepare-runtime.js index 2cfab62cd56..8d81ce6d69f 100755 --- a/packages/desktop-shell/scripts/prepare-runtime.js +++ b/packages/desktop-shell/scripts/prepare-runtime.js @@ -28,6 +28,7 @@ if (refreshChecksums !== -1) { process.exit(0); } fs.mkdirSync(runtimeDir, { recursive: true }); +recoverInterruptedRuntime(); const stagingRoot = fs.mkdtempSync(path.join(runtimeDir, '.prepare-')); const packageRoot = path.join(stagingRoot, 'qwen-code'); const libDir = path.join(packageRoot, 'lib'); @@ -149,37 +150,37 @@ async function installNodeRuntime(destination, desktopTarget) { ? path.resolve(process.env.QWEN_DESKTOP_NODE_CACHE_DIR) : path.join(os.tmpdir(), 'qwen-desktop-node-cache'); const cacheDir = path.join(cacheRoot, `v${nodeVersion}`); - const cachedChecksumsPath = path.join(cacheDir, 'SHASUMS256.txt'); const cachedArchivePath = path.join(cacheDir, archiveName); + fs.rmSync(path.join(cacheDir, 'SHASUMS256.txt'), { force: true }); const temporaryRoot = fs.mkdtempSync( path.join(os.tmpdir(), 'qwen-desktop-node-'), ); try { const checksumsPath = path.join(temporaryRoot, 'SHASUMS256.txt'); const archivePath = path.join(temporaryRoot, archiveName); + await download(`${downloadRoot}/SHASUMS256.txt`, checksumsPath); + const checksums = fs.readFileSync(checksumsPath, 'utf8'); if ( - validCachedArchive(cachedArchivePath, cachedChecksumsPath, archiveName) + copyValidCachedArchive( + cachedArchivePath, + archivePath, + archiveName, + checksums, + ) ) { console.log(`Using cached Node.js runtime ${archiveName}`); - fs.copyFileSync(cachedChecksumsPath, checksumsPath); - fs.copyFileSync(cachedArchivePath, archivePath); } else { - await download(`${downloadRoot}/SHASUMS256.txt`, checksumsPath); await download(`${downloadRoot}/${archiveName}`, archivePath); - verifyChecksum( - archivePath, - archiveName, - fs.readFileSync(checksumsPath, 'utf8'), - ); + verifyChecksum(archivePath, archiveName, checksums); fs.mkdirSync(cacheDir, { recursive: true }); - fs.copyFileSync(checksumsPath, cachedChecksumsPath); - fs.copyFileSync(archivePath, cachedArchivePath); + const temporaryCachePath = `${cachedArchivePath}.${process.pid}.tmp`; + try { + fs.copyFileSync(archivePath, temporaryCachePath); + fs.renameSync(temporaryCachePath, cachedArchivePath); + } finally { + fs.rmSync(temporaryCachePath, { force: true }); + } } - verifyChecksum( - archivePath, - archiveName, - fs.readFileSync(checksumsPath, 'utf8'), - ); extractNodeArchive(archivePath, temporaryRoot); const extractedRoot = path.join( temporaryRoot, @@ -194,20 +195,20 @@ async function installNodeRuntime(destination, desktopTarget) { } } -function validCachedArchive(archivePath, checksumsPath, archiveName) { - if (!fs.existsSync(archivePath) || !fs.existsSync(checksumsPath)) { - return false; - } +function copyValidCachedArchive( + cachedArchivePath, + archivePath, + archiveName, + checksums, +) { + if (!fs.existsSync(cachedArchivePath)) return false; try { - verifyChecksum( - archivePath, - archiveName, - fs.readFileSync(checksumsPath, 'utf8'), - ); + fs.copyFileSync(cachedArchivePath, archivePath); + verifyChecksum(archivePath, archiveName, checksums); return true; } catch { + fs.rmSync(cachedArchivePath, { force: true }); fs.rmSync(archivePath, { force: true }); - fs.rmSync(checksumsPath, { force: true }); return false; } } @@ -339,6 +340,18 @@ function copyDirectory(source, destination) { }); } +function recoverInterruptedRuntime() { + for (const entry of fs.readdirSync(runtimeDir)) { + if (!entry.startsWith('.prepare-')) continue; + const staleRoot = path.join(runtimeDir, entry); + const previousRoot = path.join(staleRoot, 'previous'); + if (!fs.existsSync(finalPackageRoot) && fs.existsSync(previousRoot)) { + fs.renameSync(previousRoot, finalPackageRoot); + } + fs.rmSync(staleRoot, { recursive: true, force: true }); + } +} + function replaceRuntime() { const previousRoot = path.join(stagingRoot, 'previous'); if (fs.existsSync(finalPackageRoot)) { diff --git a/packages/desktop-shell/scripts/test-release.js b/packages/desktop-shell/scripts/test-release.js index 52560fa4048..9cf3222fbb4 100755 --- a/packages/desktop-shell/scripts/test-release.js +++ b/packages/desktop-shell/scripts/test-release.js @@ -6,7 +6,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { resolveLogRoot, sliceNewLog } from './resolve-log-root.js'; const packageDir = path.resolve( @@ -208,7 +208,7 @@ function testDesktopReleaseHardening() { ); assert.match( workflow, - /Desktop prereleases must use a SemVer prerelease suffix/, + /IS_PRERELEASE" = 'true' \] && \[\[ ! "\$version" =~ \^\[0-9\]\+\\\.\[0-9\]\+\\\.\[0-9\]\+-/, 'prerelease builds must not reuse a stable Desktop version', ); assert.match( @@ -224,6 +224,11 @@ function testDesktopReleaseHardening() { /\*\.exe\)/, 'Windows release collection must not include embedded executables', ); + assert.match( + workflow, + /\*\.AppImage\|\*\.AppImage\.sig\|\*\.deb\|\*\.deb\.sig/, + 'Linux release collection must allow only installers and updater signatures', + ); const prepareRuntime = fs.readFileSync( path.join(packageDir, 'scripts', 'prepare-runtime.js'), @@ -236,9 +241,20 @@ function testDesktopReleaseHardening() { ); assert.match( workflow, - /desktop-node-\$\{\{ matrix\.rust_target \}\}-\$\{\{ env\.NODE_VERSION \}\}/, + /desktop-node-v2-\$\{\{ matrix\.rust_target \}\}-\$\{\{ env\.NODE_VERSION \}\}-\$\{\{ inputs\.dry_run \}\}/, 'release builds must persist the bundled Node.js archive cache', ); + assert.match(workflow, /actions\/cache\/restore@/); + assert.match(workflow, /actions\/cache\/save@/); + assert.equal( + ( + workflow.match( + /path: '\$\{\{ steps\.node-cache-path\.outputs\.path \}\}'/g, + ) ?? [] + ).length, + 2, + 'cache restore and save must share the configured cache path', + ); assert.ok( prepareRuntime.indexOf('replaceRuntime();') > prepareRuntime.indexOf('writeChecksums();'), @@ -255,7 +271,11 @@ function testRuntimePreparation(directory) { const nodeVersion = process.versions.node; const archiveName = `node-v${nodeVersion}-darwin-arm64.tar.gz`; const cacheDir = path.join(cacheRoot, `v${nodeVersion}`); - const archivePath = path.join(cacheDir, archiveName); + const cachedArchivePath = path.join(cacheDir, archiveName); + const archivePath = path.join(directory, archiveName); + const checksumsPath = path.join(directory, 'SHASUMS256.txt'); + const fetchLog = path.join(directory, 'fetch.log'); + const fetchMock = path.join(directory, 'mock-fetch.mjs'); const extractedRoot = path.join( directory, `node-v${nodeVersion}-darwin-arm64`, @@ -273,7 +293,10 @@ function testRuntimePreparation(directory) { path.join(packageDir, 'scripts', 'prepare-runtime.js'), testScript, ); - fs.writeFileSync(path.join(directory, '.nvmrc'), '22\n'); + fs.writeFileSync( + path.join(directory, '.nvmrc'), + `${process.versions.node.split('.')[0]}\n`, + ); fs.writeFileSync( path.join(testPackageDir, 'package.json'), JSON.stringify({ version: '0.0.0-test' }), @@ -291,7 +314,6 @@ function testRuntimePreparation(directory) { fs.mkdirSync(path.join(extractedRoot, 'bin'), { recursive: true }); fs.writeFileSync(path.join(extractedRoot, 'bin', 'node'), 'node'); fs.writeFileSync(path.join(extractedRoot, 'LICENSE'), 'node license'); - fs.mkdirSync(cacheDir, { recursive: true }); execFileSync('tar', [ '-czf', archivePath, @@ -303,9 +325,19 @@ function testRuntimePreparation(directory) { .createHash('sha256') .update(fs.readFileSync(archivePath)) .digest('hex'); + fs.writeFileSync(checksumsPath, `${archiveHash} ${archiveName}\n`); fs.writeFileSync( - path.join(cacheDir, 'SHASUMS256.txt'), - `${archiveHash} ${archiveName}\n`, + fetchMock, + `import fs from 'node:fs'; +globalThis.fetch = async (url) => { + const value = String(url); + const source = value.endsWith('/SHASUMS256.txt') + ? process.env.QWEN_TEST_NODE_CHECKSUMS + : process.env.QWEN_TEST_NODE_ARCHIVE; + fs.appendFileSync(process.env.QWEN_TEST_FETCH_LOG, value + '\\n'); + return new Response(fs.readFileSync(source), { status: 200 }); +}; +`, ); const env = { @@ -315,6 +347,15 @@ function testRuntimePreparation(directory) { QWEN_DESKTOP_NODE_CACHE_DIR: cacheRoot, QWEN_DESKTOP_SKIP_BUILD: '1', QWEN_DESKTOP_TARGET: 'darwin-arm64', + QWEN_TEST_FETCH_LOG: fetchLog, + QWEN_TEST_NODE_ARCHIVE: archivePath, + QWEN_TEST_NODE_CHECKSUMS: checksumsPath, + NODE_OPTIONS: [ + process.env.NODE_OPTIONS, + `--import=${pathToFileURL(fetchMock).href}`, + ] + .filter(Boolean) + .join(' '), npm_execpath: process.env.npm_execpath || process.argv[1], }; const first = spawnSync(process.execPath, [testScript], { @@ -322,13 +363,50 @@ function testRuntimePreparation(directory) { env, }); assert.equal(first.status, 0, first.stderr); - assert.match(first.stdout, /Using cached Node\.js runtime/); + assert.doesNotMatch(first.stdout, /Using cached Node\.js runtime/); + assert.ok(fs.existsSync(cachedArchivePath)); assert.ok( fs.existsSync(path.join(runtimeDir, 'qwen-code', 'checksums.json')), ); + const second = spawnSync(process.execPath, [testScript], { + encoding: 'utf8', + env, + }); + assert.equal(second.status, 0, second.stderr); + assert.match(second.stdout, /Using cached Node\.js runtime/); + + fs.appendFileSync(cachedArchivePath, 'tampered'); + const poisonedHash = crypto + .createHash('sha256') + .update(fs.readFileSync(cachedArchivePath)) + .digest('hex'); + fs.writeFileSync( + path.join(cacheDir, 'SHASUMS256.txt'), + `${poisonedHash} ${archiveName}\n`, + ); + const recoveredCache = spawnSync(process.execPath, [testScript], { + encoding: 'utf8', + env, + }); + assert.equal(recoveredCache.status, 0, recoveredCache.stderr); + assert.doesNotMatch(recoveredCache.stdout, /Using cached Node\.js runtime/); + const fetches = fs.readFileSync(fetchLog, 'utf8').trim().split('\n'); + assert.equal(fetches.filter((url) => url.endsWith(archiveName)).length, 2); + assert.equal( + fetches.filter((url) => url.endsWith('SHASUMS256.txt')).length, + 3, + ); + assert.equal(fs.existsSync(path.join(cacheDir, 'SHASUMS256.txt')), false); + const marker = path.join(runtimeDir, 'qwen-code', 'complete-marker'); fs.writeFileSync(marker, 'preserve me'); + const strandedRoot = path.join(runtimeDir, '.prepare-stranded'); + fs.mkdirSync(strandedRoot); + fs.renameSync( + path.join(runtimeDir, 'qwen-code'), + path.join(strandedRoot, 'previous'), + ); fs.rmSync(path.join(sourceRoot, 'LICENSE')); const failed = spawnSync(process.execPath, [testScript], { encoding: 'utf8', @@ -336,6 +414,10 @@ function testRuntimePreparation(directory) { }); assert.notEqual(failed.status, 0); assert.equal(fs.readFileSync(marker, 'utf8'), 'preserve me'); + assert.deepEqual( + fs.readdirSync(runtimeDir).filter((entry) => entry.startsWith('.prepare-')), + [], + ); } function testUpdaterMirrorConfiguration() { diff --git a/scripts/tests/desktop-oss-workflow.test.js b/scripts/tests/desktop-oss-workflow.test.js index 7ff6b19d8dc..eda9dd77071 100644 --- a/scripts/tests/desktop-oss-workflow.test.js +++ b/scripts/tests/desktop-oss-workflow.test.js @@ -23,6 +23,9 @@ const tauriConfig = JSON.parse( describe('Desktop OSS mirror workflow', () => { it('mirrors only published stable Desktop releases', () => { expect(syncWorkflow).not.toContain('pull_request:'); + expect(releaseWorkflow).toContain( + "desktop-release-${{ inputs.dry_run && inputs.version || 'publish' }}", + ); const prepare = getWorkflowStep( getWorkflowJob(releaseWorkflow, 'prepare'), 'Resolve version', @@ -99,6 +102,13 @@ describe('Desktop OSS mirror workflow', () => { }); it('advances the OSS feed only for the current GitHub stable version', () => { + const publish = getWorkflowJob(releaseWorkflow, 'publish'); + const updateFeed = getWorkflowStep(publish, 'Update stable updater feed'); + expect(updateFeed).toContain('sort -V'); + expect(updateFeed).toContain( + 'Desktop $RELEASE_VERSION will not replace newer stable feed $current', + ); + const sync = getWorkflowJob(syncWorkflow, 'sync'); const check = getWorkflowStep( sync, @@ -107,6 +117,8 @@ describe('Desktop OSS mirror workflow', () => { expect(check).toContain("gh release download 'desktop-latest'"); expect(check).toContain("SOURCE: '${{ steps.release.outputs.source }}'"); expect(check).toContain('elif [ "$SOURCE" = \'artifact\' ]; then'); + expect(check).toContain('sort -V'); + expect(check).toContain('GitHub stable feed is already newer at $actual'); expect(check).toContain( 'GitHub stable feed is $actual after publishing Desktop $expected', );