From 1a6e4a119ed2684e740816d49072f328209dd9ca Mon Sep 17 00:00:00 2001 From: matt korwel Date: Tue, 16 Sep 2025 23:47:05 -0700 Subject: [PATCH] Release Promotion Clean up (#8597) --- .github/actions/publish-release/action.yml | 8 +- .github/actions/run-tests/action.yml | 13 +- .github/workflows/nightly-release.yml | 6 + .github/workflows/promote-release.yml | 97 ++++++++-- docs/releases.md | 13 ++ scripts/get-release-version.js | 202 +++++++++++++-------- scripts/tests/get-release-version.test.js | 163 ++++++++--------- 7 files changed, 322 insertions(+), 180 deletions(-) diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 99fc56910b..d40bdf5368 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -64,7 +64,13 @@ runs: DRY_RUN: '${{ inputs.dry-run }}' RELEASE_TAG: '${{ inputs.release-tag }}' 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}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 45bd5a9235..149f79de99 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -5,20 +5,27 @@ inputs: force_skip_tests: description: 'Whether to force skip the tests.' required: false - default: 'false' + type: 'boolean' + default: false gemini_api_key: description: 'The API key for running integration tests.' required: true + working-directory: + description: 'The working directory to run the tests in.' + required: false + default: '.' runs: using: 'composite' steps: - name: 'Run Tests' - if: "inputs.force_skip_tests != 'true'" + if: '${{ !inputs.force_skip_tests }}' env: GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + working-directory: '${{ inputs.working-directory }}' run: |- - npm run preflight + npm run build + npm run test:ci npm run test:integration:sandbox:none npm run test:integration:sandbox:docker shell: 'bash' diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 91f081b7f0..f858fe0a92 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -15,6 +15,11 @@ on: required: false type: 'boolean' default: false + ref: + description: 'The branch, tag, or SHA to release from.' + required: false + type: 'string' + default: 'main' jobs: release: @@ -23,6 +28,7 @@ jobs: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: + ref: '${{ github.event.inputs.ref }}' fetch-depth: 0 - name: 'Setup Node.js' diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 41dac3b1f0..7db2d6ed03 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -13,6 +13,11 @@ on: required: false type: 'boolean' default: false + ref: + description: 'The branch, tag, or SHA to release from.' + required: false + type: 'string' + default: 'main' jobs: calculate-versions: @@ -26,12 +31,14 @@ jobs: PREVIEW_SHA: '${{ steps.versions.outputs.PREVIEW_SHA }}' PREVIOUS_PREVIEW_TAG: '${{ steps.versions.outputs.PREVIOUS_PREVIEW_TAG }}' NEXT_NIGHTLY_VERSION: '${{ steps.versions.outputs.NEXT_NIGHTLY_VERSION }}' + PREVIOUS_NIGHTLY_TAG: '${{ steps.versions.outputs.PREVIOUS_NIGHTLY_TAG }}' steps: - name: 'Checkout' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: fetch-depth: 0 + fetch-tags: true - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' @@ -57,14 +64,73 @@ jobs: echo "PREVIOUS_STABLE_TAG=$(echo "${STABLE_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}" echo "PREVIEW_VERSION=$(echo "${PREVIEW_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}" # 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 "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: 'Promote to ${{ matrix.channel }}' + - name: 'Display Pending Updates' + 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' 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: contents: 'write' packages: 'write' @@ -83,16 +149,17 @@ jobs: previous-tag: '${{ needs.calculate-versions.outputs.PREVIOUS_PREVIEW_TAG }}' steps: - - name: 'Checkout main' + - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: - ref: 'main' + 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' @@ -104,12 +171,6 @@ jobs: 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 }}' - - name: 'Publish Release' uses: './.github/actions/publish-release' with: @@ -125,16 +186,16 @@ jobs: nightly-pr: name: 'Create Nightly PR' - needs: 'calculate-versions' + needs: ['calculate-versions', 'test'] runs-on: 'ubuntu-latest' permissions: contents: 'write' pull-requests: 'write' steps: - - name: 'Checkout main' + - name: 'Checkout Ref' uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: - ref: 'main' + ref: '${{ github.event.inputs.ref }}' - name: 'Setup Node.js' uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' @@ -165,7 +226,13 @@ jobs: BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' DRY_RUN: '${{ github.event.inputs.dry_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 }}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." diff --git a/docs/releases.md b/docs/releases.md index 769b2a6946..6425fae616 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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. +### 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 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. diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index 5adeb38491..b467b5be98 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -8,11 +8,6 @@ import { execSync } from 'node:child_process'; 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() { const args = {}; @@ -26,96 +21,147 @@ function getArgs() { } 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 { return execSync(command).toString().trim(); } catch { - // Suppress error output for cleaner test failures return ''; } } -export function getVersion(options = {}) { - const args = getArgs(); - const type = options.type || args.type || 'nightly'; +function getVersionFromNPM(distTag) { + const command = `npm view @google/gemini-cli version --tag=${distTag}`; + try { + return execSync(command).toString().trim(); + } catch { + return ''; + } +} - let releaseVersion; - let npmTag; - let previousReleaseTag; - - if (type === 'nightly') { - 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')) { +function verifyGitHubReleaseExists(tagName) { + const command = `gh release view "${tagName}" --json tagName --jq .tagName`; + try { + const output = execSync(command).toString().trim(); + if (output !== tagName) { 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') { - previousReleaseTag = getLatestTag( - '(contains("nightly") or contains("preview")) | not', - ); - const versionParts = previousReleaseTag.replace(/^v/, '').split('.'); - const major = versionParts[0]; - const minor = versionParts[1]; - const patch = versionParts[2] ? parseInt(versionParts[2]) : 0; - releaseVersion = `${major}.${minor}.${patch + 1}`; - npmTag = 'latest'; - } else { - // patchFrom === 'preview' - previousReleaseTag = getLatestTag('contains("preview")'); - const [version, prerelease] = previousReleaseTag - .replace(/^v/, '') - .split('-'); - const versionParts = version.split('.'); - const major = versionParts[0]; - const minor = versionParts[1]; - const patch = versionParts[2] ? parseInt(versionParts[2]) : 0; - releaseVersion = `${major}.${minor}.${patch + 1}-${prerelease}`; - npmTag = 'preview'; - } +function getAndVerifyTags(npmDistTag, gitTagPattern) { + const latestVersion = getVersionFromNPM(npmDistTag); + const latestTag = getLatestTag(gitTagPattern); + if (`v${latestVersion}` !== latestTag) { + throw new Error( + `Discrepancy found! NPM ${npmDistTag} tag (${latestVersion}) does not match latest git ${npmDistTag} tag (${latestTag}).`, + ); + } + verifyGitHubReleaseExists(latestTag); + return { latestVersion, latestTag }; +} + +function getNightlyVersion() { + const { latestVersion, latestTag } = getAndVerifyTags( + 'nightly', + 'v*-nightly*', + ); + const baseVersion = 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', + 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 { - releaseTag, - releaseVersion, - npmTag, - previousReleaseTag, + releaseTag: `v${versionData.releaseVersion}`, + ...versionData, }; } if (process.argv[1] === fileURLToPath(import.meta.url)) { - console.log(JSON.stringify(getVersion(), null, 2)); + console.log(JSON.stringify(getVersion(getArgs()), null, 2)); } diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index c1be3ad4f0..b40955249e 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -7,120 +7,117 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { getVersion } from '../get-release-version.js'; import { execSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; vi.mock('node:child_process'); -vi.mock('node:fs'); -describe('getReleaseVersion', () => { +describe('getVersion', () => { beforeEach(() => { vi.resetAllMocks(); - // Mock date to be consistent - vi.setSystemTime(new Date('2025-09-11T00:00:00.000Z')); + vi.setSystemTime(new Date('2025-09-17T00:00:00.000Z')); }); - describe('Nightly Workflow Logic', () => { - it('should calculate the next nightly version based on package.json', () => { - vi.mocked(readFileSync).mockReturnValue('{"version": "0.5.0"}'); - vi.mocked(execSync).mockImplementation((command) => { - if (command.includes('rev-parse')) return 'a1b2c3d'; - if (command.includes('release list')) - return 'v0.5.0-nightly.20250910.abcdef'; - return ''; - }); + const mockExecSync = (command) => { + // NPM Mocks + if (command.includes('npm view') && command.includes('--tag=latest')) + return '0.4.1'; + if (command.includes('npm view') && command.includes('--tag=preview')) + return '0.5.0-preview-2'; + if (command.includes('npm view') && command.includes('--tag=nightly')) + 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'); - expect(result.npmTag).toBe('nightly'); - expect(result.previousReleaseTag).toBe('v0.5.0-nightly.20250910.abcdef'); - }); + // GitHub Release Mocks + if (command.includes('gh release view "v0.4.1"')) return 'v0.4.1'; + 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', () => { - vi.mocked(readFileSync).mockReturnValue('{"version": "0"}'); - 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 ''; - }); + // Git Hash Mock + if (command.includes('git rev-parse --short HEAD')) return 'd3bf8a3d'; - const result = getVersion({ type: 'nightly' }); - - 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 ''; - }); + 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.5.0'); 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', () => { - const latestNightly = 'v0.6.0-nightly.20250910.abcdef'; - const latestPreview = 'v0.5.0-preview'; - - vi.mocked(execSync).mockImplementation((command) => { - if (command.includes('nightly')) return latestNightly; - if (command.includes('preview')) return latestPreview; - return ''; - }); - + 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.6.0-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', () => { - const latestStable = 'v0.5.1'; - vi.mocked(execSync).mockReturnValue(latestStable); - - const result = getVersion({ type: 'patch', patchFrom: 'stable' }); - - expect(result.releaseVersion).toBe('0.5.2'); + vi.mocked(execSync).mockImplementation(mockExecSync); + const result = getVersion({ type: 'patch', 'patch-from': 'stable' }); + expect(result.releaseVersion).toBe('0.4.2'); 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', () => { - const latestPreview = 'v0.6.0-preview'; - vi.mocked(execSync).mockReturnValue(latestPreview); - - const result = getVersion({ type: 'patch', patchFrom: 'preview' }); - - expect(result.releaseVersion).toBe('0.6.1-preview'); + vi.mocked(execSync).mockImplementation(mockExecSync); + const result = getVersion({ type: 'patch', 'patch-from': 'preview' }); + expect(result.releaseVersion).toBe('0.5.1-preview-2'); 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', () => { - const latestStable = 'v0.5'; - vi.mocked(execSync).mockReturnValue(latestStable); + it('should throw an error if the GitHub release is missing', () => { + const mockWithMissingRelease = (command) => { + 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(result.releaseVersion).toBe('0.5.1'); - expect(result.npmTag).toBe('latest'); - expect(result.previousReleaseTag).toBe(latestStable); + expect(() => getVersion({ type: 'stable' })).toThrow( + 'Discrepancy found! Failed to verify GitHub release for v0.5.0-preview-2.', + ); }); }); });