Skip to content

Commit 2308a30

Browse files
committed
feat: select Homebrew tap preview builds with an environment variable
1 parent 90dcb25 commit 2308a30

7 files changed

Lines changed: 236 additions & 6 deletions

File tree

‎.github/scripts/__tests__/homebrew-formula.mjs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ await test('updates preserve recipe changes and reset only a previous version re
7575
assert.ok(!result.includes('revision 2'));
7676
assert.ok(result.includes('conflicts_with "vite-plus"'));
7777
assert.ok(result.includes(' def install\n bin.install "vp"'));
78+
assert.ok(result.includes('ENV["HOMEBREW_VP_PR_VERSION"].presence'));
79+
assert.ok(result.includes('Preview.resolve(preview_ref, platform)'));
7880
const sameVersion = result.replace(' license', ' revision 1\n license');
7981
assert.equal(updateFormula(sameVersion, version, assets), sameVersion);
8082
assert.throws(() => updateFormula(result, currentVersion, assets), /downgrade/);
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# frozen_string_literal: true
2+
3+
# Run with `brew ruby` inside the disposable Homebrew e2e prefix.
4+
require "formulary"
5+
6+
def check(condition, message)
7+
raise message unless condition
8+
end
9+
10+
def rejects(message)
11+
yield
12+
rescue StandardError => e
13+
raise unless e.message.include?(message)
14+
else
15+
raise "Expected failure: #{message}"
16+
end
17+
18+
source = Pathname.new(__dir__).join("../../../HomebrewFormula/vp.rb").realpath
19+
load_formula = lambda do
20+
Formulary.clear_cache
21+
Formulary.from_contents("vp", source, source.read)
22+
end
23+
24+
# Metadata requests are fixtures. Archive downloads use Homebrew's real cache
25+
# and resource implementation, with bytes placed at the expected cache key.
26+
metadata = nil
27+
requests = []
28+
commit = "a" * 40
29+
commit_key = "voidzero-dev:vite-plus:#{commit}"
30+
Utils::Curl.define_singleton_method(:curl_output) do |*args, **_options|
31+
requests << args.last
32+
raise "Unexpected metadata request" unless metadata
33+
34+
Struct.new(:stdout) do
35+
def assert_success!; end
36+
end.new(JSON.generate(metadata))
37+
end
38+
Utils::Curl.define_singleton_method(:curl_headers) do |*args, **_options|
39+
requests << args.last
40+
{ responses: [{ headers: { "x-commit-key" => commit_key } }] }
41+
end
42+
43+
ENV.delete("HOMEBREW_VP_PR_VERSION")
44+
stable = load_formula.call
45+
check(stable.stable.url.start_with?("https://github.com/voidzero-dev/vite-plus/releases/"), "Stable URL changed")
46+
check(requests.empty?, "Stable formula requested preview metadata")
47+
preview = stable.class.const_get(:Preview)
48+
49+
seed = lambda do |platform, sha, bytes = "preview archive"|
50+
version = "0.0.0-commit.#{sha}"
51+
package = "@voidzero-dev/vite-plus-cli-#{platform}"
52+
shasum = Digest::SHA1.hexdigest(bytes)
53+
url = "https://registry-bridge.viteplus.dev/tarballs/#{package}/#{version}/#{shasum}.tgz"
54+
archive = HOMEBREW_CACHE/"downloads/#{Digest::SHA256.hexdigest(url)}--#{shasum}.tgz"
55+
archive.dirname.mkpath
56+
archive.write(bytes)
57+
cached_metadata = HOMEBREW_CACHE/"vp-preview/#{sha}-#{platform}.json"
58+
cached_metadata.unlink if cached_metadata.exist?
59+
metadata = {
60+
"name" => package, "version" => version,
61+
"dist" => { "tarball" => url, "shasum" => shasum,
62+
"integrity" => "sha512-#{Digest::SHA512.base64digest(bytes)}" },
63+
}
64+
archive
65+
end
66+
67+
%w[../main main 0 12x abcdef0].each do |ref|
68+
rejects("PR number or a full commit SHA") { preview.resolve(ref, "darwin-arm64") }
69+
end
70+
check(requests.empty?, "Invalid selectors made network requests")
71+
72+
commit_key = ""
73+
rejects("No published Vite+ preview") { preview.resolve("2740", "darwin-arm64") }
74+
commit_key = "voidzero-dev:vite-plus:#{commit}"
75+
76+
%w[darwin-arm64 darwin-x64 linux-arm64-gnu linux-x64-gnu].each do |platform|
77+
archive = seed.call(platform, commit)
78+
result = preview.resolve("2740", platform)
79+
check(result[:version] == "0.0.0-commit.#{commit}", "PR did not resolve to a commit")
80+
check(result[:sha256] == Digest::SHA256.file(archive).hexdigest, "Wrong SHA-256")
81+
check(ENV["HOMEBREW_VP_PR_VERSION"] == commit, "Build subprocess was not pinned")
82+
count = requests.length
83+
check(preview.resolve(commit, platform) == result, "Cached preview changed")
84+
check(requests.length == count, "Pinned build requested metadata again")
85+
end
86+
puts "PR resolution, all four platforms, integrity verification, and offline build subprocesses pass."
87+
88+
platform = "#{OS.mac? ? "darwin" : "linux"}-#{Hardware::CPU.arm? ? "arm64" : "x64"}#{"-gnu" if OS.linux?}"
89+
expected = preview.resolve(commit, platform)
90+
ENV["HOMEBREW_VP_PR_VERSION"] = commit.upcase
91+
selected = load_formula.call
92+
check(selected.version.to_s == "0.0.0-commit.#{commit}", "Formula did not select the preview")
93+
check(!selected.disabled?, "Preview kept the stable launch gate")
94+
check(selected.stable.url == expected[:url], "Wrong formula URL")
95+
check(selected.stable.checksum.to_s == Digest::SHA256.hexdigest("preview archive"), "Formula lost its checksum")
96+
97+
seed.call(platform, "b" * 40)
98+
metadata["version"] = "0.3.3"
99+
rejects("does not match") { preview.resolve("b" * 40, platform) }
100+
seed.call(platform, "c" * 40)
101+
metadata["dist"]["tarball"] = "https://example.test/untrusted.tgz"
102+
rejects("invalid download URL or checksum") { preview.resolve("c" * 40, platform) }
103+
seed.call(platform, "d" * 40)
104+
metadata["dist"]["integrity"] = "sha512-invalid"
105+
rejects("invalid download URL or checksum") { preview.resolve("d" * 40, platform) }
106+
archive = seed.call(platform, "e" * 40)
107+
archive.write("corrupted archive")
108+
rejects("checksum mismatch") { preview.resolve("e" * 40, platform) }
109+
check(!archive.exist?, "Corrupted download stayed in the cache")
110+
puts "Wrong versions, foreign URLs, invalid integrity, and corrupt archives are rejected."
111+
112+
ENV.delete("HOMEBREW_VP_PR_VERSION")
113+
restored = load_formula.call
114+
check(restored.version == stable.version && restored.stable.url == stable.stable.url, "Stable selection did not return")
115+
puts "Removing the selector restores the stable formula."

‎.github/scripts/test-homebrew.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ try {
233233
HOMEBREW_NO_INSTALL_CLEANUP: '1',
234234
PATH: [noNpm, '/usr/bin', '/bin', '/usr/sbin', '/sbin'].join(path.delimiter),
235235
};
236+
await run(
237+
brew,
238+
['ruby', path.join(repository, '.github/scripts/__tests__/homebrew-preview.rb')],
239+
brewEnv,
240+
);
236241
const tapSource = path.join(root, 'tap');
237242
await fs.mkdir(path.join(tapSource, 'HomebrewFormula'), { recursive: true });
238243
await fs.writeFile(

‎.github/workflows/test-homebrew.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ on:
1212
- 'HomebrewFormula/**'
1313
- '.github/scripts/*homebrew*'
1414
- '.github/scripts/__tests__/homebrew-formula.mjs'
15+
- '.github/scripts/__tests__/homebrew-preview.rb'
1516
- '.github/workflows/test-homebrew.yml'
1617
- '.github/workflows/release.yml'
1718
- 'crates/vp_setup/**'
@@ -57,6 +58,7 @@ jobs:
5758
- 'HomebrewFormula/**'
5859
- '.github/scripts/*homebrew*'
5960
- '.github/scripts/__tests__/homebrew-formula.mjs'
61+
- '.github/scripts/__tests__/homebrew-preview.rb'
6062
- '.github/workflows/test-homebrew.yml'
6163
- '.github/workflows/release.yml'
6264
- 'crates/vp_setup/**'

‎HomebrewFormula/vp.rb‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,83 @@
11
# frozen_string_literal: true
22

3+
require "digest"
4+
35
class Vp < Formula
6+
# Keep this helper in the formula: Homebrew also loads the copy saved in the keg.
7+
module Preview
8+
REGISTRY = "https://registry-bridge.viteplus.dev"
9+
10+
def self.resolve(ref, platform)
11+
unless ref.match?(/\A(?:[1-9]\d*|[a-fA-F0-9]{40})\z/)
12+
raise ArgumentError, "HOMEBREW_VP_PR_VERSION must be a PR number or a full commit SHA"
13+
end
14+
15+
sha = ref.downcase
16+
unless sha.match?(/\A[a-f0-9]{40}\z/)
17+
headers = Utils::Curl.curl_headers(
18+
"--max-time", "30", "#{REGISTRY}/voidzero-dev/vite-plus@#{ref}",
19+
wanted_headers: ["x-commit-key"]
20+
)
21+
key = headers.fetch(:responses).last.fetch(:headers).fetch("x-commit-key", "")
22+
sha = key[/\Avoidzero-dev:vite-plus:([a-f0-9]{40})\z/, 1]
23+
raise "No published Vite+ preview for PR #{ref}; apply the preview-build label first" unless sha
24+
end
25+
26+
preview_version = "0.0.0-commit.#{sha}"
27+
package = "@voidzero-dev/vite-plus-cli-#{platform}"
28+
# Build subprocesses must use the same commit and need no metadata network access.
29+
cache = HOMEBREW_CACHE/"vp-preview/#{sha}-#{platform}.json"
30+
metadata = if cache.file?
31+
JSON.parse(cache.read)
32+
else
33+
result = Utils::Curl.curl_output(
34+
"--fail", "--silent", "--show-error", "--max-time", "30",
35+
"#{REGISTRY}/#{package}/#{preview_version}"
36+
)
37+
result.assert_success!
38+
JSON.parse(result.stdout)
39+
end
40+
if metadata["name"] != package || metadata["version"] != preview_version
41+
raise "Preview metadata does not match #{package}@#{preview_version}"
42+
end
43+
44+
dist = metadata.fetch("dist")
45+
shasum = dist.fetch("shasum")
46+
download_url = "#{REGISTRY}/tarballs/#{package}/#{preview_version}/#{shasum}.tgz"
47+
integrity = dist.fetch("integrity")
48+
if !shasum.match?(/\A[a-f0-9]{40}\z/) || dist["tarball"] != download_url ||
49+
!integrity.match?(%r{\Asha512-[A-Za-z0-9+/]{86}==\z})
50+
raise "Preview metadata has an invalid download URL or checksum"
51+
end
52+
53+
resource = Resource.new("vp-preview") do
54+
url download_url
55+
version preview_version
56+
end
57+
# npm publishes SHA-512. Verify it before deriving Homebrew's SHA-256;
58+
# the normal formula fetch reuses this archive from Homebrew's download cache.
59+
archive = resource.cached_download
60+
resource.fetch(verify_download_integrity: false, quiet: true) unless archive.file?
61+
if "sha512-#{Digest::SHA512.file(archive).base64digest}" != integrity
62+
resource.clear_cache
63+
raise "Vite+ preview checksum mismatch"
64+
end
65+
unless cache.file?
66+
cache.dirname.mkpath
67+
cache.atomic_write(JSON.generate(metadata))
68+
end
69+
ENV["HOMEBREW_VP_PR_VERSION"] = sha
70+
{ version: preview_version, url: download_url, sha256: Digest::SHA256.file(archive).hexdigest }
71+
end
72+
end
473
desc "Unified toolchain for the web"
574
homepage "https://viteplus.dev/"
675
license "MIT"
776

77+
preview_ref = ENV["HOMEBREW_VP_PR_VERSION"].presence
78+
879
# The updater removes this gate after the first compatible release is published.
9-
disable! date: "2026-09-22", because: "requires a release with per-user Homebrew setup"
80+
disable! date: "2026-09-22", because: "requires a release with per-user Homebrew setup" unless preview_ref
1081

1182
# BEGIN RELEASE ASSETS
1283
on_macos do
@@ -32,6 +103,16 @@ class Vp < Formula
32103
end
33104
# END RELEASE ASSETS
34105

106+
if preview_ref
107+
os = OS.mac? ? "darwin" : "linux"
108+
arch = Hardware::CPU.arm? ? "arm64" : "x64"
109+
platform = "#{os}-#{arch}#{"-gnu" if OS.linux?}"
110+
preview = Preview.resolve(preview_ref, platform)
111+
url preview.fetch(:url)
112+
version preview.fetch(:version)
113+
sha256 preview.fetch(:sha256)
114+
end
115+
35116
conflicts_with "vite-plus", because: "both install vp, vpr, and vpx"
36117

37118
def install

‎docs/guide/homebrew.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Homebrew tap
22

33
::: warning Availability
4-
The official tap is not available yet. Its formula stays disabled until the first release with per-user Homebrew setup is published.
4+
Stable installation stays disabled until the first release with per-user Homebrew setup is published. Preview builds can be tested with the selector below.
55
Use the [script installer](/guide/global-cli) or Homebrew core in the meantime.
66
:::
77

@@ -76,3 +76,22 @@ brew uninstall voidzero-dev/vite-plus/vp
7676
```
7777

7878
`brew uninstall` alone keeps user data. Each user can remove their own data with `vp implode` before the shared executable is removed.
79+
80+
## Test a preview build
81+
82+
Set `HOMEBREW_VP_PR_VERSION` to a PR number or full commit SHA. The PR must have a published `preview-build` that includes Homebrew tap support.
83+
84+
```bash
85+
HOMEBREW_VP_PR_VERSION="<pr-or-sha>" brew install voidzero-dev/vite-plus/vp
86+
HOMEBREW_VP_PR_VERSION="<pr-or-sha>" brew test voidzero-dev/vite-plus/vp
87+
```
88+
89+
Use `brew reinstall` instead of `brew install` to replace an installed build. Use a full SHA to keep installation and verification on the same commit.
90+
`brew test` uses temporary user data and does not change your shell configuration.
91+
92+
Homebrew downloads only the native preview package from the registry bridge and verifies its checksum. It resolves PR numbers to the latest published commit, which can differ from the current PR head.
93+
First launch installs the matching dependencies from the bridge. An explicit `NPM_CONFIG_REGISTRY` must point to a registry that also serves those preview packages.
94+
95+
The selector is specific to this tap. It does not change other Homebrew formulae, and no npm client is needed during `brew install`.
96+
To test changes to the formula itself, first check out the PR in the tap repository. Set `HOMEBREW_NO_AUTO_UPDATE=1` for these commands to keep that checkout.
97+
To return to a stable release once available, run `brew reinstall voidzero-dev/vite-plus/vp` without the selector.

‎rfcs/official-homebrew-tap.md‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# RFC: Vite+-managed Homebrew tap
22

3-
Status: Implementation proposed. The formula stays disabled until a compatible
4-
release is published and its formula update is approved.
3+
Status: Implementation proposed. Stable installation stays disabled until a
4+
compatible release is published and its formula update is approved.
55

66
## Motivation
77

@@ -270,6 +270,12 @@ merge must not trigger another product release.
270270
The updater checks the release tag for the new bootstrap and formula before
271271
removing the initial disable gate. Existing releases cannot enable the tap.
272272

273+
For early testing, `HOMEBREW_VP_PR_VERSION` selects a published preview by PR
274+
number or full commit SHA. The formula downloads the native package from the
275+
registry bridge and verifies its integrity. First use installs matching preview
276+
dependencies. The [Homebrew guide](../docs/guide/homebrew.md#test-a-preview-build)
277+
describes this opt-in path; normal installation continues to use GitHub Releases.
278+
273279
Vite+ release maintainers own the formula and its release automation through
274280
the existing repository review process. Confirm a primary maintainer and a
275281
backup before launch. Formula failures belong in this repository's issue tracker.
@@ -300,6 +306,6 @@ migration guides when the tap is ready.
300306

301307
## Tradeoffs
302308

303-
The formula stays small and dependency installation shares the script installer's
304-
code. Each user downloads and stores their own dependencies. Homebrew manages
309+
Dependency installation shares the script installer's code. Each user downloads
310+
and stores their own dependencies. Homebrew manages
305311
the executable; Vite+ manages the per-user JavaScript installation.

0 commit comments

Comments
 (0)