qwen-code/scripts/tests/get-release-version.test.js
易良 078b924989
refactor(ci): extract release workflow scripts (#11165)
* refactor(ci): extract release workflow scripts

* test(ci): pin release-note labeling to the extracted step script

release.yml no longer inlines the auto-labeling body: the publish step now
just dispatches to run-release-step.sh, which owns the git rev-list
enumeration and the classify-release-notes.mjs call. The wiring test kept
asserting those four strings against the step's run block, so it failed on
the dispatcher path alone.

Read run-release-step.sh and assert the labeling body there, and assert the
dispatch from release.yml. The workflow-level guarantees (step name,
continue-on-error, GITHUB_TOKEN env, issues/pull-requests write permissions)
and the .github/release.yml exclusion entry are unchanged.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtpmlcodmz

* test(ci): repoint release assertions at the extracted step script

The extraction moved the GitHub Release notes, standalone archive, and
release-failure autofix label logic out of .github/workflows/release.yml
into .github/scripts/run-release-step.sh, but three consumer suites still
pinned the moved text in the YAML, so `npm run test:scripts` was red and
the release workflow's fail-closed `quality` gate would refuse to publish.

Read the step script in ai-release-notes-workflow, install-script, and
qwen-autofix-workflow and assert the moved strings there with their new
lowercase locals (`notes_args`/`notes_file`), keeping on release.yml only
what genuinely stayed in it. Restore the "Safe to auto-apply approval"
rationale above both AUTOFIX_APPROVED_LABEL writes in the notify-failure
arm; it is the only recorded justification for the workflow granting
itself autonomous autofix approval.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtq0vr74ni

* test(ci): derive the ECS wipe subject set from the runner, not the wipe

Selecting checkout jobs by "has a Restore workspace ownership step" made
the subject set a function of the property under test, so a job added to
the ecs-qwen-hk4-host pool with an actions/checkout and no wipe was
filtered out before any assertion ran. Key the exemption on the pool
marker instead, and pin that every pool-routed job carries the wipe
whether or not it checks out.

The hosted label cannot be the discriminator: every pool-routed runs-on
expression names 'ubuntu-latest' as its fallback branch.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtq0vr74ni

* test(ci): re-scope the release-creation token pin to its own step

The workflow-wide toContain was satisfied by the second CI_BOT_PAT
occurrence on "Trigger ECS runner qwen update", so flipping the token on
"Create GitHub Release and Tag" to github.token left every lane green.
Restore the step scoping and the deleted rationale comment in release.yml.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtqfvvmto2

* fix(ci): fail the release step runner closed on pipeline legs

run-release-step.sh set bare `set -e` while both sibling runners set
pipefail. In notify-failure a connection-level gh failure inside
`gh issue list ... | jq -c ...` leaves jq exiting 0 on empty input, so the
empty result took the create-a-new-issue branch and filed a duplicate
autofix/approved release-failure issue past all three reuse guards.

Pin the flag next to the existing sibling pin and add a behavioural row for
the notify-failure arm with an unreachable gh.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtqfvvmto2

* test(ci): relocate the release timeout value-format contract

This PR condensed the nine-line release.yml lane comment to "values are
minutes" and relocated the format half nowhere: grep for the deleted claims
(expression error / free text / truthy / leading number) found no copy under
.github/, scripts/tests/ or docs/. Put the contract beside the assertions that
already pin the three tunable lanes' expressions, since release.yml is
size-pinned twice.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtqfvvmto2

* docs(ci): scope the workflow_sha claim to the extracted helpers

The design doc said the trusted checkout "prevents an operator-selected
release ref from supplying code that receives release credentials". Two paths
at HEAD contradict that reading: resolve-version executes the ref's
scripts/get-release-version.js under the job token (run-release-step.sh:84),
and push-release-branch commits with core.hooksPath .husky (:116, :130) in a
step whose env carries CI_BOT_PAT (release.yml:659). Both predate this
extraction, so name them as residual paths rather than as prevented ones.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmtqfvvmto2

* fix(ci): format the release-version test to satisfy the Prettier gate

`Lint & Static` failed at the `Run Prettier` step: a continuation line in
the `assert-unreleased` format-gate test carried four extra spaces.

* fix(ci): exclude nested .git dirs from the docker build context

The extraction added a `.release-workflow/` checkout to `integration_docker`
between the sandbox build's reset and the build itself, and `.dockerignore`
listed `.git` only in its root-anchored form. A depth-1 sparse checkout of
the pinned ref is 1.1M of worktree over 51M of git objects, so every
uncached sandbox image baked those objects into a discarded builder layer on
the shared pool this job is routed to. `node_modules` and `dist` already
carry both forms.

* fix(release): close four gaps the script extraction left behind

R1-15: the push-time guard returned exit 2 for a malformed version, and
run-release-step.sh treats 2 as a transient probe failure — so a refusal
that can never succeed was retried three times over ~45s and logged as a
connectivity problem. Malformed versions now exit 4, which the retry loop
breaks on immediately and which is not the exit-3 version_refusal marker.

R1-16: the extraction moved the guard behind `.release-workflow/` so the
decision comes from the workflow-pinned SHA, but left a working
`--assert-unreleased` dispatch in the ref-supplied get-release-version.js.
A future step using that spelling would load the selected ref's guard.
Removed it, and re-pointed its five tests at the real entry point.

R1-13: assert-release-version.mjs ships alone under release.yml's
sparse-checkout set, so it cannot import release-helpers.js — the
duplicated isExpectedMissingGitHubRelease is deliberate. Documented that
at both sites and pinned the two bodies identical, so widening only the
guard's copy (which would read a rate-limited probe as "release absent")
goes red.

R1-6: restored the notify-failure arm's rationale, matching the condensed
comments the other five arms kept: the exact-title re-check that stops a
v0.18.1 failure reusing a v0.18.10 issue, the bot-author preference, and
the re-query that keeps autofix off a maintainer-owned issue.

* fix(release): re-pin the trusted runner before every step that runs it

R1-10: `Build Bundle and Prepare Package`, `Build Standalone Archives` and
`Verify Standalone Archives` executed `.release-workflow/.../run-release-step.sh`
from a tree that selected-ref npm code had already run over — `build-package`
for the first two, and `npm publish`'s own lifecycle scripts for the third,
inside the job that holds `id-token: 'write'`. At the merge base these were
inline `run:` bodies sourced from the workflow file, immune to anything
written into the working tree, so the exposure is introduced by the
extraction. Each now gets the reset-and-re-checkout pair the design doc
already promises for credential-bearing steps.

The guarding test hand-listed six protected step names, so it could not fail
for the three it omitted. It now sweeps every step whose `run` mentions
`.release-workflow/` (17 today) and separately pins that each re-checkout is
preceded by a reset.

R1-5: nothing tied the publish allowlist to the guard's `PUBLISHED_PACKAGES`
except a comment pointing at publish steps this PR deleted from release.yml.
A channel added to the loop but not the array would ship unprobed, so a retry
of a partial release reports "unreleased" and force-pushes over the tip the
shipped package anchors to. The two sets are now pinned against each other,
and the stale comment points at the real pin.

The three inserted step pairs grow release.yml by 294 bytes (well inside the
4096-byte allowance); the ratchet baseline and the PR description move with
it rather than letting the recorded number drift.

* test(release): pin the flags, arms and modes the extraction left unwitnessed

R1-19: the arm's only executable probe stubbed npm with a script that
recorded `$PWD` and discarded every argument, so deleting `--tag=${NPM_TAG}`
stayed green while `npm publish` would fall back to the `latest` dist-tag —
handing the 21:00 UTC nightly to every end-user install and to the ECS fleet
updater. The stub now records the argv and each publish is checked for
`--access public` and the dist-tag.

R2-3: `resolve-version` was the one arm no test executed or text-pinned;
flipping `--type=preview` to `--type=stable` survived all 2233 scripts tests,
while at runtime the stable path ignores `preview_version_override` and would
publish an operator's manual preview as a stable release. Two execution tests
now cover the preview mapping and the malformed-version rejection; both were
run against the real arm before pushing.

R1-18: release.yml invokes these scripts by bare path, so the executable bit
is load-bearing, but every test invokes them as `bash <script>`, which
ignores the mode — and the workflow triggers only on schedule/dispatch, so no
pull-request lane runs the bare-path form. A mode-normalizing commit would
keep the suite green and kill `set-flags` with exit 126. The recorded git
mode is now pinned for all three scripts.

* test(release): pin the trusted prefix, the fork gate and the real version formats

R1-3: every arm pin elsewhere is a substring match that a bare
`.github/scripts/run-release-step.sh` invocation would satisfy just as well,
so nothing pinned the mechanism the extraction exists for. All 17 step
invocations are now required to carry the `.release-workflow/` prefix and
forbidden from naming the release-ref copy.

R1-9: `PUBLISH_AUDIO_CAPTURE` replaced a deleted step-level
`if: github.repository == 'QwenLM/qwen-code'` gate and, unlike its
`PUBLISH_EXTERNAL_CONTEXT_MEM0` sibling, had neither its gate expression nor
its true branch pinned — a fork running this workflow would publish
@qwen-code/audio-capture. Both halves are pinned now.

R1-14: the format gate is the guard's only genuinely new behaviour and its
accept path was exercised solely with '1.2.3', while the two scheduled
releases produce nightly and preview strings. Both formats are now asserted,
so tightening the pattern fails here rather than at 21:00 UTC.

* fix(release): keep failure reporting alive when the trusted runner is not

R1-11: `notify_failure` gained a checkout of `github.workflow_sha` as its
first step, and its whole body now lives in a shell script. At base the job
had no checkout at all and the notifier was inline, so the job whose only
purpose is reporting every other job's failure acquired two ways to fail
silently: a degraded git backend takes out the checkout, and a syntax error
or dropped exec bit in run-release-step.sh takes out every arm including
notify-failure. In both cases `Create Issue on Failure` carried the default
`if: success()` and was skipped — no issue, no autofix label, no dispatch,
and the scheduled autofix fallback scans issues, so nothing recovered it.

Rather than moving 105 lines of notifier back into the workflow — which the
orchestration guard in this PR forbids at 12 run-lines per step — the primary
path keeps the extraction and stops being load-bearing: it runs under
`always()`, absorbs its own failure, and a five-line inline fallback files a
plain issue whenever it did not succeed. The fallback depends on neither the
checkout nor the script, does no reuse, labelling or dispatch, and accepts a
duplicate issue as the cost of never going silent.

The workflow's line ceiling moves 800 -> 830 for those lines, with the reason
recorded at the assertion; the per-step run cap that keeps logic out of the
YAML is unchanged and still holds. Ratchet baseline and the PR description
move with the file.

* style(release): apply the pinned Prettier to the fallback notifier

Prettier 3.6.1 normalizes the quoting on the new `if:` and drops a
trailing space; the ratchet baseline follows the two-byte change.

* docs(release): name exit 4 where the guard's exit codes are described

Adding the malformed-version code left two descriptions behind: the retry
loop had no statement of the contract at all, and the test comment beside it
still said exit 0 and exit 3 were the only decisive outcomes.

* fix(release): avoid duplicate fallback failure issues

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
2026-09-08 07:09:09 +00:00

978 lines
37 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getVersion,
PUBLISHED_PACKAGES,
runCli,
} from '../get-release-version.js';
// The guard is imported from its own module: `get-release-version.js` no
// longer re-exports it, because the release workflow must reach the guard
// only through the workflow-pinned checkout.
import {
assertVersionUnreleased,
runAssertVersionCli,
} from '../assert-release-version.mjs';
import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
vi.mock('node:child_process');
vi.mock('node:fs');
describe('getVersion', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.setSystemTime(new Date('2025-09-17T00:00:00.000Z'));
// Mock package.json being read by getNightlyVersion
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({ version: '0.8.0' }),
);
});
// This is the base mock for a clean state with no conflicts or rollbacks
const mockExecSync = (command) => {
// NPM dist-tags
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.6.1';
if (command.includes('npm view') && command.includes('--tag=preview'))
return '0.7.0-preview.1';
if (command.includes('npm view') && command.includes('--tag=nightly'))
return '0.8.0-nightly.20250916.abcdef';
// NPM versions list
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.6.0',
'0.6.1',
'0.7.0-preview.0',
'0.7.0-preview.1',
'0.8.0-nightly.20250916.abcdef',
]);
// Deprecation checks (default to not deprecated)
if (command.includes('deprecated')) return '';
// Git Tag Mocks
if (command.includes("git tag -l 'v*'")) return 'v0.6.0\nv0.6.1';
if (command.includes("git tag -l 'v[0-9].[0-9].[0-9]'")) return 'v0.6.1';
if (command.includes("git tag -l 'v*-preview*'")) return 'v0.7.0-preview.1';
if (command.includes("git tag -l 'v*-nightly*'"))
return 'v0.8.0-nightly.20250916.abcdef';
// Git Hash Mock
if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d';
// For doesVersionExist checks - default to not found on any package
if (
command.includes('npm view') &&
command.includes('version 2>/dev/null')
) {
throw new Error('NPM version not found');
}
if (command.includes('git tag -l')) return '';
if (command.includes('gh release view')) {
throw new Error('GH release not found');
}
return '';
};
describe('Happy Path - Version Calculation', () => {
it('should calculate the next stable version from the latest preview', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next preview version from the latest nightly', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.8.0-preview.0');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next nightly version from package.json', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'nightly' });
// Note: The base version now comes from package.json, not the previous nightly tag.
expect(result.releaseVersion).toBe('0.8.0-nightly.20250917.d3bf8a3d');
expect(result.npmTag).toBe('nightly');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next patch version for a stable release', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'patch', 'patch-from': 'stable' });
expect(result.releaseVersion).toBe('0.6.2');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should calculate the next patch version for a preview release', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'patch', 'patch-from': 'preview' });
expect(result.releaseVersion).toBe('0.7.0-preview.2');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
});
describe('Advanced Scenarios', () => {
it('should ignore a deprecated version and use the next highest', () => {
const mockWithDeprecated = (command) => {
// The highest nightly is 0.9.0, but it's deprecated
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.8.0-nightly.20250916.abcdef',
'0.9.0-nightly.20250917.deprecated', // This one is deprecated
]);
// Mock the deprecation check
if (
command.includes(
'npm view @qwen-code/qwen-code@0.9.0-nightly.20250917.deprecated deprecated',
)
)
return 'This version is deprecated';
// The dist-tag still points to the older, valid version
if (command.includes('npm view') && command.includes('--tag=nightly'))
return '0.8.0-nightly.20250916.abcdef';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithDeprecated);
const result = getVersion({ type: 'preview' });
// It should base the preview off 0.8.0, not the deprecated 0.9.0
expect(result.releaseVersion).toBe('0.8.0-preview.0');
});
it('should auto-increment patch version if the calculated one already exists', () => {
const mockWithConflict = (command) => {
// The calculated version 0.7.0 already exists as a git tag
if (command.includes("git tag -l 'v0.7.0'")) return 'v0.7.0';
// The next version, 0.7.1, is available
if (command.includes("git tag -l 'v0.7.1'")) return '';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithConflict);
const result = getVersion({ type: 'stable' });
// Should have skipped 0.7.0 and landed on 0.7.1
expect(result.releaseVersion).toBe('0.7.1');
});
it('should auto-increment preview number if the calculated one already exists', () => {
const mockWithConflict = (command) => {
// The calculated preview 0.8.0-preview.0 already exists on NPM
if (
command.includes(
'npm view @qwen-code/qwen-code@0.8.0-preview.0 version',
)
)
return '0.8.0-preview.0';
// The next one is available
if (
command.includes(
'npm view @qwen-code/qwen-code@0.8.0-preview.1 version',
)
)
throw new Error('Not found');
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithConflict);
const result = getVersion({ type: 'preview' });
// Should have skipped preview.0 and landed on preview.1
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';
if (command.includes("git tag -l 'v*'"))
return 'v0.6.0\nv0.6.1\nv0.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';
if (command.includes("git tag -l 'v*'"))
return 'v0.6.0\nv0.6.1\nv0.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';
if (command.includes("git tag -l 'v*'"))
return 'v0.6.0\nv0.6.1\nv0.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 the latest existing git tag when the npm baseline has no tag (half-shipped release)', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view') && command.includes('--tag=latest'))
// Published to npm, but its release run failed before the git
// tag was created, so the base mock's tag list lacks v0.6.2.
return '0.6.2';
return mockExecSync(command);
});
const result = getVersion({ type: 'stable' });
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should skip unparseable tags (e.g. leading zeros) when resolving the previous release tag', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.6.2';
if (command.includes("git tag -l 'v*'"))
return 'v01.2.3\nv0.6.0\nv0.6.1';
return mockExecSync(command);
});
const result = getVersion({ type: 'stable' });
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should sort tags by semver order, not lexicographic order', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes("git tag -l 'v*'")) return 'v0.9.0\nv0.10.0';
return mockExecSync(command);
});
const result = getVersion({ type: 'stable' });
// semver descending: v0.10.0 > v0.9.0 (lexicographic would pick v0.9.0)
expect(result.previousReleaseTag).toBe('v0.10.0');
});
it('should keep the npm-derived previous tag when git tag listing fails', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes("git tag -l 'v*'")) {
throw new Error('git not available');
}
return mockExecSync(command);
});
const result = getVersion({ type: 'stable' });
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should return an empty previous tag when no stable git tags exist', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes("git tag -l 'v*'")) {
// Only prerelease tags exist; the stable filter drops them all.
return 'v0.7.0-preview.1\nv0.8.0-nightly.20250916.abcdef';
}
return mockExecSync(command);
});
const result = getVersion({ type: 'stable' });
expect(result.previousReleaseTag).toBe('');
});
it('should fall back to package.json when no nightly dist-tag exists (preview)', () => {
const mockWithNoNightly = (command) => {
// No nightly dist-tag exists
if (command.includes('npm view') && command.includes('--tag=nightly')) {
throw new Error('npm error code E404');
}
// Stable versions exist but no nightly dist-tag
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.6.0', '0.6.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoNightly);
const result = getVersion({ type: 'preview' });
// Should fall back to package.json version (0.8.0) + -preview.0
expect(result.releaseVersion).toBe('0.8.0-preview.0');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should fall back to package.json when no preview dist-tag exists (stable)', () => {
const mockWithNoPreview = (command) => {
// No preview dist-tag exists
if (command.includes('npm view') && command.includes('--tag=preview')) {
throw new Error('npm error code E404');
}
// Stable versions exist but no preview dist-tag
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.6.0', '0.6.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoPreview);
const result = getVersion({ type: 'stable' });
// Should fall back to package.json version (0.8.0)
expect(result.releaseVersion).toBe('0.8.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should throw when no nightly dist-tag exists (promote-nightly)', () => {
const mockWithNoNightly = (command) => {
if (command.includes('npm view') && command.includes('--tag=nightly')) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.6.0', '0.6.1']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoNightly);
expect(() => getVersion({ type: 'promote-nightly' })).toThrow(
'Unable to determine baseline version for nightly',
);
});
it('should throw when no dist-tag exists (patch)', () => {
const mockWithNoLatest = (command) => {
if (command.includes('npm view') && command.includes('--tag=latest')) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoLatest);
expect(() =>
getVersion({ type: 'patch', 'patch-from': 'stable' }),
).toThrow('Unable to determine baseline version for latest');
});
it('should fall back to package.json in true greenfield scenario (all dist-tags missing)', () => {
const mockGreenfield = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockGreenfield);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.8.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('');
});
it('should handle E404 from versions list in true greenfield scenario', () => {
const mockGreenfieldVersionsE404 = (command) => {
if (command.includes('npm view') && command.includes('versions --json'))
throw new Error('npm error code E404');
if (
command.includes('npm view') &&
command.includes('--tag=') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockGreenfieldVersionsE404);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.8.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('');
});
it('should derive baseline from versions list when dist-tag is missing but versions exist', () => {
const mockWithVersionsButNoTag = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.6.0',
'0.6.1',
'0.7.0-preview.0',
'0.7.0-preview.3',
]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithVersionsButNoTag);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should propagate transient NPM errors from dist-tag lookup', () => {
const mockWithTransientError = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
) {
throw new Error('npm error code ETIMEDOUT');
}
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithTransientError);
expect(() => getVersion({ type: 'stable' })).toThrow('ETIMEDOUT');
});
it('should fall back to dist-tag when versions list lookup fails transiently', () => {
const mockWithTransientVersionsError = (command) => {
if (
command.includes('npm view') &&
command.includes('versions --json')
) {
throw new Error('npm error code ECONNRESET');
}
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithTransientVersionsError);
const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.7.0');
expect(result.npmTag).toBe('latest');
});
it('should propagate transient NPM errors from versions list when dist-tag is also missing', () => {
const mockWithBothFailing = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (
command.includes('npm view') &&
command.includes('versions --json')
) {
throw new Error('npm error code ECONNRESET');
}
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithBothFailing);
expect(() => getVersion({ type: 'stable' })).toThrow('ECONNRESET');
});
it('should derive baseline from latest versions when latest dist-tag is missing', () => {
const mockWithNoLatestTag = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=latest') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.5.0', '0.6.0', '0.6.1', '0.7.0-preview.0']);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoLatestTag);
const result = getVersion({ type: 'patch', 'patch-from': 'stable' });
expect(result.releaseVersion).toBe('0.6.2');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should fall back to package.json when all matching versions are deprecated (no dist-tag)', () => {
const mockWithAllDeprecated = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=nightly') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify(['0.7.0-nightly.1', '0.7.0-nightly.2']);
if (command.includes('deprecated')) return 'Deprecated';
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithAllDeprecated);
const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.8.0-preview.0');
});
it('should throw when no preview dist-tag exists (patch)', () => {
const mockWithNoPreview = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNoPreview);
expect(() =>
getVersion({ type: 'patch', 'patch-from': 'preview' }),
).toThrow('Unable to determine baseline version for preview');
});
it('should derive preview from nightly versions when nightly dist-tag is missing', () => {
const mockWithNightliesButNoTag = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=nightly') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.6.0',
'0.6.1',
'0.8.0-nightly.20250916.abcdef',
]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithNightliesButNoTag);
const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.8.0-preview.0');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe('v0.6.1');
});
it('should reject an invalid stable version derived from preview', () => {
const mockWithInvalidPreview = (command) => {
if (command.includes('npm view') && command.includes('--tag=preview'))
return 'invalid-preview.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithInvalidPreview);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Invalid derived from preview dist-tag: invalid',
);
});
it('should reject an invalid preview version derived from nightly', () => {
const mockWithInvalidNightly = (command) => {
if (command.includes('npm view') && command.includes('--tag=nightly'))
return 'invalid-nightly.20250916.abcdef';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithInvalidNightly);
expect(() => getVersion({ type: 'preview' })).toThrow(
'Invalid derived from nightly dist-tag: invalid-preview.0',
);
});
it('should reject a stable release derived below the published latest version', () => {
const mockWithOlderPreviewVersions = (command) => {
if (
command.includes('npm view') &&
command.includes('--tag=preview') &&
!command.includes('versions --json')
) {
throw new Error('npm error code E404');
}
if (command.includes('npm view') && command.includes('--tag=latest'))
return '0.9.0';
if (command.includes('npm view') && command.includes('versions --json'))
return JSON.stringify([
'0.7.0-preview.0',
'0.7.0-preview.1',
'0.9.0',
]);
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithOlderPreviewVersions);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Derived stable version 0.7.0 is lower than published latest 0.9.0',
);
});
});
it('runCli default dispatch prints the version JSON and exits 0', () => {
// The prepare job consumes this path (VERSION_JSON=$(node
// scripts/get-release-version.js ...)); pin it through runCli, the
// wrapper prepare actually invokes, so a dropped args pass-through or
// a flipped exit code fails here instead of at the next release. The
// override makes the printed version depend on args reaching
// getVersion.
vi.mocked(execSync).mockImplementation(mockExecSync);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(runCli({ type: 'stable', stable_version_override: '9.9.9' })).toBe(
0,
);
expect(logSpy).toHaveBeenCalledTimes(1);
expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({
releaseTag: 'v9.9.9',
releaseVersion: '9.9.9',
npmTag: 'latest',
previousReleaseTag: 'v0.6.1',
});
});
});
describe('assertVersionUnreleased', () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
const notFoundAnywhere = (command) => {
if (command.includes('npm view')) throw new Error('npm error code E404');
if (command.includes('git ls-remote')) {
// --exit-code exits 2 when no ref matches.
throw Object.assign(new Error('no match'), { status: 2 });
}
if (command.includes('gh release view')) {
throw new Error('release not found');
}
return '';
};
// The push-time refusal must tell the operator where the version was
// found and that re-running cannot fix a partial publish.
const refusalMessage = (foundOn) =>
`Version 1.2.3 has already shipped; refusing to force-push the release branch over it. Found on: ${foundOn}. 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.`;
it('pins the full published-package set', () => {
// The push-time guard derives from this list; the publish allowlist in
// `.github/scripts/run-release-step.sh` enumerates the same channels
// separately, so adding or removing a package must update both. The two
// are pinned against each other by "keeps the publish allowlist and the
// guard package set in step" in release-workflow.test.js — release.yml
// itself no longer contains any publish step.
expect(PUBLISHED_PACKAGES).toEqual([
'@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',
]);
});
it('passes when no package, tag, or release has shipped the version', () => {
vi.mocked(execSync).mockImplementation(notFoundAnywhere);
// The two scheduled releases never produce a plain X.Y.Z: the 21:00 UTC
// nightly and the preview lane are the formats the gate actually sees at
// runtime, so tightening the pattern must fail here rather than at 21:00.
for (const version of [
'1.2.3',
'1.2.3-preview.4',
'0.7.0-nightly.20260907.a1b2c3d',
]) {
expect(() => assertVersionUnreleased(version), version).not.toThrow();
}
});
it('checks origin for tags, not the stale local checkout', () => {
const commands = [];
vi.mocked(execSync).mockImplementation((command) => {
commands.push(command);
return notFoundAnywhere(command);
});
assertVersionUnreleased('1.2.3');
expect(commands).toContain(
'git ls-remote --exit-code origin "refs/tags/v1.2.3"',
);
expect(commands.some((c) => c.includes('git tag -l'))).toBe(false);
});
it('refuses when ANY published package has shipped the version', () => {
// Ship the version on one package at a time, middle-list packages
// included: dropping any of them from PUBLISHED_PACKAGES must fail.
for (const pkg of PUBLISHED_PACKAGES) {
vi.mocked(execSync).mockImplementation((command) => {
if (command === `npm view ${pkg}@1.2.3 version`) {
return '1.2.3';
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(/already shipped/);
}
});
it('refuses when the tag already exists on origin', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('git ls-remote')) {
return '0123456789abcdef\trefs/tags/v1.2.3';
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
refusalMessage('origin tag v1.2.3'),
);
});
it('refuses when the GitHub release already exists', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('gh release view')) return 'v1.2.3';
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
refusalMessage('GitHub release v1.2.3'),
);
});
it('names every shipped package after a partial publish', () => {
// A retry of a partially published release is refused forever; the
// refusal must name exactly which packages to complete, which needs
// the strict scan of every package, not a stop at the first hit.
const shipped = [PUBLISHED_PACKAGES[1], PUBLISHED_PACKAGES[7]];
const commands = [];
vi.mocked(execSync).mockImplementation((command) => {
commands.push(command);
if (shipped.some((pkg) => command === `npm view ${pkg}@1.2.3 version`)) {
return '1.2.3';
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
refusalMessage(shipped.join(', ')),
);
expect(commands.filter((c) => c.startsWith('npm view '))).toHaveLength(
PUBLISHED_PACKAGES.length,
);
});
it('stops probing once the refusal is decided', () => {
// A shipped version already decides the refusal; running the remaining
// probes would let a flaky one replace the refusal's recovery
// guidance with a probe-failure error.
const commands = [];
vi.mocked(execSync).mockImplementation((command) => {
commands.push(command);
if (command === `npm view ${PUBLISHED_PACKAGES[0]}@1.2.3 version`) {
return '1.2.3';
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(/already shipped/);
expect(commands.some((c) => c.includes('ls-remote'))).toBe(false);
expect(commands.some((c) => c.includes('gh release view'))).toBe(false);
});
it('rejects a missing or non-string version instead of failing open', () => {
for (const bad of [undefined, '', true, '1.2.3; echo injected']) {
expect(() => assertVersionUnreleased(bad)).toThrow(/requires a version/);
}
});
it('fails closed when an npm probe errors with anything other than E404', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view')) {
throw new Error('npm error code ETIMEDOUT');
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
/Failed to verify .* on npm/,
);
});
it('keeps the refusal decisive when a probe fails after a shipped hit', () => {
// A partial publish retried during a registry disruption: the scan
// hits on a shipped package, then a later probe errors transiently.
// The refusal is already decided; throwing there would demote the
// exit-3 refusal to a probe-failure exit and lose the version_refusal
// marker the workflow keys on to skip the release-failed notification.
vi.mocked(execSync).mockImplementation((command) => {
if (command === `npm view ${PUBLISHED_PACKAGES[0]}@1.2.3 version`) {
return '1.2.3';
}
if (command.includes('npm view')) {
throw new Error('npm error code ETIMEDOUT');
}
return notFoundAnywhere(command);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
// The runner parses workflow commands from stdout only; ::error:: on
// stderr would never surface as an annotation in the Actions UI.
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(runAssertVersionCli('1.2.3')).toBe(3);
expect(logSpy).toHaveBeenCalledWith(
`::error::${refusalMessage(PUBLISHED_PACKAGES[0])}`,
);
});
it('fails closed when ls-remote errors with anything other than no-match', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('git ls-remote')) {
throw Object.assign(new Error('fatal: authentication failed'), {
status: 128,
});
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
/Failed to verify tag v1\.2\.3 on origin/,
);
});
it('fails closed when gh release view errors with anything other than not-found', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('gh release view')) {
throw new Error('gh: To use GitHub CLI in automation, set GH_TOKEN.');
}
return notFoundAnywhere(command);
});
expect(() => assertVersionUnreleased('1.2.3')).toThrow(
/Failed to verify release v1\.2\.3 on GitHub/,
);
});
it('guard CLI: exits 3 (benign refusal) when the version has shipped', () => {
// Exit 3 is the marker the release workflow uses to keep this
// decisive, benign refusal out of the release-failed notification.
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view')) return '1.2.3';
return notFoundAnywhere(command);
});
vi.spyOn(console, 'error').mockImplementation(() => {});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(runAssertVersionCli('1.2.3')).toBe(3);
expect(logSpy).toHaveBeenCalledWith(
`::error::${refusalMessage(PUBLISHED_PACKAGES.join(', '))}`,
);
});
it('guard CLI: exits 2 (not the refusal marker) when a probe fails', () => {
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('npm view')) {
throw new Error('npm error code ETIMEDOUT');
}
return notFoundAnywhere(command);
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(runAssertVersionCli('1.2.3')).toBe(2);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining('::error::Failed to verify'),
);
});
it('guard CLI: exits 4 (permanent, not retried) on a malformed version', () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// 4, not 2: run-release-step.sh retries exit 2 three times as a
// transient probe failure, and a malformed version never becomes valid.
expect(runAssertVersionCli('')).toBe(4);
expect(logSpy).toHaveBeenCalledWith(
'::error::assert-unreleased requires a version in release format, e.g. --assert-unreleased=1.2.3',
);
});
it('guard CLI: exits 0 when the version has not shipped', () => {
vi.mocked(execSync).mockImplementation(notFoundAnywhere);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
expect(runAssertVersionCli('1.2.3')).toBe(0);
expect(logSpy).not.toHaveBeenCalled();
});
});