diff --git a/.github/workflows/prepare-publish.yml b/.github/workflows/prepare-publish.yml new file mode 100644 index 000000000000..615ccd89e25b --- /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 005d5f6bedff..8dbecf4c0588 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,8 +2,9 @@ name: Publish Package on: push: - tags: - - 'v*' + branches: + - main + - 'v[0-9]*' permissions: {} @@ -12,9 +13,40 @@ env: VITE_TEST_WATCHER_DEBUG: 'false' jobs: - publish: - # only run on main, don't trigger in forks + # 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: @@ -27,8 +59,16 @@ jobs: 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 24 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -38,6 +78,9 @@ jobs: # 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 env: @@ -46,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 5884b9150d03..c7321aa9d8a9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.1", "@types/node": "^22.15.32", + "@types/semver": "catalog:", "@types/ws": "catalog:", "@vitest/browser": "workspace:*", "@vitest/coverage-istanbul": "workspace:*", @@ -58,6 +59,7 @@ "rollup": "^4.43.0", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-license": "^3.6.0", + "semver": "catalog:", "tinyglobby": "catalog:", "tsx": "^4.20.3", "typescript": "^5.8.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 897acd5a58d1..7126ed5a5418 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ catalogs: '@types/istanbul-reports': specifier: ^3.0.4 version: 3.0.4 + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 '@types/test-exclude': specifier: ^6.0.2 version: 6.0.2 @@ -96,6 +99,9 @@ catalogs: pathe: specifier: ^2.0.3 version: 2.0.3 + semver: + specifier: ^7.8.3 + version: 7.8.4 sirv: specifier: ^3.0.1 version: 3.0.1 @@ -178,6 +184,9 @@ importers: '@types/node': specifier: ^22.15.32 version: 22.15.32 + '@types/semver': + specifier: 'catalog:' + version: 7.7.1 '@types/ws': specifier: 'catalog:' version: 8.18.1 @@ -223,6 +232,9 @@ importers: rollup-plugin-license: specifier: ^3.6.0 version: 3.6.0(picomatch@4.0.2)(rollup@4.43.0) + semver: + specifier: 'catalog:' + version: 7.8.4 tinyglobby: specifier: 'catalog:' version: 0.2.14 @@ -3711,6 +3723,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@8.1.5': resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} @@ -7703,6 +7718,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'} @@ -11121,6 +11141,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/semver@7.7.1': {} + '@types/sinonjs__fake-timers@8.1.5(patch_hash=0218b33f433e26861380c2b90c757bde6fea871cb988083c0bd4a9a1f6c00252)': {} '@types/statuses@2.0.5': {} @@ -15715,6 +15737,8 @@ snapshots: semver@7.7.2: {} + semver@7.8.4: {} + send@0.18.0: dependencies: debug: 2.6.9 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5eafed1e7c96..b8f9e0e7b6b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,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/test-exclude': ^6.0.2 '@types/ws': ^8.18.1 '@unocss/reset': ^66.2.1 @@ -35,6 +36,7 @@ catalog: magicast: ^0.3.5 msw: ^2.10.2 pathe: ^2.0.3 + semver: ^7.8.3 sirv: ^3.0.1 std-env: ^3.9.0 strip-literal: ^3.0.0 diff --git a/scripts/publish-ci.ts b/scripts/publish-ci.ts index 65515e0e519b..538c3c79e9e5 100644 --- a/scripts/publish-ci.ts +++ b/scripts/publish-ci.ts @@ -1,43 +1,88 @@ -#!/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=3.2.7 pnpm release +// VITEST_GENERATE_UI_TOKEN=true VITE_TEST_WATCHER_DEBUG=false PUBLISH_DRY_RUN=true PUBLISH_BRANCH=v3 pnpm publish-ci 3.2.7 -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} ${getPublishFilters(version, publishBranch)} ${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. + + // check prerelease e.g. beta, alpha, rc + const parsed = semver.parse(version, {}, true) + if (!parsed) { + throw new Error(`Invalid release version "${version}"`) + } + if (parsed.prerelease.length > 0) { + return parsed.prerelease[0] + } -console.log('Publishing version', version, 'with tag', releaseTag || 'latest') + // 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' + } -if (releaseTag) { - await $`pnpm -r publish --access public --no-git-checks --tag ${releaseTag}` + // 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 + // - v3 branch -> V3 dist tag + // - v3.1 branch -> V3.1 dist tag + return publishBranch.toUpperCase() } -else { - await $`pnpm -r publish --access public --no-git-checks --tag V3 --filter="!vite-node" --filter="!@vitest/ws-client"` + +function getPublishFilters(version: string, publishBranch: string) { + const parsed = semver.parse(version, {}, true) + if (!parsed) { + throw new Error(`Invalid release version "${version}"`) + } + if (parsed.prerelease.length === 0 && /^v3(?:\.|$)/.test(publishBranch)) { + return ['--filter=!vite-node', '--filter=!@vitest/ws-client'] + } + return [] } + +main().catch((error) => { + console.error('Error during publishing:', error) + process.exit(1) +}) diff --git a/scripts/release.ts b/scripts/release.ts index 3f85d4560ee0..d837215d5a1d 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) +})