Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 10 additions & 6 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.500` 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 @@ -411,14 +413,15 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me

**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 @@ -484,7 +487,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 @@ -511,7 +515,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.501' }),
),
'8.3.501',
);
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/,
);
});
39 changes: 39 additions & 0 deletions .github/scripts/__tests__/upgrade-deps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,11 @@ function createSyncFixture() {
const metaDir = join(root, 'meta');
const versionsPath = join(metaDir, 'versions.json');
writeJson(corePath, {
version: '8.3.501',
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 @@ -216,6 +218,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 @@ -239,9 +242,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.501',
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 @@ -271,6 +277,39 @@ 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.200');
expect(readJson(corePath).bundledVersions).toEqual({ vite: '8.4.2' });
expect(readJson(versionsPath)['@voidzero-dev/vite-plus-core']).toEqual({
old: '8.3.501',
new: '8.4.200',
});
expect(readFileSync(join(metaDir, 'pr-body.md'), 'utf8')).toContain(
'| `@voidzero-dev/vite-plus-core` | `8.3.501` | `8.4.200` |',
);

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

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
25 changes: 25 additions & 0 deletions .github/scripts/upgrade-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

import {
CORE_PACKAGE_NAME,
encodeCoreVersion,
tryDecodeCoreVersion,
} from '../../packages/tools/src/core-version.ts';
import {
findLatestStableVersionForMajor,
getBundledDependencyRanges,
Expand Down Expand Up @@ -533,10 +538,30 @@ function syncBundledDependencies(): void {
recordChange(name, changes.get(name)?.old ?? oldRange, range);
pkg[field][name] = range;
}
syncCoreVersion(pkg);
fs.writeFileSync(filePath, JSON.stringify(pkg, null, 2) + '\n');
writeMetaFiles();
}

// The core version encodes the bundled Vite version (rfcs/core-version-alignment.md).
// When the bundled Vite changes, restart the release counter at 0. The release
// workflow selects the next unpublished counter.
function syncCoreVersion(pkg: { version?: unknown; bundledVersions?: Record<string, string> }) {
const vitePath = path.join(ROOT, 'vite/packages/vite/package.json');
const viteVersion = readJsonFile(vitePath).version;
if (typeof viteVersion !== 'string' || !viteVersion.trim()) {
throw new Error(`Missing version in ${vitePath}`);
}
pkg.bundledVersions = { ...pkg.bundledVersions, vite: viteVersion };
const current = typeof pkg.version === 'string' ? pkg.version : undefined;
if (current !== undefined && tryDecodeCoreVersion(current)?.vite === viteVersion) {
return;
}
const next = encodeCoreVersion(viteVersion, 0);
recordChange(CORE_PACKAGE_NAME, changes.get(CORE_PACKAGE_NAME)?.old ?? current, next);
pkg.version = next;
}

async function upgradeDependencies(): Promise<void> {
console.log('Fetching latest versions…');

Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/prepare_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ jobs:
# release.yml updates HomebrewFormula/vp.rb after the release archives and
# their checksums are published. Do not point the tap at unpublished assets.
- name: Bump versions
id: versions
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
update_json() {
local file=$1
sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$VERSION\"/" "$file"
grep -qF "\"version\": \"$VERSION\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
local file=$1 version=${2:-$VERSION}
sed -i -E "s/^(\s*\"version\":\s*)\"[^\"]+\"/\1\"$version\"/" "$file"
grep -qF "\"version\": \"$version\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
echo "Updated $file"
}
update_toml() {
Expand All @@ -54,12 +55,17 @@ jobs:
grep -qF "version = \"$VERSION\"" "$file" || { echo "::error::Failed to update $file"; exit 1; }
echo "Updated $file"
}
# The core version encodes the bundled Vite version and a release
# counter read from the registry (rfcs/core-version-alignment.md).
CORE_VERSION=$(node packages/tools/src/core-version.ts next --vite-plus "$VERSION")
echo "core-version=$CORE_VERSION" >> "$GITHUB_OUTPUT"
update_json packages/cli/package.json
update_json packages/core/package.json
update_json packages/core/package.json "$CORE_VERSION"
update_json packages/prompts/package.json
update_json packages/create-vite-plus/package.json
update_toml packages/cli/binding/Cargo.toml
update_toml crates/vp_global_cli/Cargo.toml
node packages/tools/src/core-version.ts check --release

- name: Refresh Cargo.lock
run: cargo check
Expand All @@ -81,7 +87,7 @@ jobs:
base: main
sign-commits: true
body: |
Release vite-plus v${{ inputs.version }}.
Release vite-plus v${{ inputs.version }} with @voidzero-dev/vite-plus-core ${{ steps.versions.outputs.core-version }}.

For a stable release, complete the `HomebrewFormula/vp.rb` update after publication by merging the generated `release/homebrew-v${{ inputs.version }}` PR.

Expand Down
Loading
Loading