Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1dd4ead
docs(rfc): align the core version with the bundled Vite version
fengmk2 Oct 30, 2026
8356c83
feat(core): align the core version with the bundled Vite version
fengmk2 Oct 30, 2026
15d2088
fix(cli): keep the stale core check for the workspace spec
fengmk2 Oct 30, 2026
50035c9
test(snapshots): re-record for the encoded core version
fengmk2 Oct 30, 2026
e491882
fix(release): wait for core at its own version in the Homebrew update
fengmk2 Nov 6, 2026
9131d2b
refactor: simplify the core version and peer-rule code
fengmk2 Nov 9, 2026
9d92f9e
fix(release): reject a core version that another release published
fengmk2 Nov 10, 2026
394a780
docs: cover follow-ups from the core version review
fengmk2 Nov 10, 2026
bc9bf3c
refactor(core): use a patch factor of 1000 for the core version
fengmk2 Nov 10, 2026
fd343a3
ci: read the local core version from the core manifest only
fengmk2 Nov 10, 2026
1ac6e96
fix(migrate): keep the pnpm vite peer rule before pnpm 10.2.0
fengmk2 Nov 10, 2026
163fb13
docs(rfc): show the effect before and after the core version change
fengmk2 Nov 10, 2026
6ec8f88
docs: recommend pnpm's :peer override for one incompatible plugin
fengmk2 Nov 10, 2026
0962c7c
docs(rfc): reduce the before-and-after section to the pnpm config change
fengmk2 Nov 10, 2026
6495704
docs(rfc): add an example of Vite+ betas on a stable Vite
fengmk2 Nov 10, 2026
74a5832
docs(rfc): add an example of several vite-plus releases on one Vite
fengmk2 Nov 10, 2026
297aaa0
test(tools): cover the RFC release sequences for core versions
fengmk2 Nov 10, 2026
00a9736
refactor: compute the pnpm peer-rule keys once per migration step
fengmk2 Nov 10, 2026
a6d63f0
docs: leave the tsgolint convention out of the user guide
fengmk2 Nov 10, 2026
13cc82a
fix(release): never select a used core version, and run releases in o…
fengmk2 Nov 10, 2026
dc9d7eb
fix(release): give local builds a reserved core version on a Vite pre…
fengmk2 Nov 10, 2026
fffe426
test(snapshots): drop the vite peer rule from the new create snapshots
fengmk2 Nov 10, 2026
ce92b4c
docs(rfc): start the release examples after v1.6.0
fengmk2 Nov 12, 2026
9507271
fix(release): recover from a rejected core version and reject unpubli…
fengmk2 Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions .claude/skills/release-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ gh workflow run prepare_release.yml --repo voidzero-dev/vite-plus -f version=X.Y

The workflow bumps `packages/cli/package.json`, `packages/core/package.json`, `packages/prompts/package.json`, `packages/cli/binding/Cargo.toml`, and `crates/vp_global_cli/Cargo.toml`, refreshes `Cargo.lock`, and opens a PR titled `release: vX.Y.Z` from branch `release/vX.Y.Z`. The PR body ends with `Merging this PR will trigger the release workflow.` and that line must survive every later edit.

`@voidzero-dev/vite-plus-core` does not use `X.Y.Z`. Its version encodes the bundled Vite version (for example, `8.3.5000` for Vite `8.3.5`; see [Core Version](../../../packages/core/BUNDLING.md#core-version)). The workflow selects it from the registry and names it in the first line of the PR body. In the steps below, `<core>` is that version. Read it from the release branch with `node -p "require('./packages/core/package.json').version"`.

## 2. Sync release versions (required every release)

### NAPI binding
Expand Down Expand Up @@ -384,6 +386,8 @@ git checkout release/vX.Y.Z && git merge origin/main --no-edit && git push origi

Do not assume the merge brought in only the fix PR: `main` may have accumulated several. Before merging, list everything that will come in with `git log origin/release/vX.Y.Z..origin/main --oneline`, then add a changelog entry for **every** newly included PR and rerun the step 3 validation (its missing/extra diff against `generate-notes` catches any entry you missed). When the merged commits change bundled tool versions or `vp migrate` behavior, rebuild the preview (step 4) and repeat the smoke test on the new build: start each fork from its current test branch so earlier fixes carry over, open new fork PRs, close the superseded ones, and report results per build.

When the merged commits change `bundledVersions.vite` in `packages/core/package.json`, the core version on the release branch no longer encodes the bundled Vite, and the merge usually conflicts on that `version` line. Take either side, then select a new core version on the release branch: run `node packages/tools/src/core-version.ts prepare --vite-plus X.Y.Z` (it reads the registry and writes `version` and `vitePlusVersion`), commit `packages/core/package.json`, and change the core version in the first line of the PR body. `node packages/tools/src/core-version.ts check --release` must pass before the merge.

When several fix PRs merge back to back, the required `done` check must pass again on each later PR after `main` moves: update that PR's branch and let CI rerun before merging it.

Known release-branch-only failure modes:
Expand All @@ -402,9 +406,9 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me

## 7. Automated release pipeline (what happens after merge)

`release.yml` runs on the `main` push because `packages/cli/package.json` changed:
`release.yml` runs on the `main` push because `packages/cli/package.json` or `packages/core/package.json` changed:

1. `check`: compares the local version against `unpkg.com/vite-plus@latest`; everything below is skipped unless it changed.
1. `check`: compares the local version against `unpkg.com/vite-plus@latest`; everything below is skipped unless it changed. Then `Check core version` runs `core-version.ts check --release`, which fails when the core version does not encode the bundled Vite, names another `vite-plus` version, or is already used on npm by another release.
2. `build-rust`: full multi-platform build.
3. `request-approval`: posts an approval request to the releases Discord channel, and the `Release` job waits on the `release` GitHub environment. **A person with environment approval rights must approve the run in the Actions UI.** The environment sets `prevent_self_review: true`, so whoever merged the release PR triggered the run and cannot approve it: a _different_ reviewer must. Check who can, and tell the release manager rather than leaving them waiting on themselves:

Expand All @@ -419,16 +423,19 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me
7. `discord-notify`: announces to Discord after Docker publishing and docs deployment succeed (docs are skipped for prereleases).
8. `update-homebrew`: after a stable release is published, verifies its archives and npm packages, then opens the Homebrew formula update PR. Published-artifact installation tests run on that PR on all four supported platforms before maintainer review and merge.

**When `Check core version` fails, select a new core version through a PR.** The usual cause is a second release PR that was prepared before the first one published the same core version. Run the Prepare Release workflow again with the same version. Its PR changes only `packages/core/package.json`, and merging it starts `release.yml` again, because that file is a trigger and the CLI version is still unpublished. Do not rerun the failed run: it checks the same commit and fails again.

**When the `Release` job fails partway, fix the cause, then use "Re-run failed jobs", never "Re-run all jobs".** A full rerun repeats `check`, which now sees the new version on `unpkg.com/vite-plus@latest` and skips the whole release. A failed-jobs rerun reuses `check` and the build artifacts, and every npm publish step is idempotent: `.github/scripts/publish-npm-package.ts` skips versions the registry already serves (`<pkg>@X.Y.Z is already published; skipping upload.`), so only the unpublished packages upload. Confirm that from the rerun log. The rerun waits on the `release` environment again, and whoever started the rerun cannot approve it.

**A successful publish command does not mean the packages are installable.** `pnpm publish` prints `✅ Published package <name>@X.Y.Z` as soon as the registry accepts the request, and the registry can then take tens of minutes to actually serve that version. This has shipped a broken release: `vite-plus@X.Y.Z` went live on `latest` with an exact dependency on `@voidzero-dev/vite-plus-core@X.Y.Z` that was invisible for about 35 minutes, so every `npm install vite-plus` failed with `ETARGET` and both `publish-docker` and `Deploy docs` failed on `ERR_PNPM_NO_MATCHING_VERSION`. The downstream job failures are the symptom, not the cause; do not re-run them until the registry has the package.
**A successful publish command does not mean the packages are installable.** `pnpm publish` prints `✅ Published package <name>@X.Y.Z` as soon as the registry accepts the request, and the registry can then take tens of minutes to actually serve that version. This has shipped a broken release: `vite-plus@X.Y.Z` went live on `latest` with an exact dependency on its `@voidzero-dev/vite-plus-core` version that was invisible for about 35 minutes, so every `npm install vite-plus` failed with `ETARGET` and both `publish-docker` and `Deploy docs` failed on `ERR_PNPM_NO_MATCHING_VERSION`. The downstream job failures are the symptom, not the cause; do not re-run them until the registry has the package.

Check visibility directly, not through `npm view`, which caches:

```bash
for pkg in '@voidzero-dev%2Fvite-plus-core' 'vite-plus'; do
curl -s -H 'Cache-Control: no-cache' "https://registry.npmjs.org/$pkg?t=$(date +%s)" |
python3 -c "import json,sys;d=json.load(sys.stdin);print('$pkg', d['dist-tags'].get('latest'), 'X.Y.Z' in d['versions'])"
for entry in '@voidzero-dev%2Fvite-plus-core <core>' 'vite-plus X.Y.Z'; do
set -- $entry
curl -s -H 'Cache-Control: no-cache' "https://registry.npmjs.org/$1?t=$(date +%s)" |
python3 -c "import json,sys;d=json.load(sys.stdin);print('$1', d['dist-tags'].get('latest'), '$2' in d['versions'])"
done
```

Expand Down Expand Up @@ -511,7 +518,8 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look

```bash
npm view vite-plus version # X.Y.Z
npm view @voidzero-dev/vite-plus-core version # X.Y.Z
npm view @voidzero-dev/vite-plus-core version # <core>, which encodes the bundled Vite version
npm view vite-plus@X.Y.Z dependencies.vite # npm:@voidzero-dev/vite-plus-core@<core>
npm view create-vite-plus version # X.Y.Z
npm view @voidzero-dev/vite-plus-cli-darwin-arm64 version # X.Y.Z, spot-check a native platform package
npm view vite-plus dist-tags.latest # X.Y.Z
Expand All @@ -538,7 +546,7 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look

Require the target version directory, the expected `current` link, and `vp --version` output; a success message alone is insufficient. `Already up to date` passes only when the selected installation is already on the target version.

The Docker check must run `vp --version` inside the image, not just pull it: the output must report `vp vX.Y.Z`. Outside a project that output lists no bundled tools, so inspect the installed image package tree under `~/.vite-plus/X.Y.Z/node_modules/.pnpm` and confirm the bundled tool packages and versions match the changelog's Bundled Versions table. `tsdown` will be absent from that tree because it is bundled into `@voidzero-dev/vite-plus-core`; verify it with `npm view @voidzero-dev/vite-plus-core@X.Y.Z bundledVersions --json` instead. If no local Docker runtime is available, confirm `publish-docker` succeeded and inspect the GHCR manifest for both `linux/amd64` and `linux/arm64`. For the current stable release, confirm the version tag and `latest` have the same digest. Record each architecture's `vp --version` output from the Docker build logs when available (`gh api --allow-escape-sequences repos/voidzero-dev/vite-plus/actions/jobs/<job-id>/logs`; without the flag, `gh` refuses logs containing terminal escape sequences), and distinguish that evidence from a local run:
The Docker check must run `vp --version` inside the image, not just pull it: the output must report `vp vX.Y.Z`. Outside a project that output lists no bundled tools, so inspect the installed image package tree under `~/.vite-plus/X.Y.Z/node_modules/.pnpm` and confirm the bundled tool packages and versions match the changelog's Bundled Versions table. `tsdown` will be absent from that tree because it is bundled into `@voidzero-dev/vite-plus-core`; verify it with `npm view @voidzero-dev/vite-plus-core@<core> bundledVersions --json` instead. If no local Docker runtime is available, confirm `publish-docker` succeeded and inspect the GHCR manifest for both `linux/amd64` and `linux/arm64`. For the current stable release, confirm the version tag and `latest` have the same digest. Record each architecture's `vp --version` output from the Docker build logs when available (`gh api --allow-escape-sequences repos/voidzero-dev/vite-plus/actions/jobs/<job-id>/logs`; without the flag, `gh` refuses logs containing terminal escape sequences), and distinguish that evidence from a local run:

```bash
TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:voidzero-dev/vite-plus:pull" \
Expand Down
23 changes: 23 additions & 0 deletions .github/scripts/__tests__/homebrew-formula.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { test } from 'node:test';
import { fileURLToPath } from 'node:url';

import {
publishedCoreVersion,
publishedFormulaVersion,
releaseAssets,
targets,
Expand Down Expand Up @@ -143,3 +144,25 @@ await test('updates preserve recipe changes and reset only a previous version re
/Unsafe formula URL/,
);
});

await test('reads the core version from the published CLI alias', async () => {
const manifest = (dependencies) => async (url) => {
assert.equal(url, `https://registry.npmjs.org/vite-plus/${version}`);
return Response.json({ dependencies });
};
assert.equal(
await publishedCoreVersion(
version,
manifest({ vite: 'npm:@voidzero-dev/vite-plus-core@8.3.5001' }),
),
'8.3.5001',
);
await assert.rejects(
publishedCoreVersion(version, manifest({ vite: '^8.0.0' })),
/does not alias vite to @voidzero-dev\/vite-plus-core/,
);
await assert.rejects(
publishedCoreVersion(version, async () => new Response('', { status: 404 })),
/HTTP 404/,
);
});
47 changes: 47 additions & 0 deletions .github/scripts/__tests__/upgrade-deps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,11 @@ function createSyncFixture() {
const metaDir = join(root, 'meta');
const versionsPath = join(metaDir, 'versions.json');
writeJson(corePath, {
version: '8.3.5001',
dependencies: { 'yuku-parser': '^0.9.3', postcss: '^8.5.6' },
optionalDependencies: { fsevents: '~2.3.3' },
devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
bundledVersions: { vite: '8.3.5' },
});
writeJson(join(root, 'store/tsdown/package.json'), { name: 'tsdown', version: '0.23.0' });
writeJson(pluginPath, {
Expand All @@ -268,6 +270,7 @@ function createSyncFixture() {
dependencies: { 'yuku-codegen': '^0.8.1', 'yuku-parser': '^0.8.1' },
});
writeJson(vitePath, {
version: '8.3.5',
dependencies: { postcss: '^8.5.28' },
optionalDependencies: { fsevents: '~2.4.0' },
});
Expand All @@ -291,9 +294,12 @@ test('pins the bundled parser, syncs Vite ranges, and preserves upgrade metadata
const { corePath, versionsPath, metaDir, run } = fixture;
run();
expect(readJson(corePath)).toEqual({
// The bundled Vite did not change, so the release counter stays.
version: '8.3.5001',
dependencies: { 'yuku-parser': '0.10.3', postcss: '^8.5.28' },
optionalDependencies: { fsevents: '~2.4.0' },
devDependencies: { 'rolldown-plugin-dts': '^0.28.0' },
bundledVersions: { vite: '8.3.5' },
});
expect(readJson(versionsPath)).toEqual({
oxlint: { old: '1.86.0', new: '1.87.0' },
Expand Down Expand Up @@ -323,6 +329,47 @@ test('pins the bundled parser, syncs Vite ranges, and preserves upgrade metadata
expect(readFileSync(corePath, 'utf8')).toBe(before);
});

test('restarts the core version counter when the bundled Vite changes', () => {
const fixture = createSyncFixture();
const { corePath, versionsPath, metaDir, run } = fixture;
writeJson(fixture.vitePath, { ...readJson(fixture.vitePath), version: '8.4.2' });
run();
expect(readJson(corePath).version).toBe('8.4.2000');
expect(readJson(corePath).bundledVersions).toEqual({ vite: '8.4.2' });
expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
old: '8.3.5001',
new: '8.4.2000',
});
expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain(
'| `@voidzero-dev/vite-plus-core` | `8.3.5001` | `8.4.2000` |',
);

// A second pass keeps the recorded old version and the encoded version.
run();
expect(readJson(corePath).version).toBe('8.4.2000');
expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
old: '8.3.5001',
new: '8.4.2000',
});
});

test('starts the vp counter when the bundled Vite is a prerelease', () => {
const fixture = createSyncFixture();
writeJson(fixture.vitePath, { ...readJson(fixture.vitePath), version: '9.0.0-beta.3' });
fixture.run();
expect(readJson(fixture.corePath).version).toBe('9.0.0-beta.3.vp.0');
expect(readJson(fixture.corePath).bundledVersions).toEqual({ vite: '9.0.0-beta.3' });
});

test('fails without writing core when the Vite source has no version', () => {
const fixture = createSyncFixture();
const { version: _, ...vite } = readJson(fixture.vitePath);
writeJson(fixture.vitePath, vite);
const before = readFileSync(fixture.corePath, 'utf8');
expect(fixture.run).toThrow('Missing version');
expect(readFileSync(fixture.corePath, 'utf8')).toBe(before);
});

test('updates the parser pin when its installed version changes within the same range', () => {
const { corePath, parserPath, versionsPath, run } = createSyncFixture();
run();
Expand Down
51 changes: 39 additions & 12 deletions .github/scripts/update-homebrew-formula.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';

import { waitForNpmPackagesFromEnv } from './wait-for-npm-packages.ts';
import { CORE_ALIAS_PREFIX } from '../../packages/tools/src/core-version.ts';
import { DEFAULT_NPM_REGISTRY, waitForNpmPackagesFromEnv } from './wait-for-npm-packages.ts';

export const targets = [
['macos', 'arm', 'aarch64-apple-darwin', 'darwin-arm64'],
Expand Down Expand Up @@ -105,15 +106,17 @@ export function updateFormula(source, version, assets) {
return updated;
}

async function download(url, fetchImpl) {
const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
return response;
}

export async function verifyRelease(version, release, fetchImpl = fetch) {
stableVersion(version);
assert.equal(release.tag_name, `v${version}`);
assert.ok(!release.draft && !release.prerelease, 'Release must be published and stable');
const get = async (url) => {
const response = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
assert.ok(response.ok, `Download failed: ${url} (HTTP ${response.status})`);
return response;
};
const get = (url) => download(url, fetchImpl);
// The formula is added with the per-user Homebrew runtime. Check the release
// tag, never main, before enabling installation.
await get(`https://raw.githubusercontent.com/${repository}/v${version}/HomebrewFormula/vp.rb`);
Expand All @@ -137,6 +140,24 @@ export async function verifyRelease(version, release, fetchImpl = fetch) {
return assets;
}

// The core version encodes the bundled Vite version, so it differs from the
// vite-plus version (rfcs/core-version-alignment.md). Read it from the `vite`
// alias of the published CLI. The release job publishes the CLI before this
// script runs.
export async function publishedCoreVersion(
version,
fetchImpl = fetch,
registry = process.env.PUBLISH_REGISTRY ?? DEFAULT_NPM_REGISTRY,
) {
const url = `${registry.replace(/\/+$/, '')}/vite-plus/${version}`;
const spec = (await (await download(url, fetchImpl)).json()).dependencies?.vite;
assert.ok(
typeof spec === 'string' && spec.startsWith(CORE_ALIAS_PREFIX),
`vite-plus@${version} does not alias vite to @voidzero-dev/vite-plus-core: ${spec}`,
);
return spec.slice(CORE_ALIAS_PREFIX.length);
}

async function main() {
const [version] = process.argv.slice(2);
stableVersion(version ?? '');
Expand All @@ -145,14 +166,20 @@ async function main() {
encoding: 'utf8',
}),
);
const assets = await verifyRelease(version, release);
const [assets, coreVersion] = await Promise.all([
verifyRelease(version, release),
publishedCoreVersion(version),
]);
const packages = [
'vite-plus',
'@voidzero-dev/vite-plus-core',
'@voidzero-dev/vite-plus-prompts',
...targets.map(([, , , platform]) => `@voidzero-dev/vite-plus-${platform}`),
{ name: 'vite-plus', version },
{ name: '@voidzero-dev/vite-plus-core', version: coreVersion },
{ name: '@voidzero-dev/vite-plus-prompts', version },
...targets.map(([, , , platform]) => ({
name: `@voidzero-dev/vite-plus-${platform}`,
version,
})),
];
await waitForNpmPackagesFromEnv(packages.map((name) => ({ name, version })));
await waitForNpmPackagesFromEnv(packages);
const file = new URL('../../HomebrewFormula/vp.rb', import.meta.url);
const source = await readFile(file, 'utf8');
const result = updateFormula(source, version, assets);
Expand Down
Loading
Loading