feat: simplify patch release workflow (#8196)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Richie Foreman <richie.foreman@gmail.com>
This commit is contained in:
matt korwel
2025-09-12 10:22:10 -07:00
committed by GitHub
parent eaadc6d93d
commit c99539b991
10 changed files with 489 additions and 86 deletions

View File

@@ -19,9 +19,10 @@ inputs:
required: true
dry-run:
description: 'Whether to run in dry-run mode.'
type: 'boolean'
required: true
release-branch:
description: 'The branch to target for the release.'
release-tag:
description: 'The release tag for the release (e.g., v0.1.11).'
required: true
previous-tag:
description: 'The previous tag to use for generating release notes.'
@@ -34,6 +35,44 @@ inputs:
runs:
using: 'composite'
steps:
- name: 'Configure Git User'
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |-
git config user.name "gemini-cli-robot"
git config user.email "gemini-cli-robot@google.com"
- name: 'Create and switch to a release branch'
working-directory: '${{ inputs.working-directory }}'
id: 'release_branch'
shell: 'bash'
run: |
BRANCH_NAME="release/${{ inputs.release-tag }}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions'
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: 'npm run release:version "${{ inputs.release-version }}"'
- name: 'Commit and Conditionally Push package versions'
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
DRY_RUN: '${{ inputs.dry-run }}'
RELEASE_TAG: '${{ inputs.release-tag }}'
run: |-
git add package.json package-lock.json packages/*/package.json
git commit -m "chore(release): ${RELEASE_TAG}"
if [[ "${DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- name: 'Build and Prepare Packages'
working-directory: '${{ inputs.working-directory }}'
run: |-
@@ -61,7 +100,7 @@ runs:
- name: 'Install latest core package'
working-directory: '${{ inputs.working-directory }}'
if: '${{ inputs.dry-run == "false" }}'
if: '${{ inputs.dry-run == false }}'
run: |-
npm install "@google/gemini-cli-core@${{ inputs.release-version }}" \
--workspace="@google/gemini-cli" \
@@ -86,14 +125,28 @@ runs:
- name: 'Create GitHub Release'
working-directory: '${{ inputs.working-directory }}'
if: '${{ inputs.dry-run == "false" }}'
if: '${{ inputs.dry-run == false }}'
env:
GITHUB_TOKEN: '${{ inputs.github-token }}'
run: |-
gh release create "v${{ inputs.release-version }}" \
gh release create "${{ inputs.release-tag }}" \
bundle/gemini.js \
--target "${{ inputs.release-branch }}" \
--title "Release v${{ inputs.release-version }}" \
--target "${{ steps.release_branch.outputs.BRANCH_NAME }}" \
--title "Release ${{ inputs.release-tag }}" \
--notes-start-tag "${{ inputs.previous-tag }}" \
--generate-notes
shell: 'bash'
- name: 'Create Issue on Failure'
if: |-
${{ failure() }}
env:
GITHUB_TOKEN: '${{ inputs.github-token }}'
RELEASE_TAG: '${{ inputs.release-tag }} || "N/A"'
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
gh issue create \
--title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \
--body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \
--label "kind/bug,release-failure,priority/p0"
shell: 'bash'

24
.github/actions/run-tests/action.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: 'Run Tests'
description: 'Runs the preflight checks and integration tests.'
inputs:
force_skip_tests:
description: 'Whether to force skip the tests.'
required: false
default: 'false'
gemini_api_key:
description: 'The API key for running integration tests.'
required: true
runs:
using: 'composite'
steps:
- name: 'Run Tests'
if: "inputs.force_skip_tests != 'true'"
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
run: |-
npm run preflight
npm run test:integration:sandbox:none
npm run test:integration:sandbox:docker
shell: 'bash'

View File

@@ -10,6 +10,11 @@ on:
required: true
type: 'boolean'
default: true
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
jobs:
release:
@@ -29,6 +34,12 @@ jobs:
- name: 'Install Dependencies'
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: 'Get Nightly Version'
id: 'nightly_version'
env:
@@ -44,10 +55,10 @@ jobs:
uses: './.github/actions/publish-release'
with:
release-version: '${{ steps.nightly_version.outputs.RELEASE_VERSION }}'
release-tag: '${{ steps.nightly_version.outputs.RELEASE_TAG }}'
npm-tag: '${{ steps.nightly_version.outputs.NPM_TAG }}'
wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}'
wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}'
github-token: '${{ secrets.GITHUB_TOKEN }}'
dry-run: '${{ github.event.inputs.dry_run }}'
release-branch: 'main'
previous-tag: '${{ steps.nightly_version.outputs.PREVIOUS_TAG }}'

94
.github/workflows/patch-release.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: 'Patch Release'
on:
workflow_dispatch:
inputs:
type:
description: 'The type of release to patch from.'
required: true
type: 'choice'
options:
- 'stable'
- 'preview'
ref:
description: 'The branch or ref (full git sha) to release from.'
required: true
type: 'string'
default: 'main'
dry_run:
description: 'Run a dry-run of the release process; no branches, npm packages or GitHub releases will be created.'
required: true
type: 'boolean'
default: true
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
jobs:
release:
runs-on: 'ubuntu-latest'
environment:
name: 'production-release'
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ steps.version.outputs.RELEASE_TAG }}'
if: |-
${{ github.repository == 'google-gemini/gemini-cli' }}
permissions:
contents: 'write'
packages: 'write'
id-token: 'write'
issues: 'write' # For creating issues on failure
outputs:
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
ref: '${{ github.event.inputs.ref || github.sha }}'
fetch-depth: 0
- name: 'Setup Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install Dependencies'
run: |-
npm ci
- name: 'Get the version'
id: 'version'
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |-
VERSION_JSON="$(node scripts/get-release-version.js --type=patch --patch-from=${{ github.event.inputs.type }})"
echo "${VERSION_JSON}"
echo "RELEASE_TAG=$(echo "${VERSION_JSON}" | jq -r .releaseTag)" >> "${GITHUB_OUTPUT}"
echo "RELEASE_VERSION=$(echo "${VERSION_JSON}" | jq -r .releaseVersion)" >> "${GITHUB_OUTPUT}"
echo "NPM_TAG=$(echo "${VERSION_JSON}" | jq -r .npmTag)" >> "${GITHUB_OUTPUT}"
echo "PREVIOUS_TAG=$(echo "${VERSION_JSON}" | jq -r .previousReleaseTag)" >> "${GITHUB_OUTPUT}"
- name: 'Print Calculated Version'
run: |-
echo "Calculated version: ${{ steps.version.outputs.RELEASE_VERSION }}"
- 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:
release-version: '${{ steps.version.outputs.RELEASE_VERSION }}'
release-tag: '${{ steps.version.outputs.RELEASE_TAG }}'
npm-tag: '${{ steps.version.outputs.NPM_TAG }}'
wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}'
wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}'
github-token: '${{ secrets.GITHUB_TOKEN }}'
dry-run: '${{ github.event.inputs.dry_run }}'
previous-tag: '${{ steps.version.outputs.PREVIOUS_TAG }}'

View File

@@ -8,6 +8,11 @@ on:
required: true
type: 'boolean'
default: true
force_skip_tests:
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
required: false
type: 'boolean'
default: false
jobs:
calculate-versions:
@@ -99,50 +104,22 @@ jobs:
working-directory: './release'
run: 'npm ci'
- name: 'Configure Git User'
working-directory: './release'
run: |-
git config user.name "gemini-cli-robot"
git config user.email "gemini-cli-robot@google.com"
- name: 'Create and switch to a release branch'
working-directory: './release'
id: 'release_branch'
run: |
BRANCH_NAME="release/v${{ matrix.version }}"
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
- name: 'Update package versions'
working-directory: './release'
run: 'npm run release:version "${{ matrix.version }}"'
- name: 'Commit and Conditionally Push package versions'
working-directory: './release'
env:
BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
DRY_RUN: '${{ github.event.inputs.dry_run }}'
RELEASE_TAG: 'v${{ matrix.version }}'
run: |-
git add package.json package-lock.json packages/*/package.json
git commit -m "chore(release): ${RELEASE_TAG}"
if [[ "${DRY_RUN}" == "false" ]]; then
echo "Pushing release branch to remote..."
git push --set-upstream origin "${BRANCH_NAME}" --follow-tags
else
echo "Dry run enabled. Skipping push."
fi
- 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:
release-version: '${{ matrix.version }}'
release-tag: 'v${{ matrix.version }}'
npm-tag: '${{ matrix.npm-tag }}'
wombat-token-core: '${{ secrets.WOMBAT_TOKEN_CORE }}'
wombat-token-cli: '${{ secrets.WOMBAT_TOKEN_CLI }}'
github-token: '${{ secrets.GITHUB_TOKEN }}'
dry-run: '${{ github.event.inputs.dry_run }}'
release-branch: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
previous-tag: '${{ matrix.previous-tag }}'
working-directory: './release'

View File

@@ -28,9 +28,7 @@ npm install -g @google/gemini-cli@latest
npm install -g @google/gemini-cli@nightly
```
# Release Process.
Where `x.y.z` is the next version to be released. In most all cases for the weekly release this will be an increment on `y`, aka minor version update. Major version updates `x` will need broader coordination and communication. For patches `z` see below. When possible we will do our best to adher to https://semver.org/
# Release Process
Our release cadence is new releases are sent to a preview channel for a week and then promoted to stable after a week. Version numbers will follow SemVer with weekly releases incrementing the minor version. Patches and bug fixes to both preview and stable releases will increment the patch version.
@@ -38,23 +36,15 @@ Our release cadence is new releases are sent to a preview channel for a week and
Each night at UTC 0000 we will auto deploy a nightly release from `main`. This will be a version of the next production release, x.y.z, with the nightly tag.
## Create Preview Release
## Weekly Release Promotion
Each Tuesday at UTC 2359 we will auto deploy a preview release of the next production release x.y.z.
Each Tuesday, the on-call engineer will trigger the "Promote Release" workflow. This single action automates the entire weekly release process:
- This will happen as a scheduled instance of the release action. It will be cut off of Main.
- This will create a branch `release/vx.y.z-preview.n`
- We will run evals and smoke testing against this branch and the npm package. For now this should be manual smoke testing, we don't have a dedicated matrix or specific detailed process. There is work coming soon to make this more formalized and automatic see https://github.com/google-gemini/gemini-cli/issues/3788
- Users installing `@preview` will get this release as well
1. **Promotes Preview to Stable:** The workflow identifies the latest `preview` release and promotes it to `stable`. This becomes the new `latest` version on npm.
2. **Promotes Nightly to Preview:** The latest `nightly` release is then promoted to become the new `preview` version.
3. **Prepares for next Nightly:** A pull request is automatically created and merged to bump the version in `main` in preparation for the next nightly release.
## Promote Stable Release
After one week (On the following Tuesday) with all signals a go, we will manually release at 2000 UTC via the current on-call person.
- The release action will be used with the source branch as `release/vx.y.z-preview.n`
- The version will be x.y.z
- The releaser will create and merge a pr into main with the version changes.
- Smoke tests and manual validation will be run. For now this should be manual smoke testing, we don't have a dedicated matrix or specific detailed process. There is work coming soon to make this more formalized and automatic see https://github.com/google-gemini/gemini-cli/issues/3788
This process ensures a consistent and reliable release cadence with minimal manual intervention.
## Patching Releases
@@ -79,7 +69,11 @@ The workflow will automatically find the merge commit SHA and begin the patch pr
**Option B: Manually Triggering the Workflow**
Navigate to the **Actions** tab and run the **Create Patch PR** workflow.
Follow the manual release process using the "Patch Release" GitHub Actions workflow.
- **Type**: Select whether you are patching a `stable` or `preview` release. The workflow will automatically calculate the next patch version.
- **Ref**: Use your source branch as the reference (ex. `release/v0.2.0-preview.0`)
Navigate to the **Actions** tab and run the **Create Patch PR** workflow.
- **Commit**: The full SHA of the commit on `main` that you want to cherry-pick.
- **Channel**: The channel you want to patch (`stable` or `preview`).
@@ -174,14 +168,28 @@ This fully automated process ensures that patches are created and released consi
## How To Release
Releases are managed through the [release.yml](https://github.com/google-gemini/gemini-cli/actions/workflows/release.yml) GitHub Actions workflow. To perform a manual release for a patch or hotfix:
Releases are managed through GitHub Actions workflows.
### Weekly Promotions
To perform the weekly promotion of `preview` to `stable` and `nightly` to `preview`:
1. Navigate to the **Actions** tab of the repository.
2. Select the **Release** workflow from the list.
2. Select the **Promote Release** workflow from the list.
3. Click the **Run workflow** dropdown button.
4. Leave **Dry Run** as `true` to test the workflow without publishing, or set to `false` to perform a live release.
5. Click **Run workflow**.
### Patching a Release
To perform a manual release for a patch or hotfix:
1. Navigate to the **Actions** tab of the repository.
2. Select the **Patch Release** workflow from the list.
3. Click the **Run workflow** dropdown button.
4. Fill in the required inputs:
- **Version**: The exact version to release (e.g., `v0.2.1`).
- **Ref**: The branch or commit SHA to release from (defaults to `main`).
- **Type**: Select whether you are patching a `stable` or `preview` release.
- **Ref**: The branch or commit SHA to release from.
- **Dry Run**: Leave as `true` to test the workflow without publishing, or set to `false` to perform a live release.
5. Click **Run workflow**.

View File

@@ -42,6 +42,7 @@
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
"lint:ci": "eslint . --ext .ts,.tsx --max-warnings 0 && eslint integration-tests --max-warnings 0",
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",

View File

@@ -47,8 +47,10 @@ export function getVersion(options = {}) {
const packageJson = JSON.parse(
readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'),
);
const [major, minor] = packageJson.version.split('.');
const nextMinor = parseInt(minor) + 1;
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()
@@ -71,6 +73,37 @@ export function getVersion(options = {}) {
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(
'Patch type must be specified with --patch-from=stable or --patch-from=preview',
);
}
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';
}
}
const releaseTag = `v${releaseVersion}`;

161
scripts/lint.js Normal file
View File

@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
import { mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const ACTIONLINT_VERSION = '1.7.7';
const SHELLCHECK_VERSION = '0.11.0';
const YAMLLINT_VERSION = '1.35.1';
const TEMP_DIR = join(tmpdir(), 'gemini-cli-linters');
function getPlatformArch() {
const platform = process.platform;
const arch = process.arch;
if (platform === 'linux' && arch === 'x64') {
return {
actionlint: 'linux_amd64',
shellcheck: 'linux.x86_64',
};
}
if (platform === 'darwin' && arch === 'x64') {
return {
actionlint: 'darwin_amd64',
shellcheck: 'darwin.x86_64',
};
}
if (platform === 'darwin' && arch === 'arm64') {
return {
actionlint: 'darwin_arm64',
shellcheck: 'darwin.aarch64',
};
}
throw new Error(`Unsupported platform/architecture: ${platform}/${arch}`);
}
const platformArch = getPlatformArch();
/**
* @typedef {{
* check: string;
* installer: string;
* run: string;
* }} Linter
*/
/**
* @type {{[linterName: string]: Linter}}
*/
const LINTERS = {
actionlint: {
check: 'command -v actionlint',
installer: `
mkdir -p "${TEMP_DIR}/actionlint"
curl -sSLo "${TEMP_DIR}/.actionlint.tgz" "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_${platformArch.actionlint}.tar.gz"
tar -xzf "${TEMP_DIR}/.actionlint.tgz" -C "${TEMP_DIR}/actionlint"
`,
run: `
actionlint \
-color \
-ignore 'SC2002:' \
-ignore 'SC2016:' \
-ignore 'SC2129:' \
-ignore 'label ".+" is unknown'
`,
},
shellcheck: {
check: 'command -v shellcheck',
installer: `
mkdir -p "${TEMP_DIR}/shellcheck"
curl -sSLo "${TEMP_DIR}/.shellcheck.txz" "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.${platformArch.shellcheck}.tar.xz"
tar -xf "${TEMP_DIR}/.shellcheck.txz" -C "${TEMP_DIR}/shellcheck" --strip-components=1
`,
run: `
git ls-files | grep -E '^([^.]+|.*\\.(sh|zsh|bash))' | xargs file --mime-type \
| grep "text/x-shellscript" | awk '{ print substr($1, 1, length($1)-1) }' \
| xargs shellcheck \
--check-sourced \
--enable=all \
--exclude=SC2002,SC2129,SC2310 \
--severity=style \
--format=gcc \
--color=never | sed -e 's/note:/warning:/g' -e 's/style:/warning:/g'
`,
},
yamllint: {
check: 'command -v yamllint',
installer: `pip3 install --user "yamllint==${YAMLLINT_VERSION}"`,
run: "git ls-files | grep -E '\\.(yaml|yml)' | xargs yamllint --format github",
},
};
function runCommand(command, stdio = 'inherit') {
try {
const env = { ...process.env };
env.PATH = `${TEMP_DIR}/actionlint:${TEMP_DIR}/shellcheck:${env.PATH}`;
if (process.platform === 'darwin') {
env.PATH = `${env.PATH}:${process.env.HOME}/Library/Python/3.12/bin`;
} else if (process.platform === 'linux') {
env.PATH = `${env.PATH}:${process.env.HOME}/.local/bin`;
}
execSync(command, { stdio, env });
return true;
} catch (_e) {
return false;
}
}
function setupLinters() {
console.log('Setting up linters...');
rmSync(TEMP_DIR, { recursive: true, force: true });
mkdirSync(TEMP_DIR, { recursive: true });
for (const linter in LINTERS) {
const { check, installer } = LINTERS[linter];
if (!runCommand(check, 'ignore')) {
console.log(`Installing ${linter}...`);
if (!runCommand(installer)) {
console.error(
`Failed to install ${linter}. Please install it manually.`,
);
process.exit(1);
}
}
}
console.log('All required linters are available.');
}
function runLinters() {
console.log('\nRunning ESLint...');
if (!runCommand('npm run lint:ci')) {
process.exit(1);
}
console.log('\nRunning actionlint...');
if (!runCommand(LINTERS.actionlint.run)) {
process.exit(1);
}
console.log('\nRunning shellcheck...');
if (!runCommand(LINTERS.shellcheck.run)) {
process.exit(1);
}
console.log('\nRunning yamllint...');
if (!runCommand(LINTERS.yamllint.run)) {
process.exit(1);
}
console.log('\nAll linting checks passed!');
}
setupLinters();
runLinters();

View File

@@ -7,28 +7,11 @@
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');
vi.mock('../get-release-version.js', async () => {
const actual = await vi.importActual('../get-release-version.js');
return {
...actual,
getVersion: (options) => {
if (options.type === 'nightly') {
return {
releaseTag: 'v0.6.0-nightly.20250911.a1b2c3d',
releaseVersion: '0.6.0-nightly.20250911.a1b2c3d',
npmTag: 'nightly',
previousReleaseTag: 'v0.5.0-nightly.20250910.abcdef',
};
}
return actual.getVersion(options);
},
};
});
describe('getReleaseVersion', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -37,14 +20,37 @@ describe('getReleaseVersion', () => {
});
describe('Nightly Workflow Logic', () => {
it('should calculate the next nightly version based on package.json', async () => {
const { getVersion } = await import('../get-release-version.js');
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 result = getVersion({ type: 'nightly' });
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');
});
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 '';
});
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', () => {
@@ -82,4 +88,39 @@ describe('getReleaseVersion', () => {
expect(result.previousReleaseTag).toBe(latestPreview);
});
});
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');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe(latestStable);
});
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');
expect(result.npmTag).toBe('preview');
expect(result.previousReleaseTag).toBe(latestPreview);
});
it('should default patch to 0 if missing in stable release', () => {
const latestStable = 'v0.5';
vi.mocked(execSync).mockReturnValue(latestStable);
const result = getVersion({ type: 'patch', patchFrom: 'stable' });
expect(result.releaseVersion).toBe('0.5.1');
expect(result.npmTag).toBe('latest');
expect(result.previousReleaseTag).toBe(latestStable);
});
});
});