diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e76537e..efd310b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -86,13 +86,14 @@ nfpms: bindir: /usr/bin homebrew_casks: - # skip_upload: cask files are still built into dist/ so this stays testable, but - # nothing is pushed until harness/homebrew-tap exists and HOMEBREW_TAP_GITHUB_TOKEN - # is set — see docs/publishing-to-homebrew.md. Remove once both are in place. - - name: harness + # Installs the core `harness` binary only — modules/plugins are pulled in + # afterwards with `harness install module `. + - name: harness-cli ids: [harness-core] binaries: [harness] - skip_upload: true + # "auto" publishes on stable tags and skips prerelease tags (v1.2.3-rc1), + # so an rc never becomes what `brew install` hands out. + skip_upload: auto repository: owner: harness name: homebrew-tap @@ -106,6 +107,10 @@ homebrew_casks: if OS.mac? system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/harness"] end + system_command "#{staged_path}/harness", + args: ["--post-install"], + env: { "HARNESS_INSTALL_TYPE" => "brew" }, + must_succeed: false checksum: name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" diff --git a/README.md b/README.md index efb17d0..01dc4cd 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,14 @@ irm https://raw.githubusercontent.com/harness/cli/main/install.ps1 | iex $env:HARNESS_NONINTERACTIVE=1; $env:HARNESS_CORE_ONLY=1; irm https://raw.githubusercontent.com/harness/cli/main/install.ps1 | iex ``` +### Homebrew (macOS) + +```sh +brew install --cask harness/tap/harness-cli +``` + +Installs the core `harness` binary only. Modules/Plugins are managed by the CLI, not by Homebrew. + ### Manual install Prefer to install by hand? Download an archive from [GitHub Releases](https://github.com/harness/cli/releases), place `harness` on your `PATH`, and register the bundled `har` plugin with `harness install plugin`. Unix bundles are `tar.gz`; Windows bundles are `zip`. Published for `linux_amd64`, `linux_arm64`, `darwin_amd64`, `darwin_arm64`, `windows_amd64`, and `windows_arm64`. @@ -148,6 +156,9 @@ harness install cli --core-only # skip module updates | `--check` | Print the resolved version without installing; exits 1 if not found | | `--core-only` | Only install the core binary, skip module updates | +> [!NOTE] +> If you installed with Homebrew, upgrade with Homebrew: `brew upgrade --cask harness/tap/harness-cli`. + External modules are managed the same way: ```sh @@ -751,7 +762,7 @@ Add the built binaries to your `PATH` for the duration of the session: source local-setup.zsh ``` -For details on the release process and Homebrew publishing, see [`BUILD.md`](BUILD.md) and [`docs/publishing-to-homebrew.md`](docs/publishing-to-homebrew.md). +For details on the release process, see [`BUILD.md`](BUILD.md). --- diff --git a/go.work.sum b/go.work.sum index e64497f..52cc6ce 100644 --- a/go.work.sum +++ b/go.work.sum @@ -25,6 +25,7 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= diff --git a/modules/core/mgmt/install.go b/modules/core/mgmt/install.go index d686dd4..ac17953 100644 --- a/modules/core/mgmt/install.go +++ b/modules/core/mgmt/install.go @@ -169,8 +169,24 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { } installDir = hbase.ExpandHomeDir(installDir) - if err := checkRunningFromInstallDir(installDir); err != nil { - return err + // --check only reports versions, so it stays useful on a brew install. + if !force && !check { + if path, ok := hbase.BrewManagedBinary(); ok { + return fmt.Errorf( + "harness was installed by Homebrew (%s)\n"+ + "Upgrade it with: brew upgrade --cask %s\n"+ + "Pass --force to install a separate copy anyway", + path, hbase.BrewCaskRef, + ) + } + } + + // --force means the caller wants a copy in installDir regardless of where + // the running binary lives, which is the only way off a brew install. + if !force { + if err := checkRunningFromInstallDir(installDir); err != nil { + return err + } } platform, err := detectPlatform() diff --git a/pkg/hbase/hbase.go b/pkg/hbase/hbase.go index 23fa623..8108097 100644 --- a/pkg/hbase/hbase.go +++ b/pkg/hbase/hbase.go @@ -129,6 +129,41 @@ func GetHarnessBinDir() string { return filepath.Join(GetHarnessHomeDir(), "bin") } +// BrewCaskRef is the tap-qualified cask this CLI is published as, suitable for +// `brew install --cask` / `brew upgrade --cask`. +const BrewCaskRef = "harness/tap/harness-cli" + +// brewCaskDirName is the directory Homebrew stages cask payloads under: +// $HOMEBREW_PREFIX/Caskroom///, with a symlink to it +// from $HOMEBREW_PREFIX/bin. Matching a resolved path on this segment is +// prefix-independent (works for /opt/homebrew, /usr/local, and a custom +// HOMEBREW_PREFIX) and cannot false-positive on a manual install into +// /usr/local/bin, which a bare prefix match would. +const brewCaskDirName = "Caskroom" + +// BrewManagedBinary reports whether the running binary was installed by a +// Homebrew cask, returning the resolved path when it was. Homebrew owns that +// path, so self-update must defer to `brew upgrade` rather than write over it. +func BrewManagedBinary() (string, bool) { + if runtime.GOOS == "windows" { + return "", false + } + exe, err := os.Executable() + if err != nil { + return "", false + } + resolved, err := filepath.EvalSymlinks(exe) + if err != nil { + resolved = exe + } + for _, seg := range strings.Split(resolved, string(filepath.Separator)) { + if seg == brewCaskDirName { + return resolved, true + } + } + return "", false +} + // EnsureHarnessHome creates ~/.harness with 0700 permissions if it does not exist. // Returns an error if the directory cannot be created or if the path exists but is not a directory. func EnsureHarnessHome() error { diff --git a/pkg/release/release.go b/pkg/release/release.go index fd0c50d..8d26231 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -163,7 +163,11 @@ func NagIfDue(currentVersion string) { if err := writeCache(c); err != nil { return } - fmt.Fprintf(os.Stderr, "\nA new version of the Harness CLI is available: %s → %s\nRun: harness install cli\n\n", currentVersion, c.LatestVersion) + upgradeCmd := "harness install cli" + if _, ok := hbase.BrewManagedBinary(); ok { + upgradeCmd = "brew upgrade --cask " + hbase.BrewCaskRef + } + fmt.Fprintf(os.Stderr, "\nA new version of the Harness CLI is available: %s → %s\nRun: %s\n\n", currentVersion, c.LatestVersion, upgradeCmd) } // shouldUpdateCheck returns false for all gating conditions that mean we skip entirely. diff --git a/pkg/rootcmd/rootcmd.go b/pkg/rootcmd/rootcmd.go index 4ab7ade..f6cc3c0 100644 --- a/pkg/rootcmd/rootcmd.go +++ b/pkg/rootcmd/rootcmd.go @@ -31,11 +31,12 @@ func MaybeRunBackgroundUpdateCheck() { } } -// postInstallFlag is the hidden flag install.sh invokes right after placing a -// fresh binary on disk, purely to fire a cli_installed telemetry event. +// postInstallFlag is the hidden flag the installers (install.sh, install.ps1, +// the Homebrew cask's postflight hook) invoke right after placing a fresh +// binary on disk, purely to fire a cli_installed telemetry event. const postInstallFlag = "--post-install" -// MaybeRunPostInstall exits if this invocation is install.sh's post-install +// MaybeRunPostInstall exits if this invocation is an installer's post-install // telemetry ping. Respects the same opt-out as every other event. func MaybeRunPostInstall() { for _, arg := range os.Args[1:] { diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index c1fadf7..101c7e1 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -168,7 +168,7 @@ type CommandError struct { Env Env } -// InstallEvent is emitted once by install.sh, via the hidden --post-install +// InstallEvent is emitted once by an installer, via the hidden --post-install // flag, right after a fresh binary is placed on disk. type InstallEvent struct { RunID string @@ -180,23 +180,24 @@ type InstallEvent struct { Env Env } -// InstallType values. Add new install methods (e.g. "brew") here as they're -// wired up, and have the installer set [hbase.EnvInstallType] accordingly. +// InstallType values. Add new install methods here as they're wired up, and +// have the installer set [hbase.EnvInstallType] accordingly. const ( InstallTypeScript = "script" + InstallTypeBrew = "brew" InstallTypeUnknown = "unknown" ) // installTypeWhitelist is every value ResolveInstallType may return. var installTypeWhitelist = map[string]bool{ InstallTypeScript: true, + InstallTypeBrew: true, InstallTypeUnknown: true, } // ResolveInstallType reads [hbase.EnvInstallType], defaulting to -// [InstallTypeScript] when unset (install.sh is currently the only caller of -// --post-install) and falling back to [InstallTypeUnknown] for any value -// outside the whitelist. +// [InstallTypeScript] when unset and falling back to [InstallTypeUnknown] for +// any value outside the whitelist. func ResolveInstallType() string { v := os.Getenv(hbase.EnvInstallType) if v == "" {