From 62da5c0b1499c30d924a06df57ec7e04b80c40b9 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 15 Jun 2026 15:12:38 +0900 Subject: [PATCH] ci: setup PR-driven release pipeline [backport to v4] (#10585) Co-authored-by: Hiroshi Ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Codex --- .github/workflows/prepare-publish.yml | 99 ++++++++++++++++++++++ .github/workflows/publish.yml | 113 ++++++++++++++++++++++---- package.json | 2 + pnpm-lock.yaml | 54 ++++++++---- pnpm-workspace.yaml | 2 + scripts/publish-ci.ts | 89 +++++++++++++------- scripts/release.ts | 27 +++--- 7 files changed, 317 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/prepare-publish.yml diff --git a/.github/workflows/prepare-publish.yml b/.github/workflows/prepare-publish.yml new file mode 100644 index 000000000..615ccd89e --- /dev/null +++ b/.github/workflows/prepare-publish.yml @@ -0,0 +1,99 @@ +name: Prepare Publish + +on: + workflow_dispatch: + inputs: + target_branch: + description: Release target branch. + required: true + default: main + type: string + release: + description: Bumpp release type. + required: true + default: next + type: choice + options: + - next + - patch + - minor + - major + - prepatch + - preminor + - premajor + version: + description: Specify exact version instead of bumpp release type + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ inputs.target_branch }}-${{ inputs.version || inputs.release }} + cancel-in-progress: false + +permissions: {} + +jobs: + prepare: + if: github.repository == 'vitest-dev/vitest' + name: Prepare release PR + runs-on: ubuntu-latest + permissions: + contents: read # checkout target branch + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + + - name: Set node version to 24 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + package-manager-cache: false + + - name: Install + run: pnpm install --frozen-lockfile --prefer-offline + env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + + - name: Create branch and update version + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + RELEASE_TYPE: ${{ inputs.release }} + RELEASE_VERSION: ${{ inputs.version }} + RUN_ID: ${{ github.run_id }} + run: | + RELEASE_INPUT="${RELEASE_VERSION:-$RELEASE_TYPE}" + PREPARE_BRANCH="prepare-$TARGET_BRANCH-$RELEASE_INPUT-$RUN_ID" + git switch -c "$PREPARE_BRANCH" + # The numeric prefix comes from `gh api /users/vitest-release-bot%5Bbot%5D --jq .id`. + # This is just to make the avatar in the commit look pretty. + git config user.name "vitest-release-bot[bot]" + git config user.email "292707936+vitest-release-bot[bot]@users.noreply.github.com" + pnpm run release + + - id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_GITHUB_APP_ID }} + private-key: ${{ secrets.RELEASE_GITHUB_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + + - name: Open release PR + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + PREPARE_BRANCH="$(git branch --show-current)" + VERSION="$(jq -r .version package.json)" + git push -u "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" HEAD + gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$PREPARE_BRANCH" \ + --title "chore: release v$VERSION" \ + --body "Release PR generated by the Prepare Publish workflow." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 03029c8d5..8dbecf4c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,36 +2,84 @@ name: Publish Package on: push: - tags: - - 'v*' + branches: + - main + - 'v[0-9]*' -permissions: - contents: write - id-token: write +permissions: {} env: VITEST_GENERATE_UI_TOKEN: 'true' VITE_TEST_WATCHER_DEBUG: 'false' jobs: - publish: + # Keep detection outside the Release environment. Environment approval is job-level, + # so the publish job is only created after a release commit is detected. + detect: if: github.repository == 'vitest-dev/vitest' + name: Detect release commit + runs-on: ubuntu-slim + outputs: + release: ${{ steps.detect.outputs.release }} + version: ${{ steps.detect.outputs.version }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Detect release + id: detect + run: | + SUBJECT="$(git log -1 --format=%s)" + VERSION="$(jq -r .version package.json)" + EXPECTED="chore: release v$VERSION" + # use prefix match since commit has PR number trailer. + if [[ "$SUBJECT" == "$EXPECTED"* ]]; then + echo "release=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Detected release v$VERSION" + else + echo "release=false" >> "$GITHUB_OUTPUT" + echo "No release commit found at HEAD" + fi + + publish: + if: needs.detect.outputs.release == 'true' + needs: detect + name: Publish Vitest runs-on: ubuntu-latest + permissions: + contents: write # trusted publishing and changelog requirement + id-token: write # trusted publishing requirement environment: Release steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - - name: Install pnpm - uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + - name: Check release tag + env: + VERSION: ${{ needs.detect.outputs.version }} + run: | + TAG="v$VERSION" + + if git rev-parse --verify --quiet "refs/tags/$TAG"; then + echo "Tag $TAG already exists" + exit 1 + fi - - name: Set node version to 20 + - name: Set node version to 24 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: 20 + node-version: 24 registry-url: https://registry.npmjs.org/ - cache: pnpm + # disable cache to avoid cache poisoning + package-manager-cache: false + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 - name: Install run: pnpm install --frozen-lockfile --prefer-offline @@ -41,10 +89,47 @@ jobs: - name: Build run: pnpm build - - name: Publish to npm - run: npm i -g npm@^11.5.2 && pnpm run publish-ci ${{ github.ref_name }} + - name: Install pnpm for staged publishing + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 + with: + version: 11.6.0 + # Do not read package.json's pnpm@10 packageManager field when installing pnpm 11. + package_json_file: .github/pnpm-publish-package.json + + - name: Stage publish to npm (dry run) + env: + VERSION: ${{ needs.detect.outputs.version }} + PUBLISH_BRANCH: ${{ github.ref_name }} + PUBLISH_DRY_RUN: 'true' + # Keep pnpm 11 instead of switching to package.json's pnpm@10 during staged publishing. + PNPM_CONFIG_PM_ON_FAIL: ignore + # Avoid pnpm 11 rerunning install before pnpm run, which can fail on unapproved builds. + PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: 'false' + run: pnpm run publish-ci "$VERSION" + + - name: Stage publish to npm + env: + VERSION: ${{ needs.detect.outputs.version }} + PUBLISH_BRANCH: ${{ github.ref_name }} + PNPM_CONFIG_PM_ON_FAIL: ignore + PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: 'false' + run: pnpm run publish-ci "$VERSION" + + - name: Push release tag + env: + VERSION: ${{ needs.detect.outputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v$VERSION" + git config user.name "vitest-release-bot" + git config user.email "actions@github.com" + git tag "$TAG" "$GITHUB_SHA" + git push "https://x-access-token:$GITHUB_TOKEN@github.com/$GITHUB_REPOSITORY.git" "$TAG" - name: Generate Changelog - run: npx changelogithub env: + VERSION: ${{ needs.detect.outputs.version }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="v$VERSION" + npx changelogithub --to "$TAG" --name "$TAG" diff --git a/package.json b/package.json index 70be1c0e1..faccca2a5 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@types/node": "24.12.0", + "@types/semver": "catalog:", "@types/ws": "catalog:", "@vitest/browser": "workspace:*", "@vitest/coverage-istanbul": "workspace:*", @@ -62,6 +63,7 @@ "rollup": "^4.59.0", "rollup-plugin-dts": "^6.3.0", "rollup-plugin-license": "^3.7.0", + "semver": "catalog:", "tinyglobby": "catalog:", "tsx": "^4.21.0", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fdac4a17..5a4d26513 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ catalogs: '@types/istanbul-reports': specifier: ^3.0.4 version: 3.0.4 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/ws': specifier: ^8.18.1 version: 8.18.1 @@ -111,6 +114,9 @@ catalogs: playwright: specifier: ^1.59.0 version: 1.59.0 + semver: + specifier: ^7.8.3 + version: 7.8.4 sinon: specifier: ^21.0.3 version: 21.0.3 @@ -215,6 +221,9 @@ importers: '@types/node': specifier: 24.12.0 version: 24.12.0 + '@types/semver': + specifier: 'catalog:' + version: 7.7.1 '@types/ws': specifier: 'catalog:' version: 8.18.1 @@ -260,6 +269,9 @@ importers: rollup-plugin-license: specifier: ^3.7.0 version: 3.7.0(picomatch@4.0.3)(rollup@4.59.0) + semver: + specifier: 'catalog:' + version: 7.8.4 tinyglobby: specifier: 'catalog:' version: 0.2.15 @@ -5346,6 +5358,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/sinonjs__fake-timers@15.0.1': resolution: {integrity: sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==} @@ -9453,6 +9468,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -13108,7 +13128,7 @@ snapshots: extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.3 + semver: 7.7.4 tar-fs: 3.0.8 yargs: 17.7.2 transitivePeerDependencies: @@ -13803,6 +13823,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/semver@7.7.1': {} + '@types/sinonjs__fake-timers@15.0.1': {} '@types/sinonjs__fake-timers@8.1.5': {} @@ -13917,7 +13939,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.56.1 debug: 4.4.3 minimatch: 10.2.3 - semver: 7.7.3 + semver: 7.8.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -14781,7 +14803,7 @@ snapshots: escalade: 3.2.0 jsonc-parser: 3.3.1 package-manager-detector: 1.6.0 - semver: 7.7.3 + semver: 7.8.4 tinyexec: 1.0.2 tinyglobby: 0.2.15 yaml: 2.8.2 @@ -14894,7 +14916,7 @@ snapshots: pathe: 1.1.2 pkg-types: 1.3.1 scule: 1.3.0 - semver: 7.7.3 + semver: 7.8.4 std-env: 3.10.0 yaml: 2.8.2 transitivePeerDependencies: @@ -14909,7 +14931,7 @@ snapshots: convert-gitmoji: 0.1.5 execa: 9.6.0 ofetch: 1.5.1 - semver: 7.7.3 + semver: 7.8.4 tinyglobby: 0.2.15 transitivePeerDependencies: - magicast @@ -15394,7 +15416,7 @@ snapshots: '@one-ini/wasm': 0.1.1 commander: 10.0.1 minimatch: 9.0.1 - semver: 7.7.3 + semver: 7.7.4 ee-first@1.1.1: {} @@ -15622,7 +15644,7 @@ snapshots: eslint-compat-utils@0.5.1(eslint@10.0.3(jiti@2.6.1)): dependencies: eslint: 10.0.3(jiti@2.6.1) - semver: 7.7.3 + semver: 7.8.4 eslint-config-flat-gitignore@2.2.1(eslint@10.0.3(jiti@2.6.1)): dependencies: @@ -15681,7 +15703,7 @@ snapshots: html-entities: 2.6.0 object-deep-merge: 2.0.0 parse-imports-exports: 0.2.4 - semver: 7.7.4 + semver: 7.8.4 spdx-expression-parse: 4.0.0 to-valid-identifier: 1.0.0 transitivePeerDependencies: @@ -15712,7 +15734,7 @@ snapshots: globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 - semver: 7.7.3 + semver: 7.8.4 ts-declaration-location: 1.0.7(typescript@5.9.3) transitivePeerDependencies: - typescript @@ -15778,7 +15800,7 @@ snapshots: pluralize: 8.0.0 regexp-tree: 0.1.27 regjsparser: 0.13.0 - semver: 7.7.3 + semver: 7.8.4 strip-indent: 4.1.1 eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1)): @@ -15794,7 +15816,7 @@ snapshots: natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 - semver: 7.7.3 + semver: 7.8.4 vue-eslint-parser: 10.4.0(eslint@10.0.3(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: @@ -16917,7 +16939,7 @@ snapshots: dependencies: acorn: 8.11.3(patch_hash=62f89b815dbd769c8a4d5b19b1f6852f28922ecb581d876c8a8377d05c2483c4) eslint-visitor-keys: 5.0.0 - semver: 7.7.3 + semver: 7.8.4 jsonc-parser@3.3.1: {} @@ -17185,7 +17207,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 mark.js@8.11.1: {} @@ -18749,6 +18771,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.4: {} + send@0.18.0: dependencies: debug: 2.6.9 @@ -18822,7 +18846,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.4 - semver: 7.7.3 + semver: 7.7.4 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -19845,7 +19869,7 @@ snapshots: eslint-visitor-keys: 5.0.0 espree: 11.1.0 esquery: 1.7.0 - semver: 7.7.3 + semver: 7.8.4 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 24d4e6d10..61f4e125a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -60,6 +60,7 @@ catalog: '@types/istanbul-lib-report': ^3.0.3 '@types/istanbul-lib-source-maps': ^4.0.4 '@types/istanbul-reports': ^3.0.4 + '@types/semver': ^7.7.1 '@types/ws': ^8.18.1 '@types/yauzl': ^2.10.3 '@unocss/reset': ^66.6.6 @@ -80,6 +81,7 @@ catalog: obug: ^2.1.1 pathe: ^2.0.3 playwright: ^1.59.0 + semver: ^7.8.3 sinon: ^21.0.3 sinon-chai: ^4.0.1 sirv: ^3.0.2 diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts index e77fbc0f9..a1bc4e41d 100644 --- a/scripts/publish-ci.ts +++ b/scripts/publish-ci.ts @@ -1,43 +1,74 @@ -#!/usr/bin/env zx - import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import * as semver from 'semver' import { $ } from 'zx' -if (process.env.VITEST_GENERATE_UI_TOKEN !== 'true' || process.env.VITE_TEST_WATCHER_DEBUG !== 'false') { - throw new Error(`Cannot release Vitest without VITEST_GENERATE_UI_TOKEN=${process.env.VITEST_GENERATE_UI_TOKEN} and VITE_TEST_WATCHER_DEBUG=${process.env.VITE_TEST_WATCHER_DEBUG} environment variable. `) -} +// (This probably requires temporarily installing pnpm 11 like publish.yml) +// How to test release script locally: +// RELEASE_VERSION=4.1.9 pnpm release +// VITEST_GENERATE_UI_TOKEN=true VITE_TEST_WATCHER_DEBUG=false PUBLISH_DRY_RUN=true PUBLISH_BRANCH=v4 pnpm publish-ci 4.1.9 -let version = process.argv[2] +const $$ = $({ stdio: 'inherit' }) -if (!version) { - throw new Error('No tag specified') -} +async function main() { + if (process.env.VITEST_GENERATE_UI_TOKEN !== 'true' || process.env.VITE_TEST_WATCHER_DEBUG !== 'false') { + throw new Error(`Cannot release Vitest without VITEST_GENERATE_UI_TOKEN=${process.env.VITEST_GENERATE_UI_TOKEN} and VITE_TEST_WATCHER_DEBUG=${process.env.VITE_TEST_WATCHER_DEBUG} environment variable. `) + } -if (version.startsWith('v')) { - version = version.slice(1) -} + const version = process.argv[2] + if (!version) { + throw new Error('Missing argument to specify version') + } -const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url)) -const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url)) + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) + if (pkg.version !== version) { + throw new Error( + `Input version "${version}" does not match package.json version "${pkg.version}"`, + ) + } -if (pkg.version !== version) { - throw new Error( - `Package version from tag "${version}" mismatches with the current version "${pkg.version}"`, - ) + const publishBranch = process.env.PUBLISH_BRANCH + if (!publishBranch) { + throw new Error('Missing PUBLISH_BRANCH environment variable') + } + const releaseTag = await getReleaseTag(version, publishBranch) + + const dryRun = process.env.PUBLISH_DRY_RUN === 'true' + if (dryRun) { + console.log('== DRY RUN ==') + } + console.log(`Staging version '${version}' with tag '${releaseTag}'`) + await $$`pnpm -r stage publish --access public --no-git-checks --tag ${releaseTag} ${dryRun ? ['--dry-run'] : []}` } -const releaseTag = version.includes('beta') - ? 'beta' - : version.includes('alpha') - ? 'alpha' - : undefined +async function getReleaseTag(version: string, publishBranch: string) { + // Always specify the dist-tag explicitly since otherwise `latest` would be overwritten. + // Note that `main` branch doesn't always mean `latest` tag because of pre-release phase. -console.log('Publishing version', version, 'with tag', releaseTag || 'latest') + // check prerelease e.g. beta, alpha, rc + const parsed = semver.parse(version, {}, true) + if (parsed.prerelease.length > 0) { + return parsed.prerelease[0] + } -if (releaseTag) { - await $`pnpm -r publish --access public --no-git-checks --tag ${releaseTag}` -} -else { - await $`pnpm -r publish --access public --no-git-checks` + // If the version is not a pre-release and is greater than the latest version on npm, + // then that should become the new latest version. + const npmView = await $`npm view vitest dist-tags --json` + const latestVersion = JSON.parse(npmView.stdout).latest + if (semver.gt(version, latestVersion)) { + return 'latest' + } + + // Otherwise this is a backport release. + // Use the uppercase of the branch name to avoid npm dist-tag caveats + // https://docs.npmjs.com/cli/v11/commands/npm-dist-tag#caveats + // - v4 branch -> V4 dist tag + // - v4.1 branch -> V4.1 dist tag + return publishBranch.toUpperCase() } + +main().catch((error) => { + console.error('Error during publishing:', error) + process.exit(1) +}) diff --git a/scripts/release.ts b/scripts/release.ts index 3f85d4560..d837215d5 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -1,22 +1,27 @@ -#!/usr/bin/env zx - import { versionBump } from 'bumpp' import { glob } from 'tinyglobby' -try { - const packages = await glob(['package.json', './packages/*/package.json'], { expandDirectories: false }) +async function main() { + const packages = await glob(['package.json', './packages/*/package.json'], { + expandDirectories: false, + }) console.log('Bumping versions in packages:', packages.join(', '), '\n') + const release = process.env.RELEASE_VERSION || process.env.RELEASE_TYPE + await versionBump({ files: packages, + release, commit: true, - push: true, - tag: true, + tag: false, + push: false, + printCommits: false, + confirm: !release, }) - - console.log('New release is ready, waiting for conformation at https://github.com/vitest-dev/vitest/actions') -} -catch (err) { - console.error(err) } + +main().catch((error) => { + console.error('Error during version bump:', error) + process.exit(1) +}) -- 2.51.2