From b8ff3fec4440d4eabd1aa6f5bbcd1d477334f828 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 27 Jul 2026 23:51:34 -0500 Subject: [PATCH 1/3] fix(release): detect new formula file when publishing homebrew tap Stage the formula before checking for changes so a new (untracked) file is detected, not just modifications to a tracked one. Use git diff --cached and push with -u origin HEAD so a fresh/empty tap repo publishes correctly. --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0acebc656..4666799d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -435,13 +435,13 @@ jobs: cd tap git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - if git diff --quiet; then + git add Formula/devsy.rb + if git diff --cached --quiet; then echo "Formula unchanged; nothing to publish" exit 0 fi - git add Formula/devsy.rb git commit -m "devsy ${TAG}" - git push + git push -u origin HEAD prerelease: name: Publish Prerelease From cfa5cfdc5f97c63c953c8aeef733f5272e754976 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 00:00:35 -0500 Subject: [PATCH 2/3] feat(release): publish homebrew cask for desktop app Add a cask generator (hack/homebrew_cask) that emits Casks/devsy.rb from the notarized macOS dmgs, and wire it into the publish-homebrew job: upload the mac dmgs as a workflow artifact, download them alongside the CLI binaries, and generate + commit the cask next to the formula. Users can install the desktop app with: brew install --cask devsy-org/tap/devsy --- .github/workflows/release.yml | 30 +++++++-- hack/homebrew_cask/main.go | 116 ++++++++++++++++++++++++++++++++ hack/homebrew_cask/main_test.go | 54 +++++++++++++++ 3 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 hack/homebrew_cask/main.go create mode 100644 hack/homebrew_cask/main_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4666799d8..af7c4b99b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,6 +232,13 @@ jobs: desktop/release/*.deb desktop/release/*.rpm + - name: upload macOS dmg for homebrew cask + if: runner.os == 'macOS' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-dmg-${{ matrix.name }} + path: desktop/release/*.dmg + - name: rewrite manifest URLs to GitHub Release assets if: hashFiles('desktop/release/latest*.yml', 'desktop/release/beta*.yml') != '' # Windows defaults to pwsh; `$REPO`/`$TAG` would be empty PowerShell vars. @@ -372,7 +379,7 @@ jobs: ./Devsy.flatpak publish-homebrew: - name: Publish Homebrew Formula + name: Publish Homebrew Formula & Cask needs: [build-desktop] if: ${{ !github.event.release.prerelease }} runs-on: ubuntu-latest @@ -397,6 +404,13 @@ jobs: name: devsy-linux path: ${{ runner.temp }}/devsy-bin/ + - name: download macOS dmg artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: desktop-dmg-* + path: ${{ runner.temp }}/devsy-dmg/ + merge-multiple: true + - name: flatten CLI artifacts run: go run ./hack/release_artifacts inventory --dir "${{ runner.temp }}/devsy-bin" --flatten @@ -409,13 +423,14 @@ jobs: owner: ${{ github.repository_owner }} repositories: homebrew-tap - - name: generate and push formula + - name: generate and push formula and cask env: TAP_TOKEN: ${{ steps.app-token.outputs.token }} TAP_REPO: ${{ github.repository_owner }}/homebrew-tap REPO: ${{ github.repository }} TAG: ${{ inputs.tag || github.ref_name }} BIN_DIR: ${{ runner.temp }}/devsy-bin + DMG_DIR: ${{ runner.temp }}/devsy-dmg run: | set -euo pipefail @@ -425,19 +440,24 @@ jobs: fi git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap - mkdir -p tap/Formula + mkdir -p tap/Formula tap/Casks go run ./hack/homebrew_formula \ --bin-dir "$BIN_DIR" \ --repo "$REPO" \ --tag "$TAG" \ --out tap/Formula/devsy.rb + go run ./hack/homebrew_cask \ + --dmg-dir "$DMG_DIR" \ + --repo "$REPO" \ + --tag "$TAG" \ + --out tap/Casks/devsy.rb cd tap git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Formula/devsy.rb + git add Formula/devsy.rb Casks/devsy.rb if git diff --cached --quiet; then - echo "Formula unchanged; nothing to publish" + echo "Formula and cask unchanged; nothing to publish" exit 0 fi git commit -m "devsy ${TAG}" diff --git a/hack/homebrew_cask/main.go b/hack/homebrew_cask/main.go new file mode 100644 index 000000000..ef57a4873 --- /dev/null +++ b/hack/homebrew_cask/main.go @@ -0,0 +1,116 @@ +// Generates the Homebrew cask for the Devsy desktop app from the released +// per-arch macOS disk images, embedding each image's sha256. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +type arch struct { + Cask string // cask arch token: arm, intel + DMG string // release asset filename + SHA256 string +} + +var arches = []arch{ + {Cask: "arm", DMG: "Devsy_mac_arm64.dmg"}, + {Cask: "intel", DMG: "Devsy_mac_x64.dmg"}, +} + +const caskTmpl = `cask "devsy" do + arch arm: "arm64", intel: "x64" + + version "{{ .Version }}" + sha256 arm: "{{ (index .Arches "arm").SHA256 }}", + intel: "{{ (index .Arches "intel").SHA256 }}" + + url "https://github.com/{{ .Repo }}/releases/download/v#{version}/Devsy_mac_#{arch}.dmg" + name "Devsy" + desc "Standardized dev workspaces across Docker, Kubernetes, cloud, and SSH" + homepage "https://www.devsy.sh" + + app "Devsy.app" + + zap trash: [ + "~/Library/Application Support/Devsy", + "~/Library/Logs/Devsy", + "~/Library/Preferences/sh.devsy.app.plist", + "~/Library/Saved Application State/sh.devsy.app.savedState", + ] +end +` + +func fileSHA256(path string) (string, error) { + data, err := os.ReadFile(path) // #nosec G304 -- release tooling reads local build artifacts. + if err != nil { + return "", err + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), nil +} + +func render(dmgDir, repo, tag string) (string, error) { + byKey := make(map[string]arch, len(arches)) + for _, a := range arches { + sum, err := fileSHA256(filepath.Join(dmgDir, a.DMG)) + if err != nil { + return "", fmt.Errorf("checksum %s: %w", a.DMG, err) + } + a.SHA256 = sum + byKey[a.Cask] = a + } + + tmpl, err := template.New("cask").Parse(caskTmpl) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + var sb strings.Builder + data := struct { + Version string + Repo string + Arches map[string]arch + }{Version: strings.TrimPrefix(tag, "v"), Repo: repo, Arches: byKey} + if err := tmpl.Execute(&sb, data); err != nil { + return "", fmt.Errorf("render template: %w", err) + } + return sb.String(), nil +} + +func main() { + dmgDir := flag.String( + "dmg-dir", + "", + "directory containing the Devsy_mac_.dmg release images", + ) + repo := flag.String("repo", "", "owner/repo the release assets are published under") + tag := flag.String("tag", "", "release tag (e.g. v1.2.3)") + out := flag.String("out", "", "path to write the generated cask") + flag.Parse() + + if *dmgDir == "" || *repo == "" || *tag == "" || *out == "" { + fmt.Fprintln( + os.Stderr, + "usage: homebrew_cask --dmg-dir DIR --repo OWNER/REPO --tag TAG --out FILE", + ) + os.Exit(2) + } + + cask, err := render(*dmgDir, *repo, *tag) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + // #nosec G306 -- cask is public. + if err := os.WriteFile(*out, []byte(cask), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("wrote %s\n", *out) +} diff --git a/hack/homebrew_cask/main_test.go b/hack/homebrew_cask/main_test.go new file mode 100644 index 000000000..b51fb14ad --- /dev/null +++ b/hack/homebrew_cask/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeDMGs(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, a := range arches { + path := filepath.Join(dir, a.DMG) + if err := os.WriteFile(path, []byte("content-"+a.DMG), 0o644); err != nil { + t.Fatalf("write %s: %v", a.DMG, err) + } + } + return dir +} + +func TestRender(t *testing.T) { + out, err := render(writeDMGs(t), "devsy-org/devsy", "v1.2.3") + if err != nil { + t.Fatalf("render: %v", err) + } + + // sha256("content-Devsy_mac_arm64.dmg") + const wantARM = "f65094affc51b44bb65e948773afbdb527e5073f82569bcd9361deb17581807b" + + for _, want := range []string{ + `version "1.2.3"`, // leading v stripped + `cask "devsy" do`, + `arch arm: "arm64", intel: "x64"`, + "https://github.com/devsy-org/devsy/releases/download/v#{version}/Devsy_mac_#{arch}.dmg", + `app "Devsy.app"`, + `homepage "https://www.devsy.sh"`, + `sha256 arm: "` + wantARM + `"`, + } { + if !strings.Contains(out, want) { + t.Errorf("cask missing %q\n---\n%s", want, out) + } + } +} + +func TestRenderMissingDMG(t *testing.T) { + dir := writeDMGs(t) + if err := os.Remove(filepath.Join(dir, "Devsy_mac_x64.dmg")); err != nil { + t.Fatal(err) + } + if _, err := render(dir, "devsy-org/devsy", "v1.2.3"); err == nil { + t.Fatal("expected error for missing dmg, got nil") + } +} From 04c5e9014a9dcef86cb8d3b1451bc46378fa2518 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 28 Jul 2026 10:22:17 -0500 Subject: [PATCH 3/3] test(homebrew_cask): assert intel checksum in rendered cask --- hack/homebrew_cask/main_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hack/homebrew_cask/main_test.go b/hack/homebrew_cask/main_test.go index b51fb14ad..16fdfe07b 100644 --- a/hack/homebrew_cask/main_test.go +++ b/hack/homebrew_cask/main_test.go @@ -27,6 +27,8 @@ func TestRender(t *testing.T) { // sha256("content-Devsy_mac_arm64.dmg") const wantARM = "f65094affc51b44bb65e948773afbdb527e5073f82569bcd9361deb17581807b" + // sha256("content-Devsy_mac_x64.dmg") + const wantIntel = "f1045e0e16797365c7f23e330cbab90e74218dc48b55a030fe23a93f4e8c6b9e" for _, want := range []string{ `version "1.2.3"`, // leading v stripped @@ -36,6 +38,7 @@ func TestRender(t *testing.T) { `app "Devsy.app"`, `homepage "https://www.devsy.sh"`, `sha256 arm: "` + wantARM + `"`, + `intel: "` + wantIntel + `"`, } { if !strings.Contains(out, want) { t.Errorf("cask missing %q\n---\n%s", want, out)