From 84fb74e46122ac83123d8f0795053034468db8bb Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 22:36:52 -0600 Subject: [PATCH 01/13] fix(update): guard package-managed installations Signed-off-by: Samuel K --- cmd/update/update.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/cmd/update/update.go b/cmd/update/update.go index 22c5a3d11..c6185f189 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -6,6 +6,7 @@ import ( cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/selfupdate" + "github.com/devsy-org/devsy/pkg/version" "github.com/spf13/cobra" ) @@ -42,6 +43,9 @@ func NewUpdateCmd() *cobra.Command { } }, RunE: func(cobraCmd *cobra.Command, args []string) error { + if err := checkPackageManager(version.GetPackageManager()); err != nil { + return err + } ctx := cobraCmd.Context() opts := selfupdate.Options{ Version: cmd.Version, @@ -78,3 +82,16 @@ func NewUpdateCmd() *cobra.Command { ) return updateCmd } + +func checkPackageManager(packageManager string) error { + switch packageManager { + case "", "direct": + return nil + case "homebrew": + return fmt.Errorf( + "this Devsy installation is managed by Homebrew; run `brew upgrade devsy` to update", + ) + default: + return fmt.Errorf("unsupported package manager %q", packageManager) + } +} From 46f94aae18958b77eff86f1e7da9ac8d93ed20e8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 22:38:31 -0600 Subject: [PATCH 02/13] feat(update): add package manager build identity Signed-off-by: Samuel K --- pkg/version/package_manager.go | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 pkg/version/package_manager.go diff --git a/pkg/version/package_manager.go b/pkg/version/package_manager.go new file mode 100644 index 000000000..9acaed971 --- /dev/null +++ b/pkg/version/package_manager.go @@ -0,0 +1,10 @@ +package version + +// packageManager is injected at build time by package-managed distributions. +// An empty value means the binary owns its update lifecycle. +var packageManager string + +// GetPackageManager identifies the package manager responsible for this binary. +func GetPackageManager() string { + return packageManager +} From bf50c95e732b6ffe76b6bc94124f78492a899d1b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 22:38:41 -0600 Subject: [PATCH 03/13] build(homebrew): add source formula candidate Signed-off-by: Samuel K --- Formula/devsy.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 Formula/devsy.rb diff --git a/Formula/devsy.rb b/Formula/devsy.rb new file mode 100644 index 000000000..28709b284 --- /dev/null +++ b/Formula/devsy.rb @@ -0,0 +1,24 @@ +class Devsy < Formula + desc "Standardized dev workspaces across Docker, Kubernetes, cloud, and SSH" + homepage "https://www.devsy.sh" + url "https://github.com/devsy-org/devsy/archive/refs/tags/v1.19.1.tar.gz" + sha256 "REPLACE_WITH_V1_19_1_SOURCE_SHA256" + license "MPL-2.0" + + depends_on "go" => :build + + def install + ldflags = %W[ + -s -w + -X github.com/devsy-org/devsy/pkg/version.version=v#{version} + -X github.com/devsy-org/devsy/pkg/version.packageManager=homebrew + ] + system "go", "build", *std_go_args(ldflags:), "." + end + + test do + assert_match version.to_s, shell_output("#{bin}/devsy --version") + output = shell_output("#{bin}/devsy update 2>&1", 1) + assert_match "brew upgrade devsy", output + end +end From 70a31de05685e9e075b300781849b11103851fa2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 22:38:48 -0600 Subject: [PATCH 04/13] docs(homebrew): document package-managed builds Signed-off-by: Samuel K --- docs/package-manager-builds.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/package-manager-builds.md diff --git a/docs/package-manager-builds.md b/docs/package-manager-builds.md new file mode 100644 index 000000000..ab8455f06 --- /dev/null +++ b/docs/package-manager-builds.md @@ -0,0 +1,20 @@ +# Package-managed builds + +Devsy normally owns its update lifecycle, so `devsy update` downloads and installs a release directly. A package manager that owns the installed binary must set its identity at build time instead: + +```sh +go build -ldflags "-X github.com/devsy-org/devsy/pkg/version.packageManager=homebrew" . +``` + +The `homebrew` identity makes `devsy update` stop before contacting the release service and direct the user to `brew upgrade devsy`. Builds without the marker, including the release binaries used by the current custom tap, keep direct self-update behavior. + +To add another package manager: + +1. Choose a stable lowercase identity and inject it with the same linker variable. +2. Add the package manager's update command to `cmd/update`. +3. Test that the managed build returns guidance without starting self-update. +4. Add a functional package test that exercises the guidance from the installed binary. + +`Formula/devsy.rb` is a source-built candidate for eventual submission to `homebrew/core`. It is not published there by this repository. + +Before submitting the candidate formula, replace its release URL and checksum with the first release that contains the package-manager contract. The checked-in placeholder prevents accidentally submitting a formula for an older source tree that cannot honor the managed-update behavior. From 85f21cc0753d4c7496dc6313cc7af79ecc71000f Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:00:32 -0600 Subject: [PATCH 05/13] chore(homebrew): keep formula in tap repository Signed-off-by: Samuel K --- Formula/devsy.rb | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 Formula/devsy.rb diff --git a/Formula/devsy.rb b/Formula/devsy.rb deleted file mode 100644 index 28709b284..000000000 --- a/Formula/devsy.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Devsy < Formula - desc "Standardized dev workspaces across Docker, Kubernetes, cloud, and SSH" - homepage "https://www.devsy.sh" - url "https://github.com/devsy-org/devsy/archive/refs/tags/v1.19.1.tar.gz" - sha256 "REPLACE_WITH_V1_19_1_SOURCE_SHA256" - license "MPL-2.0" - - depends_on "go" => :build - - def install - ldflags = %W[ - -s -w - -X github.com/devsy-org/devsy/pkg/version.version=v#{version} - -X github.com/devsy-org/devsy/pkg/version.packageManager=homebrew - ] - system "go", "build", *std_go_args(ldflags:), "." - end - - test do - assert_match version.to_s, shell_output("#{bin}/devsy --version") - output = shell_output("#{bin}/devsy update 2>&1", 1) - assert_match "brew upgrade devsy", output - end -end From 6ffc8fc20a0fa5425d31d413d783b642b0e30134 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:00:53 -0600 Subject: [PATCH 06/13] chore(homebrew): remove premature packaging docs Signed-off-by: Samuel K --- docs/package-manager-builds.md | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 docs/package-manager-builds.md diff --git a/docs/package-manager-builds.md b/docs/package-manager-builds.md deleted file mode 100644 index ab8455f06..000000000 --- a/docs/package-manager-builds.md +++ /dev/null @@ -1,20 +0,0 @@ -# Package-managed builds - -Devsy normally owns its update lifecycle, so `devsy update` downloads and installs a release directly. A package manager that owns the installed binary must set its identity at build time instead: - -```sh -go build -ldflags "-X github.com/devsy-org/devsy/pkg/version.packageManager=homebrew" . -``` - -The `homebrew` identity makes `devsy update` stop before contacting the release service and direct the user to `brew upgrade devsy`. Builds without the marker, including the release binaries used by the current custom tap, keep direct self-update behavior. - -To add another package manager: - -1. Choose a stable lowercase identity and inject it with the same linker variable. -2. Add the package manager's update command to `cmd/update`. -3. Test that the managed build returns guidance without starting self-update. -4. Add a functional package test that exercises the guidance from the installed binary. - -`Formula/devsy.rb` is a source-built candidate for eventual submission to `homebrew/core`. It is not published there by this repository. - -Before submitting the candidate formula, replace its release URL and checksum with the first release that contains the package-manager contract. The checked-in placeholder prevents accidentally submitting a formula for an older source tree that cannot honor the managed-update behavior. From 4b466f071e616dfef07fc52f1495a5327e277273 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:40:01 -0600 Subject: [PATCH 07/13] build(homebrew): add package-managed release binaries Signed-off-by: Samuel K --- .goreleaser.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index fc143c33a..705d00ed9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -62,6 +62,14 @@ builds: env: - CGO_ENABLED=0 + - id: devsy-homebrew + goos: [linux, darwin] + goarch: [amd64, arm64] + binary: devsy-homebrew-{{ .Os }}-{{ .Arch }} + ldflags: -s -w -X main.build={{ .Env.DEVSY_CLI_VERSION }} -X main.commit={{ .Commit }} -X main.date={{ .Date }} -X github.com/devsy-org/devsy/pkg/version.version={{ .Env.DEVSY_CLI_VERSION }} -X github.com/devsy-org/devsy/pkg/version.packageManager=homebrew -X github.com/devsy-org/devsy/pkg/telemetry/analytics.posthogAPIKey={{ envOrDefault "DEVSY_POSTHOG_API_KEY" "" }} + env: + - CGO_ENABLED=0 + - id: devsy-dev goos: [linux, darwin] goarch: [amd64, arm64] From 197e6ece922a72a5218fe92503a4980fafc02d50 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:40:35 -0600 Subject: [PATCH 08/13] build(homebrew): add package-managed release binaries Signed-off-by: Samuel K From 0f1a6e82785d3a6f4d5437fb1d364e65fb9f0b38 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:41:07 -0600 Subject: [PATCH 09/13] ci(homebrew): publish managed release artifacts Signed-off-by: Samuel K --- .github/workflows/release.yml | 36 ++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63f887cbf..23a512131 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -389,7 +389,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: - contents: read + contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -397,17 +397,35 @@ jobs: with: go-version-file: go.mod - - name: download darwin CLI artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - name: build Homebrew-managed CLI artifacts + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7 with: - name: devsy-darwin - path: ${{ runner.temp }}/devsy-bin/ + distribution: goreleaser + version: "~> v2" + args: build --id devsy-homebrew --snapshot + env: + DEVSY_CLI_VERSION: ${{ inputs.tag || github.ref_name }} + DEVSY_POSTHOG_API_KEY: ${{ secrets.DEVSY_POSTHOG_API_KEY }} - - name: download linux CLI artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - name: stage Homebrew-managed CLI artifacts + run: | + mkdir -p "${{ runner.temp }}/devsy-bin" + find dist -type f -name 'devsy-homebrew-*' -exec cp {} "${{ runner.temp }}/devsy-bin/" \; + + - name: verify Homebrew-managed update guidance + run: | + set +e + output=$("${{ runner.temp }}/devsy-bin/devsy-homebrew-linux-amd64" update 2>&1) + status=$? + set -e + test "$status" -ne 0 + grep -F "brew upgrade devsy" <<<"$output" + + - name: upload Homebrew-managed CLI release assets + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: - name: devsy-linux - path: ${{ runner.temp }}/devsy-bin/ + tag_name: ${{ inputs.tag || github.ref_name }} + files: ${{ runner.temp }}/devsy-bin/devsy-homebrew-* - name: download macOS dmg artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 From 45767f7ce887e72068eda62f0ee181e46e3b2413 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:41:29 -0600 Subject: [PATCH 10/13] build(homebrew): install managed release artifacts Signed-off-by: Samuel K --- hack/homebrew_formula/main.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/hack/homebrew_formula/main.go b/hack/homebrew_formula/main.go index debfd5197..f0e0b032c 100644 --- a/hack/homebrew_formula/main.go +++ b/hack/homebrew_formula/main.go @@ -22,10 +22,10 @@ type platform struct { } var platforms = []platform{ - {OS: "macos", Arch: "arm", Binary: "devsy-darwin-arm64"}, - {OS: "macos", Arch: "intel", Binary: "devsy-darwin-amd64"}, - {OS: "linux", Arch: "arm", Binary: "devsy-linux-arm64"}, - {OS: "linux", Arch: "intel", Binary: "devsy-linux-amd64"}, + {OS: "macos", Arch: "arm", Binary: "devsy-homebrew-darwin-arm64"}, + {OS: "macos", Arch: "intel", Binary: "devsy-homebrew-darwin-amd64"}, + {OS: "linux", Arch: "arm", Binary: "devsy-homebrew-linux-arm64"}, + {OS: "linux", Arch: "intel", Binary: "devsy-homebrew-linux-amd64"}, } const formulaTmpl = `class Devsy < Formula @@ -57,7 +57,7 @@ const formulaTmpl = `class Devsy < Formula end def install - bin.install Dir["devsy-*"].first => "devsy" + bin.install Dir["devsy-homebrew-*"].first => "devsy" end test do From 6395556f21ed1b46aaac6978276bca9537f84359 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 20 Sep 2026 23:41:38 -0600 Subject: [PATCH 11/13] test(homebrew): update formula artifact names Signed-off-by: Samuel K --- hack/homebrew_formula/main_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hack/homebrew_formula/main_test.go b/hack/homebrew_formula/main_test.go index 6f870549e..f0b29f3c7 100644 --- a/hack/homebrew_formula/main_test.go +++ b/hack/homebrew_formula/main_test.go @@ -25,16 +25,16 @@ func TestRender(t *testing.T) { t.Fatalf("render: %v", err) } - // sha256("content-devsy-darwin-arm64") - const wantARM = "9bc996e636ac5321e2aae6bd3fb421c5ea82b34a5b20e2c9fd65cc81b2f3753e" + // sha256("content-devsy-homebrew-darwin-arm64") + const wantARM = "8d309b67abfc5861b1bfb39ea9ed2eda8622eb3aa1f82131cbbefce2bb6ffad7" for _, want := range []string{ `version "1.2.3"`, // leading v stripped `license "MPL-2.0"`, - "https://github.com/devsy-org/devsy/releases/download/v1.2.3/devsy-darwin-arm64", - "https://github.com/devsy-org/devsy/releases/download/v1.2.3/devsy-linux-amd64", + "https://github.com/devsy-org/devsy/releases/download/v1.2.3/devsy-homebrew-darwin-arm64", + "https://github.com/devsy-org/devsy/releases/download/v1.2.3/devsy-homebrew-linux-amd64", `sha256 "` + wantARM + `"`, - `bin.install Dir["devsy-*"].first => "devsy"`, + `bin.install Dir["devsy-homebrew-*"].first => "devsy"`, } { if !strings.Contains(out, want) { t.Errorf("formula missing %q\n---\n%s", want, out) @@ -44,7 +44,7 @@ func TestRender(t *testing.T) { func TestRenderMissingBinary(t *testing.T) { dir := writeBinaries(t) - if err := os.Remove(filepath.Join(dir, "devsy-linux-arm64")); err != nil { + if err := os.Remove(filepath.Join(dir, "devsy-homebrew-linux-arm64")); err != nil { t.Fatal(err) } if _, err := render(dir, "devsy-org/devsy", "v1.2.3"); err == nil { From 8df1b0a85f86382ac3f7825c86d185f6eafbc7cf Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 21:37:44 -0600 Subject: [PATCH 12/13] fix(homebrew): prevent binary URL extraction --- hack/homebrew_formula/main.go | 8 ++++---- hack/homebrew_formula/main_test.go | 8 ++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hack/homebrew_formula/main.go b/hack/homebrew_formula/main.go index f0e0b032c..b327a6422 100644 --- a/hack/homebrew_formula/main.go +++ b/hack/homebrew_formula/main.go @@ -36,22 +36,22 @@ const formulaTmpl = `class Devsy < Formula on_macos do on_arm do - url "{{ (index .Platforms "macos/arm").URL }}" + url "{{ (index .Platforms "macos/arm").URL }}" using: :nounzip sha256 "{{ (index .Platforms "macos/arm").SHA256 }}" end on_intel do - url "{{ (index .Platforms "macos/intel").URL }}" + url "{{ (index .Platforms "macos/intel").URL }}" using: :nounzip sha256 "{{ (index .Platforms "macos/intel").SHA256 }}" end end on_linux do on_arm do - url "{{ (index .Platforms "linux/arm").URL }}" + url "{{ (index .Platforms "linux/arm").URL }}" using: :nounzip sha256 "{{ (index .Platforms "linux/arm").SHA256 }}" end on_intel do - url "{{ (index .Platforms "linux/intel").URL }}" + url "{{ (index .Platforms "linux/intel").URL }}" using: :nounzip sha256 "{{ (index .Platforms "linux/intel").SHA256 }}" end end diff --git a/hack/homebrew_formula/main_test.go b/hack/homebrew_formula/main_test.go index f0b29f3c7..1d870184b 100644 --- a/hack/homebrew_formula/main_test.go +++ b/hack/homebrew_formula/main_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strings" @@ -40,6 +41,13 @@ func TestRender(t *testing.T) { t.Errorf("formula missing %q\n---\n%s", want, out) } } + + for _, p := range platforms { + want := fmt.Sprintf(`url "%s" using: :nounzip`, assetURL("devsy-org/devsy", "v1.2.3", p.Binary)) + if !strings.Contains(out, want) { + t.Errorf("formula missing raw binary URL option for %s/%s: %q\n---\n%s", p.OS, p.Arch, want, out) + } + } } func TestRenderMissingBinary(t *testing.T) { From 279d4e2b4e02ece9159638e3878b87432562d588 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 21 Sep 2026 22:07:37 -0600 Subject: [PATCH 13/13] fix(homebrew): format formula test --- hack/homebrew_formula/main_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/hack/homebrew_formula/main_test.go b/hack/homebrew_formula/main_test.go index 1d870184b..33fb3574f 100644 --- a/hack/homebrew_formula/main_test.go +++ b/hack/homebrew_formula/main_test.go @@ -43,9 +43,18 @@ func TestRender(t *testing.T) { } for _, p := range platforms { - want := fmt.Sprintf(`url "%s" using: :nounzip`, assetURL("devsy-org/devsy", "v1.2.3", p.Binary)) + want := fmt.Sprintf( + `url "%s" using: :nounzip`, + assetURL("devsy-org/devsy", "v1.2.3", p.Binary), + ) if !strings.Contains(out, want) { - t.Errorf("formula missing raw binary URL option for %s/%s: %q\n---\n%s", p.OS, p.Arch, want, out) + t.Errorf( + "formula missing raw binary URL option for %s/%s: %q\n---\n%s", + p.OS, + p.Arch, + want, + out, + ) } } }