mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-31 19:52:43 +00:00
* ci(sdk-java): guard Flyway version uniqueness and watch red main runs
Two in-flight PRs each added a V16 managed-agent-server migration and
merged 23 minutes apart; each branch was green in isolation, and Flyway
refused to boot the merged result, leaving main red for two hours while
three more merges stacked on it (#12940).
A new flyway-migrations job runs scripts/check-flyway-migrations.js on
every SDK Java pull request and push: a filename scan of the two
migration locations that share one version sequence, failing in seconds
and naming both files when two claim one version. Versions compare the
way Flyway compares them — numerically per segment — so V016 collides
with V16, and a location rename that would empty the guard fails instead
of passing vacuously.
The main-CI failure watcher now watches the SDK Java workflow, whose
path-filtered push run on main is the only signal for a merge result
neither PR could fail, and the failure-signature helper reads Surefire's
per-test error lines, so a red Java lane files one deduped issue naming
the failing tests instead of a per-commit stub.
Refs #12940
* ci(sdk-java): harden the Flyway guard and dedupe its main-red issues
Review on #12965:
- compare versions across every module of one invocation — Flyway resolves
classpath:db/migration across jars, so a version claimed in two modules
collides exactly like two files in one module
- fail when the SQL location yields no migration even if the Java location
still does, so renaming one location away can no longer pass vacuously
- escape %/CR/LF in module names and file paths before emitting ::error::
(the runner parses workflow commands from stderr too, and the job scans
contributor-controlled filenames on pull_request)
- match the migration suffix case-insensitively, as Flyway does
- keep the fully qualified Surefire id as the dedupe identity so same-named
tests in different packages no longer merge into one issue; the cosmetic
package strip moves to shortenForTitle
- give the guard's ::error:: line a stable flyway:<module>:<version>
identity, so a standing red dedupes to one issue instead of one per
stacked merge, and render the failed jobs in the test-keyed body
- pin the workflow_run watch entry to sdk-java.yml's name key
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* ci(sdk-java): fix the Flyway dedupe on runner-rendered logs and close review findings
The Actions runner consumes ::error:: workflow commands and writes
##[error] into the downloadable job log, so the dedupe pattern anchored
on ^::error:: never matched production input and every merge stacked on
a standing red would have opened a fresh autofix issue. The anchor now
accepts both forms, the guard's two non-collision failure modes (moved
migration location, missing module directory) get their own stable
module+mode identity, and the synthesized flyway id is written to be
read since it doubles as the issue title and body bullet.
The issue body's "- Failed jobs:" block is now machine-owned like
"## Also failing": stripped and re-emitted from the current run on every
merge instead of freezing the lanes recorded at filing time, with the
empty-list case pinned against a dangling heading.
The guard itself now reports a multi-module collision once after all
modules are scanned, naming every claimant (a second line would file a
second issue), never prints a clean summary for a claimant, and only
requires the SQL location of a module that owns a db/migration*
directory — which lets the workflow invocation cover runtime-broker, the
module that shares the classpath:db/migration namespace without carrying
migrations today.
escapeWorkflowCommand moves to scripts/release-script-utils.js (the
byte-identical copy would have drifted from the tested original), and
the filename-forgery tests gain a runner-decoding oracle and a
capability probe so the Windows lane skips instead of failing on
filenames Win32 cannot hold.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
* ci(sdk-java): close the guard's blind spots and validate its dedupe captures
Review and sandboxed verification on #12965 found the moved-location
probe keying on the two exact directory names: a rename outside the
migration* family (db/migration -> db/changelog) passed silently with
the whole sequence inside it, a renamed Java location printed "all
versions unique" from a scan that skipped it, and a Java-only module
red-lit as "moved" with its real collision never named. The probe is
now derived from the LOCATIONS table: a versioned-migration file
anywhere under a source root but outside the configured location fails
with the files named, the sibling check is per location and fires only
when the location directory itself is gone, and a repeated module
argument no longer fabricates a collision of a file with itself.
The invocation now names qwencode too — managed-agent-server depends on
it at compile scope, the same namespace premise that admitted
runtime-broker — and the .size-baseline entry is re-recorded at the
file's current size.
On the consumer side, both Flyway patterns feed their captures through a
module-shape validation before they become a dedupe identity, so a
filename-borne forgery rendered by the runner's annotation decode can no
longer inject a second identity into the issue this files, and the
guard's run-level diagnosis is ordered ahead of first-seen test ids so a
mass failure in another lane cannot crowd it past the search and body
caps. A closure pin keeps the spawned guard importable in the
dependency-free helper lane.
Refs #12940
* ci(sdk-java): close the Windows-drive forgery and split the stray-file message
The drive-prefix alternative in the module validator re-opened the
forgery it closed: backslash and colon are ordinary characters in a
POSIX filename, so a migration name could supply a `C:\evil` prefix and
pass the path-shape check. No lane that runs this consumer ever
produces a backslashed module — the producer and analyze jobs are
ubuntu-latest and the suite runs only on the Linux lint_and_static
lane — so the branch is dropped, backslashes are rejected outright, and
the fixtures hand the guard a slash-bearing argument (a no-op on POSIX)
instead of teaching the consumer a shape only a forgery can produce.
The unsafe-character half of the validator is now pinned for
whitespace, backticks and backslashes across both call sites.
A populated location with a migration-shaped stray elsewhere under the
source root no longer prints "found no migration under": the guard
names the stray as the cause, and the consumer's config identity learns
the new phrase, so the standing red still files one issue. The dep-free
closure pin now walks the guard's relative-import closure instead of a
hardcoded depth-one list, so a new hop carrying an npm import cannot
slip through.
Refs #12940
* ci(sdk-java): re-record the sdk-java.yml size baseline after merging main
The rebase onto 51b80dadbc folded #12945's Hosted latency steps into the
workflow; the file is 22735 bytes now, and the ratchet requires the
number in the same PR.
* ci(sdk-java): anchor the flyway dedupe identity to runner-emitted lines
Sandboxed verification found the module-shape validator closing the
filename door but not the directory one: the guard prints file paths,
so a LF inside a directory component leaves the forged text after a
real `/` — slash-bearing and past the shape. The runner timestamps
every line it writes, and a decoded annotation continuation carries
none, so both flyway arms now accept their line only when it came from
the runner itself. The shape validation stays as the second layer for
every other channel, and its inline fixtures are timestamped so they
still reach it. The Surefire arm's matching exposure is pre-existing —
the vitest and pytest patterns share it — and is left to a follow-up.
Refs #12940
* ci(sdk-java): close the forgery family at the guard's emission sites
The third Critical in the family, and the round that ends it. The
guard interpolates contributor-controlled path bytes into ::error::
command data, and the runner percent-decodes that data where the
consumer parses — so percent-escaping control bytes was never enough:
a directory name carries the LF, the slash and every pattern byte the
decoded continuation needs. No charset rule over the consumer's module
shape can close that — the forged module is shape-indistinguishable —
and double-escaping regresses the genuine LF arm into the suppression
arm. What closes every measured arm is bounding the emitted alphabet
at the four emission sites: nothing outside a migration path's real
characters reaches the command data, so no decode pass can restore
pattern text from it.
The suppression arm needs no newline at all and no runner behaviour:
spaces on the timestamped line run the duplicate-version arm's lazy
module capture all the way to the payload, the shape rejects it, and
an unconditional continue used to discard the line's genuine config
identity with it — the duplicate storm this change exists to end, from
the opposite direction. The flyway arm now falls through to the config
arm when its capture is rejected.
The stray-file test's path assertion is separator-agnostic, per the
round-6 review's note that the suite must pass on the Windows lane.
Refs #12940
* ci(sdk-java): re-record the sdk-java.yml size baseline after merging main
The merge folded #13083's workflow growth into the file (23,524 bytes
now); the ratchet asks for the number in the same PR.
---------
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
977 lines
37 KiB
JavaScript
977 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', async (importOriginal) => {
|
|
const fs = await importOriginal();
|
|
return { ...fs, readFileSync: vi.fn(fs.readFileSync) };
|
|
});
|
|
|
|
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', () => {
|
|
// Changing private flags must not accidentally expand the release surface.
|
|
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',
|
|
'@qwen-code/web-shell',
|
|
]);
|
|
});
|
|
|
|
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 both streams; this CLI emits
|
|
// ::error:: on stdout.
|
|
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();
|
|
});
|
|
});
|