Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a1eb2ec
docs: propose a Vite+-managed Homebrew tap
fengmk2 Sep 18, 2026
646788a
docs: keep Homebrew tap in vite-plus repository
fengmk2 Sep 18, 2026
f509b98
docs: link original Homebrew distribution requirement
fengmk2 Sep 18, 2026
7d26116
docs: describe authenticated npm downloads for Homebrew tap
fengmk2 Sep 18, 2026
cb7ba6a
docs: rename proposed Homebrew npm registry variable
fengmk2 Sep 18, 2026
ddfd083
docs: rename proposed Homebrew npm token variable
fengmk2 Sep 18, 2026
0d08fbc
docs: use HOMEBREW_NPM_TOKEN in tap RFC
fengmk2 Sep 18, 2026
14870f9
docs: make npm primary in Homebrew tap RFC
fengmk2 Sep 18, 2026
13c5442
docs: simplify Homebrew tap RFC
fengmk2 Sep 18, 2026
c905d95
docs: simplify tap with first-run dependency setup
fengmk2 Sep 19, 2026
0dcceb5
docs: assume network access for Homebrew tap
fengmk2 Sep 19, 2026
b316ff1
docs: illustrate Homebrew installation stages
fengmk2 Sep 19, 2026
8ad6bc5
docs: align Homebrew tap RFC with current setup
fengmk2 Sep 22, 2026
1bba437
docs: propose Homebrew tap launch and bootstrap decisions
fengmk2 Sep 22, 2026
f7aec7f
feat: add official Homebrew tap with per-user setup
fengmk2 Sep 22, 2026
455a9fb
fix: support native hosts in Homebrew installation tests
fengmk2 Sep 22, 2026
3b168ef
fix: stabilize Homebrew paths and installation checks
fengmk2 Sep 22, 2026
cc4545c
test: keep formula checks valid across releases
fengmk2 Sep 22, 2026
31deeaf
test: keep Homebrew prefixes outside system temp dirs
fengmk2 Sep 22, 2026
6ddf97d
test: support prerelease versions in Homebrew e2e
fengmk2 Sep 22, 2026
0317409
fix: preserve Homebrew entrypoints and repair package shims
fengmk2 Sep 22, 2026
dffa93d
feat: select Homebrew tap preview builds with an environment variable
fengmk2 Sep 23, 2026
b003719
docs: require Homebrew formula updates for stable releases
fengmk2 Sep 24, 2026
25bcc55
refactor: distinguish Homebrew sources and document the official tap
fengmk2 Sep 24, 2026
194ddab
fix(ci): keep unrelated labels from canceling install previews
fengmk2 Sep 24, 2026
88cd288
refactor: reuse shared installer for Homebrew tap setup
fengmk2 Oct 21, 2026
4f0e16c
fix(ci): scope Homebrew release token permissions
fengmk2 Oct 21, 2026
9d180d2
fix: expand npm user config paths before installer handoff
fengmk2 Oct 21, 2026
d8aa648
fix: expand registry overrides during pnpm bootstrap
fengmk2 Oct 23, 2026
28a3ca7
test: handle Windows environment aliases in installer coverage
fengmk2 Oct 23, 2026
2082b0d
test: compare installer config paths using platform semantics
fengmk2 Oct 23, 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
19 changes: 17 additions & 2 deletions .claude/skills/release-manager/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ When given a release PR (URL or number), do not start from step 1. First audit t
- Does `main` have commits the release branch lacks? (`git log origin/release/vX.Y.Z..origin/main`, step 5)
- What is CI status? (`gh pr checks <PR#>`, step 5)
- Already merged? Check the Release workflow (`gh run list --workflow Release --repo voidzero-dev/vite-plus`) and whether the GitHub release body is still the generated stub, then continue at step 7 or 8.
- For a published stable release, is its Homebrew formula update merged and does `HomebrewFormula/vp.rb` select that release? (steps 2 and 8)

Report the detected state before making changes, so the previous release manager's work is not redone or overwritten.

Expand All @@ -35,7 +36,7 @@ Before post-release work, fetch `origin/main` and read its copy of this skill (`
1. `Prepare Release` workflow bumps versions and opens the release PR (`release/vX.Y.Z` -> `main`).
2. Release manager: sync `binding/index.cjs` and the release versions in the documentation and prompts, write the changelog PR description, offer the preview-build smoke test (recommend it when the release has more than 10 commits since the previous tag), get CI green.
3. Merging the PR pushes a `packages/cli/package.json` change to `main`, which triggers `release.yml`: build, manual approval gate, npm publish, GitHub release, Docker image, Discord notification.
4. Release manager: polish the GitHub release notes, verify installs, announce.
4. Release manager: complete the Homebrew formula update for stable releases, polish the GitHub release notes, verify installs, announce.

Canonical sources: `.github/workflows/prepare_release.yml`, `.github/workflows/release.yml`, `.github/workflows/publish-preview.yml`.

Expand All @@ -45,7 +46,7 @@ Canonical sources: `.github/workflows/prepare_release.yml`, `.github/workflows/r
gh workflow run prepare_release.yml --repo voidzero-dev/vite-plus -f version=X.Y.Z
```

The workflow bumps `packages/cli/package.json`, `packages/core/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.
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.

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

Expand Down Expand Up @@ -97,6 +98,14 @@ Commit these updates on the release branch with the binding sync or in a separat

Only these release-version sync commits go directly on the release branch. Everything else goes through `main` (see step 5).

### Homebrew tap

Every stable version change, including a manual bump or a changed release target, also requires an update to `HomebrewFormula/vp.rb`. Keep its current release URLs and checksums during preparation: the new archive checksums do not exist yet.

After publication, `release.yml` runs `.github/scripts/update-homebrew-formula.mjs` and opens `release/homebrew-vX.Y.Z`. The script verifies all four release archives and matching npm packages before updating the formula. Review and merge that PR through the normal review process; an open update PR does not complete the tap release. Follow step 8 for verification and recovery.

Prereleases and preview builds do not advance the stable formula. Test a published preview with `HOMEBREW_VP_PR_VERSION=<full-commit-sha>` as described in `docs/guide/homebrew.md`.

## 3. Write the release PR description

The release tag does not exist yet, so read release files from the PR head branch and generate notes against `main`:
Expand Down Expand Up @@ -395,6 +404,7 @@ Auto-merge being enabled is not a completed merge. Confirm `mergedAt` and the me
5. `publish-docker`: multi-arch toolchain image to `ghcr.io/voidzero-dev/vite-plus`, after npm publish (the image installs vp from npm).
6. `deploy-docs`: deploys the production docs after a stable release is published. It is skipped for prereleases. When a prerelease is published to `latest` and its notes or CLI messages link to docs that production does not serve yet, ask the release manager whether to run `gh workflow run deploy-docs.yml --ref <ref>` once the `Release` job is publishing. Use the `vX.Y.Z` tag, or `main` while it still points at the release commit, and confirm the run's head SHA. The `vp` version that builds the docs does not need to match the release: the site and its install scripts come from the checked-out commit. The job authenticates with the `VOID_TOKEN` repository secret. When that token has expired, the job fails with `` `VOID_TOKEN` is invalid or expired `` and `discord-notify` is skipped. Ask someone with access to rotate the secret, then run `gh run rerun <run-id> --failed` to rerun the docs deployment and the Discord notification.
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.

**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.

Expand Down Expand Up @@ -463,6 +473,10 @@ Local clients can also mislead in the other direction. npm and pnpm cache a look

2. **Verify**:

For each stable release, confirm that `release/homebrew-vX.Y.Z` merged and `HomebrewFormula/vp.rb` has the target version in all four release URLs, with their matching checksums. If the job failed, fix the publication problem and rerun `update-homebrew`. For manual recovery, run `node .github/scripts/update-homebrew-formula.mjs X.Y.Z` from current `main` and submit the resulting formula and guide changes for review. Do not replace version strings while retaining old checksums.

In an isolated Homebrew installation, update the tap, install or upgrade `voidzero-dev/vite-plus/vp`, and run `brew test voidzero-dev/vite-plus/vp`. Unset `HOMEBREW_VP_PR_VERSION` for stable verification. Confirm `"$(brew --prefix)/bin/vp" --version` reports the target version, so a script-install shim cannot mask the result. Keep the tap release pending if the formula update or these checks are incomplete.

```bash
npm view vite-plus version # X.Y.Z
npm view @voidzero-dev/vite-plus-core version # X.Y.Z
Expand Down Expand Up @@ -554,6 +568,7 @@ After the release ships and announcements are approved or confirmed complete, re
- [ ] Smoke test offered to the release manager at both levels (local sweep and fork-PR CI), with the commit count stated and a recommendation to run it when that count is above 10; if accepted, forks synced to upstream first, preview build published, and the full ecosystem-ci catalog verified via `test-pkg-pr-new-migrate` (following TESTING.md), with every failure triaged and regressions ruled out against the previous release
- [ ] CI green; any fixes landed via separate PRs to main, merged back, and added to the changelog
- [ ] Release PR merged; `release` environment approved by someone other than the merger; npm + GitHub release + Docker image all published
- [ ] For a stable release, `HomebrewFormula/vp.rb` updated to the target version and verified archive checksums; formula PR merged; Homebrew installation and `brew test` verified without the preview selector
- [ ] GitHub release notes polished (release manager approved before applying), retitled, and validated; Installation ends with the Docker usage block and is followed by the Create a New App section; the prerelease flag matches the version
- [ ] Installs verified (npm versions + latest tag, `vp upgrade`, `vp --version` output inside the ghcr Docker image)
- [ ] Announcements handed over in chat (Discord and any requested X drafts), or confirmed complete by the release manager
Expand Down
24 changes: 9 additions & 15 deletions .github/actions/download-rolldown-binaries/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,26 @@ runs:
- name: Install previous release
shell: bash
run: |
# Always download the binding that matches the runner OS (not the
# Always download the binding that matches the running Node (not the
# Rust cross-compilation target). The rolldown binding is loaded by
# Node.js on the host to run JS build steps, not cross-compiled.
if ${{ runner.os == 'Windows' }}; then
export TARGET="win32-x64-msvc"
elif ${{ runner.os == 'Linux' }}; then
export TARGET="linux-x64-gnu"
elif ${{ runner.os == 'macOS' }}; then
export TARGET="darwin-arm64"
fi
rolldown_host=$(node -p 'process.platform + "-" + process.arch + (process.platform === "linux" ? "-gnu" : process.platform === "win32" ? "-msvc" : "")')

# Pin to the version from checked-out rolldown source to avoid mismatch
# between JS code (built from source) and native binary (downloaded from npm).
# Falls back to npm latest only when rolldown source isn't cloned yet
# (e.g., the standalone download-previous-rolldown-binaries job).
if [ -f "./rolldown/packages/rolldown/package.json" ]; then
export VERSION=$(node -p "require('./rolldown/packages/rolldown/package.json').version")
echo "Using rolldown version from source: ${VERSION}"
rolldown_version=$(node -p "require('./rolldown/packages/rolldown/package.json').version")
echo "Using rolldown version from source: ${rolldown_version}"
else
export VERSION=$(npm view --json rolldown | jq -r '.version')
echo "Warning: rolldown source not found, using npm latest: ${VERSION}"
rolldown_version=$(npm view --json rolldown | jq -r '.version')
echo "Warning: rolldown source not found, using npm latest: ${rolldown_version}"
fi
npm pack "@rolldown/binding-${TARGET}@${VERSION}"
tar -xzf "rolldown-binding-${TARGET}-${VERSION}.tgz"
npm pack "@rolldown/binding-${rolldown_host}@${rolldown_version}"
tar -xzf "rolldown-binding-${rolldown_host}-${rolldown_version}.tgz"
if [ -d "./rolldown/packages/rolldown/src" ]; then
cp "./package/rolldown-binding.${TARGET}.node" ./rolldown/packages/rolldown/src
cp "./package/rolldown-binding.${rolldown_host}.node" ./rolldown/packages/rolldown/src
ls ./rolldown/packages/rolldown/src
fi
env:
Expand Down
95 changes: 95 additions & 0 deletions .github/scripts/__tests__/homebrew-formula.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';

import {
releaseAssets,
targets,
updateFormula,
verifyRelease,
} from '../update-homebrew-formula.mjs';

const formula = await readFile(new URL('../../../HomebrewFormula/vp.rb', import.meta.url), 'utf8');
const currentVersion = formula.match(/\/releases\/download\/v(\d+\.\d+\.\d+)\//)[1];
const [major, minor, patch] = currentVersion.split('.').map(Number);
const version = `${major}.${minor}.${patch + 1}`;
const archive = Buffer.from('release fixture');
const digest = createHash('sha256').update(archive).digest('hex');
const checksums = targets.map(([, , target]) => `${digest} vp-${target}.tar.gz`).join('\n');
const assets = releaseAssets(version, checksums);
const release = {
tag_name: `v${version}`,
draft: false,
prerelease: false,
assets: assets.map((asset) => ({ name: asset.name, browser_download_url: asset.url })),
};

function download(url) {
return Promise.resolve(new Response(url.endsWith('vp-checksums.txt') ? checksums : archive));
}

await test('release publication requires the compatible tag and each verified archive', async () => {
assert.deepEqual(await verifyRelease(version, release, download), assets);
await assert.rejects(verifyRelease(version, { ...release, draft: true }, download), /published/);
await assert.rejects(
verifyRelease(version, { ...release, assets: release.assets.slice(1) }, download),
/Missing release asset/,
);
await assert.rejects(
verifyRelease(version, release, async (url) =>
url.endsWith('HomebrewFormula/vp.rb') ? new Response('', { status: 404 }) : download(url),
),
/Download failed/,
);
await assert.rejects(
verifyRelease(version, release, async (url) =>
url.endsWith('.tar.gz') ? new Response('corrupt') : download(url),
),
/Checksum mismatch/,
);
});

await test('missing, repeated, and invalid checksums cannot update a formula', () => {
assert.throws(
() => releaseAssets(version, checksums.split('\n').slice(1).join('\n')),
/Expected one checksum/,
);
assert.throws(
() => releaseAssets(version, `${checksums}\n${checksums}`),
/Expected one checksum/,
);
assert.throws(
() => releaseAssets(version, checksums.replace(digest, 'invalid')),
/Invalid checksum/,
);
assert.throws(() => releaseAssets('0.3.4-beta.1', checksums), /stable release/);
});

await test('updates preserve recipe changes and reset only a previous version revision', () => {
const revised = formula
.replace(/^ revision \d+\n/m, '')
.replace(' license', ' revision 2\n license');
const result = updateFormula(revised, version, assets);
assert.ok(!result.includes('disable!'));
assert.ok(!result.includes('revision 2'));
assert.ok(result.includes('conflicts_with "vite-plus"'));
assert.ok(result.includes(' def install\n libexec.install "vp", "vp-install-lock.yaml"'));
assert.ok(result.includes('ENV["HOMEBREW_VP_PR_VERSION"].presence'));
assert.ok(result.includes('Preview.resolve(preview_ref, platform)'));
const sameVersion = result.replace(' license', ' revision 1\n license');
assert.equal(updateFormula(sameVersion, version, assets), sameVersion);
assert.throws(() => updateFormula(result, currentVersion, assets), /downgrade/);
assert.throws(
() => updateFormula(formula.replace('BEGIN RELEASE ASSETS', 'missing'), version, assets),
/Missing release asset block/,
);
assert.throws(
() =>
updateFormula(formula, version, [
{ ...assets[0], url: 'https://example.test/"; system("bad")' },
...assets.slice(1),
]),
/Unsafe formula URL/,
);
});
115 changes: 115 additions & 0 deletions .github/scripts/__tests__/homebrew-preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# frozen_string_literal: true

# Run with `brew ruby` inside the disposable Homebrew e2e prefix.
require "formulary"

def check(condition, message)
raise message unless condition
end

def rejects(message)
yield
rescue StandardError => e
raise unless e.message.include?(message)
else
raise "Expected failure: #{message}"
end

source = Pathname.new(__dir__).join("../../../HomebrewFormula/vp.rb").realpath
load_formula = lambda do
Formulary.clear_cache
Formulary.from_contents("vp", source, source.read)
end

# Metadata requests are fixtures. Archive downloads use Homebrew's real cache
# and resource implementation, with bytes placed at the expected cache key.
metadata = nil
requests = []
commit = "a" * 40
commit_key = "voidzero-dev:vite-plus:#{commit}"
Utils::Curl.define_singleton_method(:curl_output) do |*args, **_options|
requests << args.last
raise "Unexpected metadata request" unless metadata

Struct.new(:stdout) do
def assert_success!; end
end.new(JSON.generate(metadata))
end
Utils::Curl.define_singleton_method(:curl_headers) do |*args, **_options|
requests << args.last
{ responses: [{ headers: { "x-commit-key" => commit_key } }] }
end

ENV.delete("HOMEBREW_VP_PR_VERSION")
stable = load_formula.call
check(stable.stable.url.start_with?("https://github.com/voidzero-dev/vite-plus/releases/"), "Stable URL changed")
check(requests.empty?, "Stable formula requested preview metadata")
preview = stable.class.const_get(:Preview)

seed = lambda do |platform, sha, bytes = "preview archive"|
version = "0.0.0-commit.#{sha}"
package = "@voidzero-dev/vite-plus-cli-#{platform}"
shasum = Digest::SHA1.hexdigest(bytes)
url = "https://registry-bridge.viteplus.dev/tarballs/#{package}/#{version}/#{shasum}.tgz"
archive = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--#{shasum}.tgz"
archive.dirname.mkpath
archive.write(bytes)
cached_metadata = HOMEBREW_CACHE/"vp-preview/#{sha}-#{platform}.json"
cached_metadata.unlink if cached_metadata.exist?
metadata = {
"name" => package, "version" => version,
"dist" => { "tarball" => url, "shasum" => shasum,
"integrity" => "sha512-#{Digest::SHA512.base64digest(bytes)}" },
}
archive
end

%w[../main main 0 12x abcdef0].each do |ref|
rejects("PR number or a full commit SHA") { preview.resolve(ref, "darwin-arm64") }
end
check(requests.empty?, "Invalid selectors made network requests")

commit_key = ""
rejects("No published Vite+ preview") { preview.resolve("2740", "darwin-arm64") }
commit_key = "voidzero-dev:vite-plus:#{commit}"

%w[darwin-arm64 darwin-x64 linux-arm64-gnu linux-x64-gnu].each do |platform|
archive = seed.call(platform, commit)
result = preview.resolve("2740", platform)
check(result[:version] == "0.0.0-commit.#{commit}", "PR did not resolve to a commit")
check(result[:sha256] == Digest::SHA256.file(archive).hexdigest, "Wrong SHA-256")
check(ENV["HOMEBREW_VP_PR_VERSION"] == commit, "Build subprocess was not pinned")
count = requests.length
check(preview.resolve(commit, platform) == result, "Cached preview changed")
check(requests.length == count, "Pinned build requested metadata again")
end
puts "PR resolution, all four platforms, integrity verification, and offline build subprocesses pass."

platform = "#{OS.mac? ? "darwin" : "linux"}-#{Hardware::CPU.arm? ? "arm64" : "x64"}#{"-gnu" if OS.linux?}"
expected = preview.resolve(commit, platform)
ENV["HOMEBREW_VP_PR_VERSION"] = commit.upcase
selected = load_formula.call
check(selected.version.to_s == "0.0.0-commit.#{commit}", "Formula did not select the preview")
check(!selected.disabled?, "Preview kept the stable launch gate")
check(selected.stable.url == expected[:url], "Wrong formula URL")
check(selected.stable.checksum.to_s == Digest::SHA256.hexdigest("preview archive"), "Formula lost its checksum")

seed.call(platform, "b" * 40)
metadata["version"] = "0.3.3"
rejects("does not match") { preview.resolve("b" * 40, platform) }
seed.call(platform, "c" * 40)
metadata["dist"]["tarball"] = "https://example.test/untrusted.tgz"
rejects("invalid download URL or checksum") { preview.resolve("c" * 40, platform) }
seed.call(platform, "d" * 40)
metadata["dist"]["integrity"] = "sha512-invalid"
rejects("invalid download URL or checksum") { preview.resolve("d" * 40, platform) }
archive = seed.call(platform, "e" * 40)
archive.write("corrupted archive")
rejects("checksum mismatch") { preview.resolve("e" * 40, platform) }
check(!archive.exist?, "Corrupted download stayed in the cache")
puts "Wrong versions, foreign URLs, invalid integrity, and corrupt archives are rejected."

ENV.delete("HOMEBREW_VP_PR_VERSION")
restored = load_formula.call
check(restored.version == stable.version && restored.stable.url == stable.stable.url, "Stable selection did not return")
puts "Removing the selector restores the stable formula."
Loading
Loading