diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8b04a5be40..f95d91021bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -438,35 +438,87 @@ jobs: ${{ github.repository == 'QwenLM/qwen-code' }} working-directory: 'packages/audio-capture' run: |- - npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + PACKAGE_NAME="$(node -p "require('./package.json').name")" + PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}") + if [[ "${IS_DRY_RUN}" == "true" ]]; then + PUBLISH_ARGS+=(--dry-run) + elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping" + exit 0 + fi + npm publish "${PUBLISH_ARGS[@]}" env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' + IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' - name: 'Publish @qwen-code/qwen-code' working-directory: 'dist' run: |- - npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + PACKAGE_NAME="$(node -p "require('./package.json').name")" + PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}") + if [[ "${IS_DRY_RUN}" == "true" ]]; then + PUBLISH_ARGS+=(--dry-run) + elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping" + exit 0 + fi + npm publish "${PUBLISH_ARGS[@]}" env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' + IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' - name: 'Publish @qwen-code/channel-base' working-directory: 'packages/channels/base' run: |- - npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }} + PACKAGE_NAME="$(node -p "require('./package.json').name")" + PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}") + if [[ "${IS_DRY_RUN}" == "true" ]]; then + PUBLISH_ARGS+=(--dry-run) + elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping" + exit 0 + fi + npm publish "${PUBLISH_ARGS[@]}" env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' + IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' - name: 'Publish remaining channel packages' working-directory: 'packages/channels' run: |- # Explicit allowlist: new channel workspaces require release approval. + PUBLISH_MARKER="$(mktemp)" for channel in dingtalk feishu github qqbot telegram wecom weixin; do echo "::group::Publishing @qwen-code/channel-${channel}" - (cd "${channel}" && npm publish --access public --tag=${{ needs.prepare.outputs.npm_tag }} ${{ needs.prepare.outputs.is_dry_run == 'true' && '--dry-run' || '' }}) + ( + cd "${channel}" + PACKAGE_NAME="$(node -p "require('./package.json').name")" + PUBLISH_ARGS=(--access public "--tag=${NPM_TAG}") + if [[ "${IS_DRY_RUN}" == "true" ]]; then + PUBLISH_ARGS+=(--dry-run) + elif npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + echo "::notice::${PACKAGE_NAME}@${RELEASE_VERSION} already published; skipping" + exit 0 + fi + npm publish "${PUBLISH_ARGS[@]}" + echo "${channel}" >> "${PUBLISH_MARKER}" + ) echo "::endgroup::" done + if [[ "${IS_DRY_RUN}" != "true" ]] && [[ ! -s "${PUBLISH_MARKER}" ]]; then + echo "::warning::Every channel package was already published; nothing shipped" + fi env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' + RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}' + NPM_TAG: '${{ needs.prepare.outputs.npm_tag }}' + IS_DRY_RUN: '${{ needs.prepare.outputs.is_dry_run }}' - name: 'Verify Standalone Archives' run: |- diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 96e94a592fd..3ed56d26968 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -176,17 +176,36 @@ function detectRollbackAndGetBaseline(npmDistTag) { }; } +/** + * All packages that share the same release version. A version is considered + * "taken" if it exists on *any* of them — not just the main package. + */ +const PUBLISHED_PACKAGES = [ + '@qwen-code/qwen-code', + '@qwen-code/audio-capture', + '@qwen-code/channel-base', + '@qwen-code/channel-dingtalk', + '@qwen-code/channel-feishu', + '@qwen-code/channel-github', + '@qwen-code/channel-qqbot', + '@qwen-code/channel-telegram', + '@qwen-code/channel-wecom', + '@qwen-code/channel-weixin', +]; + function doesVersionExist(version) { - // Check NPM - try { - const command = `npm view @qwen-code/qwen-code@${version} version 2>/dev/null`; - const output = execSync(command).toString().trim(); - if (output === version) { - console.error(`Version ${version} already exists on NPM.`); - return true; + // Check NPM across all published packages + for (const pkg of PUBLISHED_PACKAGES) { + try { + const command = `npm view ${pkg}@${version} version 2>/dev/null`; + const output = execSync(command).toString().trim(); + if (output === version) { + console.error(`Version ${version} already exists on NPM (${pkg}).`); + return true; + } + } catch (_error) { + // This is expected if the version doesn't exist on this package. } - } catch (_error) { - // This is expected if the version doesn't exist. } // Check Git tags @@ -335,8 +354,28 @@ function getPreviewVersion(args) { ); releaseVersion = overrideVersion; } else if (tagResult) { - releaseVersion = - tagResult.latestVersion.replace(/-nightly.*/, '') + '-preview.0'; + let baseVersion = tagResult.latestVersion.replace(/-nightly.*/, ''); + // When the nightly base is already published as stable, the preview must + // target the next patch — otherwise the scheduled Tuesday release derives + // a version whose channel packages already exist on npm (E403). + // Use the rollback-aware lookup so a rolled-back dist-tag doesn't produce + // a retrograde preview base. + const latestTagResult = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]'); + const latestStable = latestTagResult?.latestVersion ?? ''; + if ( + latestStable && + semver.valid(latestStable) && + semver.valid(baseVersion) + ) { + if (semver.gte(latestStable, baseVersion)) { + const bumped = semver.inc(latestStable, 'patch'); + console.error( + `Nightly base ${baseVersion} is at or below published latest ${latestStable}; bumping preview base to ${bumped}.`, + ); + baseVersion = bumped; + } + } + releaseVersion = baseVersion + '-preview.0'; validateVersion( releaseVersion, 'X.Y.Z-preview.N', diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index 8c0f9eb70b0..caa7b5ab581 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -54,10 +54,10 @@ describe('getVersion', () => { // Git Hash Mock if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d'; - // For doesVersionExist checks - default to not found + // For doesVersionExist checks - default to not found on any package if ( command.includes('npm view') && - command.includes('@qwen-code/qwen-code@') + command.includes('version 2>/dev/null') ) { throw new Error('NPM version not found'); } @@ -183,6 +183,78 @@ describe('getVersion', () => { expect(result.releaseVersion).toBe('0.8.0-preview.1'); }); + it('should auto-increment when the version exists only on a channel package', () => { + vi.mocked(execSync).mockImplementation((command) => { + if ( + command.includes( + 'npm view @qwen-code/channel-telegram@0.8.0-preview.0 version', + ) + ) + return '0.8.0-preview.0'; + + return mockExecSync(command); + }); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.0-preview.1'); + }); + + it('should keep the nightly base when no stable is published yet (greenfield)', () => { + vi.mocked(execSync).mockImplementation((command) => { + // No latest dist-tag + if (command.includes('npm view') && command.includes('--tag=latest')) + throw new Error('npm error code E404'); + // No versions published at all + if (command.includes('npm view') && command.includes('versions --json')) + return JSON.stringify([]); + return mockExecSync(command); + }); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.0-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe(''); + }); + + it('should bump the preview base when the latest stable matches it', () => { + vi.mocked(execSync).mockImplementation((command) => { + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.8.0'; + return mockExecSync(command); + }); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.1-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe('v0.8.0'); + }); + + it('should bump the preview base when the latest stable is ahead', () => { + vi.mocked(execSync).mockImplementation((command) => { + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.9.0'; + return mockExecSync(command); + }); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.9.1-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe('v0.9.0'); + }); + + it('should not bump the preview base when the latest stable is below it', () => { + vi.mocked(execSync).mockImplementation((command) => { + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.7.9'; + return mockExecSync(command); + }); + + const result = getVersion({ type: 'preview' }); + expect(result.releaseVersion).toBe('0.8.0-preview.0'); + expect(result.npmTag).toBe('preview'); + expect(result.previousReleaseTag).toBe('v0.7.9'); + }); + it('should fall back to package.json when no nightly dist-tag exists (preview)', () => { const mockWithNoNightly = (command) => { // No nightly dist-tag exists diff --git a/scripts/tests/package-scripts.test.js b/scripts/tests/package-scripts.test.js index a9f4e91c3a0..d231957e631 100644 --- a/scripts/tests/package-scripts.test.js +++ b/scripts/tests/package-scripts.test.js @@ -451,6 +451,46 @@ describe('package scripts', () => { expect(buildStep).toContain('npm run build\n npm run bundle'); }); + it('skips npm packages whose release version is already published', () => { + const workflow = readWorkflow('.github/workflows/release.yml'); + const publishJob = getWorkflowJob(workflow, 'publish'); + + for (const stepName of [ + 'Publish @qwen-code/audio-capture', + 'Publish @qwen-code/qwen-code', + 'Publish @qwen-code/channel-base', + 'Publish remaining channel packages', + ]) { + const publishStep = getWorkflowStep(publishJob, stepName); + expect(publishStep).toContain( + "RELEASE_VERSION: '${{ needs.prepare.outputs.release_version }}'", + ); + expect(publishStep).toContain( + 'PACKAGE_NAME="$(node -p "require(\'./package.json\').name")"', + ); + expect(publishStep).toContain('PUBLISH_ARGS+=(--dry-run)'); + expect(publishStep).toContain( + 'npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version', + ); + expect(publishStep).toContain('already published; skipping'); + expect(publishStep).toContain('exit 0'); + expect(publishStep).toContain('npm publish "${PUBLISH_ARGS[@]}"'); + } + + // The channel loop must wrap each iteration in a subshell so that + // `exit 0` skips only the current channel, not the entire step. + const channelStep = getWorkflowStep( + publishJob, + 'Publish remaining channel packages', + ); + expect(channelStep).toContain('(\n'); + expect(channelStep).toContain(')'); + // A fully-skipped publish must be visible, not silently green. + expect(channelStep).toContain( + 'Every channel package was already published; nothing shipped', + ); + }); + it('fast-tracks trusted autofix issue triggers before LLM assessment', () => { const workflow = readWorkflow('.github/workflows/qwen-autofix.yml'); const issueJob = getWorkflowJob(workflow, 'issue-autofix');