Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ brew tap buildkite/buildkite && brew install buildkite/buildkite/bk

Or download a binary from the [releases page](https://github.com/buildkite/cli/releases).

To update a standalone release-binary install later, run:

```sh
bk update
```

If `bk` is managed by Homebrew or mise, `bk update` will tell you how to update
it with that tool instead.

### Authenticate

```sh
Expand Down
154 changes: 154 additions & 0 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package update

import (
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"

versionPkg "github.com/buildkite/cli/v3/cmd/version"
"github.com/buildkite/cli/v3/internal/selfupdate"
)

// UpdateCmd updates the installed bk CLI in place, or prints the right
// instruction when bk is managed by Homebrew or mise.
//
// The unexported fields are dependency-injection seams for tests. Kong
// constructs the command with all fields zero-valued; Run() fills in the
// real implementations.
type UpdateCmd struct {
stdout io.Writer
stderr io.Writer
version string
targetOS string
targetArch string
currentInstallation func() (selfupdate.Installation, error)
latestReleaseVersion func() (string, error)
buildDownloadURL func(version, targetOS, targetArch string) string
buildChecksumURL func(version string) string
}

func (c *UpdateCmd) Help() string {
return `Update the installed bk CLI.

If bk is managed by Homebrew or mise, this command prints the right update
instruction for that tool.

If bk was installed as a standalone release binary, this command downloads the
latest release for the current platform, verifies its checksum, and replaces
that binary in place.
`
}

func (c *UpdateCmd) Run() error {
c.applyDefaults()

installation, err := c.currentInstallation()
if err != nil {
return fmt.Errorf("determining current installation: %w", err)
}

current := strings.TrimPrefix(c.version, "v")

switch installation.Method {
case selfupdate.InstallMethodHomebrew, selfupdate.InstallMethodMise:
return c.printManagedInstallMessage(installation, current)
}

if !versionPkg.IsReleaseVersion(current) {
return fmt.Errorf("self-update is only supported for released builds (current version: %s)", c.version)
}

latest, err := c.latestReleaseVersion()
if err != nil {
return fmt.Errorf("checking latest release: %w", err)
}
if !versionPkg.HasUpdate(current, latest) {
fmt.Fprintf(c.stdout, "bk is already up to date (%s)\n", current)
return nil
}

if c.targetOS == "windows" {
return fmt.Errorf("self-update is not supported on Windows yet; please download a new release manually")
}

fmt.Fprintf(c.stdout, "Downloading bk %s for %s/%s...\n", latest, c.targetOS, c.targetArch)
downloadURL := c.buildDownloadURL(latest, c.targetOS, c.targetArch)
archivePath, err := selfupdate.DownloadToTemp(downloadURL)
if err != nil {
return fmt.Errorf("downloading bk: %w", err)
}
defer os.Remove(archivePath)

fmt.Fprintln(c.stdout, "Verifying checksum...")
expectedHash, err := selfupdate.FetchExpectedSHA256(c.buildChecksumURL(latest), filepath.Base(downloadURL))
if err != nil {
return fmt.Errorf("fetching checksum: %w", err)
}
if err := selfupdate.VerifySHA256(archivePath, expectedHash); err != nil {
return fmt.Errorf("checksum verification failed: %w", err)
}

if err := selfupdate.ReplaceBinary(archivePath, installation.TargetPath(), c.targetOS); err != nil {
return fmt.Errorf("installing updated bk: %w", err)
}

fmt.Fprintf(c.stdout, "Updated bk to version %s\n", latest)
return nil
}

func (c *UpdateCmd) printManagedInstallMessage(installation selfupdate.Installation, current string) error {
latest, err := c.latestReleaseVersion()
switch {
case err != nil:
fmt.Fprintf(c.stderr, "Warning: could not check for the latest release: %v\n", err)
case versionPkg.IsReleaseVersion(current) && !versionPkg.HasUpdate(current, latest):
fmt.Fprintf(c.stdout, "bk is already up to date (%s)\n", current)
return nil
default:
fmt.Fprintf(c.stdout, "A new version of bk is available: %s\n", latest)
}

switch installation.Method {
case selfupdate.InstallMethodHomebrew:
fmt.Fprintln(c.stdout, "This installation is managed by Homebrew.")
fmt.Fprintf(c.stdout, "Update it with: %s\n", selfupdate.UpdateInstruction(installation))
case selfupdate.InstallMethodMise:
fmt.Fprintln(c.stdout, "This installation is managed by mise.")
fmt.Fprintln(c.stdout, "Update it with mise.")
}

return nil
}

func (c *UpdateCmd) applyDefaults() {
if c.stdout == nil {
c.stdout = os.Stdout
}
if c.stderr == nil {
c.stderr = os.Stderr
}
if c.version == "" {
c.version = versionPkg.Version
}
if c.targetOS == "" {
c.targetOS = runtime.GOOS
}
if c.targetArch == "" {
c.targetArch = runtime.GOARCH
}
if c.currentInstallation == nil {
c.currentInstallation = selfupdate.CurrentInstallation
}
if c.latestReleaseVersion == nil {
c.latestReleaseVersion = versionPkg.LatestReleaseVersion
}
if c.buildDownloadURL == nil {
c.buildDownloadURL = selfupdate.BuildDownloadURL
}
if c.buildChecksumURL == nil {
c.buildChecksumURL = selfupdate.BuildChecksumURL
}
}
187 changes: 187 additions & 0 deletions cmd/update/update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package update

import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"

"github.com/buildkite/cli/v3/internal/selfupdate"
)

func TestUpdateCmd_RunStandaloneSelfUpdates(t *testing.T) {
t.Parallel()

target := filepath.Join(t.TempDir(), "bk")
if err := os.WriteFile(target, []byte("old binary"), 0o755); err != nil {
t.Fatal(err)
}

archiveData := makeTarGz(t, map[string]string{
"bk_3.2.0_linux_amd64/README.md": "ignore",
"bk_3.2.0_linux_amd64/bk": "new binary",
})
hash := sha256.Sum256(archiveData)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/bk_3.2.0_linux_amd64.tar.gz":
_, _ = w.Write(archiveData)
case "/bk_3.2.0_checksums.txt":
fmt.Fprintf(w, "%s bk_3.2.0_linux_amd64.tar.gz\n", hex.EncodeToString(hash[:]))
default:
http.NotFound(w, r)
}
}))
defer server.Close()

var output bytes.Buffer
cmd := &UpdateCmd{
stdout: &output,
version: "3.1.0",
targetOS: "linux",
targetArch: "amd64",
currentInstallation: func() (selfupdate.Installation, error) {
return selfupdate.Installation{Path: target, ResolvedPath: target, Method: selfupdate.InstallMethodStandalone}, nil
},
latestReleaseVersion: func() (string, error) { return "3.2.0", nil },
buildDownloadURL: func(string, string, string) string { return server.URL + "/bk_3.2.0_linux_amd64.tar.gz" },
buildChecksumURL: func(string) string { return server.URL + "/bk_3.2.0_checksums.txt" },
}

if err := cmd.Run(); err != nil {
t.Fatalf("Run() error = %v", err)
}

got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "new binary" {
t.Fatalf("updated binary = %q, want %q", got, "new binary")
}
if !strings.Contains(output.String(), "Updated bk to version 3.2.0") {
t.Fatalf("expected success output, got %q", output.String())
}
}

func TestUpdateCmd_RunHomebrewPrintsInstructions(t *testing.T) {
t.Parallel()

var output bytes.Buffer
cmd := &UpdateCmd{
stdout: &output,
version: "3.44.0",
currentInstallation: func() (selfupdate.Installation, error) {
return selfupdate.Installation{
Path: "/opt/homebrew/bin/bk",
ResolvedPath: "/opt/homebrew/Cellar/bk@3/3.44.0/bin/bk",
Method: selfupdate.InstallMethodHomebrew,
BrewFormula: "bk@3",
}, nil
},
latestReleaseVersion: func() (string, error) { return "3.45.0", nil },
}

if err := cmd.Run(); err != nil {
t.Fatalf("Run() error = %v", err)
}

got := output.String()
if !strings.Contains(got, "managed by Homebrew") {
t.Fatalf("expected Homebrew output, got %q", got)
}
if !strings.Contains(got, "brew upgrade bk@3") {
t.Fatalf("expected brew instruction, got %q", got)
}
}

func TestUpdateCmd_RunHomebrewWarnsOnLatestReleaseError(t *testing.T) {
t.Parallel()

var stdout, stderr bytes.Buffer
cmd := &UpdateCmd{
stdout: &stdout,
stderr: &stderr,
version: "3.44.0",
currentInstallation: func() (selfupdate.Installation, error) {
return selfupdate.Installation{
Path: "/opt/homebrew/bin/bk",
ResolvedPath: "/opt/homebrew/Cellar/bk@3/3.44.0/bin/bk",
Method: selfupdate.InstallMethodHomebrew,
BrewFormula: "bk@3",
}, nil
},
latestReleaseVersion: func() (string, error) { return "", fmt.Errorf("network down") },
}

if err := cmd.Run(); err != nil {
t.Fatalf("Run() error = %v", err)
}

if !strings.Contains(stderr.String(), "could not check for the latest release") {
t.Fatalf("expected warning on stderr, got %q", stderr.String())
}
if !strings.Contains(stdout.String(), "brew upgrade bk@3") {
t.Fatalf("expected brew instruction on stdout, got %q", stdout.String())
}
}

func TestUpdateCmd_RunStandaloneDevBuildRefusesSelfUpdate(t *testing.T) {
t.Parallel()

var output bytes.Buffer
cmd := &UpdateCmd{
stdout: &output,
version: "DEV",
currentInstallation: func() (selfupdate.Installation, error) {
return selfupdate.Installation{Path: "/tmp/bk", ResolvedPath: "/tmp/bk", Method: selfupdate.InstallMethodStandalone}, nil
},
latestReleaseVersion: func() (string, error) {
t.Fatal("latestReleaseVersion should not be called for dev builds")
return "", nil
},
}

err := cmd.Run()
if err == nil {
t.Fatal("Run() succeeded, want error")
}
if !strings.Contains(err.Error(), "released builds") {
t.Fatalf("expected released-builds error, got %v", err)
}
}

func makeTarGz(t *testing.T, files map[string]string) []byte {
t.Helper()

var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)

for name, content := range files {
if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(content)), Mode: 0o755}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}

if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gw.Close(); err != nil {
t.Fatal(err)
}

return buf.Bytes()
}
Loading