Release Promotion Clean up (#8597)

This commit is contained in:
matt korwel
2025-09-16 23:47:05 -07:00
committed by GitHub
parent dee20cc6e8
commit 33841c154b
7 changed files with 322 additions and 180 deletions
+7 -1
View File
@@ -64,7 +64,13 @@ runs:
DRY_RUN: '${{ inputs.dry-run }}' DRY_RUN: '${{ inputs.dry-run }}'
RELEASE_TAG: '${{ inputs.release-tag }}' RELEASE_TAG: '${{ inputs.release-tag }}'
run: |- run: |-
git add package.json npm-shrinkwrap.json packages/*/package.json git add package.json packages/*/package.json
if [ -f npm-shrinkwrap.json ]; then
git add npm-shrinkwrap.json
fi
if [ -f package-lock.json ]; then
git add package-lock.json
fi
git commit -m "chore(release): ${RELEASE_TAG}" git commit -m "chore(release): ${RELEASE_TAG}"
if [[ "${DRY_RUN}" == "false" ]]; then if [[ "${DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..." echo "Pushing release branch to remote..."
+10 -3
View File
@@ -5,20 +5,27 @@ inputs:
force_skip_tests: force_skip_tests:
description: 'Whether to force skip the tests.' description: 'Whether to force skip the tests.'
required: false required: false
default: 'false' type: 'boolean'
default: false
gemini_api_key: gemini_api_key:
description: 'The API key for running integration tests.' description: 'The API key for running integration tests.'
required: true required: true
working-directory:
description: 'The working directory to run the tests in.'
required: false
default: '.'
runs: runs:
using: 'composite' using: 'composite'
steps: steps:
- name: 'Run Tests' - name: 'Run Tests'
if: "inputs.force_skip_tests != 'true'" if: '${{ !inputs.force_skip_tests }}'
env: env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
working-directory: '${{ inputs.working-directory }}'
run: |- run: |-
npm run preflight npm run build
npm run test:ci
npm run test:integration:sandbox:none npm run test:integration:sandbox:none
npm run test:integration:sandbox:docker npm run test:integration:sandbox:docker
shell: 'bash' shell: 'bash'
+6
View File
@@ -15,6 +15,11 @@ on:
required: false required: false
type: 'boolean' type: 'boolean'
default: false default: false
ref:
description: 'The branch, tag, or SHA to release from.'
required: false
type: 'string'
default: 'main'
jobs: jobs:
release: release:
@@ -23,6 +28,7 @@ jobs:
- name: 'Checkout' - name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with: with:
ref: '${{ github.event.inputs.ref }}'
fetch-depth: 0 fetch-depth: 0
- name: 'Setup Node.js' - name: 'Setup Node.js'
+82 -15
View File
@@ -13,6 +13,11 @@ on:
required: false required: false
type: 'boolean' type: 'boolean'
default: false default: false
ref:
description: 'The branch, tag, or SHA to release from.'
required: false
type: 'string'
default: 'main'
jobs: jobs:
calculate-versions: calculate-versions:
@@ -26,12 +31,14 @@ jobs:
PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}'
PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}'
NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}'
PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}'
steps: steps:
- name: 'Checkout' - name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true
- name: 'Setup Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
@@ -57,14 +64,73 @@ jobs:
echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
# shellcheck disable=SC1083 # shellcheck disable=SC1083
echo "PREVIEW_SHA=$(git rev-parse "$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)"^{commit})" >> "${GITHUB_OUTPUT}" echo "PREVIEW_SHA=$(git rev-parse '${{ github.event.inputs.ref }}'^{commit})" >> "${GITHUB_OUTPUT}"
echo "PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_PREVIEW_TAG=$(echo "${PREVIEW_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
echo "NEXT_NIGHTLY_VERSION=$(echo "${NIGHTLY_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" echo "NEXT_NIGHTLY_VERSION=$(echo "${NIGHTLY_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
echo "PREVIOUS_NIGHTLY_TAG=$(echo "${NIGHTLY_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
CURRENT_NIGHTLY_TAG=$(git describe --tags --abbrev=0 --match="*nightly*")
echo "CURRENT_NIGHTLY_TAG=${CURRENT_NIGHTLY_TAG}" >> "${GITHUB_OUTPUT}"
echo "NEXT_SHA=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
promote: - name: 'Display Pending Updates'
name: 'Promote to ${{ matrix.channel }}' run: |
echo "Pending Changes:"
echo " Nightly: ${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }} -> ${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}"
echo " Preview: ${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }} -> ${{ steps.versions.outputs.PREVIEW_VERSION }}"
echo " Stable: ${{ steps.versions.outputs.PREVIOUS_STABLE_TAG }} -> ${{ steps.versions.outputs.STABLE_VERSION }}"
echo ""
echo "Relevant SHAs:"
echo " Current (Stable): ${{ steps.versions.outputs.STABLE_SHA }}"
echo " Current (Preview): ${{ steps.versions.outputs.PREVIEW_SHA }}"
echo " Next (to be promoted): ${{ steps.versions.outputs.NEXT_SHA }}"
test:
name: 'Test ${{ matrix.channel }}'
needs: 'calculate-versions' needs: 'calculate-versions'
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
strategy:
matrix:
include:
- channel: 'stable'
sha: '${{ needs.calculate-versions.outputs.STABLE_SHA }}'
- channel: 'preview'
sha: '${{ needs.calculate-versions.outputs.PREVIEW_SHA }}'
- channel: 'nightly'
sha: '${{ github.event.inputs.ref }}'
steps:
- name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
ref: '${{ github.event.inputs.ref }}'
- name: 'Checkout correct SHA'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with:
ref: '${{ matrix.sha }}'
path: 'release'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install Dependencies'
working-directory: './release'
run: 'npm ci'
- name: 'Run Tests'
uses: './.github/actions/run-tests'
with:
force_skip_tests: '${{ github.event.inputs.force_skip_tests }}'
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
working-directory: './release'
publish:
name: 'Publish ${{ matrix.channel }}'
needs: ['calculate-versions', 'test']
runs-on: 'ubuntu-latest'
permissions: permissions:
contents: 'write' contents: 'write'
packages: 'write' packages: 'write'
@@ -83,16 +149,17 @@ jobs:
previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}' previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}'
steps: steps:
- name: 'Checkout main' - name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with: with:
ref: 'main' ref: '${{ github.event.inputs.ref }}'
- name: 'Checkout correct SHA' - name: 'Checkout correct SHA'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with: with:
ref: '${{ matrix.sha }}' ref: '${{ matrix.sha }}'
path: 'release' path: 'release'
fetch-depth: 0
- name: 'Setup Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
@@ -104,12 +171,6 @@ jobs:
working-directory: './release' working-directory: './release'
run: 'npm ci' run: 'npm ci'
- name: 'Run Tests'
uses: './.github/actions/run-tests'
with:
force_skip_tests: '${{ github.event.inputs.force_skip_tests }}'
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
- name: 'Publish Release' - name: 'Publish Release'
uses: './.github/actions/publish-release' uses: './.github/actions/publish-release'
with: with:
@@ -125,16 +186,16 @@ jobs:
nightly-pr: nightly-pr:
name: 'Create Nightly PR' name: 'Create Nightly PR'
needs: 'calculate-versions' needs: ['calculate-versions', 'test']
runs-on: 'ubuntu-latest' runs-on: 'ubuntu-latest'
permissions: permissions:
contents: 'write' contents: 'write'
pull-requests: 'write' pull-requests: 'write'
steps: steps:
- name: 'Checkout main' - name: 'Checkout Ref'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8'
with: with:
ref: 'main' ref: '${{ github.event.inputs.ref }}'
- name: 'Setup Node.js' - name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020'
@@ -165,7 +226,13 @@ jobs:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
DRY_RUN: '${{ github.event.inputs.dry_run }}' DRY_RUN: '${{ github.event.inputs.dry_run }}'
run: |- run: |-
git add package.json npm-shrinkwrap.json packages/*/package.json git add package.json packages/*/package.json
if [ -f npm-shrinkwrap.json ]; then
git add npm-shrinkwrap.json
fi
if [ -f package-lock.json ]; then
git add package-lock.json
fi
git commit -m "chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}" git commit -m "chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"
if [[ "${DRY_RUN}" == "false" ]]; then if [[ "${DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..." echo "Pushing release branch to remote..."
+13
View File
@@ -46,6 +46,19 @@ Each Tuesday, the on-call engineer will trigger the "Promote Release" workflow.
This process ensures a consistent and reliable release cadence with minimal manual intervention. This process ensures a consistent and reliable release cadence with minimal manual intervention.
### Source of Truth for Versioning
To ensure the highest reliability, the release promotion process uses the **NPM registry as the single source of truth** for determining the current version of each release channel (`stable`, `preview`, and `nightly`).
1. **Fetch from NPM:** The workflow begins by querying NPM's `dist-tags` (`latest`, `preview`, `nightly`) to get the exact version strings for the packages currently available to users.
2. **Cross-Check for Integrity:** For each version retrieved from NPM, the workflow performs a critical integrity check:
- It verifies that a corresponding **git tag** exists in the repository.
- It verifies that a corresponding **GitHub Release** has been created.
3. **Halt on Discrepancy:** If either the git tag or the GitHub Release is missing for a version listed on NPM, the workflow will immediately fail. This strict check prevents promotions from a broken or incomplete previous release and alerts the on-call engineer to a release state inconsistency that must be manually resolved.
4. **Calculate Next Version:** Only after these checks pass does the workflow proceed to calculate the next semantic version based on the trusted version numbers retrieved from NPM.
This NPM-first approach, backed by integrity checks, makes the release process highly robust and prevents the kinds of versioning discrepancies that can arise from relying solely on git history or API outputs.
## Patching Releases ## Patching Releases
If a critical bug that is already fixed on `main` needs to be patched on a `stable` or `preview` release, the process is now highly automated. If a critical bug that is already fixed on `main` needs to be patched on a `stable` or `preview` release, the process is now highly automated.
+124 -78
View File
@@ -8,11 +8,6 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { readFileSync } from 'node:fs';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function getArgs() { function getArgs() {
const args = {}; const args = {};
@@ -26,96 +21,147 @@ function getArgs() {
} }
function getLatestTag(pattern) { function getLatestTag(pattern) {
const command = `gh release list --limit 100 --json tagName | jq -r '[.[] | select(.tagName | ${pattern})] | .[0].tagName'`; const command = `git tag --sort=-creatordate -l '${pattern}' | head -n 1`;
try { try {
return execSync(command).toString().trim(); return execSync(command).toString().trim();
} catch { } catch {
// Suppress error output for cleaner test failures
return ''; return '';
} }
} }
export function getVersion(options = {}) { function getVersionFromNPM(distTag) {
const args = getArgs(); const command = `npm view @google/gemini-cli version --tag=${distTag}`;
const type = options.type || args.type || 'nightly'; try {
return execSync(command).toString().trim();
} catch {
return '';
}
}
let releaseVersion; function verifyGitHubReleaseExists(tagName) {
let npmTag; const command = `gh release view "${tagName}" --json tagName --jq .tagName`;
let previousReleaseTag; try {
const output = execSync(command).toString().trim();
if (type === 'nightly') { if (output !== tagName) {
const packageJson = JSON.parse(
readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
);
const versionParts = packageJson.version.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();
releaseVersion = `${major}.${nextMinor}.0-nightly.${date}.${gitShortHash}`;
npmTag = 'nightly';
previousReleaseTag = getLatestTag('contains("nightly")');
} else if (type === 'stable') {
const latestPreviewTag = getLatestTag('contains("preview")');
releaseVersion = latestPreviewTag
.replace(/-preview.*/, '')
.replace(/^v/, '');
npmTag = 'latest';
previousReleaseTag = getLatestTag(
'(contains("nightly") or contains("preview")) | not',
);
} else if (type === 'preview') {
const latestNightlyTag = getLatestTag('contains("nightly")');
releaseVersion =
latestNightlyTag.replace(/-nightly.*/, '').replace(/^v/, '') + '-preview';
npmTag = 'preview';
previousReleaseTag = getLatestTag('contains("preview")');
} else if (type === 'patch') {
const patchFrom = options.patchFrom || args.patchFrom;
if (!patchFrom || (patchFrom !== 'stable' && patchFrom !== 'preview')) {
throw new Error( throw new Error(
'Patch type must be specified with --patch-from=stable or --patch-from=preview', `Discrepancy found! NPM version ${tagName} is missing a corresponding GitHub release.`,
); );
} }
} catch (error) {
throw new Error(
`Discrepancy found! Failed to verify GitHub release for ${tagName}. Error: ${error.message}`,
);
}
}
if (patchFrom === 'stable') { function getAndVerifyTags(npmDistTag, gitTagPattern) {
previousReleaseTag = getLatestTag( const latestVersion = getVersionFromNPM(npmDistTag);
'(contains("nightly") or contains("preview")) | not', const latestTag = getLatestTag(gitTagPattern);
); if (`v${latestVersion}` !== latestTag) {
const versionParts = previousReleaseTag.replace(/^v/, '').split('.'); throw new Error(
const major = versionParts[0]; `Discrepancy found! NPM ${npmDistTag} tag (${latestVersion}) does not match latest git ${npmDistTag} tag (${latestTag}).`,
const minor = versionParts[1]; );
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0; }
releaseVersion = `${major}.${minor}.${patch + 1}`; verifyGitHubReleaseExists(latestTag);
npmTag = 'latest'; return { latestVersion, latestTag };
} else { }
// patchFrom === 'preview'
previousReleaseTag = getLatestTag('contains("preview")'); function getNightlyVersion() {
const [version, prerelease] = previousReleaseTag const { latestVersion, latestTag } = getAndVerifyTags(
.replace(/^v/, '') 'nightly',
.split('-'); 'v*-nightly*',
const versionParts = version.split('.'); );
const major = versionParts[0]; const baseVersion = latestVersion.split('-')[0];
const minor = versionParts[1]; const versionParts = baseVersion.split('.');
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0; const major = versionParts[0];
releaseVersion = `${major}.${minor}.${patch + 1}-${prerelease}`; const minor = versionParts[1] ? parseInt(versionParts[1]) : 0;
npmTag = 'preview'; 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',
previousReleaseTag: latestTag,
};
}
function getStableVersion() {
const { latestVersion, latestTag } = getAndVerifyTags(
'preview',
'v*-preview*',
);
return {
releaseVersion: latestVersion.replace(/-preview.*/, ''),
npmTag: 'latest',
previousReleaseTag: latestTag,
};
}
function getPreviewVersion() {
const { latestVersion, latestTag } = getAndVerifyTags(
'nightly',
'v*-nightly*',
);
return {
releaseVersion: latestVersion.replace(/-nightly.*/, '') + '-preview',
npmTag: 'preview',
previousReleaseTag: latestTag,
};
}
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 { latestVersion, latestTag } = getAndVerifyTags(distTag, pattern);
const [version, ...prereleaseParts] = latestVersion.split('-');
const prerelease = prereleaseParts.join('-');
const versionParts = version.split('.');
const major = versionParts[0];
const minor = versionParts[1];
const patch = versionParts[2] ? parseInt(versionParts[2]) : 0;
const releaseVersion = prerelease
? `${major}.${minor}.${patch + 1}-${prerelease}`
: `${major}.${minor}.${patch + 1}`;
return {
releaseVersion,
npmTag: distTag,
previousReleaseTag: latestTag,
};
}
export function getVersion(options = {}) {
const args = { ...getArgs(), ...options };
const type = args.type || 'nightly';
let versionData;
switch (type) {
case 'nightly':
versionData = getNightlyVersion();
break;
case 'stable':
versionData = getStableVersion();
break;
case 'preview':
versionData = getPreviewVersion();
break;
case 'patch':
versionData = getPatchVersion(args['patch-from']);
break;
default:
throw new Error(`Unknown release type: ${type}`);
} }
const releaseTag = `v${releaseVersion}`;
return { return {
releaseTag, releaseTag: `v${versionData.releaseVersion}`,
releaseVersion, ...versionData,
npmTag,
previousReleaseTag,
}; };
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) { if (process.argv[1] === fileURLToPath(import.meta.url)) {
console.log(JSON.stringify(getVersion(), null, 2)); console.log(JSON.stringify(getVersion(getArgs()), null, 2));
} }
+80 -83
View File
@@ -7,120 +7,117 @@
import { vi, describe, it, expect, beforeEach } from 'vitest'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import { getVersion } from '../get-release-version.js'; import { getVersion } from '../get-release-version.js';
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
vi.mock('node:child_process'); vi.mock('node:child_process');
vi.mock('node:fs');
describe('getReleaseVersion', () => { describe('getVersion', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
// Mock date to be consistent vi.setSystemTime(new Date('2025-09-17T00:00:00.000Z'));
vi.setSystemTime(new Date('2025-09-11T00:00:00.000Z'));
}); });
describe('Nightly Workflow Logic', () => { const mockExecSync = (command) => {
it('should calculate the next nightly version based on package.json', () => { // NPM Mocks
vi.mocked(readFileSync).mockReturnValue('{"version": "0.5.0"}'); if (command.includes('npm view') && command.includes('--tag=latest'))
vi.mocked(execSync).mockImplementation((command) => { return '0.4.1';
if (command.includes('rev-parse')) return 'a1b2c3d'; if (command.includes('npm view') && command.includes('--tag=preview'))
if (command.includes('release list')) return '0.5.0-preview-2';
return 'v0.5.0-nightly.20250910.abcdef'; if (command.includes('npm view') && command.includes('--tag=nightly'))
return ''; return '0.6.0-nightly.20250910.a31830a3';
});
const result = getVersion({ type: 'nightly' }); // Git Tag Mocks
if (command.includes("git tag --sort=-creatordate -l 'v[0-9].[0-9].[0-9]'"))
return 'v0.4.1';
if (command.includes("git tag --sort=-creatordate -l 'v*-preview*'"))
return 'v0.5.0-preview-2';
if (command.includes("git tag --sort=-creatordate -l 'v*-nightly*'"))
return 'v0.6.0-nightly.20250910.a31830a3';
expect(result.releaseVersion).toBe('0.6.0-nightly.20250911.a1b2c3d'); // GitHub Release Mocks
expect(result.npmTag).toBe('nightly'); if (command.includes('gh release view "v0.4.1"')) return 'v0.4.1';
expect(result.previousReleaseTag).toBe('v0.5.0-nightly.20250910.abcdef'); if (command.includes('gh release view "v0.5.0-preview-2"'))
}); return 'v0.5.0-preview-2';
if (command.includes('gh release view "v0.6.0-nightly.20250910.a31830a3"'))
return 'v0.6.0-nightly.20250910.a31830a3';
it('should default minor to 0 if missing in package.json version', () => { // Git Hash Mock
vi.mocked(readFileSync).mockReturnValue('{"version": "0"}'); if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d';
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('rev-parse')) return 'a1b2c3d';
if (command.includes('release list'))
return 'v0.0.0-nightly.20250910.abcdef';
return '';
});
const result = getVersion({ type: 'nightly' }); return '';
};
expect(result.releaseVersion).toBe('0.1.0-nightly.20250911.a1b2c3d');
expect(result.npmTag).toBe('nightly');
expect(result.previousReleaseTag).toBe('v0.0.0-nightly.20250910.abcdef');
});
});
describe('Promote Workflow Logic', () => {
it('should calculate stable version from the latest preview tag', () => {
const latestPreview = 'v0.5.0-preview';
const latestStable = 'v0.4.0';
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('not')) return latestStable;
if (command.includes('contains("preview")')) return latestPreview;
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' }); const result = getVersion({ type: 'stable' });
expect(result.releaseVersion).toBe('0.5.0'); expect(result.releaseVersion).toBe('0.5.0');
expect(result.npmTag).toBe('latest'); expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe(latestStable); expect(result.previousReleaseTag).toBe('v0.5.0-preview-2');
}); });
it('should calculate preview version from the latest nightly tag', () => { it('should calculate the next preview version from the latest nightly', () => {
const latestNightly = 'v0.6.0-nightly.20250910.abcdef'; vi.mocked(execSync).mockImplementation(mockExecSync);
const latestPreview = 'v0.5.0-preview';
vi.mocked(execSync).mockImplementation((command) => {
if (command.includes('nightly')) return latestNightly;
if (command.includes('preview')) return latestPreview;
return '';
});
const result = getVersion({ type: 'preview' }); const result = getVersion({ type: 'preview' });
expect(result.releaseVersion).toBe('0.6.0-preview'); expect(result.releaseVersion).toBe('0.6.0-preview');
expect(result.npmTag).toBe('preview'); expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe(latestPreview); expect(result.previousReleaseTag).toBe(
'v0.6.0-nightly.20250910.a31830a3',
);
});
it('should calculate the next nightly version from the latest nightly', () => {
vi.mocked(execSync).mockImplementation(mockExecSync);
const result = getVersion({ type: 'nightly' });
expect(result.releaseVersion).toBe('0.7.0-nightly.20250917.d3bf8a3d');
expect(result.npmTag).toBe('nightly');
expect(result.previousReleaseTag).toBe(
'v0.6.0-nightly.20250910.a31830a3',
);
}); });
});
describe('Patch Workflow Logic', () => {
it('should calculate the next patch version for a stable release', () => { it('should calculate the next patch version for a stable release', () => {
const latestStable = 'v0.5.1'; vi.mocked(execSync).mockImplementation(mockExecSync);
vi.mocked(execSync).mockReturnValue(latestStable); const result = getVersion({ type: 'patch', 'patch-from': 'stable' });
expect(result.releaseVersion).toBe('0.4.2');
const result = getVersion({ type: 'patch', patchFrom: 'stable' });
expect(result.releaseVersion).toBe('0.5.2');
expect(result.npmTag).toBe('latest'); expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe(latestStable); expect(result.previousReleaseTag).toBe('v0.4.1');
}); });
it('should calculate the next patch version for a preview release', () => { it('should calculate the next patch version for a preview release', () => {
const latestPreview = 'v0.6.0-preview'; vi.mocked(execSync).mockImplementation(mockExecSync);
vi.mocked(execSync).mockReturnValue(latestPreview); const result = getVersion({ type: 'patch', 'patch-from': 'preview' });
expect(result.releaseVersion).toBe('0.5.1-preview-2');
const result = getVersion({ type: 'patch', patchFrom: 'preview' });
expect(result.releaseVersion).toBe('0.6.1-preview');
expect(result.npmTag).toBe('preview'); expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe(latestPreview); expect(result.previousReleaseTag).toBe('v0.5.0-preview-2');
});
});
describe('Failure Path - Discrepancy Checks', () => {
it('should throw an error if the git tag does not match npm', () => {
const mockWithMismatchGitTag = (command) => {
if (command.includes("git tag --sort=-creatordate -l 'v*-preview*'"))
return 'v0.4.0-preview-99'; // Mismatch
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithMismatchGitTag);
expect(() => getVersion({ type: 'stable' })).toThrow(
'Discrepancy found! NPM preview tag (0.5.0-preview-2) does not match latest git preview tag (v0.4.0-preview-99).',
);
}); });
it('should default patch to 0 if missing in stable release', () => { it('should throw an error if the GitHub release is missing', () => {
const latestStable = 'v0.5'; const mockWithMissingRelease = (command) => {
vi.mocked(execSync).mockReturnValue(latestStable); if (command.includes('gh release view "v0.5.0-preview-2"')) {
throw new Error('gh command failed'); // Simulate gh failure
}
return mockExecSync(command);
};
vi.mocked(execSync).mockImplementation(mockWithMissingRelease);
const result = getVersion({ type: 'patch', patchFrom: 'stable' }); expect(() => getVersion({ type: 'stable' })).toThrow(
'Discrepancy found! Failed to verify GitHub release for v0.5.0-preview-2.',
expect(result.releaseVersion).toBe('0.5.1'); );
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe(latestStable);
}); });
}); });
}); });