Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7497594
docs: propose a Vite+-managed Homebrew tap
fengmk2 Sep 18, 2026
72ca08a
docs: keep Homebrew tap in vite-plus repository
fengmk2 Sep 18, 2026
c93fdc7
docs: link original Homebrew distribution requirement
fengmk2 Sep 18, 2026
b028af7
docs: describe authenticated npm downloads for Homebrew tap
fengmk2 Sep 18, 2026
64130b0
docs: rename proposed Homebrew npm registry variable
fengmk2 Sep 18, 2026
18bdfba
docs: rename proposed Homebrew npm token variable
fengmk2 Sep 18, 2026
87fd6c0
docs: use HOMEBREW_NPM_TOKEN in tap RFC
fengmk2 Sep 18, 2026
4f93bb9
docs: make npm primary in Homebrew tap RFC
fengmk2 Sep 18, 2026
3f97904
docs: simplify Homebrew tap RFC
fengmk2 Sep 18, 2026
c94d115
docs: simplify tap with first-run dependency setup
fengmk2 Sep 19, 2026
bbb9cf5
docs: assume network access for Homebrew tap
fengmk2 Sep 19, 2026
96b608a
docs: illustrate Homebrew installation stages
fengmk2 Sep 19, 2026
13f624d
docs: align Homebrew tap RFC with current setup
fengmk2 Sep 22, 2026
0200559
docs: propose Homebrew tap launch and bootstrap decisions
fengmk2 Sep 22, 2026
0584f30
feat: add official Homebrew tap with per-user setup
fengmk2 Sep 22, 2026
4fdc1eb
fix: support native hosts in Homebrew installation tests
fengmk2 Sep 22, 2026
57f07c2
fix: stabilize Homebrew paths and installation checks
fengmk2 Sep 22, 2026
752d0f9
test: keep formula checks valid across releases
fengmk2 Sep 22, 2026
cdd32dd
test: keep Homebrew prefixes outside system temp dirs
fengmk2 Sep 22, 2026
6ce3b0c
test: support prerelease versions in Homebrew e2e
fengmk2 Sep 22, 2026
93b32ff
fix: preserve Homebrew entrypoints and repair package shims
fengmk2 Sep 22, 2026
df6a4c0
feat: select Homebrew tap preview builds with an environment variable
fengmk2 Sep 23, 2026
cc9c621
docs: require Homebrew formula updates for stable releases
fengmk2 Sep 24, 2026
019ab84
refactor: distinguish Homebrew sources and document the official tap
fengmk2 Sep 24, 2026
d002b1e
fix(ci): keep unrelated labels from canceling install previews
fengmk2 Sep 24, 2026
5da5674
refactor: reuse shared installer for Homebrew tap setup
fengmk2 Oct 21, 2026
6a1fa59
fix(ci): scope Homebrew release token permissions
fengmk2 Oct 21, 2026
7fd413b
fix: expand npm user config paths before installer handoff
fengmk2 Oct 21, 2026
bb7ead8
fix: expand registry overrides during pnpm bootstrap
fengmk2 Oct 23, 2026
2bbddfa
test: handle Windows environment aliases in installer coverage
fengmk2 Oct 23, 2026
0004b3b
test: compare installer config paths using platform semantics
fengmk2 Oct 23, 2026
176bd6f
fix: serialize Homebrew package shim migration
fengmk2 Oct 25, 2026
17b2975
ci: include Homebrew installation in preview comments
fengmk2 Oct 25, 2026
7b670e2
test: account for the global bin lock in external setup
fengmk2 Oct 25, 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 @@ -398,6 +407,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 @@ -466,6 +476,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 @@ -557,6 +571,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