From 2c93fd670ccd68a0d600d9b5bb48e0291bb89231 Mon Sep 17 00:00:00 2001 From: jinye Date: Tue, 5 May 2026 10:15:17 +0800 Subject: [PATCH] refactor: extract shared release helper utilities (#3834) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sdk-python/scripts/get-release-version.js | 37 +---- .../scripts/get-release-version.js | 44 +----- scripts/get-release-version.js | 44 +----- scripts/lib/release-helpers.js | 62 ++++++++ scripts/tests/release-helpers.test.js | 132 ++++++++++++++++++ 5 files changed, 211 insertions(+), 108 deletions(-) create mode 100644 scripts/lib/release-helpers.js create mode 100644 scripts/tests/release-helpers.test.js diff --git a/packages/sdk-python/scripts/get-release-version.js b/packages/sdk-python/scripts/get-release-version.js index 8f34678059..28c32d5ecc 100644 --- a/packages/sdk-python/scripts/get-release-version.js +++ b/packages/sdk-python/scripts/get-release-version.js @@ -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), diff --git a/packages/sdk-typescript/scripts/get-release-version.js b/packages/sdk-typescript/scripts/get-release-version.js index 2852bf52f1..43d1a5c81a 100644 --- a/packages/sdk-typescript/scripts/get-release-version.js +++ b/packages/sdk-typescript/scripts/get-release-version.js @@ -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) { diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index d89fddae52..4edef1dada 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -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', diff --git a/scripts/lib/release-helpers.js b/scripts/lib/release-helpers.js new file mode 100644 index 0000000000..a18e73397c --- /dev/null +++ b/scripts/lib/release-helpers.js @@ -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'); +} diff --git a/scripts/tests/release-helpers.test.js b/scripts/tests/release-helpers.test.js new file mode 100644 index 0000000000..0296fb2429 --- /dev/null +++ b/scripts/tests/release-helpers.test.js @@ -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); + }); +});