From ac6b19bbf3f9f175629c9b7a9091bcc20334a344 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:06:18 -0400 Subject: [PATCH 1/7] Add GoReleaser pipeline with build-time version embedding. Tag pushes (v*) publish multi-platform archives and checksums; ship --version reports the injected release version. Co-authored-by: Cursor --- .github/workflows/ci.yml | 17 ++++++++++++++ .github/workflows/release.yml | 31 ++++++++++++++++++++++++++ .gitignore | 1 + .goreleaser.yaml | 37 +++++++++++++++++++++++++++++++ cmd/ship/main.go | 14 ++++++++++++ cmd/ship/main_test.go | 16 ++++++++++++++ internal/version/version.go | 6 +++++ internal/version/version_test.go | 38 ++++++++++++++++++++++++++++++++ 8 files changed, 160 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 internal/version/version.go create mode 100644 internal/version/version_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be42bdb..0d4a0cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,20 @@ jobs: uses: securego/gosec@master with: args: ./... + + release-check: + name: Release config + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: GoReleaser check + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b2726e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + name: GoReleaser + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..178135c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f3a8f4e --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,37 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +builds: + - id: ship + main: ./cmd/ship + binary: ship + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X github.com/maxBRT/ship-cli/internal/version.Version={{.Version}} + +archives: + - formats: [tar.gz] + # GoReleaser-recommended name template (uname-compatible OS/Arch). + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else }}{{ .Arch }}{{ end }} + format_overrides: + - goos: windows + formats: [zip] + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" diff --git a/cmd/ship/main.go b/cmd/ship/main.go index 67082ab..fd4602a 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -15,6 +15,7 @@ import ( "github.com/maxBRT/ship-cli/internal/run" "github.com/maxBRT/ship-cli/internal/throbber" "github.com/maxBRT/ship-cli/internal/ticket" + "github.com/maxBRT/ship-cli/internal/version" ) func main() { @@ -27,6 +28,10 @@ func Main(args []string, stdout, stderr io.Writer, dir string) int { if len(args) > 0 && args[0] == "init" { return runInit(stderr, dir) } + if wantsVersion(args) { + fmt.Fprintln(stdout, version.Version) + return 0 + } if wantsHelp(args) { run.WriteUsage(stdout) return 0 @@ -93,3 +98,12 @@ func wantsHelp(args []string) bool { } return false } + +func wantsVersion(args []string) bool { + for _, a := range args { + if a == "-version" || a == "--version" { + return true + } + } + return false +} diff --git a/cmd/ship/main_test.go b/cmd/ship/main_test.go index ebba4a5..7551168 100644 --- a/cmd/ship/main_test.go +++ b/cmd/ship/main_test.go @@ -6,6 +6,22 @@ import ( "testing" ) +func TestMain_versionPrintsEmbeddedVersion(t *testing.T) { + var stdout, stderr bytes.Buffer + code := Main([]string{"--version"}, &stdout, &stderr, t.TempDir()) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("want empty stderr, got %q", stderr.String()) + } + // Non-release builds report the known default "dev". + got := strings.TrimSpace(stdout.String()) + if got != "dev" { + t.Errorf("version = %q, want %q", got, "dev") + } +} + func TestMain_helpDocumentsDomainLanguage(t *testing.T) { var stdout, stderr bytes.Buffer code := Main([]string{"--help"}, &stdout, &stderr, t.TempDir()) diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..e3051ac --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,6 @@ +// Package version holds the Ship release version embedded at build time. +package version + +// Version is the release version reported by ship --version. +// Release builds override this via -ldflags -X. +var Version = "dev" diff --git a/internal/version/version_test.go b/internal/version/version_test.go new file mode 100644 index 0000000..d376c9a --- /dev/null +++ b/internal/version/version_test.go @@ -0,0 +1,38 @@ +package version_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestVersion_ldflagsInjection(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + root := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "../..")) + + dir := t.TempDir() + bin := filepath.Join(dir, "ship") + ldflags := "-X github.com/maxBRT/ship-cli/internal/version.Version=v1.2.3" + build := exec.Command("go", "build", "-o", bin, "-ldflags", ldflags, "./cmd/ship") + build.Dir = root + build.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + cmd := exec.Command(bin, "--version") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ship --version: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) + if got != "v1.2.3" { + t.Errorf("version = %q, want %q", got, "v1.2.3") + } +} From 04f7e39c70a947b00837e4aa40835ace5b7a8f19 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:08:55 -0400 Subject: [PATCH 2/7] Use GoReleaser default archive names for install/update sharing. Co-authored-by: Cursor --- .goreleaser.yaml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index f3a8f4e..d6370c6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,17 +17,8 @@ builds: ldflags: - -s -w -X github.com/maxBRT/ship-cli/internal/version.Version={{.Version}} -archives: - - formats: [tar.gz] - # GoReleaser-recommended name template (uname-compatible OS/Arch). - name_template: >- - {{ .ProjectName }}_ - {{- title .Os }}_ - {{- if eq .Arch "amd64" }}x86_64 - {{- else }}{{ .Arch }}{{ end }} - format_overrides: - - goos: windows - formats: [zip] +# Archives and checksums use GoReleaser defaults (name includes version/os/arch; +# checksums file is {{ .ProjectName }}_{{ .Version }}_checksums.txt). changelog: sort: asc From 94dac6d4405b50889a3413535e98b66690f824ea Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:16:48 -0400 Subject: [PATCH 3/7] Add ship update with Updater port over GitHub Releases. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing installs can self-update via resolve → download → checksum verify → atomic replace, with CLI routing that stays off the Ticket Run path. Co-authored-by: Cursor --- cmd/ship/main.go | 32 +++ cmd/ship/main_test.go | 93 +++++++++ internal/run/config.go | 6 + internal/update/default.go | 28 +++ internal/update/port.go | 19 ++ internal/update/releases.go | 322 +++++++++++++++++++++++++++++++ internal/update/releases_test.go | 307 +++++++++++++++++++++++++++++ 7 files changed, 807 insertions(+) create mode 100644 internal/update/default.go create mode 100644 internal/update/port.go create mode 100644 internal/update/releases.go create mode 100644 internal/update/releases_test.go diff --git a/cmd/ship/main.go b/cmd/ship/main.go index fd4602a..4024f97 100644 --- a/cmd/ship/main.go +++ b/cmd/ship/main.go @@ -15,6 +15,7 @@ import ( "github.com/maxBRT/ship-cli/internal/run" "github.com/maxBRT/ship-cli/internal/throbber" "github.com/maxBRT/ship-cli/internal/ticket" + "github.com/maxBRT/ship-cli/internal/update" "github.com/maxBRT/ship-cli/internal/version" ) @@ -25,9 +26,18 @@ func main() { // Main is the testable CLI entrypoint. It parses Run configuration and drives // the Run orchestrator over the checkout in dir. func Main(args []string, stdout, stderr io.Writer, dir string) int { + return MainWith(args, stdout, stderr, dir, nil) +} + +// MainWith is Main with an optional Updater. Nil uses the real GitHub Releases +// adapter for ship update. +func MainWith(args []string, stdout, stderr io.Writer, dir string, up update.Port) int { if len(args) > 0 && args[0] == "init" { return runInit(stderr, dir) } + if len(args) > 0 && args[0] == "update" { + return runUpdate(stdout, stderr, up) + } if wantsVersion(args) { fmt.Fprintln(stdout, version.Version) return 0 @@ -76,6 +86,28 @@ func Main(args []string, stdout, stderr io.Writer, dir string) int { return 0 } +func runUpdate(stdout, stderr io.Writer, up update.Port) int { + if up == nil { + var err error + up, err = update.Default() + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + } + res, err := up.Update(context.Background()) + if err != nil { + fmt.Fprintln(stderr, err) + return 1 + } + if res.AlreadyCurrent { + fmt.Fprintf(stdout, "ship is up to date (%s)\n", res.OldVersion) + return 0 + } + fmt.Fprintf(stdout, "updated ship %s → %s\n", res.OldVersion, res.NewVersion) + return 0 +} + func runInit(stderr io.Writer, dir string) int { created, err := run.InitConfig(dir) if err != nil { diff --git a/cmd/ship/main_test.go b/cmd/ship/main_test.go index 7551168..9222951 100644 --- a/cmd/ship/main_test.go +++ b/cmd/ship/main_test.go @@ -2,10 +2,98 @@ package main import ( "bytes" + "context" "strings" "testing" + + "github.com/maxBRT/ship-cli/internal/update" ) +type fakeUpdater struct { + result update.Result + err error + called bool +} + +func (f *fakeUpdater) Update(context.Context) (update.Result, error) { + f.called = true + return f.result, f.err +} + +func TestMain_updateRoutesToUpdaterNotTicketRun(t *testing.T) { + up := &fakeUpdater{result: update.Result{ + AlreadyCurrent: true, + OldVersion: "v1.0.0", + NewVersion: "v1.0.0", + }} + var stdout, stderr bytes.Buffer + code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) + } + if !up.called { + t.Fatal("Updater.Update was not called; ship update must not start a Ticket Run") + } +} + +func TestMain_updateAlreadyCurrentPrintsSuccess(t *testing.T) { + up := &fakeUpdater{result: update.Result{ + AlreadyCurrent: true, + OldVersion: "v1.2.3", + NewVersion: "v1.2.3", + }} + var stdout, stderr bytes.Buffer + code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) + } + got := stdout.String() + if !strings.Contains(got, "up to date") { + t.Errorf("stdout missing up-to-date message; got %q", got) + } + if !strings.Contains(got, "v1.2.3") { + t.Errorf("stdout missing version; got %q", got) + } + if stderr.Len() != 0 { + t.Errorf("want empty stderr, got %q", stderr.String()) + } +} + +func TestMain_updateNewerReleasePrintsOldAndNewVersions(t *testing.T) { + up := &fakeUpdater{result: update.Result{ + OldVersion: "v1.0.0", + NewVersion: "v1.2.3", + }} + var stdout, stderr bytes.Buffer + code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%q", code, stderr.String()) + } + got := stdout.String() + if !strings.Contains(got, "v1.0.0") || !strings.Contains(got, "v1.2.3") { + t.Errorf("stdout should print old and new versions; got %q", got) + } + if stderr.Len() != 0 { + t.Errorf("want empty stderr, got %q", stderr.String()) + } +} + +func TestMain_updateFailureLeavesNonZeroAndStderr(t *testing.T) { + up := &fakeUpdater{err: errString("checksum mismatch")} + var stdout, stderr bytes.Buffer + code := MainWith([]string{"update"}, &stdout, &stderr, t.TempDir(), up) + if code == 0 { + t.Fatal("exit = 0, want non-zero") + } + if !strings.Contains(stderr.String(), "checksum mismatch") { + t.Errorf("stderr should carry updater error; got %q", stderr.String()) + } +} + +type errString string + +func (e errString) Error() string { return string(e) } + func TestMain_versionPrintsEmbeddedVersion(t *testing.T) { var stdout, stderr bytes.Buffer code := Main([]string{"--version"}, &stdout, &stderr, t.TempDir()) @@ -34,6 +122,11 @@ func TestMain_helpDocumentsDomainLanguage(t *testing.T) { t.Errorf("help missing domain term %q; got:\n%s", term, out) } } + for _, term := range []string{"update", "version"} { + if !strings.Contains(out, term) { + t.Errorf("help missing %q; got:\n%s", term, out) + } + } } func TestMain_invalidFlagExitsNonZero(t *testing.T) { diff --git a/internal/run/config.go b/internal/run/config.go index 2210550..555e930 100644 --- a/internal/run/config.go +++ b/internal/run/config.go @@ -28,6 +28,8 @@ Phase), then a Final Phase. Usage: ship [flags] ship init + ship update + ship --version Flags: `) @@ -36,6 +38,10 @@ Flags: fs.SetOutput(w) fs.PrintDefaults() fmt.Fprintf(w, ` +Commands: + init create .ship/config.yaml with defaults + update download the latest GitHub Release and replace this binary + Config: .ship/config.yaml at the checkout root (defaults → YAML → flags). Run "ship init" to create one with filled defaults. diff --git a/internal/update/default.go b/internal/update/default.go new file mode 100644 index 0000000..04bd4c1 --- /dev/null +++ b/internal/update/default.go @@ -0,0 +1,28 @@ +package update + +import ( + "fmt" + "os" + + "github.com/maxBRT/ship-cli/internal/version" +) + +// DefaultOwner and DefaultRepo are the GitHub project that publishes Ship releases. +const ( + DefaultOwner = "maxBRT" + DefaultRepo = "ship-cli" +) + +// Default returns a Releases Updater for the running executable and embedded version. +func Default() (Port, error) { + exe, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("update: resolve executable: %w", err) + } + return &Releases{ + Owner: DefaultOwner, + Repo: DefaultRepo, + Version: version.Version, + Executable: exe, + }, nil +} diff --git a/internal/update/port.go b/internal/update/port.go new file mode 100644 index 0000000..f9b20ee --- /dev/null +++ b/internal/update/port.go @@ -0,0 +1,19 @@ +// Package update brings a Ship installation up to date with the latest +// published GitHub Release. +package update + +import "context" + +// Port owns “bring this installation up to date with the latest published +// release.” The real adapter talks to GitHub Releases; tests fake this port. +type Port interface { + Update(ctx context.Context) (Result, error) +} + +// Result is the observable outcome of an Update call. +type Result struct { + // AlreadyCurrent is true when the installed binary matches the latest release. + AlreadyCurrent bool + OldVersion string + NewVersion string +} diff --git a/internal/update/releases.go b/internal/update/releases.go new file mode 100644 index 0000000..140eada --- /dev/null +++ b/internal/update/releases.go @@ -0,0 +1,322 @@ +package update + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// Releases is the GitHub Releases Updater. It resolves the latest release, +// downloads and verifies the matching asset, and replaces Executable in place. +type Releases struct { + Owner string + Repo string + Version string // currently installed version + Executable string // path of the running binary to replace + GOOS string // empty → runtime.GOOS + GOARCH string // empty → runtime.GOARCH + + // APIBase overrides the GitHub API root (tests). Empty → https://api.github.com. + APIBase string + // Client is the HTTP client for API and asset downloads. Nil → http.DefaultClient. + Client *http.Client +} + +var _ Port = (*Releases)(nil) + +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func (r *Releases) client() *http.Client { + if r.Client != nil { + return r.Client + } + return http.DefaultClient +} + +func (r *Releases) apiBase() string { + if r.APIBase != "" { + return strings.TrimRight(r.APIBase, "/") + } + return "https://api.github.com" +} + +func (r *Releases) goos() string { + if r.GOOS != "" { + return r.GOOS + } + return runtime.GOOS +} + +func (r *Releases) goarch() string { + if r.GOARCH != "" { + return r.GOARCH + } + return runtime.GOARCH +} + +// Update brings the installation at Executable up to date with the latest release. +func (r *Releases) Update(ctx context.Context) (Result, error) { + if r.Executable == "" { + return Result{}, fmt.Errorf("update: executable path is required") + } + rel, err := r.fetchLatest(ctx) + if err != nil { + return Result{}, err + } + old := r.Version + latest := rel.TagName + if normVersion(old) == normVersion(latest) { + return Result{AlreadyCurrent: true, OldVersion: old, NewVersion: latest}, nil + } + + goos, goarch := r.goos(), r.goarch() + if err := supportedPlatform(goos, goarch); err != nil { + return Result{}, err + } + + ver := normVersion(latest) + assetName := fmt.Sprintf("ship-cli_%s_%s_%s.tar.gz", ver, goos, goarch) + sumsName := fmt.Sprintf("ship-cli_%s_checksums.txt", ver) + + assetURL, err := findAssetURL(rel.Assets, assetName) + if err != nil { + return Result{}, err + } + sumsURL, err := findAssetURL(rel.Assets, sumsName) + if err != nil { + return Result{}, err + } + + archive, err := r.download(ctx, assetURL) + if err != nil { + return Result{}, fmt.Errorf("update: download %s: %w", assetName, err) + } + sumsBody, err := r.download(ctx, sumsURL) + if err != nil { + return Result{}, fmt.Errorf("update: download checksums: %w", err) + } + if err := verifyChecksum(archive, assetName, sumsBody); err != nil { + return Result{}, err + } + + bin, err := extractShipBinary(archive) + if err != nil { + return Result{}, err + } + if err := replaceBinary(r.Executable, bin); err != nil { + return Result{}, err + } + return Result{OldVersion: old, NewVersion: latest}, nil +} + +func supportedPlatform(goos, goarch string) error { + switch goos { + case "linux", "darwin", "windows": + default: + return fmt.Errorf("update: unsupported platform %s/%s", goos, goarch) + } + switch goarch { + case "amd64", "arm64": + default: + return fmt.Errorf("update: unsupported platform %s/%s", goos, goarch) + } + return nil +} + +func findAssetURL(assets []ghAsset, name string) (string, error) { + for _, a := range assets { + if a.Name == name { + if a.BrowserDownloadURL == "" { + return "", fmt.Errorf("update: asset %q missing download URL", name) + } + return a.BrowserDownloadURL, nil + } + } + return "", fmt.Errorf("update: no release asset named %q (unsupported platform or incomplete release)", name) +} + +func normVersion(v string) string { + return strings.TrimPrefix(strings.TrimSpace(v), "v") +} + +func (r *Releases) fetchLatest(ctx context.Context) (ghRelease, error) { + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", r.apiBase(), r.Owner, r.Repo) + body, status, err := r.get(ctx, url, "application/vnd.github+json") + if err != nil { + return ghRelease{}, fmt.Errorf("update: fetch latest release: %w", err) + } + if status == http.StatusForbidden || status == http.StatusTooManyRequests { + return ghRelease{}, fmt.Errorf("update: GitHub rate limit or forbidden (%s)", http.StatusText(status)) + } + if status != http.StatusOK { + return ghRelease{}, fmt.Errorf("update: latest release: %s", http.StatusText(status)) + } + var rel ghRelease + if err := json.Unmarshal(body, &rel); err != nil { + return ghRelease{}, fmt.Errorf("update: parse latest release: %w", err) + } + if rel.TagName == "" { + return ghRelease{}, fmt.Errorf("update: latest release missing tag_name") + } + return rel, nil +} + +func (r *Releases) download(ctx context.Context, url string) ([]byte, error) { + body, status, err := r.get(ctx, url, "application/octet-stream") + if err != nil { + return nil, err + } + if status != http.StatusOK { + return nil, fmt.Errorf("%s", http.StatusText(status)) + } + return body, nil +} + +func (r *Releases) get(ctx context.Context, url, accept string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, 0, err + } + if accept != "" { + req.Header.Set("Accept", accept) + } + resp, err := r.client().Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} + +func verifyChecksum(archive []byte, assetName string, sumsBody []byte) error { + want, err := checksumFor(assetName, sumsBody) + if err != nil { + return err + } + sum := sha256.Sum256(archive) + got := hex.EncodeToString(sum[:]) + if got != want { + return fmt.Errorf("update: checksum mismatch for %s", assetName) + } + return nil +} + +func checksumFor(assetName string, sumsBody []byte) (string, error) { + for _, line := range strings.Split(string(sumsBody), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // GoReleaser: " " + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + if fields[len(fields)-1] == assetName { + return fields[0], nil + } + } + return "", fmt.Errorf("update: checksums file has no entry for %s", assetName) +} + +func extractShipBinary(archive []byte) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewReader(archive)) + if err != nil { + return nil, fmt.Errorf("update: gunzip archive: %w", err) + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("update: read archive: %w", err) + } + base := filepath.Base(hdr.Name) + if base != "ship" && base != "ship.exe" { + continue + } + if hdr.Typeflag != tar.TypeReg { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("update: extract ship: %w", err) + } + return data, nil + } + return nil, fmt.Errorf("update: archive does not contain ship binary") +} + +func replaceBinary(dest string, bin []byte) error { + info, err := os.Stat(dest) + if err != nil { + return fmt.Errorf("update: stat %s: %w", dest, err) + } + dir := filepath.Dir(dest) + if err := checkWritable(dir, dest, info.Mode()); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".ship-update-*") + if err != nil { + return fmt.Errorf("update: create temp file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() + + if _, err := tmp.Write(bin); err != nil { + _ = tmp.Close() + return fmt.Errorf("update: write temp binary: %w", err) + } + if err := tmp.Chmod(info.Mode()); err != nil { + _ = tmp.Close() + return fmt.Errorf("update: chmod temp binary: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("update: close temp binary: %w", err) + } + if err := os.Rename(tmpName, dest); err != nil { + return fmt.Errorf("update: replace binary: %w", err) + } + return nil +} + +func checkWritable(dir, dest string, mode os.FileMode) error { + f, err := os.CreateTemp(dir, ".ship-write-probe-*") + if err != nil { + return fmt.Errorf("update: binary directory not writable: %w", err) + } + name := f.Name() + _ = f.Close() + _ = os.Remove(name) + + if mode.Perm()&0200 == 0 { + return fmt.Errorf("update: binary not writable: %s", dest) + } + return nil +} diff --git a/internal/update/releases_test.go b/internal/update/releases_test.go new file mode 100644 index 0000000..b4c98c1 --- /dev/null +++ b/internal/update/releases_test.go @@ -0,0 +1,307 @@ +package update_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/maxBRT/ship-cli/internal/update" +) + +func TestReleases_alreadyCurrentLeavesBinaryIntact(t *testing.T) { + exe := filepath.Join(t.TempDir(), "ship") + original := []byte("original-binary-contents") + if err := os.WriteFile(exe, original, 0o755); err != nil { + t.Fatal(err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/maxBRT/ship-cli/releases/latest" { + http.NotFound(w, r) + return + } + fmt.Fprint(w, `{"tag_name":"v1.2.3","assets":[]}`) + })) + t.Cleanup(srv.Close) + + up := &update.Releases{ + Owner: "maxBRT", + Repo: "ship-cli", + Version: "v1.2.3", + Executable: exe, + APIBase: srv.URL, + Client: srv.Client(), + } + res, err := up.Update(context.Background()) + if err != nil { + t.Fatalf("Update: %v", err) + } + if !res.AlreadyCurrent { + t.Fatalf("AlreadyCurrent = false, want true; %+v", res) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Errorf("binary changed; want intact original") + } +} + +func TestReleases_newerReleaseReplacesBinary(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if goos != "linux" && goos != "darwin" && goos != "windows" { + t.Skip("unsupported GOOS for this test") + } + if goarch != "amd64" && goarch != "arm64" { + t.Skip("unsupported GOARCH for this test") + } + + exe := filepath.Join(t.TempDir(), "ship") + if err := os.WriteFile(exe, []byte("old-ship"), 0o755); err != nil { + t.Fatal(err) + } + + newBinary := []byte("new-ship-binary-v2") + archiveBytes := makeTarGz(t, "ship", newBinary) + ver := "2.0.0" + asset := archiveName(ver, goos, goarch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + up := &update.Releases{ + Owner: "maxBRT", + Repo: "ship-cli", + Version: "v1.0.0", + Executable: exe, + GOOS: goos, + GOARCH: goarch, + APIBase: srv.URL, + Client: srv.Client(), + } + res, err := up.Update(context.Background()) + if err != nil { + t.Fatalf("Update: %v", err) + } + if res.AlreadyCurrent { + t.Fatal("AlreadyCurrent = true, want false") + } + if res.OldVersion != "v1.0.0" || res.NewVersion != "v"+ver { + t.Errorf("versions = %+v, want old=v1.0.0 new=v%s", res, ver) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newBinary) { + t.Errorf("binary = %q, want %q", got, newBinary) + } +} + +func TestReleases_checksumMismatchLeavesBinaryIntact(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + exe := filepath.Join(t.TempDir(), "ship") + original := []byte("old-ship") + if err := os.WriteFile(exe, original, 0o755); err != nil { + t.Fatal(err) + } + + archiveBytes := makeTarGz(t, "ship", []byte("new-ship")) + ver := "2.0.0" + asset := archiveName(ver, goos, goarch) + badSums := checksumLine(sha256Sum([]byte("not-the-archive")), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, badSums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + up := &update.Releases{ + Owner: "maxBRT", Repo: "ship-cli", Version: "v1.0.0", + Executable: exe, GOOS: goos, GOARCH: goarch, + APIBase: srv.URL, Client: srv.Client(), + } + _, err := up.Update(context.Background()) + if err == nil { + t.Fatal("Update error = nil, want checksum failure") + } + if !strings.Contains(err.Error(), "checksum") { + t.Errorf("error should mention checksum; got %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("binary changed after verify failure; want intact") + } +} + +func TestReleases_unsupportedPlatformError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v2.0.0","assets":[]}`) + })) + t.Cleanup(srv.Close) + + exe := filepath.Join(t.TempDir(), "ship") + if err := os.WriteFile(exe, []byte("old"), 0o755); err != nil { + t.Fatal(err) + } + up := &update.Releases{ + Owner: "maxBRT", Repo: "ship-cli", Version: "v1.0.0", + Executable: exe, GOOS: "plan9", GOARCH: "amd64", + APIBase: srv.URL, Client: srv.Client(), + } + _, err := up.Update(context.Background()) + if err == nil { + t.Fatal("Update error = nil, want unsupported platform") + } + if !strings.Contains(err.Error(), "unsupported") { + t.Errorf("error should mention unsupported; got %v", err) + } +} + +func TestReleases_nonWritableBinaryRefused(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + dir := t.TempDir() + exe := filepath.Join(dir, "ship") + original := []byte("old-ship") + if err := os.WriteFile(exe, original, 0o555); err != nil { + t.Fatal(err) + } + + newBinary := []byte("new-ship-binary-v2") + archiveBytes := makeTarGz(t, "ship", newBinary) + ver := "2.0.0" + asset := archiveName(ver, goos, goarch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + up := &update.Releases{ + Owner: "maxBRT", Repo: "ship-cli", Version: "v1.0.0", + Executable: exe, GOOS: goos, GOARCH: goarch, + APIBase: srv.URL, Client: srv.Client(), + } + _, err := up.Update(context.Background()) + if err == nil { + t.Fatal("Update error = nil, want permission failure") + } + if !strings.Contains(err.Error(), "not writable") { + t.Errorf("error should mention not writable; got %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("binary changed after permission failure; want intact") + } +} + +func makeTarGz(t *testing.T, name string, contents []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(contents))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func archiveName(version, goos, goarch string) string { + return fmt.Sprintf("ship-cli_%s_%s_%s.tar.gz", strings.TrimPrefix(version, "v"), goos, goarch) +} + +func checksumLine(sum []byte, name string) string { + return hex.EncodeToString(sum) + " " + name + "\n" +} + +func sha256Sum(b []byte) []byte { + h := sha256.Sum256(b) + return h[:] +} From a687ad011d49e277e479ebd466c3a2fe74e6f987 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:22:01 -0400 Subject: [PATCH 4/7] Harden ship update downloads and Windows binary replace. Require HTTPS for release assets, surface download rate limits clearly, and move-aside replace on Windows where rename cannot overwrite. Co-authored-by: Cursor --- internal/update/releases.go | 57 ++++++++++++++- internal/update/releases_test.go | 121 +++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/internal/update/releases.go b/internal/update/releases.go index 140eada..10888c2 100644 --- a/internal/update/releases.go +++ b/internal/update/releases.go @@ -10,7 +10,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "path/filepath" "runtime" @@ -180,17 +182,47 @@ func (r *Releases) fetchLatest(ctx context.Context) (ghRelease, error) { return rel, nil } -func (r *Releases) download(ctx context.Context, url string) ([]byte, error) { - body, status, err := r.get(ctx, url, "application/octet-stream") +func (r *Releases) download(ctx context.Context, rawURL string) ([]byte, error) { + if err := requireHTTPS(rawURL); err != nil { + return nil, err + } + body, status, err := r.get(ctx, rawURL, "application/octet-stream") if err != nil { return nil, err } + if status == http.StatusForbidden || status == http.StatusTooManyRequests { + return nil, fmt.Errorf("GitHub rate limit or forbidden (%s)", http.StatusText(status)) + } if status != http.StatusOK { return nil, fmt.Errorf("%s", http.StatusText(status)) } return body, nil } +// requireHTTPS insists on HTTPS for release downloads. Loopback HTTP is allowed +// so unit tests can serve assets over httptest. +func requireHTTPS(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("update: bad download URL: %w", err) + } + if u.Scheme == "https" { + return nil + } + if u.Scheme == "http" && isLoopbackHost(u.Hostname()) { + return nil + } + return fmt.Errorf("update: download URL must use HTTPS") +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func (r *Releases) get(ctx context.Context, url, accept string) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -300,12 +332,31 @@ func replaceBinary(dest string, bin []byte) error { if err := tmp.Close(); err != nil { return fmt.Errorf("update: close temp binary: %w", err) } - if err := os.Rename(tmpName, dest); err != nil { + if err := atomicReplace(tmpName, dest); err != nil { return fmt.Errorf("update: replace binary: %w", err) } return nil } +// atomicReplace renames tmp onto dest. On Windows, os.Rename cannot overwrite an +// existing file, so the current binary is moved aside first. +func atomicReplace(tmp, dest string) error { + if runtime.GOOS != "windows" { + return os.Rename(tmp, dest) + } + bak := dest + ".old" + _ = os.Remove(bak) + if err := os.Rename(dest, bak); err != nil { + return err + } + if err := os.Rename(tmp, dest); err != nil { + _ = os.Rename(bak, dest) // best-effort restore + return err + } + _ = os.Remove(bak) + return nil +} + func checkWritable(dir, dest string, mode os.FileMode) error { f, err := os.CreateTemp(dir, ".ship-write-probe-*") if err != nil { diff --git a/internal/update/releases_test.go b/internal/update/releases_test.go index b4c98c1..bda4beb 100644 --- a/internal/update/releases_test.go +++ b/internal/update/releases_test.go @@ -213,6 +213,127 @@ func TestReleases_unsupportedPlatformError(t *testing.T) { } } +func TestReleases_nonHTTPSDownloadURLRejected(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedTestPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for this test") + } + exe := filepath.Join(t.TempDir(), "ship") + original := []byte("old-ship") + if err := os.WriteFile(exe, original, 0o755); err != nil { + t.Fatal(err) + } + + ver := "2.0.0" + asset := archiveName(ver, goos, goarch) + sumsName := fmt.Sprintf("ship-cli_%s_checksums.txt", ver) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/maxBRT/ship-cli/releases/latest" { + http.NotFound(w, r) + return + } + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":"http://example.com/download/%s"}, + {"name":%q,"browser_download_url":"http://example.com/download/checksums.txt"} + ] + }`, ver, asset, asset, sumsName) + })) + t.Cleanup(srv.Close) + + up := &update.Releases{ + Owner: "maxBRT", Repo: "ship-cli", Version: "v1.0.0", + Executable: exe, GOOS: goos, GOARCH: goarch, + APIBase: srv.URL, Client: srv.Client(), + } + _, err := up.Update(context.Background()) + if err == nil { + t.Fatal("Update error = nil, want HTTPS failure") + } + if !strings.Contains(err.Error(), "HTTPS") { + t.Errorf("error should mention HTTPS; got %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("binary changed after HTTPS failure; want intact") + } +} + +func TestReleases_downloadRateLimitReadable(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedTestPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for this test") + } + exe := filepath.Join(t.TempDir(), "ship") + original := []byte("old-ship") + if err := os.WriteFile(exe, original, 0o755); err != nil { + t.Fatal(err) + } + + ver := "2.0.0" + asset := archiveName(ver, goos, goarch) + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case strings.HasPrefix(r.URL.Path, "/download/"): + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, "rate limited") + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + up := &update.Releases{ + Owner: "maxBRT", Repo: "ship-cli", Version: "v1.0.0", + Executable: exe, GOOS: goos, GOARCH: goarch, + APIBase: srv.URL, Client: srv.Client(), + } + _, err := up.Update(context.Background()) + if err == nil { + t.Fatal("Update error = nil, want rate-limit failure") + } + if !strings.Contains(err.Error(), "rate limit") { + t.Errorf("error should mention rate limit; got %v", err) + } + got, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("binary changed after rate-limit failure; want intact") + } +} + +func supportedTestPlatform(goos, goarch string) bool { + switch goos { + case "linux", "darwin", "windows": + default: + return false + } + switch goarch { + case "amd64", "arm64": + return true + default: + return false + } +} + func TestReleases_nonWritableBinaryRefused(t *testing.T) { goos, goarch := runtime.GOOS, runtime.GOARCH dir := t.TempDir() From 3aba4b7559625908e306ac02d44bf58d16709e7e Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:27:35 -0400 Subject: [PATCH 5/7] Add curl install script with checksum-verified GitHub Releases install. Gives new users a one-liner that picks linux/darwin amd64/arm64 GoReleaser assets, verifies SHA256, and installs ship to ~/.local/bin (or a writable system bin) with clear PATH and platform errors. Co-authored-by: Cursor --- install.sh | 149 ++++++++++++ install/install_test.go | 491 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 640 insertions(+) create mode 100755 install.sh create mode 100644 install/install_test.go diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..111260c --- /dev/null +++ b/install.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Install ship from the latest GitHub Release (GoReleaser assets). +# Usage: curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | bash +# +# Supports linux/darwin × amd64/arm64. Windows release assets are published on the +# same GitHub Releases page for manual download; this script is Unix-shell-first. +set -euo pipefail + +REPO="${SHIP_REPO:-maxBRT/ship-cli}" +API_BASE="${SHIP_GITHUB_API:-https://api.github.com}" +INSTALL_DIR="${SHIP_INSTALL_DIR:-}" + +err() { + printf '%s\n' "$*" >&2 + exit 1 +} + +detect_platform() { + local os arch + os="${SHIP_OS:-$(uname -s)}" + arch="${SHIP_ARCH:-$(uname -m)}" + case "$os" in + Linux|linux) os=linux ;; + Darwin|darwin) os=darwin ;; + Windows_NT|windows|MINGW*|MSYS*|CYGWIN*) + err "unsupported platform: ${os}/${arch} (install script is Unix-first; download Windows assets from https://github.com/${REPO}/releases)" + ;; + *) err "unsupported platform: ${os}/${arch} (need linux or darwin with amd64 or arm64)" ;; + esac + case "$arch" in + x86_64|amd64) arch=amd64 ;; + aarch64|arm64) arch=arm64 ;; + *) err "unsupported platform: ${os}/${arch} (need linux or darwin with amd64 or arm64)" ;; + esac + OS="$os" + ARCH="$arch" +} + +resolve_install_dir() { + if [[ -n "$INSTALL_DIR" ]]; then + DEST="$INSTALL_DIR" + return + fi + local home_bin="${HOME}/.local/bin" + if mkdir -p "$home_bin" 2>/dev/null && [[ -w "$home_bin" ]]; then + DEST="$home_bin" + return + fi + for d in /usr/local/bin /usr/bin; do + if [[ -d "$d" && -w "$d" ]]; then + DEST="$d" + return + fi + done + err "no writable install directory (tried ~/.local/bin, /usr/local/bin, /usr/bin)" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || err "missing required command: $1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + err "missing required command: sha256sum or shasum" + fi +} + +main() { + need_cmd curl + need_cmd tar + need_cmd mktemp + + detect_platform + resolve_install_dir + mkdir -p "$DEST" + + local tmp + tmp="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '$tmp'" EXIT + + local release_json tag ver asset sums_name archive sums want got + release_json="$(curl -fsSL -H 'Accept: application/vnd.github+json' \ + "${API_BASE}/repos/${REPO}/releases/latest")" || err "failed to fetch latest release from ${API_BASE}" + + tag="$(printf '%s' "$release_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + [[ -n "$tag" ]] || err "latest release missing tag_name" + ver="${tag#v}" + asset="ship-cli_${ver}_${OS}_${ARCH}.tar.gz" + sums_name="ship-cli_${ver}_checksums.txt" + + local asset_url sums_url + asset_url="$(printf '%s' "$release_json" | sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"${asset}\"[[:space:]]*,[[:space:]]*\"browser_download_url\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n1)" + # Prefer parsing assets more carefully with a second pass if sed failed on order + if [[ -z "$asset_url" ]]; then + asset_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\":\"${asset}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + fi + if [[ -z "$asset_url" ]]; then + asset_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\": \"${asset}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + fi + [[ -n "$asset_url" ]] || err "no release asset named ${asset} (unsupported platform or incomplete release)" + + sums_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\":\"${sums_name}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + if [[ -z "$sums_url" ]]; then + sums_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\": \"${sums_name}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" + fi + [[ -n "$sums_url" ]] || err "no checksums asset named ${sums_name}" + + case "$asset_url" in + https://*|http://127.*|http://localhost*|http://\[::1\]*) ;; + http://*) err "download URL must use HTTPS" ;; + *) ;; + esac + case "$sums_url" in + https://*|http://127.*|http://localhost*|http://\[::1\]*) ;; + http://*) err "download URL must use HTTPS" ;; + *) ;; + esac + + archive="${tmp}/${asset}" + sums="${tmp}/checksums.txt" + curl -fsSL "$asset_url" -o "$archive" || err "failed to download ${asset}" + curl -fsSL "$sums_url" -o "$sums" || err "failed to download checksums" + + want="$(awk -v f="$asset" '$NF == f { print $1; exit }' "$sums")" + [[ -n "$want" ]] || err "checksums file has no entry for ${asset}" + got="$(sha256_file "$archive")" + [[ "$got" == "$want" ]] || err "checksum mismatch for ${asset}" + + tar -xzf "$archive" -C "$tmp" + [[ -f "${tmp}/ship" ]] || err "archive does not contain ship binary" + chmod +x "${tmp}/ship" + mv "${tmp}/ship" "${DEST}/ship" + + printf 'Installed ship %s to %s\n' "$tag" "${DEST}/ship" + + case ":${PATH}:" in + *":${DEST}:"*) ;; + *) + printf 'warning: %s is not on PATH; add it so you can run ship\n' "$DEST" >&2 + ;; + esac +} + +main "$@" diff --git a/install/install_test.go b/install/install_test.go new file mode 100644 index 0000000..522bc8e --- /dev/null +++ b/install/install_test.go @@ -0,0 +1,491 @@ +package install_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestInstallScript_installsShipIntoBinDir(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for install script test") + } + + binDir := t.TempDir() + binary := []byte("ship-binary-v1") + archiveBytes := makeTarGz(t, "ship", binary) + ver := "1.0.0" + asset := archiveName(ver, goos, goarch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = append(os.Environ(), + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh failed: %v\n%s", err, out) + } + + installed := filepath.Join(binDir, "ship") + got, err := os.ReadFile(installed) + if err != nil { + t.Fatalf("read installed ship: %v\n%s", err, out) + } + if !bytes.Equal(got, binary) { + t.Errorf("installed binary = %q, want %q", got, binary) + } + info, err := os.Stat(installed) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("installed ship is not executable: mode=%v", info.Mode()) + } +} + +func TestInstallScript_windowsIsUnixFirstClearError(t *testing.T) { + binDir := t.TempDir() + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = cleanEnv( + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API=http://127.0.0.1:1", + "SHIP_OS=windows", + "SHIP_ARCH=amd64", + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install.sh succeeded on windows, want error\n%s", out) + } + if !strings.Contains(string(out), "unsupported") { + t.Errorf("error should mention unsupported; got:\n%s", out) + } +} + +func TestInstallScript_selectsAssetForLinuxDarwinAmd64Arm64(t *testing.T) { + platforms := []struct{ os, arch string }{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"darwin", "amd64"}, + {"darwin", "arm64"}, + } + for _, p := range platforms { + t.Run(p.os+"_"+p.arch, func(t *testing.T) { + binDir := t.TempDir() + payload := []byte("ship-" + p.os + "-" + p.arch) + archiveBytes := makeTarGz(t, "ship", payload) + ver := "3.1.4" + asset := archiveName(ver, p.os, p.arch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + var downloaded string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + // Publish all four assets so a wrong pick would still 200 if the script + // chose another name; we assert via downloaded asset path instead. + var assets []string + for _, q := range platforms { + name := archiveName(ver, q.os, q.arch) + assets = append(assets, fmt.Sprintf( + `{"name":%q,"browser_download_url":%q}`, + name, srvURL+"/download/"+name, + )) + } + assets = append(assets, fmt.Sprintf( + `{"name":%q,"browser_download_url":%q}`, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), + srvURL+"/download/"+fmt.Sprintf("ship-cli_%s_checksums.txt", ver), + )) + fmt.Fprintf(w, `{"tag_name":"v%s","assets":[%s]}`, ver, strings.Join(assets, ",")) + case strings.HasPrefix(r.URL.Path, "/download/"): + name := strings.TrimPrefix(r.URL.Path, "/download/") + if name == fmt.Sprintf("ship-cli_%s_checksums.txt", ver) { + _, _ = io.WriteString(w, sums) + return + } + downloaded = name + if name != asset { + http.NotFound(w, r) + return + } + _, _ = w.Write(archiveBytes) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = cleanEnv( + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "SHIP_OS="+p.os, + "SHIP_ARCH="+p.arch, + "PATH="+binDir+string(os.PathListSeparator)+"/usr/bin:/bin", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh failed: %v\n%s", err, out) + } + if downloaded != asset { + t.Fatalf("downloaded %q, want %q", downloaded, asset) + } + got, err := os.ReadFile(filepath.Join(binDir, "ship")) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, payload) { + t.Errorf("binary = %q, want %q", got, payload) + } + }) + } +} + +func TestInstallScript_defaultsToUserLocalBin(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for install script test") + } + + home := t.TempDir() + wantDir := filepath.Join(home, ".local", "bin") + binary := []byte("ship-binary-default") + archiveBytes := makeTarGz(t, "ship", binary) + ver := "1.0.0" + asset := archiveName(ver, goos, goarch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = cleanEnv( + "HOME="+home, + "SHIP_GITHUB_API="+srv.URL, + "PATH="+wantDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh failed: %v\n%s", err, out) + } + got, err := os.ReadFile(filepath.Join(wantDir, "ship")) + if err != nil { + t.Fatalf("expected ship in ~/.local/bin: %v\n%s", err, out) + } + if !bytes.Equal(got, binary) { + t.Errorf("installed binary = %q, want %q", got, binary) + } +} + +func TestInstallScript_rejectsNonHTTPSDownloadURL(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for install script test") + } + + binDir := t.TempDir() + ver := "1.0.0" + asset := archiveName(ver, goos, goarch) + sumsName := fmt.Sprintf("ship-cli_%s_checksums.txt", ver) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/repos/maxBRT/ship-cli/releases/latest" { + http.NotFound(w, r) + return + } + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":"http://example.com/download/%s"}, + {"name":%q,"browser_download_url":"http://example.com/download/checksums.txt"} + ] + }`, ver, asset, asset, sumsName) + })) + t.Cleanup(srv.Close) + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = cleanEnv( + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install.sh succeeded, want HTTPS failure\n%s", out) + } + if !strings.Contains(string(out), "HTTPS") { + t.Errorf("error should mention HTTPS; got:\n%s", out) + } + if _, err := os.Stat(filepath.Join(binDir, "ship")); !os.IsNotExist(err) { + t.Errorf("ship should not be installed after HTTPS failure; stat err=%v", err) + } +} + +func cleanEnv(extra ...string) []string { + out := make([]string, 0, len(os.Environ())+len(extra)) + for _, e := range os.Environ() { + switch { + case strings.HasPrefix(e, "SHIP_INSTALL_DIR="), + strings.HasPrefix(e, "SHIP_GITHUB_API="), + strings.HasPrefix(e, "SHIP_OS="), + strings.HasPrefix(e, "SHIP_ARCH="), + strings.HasPrefix(e, "SHIP_REPO="), + strings.HasPrefix(e, "HOME="), + strings.HasPrefix(e, "PATH="): + continue + } + out = append(out, e) + } + return append(out, extra...) +} + +func TestInstallScript_warnsWhenInstallDirNotOnPATH(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for install script test") + } + + binDir := t.TempDir() + binary := []byte("ship-binary-v1") + archiveBytes := makeTarGz(t, "ship", binary) + ver := "1.0.0" + asset := archiveName(ver, goos, goarch) + sums := checksumLine(sha256Sum(archiveBytes), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + // PATH deliberately excludes binDir. + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = append(os.Environ(), + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "PATH=/usr/bin:/bin", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install.sh failed: %v\n%s", err, out) + } + if !strings.Contains(string(out), "not on PATH") { + t.Errorf("expected PATH warning; got:\n%s", out) + } + if _, err := os.Stat(filepath.Join(binDir, "ship")); err != nil { + t.Fatalf("ship should still be installed: %v", err) + } +} + +func TestInstallScript_unsupportedPlatformClearError(t *testing.T) { + binDir := t.TempDir() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v1.0.0","assets":[]}`) + })) + t.Cleanup(srv.Close) + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = append(os.Environ(), + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "SHIP_OS=plan9", + "SHIP_ARCH=amd64", + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install.sh succeeded, want unsupported platform error\n%s", out) + } + if !strings.Contains(string(out), "unsupported") { + t.Errorf("error should mention unsupported; got:\n%s", out) + } + if strings.Contains(string(out), "404") { + t.Errorf("error should not be a cryptic download failure; got:\n%s", out) + } +} + +func TestInstallScript_checksumMismatchDoesNotInstall(t *testing.T) { + goos, goarch := runtime.GOOS, runtime.GOARCH + if !supportedPlatform(goos, goarch) { + t.Skip("unsupported GOOS/GOARCH for install script test") + } + + binDir := t.TempDir() + archiveBytes := makeTarGz(t, "ship", []byte("ship-binary-v1")) + ver := "1.0.0" + asset := archiveName(ver, goos, goarch) + badSums := checksumLine(sha256Sum([]byte("not-the-archive")), asset) + + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{ + "tag_name":"v%s", + "assets":[ + {"name":%q,"browser_download_url":%q}, + {"name":%q,"browser_download_url":%q} + ] + }`, ver, asset, srvURL+"/download/"+asset, + fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archiveBytes) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, badSums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + + script := filepath.Join("..", "install.sh") + cmd := exec.Command("bash", script) + cmd.Env = append(os.Environ(), + "SHIP_INSTALL_DIR="+binDir, + "SHIP_GITHUB_API="+srv.URL, + "PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + out, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install.sh succeeded, want checksum failure\n%s", out) + } + if !strings.Contains(string(out), "checksum") { + t.Errorf("stderr should mention checksum; got:\n%s", out) + } + if _, err := os.Stat(filepath.Join(binDir, "ship")); !os.IsNotExist(err) { + t.Errorf("ship should not be installed after checksum failure; stat err=%v", err) + } +} + +func supportedPlatform(goos, goarch string) bool { + switch goos { + case "linux", "darwin": + default: + return false + } + switch goarch { + case "amd64", "arm64": + return true + default: + return false + } +} + +func makeTarGz(t *testing.T, name string, contents []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(contents))} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(contents); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func archiveName(version, goos, goarch string) string { + return fmt.Sprintf("ship-cli_%s_%s_%s.tar.gz", strings.TrimPrefix(version, "v"), goos, goarch) +} + +func checksumLine(sum []byte, name string) string { + return hex.EncodeToString(sum) + " " + name + "\n" +} + +func sha256Sum(b []byte) []byte { + h := sha256.Sum256(b) + return h[:] +} From d92a935f45ddab86f310d48c3343be897701836d Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:35:52 -0400 Subject: [PATCH 6/7] Document curl install and ship update as primary README paths. Leads with the install one-liner and upgrade command so new users do not think they need Go; keep go install/build as secondary and drop the HTML guide. Co-authored-by: Cursor --- README.md | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2ede35f..842f201 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,21 @@ CLI that runs a sequential Ticket Run in your existing git checkout: confirm a s **Status:** v0.0.1 (experimental). -## User guide - -Open **[guide.html](guide.html)** in a browser for install, update, how a Run works, examples, flags, and troubleshooting. +## Install ```bash -xdg-open guide.html # or open guide.html from a file manager +curl -fsSL https://raw.githubusercontent.com/maxBRT/ship-cli/main/install.sh | bash ``` -## Quick install (today) +The script detects linux/darwin × amd64/arm64, downloads the latest GitHub Release, verifies checksums, and installs `ship` to `~/.local/bin` (or a writable system bin). Windows release assets are on the same [Releases](https://github.com/maxBRT/ship-cli/releases) page for manual download. + +## Update ```bash -git clone git@github.com:maxBRT/ship-cli.git -cd ship-cli -go build -o ~/.local/bin/ship ./cmd/ship -ship --help +ship update ``` -Or: `go install github.com/maxBRT/ship-cli/cmd/ship@latest` +Downloads the latest GitHub Release, verifies checksums, and replaces the running binary in place. Check what you have with `ship --version`. ## Prerequisites @@ -37,4 +34,18 @@ gh issue edit --add-label "ship" ship ``` -See [guide.html](guide.html) for flags, env vars, and more examples. Domain language: [CONTEXT.md](CONTEXT.md). +Domain language: [CONTEXT.md](CONTEXT.md). Run `ship --help` for flags and commands. + +## Install from source (Go developers) + +```bash +go install github.com/maxBRT/ship-cli/cmd/ship@latest +``` + +Or clone and build locally: + +```bash +git clone git@github.com:maxBRT/ship-cli.git +cd ship-cli +go build -o ~/.local/bin/ship ./cmd/ship +``` From 15359fee44a3bdae250084be25b5567ccbca22b9 Mon Sep 17 00:00:00 2001 From: Maxime Bourret Date: Tue, 21 Jul 2026 15:42:42 -0400 Subject: [PATCH 7/7] Fix install.sh asset URL parsing for real GitHub Releases JSON. Nested uploader objects between name and browser_download_url broke the old sed/brace parsers; parse after the matching name and cover that shape in tests. Co-authored-by: Cursor --- install.sh | 62 +++++++++----- install/install_test.go | 181 ++++++++++++++++------------------------ 2 files changed, 110 insertions(+), 133 deletions(-) diff --git a/install.sh b/install.sh index 111260c..7b31273 100755 --- a/install.sh +++ b/install.sh @@ -69,6 +69,41 @@ sha256_file() { fi } +# GitHub embeds a nested "uploader" object between "name" and "browser_download_url", +# often across multiple lines. Flatten newlines, find the matching name, then take the +# first browser_download_url after it. +release_asset_url() { + local json="$1" name="$2" + printf '%s' "$json" | tr '\n' ' ' | awk -v name="$name" ' + BEGIN { + needle1 = "\"name\":\"" name "\"" + needle2 = "\"name\": \"" name "\"" + } + { + line = $0 + idx = index(line, needle1) + if (idx == 0) idx = index(line, needle2) + if (idx == 0) next + rest = substr(line, idx) + if (match(rest, /"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"/)) { + m = substr(rest, RSTART, RLENGTH) + sub(/^"browser_download_url"[[:space:]]*:[[:space:]]*"/, "", m) + sub(/"$/, "", m) + print m + exit + } + } + ' +} + +require_https_url() { + case "$1" in + https://*|http://127.*|http://localhost*|http://\[::1\]*) ;; + http://*) err "download URL must use HTTPS" ;; + *) ;; + esac +} + main() { need_cmd curl need_cmd tar @@ -94,32 +129,13 @@ main() { sums_name="ship-cli_${ver}_checksums.txt" local asset_url sums_url - asset_url="$(printf '%s' "$release_json" | sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"${asset}\"[[:space:]]*,[[:space:]]*\"browser_download_url\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" | head -n1)" - # Prefer parsing assets more carefully with a second pass if sed failed on order - if [[ -z "$asset_url" ]]; then - asset_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\":\"${asset}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" - fi - if [[ -z "$asset_url" ]]; then - asset_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\": \"${asset}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" - fi + asset_url="$(release_asset_url "$release_json" "$asset")" [[ -n "$asset_url" ]] || err "no release asset named ${asset} (unsupported platform or incomplete release)" - - sums_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\":\"${sums_name}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" - if [[ -z "$sums_url" ]]; then - sums_url="$(printf '%s' "$release_json" | tr '{' '\n' | grep -F "\"name\": \"${sums_name}\"" | sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)" - fi + sums_url="$(release_asset_url "$release_json" "$sums_name")" [[ -n "$sums_url" ]] || err "no checksums asset named ${sums_name}" - case "$asset_url" in - https://*|http://127.*|http://localhost*|http://\[::1\]*) ;; - http://*) err "download URL must use HTTPS" ;; - *) ;; - esac - case "$sums_url" in - https://*|http://127.*|http://localhost*|http://\[::1\]*) ;; - http://*) err "download URL must use HTTPS" ;; - *) ;; - esac + require_https_url "$asset_url" + require_https_url "$sums_url" archive="${tmp}/${asset}" sums="${tmp}/checksums.txt" diff --git a/install/install_test.go b/install/install_test.go index 522bc8e..70ab89d 100644 --- a/install/install_test.go +++ b/install/install_test.go @@ -30,29 +30,7 @@ func TestInstallScript_installsShipIntoBinDir(t *testing.T) { ver := "1.0.0" asset := archiveName(ver, goos, goarch) sums := checksumLine(sha256Sum(archiveBytes), asset) - - var srvURL string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": - fmt.Fprintf(w, `{ - "tag_name":"v%s", - "assets":[ - {"name":%q,"browser_download_url":%q}, - {"name":%q,"browser_download_url":%q} - ] - }`, ver, asset, srvURL+"/download/"+asset, - fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") - case r.URL.Path == "/download/"+asset: - _, _ = w.Write(archiveBytes) - case r.URL.Path == "/download/checksums.txt": - _, _ = io.WriteString(w, sums) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - srvURL = srv.URL + srv := newReleaseServer(t, ver, asset, archiveBytes, sums) script := filepath.Join("..", "install.sh") cmd := exec.Command("bash", script) @@ -117,6 +95,7 @@ func TestInstallScript_selectsAssetForLinuxDarwinAmd64Arm64(t *testing.T) { ver := "3.1.4" asset := archiveName(ver, p.os, p.arch) sums := checksumLine(sha256Sum(archiveBytes), asset) + sumsName := fmt.Sprintf("ship-cli_%s_checksums.txt", ver) var srvURL string var downloaded string @@ -128,20 +107,14 @@ func TestInstallScript_selectsAssetForLinuxDarwinAmd64Arm64(t *testing.T) { var assets []string for _, q := range platforms { name := archiveName(ver, q.os, q.arch) - assets = append(assets, fmt.Sprintf( - `{"name":%q,"browser_download_url":%q}`, - name, srvURL+"/download/"+name, - )) + assets = append(assets, githubAssetJSON(name, srvURL+"/download/"+name)) } - assets = append(assets, fmt.Sprintf( - `{"name":%q,"browser_download_url":%q}`, - fmt.Sprintf("ship-cli_%s_checksums.txt", ver), - srvURL+"/download/"+fmt.Sprintf("ship-cli_%s_checksums.txt", ver), - )) - fmt.Fprintf(w, `{"tag_name":"v%s","assets":[%s]}`, ver, strings.Join(assets, ",")) + assets = append(assets, githubAssetJSON(sumsName, srvURL+"/download/"+sumsName)) + fmt.Fprintf(w, `{"url":"https://api.github.com/repos/maxBRT/ship-cli/releases/1","tag_name":"v%s","name":"v%s","assets":[%s]}`, + ver, ver, strings.Join(assets, ",")) case strings.HasPrefix(r.URL.Path, "/download/"): name := strings.TrimPrefix(r.URL.Path, "/download/") - if name == fmt.Sprintf("ship-cli_%s_checksums.txt", ver) { + if name == sumsName { _, _ = io.WriteString(w, sums) return } @@ -198,29 +171,7 @@ func TestInstallScript_defaultsToUserLocalBin(t *testing.T) { ver := "1.0.0" asset := archiveName(ver, goos, goarch) sums := checksumLine(sha256Sum(archiveBytes), asset) - - var srvURL string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": - fmt.Fprintf(w, `{ - "tag_name":"v%s", - "assets":[ - {"name":%q,"browser_download_url":%q}, - {"name":%q,"browser_download_url":%q} - ] - }`, ver, asset, srvURL+"/download/"+asset, - fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") - case r.URL.Path == "/download/"+asset: - _, _ = w.Write(archiveBytes) - case r.URL.Path == "/download/checksums.txt": - _, _ = io.WriteString(w, sums) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - srvURL = srv.URL + srv := newReleaseServer(t, ver, asset, archiveBytes, sums) script := filepath.Join("..", "install.sh") cmd := exec.Command("bash", script) @@ -258,13 +209,11 @@ func TestInstallScript_rejectsNonHTTPSDownloadURL(t *testing.T) { http.NotFound(w, r) return } - fmt.Fprintf(w, `{ - "tag_name":"v%s", - "assets":[ - {"name":%q,"browser_download_url":"http://example.com/download/%s"}, - {"name":%q,"browser_download_url":"http://example.com/download/checksums.txt"} - ] - }`, ver, asset, asset, sumsName) + fmt.Fprintf(w, `{"tag_name":"v%s","name":"v%s","assets":[%s,%s]}`, + ver, ver, + githubAssetJSON(asset, "http://example.com/download/"+asset), + githubAssetJSON(sumsName, "http://example.com/download/checksums.txt"), + ) })) t.Cleanup(srv.Close) @@ -317,29 +266,7 @@ func TestInstallScript_warnsWhenInstallDirNotOnPATH(t *testing.T) { ver := "1.0.0" asset := archiveName(ver, goos, goarch) sums := checksumLine(sha256Sum(archiveBytes), asset) - - var srvURL string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": - fmt.Fprintf(w, `{ - "tag_name":"v%s", - "assets":[ - {"name":%q,"browser_download_url":%q}, - {"name":%q,"browser_download_url":%q} - ] - }`, ver, asset, srvURL+"/download/"+asset, - fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") - case r.URL.Path == "/download/"+asset: - _, _ = w.Write(archiveBytes) - case r.URL.Path == "/download/checksums.txt": - _, _ = io.WriteString(w, sums) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - srvURL = srv.URL + srv := newReleaseServer(t, ver, asset, archiveBytes, sums) // PATH deliberately excludes binDir. script := filepath.Join("..", "install.sh") @@ -399,29 +326,7 @@ func TestInstallScript_checksumMismatchDoesNotInstall(t *testing.T) { ver := "1.0.0" asset := archiveName(ver, goos, goarch) badSums := checksumLine(sha256Sum([]byte("not-the-archive")), asset) - - var srvURL string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": - fmt.Fprintf(w, `{ - "tag_name":"v%s", - "assets":[ - {"name":%q,"browser_download_url":%q}, - {"name":%q,"browser_download_url":%q} - ] - }`, ver, asset, srvURL+"/download/"+asset, - fmt.Sprintf("ship-cli_%s_checksums.txt", ver), srvURL+"/download/checksums.txt") - case r.URL.Path == "/download/"+asset: - _, _ = w.Write(archiveBytes) - case r.URL.Path == "/download/checksums.txt": - _, _ = io.WriteString(w, badSums) - default: - http.NotFound(w, r) - } - })) - t.Cleanup(srv.Close) - srvURL = srv.URL + srv := newReleaseServer(t, ver, asset, archiveBytes, badSums) script := filepath.Join("..", "install.sh") cmd := exec.Command("bash", script) @@ -456,6 +361,62 @@ func supportedPlatform(goos, goarch string) bool { } } +// githubAssetJSON mirrors the GitHub Releases API shape: "name" appears before a +// nested "uploader" object, and "browser_download_url" comes after it. +func githubAssetJSON(name, downloadURL string) string { + return fmt.Sprintf(`{ + "url":"https://api.github.com/repos/maxBRT/ship-cli/releases/assets/1", + "id":1, + "node_id":"RA_test", + "name":%q, + "label":"", + "uploader":{ + "login":"releaser", + "id":1, + "node_id":"U_test", + "avatar_url":"https://example.test/a", + "gravatar_id":"", + "url":"https://api.github.com/users/releaser", + "html_url":"https://github.com/releaser", + "type":"User", + "site_admin":false + }, + "content_type":"application/gzip", + "state":"uploaded", + "size":1, + "digest":null, + "download_count":0, + "created_at":"2026-01-01T00:00:00Z", + "updated_at":"2026-01-01T00:00:00Z", + "browser_download_url":%q + }`, name, downloadURL) +} + +func newReleaseServer(t *testing.T, ver, asset string, archive []byte, sums string) *httptest.Server { + t.Helper() + sumsName := fmt.Sprintf("ship-cli_%s_checksums.txt", ver) + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/repos/maxBRT/ship-cli/releases/latest": + fmt.Fprintf(w, `{"url":"https://api.github.com/repos/maxBRT/ship-cli/releases/1","tag_name":"v%s","name":"v%s","assets":[%s,%s]}`, + ver, ver, + githubAssetJSON(asset, srvURL+"/download/"+asset), + githubAssetJSON(sumsName, srvURL+"/download/checksums.txt"), + ) + case r.URL.Path == "/download/"+asset: + _, _ = w.Write(archive) + case r.URL.Path == "/download/checksums.txt": + _, _ = io.WriteString(w, sums) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + srvURL = srv.URL + return srv +} + func makeTarGz(t *testing.T, name string, contents []byte) []byte { t.Helper() var buf bytes.Buffer