mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> 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 <[email protected]> Co-authored-by: yiliang114 <[email protected]>
552 lines
18 KiB
JavaScript
552 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import semver from 'semver';
|
|
import {
|
|
getArgs,
|
|
isExpectedMissingGitHubRelease,
|
|
readJson,
|
|
validateVersion,
|
|
} from './lib/release-helpers.js';
|
|
import { PUBLISHED_PACKAGES } from './assert-release-version.mjs';
|
|
|
|
export { PUBLISHED_PACKAGES };
|
|
|
|
function getVersionFromNPM(distTag) {
|
|
const command = `npm view @qwen-code/qwen-code version --tag=${distTag}`;
|
|
try {
|
|
return execSync(command).toString().trim();
|
|
} catch (error) {
|
|
if (error.message?.includes('E404')) {
|
|
return '';
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function getAllVersionsFromNPM() {
|
|
const command = `npm view @qwen-code/qwen-code versions --json`;
|
|
try {
|
|
const versionsJson = execSync(command).toString().trim();
|
|
return JSON.parse(versionsJson);
|
|
} catch (error) {
|
|
if (error.message?.includes('E404')) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isVersionDeprecated(version) {
|
|
const command = `npm view @qwen-code/qwen-code@${version} deprecated`;
|
|
try {
|
|
const output = execSync(command).toString().trim();
|
|
return output.length > 0;
|
|
} catch (error) {
|
|
// This command shouldn't fail for existing versions, but as a safeguard:
|
|
console.error(
|
|
`Failed to check deprecation status for ${version}: ${error.message}`,
|
|
);
|
|
return false; // Assume not deprecated on error to avoid breaking the release.
|
|
}
|
|
}
|
|
|
|
function detectRollbackAndGetBaseline(npmDistTag) {
|
|
// Get the current dist-tag version
|
|
const distTagVersion = getVersionFromNPM(npmDistTag);
|
|
|
|
// Get all published versions
|
|
let allVersions;
|
|
try {
|
|
allVersions = getAllVersionsFromNPM();
|
|
} catch (error) {
|
|
if (distTagVersion) {
|
|
console.error(
|
|
`Could not fetch versions list, proceeding with dist-tag: ${error.message}`,
|
|
);
|
|
return { baseline: distTagVersion, isRollback: false };
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!distTagVersion) {
|
|
// Dist-tag is missing — try to derive baseline from published versions
|
|
if (allVersions.length === 0) return { baseline: '', isRollback: false };
|
|
|
|
let matchingVersions;
|
|
if (npmDistTag === 'latest') {
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && !semver.prerelease(v),
|
|
);
|
|
} else if (npmDistTag === 'preview') {
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && v.includes('-preview'),
|
|
);
|
|
} else if (npmDistTag === 'nightly') {
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && v.includes('-nightly'),
|
|
);
|
|
} else {
|
|
return { baseline: '', isRollback: false };
|
|
}
|
|
|
|
if (matchingVersions.length === 0)
|
|
return { baseline: '', isRollback: false };
|
|
|
|
matchingVersions.sort((a, b) => semver.rcompare(a, b));
|
|
|
|
let highestExistingVersion = '';
|
|
for (const version of matchingVersions) {
|
|
if (!isVersionDeprecated(version)) {
|
|
highestExistingVersion = version;
|
|
break;
|
|
} else {
|
|
console.error(`Ignoring deprecated version: ${version}`);
|
|
}
|
|
}
|
|
|
|
if (!highestExistingVersion) return { baseline: '', isRollback: false };
|
|
|
|
return {
|
|
baseline: highestExistingVersion,
|
|
isRollback: false,
|
|
highestExistingVersion,
|
|
};
|
|
}
|
|
|
|
if (allVersions.length === 0)
|
|
return { baseline: distTagVersion, isRollback: false };
|
|
|
|
// Filter versions by type to match the dist-tag
|
|
let matchingVersions;
|
|
if (npmDistTag === 'latest') {
|
|
// Stable versions: no prerelease identifiers
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && !semver.prerelease(v),
|
|
);
|
|
} else if (npmDistTag === 'preview') {
|
|
// Preview versions: contain -preview
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && v.includes('-preview'),
|
|
);
|
|
} else if (npmDistTag === 'nightly') {
|
|
// Nightly versions: contain -nightly
|
|
matchingVersions = allVersions.filter(
|
|
(v) => semver.valid(v) && v.includes('-nightly'),
|
|
);
|
|
} else {
|
|
// For other dist-tags, just use the dist-tag version
|
|
return { baseline: distTagVersion, isRollback: false };
|
|
}
|
|
|
|
if (matchingVersions.length === 0)
|
|
return { baseline: distTagVersion, isRollback: false };
|
|
|
|
// Sort by semver to get a list from highest to lowest
|
|
matchingVersions.sort((a, b) => semver.rcompare(a, b));
|
|
|
|
// Find the highest non-deprecated version
|
|
let highestExistingVersion = '';
|
|
for (const version of matchingVersions) {
|
|
if (!isVersionDeprecated(version)) {
|
|
highestExistingVersion = version;
|
|
break; // Found the one we want
|
|
} else {
|
|
console.error(`Ignoring deprecated version: ${version}`);
|
|
}
|
|
}
|
|
|
|
// If all matching versions were deprecated, fall back to the dist-tag version
|
|
if (!highestExistingVersion) {
|
|
highestExistingVersion = distTagVersion;
|
|
}
|
|
|
|
// Check if we're in a rollback scenario
|
|
const isRollback = semver.gt(highestExistingVersion, distTagVersion);
|
|
|
|
return {
|
|
baseline: isRollback ? highestExistingVersion : distTagVersion,
|
|
isRollback,
|
|
distTagVersion,
|
|
highestExistingVersion,
|
|
};
|
|
}
|
|
|
|
function doesVersionExist(version) {
|
|
// 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 {
|
|
// Missing packages and transient registry failures are best effort here.
|
|
}
|
|
}
|
|
|
|
// Check local Git tags.
|
|
try {
|
|
const command = `git tag -l 'v${version}'`;
|
|
const tagOutput = execSync(command).toString().trim();
|
|
if (tagOutput === `v${version}`) {
|
|
console.error(`Git tag v${version} already exists.`);
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to check git tags for conflicts: ${error.message}`);
|
|
}
|
|
|
|
// Check GitHub releases
|
|
try {
|
|
const command = `gh release view "v${version}" --json tagName --jq .tagName`;
|
|
const output = execSync(command).toString().trim();
|
|
if (output === `v${version}`) {
|
|
console.error(`GitHub release v${version} already exists.`);
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
if (!isExpectedMissingGitHubRelease(error)) {
|
|
console.error(
|
|
`Failed to check GitHub releases for conflicts: ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function getAndVerifyTags(npmDistTag, _gitTagPattern) {
|
|
// Detect rollback scenarios and get the correct baseline
|
|
const rollbackInfo = detectRollbackAndGetBaseline(npmDistTag);
|
|
const baselineVersion = rollbackInfo.baseline;
|
|
|
|
if (!baselineVersion) {
|
|
console.error(
|
|
`No baseline version found for dist-tag "${npmDistTag}" — returning null`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (rollbackInfo.isRollback) {
|
|
// Rollback scenario: warn about the rollback but don't fail
|
|
console.error(
|
|
`Rollback detected! NPM ${npmDistTag} tag is ${rollbackInfo.distTagVersion}, but using ${baselineVersion} as baseline for next version calculation (highest existing version).`,
|
|
);
|
|
}
|
|
|
|
// Not verifying against git tags or GitHub releases as per user request.
|
|
|
|
return {
|
|
latestVersion: baselineVersion,
|
|
latestTag: `v${baselineVersion}`,
|
|
};
|
|
}
|
|
|
|
function listExistingStableTags() {
|
|
const output = execSync(`git tag -l 'v*'`).toString();
|
|
return output
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag) && semver.valid(tag))
|
|
.sort(semver.rcompare);
|
|
}
|
|
|
|
function getLatestStableReleaseTag() {
|
|
try {
|
|
const result = getAndVerifyTags('latest', 'v[0-9].[0-9].[0-9]');
|
|
if (!result) return '';
|
|
const npmDerivedTag = result.latestTag;
|
|
let existingTags;
|
|
try {
|
|
existingTags = listExistingStableTags();
|
|
} catch (gitError) {
|
|
// Without a tag listing we cannot verify; keep the npm-derived tag as
|
|
// before rather than dropping the release-notes anchor entirely.
|
|
console.error(
|
|
`Could not list git tags to verify ${npmDerivedTag} (${gitError.message}); using the npm-derived tag as-is.`,
|
|
);
|
|
return npmDerivedTag;
|
|
}
|
|
if (existingTags.includes(npmDerivedTag)) return npmDerivedTag;
|
|
// A half-shipped release can leave the npm baseline ahead of any git tag
|
|
// (npm publish succeeded, tag creation failed). Anchoring the release
|
|
// notes at a tag that does not exist makes GitHub fall back to the entire
|
|
// branch history, so anchor at the latest stable tag that does exist.
|
|
const fallbackTag = existingTags[0] ?? '';
|
|
console.error(
|
|
`npm-derived previous tag ${npmDerivedTag} does not exist in git; falling back to ${fallbackTag || '<none>'}.`,
|
|
);
|
|
return fallbackTag;
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to determine latest stable release tag: ${error.message}`,
|
|
);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function promoteNightlyVersion() {
|
|
const result = getAndVerifyTags('nightly', 'v*-nightly*');
|
|
if (!result) {
|
|
throw new Error(
|
|
'Unable to determine baseline version for nightly (required for promote-nightly)',
|
|
);
|
|
}
|
|
const baseVersion = result.latestVersion.split('-')[0];
|
|
const versionParts = baseVersion.split('.');
|
|
const major = versionParts[0];
|
|
const minor = versionParts[1] ? parseInt(versionParts[1]) : 0;
|
|
const nextMinor = minor + 1;
|
|
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
|
|
return {
|
|
releaseVersion: `${major}.${nextMinor}.0-nightly.${date}.${gitShortHash}`,
|
|
npmTag: 'nightly',
|
|
};
|
|
}
|
|
|
|
function getNightlyVersion() {
|
|
const packageJson = readJson('package.json');
|
|
const baseVersion = packageJson.version.split('-')[0];
|
|
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
const gitShortHash = execSync('git rev-parse --short HEAD').toString().trim();
|
|
const releaseVersion = `${baseVersion}-nightly.${date}.${gitShortHash}`;
|
|
return {
|
|
releaseVersion,
|
|
npmTag: 'nightly',
|
|
};
|
|
}
|
|
|
|
function getStableVersion(args) {
|
|
const tagResult = getAndVerifyTags('preview', 'v*-preview*');
|
|
let releaseVersion;
|
|
if (args.stable_version_override) {
|
|
const overrideVersion = args.stable_version_override.replace(/^v/, '');
|
|
validateVersion(overrideVersion, 'X.Y.Z', 'stable_version_override');
|
|
releaseVersion = overrideVersion;
|
|
} else if (tagResult) {
|
|
releaseVersion = tagResult.latestVersion.replace(/-preview.*/, '');
|
|
validateVersion(releaseVersion, 'X.Y.Z', 'derived from preview dist-tag');
|
|
const latestStable = getVersionFromNPM('latest');
|
|
if (
|
|
latestStable &&
|
|
semver.valid(latestStable) &&
|
|
semver.gt(latestStable, releaseVersion)
|
|
) {
|
|
throw new Error(
|
|
`Derived stable version ${releaseVersion} is lower than published latest ${latestStable}. Refusing retrograde baseline.`,
|
|
);
|
|
}
|
|
} else {
|
|
const packageJson = readJson('package.json');
|
|
releaseVersion = packageJson.version.split('-')[0];
|
|
validateVersion(releaseVersion, 'X.Y.Z', 'package.json version');
|
|
}
|
|
|
|
return {
|
|
releaseVersion,
|
|
npmTag: 'latest',
|
|
};
|
|
}
|
|
|
|
function getPreviewVersion(args) {
|
|
const tagResult = getAndVerifyTags('nightly', 'v*-nightly*');
|
|
let releaseVersion;
|
|
if (args.preview_version_override) {
|
|
const overrideVersion = args.preview_version_override.replace(/^v/, '');
|
|
validateVersion(
|
|
overrideVersion,
|
|
'X.Y.Z-preview.N',
|
|
'preview_version_override',
|
|
);
|
|
releaseVersion = overrideVersion;
|
|
} else if (tagResult) {
|
|
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',
|
|
'derived from nightly dist-tag',
|
|
);
|
|
} else {
|
|
const packageJson = readJson('package.json');
|
|
const baseVersion = packageJson.version.split('-')[0];
|
|
releaseVersion = baseVersion + '-preview.0';
|
|
validateVersion(baseVersion, 'X.Y.Z', 'package.json version');
|
|
}
|
|
|
|
return {
|
|
releaseVersion,
|
|
npmTag: 'preview',
|
|
};
|
|
}
|
|
|
|
function getPatchVersion(patchFrom) {
|
|
if (!patchFrom || (patchFrom !== 'stable' && patchFrom !== 'preview')) {
|
|
throw new Error(
|
|
'Patch type must be specified with --patch-from=stable or --patch-from=preview',
|
|
);
|
|
}
|
|
const distTag = patchFrom === 'stable' ? 'latest' : 'preview';
|
|
const pattern = distTag === 'latest' ? 'v[0-9].[0-9].[0-9]' : 'v*-preview*';
|
|
const tagResult = getAndVerifyTags(distTag, pattern);
|
|
if (!tagResult) {
|
|
throw new Error(
|
|
`Unable to determine baseline version for ${distTag} (required for patch)`,
|
|
);
|
|
}
|
|
const { latestVersion } = tagResult;
|
|
|
|
if (patchFrom === 'stable') {
|
|
// For stable versions, increment the patch number: 0.5.4 -> 0.5.5
|
|
const versionParts = latestVersion.split('.');
|
|
const major = versionParts[0];
|
|
const minor = versionParts[1];
|
|
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0;
|
|
const releaseVersion = `${major}.${minor}.${patch + 1}`;
|
|
return {
|
|
releaseVersion,
|
|
npmTag: distTag,
|
|
};
|
|
} else {
|
|
// For preview versions, increment the preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
|
|
const [version, prereleasePart] = latestVersion.split('-');
|
|
if (!prereleasePart || !prereleasePart.startsWith('preview.')) {
|
|
throw new Error(
|
|
`Invalid preview version format: ${latestVersion}. Expected format like "0.6.0-preview.2"`,
|
|
);
|
|
}
|
|
|
|
const previewNumber = parseInt(prereleasePart.split('.')[1]);
|
|
if (isNaN(previewNumber)) {
|
|
throw new Error(`Could not parse preview number from: ${prereleasePart}`);
|
|
}
|
|
|
|
const releaseVersion = `${version}-preview.${previewNumber + 1}`;
|
|
return {
|
|
releaseVersion,
|
|
npmTag: distTag,
|
|
};
|
|
}
|
|
}
|
|
|
|
export function getVersion(options = {}) {
|
|
const args = { ...getArgs(), ...options };
|
|
const type = args.type || 'nightly';
|
|
|
|
let versionData;
|
|
switch (type) {
|
|
case 'nightly':
|
|
versionData = getNightlyVersion();
|
|
// Nightly versions include a git hash, so conflicts are highly unlikely
|
|
// and indicate a problem. We'll still validate but not auto-increment.
|
|
if (doesVersionExist(versionData.releaseVersion)) {
|
|
throw new Error(
|
|
`Version conflict! Nightly version ${versionData.releaseVersion} already exists.`,
|
|
);
|
|
}
|
|
break;
|
|
case 'promote-nightly':
|
|
versionData = promoteNightlyVersion();
|
|
break;
|
|
case 'stable':
|
|
versionData = getStableVersion(args);
|
|
break;
|
|
case 'preview':
|
|
versionData = getPreviewVersion(args);
|
|
break;
|
|
case 'patch':
|
|
versionData = getPatchVersion(args['patch-from']);
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown release type: ${type}`);
|
|
}
|
|
|
|
// For patchable versions, check for existence and increment if needed.
|
|
if (type === 'stable' || type === 'preview' || type === 'patch') {
|
|
let releaseVersion = versionData.releaseVersion;
|
|
while (doesVersionExist(releaseVersion)) {
|
|
console.error(`Version ${releaseVersion} exists, incrementing.`);
|
|
if (releaseVersion.includes('-preview.')) {
|
|
// Increment preview number: 0.6.0-preview.2 -> 0.6.0-preview.3
|
|
const [version, prereleasePart] = releaseVersion.split('-');
|
|
const previewNumber = parseInt(prereleasePart.split('.')[1]);
|
|
releaseVersion = `${version}-preview.${previewNumber + 1}`;
|
|
} else {
|
|
// Increment patch number: 0.5.4 -> 0.5.5
|
|
const versionParts = releaseVersion.split('.');
|
|
const major = versionParts[0];
|
|
const minor = versionParts[1];
|
|
const patch = parseInt(versionParts[2]);
|
|
releaseVersion = `${major}.${minor}.${patch + 1}`;
|
|
}
|
|
}
|
|
versionData.releaseVersion = releaseVersion;
|
|
}
|
|
|
|
// All checks are done, construct the final result.
|
|
const result = {
|
|
releaseTag: `v${versionData.releaseVersion}`,
|
|
...versionData,
|
|
};
|
|
|
|
result.previousReleaseTag = getLatestStableReleaseTag();
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* CLI dispatch, exported for tests: prints the version JSON and returns the
|
|
* exit code.
|
|
*
|
|
* The push-time guard is deliberately *not* reachable from here. This file is
|
|
* supplied by the operator-selected release ref, while the guard must run from
|
|
* the workflow-pinned SHA — `release.yml` invokes
|
|
* `.release-workflow/scripts/assert-release-version.mjs` directly, and
|
|
* `scripts/tests/release-workflow.test.js` forbids the
|
|
* `node scripts/get-release-version.js --assert-unreleased=` spelling. Adding a
|
|
* dispatch back here would let a ref that has already shipped supply its own
|
|
* relaxed guard and force-push over a published version.
|
|
*/
|
|
export function runCli(args) {
|
|
console.log(JSON.stringify(getVersion(args), null, 2));
|
|
return 0;
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const exitCode = runCli(getArgs());
|
|
if (exitCode !== 0) {
|
|
process.exit(exitCode);
|
|
}
|
|
}
|