mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(release): add gated bun/OpenTUI preview flavor to standalone releases (#10814)
* feat(release): add gated bun/OpenTUI preview flavor to standalone releases Phase 2 validation needs a real archive that boots the OpenTUI renderer, so the standalone pipeline gains an opt-in second flavor instead of replacing the classic Node.js one: - create-standalone-package.js derives archive names from a shared standaloneArchiveName() helper; bun archives carry the -opentui-preview suffix, and bun shims default QWEN_TUI_RENDERER to opentui (explicit user overrides still win). - build-standalone-release.js accepts --include-opentui-preview to package both flavors in one run, with per-flavor publisher checksums and a flavor-aware output assertion. - verify-installation-release.js accepts the same flag so a gated release directory/URL and the OSS upload list both expect the preview archives. - release.yml and sync-release-to-oss.yml thread the flag behind a vars.OPENTUI_PREVIEW_RELEASE_ENABLED gate (off by default, byte-identical behavior when unset); .size-baseline re-pinned in the same PR. - Also fix a latent crash in copyOpenTuiAddon's warn-only degradation path, which asserted against a lib/node_modules directory it never created. * feat(release): add a thin installer for the gated opentui-preview archives The preview flavor is not reachable through the hosted installer chain, which only packages assets for stable releases, so testers had to download and unpack the archive by hand. This adds a scratch-directory installer pair that resolves the platform target, verifies the release SHA256SUMS, and unpacks into ~/.qwen-preview without touching PATH or an existing classic installation. A local archive can be installed directly so the path is testable before a gated release exists.
This commit is contained in:
1 parent
3e8d92a729
commit
d7fbf305cc
9 files changed
+672
-82
No files matched your search
@@ -47,7 +47,7 @@
|
||||
22037 release-sdk-python.yml
|
||||
19094 release-sdk.yml
|
||||
14546 release-vscode-companion.yml
|
||||
62799 release.yml
|
||||
65361 release.yml
|
||||
43717 repo-hygiene.yml
|
||||
1079 scorecard-monthly.yml
|
||||
10691 sdk-java.yml
|
||||
@@ -58,7 +58,7 @@
|
||||
2641 stale.yml
|
||||
10920 sync-desktop-to-oss.yml
|
||||
10018 sync-live-host-to-oss.yml
|
||||
10138 sync-release-to-oss.yml
|
||||
10988 sync-release-to-oss.yml
|
||||
2508 tui-parity.yml
|
||||
7187 update-ecs-runner-qwen.yml
|
||||
2307 web-shell-visuals-cleanup.yml
|
||||
|
||||
@@ -992,7 +992,13 @@ jobs:
|
||||
env:
|
||||
RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'
|
||||
QWEN_STANDALONE_REQUIRE_AUDIO_CAPTURE_PREBUILD: "${{ github.repository == 'QwenLM/qwen-code' && '1' || '' }}"
|
||||
run: 'npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone'
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: |-
|
||||
PREVIEW_ARGS=""
|
||||
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
|
||||
PREVIEW_ARGS="--include-opentui-preview"
|
||||
fi
|
||||
npm run package:standalone:release -- --version "${RELEASE_VERSION}" --out-dir dist/standalone ${PREVIEW_ARGS}
|
||||
|
||||
# npm trusted publishing can only be configured after a package exists.
|
||||
# Keep this disabled until the one-time bootstrap publish and trusted
|
||||
@@ -1100,8 +1106,14 @@ jobs:
|
||||
IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}'
|
||||
|
||||
- name: 'Verify Standalone Archives'
|
||||
env:
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: |-
|
||||
npm run verify:installation-release -- --dir dist/standalone
|
||||
PREVIEW_ARGS=""
|
||||
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
|
||||
PREVIEW_ARGS="--include-opentui-preview"
|
||||
fi
|
||||
npm run verify:installation-release -- --dir dist/standalone ${PREVIEW_ARGS}
|
||||
|
||||
- name: 'Auto-label internal CI PRs for release notes exclusion'
|
||||
continue-on-error: true
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
ref: '${{ env.RELEASE_TAG }}'
|
||||
|
||||
@@ -74,8 +74,14 @@ jobs:
|
||||
gh release download "${RELEASE_TAG}" --dir dist/standalone --pattern '*.tar.gz' --pattern '*.zip' --pattern 'SHA256SUMS'
|
||||
|
||||
- name: 'Verify Downloaded Release Assets'
|
||||
env:
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: |-
|
||||
npm run verify:installation-release -- --dir dist/standalone
|
||||
PREVIEW_ARGS=""
|
||||
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
|
||||
PREVIEW_ARGS="--include-opentui-preview"
|
||||
fi
|
||||
npm run verify:installation-release -- --dir dist/standalone ${PREVIEW_ARGS}
|
||||
|
||||
- name: 'Package Hosted Installation Assets'
|
||||
if: |-
|
||||
@@ -135,10 +141,15 @@ jobs:
|
||||
env:
|
||||
ALIYUN_OSS_BUCKET: "${{ vars.ALIYUN_OSS_BUCKET || 'qwen-code-assets' }}"
|
||||
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths)
|
||||
PREVIEW_ARGS=""
|
||||
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
|
||||
PREVIEW_ARGS="--include-opentui-preview"
|
||||
fi
|
||||
mapfile -t release_assets < <(node scripts/verify-installation-release.js --dir dist/standalone --list-release-asset-paths ${PREVIEW_ARGS})
|
||||
node scripts/upload-aliyun-oss-assets.js \
|
||||
--bucket "${ALIYUN_OSS_BUCKET}" \
|
||||
--config "${RUNNER_TEMP}/.ossutilconfig" \
|
||||
@@ -149,10 +160,15 @@ jobs:
|
||||
env:
|
||||
ALIYUN_OSS_PUBLIC_BASE_URL: "${{ vars.ALIYUN_OSS_PUBLIC_BASE_URL || 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com' }}"
|
||||
RELEASE_TAG: '${{ env.RELEASE_TAG }}'
|
||||
OPENTUI_PREVIEW_RELEASE_ENABLED: "${{ vars.OPENTUI_PREVIEW_RELEASE_ENABLED == 'true' }}"
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}"
|
||||
PREVIEW_ARGS=""
|
||||
if [[ "${OPENTUI_PREVIEW_RELEASE_ENABLED}" == "true" ]]; then
|
||||
PREVIEW_ARGS="--include-opentui-preview"
|
||||
fi
|
||||
npm run verify:installation-release -- --base-url "${ALIYUN_OSS_PUBLIC_BASE_URL}/releases/qwen-code/${RELEASE_TAG}" ${PREVIEW_ARGS}
|
||||
|
||||
- name: 'Sync Hosted Installation Assets to Aliyun OSS'
|
||||
if: |-
|
||||
|
||||
@@ -16,6 +16,7 @@ import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
TARGET_CLIPBOARD_PACKAGE,
|
||||
standaloneArchiveName,
|
||||
writeSha256Sums,
|
||||
} from './create-standalone-package.js';
|
||||
|
||||
@@ -55,11 +56,10 @@ const RELEASE_TARGETS = [
|
||||
bunAsset: 'bun-windows-x64',
|
||||
},
|
||||
];
|
||||
const EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length;
|
||||
|
||||
// Classic Node.js packaging stays the default. --runtime=bun opts into the
|
||||
// temporary OpenTUI preview (the renderer needs bun:ffi; Node without FFI
|
||||
// falls back to ink).
|
||||
// falls back to ink). --include-opentui-preview adds the bun flavor's
|
||||
// archives (suffixed -opentui-preview) to the same release directory.
|
||||
const DEFAULT_RUNTIME = 'node';
|
||||
const DEFAULT_BUN_VERSION = '1.3.14';
|
||||
const BUN_RELEASE_BASE_URL = 'https://github.com/oven-sh/bun/releases/download';
|
||||
@@ -102,6 +102,14 @@ async function main() {
|
||||
if (runtime !== 'node' && runtime !== 'bun') {
|
||||
fail('--runtime must be either "node" or "bun"');
|
||||
}
|
||||
// The bun flavor is additive: with --include-opentui-preview the release
|
||||
// directory carries both the classic Node.js archives and the bun/OpenTUI
|
||||
// preview archives; every downstream check derives its expected set from
|
||||
// this list.
|
||||
const flavors =
|
||||
args.includeOpentuiPreview && runtime !== 'bun'
|
||||
? ['node', 'bun']
|
||||
: [runtime];
|
||||
|
||||
const nodeVersion = args.nodeVersion || process.versions.node;
|
||||
const bunVersion = args.bunVersion || DEFAULT_BUN_VERSION;
|
||||
@@ -117,38 +125,50 @@ async function main() {
|
||||
);
|
||||
const nodeDistUrl = `https://nodejs.org/dist/v${nodeVersion}`;
|
||||
const bunDistUrl = `${BUN_RELEASE_BASE_URL}/bun-v${bunVersion}`;
|
||||
const runtimeDistUrl = runtime === 'bun' ? bunDistUrl : nodeDistUrl;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const checksumsPath = path.join(runtimeDir, 'SHASUMS256.txt');
|
||||
await downloadFile(`${runtimeDistUrl}/SHASUMS256.txt`, checksumsPath);
|
||||
const checksums = parseChecksums(fs.readFileSync(checksumsPath, 'utf8'));
|
||||
// Each flavor verifies its runtime archive against its own publisher's
|
||||
// checksum list (Node.js SHASUMS256.txt vs Bun's), so fetch one per flavor.
|
||||
const checksums = {};
|
||||
for (const flavor of flavors) {
|
||||
const checksumsPath = path.join(runtimeDir, `${flavor}-SHASUMS256.txt`);
|
||||
await downloadFile(
|
||||
`${flavor === 'bun' ? bunDistUrl : nodeDistUrl}/SHASUMS256.txt`,
|
||||
checksumsPath,
|
||||
);
|
||||
checksums[flavor] = parseChecksums(
|
||||
fs.readFileSync(checksumsPath, 'utf8'),
|
||||
);
|
||||
}
|
||||
const nativeModulesDir = stageClipboardPackages(runtimeDir);
|
||||
// Only the bun runtime consumes the staged OpenTUI packages; the classic
|
||||
// Node packaging must not install them (nor fail on a missing lockfile
|
||||
// entry) at all.
|
||||
const opentuiModulesDir =
|
||||
runtime === 'bun' ? stageOpenTuiPackages(runtimeDir) : undefined;
|
||||
const opentuiModulesDir = flavors.includes('bun')
|
||||
? stageOpenTuiPackages(runtimeDir)
|
||||
: undefined;
|
||||
|
||||
for (const target of RELEASE_TARGETS) {
|
||||
await packageTarget({
|
||||
...target,
|
||||
runtime,
|
||||
bunDistUrl,
|
||||
nodeDistUrl,
|
||||
nodeVersion,
|
||||
outDir,
|
||||
releaseVersion: args.version,
|
||||
runtimeDir,
|
||||
checksums,
|
||||
nativeModulesDir,
|
||||
opentuiModulesDir,
|
||||
});
|
||||
for (const flavor of flavors) {
|
||||
for (const target of RELEASE_TARGETS) {
|
||||
await packageTarget({
|
||||
...target,
|
||||
runtime: flavor,
|
||||
bunDistUrl,
|
||||
nodeDistUrl,
|
||||
nodeVersion,
|
||||
outDir,
|
||||
releaseVersion: args.version,
|
||||
runtimeDir,
|
||||
checksums: checksums[flavor],
|
||||
nativeModulesDir,
|
||||
opentuiModulesDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await writeSha256Sums(outDir);
|
||||
assertStandaloneOutput(outDir);
|
||||
assertStandaloneOutput(outDir, flavors);
|
||||
} finally {
|
||||
fs.rmSync(runtimeDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -374,12 +394,15 @@ async function sha256File(filePath) {
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function assertStandaloneOutput(outDir) {
|
||||
function assertStandaloneOutput(outDir, runtimes = ['node']) {
|
||||
const checksumPath = path.join(outDir, 'SHA256SUMS');
|
||||
if (!fs.existsSync(checksumPath)) {
|
||||
fail(`Standalone SHA256SUMS was not created at ${checksumPath}`);
|
||||
}
|
||||
|
||||
const expectedArchiveNames = RELEASE_TARGETS.flatMap(({ qwenTarget }) =>
|
||||
runtimes.map((runtime) => standaloneArchiveName(qwenTarget, runtime)),
|
||||
).sort();
|
||||
const archiveNames = fs
|
||||
.readFileSync(checksumPath, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
@@ -387,10 +410,6 @@ function assertStandaloneOutput(outDir) {
|
||||
.map((line) => line.trim().split(/\s+/, 2)[1]?.replace(/^\*/, ''))
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
const expectedArchiveNames = RELEASE_TARGETS.map(
|
||||
({ qwenTarget }) =>
|
||||
`qwen-code-${qwenTarget}.${qwenTarget === 'win-x64' ? 'zip' : 'tar.gz'}`,
|
||||
).sort();
|
||||
const missing = expectedArchiveNames.filter(
|
||||
(archiveName) => !archiveNames.includes(archiveName),
|
||||
);
|
||||
@@ -399,7 +418,7 @@ function assertStandaloneOutput(outDir) {
|
||||
);
|
||||
|
||||
if (
|
||||
archiveNames.length !== EXPECTED_ARCHIVE_COUNT ||
|
||||
archiveNames.length !== expectedArchiveNames.length ||
|
||||
missing.length > 0 ||
|
||||
extra.length > 0
|
||||
) {
|
||||
@@ -421,6 +440,7 @@ function assertStandaloneOutput(outDir) {
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
help: false,
|
||||
includeOpentuiPreview: false,
|
||||
nodeVersion: undefined,
|
||||
bunVersion: undefined,
|
||||
runtime: undefined,
|
||||
@@ -436,6 +456,9 @@ function parseArgs(argv) {
|
||||
case '-h':
|
||||
args.help = true;
|
||||
break;
|
||||
case '--include-opentui-preview':
|
||||
args.includeOpentuiPreview = true;
|
||||
break;
|
||||
case '--node-version':
|
||||
args.nodeVersion = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
@@ -488,6 +511,10 @@ Options:
|
||||
--node-version VERSION Node.js version to download. Defaults to current Node.
|
||||
--runtime RUNTIME Runtime to bundle: "node" (classic packaging) or
|
||||
"bun" (temporary OpenTUI preview).
|
||||
--include-opentui-preview
|
||||
Also build the bun/OpenTUI preview archives
|
||||
(qwen-code-*-opentui-preview.*) into the same output
|
||||
directory, alongside the classic archives.
|
||||
--bun-version VERSION Bun version to download. Defaults to ${DEFAULT_BUN_VERSION}.
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ async function main() {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const targetConfig = TARGETS.get(target);
|
||||
const outputName = `qwen-code-${target}.${targetConfig.outputExtension}`;
|
||||
const outputName = standaloneArchiveName(target, args.runtime);
|
||||
const outputPath = path.join(outDir, outputName);
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-standalone-'));
|
||||
|
||||
@@ -215,6 +215,21 @@ function isMainModule() {
|
||||
return process.argv[1] && path.resolve(process.argv[1]) === __filename;
|
||||
}
|
||||
|
||||
// Canonical standalone archive name for a target and runtime flavor. The bun
|
||||
// runtime is the temporary OpenTUI preview flavor, so its archives carry a
|
||||
// -opentui-preview suffix and sit alongside the classic Node.js archives in
|
||||
// gated releases. build-standalone-release.js and
|
||||
// verify-installation-release.js derive their expected names from this
|
||||
// function, so the suffix stays consistent across the release pipeline.
|
||||
function standaloneArchiveName(target, runtime = 'node') {
|
||||
const targetConfig = TARGETS.get(target);
|
||||
if (!targetConfig) {
|
||||
fail(`Unknown target: ${target}`);
|
||||
}
|
||||
const flavorSuffix = runtime === 'bun' ? '-opentui-preview' : '';
|
||||
return `qwen-code-${target}${flavorSuffix}.${targetConfig.outputExtension}`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
help: false,
|
||||
@@ -520,6 +535,7 @@ function copyOpenTuiAddon(packageRoot, target, opentuiModulesDir) {
|
||||
dereference: true,
|
||||
verbatimSymlinks: false,
|
||||
};
|
||||
let copiedAnyPackage = false;
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const packageSrc = path.join(modulesSrc, packageName);
|
||||
@@ -538,6 +554,7 @@ function copyOpenTuiAddon(packageRoot, target, opentuiModulesDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
copiedAnyPackage = true;
|
||||
fs.cpSync(packageSrc, path.join(modulesDest, packageName), copyOpts);
|
||||
|
||||
for (const library of nativeLibraries) {
|
||||
@@ -569,10 +586,14 @@ function copyOpenTuiAddon(packageRoot, target, opentuiModulesDir) {
|
||||
}
|
||||
}
|
||||
|
||||
assertNoSymlinks(
|
||||
modulesDest,
|
||||
'Bundled OpenTUI addon still contains symlinks.',
|
||||
);
|
||||
// The warn-only degradation path copies nothing, so lib/node_modules may
|
||||
// not exist here.
|
||||
if (copiedAnyPackage) {
|
||||
assertNoSymlinks(
|
||||
modulesDest,
|
||||
'Bundled OpenTUI addon still contains symlinks.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasNativePrebuild(prebuildDir) {
|
||||
@@ -885,10 +906,18 @@ function writeShims(packageRoot, runtime) {
|
||||
|
||||
const unixRuntime =
|
||||
runtime === 'bun' ? '$ROOT/bun/bin/bun' : '$ROOT/node/bin/node';
|
||||
// The bun flavor is the OpenTUI preview: default the renderer to opentui so
|
||||
// the archive launches what it was built to preview. An explicit user
|
||||
// QWEN_TUI_RENDERER still wins. No STRICT default: probe failures keep
|
||||
// falling back to ink.
|
||||
const unixRendererDefault =
|
||||
runtime === 'bun'
|
||||
? 'export QWEN_TUI_RENDERER="${QWEN_TUI_RENDERER:-opentui}"\n'
|
||||
: '';
|
||||
const unixShim = `#!/usr/bin/env sh
|
||||
set -e
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||||
QWEN_CODE_LAUNCHER_PATH="$ROOT/bin/qwen" exec "${unixRuntime}" "$ROOT/lib/cli-entry.js" "$@"
|
||||
${unixRendererDefault}QWEN_CODE_LAUNCHER_PATH="$ROOT/bin/qwen" exec "${unixRuntime}" "$ROOT/lib/cli-entry.js" "$@"
|
||||
`;
|
||||
const unixShimPath = path.join(binDir, 'qwen');
|
||||
fs.writeFileSync(unixShimPath, unixShim);
|
||||
@@ -896,10 +925,14 @@ QWEN_CODE_LAUNCHER_PATH="$ROOT/bin/qwen" exec "${unixRuntime}" "$ROOT/lib/cli-en
|
||||
|
||||
const windowsRuntime =
|
||||
runtime === 'bun' ? '%ROOT%\\bun\\bun.exe' : '%ROOT%\\node\\node.exe';
|
||||
const windowsRendererDefault =
|
||||
runtime === 'bun'
|
||||
? 'if not defined QWEN_TUI_RENDERER set "QWEN_TUI_RENDERER=opentui"\n'
|
||||
: '';
|
||||
const windowsShim = `@echo off
|
||||
setlocal
|
||||
set "ROOT=%~dp0.."
|
||||
set "QWEN_CODE_LAUNCHER_PATH=%ROOT%\\bin\\qwen.cmd"
|
||||
${windowsRendererDefault}set "QWEN_CODE_LAUNCHER_PATH=%ROOT%\\bin\\qwen.cmd"
|
||||
"${windowsRuntime}" "%ROOT%\\lib\\cli-entry.js" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
`;
|
||||
@@ -1013,6 +1046,7 @@ function fail(message) {
|
||||
export {
|
||||
TARGET_CLIPBOARD_PACKAGE,
|
||||
TARGETS,
|
||||
standaloneArchiveName,
|
||||
writeSha256Sums,
|
||||
// Exported so a test can hold the allowlist and the build's stamp together:
|
||||
// the packager aborts on any dist entry it does not know, and nothing else
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Install a gated release's bun/OpenTUI preview archive (win-x64) into a
|
||||
# scratch directory. Does not touch PATH, the hosted installer chain, or an
|
||||
# existing classic installation; delete the install directory to uninstall.
|
||||
#
|
||||
# Usage:
|
||||
# install-opentui-preview.ps1 -Tag v0.1.11 [-Dir DIR]
|
||||
# install-opentui-preview.ps1 -Archive FILE [-Dir DIR]
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Tag,
|
||||
[ValidateSet('win-x64')][string]$Target = 'win-x64',
|
||||
[string]$Dir = (Join-Path $HOME '.qwen-preview'),
|
||||
[string]$Archive
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repo = 'QwenLM/qwen-code'
|
||||
|
||||
if (-not $Archive -and $Tag -notmatch '^v[0-9A-Za-z.\-]+$') {
|
||||
throw 'Tag must look like v0.1.11 (required unless -Archive is given).'
|
||||
}
|
||||
|
||||
$archiveName = "qwen-code-$Target-opentui-preview.zip"
|
||||
$work = $null
|
||||
|
||||
try {
|
||||
if ($Archive) {
|
||||
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
|
||||
throw "Archive file not found: $Archive"
|
||||
}
|
||||
$archivePath = $Archive
|
||||
Write-Warning '-Archive given; skipping download and SHA256 verification.'
|
||||
}
|
||||
else {
|
||||
$baseUrl = "https://github.com/$repo/releases/download/$Tag"
|
||||
$work = Join-Path $env:TEMP ("qwen-preview-" + [IO.Path]::GetRandomFileName())
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$archivePath = Join-Path $work $archiveName
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing "$baseUrl/$archiveName" -OutFile $archivePath
|
||||
}
|
||||
catch {
|
||||
throw "Download failed: $baseUrl/$archiveName (was $Tag released with the OpenTUI preview flavor enabled?)"
|
||||
}
|
||||
|
||||
$sumsText = (Invoke-WebRequest -UseBasicParsing "$baseUrl/SHA256SUMS").Content
|
||||
$entry = $sumsText -split "`n" |
|
||||
Where-Object { $_ -match "[ *]$([regex]::Escape($archiveName))\s*$" } |
|
||||
Select-Object -First 1
|
||||
if (-not $entry) {
|
||||
throw "$archiveName is not listed in the release SHA256SUMS."
|
||||
}
|
||||
$expectedHash = ($entry -split '\s+')[0].ToLowerInvariant()
|
||||
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
|
||||
if ($actualHash -ne $expectedHash) {
|
||||
throw "SHA256 mismatch for ${archiveName}: expected $expectedHash, got $actualHash"
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $Dir | Out-Null
|
||||
Remove-Item -Recurse -Force (Join-Path $Dir 'qwen-code') -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $archivePath -DestinationPath $Dir -Force
|
||||
|
||||
Write-Host "installed: $(Join-Path $Dir 'qwen-code')"
|
||||
Write-Host "run: $(Join-Path $Dir 'qwen-code\bin\qwen.cmd')"
|
||||
}
|
||||
finally {
|
||||
if ($work) {
|
||||
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env sh
|
||||
# Install a gated release's bun/OpenTUI preview archive into a scratch
|
||||
# directory. Does not touch PATH, the hosted installer chain, or an existing
|
||||
# classic installation; delete the install directory to uninstall.
|
||||
#
|
||||
# Usage:
|
||||
# install-opentui-preview.sh --tag v0.1.11 [--target TARGET] [--dir DIR]
|
||||
# install-opentui-preview.sh --archive FILE [--dir DIR]
|
||||
|
||||
set -eu
|
||||
|
||||
REPO="QwenLM/qwen-code"
|
||||
|
||||
fail() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Install the bun/OpenTUI preview flavor (qwen-code-*-opentui-preview.tar.gz)
|
||||
from a gated GitHub release into a scratch directory.
|
||||
|
||||
Options:
|
||||
--tag TAG Release tag, e.g. v0.1.11. Required unless --archive.
|
||||
--target TARGET linux-x64 | linux-arm64 | darwin-arm64 | darwin-x64.
|
||||
Defaults to the current platform.
|
||||
--dir DIR Install directory. Defaults to ~/.qwen-preview.
|
||||
--archive FILE Install a local archive instead of downloading one
|
||||
(skips SHA256 verification).
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
tag=""
|
||||
target=""
|
||||
dir="${HOME}/.qwen-preview"
|
||||
archive=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tag)
|
||||
[ $# -ge 2 ] || fail "--tag requires a value"
|
||||
tag="$2"
|
||||
shift 2
|
||||
;;
|
||||
--target)
|
||||
[ $# -ge 2 ] || fail "--target requires a value"
|
||||
target="$2"
|
||||
shift 2
|
||||
;;
|
||||
--dir)
|
||||
[ $# -ge 2 ] || fail "--dir requires a value"
|
||||
dir="$2"
|
||||
shift 2
|
||||
;;
|
||||
--archive)
|
||||
[ $# -ge 2 ] || fail "--archive requires a value"
|
||||
archive="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "${tag}" in
|
||||
v[0-9A-Za-z.-]*) ;;
|
||||
*) [ -n "${archive}" ] || fail "--tag must look like v0.1.11 (required unless --archive)" ;;
|
||||
esac
|
||||
|
||||
if [ -z "${target}" ]; then
|
||||
os=$(uname -s)
|
||||
arch=$(uname -m)
|
||||
case "${os}" in
|
||||
Darwin) os="darwin" ;;
|
||||
Linux) os="linux" ;;
|
||||
*) fail "unsupported platform: ${os} (use the .ps1 script on Windows)" ;;
|
||||
esac
|
||||
case "${arch}" in
|
||||
x86_64) arch="x64" ;;
|
||||
arm64 | aarch64) arch="arm64" ;;
|
||||
*) fail "unsupported architecture: ${arch}" ;;
|
||||
esac
|
||||
target="${os}-${arch}"
|
||||
fi
|
||||
|
||||
case "${target}" in
|
||||
linux-x64 | linux-arm64 | darwin-arm64 | darwin-x64) ;;
|
||||
*) fail "unsupported --target: ${target} (preview flavors: linux-x64 linux-arm64 darwin-arm64 darwin-x64)" ;;
|
||||
esac
|
||||
|
||||
archive_name="qwen-code-${target}-opentui-preview.tar.gz"
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "${work}"' EXIT
|
||||
|
||||
fetch() {
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL --connect-timeout 15 --max-time 600 "$1" -o "$2"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -q "$1" -O "$2"
|
||||
else
|
||||
fail "neither curl nor wget is available"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ -n "${archive}" ]; then
|
||||
[ -f "${archive}" ] || fail "--archive file not found: ${archive}"
|
||||
archive_path="${archive}"
|
||||
printf 'warning: --archive given; skipping download and SHA256 verification\n' >&2
|
||||
else
|
||||
base_url="https://github.com/${REPO}/releases/download/${tag}"
|
||||
fetch "${base_url}/${archive_name}" "${work}/${archive_name}" ||
|
||||
fail "download failed: ${base_url}/${archive_name} (was ${tag} released with the OpenTUI preview flavor enabled?)"
|
||||
fetch "${base_url}/SHA256SUMS" "${work}/SHA256SUMS" ||
|
||||
fail "download failed: ${base_url}/SHA256SUMS"
|
||||
|
||||
expected=$(awk -v n="${archive_name}" '$2 == n || $2 == "*" n { print $1 }' "${work}/SHA256SUMS")
|
||||
[ -n "${expected}" ] || fail "${archive_name} is not listed in the release SHA256SUMS"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
actual=$(sha256sum "${work}/${archive_name}" | cut -d' ' -f1)
|
||||
else
|
||||
actual=$(shasum -a 256 "${work}/${archive_name}" | cut -d' ' -f1)
|
||||
fi
|
||||
[ "${actual}" = "${expected}" ] ||
|
||||
fail "SHA256 mismatch for ${archive_name}: expected ${expected}, got ${actual}"
|
||||
archive_path="${work}/${archive_name}"
|
||||
fi
|
||||
|
||||
mkdir -p "${dir}"
|
||||
rm -rf "${dir}/qwen-code"
|
||||
tar -xzf "${archive_path}" -C "${dir}"
|
||||
|
||||
printf 'installed: %s/qwen-code\n' "${dir}"
|
||||
printf 'run: %s/qwen-code/bin/qwen\n' "${dir}"
|
||||
@@ -692,16 +692,18 @@ describe('standalone release packaging', () => {
|
||||
expect(releaseScript).toContain('https://nodejs.org/dist/v${nodeVersion}');
|
||||
expect(releaseScript).toContain('SHASUMS256.txt');
|
||||
expect(releaseScript).toContain('verifyNodeArchive');
|
||||
// Archive names come from the shared standaloneArchiveName() helper so the
|
||||
// -opentui-preview flavor suffix stays consistent across release scripts.
|
||||
expect(releaseScript).toContain(
|
||||
'EXPECTED_ARCHIVE_COUNT = RELEASE_TARGETS.length',
|
||||
'standaloneArchiveName(qwenTarget, runtime)',
|
||||
);
|
||||
expect(releaseScript).toContain('nodeArchiveExtension');
|
||||
expect(releaseScript).toContain('fs.createReadStream');
|
||||
expect(releaseScript).toContain('expectedArchiveNames');
|
||||
expect(releaseScript).toContain('qwen-code-${qwenTarget}');
|
||||
expect(releaseScript).toContain('scripts/create-standalone-package.js');
|
||||
expect(releaseScript).toContain('--skip-checksums');
|
||||
expect(releaseScript).toContain('writeSha256Sums(outDir)');
|
||||
expect(releaseScript).toContain('--include-opentui-preview');
|
||||
|
||||
const hostedInstallScript = readScript(
|
||||
'scripts/build-hosted-installation-assets.js',
|
||||
@@ -890,6 +892,31 @@ describe('standalone release packaging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts opentui-preview archives only for a dual-flavor build', async () => {
|
||||
const { assertStandaloneOutput, RELEASE_TARGETS } = await import(
|
||||
standaloneReleaseScriptUrl
|
||||
);
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-test-'));
|
||||
|
||||
try {
|
||||
const lines = RELEASE_TARGETS.flatMap(({ qwenTarget }) => {
|
||||
const extension = qwenTarget === 'win-x64' ? 'zip' : 'tar.gz';
|
||||
return [
|
||||
`${'a'.repeat(64)} qwen-code-${qwenTarget}.${extension}`,
|
||||
`${'b'.repeat(64)} qwen-code-${qwenTarget}-opentui-preview.${extension}`,
|
||||
];
|
||||
});
|
||||
writeFileSync(path.join(tmpDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
|
||||
|
||||
expect(() =>
|
||||
assertStandaloneOutput(tmpDir, ['node', 'bun']),
|
||||
).not.toThrow();
|
||||
expect(() => assertStandaloneOutput(tmpDir)).toThrow(/Extra/);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installer scripts honor --version for hosted entrypoints', () => {
|
||||
const installShellSource = readScript(
|
||||
'scripts/installation/install-qwen-standalone.sh',
|
||||
@@ -1321,6 +1348,48 @@ describe('standalone release packaging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('gates opentui-preview archives behind the preview flag', async () => {
|
||||
const { standaloneArchiveNames, verifyReleaseDirectory } = await import(
|
||||
installationReleaseVerificationScriptUrl
|
||||
);
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-release-preview-'));
|
||||
|
||||
try {
|
||||
writeStandaloneReleaseAssets(
|
||||
tmpDir,
|
||||
standaloneArchiveNames({ includeOpentuiPreview: true }),
|
||||
);
|
||||
await expect(
|
||||
verifyReleaseDirectory(tmpDir, {
|
||||
archiveNames: standaloneArchiveNames({ includeOpentuiPreview: true }),
|
||||
}),
|
||||
).resolves.not.toThrow();
|
||||
await expect(verifyReleaseDirectory(tmpDir)).rejects.toThrow(
|
||||
/Unexpected release asset checksum: qwen-code-darwin-arm64-opentui-preview\.tar\.gz/,
|
||||
);
|
||||
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'scripts/verify-installation-release.js',
|
||||
'--dir',
|
||||
tmpDir,
|
||||
'--list-release-asset-paths',
|
||||
'--include-opentui-preview',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(output.trim().split('\n')).toEqual(
|
||||
[
|
||||
...standaloneArchiveNames({ includeOpentuiPreview: true }),
|
||||
'SHA256SUMS',
|
||||
].map((assetName) => path.join(tmpDir, assetName)),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itOnUnix('rejects symlinked release assets and checksum files', async () => {
|
||||
const { EXPECTED_STANDALONE_ARCHIVE_NAMES, verifyReleaseDirectory } =
|
||||
await import(installationReleaseVerificationScriptUrl);
|
||||
@@ -2083,6 +2152,171 @@ describe('standalone release packaging', () => {
|
||||
}
|
||||
});
|
||||
|
||||
itOnUnix(
|
||||
'packages the bun flavor as an opentui-preview archive with a renderer-default shim',
|
||||
() => {
|
||||
const createdDist = ensureMinimalDist();
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
||||
|
||||
try {
|
||||
const bunOutDir = path.join(tmpDir, 'out-bun');
|
||||
mkdirSync(bunOutDir, { recursive: true });
|
||||
execFileSync(
|
||||
'node',
|
||||
[
|
||||
'scripts/create-standalone-package.js',
|
||||
'--target',
|
||||
'linux-x64',
|
||||
'--node-archive',
|
||||
createFakeBunArchive(tmpDir),
|
||||
'--out-dir',
|
||||
bunOutDir,
|
||||
'--version',
|
||||
'0.0.0-smoke',
|
||||
'--runtime',
|
||||
'bun',
|
||||
],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
|
||||
const archive = path.join(
|
||||
bunOutDir,
|
||||
'qwen-code-linux-x64-opentui-preview.tar.gz',
|
||||
);
|
||||
const extractDir = path.join(tmpDir, 'extract');
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
execFileSync('tar', ['-xzf', archive, '-C', extractDir], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
const packageRoot = path.join(extractDir, 'qwen-code');
|
||||
const shim = readScript(path.join(packageRoot, 'bin', 'qwen'));
|
||||
expect(shim).toContain(
|
||||
'export QWEN_TUI_RENDERER="${QWEN_TUI_RENDERER:-opentui}"',
|
||||
);
|
||||
expect(shim).toContain('exec "$ROOT/bun/bin/bun"');
|
||||
expect(readScript(path.join(packageRoot, 'manifest.json'))).toContain(
|
||||
'"runtime": "bun"',
|
||||
);
|
||||
// Installer-compat mirror stays a regular file at the Node layout path.
|
||||
const compatNode = path.join(packageRoot, 'node', 'bin', 'node');
|
||||
expect(existsSync(compatNode)).toBe(true);
|
||||
expect(lstatSync(compatNode).isSymbolicLink()).toBe(false);
|
||||
expect(readScript(path.join(bunOutDir, 'SHA256SUMS'))).toContain(
|
||||
'qwen-code-linux-x64-opentui-preview.tar.gz',
|
||||
);
|
||||
|
||||
// The classic flavor keeps a renderer-neutral shim.
|
||||
const nodeArchive = packageFakeStandalone(tmpDir);
|
||||
expect(nodeArchive).toContain('qwen-code-linux-x64.tar.gz');
|
||||
const nodeExtractDir = path.join(tmpDir, 'extract-node');
|
||||
mkdirSync(nodeExtractDir, { recursive: true });
|
||||
execFileSync('tar', ['-xzf', nodeArchive, '-C', nodeExtractDir], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
expect(
|
||||
readScript(path.join(nodeExtractDir, 'qwen-code', 'bin', 'qwen')),
|
||||
).not.toContain('QWEN_TUI_RENDERER');
|
||||
} finally {
|
||||
restoreMinimalDist(createdDist);
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('ships a thin opentui-preview installer pair', () => {
|
||||
const previewInstallShell = readScript(
|
||||
'scripts/installation/install-opentui-preview.sh',
|
||||
);
|
||||
expect(previewInstallShell).toContain('-opentui-preview.tar.gz');
|
||||
expect(previewInstallShell).toContain(
|
||||
'https://github.com/${REPO}/releases/download/${tag}',
|
||||
);
|
||||
expect(previewInstallShell).toContain('SHA256SUMS');
|
||||
expect(previewInstallShell).toContain('sha256sum');
|
||||
expect(previewInstallShell).toContain('shasum -a 256');
|
||||
expect(previewInstallShell).toContain('tar -xzf');
|
||||
expect(previewInstallShell).toContain(
|
||||
'linux-x64 | linux-arm64 | darwin-arm64 | darwin-x64',
|
||||
);
|
||||
// Scratch-directory install only: never touches PATH or the hosted
|
||||
// installer chain.
|
||||
expect(previewInstallShell).not.toContain('PATH=');
|
||||
expect(previewInstallShell).not.toContain('install-qwen-standalone');
|
||||
|
||||
const previewInstallPs1 = readScript(
|
||||
'scripts/installation/install-opentui-preview.ps1',
|
||||
);
|
||||
expect(previewInstallPs1).toContain('-opentui-preview.zip');
|
||||
expect(previewInstallPs1).toContain('releases/download');
|
||||
expect(previewInstallPs1).toContain('Get-FileHash -Algorithm SHA256');
|
||||
expect(previewInstallPs1).toContain('Expand-Archive');
|
||||
expect(previewInstallPs1).not.toContain('$env:PATH');
|
||||
});
|
||||
|
||||
itOnUnix('installs a local preview archive with the thin installer', () => {
|
||||
const installer = 'scripts/installation/install-opentui-preview.sh';
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-preview-install-'));
|
||||
|
||||
try {
|
||||
const archiveRoot = path.join(tmpDir, 'pkg');
|
||||
mkdirSync(path.join(archiveRoot, 'qwen-code', 'bin'), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
path.join(archiveRoot, 'qwen-code', 'bin', 'qwen'),
|
||||
'#!/usr/bin/env sh\n',
|
||||
);
|
||||
const archive = path.join(
|
||||
tmpDir,
|
||||
'qwen-code-linux-x64-opentui-preview.tar.gz',
|
||||
);
|
||||
execFileSync('tar', ['-czf', archive, '-C', archiveRoot, 'qwen-code'], {
|
||||
env: { ...process.env, LC_ALL: 'C' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
const installDir = path.join(tmpDir, 'install');
|
||||
const output = execFileSync(
|
||||
'sh',
|
||||
[installer, '--archive', archive, '--dir', installDir],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(output).toContain(`run: ${installDir}/qwen-code/bin/qwen`);
|
||||
expect(
|
||||
existsSync(path.join(installDir, 'qwen-code', 'bin', 'qwen')),
|
||||
).toBe(true);
|
||||
|
||||
// Reinstalling over an existing directory replaces it cleanly.
|
||||
execFileSync(
|
||||
'sh',
|
||||
[installer, '--archive', archive, '--dir', installDir],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
existsSync(path.join(installDir, 'qwen-code', 'bin', 'qwen')),
|
||||
).toBe(true);
|
||||
|
||||
// Malformed tags/targets are rejected before any network access.
|
||||
const badTag = spawnSync('sh', [installer, '--tag', '../evil'], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
expect(badTag.status).not.toBe(0);
|
||||
expect(badTag.stderr).toContain('--tag must look like');
|
||||
const badTarget = spawnSync(
|
||||
'sh',
|
||||
[installer, '--tag', 'v0.0.1', '--target', 'win-x64'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(badTarget.status).not.toBe(0);
|
||||
expect(badTarget.stderr).toContain('unsupported --target');
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
itOnUnix('rejects incomplete explicit clipboard staging', () => {
|
||||
const createdDist = ensureMinimalDist();
|
||||
const tmpDir = mkdtempSync(path.join(tmpdir(), 'qwen-package-test-'));
|
||||
@@ -2227,6 +2461,8 @@ describe('standalone release packaging', () => {
|
||||
expect(releaseWorkflow).toContain(
|
||||
'npm run verify:installation-release -- --dir dist/standalone',
|
||||
);
|
||||
expect(releaseWorkflow).toContain('vars.OPENTUI_PREVIEW_RELEASE_ENABLED');
|
||||
expect(releaseWorkflow).toContain('--include-opentui-preview');
|
||||
expect(releaseWorkflow).not.toContain('package:installation-assets');
|
||||
expect(releaseWorkflow).not.toContain('verify_node_checksum()');
|
||||
expect(releaseWorkflow).not.toContain('download_node()');
|
||||
@@ -2252,6 +2488,8 @@ describe('standalone release packaging', () => {
|
||||
expect(ossWorkflow).toContain(
|
||||
'npm run verify:installation-release -- --dir dist/standalone',
|
||||
);
|
||||
expect(ossWorkflow).toContain('vars.OPENTUI_PREVIEW_RELEASE_ENABLED');
|
||||
expect(ossWorkflow).toContain('--include-opentui-preview');
|
||||
expect(ossWorkflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_ID');
|
||||
expect(ossWorkflow).toContain('secrets.ALIYUN_OSS_ACCESS_KEY_SECRET');
|
||||
expect(ossWorkflow).toContain('vars.ALIYUN_OSS_BUCKET');
|
||||
@@ -4435,6 +4673,27 @@ function createFakeNodeArchive(tmpDir, options = {}) {
|
||||
return archive;
|
||||
}
|
||||
|
||||
function createFakeBunArchive(tmpDir) {
|
||||
const fakeBunDir = path.join(tmpDir, 'bun-linux-x64');
|
||||
mkdirSync(path.join(fakeBunDir, 'bin'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(fakeBunDir, 'bin', 'bun'),
|
||||
'#!/usr/bin/env sh\necho 1.3.14\n',
|
||||
);
|
||||
chmodSync(path.join(fakeBunDir, 'bin', 'bun'), 0o755);
|
||||
|
||||
const archive = path.join(tmpDir, 'bun-linux-x64.tar.gz');
|
||||
execFileSync(
|
||||
'tar',
|
||||
['-czf', archive, '-C', tmpDir, path.basename(fakeBunDir)],
|
||||
{
|
||||
env: { ...process.env, LC_ALL: 'C' },
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
return archive;
|
||||
}
|
||||
|
||||
function createBadUnixNodeArchive(tmpDir) {
|
||||
const fakeRuntimeDir = path.join(tmpDir, 'not-node');
|
||||
mkdirSync(fakeRuntimeDir, { recursive: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { RELEASE_TARGETS } from './build-standalone-release.js';
|
||||
import { TARGETS } from './create-standalone-package.js';
|
||||
import { standaloneArchiveName } from './create-standalone-package.js';
|
||||
import {
|
||||
fail,
|
||||
isMainModule,
|
||||
@@ -27,7 +27,9 @@ const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const EXPECTED_STANDALONE_ARCHIVE_NAMES =
|
||||
standaloneArchiveNamesFromReleaseTargets(RELEASE_TARGETS);
|
||||
standaloneArchiveNamesFromReleaseTargets('node');
|
||||
const OPENTUI_PREVIEW_ARCHIVE_NAMES =
|
||||
standaloneArchiveNamesFromReleaseTargets('bun');
|
||||
// Release artifacts that the installer chain expects in a GitHub Release.
|
||||
// Hosted installer scripts are served from a separate endpoint and are
|
||||
// intentionally not part of this set; they have their own staging path in
|
||||
@@ -38,19 +40,30 @@ const EXPECTED_RELEASE_ASSET_NAMES = [
|
||||
];
|
||||
const REMOTE_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Mirrors `build-standalone-release.js`'s archive-name derivation. The two
|
||||
// must stay aligned: any new platform/extension landing in RELEASE_TARGETS
|
||||
// has to be reflected here (and there) before a new target ships, otherwise
|
||||
// Mirrors `build-standalone-release.js`'s archive-name derivation via the
|
||||
// shared standaloneArchiveName() helper. Release targets and flavors must stay
|
||||
// aligned across the two scripts: any new platform/extension or preview flavor
|
||||
// landing there has to be reflected here before a new target ships, otherwise
|
||||
// the verify and the build will disagree on expected filenames.
|
||||
function standaloneArchiveNamesFromReleaseTargets(releaseTargets) {
|
||||
return releaseTargets.map(({ qwenTarget }) =>
|
||||
standaloneArchiveName(qwenTarget),
|
||||
function standaloneArchiveNamesFromReleaseTargets(runtime) {
|
||||
return RELEASE_TARGETS.map(({ qwenTarget }) =>
|
||||
standaloneArchiveName(qwenTarget, runtime),
|
||||
);
|
||||
}
|
||||
|
||||
function standaloneArchiveNames({ includeOpentuiPreview = false } = {}) {
|
||||
return includeOpentuiPreview
|
||||
? [...EXPECTED_STANDALONE_ARCHIVE_NAMES, ...OPENTUI_PREVIEW_ARCHIVE_NAMES]
|
||||
: EXPECTED_STANDALONE_ARCHIVE_NAMES;
|
||||
}
|
||||
|
||||
const ARG_DEFS = {
|
||||
'--dir': { key: 'dir', type: 'value' },
|
||||
'--base-url': { key: 'baseUrl', type: 'value' },
|
||||
'--include-opentui-preview': {
|
||||
key: 'includeOpentuiPreview',
|
||||
type: 'flag',
|
||||
},
|
||||
'--list-release-asset-paths': {
|
||||
key: 'listReleaseAssetPaths',
|
||||
type: 'flag',
|
||||
@@ -78,22 +91,24 @@ async function main() {
|
||||
if (args.listReleaseAssetPaths && args.baseUrl) {
|
||||
fail('Pass --list-release-asset-paths with --dir, not --base-url.');
|
||||
}
|
||||
const archiveNames = standaloneArchiveNames(args);
|
||||
if (args.listReleaseAssetPaths) {
|
||||
const dir = path.resolve(
|
||||
args.dir || path.join(rootDir, 'dist', 'standalone'),
|
||||
);
|
||||
await verifyReleaseDirectory(dir, { silent: true });
|
||||
for (const assetPath of releaseAssetPaths(dir)) {
|
||||
await verifyReleaseDirectory(dir, { silent: true, archiveNames });
|
||||
for (const assetPath of releaseAssetPaths(dir, archiveNames)) {
|
||||
console.log(assetPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (args.baseUrl) {
|
||||
await verifyReleaseBaseUrl(args.baseUrl);
|
||||
await verifyReleaseBaseUrl(args.baseUrl, { archiveNames });
|
||||
return;
|
||||
}
|
||||
await verifyReleaseDirectory(
|
||||
path.resolve(args.dir || path.join(rootDir, 'dist', 'standalone')),
|
||||
{ archiveNames },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,6 +123,9 @@ Options:
|
||||
--dir PATH Verify a local release directory. Defaults to dist/standalone.
|
||||
--base-url URL Verify a remote release URL (e.g. a GitHub release download
|
||||
prefix). Cannot be combined with --dir.
|
||||
--include-opentui-preview
|
||||
Also expect the bun/OpenTUI preview archives
|
||||
(qwen-code-*-opentui-preview.*) in the release.
|
||||
--list-release-asset-paths
|
||||
Verify --dir, then print explicit asset paths for upload.
|
||||
-h, --help Show this help message.
|
||||
@@ -115,19 +133,21 @@ Options:
|
||||
}
|
||||
|
||||
async function verifyReleaseDirectory(dir, options = {}) {
|
||||
const { silent = false } = options;
|
||||
const { silent = false, archiveNames = EXPECTED_STANDALONE_ARCHIVE_NAMES } =
|
||||
options;
|
||||
const assetNames = [...archiveNames, 'SHA256SUMS'];
|
||||
const checksums = readReleaseChecksums(dir);
|
||||
assertExpectedChecksumEntries(checksums);
|
||||
assertExpectedChecksumEntries(checksums, archiveNames);
|
||||
|
||||
const unexpected = fs
|
||||
.readdirSync(dir)
|
||||
.filter((fileName) => !EXPECTED_RELEASE_ASSET_NAMES.includes(fileName))
|
||||
.filter((fileName) => !assetNames.includes(fileName))
|
||||
.sort();
|
||||
if (unexpected.length > 0) {
|
||||
fail(`Unexpected file(s) in release directory: ${unexpected.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
|
||||
for (const assetName of archiveNames) {
|
||||
const assetPath = path.join(dir, assetName);
|
||||
if (!fs.existsSync(assetPath)) {
|
||||
fail(`Missing release asset: ${assetName}`);
|
||||
@@ -146,23 +166,31 @@ async function verifyReleaseDirectory(dir, options = {}) {
|
||||
|
||||
if (!silent) {
|
||||
console.log(
|
||||
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets in ${dir}`,
|
||||
`Verified ${assetNames.length} installation release assets in ${dir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyReleaseBaseUrl(baseUrl, options = {}) {
|
||||
const { fetchImpl = fetch } = options;
|
||||
const {
|
||||
fetchImpl = fetch,
|
||||
archiveNames = EXPECTED_STANDALONE_ARCHIVE_NAMES,
|
||||
} = options;
|
||||
const normalizedBaseUrl = normalizeHttpsBaseUrl(baseUrl);
|
||||
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
|
||||
const checksumUrl = new URL('SHA256SUMS', normalizedBaseUrl).toString();
|
||||
const checksums = parseSha256Sums(await fetchText(checksumUrl, fetchImpl));
|
||||
assertExpectedChecksumEntries(checksums);
|
||||
assertExpectedChecksumEntries(checksums, archiveNames);
|
||||
|
||||
await assertRemoteAssetChecksums(normalizedBaseUrl, checksums, fetchImpl);
|
||||
await assertRemoteAssetChecksums(
|
||||
normalizedBaseUrl,
|
||||
checksums,
|
||||
archiveNames,
|
||||
fetchImpl,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified ${EXPECTED_RELEASE_ASSET_NAMES.length} installation release assets at ${displayBaseUrl}`,
|
||||
`Verified ${archiveNames.length + 1} installation release assets at ${displayBaseUrl}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,9 +206,12 @@ function readReleaseChecksums(dir) {
|
||||
return parseSha256Sums(fs.readFileSync(checksumPath, 'utf8'));
|
||||
}
|
||||
|
||||
function assertExpectedChecksumEntries(checksums) {
|
||||
const expected = new Set(EXPECTED_STANDALONE_ARCHIVE_NAMES);
|
||||
const missing = EXPECTED_STANDALONE_ARCHIVE_NAMES.filter(
|
||||
function assertExpectedChecksumEntries(
|
||||
checksums,
|
||||
expectedArchives = EXPECTED_STANDALONE_ARCHIVE_NAMES,
|
||||
) {
|
||||
const expected = new Set(expectedArchives);
|
||||
const missing = expectedArchives.filter(
|
||||
(assetName) => !checksums.has(assetName),
|
||||
);
|
||||
const extra = Array.from(checksums.keys()).filter(
|
||||
@@ -195,8 +226,11 @@ function assertExpectedChecksumEntries(checksums) {
|
||||
}
|
||||
}
|
||||
|
||||
function releaseAssetPaths(dir) {
|
||||
return EXPECTED_RELEASE_ASSET_NAMES.map((assetName) =>
|
||||
function releaseAssetPaths(
|
||||
dir,
|
||||
archiveNames = EXPECTED_STANDALONE_ARCHIVE_NAMES,
|
||||
) {
|
||||
return [...archiveNames, 'SHA256SUMS'].map((assetName) =>
|
||||
path.join(dir, assetName),
|
||||
);
|
||||
}
|
||||
@@ -204,10 +238,11 @@ function releaseAssetPaths(dir) {
|
||||
async function assertRemoteAssetChecksums(
|
||||
normalizedBaseUrl,
|
||||
checksums,
|
||||
archiveNames = EXPECTED_STANDALONE_ARCHIVE_NAMES,
|
||||
fetchImpl,
|
||||
) {
|
||||
const failures = [];
|
||||
for (const assetName of EXPECTED_STANDALONE_ARCHIVE_NAMES) {
|
||||
for (const assetName of archiveNames) {
|
||||
try {
|
||||
const assetUrl = new URL(assetName, normalizedBaseUrl).toString();
|
||||
const actual = await fetchSha256(assetUrl, fetchImpl);
|
||||
@@ -228,7 +263,7 @@ async function assertRemoteAssetChecksums(
|
||||
if (failures.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (failures.length === EXPECTED_STANDALONE_ARCHIVE_NAMES.length) {
|
||||
if (failures.length === archiveNames.length) {
|
||||
const displayBaseUrl = redactUrlForLog(normalizedBaseUrl);
|
||||
fail(
|
||||
`All ${failures.length} release asset URLs are unavailable; check --base-url: ${displayBaseUrl}`,
|
||||
@@ -330,14 +365,6 @@ function redactUrlForLog(url) {
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneArchiveName(qwenTarget) {
|
||||
const targetConfig = TARGETS.get(qwenTarget);
|
||||
if (!targetConfig) {
|
||||
fail(`Unknown release target: ${qwenTarget}`);
|
||||
}
|
||||
return `qwen-code-${qwenTarget}.${targetConfig.outputExtension}`;
|
||||
}
|
||||
|
||||
function isPrivateOrReservedHost(hostname) {
|
||||
const normalized = hostname
|
||||
.toLowerCase()
|
||||
@@ -497,6 +524,7 @@ export {
|
||||
isPrivateOrReservedHost,
|
||||
redactUrlForLog,
|
||||
releaseAssetPaths,
|
||||
standaloneArchiveNames,
|
||||
verifyReleaseBaseUrl,
|
||||
verifyReleaseDirectory,
|
||||
};
|
||||
Reference in new issue
Block a user