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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions .github/workflows/desktop-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -175,6 +179,20 @@ jobs:
package-lock.json
packages/desktop-shell/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.rust_target }}-${{ env.NODE_VERSION }}-${{ inputs.dry_run }}'

- uses: 'dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4' # stable
with:
targets: '${{ matrix.rust_target }}'
Expand Down Expand Up @@ -323,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'
Expand Down Expand Up @@ -487,10 +511,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
Expand Down Expand Up @@ -590,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)
Expand All @@ -603,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
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/sync-desktop-to-oss.yml
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ jobs:
id: 'latest'
env:
GH_TOKEN: '${{ github.token }}'
SOURCE: '${{ steps.release.outputs.source }}'
run: |
set -euo pipefail
directory="$(mktemp -d)"
Expand All @@ -191,6 +192,12 @@ 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
Comment thread
yiliang114 marked this conversation as resolved.
echo "::error::GitHub stable feed is $actual after publishing Desktop $expected."
exit 1
Comment thread
yiliang114 marked this conversation as resolved.
else
echo 'matches=false' >> "$GITHUB_OUTPUT"
echo "::notice::GitHub stable feed is $actual; mirrored version $expected will not replace the OSS latest feed."
Expand Down
7 changes: 7 additions & 0 deletions docs/design/desktop-release-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Desktop release hardening

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep the design contract aligned with the newer-feed branch

This says a normal release fails whenever the GitHub stable feed differs from the version it just published, but the implemented contract intentionally succeeds when the current feed is strictly newer (and the PR description now says so). Could we qualify this as failing only when the feed is invalid or older, while preserving a newer feed? Otherwise the design note tells a future maintainer to undo the intended behavior.


Verification covers the release workflow contracts, the Desktop release helpers, runtime smoke checks, and a dry-run installer build.
163 changes: 119 additions & 44 deletions packages/desktop-shell/scripts/prepare-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,20 @@ 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 });
recoverInterruptedRuntime();
const stagingRoot = fs.mkdtempSync(path.join(runtimeDir, '.prepare-'));
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
const packageRoot = path.join(stagingRoot, 'qwen-code');
const libDir = path.join(packageRoot, 'lib');
const nodeDir = path.join(packageRoot, 'node');
const qwenCodeVersion = JSON.parse(
Expand Down Expand Up @@ -87,44 +91,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) {
Expand All @@ -138,19 +146,41 @@ 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 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);
await download(`${downloadRoot}/${archiveName}`, archivePath);
verifyChecksum(
archivePath,
archiveName,
fs.readFileSync(checksumsPath, 'utf8'),
);
const checksums = fs.readFileSync(checksumsPath, 'utf8');
if (
copyValidCachedArchive(
cachedArchivePath,
archivePath,
archiveName,
checksums,
)
) {
console.log(`Using cached Node.js runtime ${archiveName}`);
} else {
await download(`${downloadRoot}/${archiveName}`, archivePath);
verifyChecksum(archivePath, archiveName, checksums);
fs.mkdirSync(cacheDir, { recursive: true });
const temporaryCachePath = `${cachedArchivePath}.${process.pid}.tmp`;
try {
fs.copyFileSync(archivePath, temporaryCachePath);
fs.renameSync(temporaryCachePath, cachedArchivePath);
} finally {
fs.rmSync(temporaryCachePath, { force: true });
}
}
extractNodeArchive(archivePath, temporaryRoot);
const extractedRoot = path.join(
temporaryRoot,
Expand All @@ -165,6 +195,24 @@ async function installNodeRuntime(destination, desktopTarget) {
}
}

function copyValidCachedArchive(
cachedArchivePath,
archivePath,
archiveName,
checksums,
) {
if (!fs.existsSync(cachedArchivePath)) return false;
try {
fs.copyFileSync(cachedArchivePath, archivePath);
verifyChecksum(archivePath, archiveName, checksums);
return true;
} catch {
fs.rmSync(cachedArchivePath, { force: true });
fs.rmSync(archivePath, { force: true });
return false;
}
}

function desktopTarget() {
const target =
process.env.QWEN_DESKTOP_TARGET || `${process.platform}-${process.arch}`;
Expand Down Expand Up @@ -291,3 +339,30 @@ function copyDirectory(source, destination) {
filter: (entry) => path.basename(entry) !== '.DS_Store',
});
}

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)) {
fs.renameSync(finalPackageRoot, previousRoot);
Comment thread
yiliang114 marked this conversation as resolved.
}
try {
fs.renameSync(packageRoot, finalPackageRoot);
} catch (error) {
if (fs.existsSync(previousRoot)) {
fs.renameSync(previousRoot, finalPackageRoot);
}
throw error;
}
}
Loading
Loading