refactor: extract shared release helper utilities (#3834)

Move four duplicated utility functions (getArgs, readJson,
validateVersion, isExpectedMissingGitHubRelease) from the three
get-release-version.js scripts into a shared module at
scripts/lib/release-helpers.js so that changes only need to happen
in one place.

Also fixes a pre-existing bug in getArgs where argument values
containing '=' were silently truncated (e.g. --msg=a=b produced
{msg:'a'} instead of {msg:'a=b'}).

Closes #3795

🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)

Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com>
This commit is contained in:
jinye 2026-05-05 10:15:17 +08:00 committed by GitHub
parent 59845407fc
commit 2c93fd670c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 211 additions and 108 deletions

View file

@ -10,6 +10,11 @@ import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
getArgs,
isExpectedMissingGitHubRelease,
validateVersion,
} from '../../../scripts/lib/release-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -27,18 +32,6 @@ function readPyprojectVersion() {
return match[1];
}
function getArgs() {
const args = {};
for (const arg of process.argv.slice(2)) {
if (!arg.startsWith('--')) {
continue;
}
const [key, value] = arg.slice(2).split('=');
args[key] = value === undefined ? true : value;
}
return args;
}
function parseVersion(version) {
let match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
if (match) {
@ -251,26 +244,6 @@ function getGitShortHash() {
return execSync('git rev-parse --short HEAD').toString().trim();
}
function validateVersion(version, format, name) {
const versionRegex = {
'X.Y.Z': /^\d+\.\d+\.\d+$/,
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
};
if (!versionRegex[format]?.test(version)) {
throw new Error(
`Invalid ${name}: ${version}. Must be in ${format} format.`,
);
}
}
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');
}
async function getReleaseState({ packageVersion, releaseTag }, allVersions) {
const state = {
packageVersionExistsOnPyPI: allVersions.includes(packageVersion),

View file

@ -8,8 +8,13 @@
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import {
getArgs,
isExpectedMissingGitHubRelease,
readJson,
validateVersion,
} from '../../../scripts/lib/release-helpers.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -17,21 +22,6 @@ const __dirname = dirname(__filename);
const PACKAGE_NAME = '@qwen-code/sdk';
const TAG_PREFIX = 'sdk-typescript-v';
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
function getArgs() {
const args = {};
process.argv.slice(2).forEach((arg) => {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
args[key] = value === undefined ? true : value;
}
});
return args;
}
function getVersionFromNPM(distTag) {
const command = `npm view ${PACKAGE_NAME} version --tag=${distTag}`;
try {
@ -142,15 +132,6 @@ function detectRollbackAndGetBaseline(npmDistTag) {
}
function doesVersionExist(version) {
const 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')
);
};
// Check NPM
try {
const command = `npm view ${PACKAGE_NAME}@${version} version 2>/dev/null`;
@ -254,19 +235,6 @@ function getNightlyVersion() {
};
}
function validateVersion(version, format, name) {
const versionRegex = {
'X.Y.Z': /^\d+\.\d+\.\d+$/,
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
};
if (!versionRegex[format] || !versionRegex[format].test(version)) {
throw new Error(
`Invalid ${name}: ${version}. Must be in ${format} format.`,
);
}
}
function getStableVersion(args) {
let releaseVersion;
if (args.stable_version_override) {

View file

@ -8,23 +8,13 @@
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import semver from 'semver';
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
function getArgs() {
const args = {};
process.argv.slice(2).forEach((arg) => {
if (arg.startsWith('--')) {
const [key, value] = arg.substring(2).split('=');
args[key] = value === undefined ? true : value;
}
});
return args;
}
import {
getArgs,
isExpectedMissingGitHubRelease,
readJson,
validateVersion,
} from './lib/release-helpers.js';
function getVersionFromNPM(distTag) {
const command = `npm view @qwen-code/qwen-code version --tag=${distTag}`;
@ -129,15 +119,6 @@ function detectRollbackAndGetBaseline(npmDistTag) {
}
function doesVersionExist(version) {
const 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')
);
};
// Check NPM
try {
const command = `npm view @qwen-code/qwen-code@${version} version 2>/dev/null`;
@ -244,19 +225,6 @@ function getNightlyVersion() {
};
}
function validateVersion(version, format, name) {
const versionRegex = {
'X.Y.Z': /^\d+\.\d+\.\d+$/,
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
};
if (!versionRegex[format] || !versionRegex[format].test(version)) {
throw new Error(
`Invalid ${name}: ${version}. Must be in ${format} format.`,
);
}
}
function getStableVersion(args) {
const { latestVersion: latestPreviewVersion } = getAndVerifyTags(
'preview',

View file

@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync } from 'node:fs';
/**
* Parse command-line arguments in `--key=value` format.
* Flags without a value (e.g. `--dry-run`) are set to `true`.
*/
export function getArgs() {
const args = {};
process.argv.slice(2).forEach((arg) => {
if (arg.startsWith('--')) {
const stripped = arg.substring(2);
const eqIndex = stripped.indexOf('=');
if (eqIndex === -1) {
args[stripped] = true;
} else {
args[stripped.substring(0, eqIndex)] = stripped.substring(eqIndex + 1);
}
}
});
return args;
}
/**
* Read and parse a JSON file.
*/
export function readJson(filePath) {
return JSON.parse(readFileSync(filePath, 'utf-8'));
}
/**
* Validate that a version string matches the expected format.
* Throws if the version is invalid.
*/
export function validateVersion(version, format, name) {
const versionRegex = {
'X.Y.Z': /^\d+\.\d+\.\d+$/,
'X.Y.Z-preview.N': /^\d+\.\d+\.\d+-preview\.\d+$/,
};
if (!versionRegex[format] || !versionRegex[format].test(version)) {
throw new Error(
`Invalid ${name}: ${version}. Must be in ${format} format.`,
);
}
}
/**
* Check whether an error from `gh release view` indicates the release
* simply doesn't exist (as opposed to an unexpected failure).
*/
export 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');
}

View file

@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest';
import { readFileSync } from 'node:fs';
vi.mock('node:fs');
import {
getArgs,
readJson,
validateVersion,
isExpectedMissingGitHubRelease,
} from '../lib/release-helpers.js';
describe('getArgs', () => {
const originalArgv = process.argv;
beforeEach(() => {
process.argv = ['node', 'script.js'];
});
afterEach(() => {
process.argv = originalArgv;
});
it('parses --key=value arguments', () => {
process.argv = ['node', 'script.js', '--type=nightly', '--channel=preview'];
expect(getArgs()).toEqual({ type: 'nightly', channel: 'preview' });
});
it('sets boolean true for flags without a value', () => {
process.argv = ['node', 'script.js', '--dry-run', '--verbose'];
expect(getArgs()).toEqual({ 'dry-run': true, verbose: true });
});
it('ignores arguments that do not start with --', () => {
process.argv = ['node', 'script.js', 'positional', '-short', '--valid=1'];
expect(getArgs()).toEqual({ valid: '1' });
});
it('preserves equals signs in argument values', () => {
process.argv = ['node', 'script.js', '--message=hello=world'];
expect(getArgs()).toEqual({ message: 'hello=world' });
});
it('returns an empty object when there are no arguments', () => {
process.argv = ['node', 'script.js'];
expect(getArgs()).toEqual({});
});
});
describe('readJson', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('reads and parses a JSON file', () => {
vi.mocked(readFileSync).mockReturnValue('{"version": "1.0.0"}');
expect(readJson('/path/to/file.json')).toEqual({ version: '1.0.0' });
expect(readFileSync).toHaveBeenCalledWith('/path/to/file.json', 'utf-8');
});
it('propagates errors from readFileSync', () => {
vi.mocked(readFileSync).mockImplementation(() => {
throw new Error('ENOENT');
});
expect(() => readJson('/nonexistent.json')).toThrow('ENOENT');
});
});
describe('validateVersion', () => {
it('accepts a valid X.Y.Z version', () => {
expect(() => validateVersion('1.2.3', 'X.Y.Z', 'test')).not.toThrow();
});
it('accepts a valid X.Y.Z-preview.N version', () => {
expect(() =>
validateVersion('1.2.3-preview.4', 'X.Y.Z-preview.N', 'test'),
).not.toThrow();
});
it('throws for an invalid X.Y.Z version', () => {
expect(() => validateVersion('bad', 'X.Y.Z', 'test')).toThrow(
'Invalid test: bad. Must be in X.Y.Z format.',
);
});
it('throws when version does not match the requested format', () => {
expect(() => validateVersion('1.2.3', 'X.Y.Z-preview.N', 'test')).toThrow(
'Invalid test: 1.2.3. Must be in X.Y.Z-preview.N format.',
);
});
it('throws for an unknown format key', () => {
expect(() => validateVersion('1.2.3', 'unknown', 'test')).toThrow(
'Invalid test: 1.2.3. Must be in unknown format.',
);
});
});
describe('isExpectedMissingGitHubRelease', () => {
it('returns true when message contains "release not found"', () => {
const error = new Error('release not found');
expect(isExpectedMissingGitHubRelease(error)).toBe(true);
});
it('returns true when stderr contains "Not Found"', () => {
const error = new Error('command failed');
error.stderr = Buffer.from('Not Found');
expect(isExpectedMissingGitHubRelease(error)).toBe(true);
});
it('returns true when stdout contains "release not found"', () => {
const error = new Error('command failed');
error.stdout = Buffer.from('release not found');
expect(isExpectedMissingGitHubRelease(error)).toBe(true);
});
it('returns false for an unrelated error', () => {
const error = new Error('network timeout');
expect(isExpectedMissingGitHubRelease(error)).toBe(false);
});
it('handles errors without stderr or stdout properties', () => {
const error = new Error('something went wrong');
expect(isExpectedMissingGitHubRelease(error)).toBe(false);
});
});