Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 5e93b5dbf7 test(update): give the Homebrew fixtures a receipt, and stop gating the receipt rule on the host
The previous commit made a receipt the thing that distinguishes a keg from any
directory sitting under a path containing "Cellar". Two fixtures still built a
keg without one, so detection correctly answered standalone and both failed:

  TestDetectInstallMethodRecognisesAHomebrewKeg  keg path: got "standalone", want "homebrew"
  TestApplyRefusesToUpdateAHomebrewKeg           refusal does not name the command that works:
                                                 download release archive: dial tcp: lookup example.test

The second is worth reading twice: with detection returning standalone, Apply
stopped refusing and went to the network, so the failure surfaced as a DNS error
rather than as a missing refusal. Both fixtures write a receipt now.

The reason this reached CI at all is the second half. Every test of the receipt
rule passed runtime.GOOS to isHomebrewPath, which already takes the target as a
parameter, so all of them skipped on Windows and the rule could only ever break
somewhere else. They pass an explicit target now and run everywhere. The two
that exercise DetectInstallMethod stay host-gated, because those are about the
wired entry point rather than the rule, but the rule underneath them is no
longer invisible here.

Verified the receipt path arithmetic on both non-Windows targets rather than
trusting the host-gated tests: isHomebrewPath is false before the receipt is
written and true after, with the receipt at the keg directory hasHomebrewReceipt
actually reads.
2026-08-20 18:46:00 +05:30
Vasanthdev2004 833573681a fix(update): require a Homebrew receipt before refusing self-update
Review found the keg check was shape-only, and it was right: a directory
tree a user built themselves can have exactly a keg's shape. Classifying
one as Homebrew refuses a `zero upgrade` that would have worked
perfectly, on a machine Homebrew has never touched.

Two changes, cheap one first. The shape rule now requires the full
<prefix>/Cellar/<formula>/<version>/bin/<binary> depth rather than one
segment fewer, which already excludes the exact path the review raised.
Then, only for a path that still looks like a keg, one Stat for
INSTALL_RECEIPT.json — the file Homebrew writes into every keg and
nothing else does.

The receipt is what the old comment argued against, on the grounds that
it would put filesystem I/O on a path that runs at every version check.
That is no longer true of it: the Stat happens only after the shape test
has already matched, so an ordinary install still reaches no filesystem
here at all.

A directory named INSTALL_RECEIPT.json is not a receipt, which a bare
Stat would have accepted silently.

NOT taken: the same review asked for the Homebrew branch to be moved
ahead of the cross-target one in upgradeGuidance. That ordering is
deliberate and already pinned by
TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew, whose comment
predicted exactly this reading — "it reads like an ordering bug until you
see which question is being answered". Homebrew is a property of the
binary on THIS machine; when the check was asked about a different
target the answer belongs to that other machine, and `brew upgrade zero`
would change this one instead. The reasoning is now recorded at the
branch as well as in the test, so the next reader finds it in both
places.

The install docs claimed detection was the Cellar path alone, which
undersold it, and stated the self-update rule absolutely in one paragraph
before excepting mise in the next. Both say the same thing now.
2026-08-19 17:04:24 +05:30
Vasanthdev2004 6bc820aa7b chore: merge main 2026-08-19 12:50:17 +05:30
Vasanthdev2004 a9e1aa81e1 chore: merge main for the Go 1.26.6 and x/image vulnerability fixes 2026-08-18 11:29:50 +05:30
Vasanthdev2004 f12911d4bc test(update): build the release fixture from the running platform 2026-08-15 21:37:41 +05:30
Vasanthdev2004 5763eb2d80 fix(update): match the keg shape, correct the docs, cover Apply and cross-target 2026-08-15 21:32:51 +05:30
Vasanthdev2004 6dcc51e591 feat(update): recognise Homebrew installs and add mise, go install docs 2026-08-15 21:21:55 +05:30
8 changed files with 586 additions and 15 deletions

No files matched your search

+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -76,7 +76,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### From source
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
```bash
git clone https://github.com/Gitlawb/zero.git
+2 -2
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
<img alt="Go 1.26.6+" src="https://img.shields.io/badge/Go-1.26.6+-00ADD8?logo=go&logoColor=white">
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
<br>
@@ -57,7 +57,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
### 从源码构建
源码构建需要 Go 1.26.5+。
源码构建需要 Go 1.26.6+。
```bash
git clone https://github.com/Gitlawb/zero.git
+83 -8
View File
@@ -1,14 +1,30 @@
# Installing Zero
Zero is distributed as:
Pick whichever fits how you already install things. None of these is the
blessed one.
- an npm package, `@gitlawb/zero`
- release archives on GitHub Releases
- source builds with Go 1.26.5+
| Method | Command | Self-update |
| --- | --- | --- |
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
| Source | `go build -o zero ./cmd/zero` | rebuild |
The install scripts download a platform-specific release archive and require a
published GitHub Release for the requested version. The npm package is
self-contained: the platform binary installs from the npm registry.
`zero upgrade` follows whatever owns the binary, with one exception it cannot
detect. It delegates to npm for an npm install, refuses outright for a Homebrew
keg and names `brew upgrade zero`, and replaces the binary in place for
everything else — including a mise-managed one, which is the exception.
mise is the one case it cannot detect: a mise-managed binary is an ordinary
standalone install on disk, so `zero upgrade` will replace it and leave mise
describing a version that is no longer there. Use `mise upgrade` instead.
Release archives are the substrate for most of the above: the install scripts
and the npm fallback both download a platform archive from a published GitHub
Release and verify its checksum.
## npm
@@ -111,6 +127,45 @@ Defaults:
- Version: latest GitHub release
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
## mise
[mise](https://mise.jdx.dev/) installs Zero straight from the GitHub Release
archives through its `ubi` backend, so there is nothing extra to publish and no
registry in the middle:
```bash
mise use -g ubi:Gitlawb/zero
```
Pin a version the same way you would any other tool:
```bash
mise use -g ubi:Gitlawb/[email protected]
```
Updates come from `mise upgrade`. `zero upgrade` also works, because a
mise-managed binary is an ordinary standalone install, but then mise's records
describe a version that is no longer on disk. Prefer `mise upgrade`.
## go install
```bash
go install github.com/Gitlawb/zero/cmd/zero@latest
```
This builds from source, so it needs Go 1.26.6+ and it does not go through the
release archives. Two consequences worth knowing before you pick it:
- On Linux you also need the sandbox helper, which is a separate binary and is
not installed by this command. See
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
Without it, native sandboxing is unavailable.
- `zero upgrade` treats the result as a standalone install and will replace the
binary with a release build rather than rebuilding from source. If you chose
`go install` deliberately, rerun it instead.
macOS and Windows need no extra helper.
## From Source
```bash
@@ -125,7 +180,7 @@ Build a local binary:
go build -o zero ./cmd/zero
```
Source builds require Go 1.26.5+.
Source builds require Go 1.26.6+.
### Sandbox Helpers For Source Builds
@@ -242,3 +297,23 @@ zero upgrade
```
See the [update guide](UPDATE.md) for update modes, flags, and platform details.
`zero upgrade` behaves differently depending on how Zero was installed, because
overwriting a binary a package manager owns leaves that manager describing a
version that is no longer there:
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
keg binary directly would be reverted by the next `brew upgrade` or
`brew reinstall`.
- **everything else**: downloads the verified release archive and replaces the
binary in place.
Homebrew is detected by the binary living inside a Cellar keg that carries
Homebrew's own `INSTALL_RECEIPT.json`. The keg shape alone is not enough: a
directory tree you built yourself can have exactly that shape, and refusing to
update an install Homebrew never touched would be a real cost for a made-up
reason. The receipt is the thing only Homebrew writes.
An ordinary install under `/usr/local/bin` is left alone even on an Intel Mac
where that is also the Homebrew prefix.
+13 -1
View File
@@ -66,7 +66,9 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
executablePath = resolved
}
method := DetectInstallMethod(executablePath)
if method != InstallMethodNpm {
// Only the standalone path rewrites the binary in place, so only it needs the
// recovery state. npm and Homebrew hand the work to the package manager.
if method == InstallMethodStandalone {
if err := preflightRecoveryState(executablePath); err != nil {
return ApplyResult{}, err
}
@@ -76,6 +78,16 @@ func Apply(ctx context.Context, options Options) (ApplyResult, error) {
}
switch method {
case InstallMethodHomebrew:
// Refused rather than performed. Running `brew upgrade` for the user would
// touch a package manager they own, and writing the binary directly is
// worse: the keg is what Homebrew's own records describe, so a self-update
// leaves brew reporting a version that is no longer installed, and the next
// `brew upgrade` or `brew reinstall` silently reverts it.
return ApplyResult{}, fmt.Errorf(
"this zero was installed with Homebrew (%s); run `brew upgrade zero` instead, so Homebrew's records match the binary on disk",
executablePath,
)
case InstallMethodNpm:
if err := applyNpmUpdate(ctx); err != nil {
return ApplyResult{}, err
+91 -2
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
)
// npmPackageName is the published package name for the npm distribution of
@@ -18,12 +20,38 @@ type InstallMethod string
const (
InstallMethodNpm InstallMethod = "npm"
InstallMethodHomebrew InstallMethod = "homebrew"
InstallMethodStandalone InstallMethod = "standalone"
)
// DetectInstallMethod inspects the directory containing executablePath for
// npm-install markers left by scripts/postinstall.mjs.
// homebrewCellar is the directory every Homebrew keg lives under:
// <prefix>/Cellar/<formula>/<version>/bin/<binary>.
//
// Matching the Cellar segment rather than the Homebrew PREFIX is deliberate.
// The prefix on Intel macOS is /usr/local, which is also where people put
// hand-installed binaries, so treating the prefix as the signal would classify
// an ordinary /usr/local/bin/zero as Homebrew-managed and refuse an update that
// works fine. Every keg is under Cellar and nothing else is, so this errs
// toward leaving self-update enabled, which is the recoverable direction.
//
// HOMEBREW_CELLAR needs no separate check: it defaults to <prefix>/Cellar and
// Homebrew does not support renaming it.
const homebrewCellar = "Cellar"
// DetectInstallMethod reports how the binary at executablePath was installed.
//
// Symlinks are resolved here rather than at the call sites. Homebrew links
// <prefix>/bin/zero to the keg, and Check did not resolve while Apply did, so
// the two disagreed about what a Homebrew install was: the check printed
// standalone guidance for an install the apply path would have handled
// differently.
func DetectInstallMethod(executablePath string) InstallMethod {
if resolved, err := filepath.EvalSymlinks(executablePath); err == nil {
executablePath = resolved
}
if isHomebrewPath(runtime.GOOS, executablePath) {
return InstallMethodHomebrew
}
dir := filepath.Dir(executablePath)
if _, err := os.Stat(filepath.Join(dir, ".zero-binary-version")); err == nil {
return InstallMethodNpm
@@ -50,3 +78,64 @@ func DetectInstallMethod(executablePath string) InstallMethod {
}
return InstallMethodStandalone
}
// isHomebrewPath reports whether executablePath is inside a Homebrew keg.
//
// goos is a parameter rather than runtime.GOOS so the decision can be tested on
// every target from any machine. Gating it on the real GOOS made the one test
// that matters skip on Windows, which is how a platform rule ends up unverified
// on the platform it excludes.
//
// TWO CHECKS, CHEAP ONE FIRST. The path shape is a filter, not the answer: a
// user's own /work/Cellar/tools/bin/zero has exactly the shape a keg does, and
// classifying it as Homebrew would refuse an update that works. The keg is only
// confirmed by Homebrew's own receipt, which it writes into every keg and
// nothing else does.
//
// The receipt costs one Stat, and only on a path that already looks like a keg —
// so the version check that runs for ordinary installs still does no filesystem
// work here at all.
func isHomebrewPath(goos string, executablePath string) bool {
keg, ok := homebrewKeg(goos, executablePath)
if !ok {
return false
}
return hasHomebrewReceipt(keg)
}
// homebrewKeg returns the <prefix>/Cellar/<formula>/<version> directory that
// executablePath sits inside, if its shape is a keg's.
//
// Deliberately not matched: the formula name, because a tap may name it
// something other than zero.
func homebrewKeg(goos string, executablePath string) (string, bool) {
if goos == "windows" {
// Homebrew does not run here, and a Windows path is far likelier to hold
// an unrelated directory called Cellar than a keg.
return "", false
}
// <prefix>/Cellar/<formula>/<version>/bin/<binary> — the two segments
// Homebrew always inserts, then the bin directory and the binary itself.
// Anything shorter cannot be a keg however it is named.
segments := strings.Split(filepath.ToSlash(executablePath), "/")
for index, segment := range segments {
if segment != homebrewCellar {
continue
}
if len(segments)-index < 5 {
continue
}
return strings.Join(segments[:index+3], "/"), true
}
return "", false
}
// homebrewReceipt is the file Homebrew writes into every keg it installs. Its
// presence is what distinguishes a keg from a directory tree that merely looks
// like one.
const homebrewReceipt = "INSTALL_RECEIPT.json"
func hasHomebrewReceipt(kegDir string) bool {
info, err := os.Stat(filepath.Join(kegDir, homebrewReceipt))
return err == nil && !info.IsDir()
}
@@ -0,0 +1,319 @@
package update
import (
"context"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The path rule itself, checked for every target from any machine. Gating this
// on runtime.GOOS would skip it on Windows, leaving the Windows branch of the
// rule to be verified by nobody.
//
// The keg SHAPE only. Whether a real keg lives there is a separate question with
// a separate answer on disk — see TestOnlyAKegWithAReceiptIsHomebrew, which is
// the check that stops a user's own Cellar directory being classified.
func TestHomebrewKegShapePerTarget(t *testing.T) {
cases := []struct {
goos string
path string
want bool
}{
{"darwin", "/opt/homebrew/Cellar/zero/0.7.1/bin/zero", true},
{"darwin", "/usr/local/Cellar/zero/0.7.1/bin/zero", true},
{"linux", "/home/linuxbrew/.linuxbrew/Cellar/zero/0.7.1/bin/zero", true},
{"darwin", "/usr/local/bin/zero", false},
{"darwin", "/Users/someone/.local/bin/zero", false},
{"linux", "/opt/cellar/bin/zero", false}, // lowercase is not a keg
{"linux", "/opt/CellarX/bin/zero", false}, // segment must match exactly
{"windows", `C:\Cellar\zero\bin\zero.exe`, false}, // Homebrew does not run here
// User directories called Cellar. Too shallow to be a keg.
{"linux", "/home/someone/Cellar/zero", false},
{"darwin", "/Users/someone/Cellar/bin/zero", false},
// The case the reviewer of #910 raised. It is one segment short of a keg,
// so the depth rule alone already refuses it.
{"linux", "/work/Cellar/tools/bin/zero", false},
// One segment deeper it IS a keg by shape, and still not a keg. Nothing in
// the path can tell the difference — only the receipt can, which is what
// TestOnlyAKegWithAReceiptIsHomebrew covers.
{"linux", "/work/Cellar/tools/1.0/bin/zero", true},
}
for _, testCase := range cases {
if _, got := homebrewKeg(testCase.goos, testCase.path); got != testCase.want {
t.Errorf("homebrewKeg(%q, %q) = %v, want %v", testCase.goos, testCase.path, got, testCase.want)
}
}
}
// THE REGRESSION THE REVIEWER ASKED FOR, and the reason the receipt check
// exists: a directory tree a user built themselves can have exactly a keg's
// shape. Classifying it as Homebrew refuses a `zero upgrade` that would have
// worked perfectly, on a machine Homebrew has never touched.
//
// Homebrew writes INSTALL_RECEIPT.json into every keg it installs and nothing
// else does, so its presence is what separates the two.
func TestOnlyAKegWithAReceiptIsHomebrew(t *testing.T) {
// An explicit target rather than runtime.GOOS, so the receipt rule is checked
// on every platform including the one it is switched off for. isHomebrewPath
// already takes the target, so the skip bought nothing and cost the ability to
// catch a break anywhere but CI. Two tests broke exactly that way when the
// receipt requirement landed: they build a keg with no receipt, and only
// macOS and Linux ever ran them.
const target = "darwin"
root := t.TempDir()
// Same shape, same depth. The only difference is the receipt.
real := filepath.Join(root, "prefix", "Cellar", "zero", "0.7.1")
fake := filepath.Join(root, "work", "Cellar", "tools", "0.7.1")
for _, keg := range []string{real, fake} {
if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(keg, "bin", "zero"), []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(real, "INSTALL_RECEIPT.json"), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
if !isHomebrewPath(target, filepath.Join(real, "bin", "zero")) {
t.Error("a keg with a receipt was not recognised as Homebrew")
}
if isHomebrewPath(target, filepath.Join(fake, "bin", "zero")) {
t.Error("a user's own Cellar-shaped directory was classified as Homebrew; `zero upgrade` would refuse for nothing")
}
}
// A receipt that is a DIRECTORY is not a receipt. Cheap to get wrong with a
// bare Stat, and the failure would be silent.
func TestADirectoryNamedLikeTheReceiptIsNotOne(t *testing.T) {
// Explicit target, same reason as above: the rule is worth checking wherever
// the tests run, not only where the feature is switched on.
const target = "darwin"
keg := filepath.Join(t.TempDir(), "prefix", "Cellar", "zero", "0.7.1")
if err := os.MkdirAll(filepath.Join(keg, "INSTALL_RECEIPT.json"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(keg, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if isHomebrewPath(target, filepath.Join(keg, "bin", "zero")) {
t.Error("a directory named INSTALL_RECEIPT.json was accepted as a receipt")
}
}
// A Homebrew install is a keg under <prefix>/Cellar with <prefix>/bin/zero
// linked to it. Detection has to survive both spellings, because Check passes
// the unresolved path and Apply passes the resolved one.
func TestDetectInstallMethodRecognisesAHomebrewKeg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("DetectInstallMethod is GOOS-gated; the rule itself is covered by TestIsHomebrewPathPerTarget")
}
prefix := t.TempDir()
keg := filepath.Join(prefix, "Cellar", "zero", "0.7.1", "bin")
if err := os.MkdirAll(keg, 0o755); err != nil {
t.Fatal(err)
}
// The receipt is what separates a keg from any directory that happens to sit
// under a path containing "Cellar". Without it detection correctly answers
// standalone, which is what this test would otherwise be asserting against.
if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
binary := filepath.Join(keg, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodHomebrew {
t.Errorf("keg path: got %q, want %q", got, InstallMethodHomebrew)
}
linkDir := filepath.Join(prefix, "bin")
if err := os.MkdirAll(linkDir, 0o755); err != nil {
t.Fatal(err)
}
link := filepath.Join(linkDir, "zero")
if err := os.Symlink(binary, link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
// The path Check sees. Without symlink resolution this reads as standalone
// and the guidance tells a Homebrew user to run `zero upgrade`.
if got := DetectInstallMethod(link); got != InstallMethodHomebrew {
t.Errorf("linked path: got %q, want %q", got, InstallMethodHomebrew)
}
}
// The failure that matters more than a missed detection: refusing to update an
// install Homebrew has never heard of. /usr/local is a Homebrew prefix on Intel
// macOS and an ordinary install location everywhere.
func TestDetectInstallMethodLeavesOrdinaryInstallsAlone(t *testing.T) {
root := t.TempDir()
for _, dir := range []string{
filepath.Join(root, "usr", "local", "bin"),
filepath.Join(root, "home", "user", ".local", "bin"),
filepath.Join(root, "opt", "homebrewish", "bin"),
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
binary := filepath.Join(dir, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodStandalone {
t.Errorf("%s: got %q, want %q", dir, got, InstallMethodStandalone)
}
}
}
// An npm install must not start reading as Homebrew now that a second check
// runs first.
func TestDetectInstallMethodStillRecognisesNpm(t *testing.T) {
dir := t.TempDir()
binary := filepath.Join(dir, "zero")
if err := os.WriteFile(binary, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, ".zero-binary-version"), []byte("0.7.1"), 0o644); err != nil {
t.Fatal(err)
}
if got := DetectInstallMethod(binary); got != InstallMethodNpm {
t.Errorf("got %q, want %q", got, InstallMethodNpm)
}
}
func TestUpgradeGuidanceSendsHomebrewUsersToBrew(t *testing.T) {
guidance := upgradeGuidance(AssetCheck{}, "", InstallMethodHomebrew)
if !strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("guidance does not name the command that works: %q", guidance)
}
if strings.Contains(guidance, "Run `zero upgrade`") {
t.Errorf("guidance still offers the command that refuses: %q", guidance)
}
}
// A custom source flag must not talk a Homebrew user back into `zero upgrade`.
func TestUpgradeGuidanceIgnoresSourceFlagForHomebrew(t *testing.T) {
guidance := upgradeGuidance(AssetCheck{}, "--source", InstallMethodHomebrew)
if !strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("source flag changed the Homebrew answer: %q", guidance)
}
}
// A CROSS-TARGET check outranks the install method, on purpose.
//
// Homebrew is a property of the binary on THIS machine. When the check was asked
// about a different target, the answer is about that other machine, and
// `brew upgrade zero` would change this one instead. Pinned as a test because it
// reads like an ordering bug until you see which question is being answered.
func TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew(t *testing.T) {
local := localReleaseTarget()
other := "linux-arm64"
if local == other {
other = "macos-x64"
}
target, err := ResolveTarget(other)
if err != nil {
t.Fatalf("resolve target: %v", err)
}
asset := AssetCheck{Platform: target.Platform, Arch: target.Arch}
guidance := upgradeGuidance(asset, "", InstallMethodHomebrew)
if strings.Contains(guidance, "brew upgrade zero") {
t.Errorf("a question about %s was answered with a command that changes this machine: %q", other, guidance)
}
if !strings.Contains(guidance, other) {
t.Errorf("cross-target guidance does not name the target asked about: %q", guidance)
}
}
// localReleaseEndpoint builds a data: release whose assets match THIS platform.
//
// Apply runs Check first, and Check fails outright when the release carries no
// archive for the running target. An empty asset list therefore made the
// Homebrew test pass on the error it was looking for and fail on the message,
// which is a fixture that proves nothing about the branch it was written for.
func localReleaseEndpoint(t *testing.T, version string) string {
t.Helper()
target := localReleaseTarget()
if target == "" {
t.Skip("no published release target for this platform")
}
extension := ".tar.gz"
if strings.HasPrefix(target, "windows") {
extension = ".zip"
}
archive := "zero-v" + version + "-" + target + extension
payload := url.QueryEscape(`{"tag_name":"v` + version + `","html_url":"https://example.test/release","assets":[` +
`{"name":"` + archive + `","browser_download_url":"https://example.test/` + archive + `"},` +
`{"name":"` + archive + `.sha256","browser_download_url":"https://example.test/` + archive + `.sha256"}]}`)
return "data:application/json," + payload
}
// Guards the helper above on EVERY platform, including the ones where the
// Homebrew test that uses it has to skip. The fixture broke on macOS while
// passing everywhere it was not exercised, so the fixture gets its own check.
func TestLocalReleaseEndpointIsAcceptedByCheck(t *testing.T) {
result, err := Check(context.Background(), Options{
CurrentVersion: "0.1.0",
Endpoint: localReleaseEndpoint(t, "0.7.0"),
})
if err != nil {
t.Fatalf("Check rejected the fixture release for this platform: %v", err)
}
if !result.UpdateAvailable {
t.Errorf("fixture release 0.7.0 should be newer than 0.1.0")
}
}
// Apply must refuse a Homebrew keg outright: no download, no write, and an error
// that names the command which does work.
func TestApplyRefusesToUpdateAHomebrewKeg(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("DetectInstallMethod is GOOS-gated; see TestIsHomebrewPathPerTarget")
}
prefix := t.TempDir()
keg := filepath.Join(prefix, "Cellar", "zero", "0.7.0", "bin")
if err := os.MkdirAll(keg, 0o755); err != nil {
t.Fatal(err)
}
// Without the receipt this is not a keg, Apply proceeds to the download, and
// the assertion below fails on a network error instead of on the refusal.
// Without the receipt this is not a keg, Apply proceeds to the download, and
// the assertion below fails on a network error instead of on the refusal.
if err := os.WriteFile(filepath.Join(filepath.Dir(keg), homebrewReceipt), []byte(`{"source":{}}`), 0o644); err != nil {
t.Fatal(err)
}
binary := filepath.Join(keg, "zero")
original := []byte("original binary")
if err := os.WriteFile(binary, original, 0o755); err != nil {
t.Fatal(err)
}
restore := currentExecutable
currentExecutable = func() (string, error) { return binary, nil }
t.Cleanup(func() { currentExecutable = restore })
_, err := Apply(context.Background(), Options{
CurrentVersion: "0.1.0",
Endpoint: localReleaseEndpoint(t, "0.7.0"),
})
if err == nil {
t.Fatal("Apply updated a Homebrew keg instead of refusing")
}
if !strings.Contains(err.Error(), "brew upgrade zero") {
t.Errorf("refusal does not name the command that works: %v", err)
}
after, readErr := os.ReadFile(binary)
if readErr != nil {
t.Fatalf("read binary: %v", readErr)
}
if string(after) != string(original) {
t.Error("Apply rewrote the keg binary it claimed to refuse")
}
}
+11
View File
@@ -311,6 +311,17 @@ func upgradeGuidance(asset AssetCheck, sourceFlag string, installMethod InstallM
}
return guidance + " `zero upgrade` installs onto this machine (" + local + ") instead."
}
if installMethod == InstallMethodHomebrew {
// Said before the source-flag branch below: whatever source the check read,
// the answer for a keg is the same and `zero upgrade` is never it.
//
// AFTER the cross-target branch above, which is not an ordering bug and is
// pinned by TestUpgradeGuidanceKeepsCrossTargetAnswerForHomebrew. Homebrew
// is a property of the binary on THIS machine; when the check was asked
// about a different target the answer belongs to that other machine, and
// `brew upgrade zero` would change this one instead.
return "This Homebrew-managed installation is updated with `brew upgrade zero`. `zero upgrade` refuses here, because replacing the keg binary would leave Homebrew's records describing a version that is no longer installed."
}
if sourceFlag != "" {
if installMethod == InstallMethodNpm {
return "This npm-managed installation can be updated with `npm install -g " + npmPackageName + "@latest`, which installs the official npm package. The custom `" + sourceFlag + "` source only affects the release check and update gating, not the npm install source."
+65
View File
@@ -0,0 +1,65 @@
# Homebrew formula for Zero.
#
# NOT PUBLISHED. This is a draft for review. Nothing installs from it until it
# is copied into a tap (Gitlawb/homebrew-tap, Formula/zero.rb) and the release
# workflow is taught to bump the version and checksums on each tag. Both of
# those are release decisions, so they are deliberately not made here.
#
# Verify locally without a tap:
#
# brew install --build-from-source ./packaging/homebrew/zero.rb
# brew audit --strict --formula ./packaging/homebrew/zero.rb
#
# The checksums below are the real ones published with v0.7.0, read from the
# .sha256 files that ship beside each archive, so this draft is installable as
# written rather than being a skeleton with placeholders.
class Zero < Formula
desc "Terminal coding agent"
homepage "https://github.com/Gitlawb/zero"
version "0.7.0"
license "MIT"
on_macos do
on_arm do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-arm64.tar.gz"
sha256 "75e859fe25f3f63785f512f20b9c9501c67394166f10746f80e374672a1a8b7f"
end
on_intel do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-macos-x64.tar.gz"
sha256 "184256abd5738b77d44cf4a99d71ac32d0a0355714ebc698a2221a69aeb71976"
end
end
on_linux do
on_arm do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-arm64.tar.gz"
sha256 "dd0355f78b6ab044e1181184e29432d0ab7652a1dc27a161960f06e8520b4f21"
end
on_intel do
url "https://github.com/Gitlawb/zero/releases/download/v0.7.0/zero-v0.7.0-linux-x64.tar.gz"
sha256 "f5120c2cc1e9f45ebf69d472b6026eb8e37eee2c113211efedd7d0917437490c"
end
end
def install
bin.install "zero"
# The Linux archive carries the sandbox helper beside the binary. Without it
# on PATH, native sandboxing is silently unavailable rather than broken, so
# install it whenever the archive provides one.
bin.install "zero-linux-sandbox" if File.exist?("zero-linux-sandbox")
end
def caveats
<<~EOS
Update with `brew upgrade zero`.
`zero upgrade` refuses on a Homebrew install on purpose: replacing the keg
binary would leave Homebrew describing a version that is no longer on disk,
and the next `brew upgrade` would revert it.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/zero --version")
end
end