Files
qwen-code/scripts/assert-release-version.mjs
T
易良andQwen-Coder e596a410bb feat(web-shell): publish the package with the cli release (#12178)
* feat(web-shell): publish the package with the cli release

The release workflow published the cli and the channel packages but never
@qwen-code/web-shell, so the npm artifact only moved when someone published
it by hand.

Add the package to the release step and record its repository so npm accepts
the provenance attestation the workflow requests.

* fix(release): probe @qwen-code/web-shell in the push-time version guard

publish_package 'packages/web-shell' made the package CI-published, but
PUBLISHED_PACKAGES — the sole input to assertVersionUnreleased — still
listed only twelve names. A release shipped only as @qwen-code/web-shell
therefore read as unshipped: the version-collision bump never fired and
the force-push guard could proceed over an already-published version.

Widen the guard list, its pin in get-release-version.test.js, and
generalise "keeps the publish allowlist and the guard package set in
step" so every bare publish_package '<dir>' target is resolved through
the package.json it publishes. The channel-loop comparison alone could
not see this addition, which is how it shipped unguarded.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6tb0j73c

* test(release): simplify publish guard coverage

* fix(release): publish web-shell after the channels

Review on #12178 (R1-3): the step runs under `set -eo pipefail` and every
publish_package call is bare, so inserting the newest name ahead of the
channel packages makes a failure of its first CI publish strand all of
them. Publish it last instead.

Also append the new entry to PUBLISHED_PACKAGES rather than inserting it
after audio-capture: the version-guard tests read that array by index, so
its position is load-bearing. Record that in the pinned assertion.

---------

Co-authored-by: Qwen-Coder <[email protected]>
2026-09-18 12:17:07 +00:00

140 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
export const PUBLISHED_PACKAGES = [
'@qwen-code/qwen-code',
'@qwen-code/external-context-mem0',
'@qwen-code/audio-capture',
'@qwen-code/channel-base',
'@qwen-code/channel-dingtalk',
'@qwen-code/channel-dws',
'@qwen-code/channel-feishu',
'@qwen-code/channel-github',
'@qwen-code/channel-qqbot',
'@qwen-code/channel-telegram',
'@qwen-code/channel-wecom',
'@qwen-code/channel-weixin',
'@qwen-code/web-shell',
];
// Deliberately a copy of `scripts/lib/release-helpers.js`, not an import:
// the workflow checks this file out alone (`/scripts/assert-release-version.mjs`
// in release.yml's sparse-checkout set), so importing from `scripts/lib/`
// resolves in every test lane and throws ERR_MODULE_NOT_FOUND only in the
// publish job. `scripts/tests/release-workflow.test.js` pins the two bodies
// identical — widen or harden them together, and never widen this copy alone:
// treating a rate-limited probe as "release absent" would let the force push
// proceed over a shipped version.
function isExpectedMissingGitHubRelease(error) {
const stderr = error.stderr?.toString() ?? '';
const stdout = error.stdout?.toString() ?? '';
const message = `${error.message}\n${stderr}\n${stdout}`;
return message.includes('release not found') || message.includes('Not Found');
}
export function assertVersionUnreleased(version) {
if (
typeof version !== 'string' ||
!/^\d+\.\d+\.\d+(?:-preview\.\d+|-nightly\.\d{8}\.[0-9a-f]+)?$/.test(
version,
)
) {
const error = new Error(
'assert-unreleased requires a version in release format, e.g. --assert-unreleased=1.2.3',
);
// Not a probe failure: a malformed version is refused identically on
// every attempt, so it must not land in the exit-2 bucket that
// run-release-step.sh retries three times while logging it as one
// transient probe error.
error.code = 'VERSION_FORMAT';
throw error;
}
const shippedTo = [];
for (const pkg of PUBLISHED_PACKAGES) {
try {
const output = execSync(`npm view ${pkg}@${version} version`)
.toString()
.trim();
if (output === version) shippedTo.push(pkg);
} catch (error) {
if (shippedTo.length === 0 && !error.message?.includes('E404')) {
throw new Error(
`Failed to verify ${pkg}@${version} on npm: ${error.message}`,
);
}
}
}
if (shippedTo.length === 0) {
try {
execSync(`git ls-remote --exit-code origin "refs/tags/v${version}"`);
shippedTo.push(`origin tag v${version}`);
} catch (error) {
if (error.status !== 2) {
throw new Error(
`Failed to verify tag v${version} on origin: ${error.message}`,
);
}
}
}
if (shippedTo.length === 0) {
try {
const output = execSync(
`gh release view "v${version}" --json tagName --jq .tagName`,
)
.toString()
.trim();
if (output === `v${version}`)
shippedTo.push(`GitHub release v${version}`);
} catch (error) {
if (!isExpectedMissingGitHubRelease(error)) {
throw new Error(
`Failed to verify release v${version} on GitHub: ${error.message}`,
);
}
}
}
if (shippedTo.length > 0) {
const error = new Error(
`Version ${version} has already shipped; refusing to force-push the release branch over it. Found on: ${shippedTo.join(', ')}. If a previous attempt published only part of the release, complete the remaining artifacts manually — re-running this job will keep failing here while the version stays published.`,
);
error.code = 'VERSION_SHIPPED';
throw error;
}
}
export function runAssertVersionCli(version) {
try {
assertVersionUnreleased(version);
} catch (error) {
console.log(`::error::${error.message}`);
// 3 = already shipped (decisive, marked so the workflow skips the
// release-failed notification); 4 = malformed version (decisive, not
// retryable, and not a benign refusal); 2 = probe failure, retried.
if (error.code === 'VERSION_SHIPPED') return 3;
if (error.code === 'VERSION_FORMAT') return 4;
return 2;
}
return 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const prefix = '--assert-unreleased=';
const arg = process.argv[2] ?? '';
const exitCode = runAssertVersionCli(
arg.startsWith(prefix) ? arg.slice(prefix.length) : '',
);
if (exitCode !== 0) process.exit(exitCode);
}