From c99539b9910c33c1b63b0ac43b32638f7e49d17e Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 12 Sep 2025 10:22:10 -0700 Subject: [PATCH] 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 --- .github/actions/publish-release/action.yml | 67 ++++++++- .github/actions/run-tests/action.yml | 24 +++ .github/workflows/nightly-release.yml | 13 +- .github/workflows/patch-release.yml | 94 ++++++++++++ .github/workflows/promote-release.yml | 45 ++---- docs/releases.md | 52 ++++--- package.json | 1 + scripts/get-release-version.js | 37 ++++- scripts/lint.js | 161 +++++++++++++++++++++ scripts/tests/get-release-version.test.js | 81 ++++++++--- 10 files changed, 489 insertions(+), 86 deletions(-) create mode 100644 .github/actions/run-tests/action.yml create mode 100644 .github/workflows/patch-release.yml create mode 100644 scripts/lint.js diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index d1ad07087d..6eb2413671 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -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' diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml new file mode 100644 index 0000000000..45bd5a9235 --- /dev/null +++ b/.github/actions/run-tests/action.yml @@ -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' diff --git a/.github/workflows/nightly-release.yml b/.github/workflows/nightly-release.yml index 37fb6c3a70..91f081b7f0 100644 --- a/.github/workflows/nightly-release.yml +++ b/.github/workflows/nightly-release.yml @@ -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 }}' diff --git a/.github/workflows/patch-release.yml b/.github/workflows/patch-release.yml new file mode 100644 index 0000000000..9a05bfad76 --- /dev/null +++ b/.github/workflows/patch-release.yml @@ -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 }}' diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 0ae7446614..2fd97bdc8b 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -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' diff --git a/docs/releases.md b/docs/releases.md index 5741335b7a..769b2a6946 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -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**. diff --git a/package.json b/package.json index 7cf538f940..44b24d611d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/get-release-version.js b/scripts/get-release-version.js index fd9a7608e8..5adeb38491 100644 --- a/scripts/get-release-version.js +++ b/scripts/get-release-version.js @@ -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}`; diff --git a/scripts/lint.js b/scripts/lint.js new file mode 100644 index 0000000000..c4acda92b6 --- /dev/null +++ b/scripts/lint.js @@ -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(); diff --git a/scripts/tests/get-release-version.test.js b/scripts/tests/get-release-version.test.js index d21c062f32..c1be3ad4f0 100644 --- a/scripts/tests/get-release-version.test.js +++ b/scripts/tests/get-release-version.test.js @@ -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); + }); + }); });