diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0acebc656..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,23 +440,28 @@ 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" - if git diff --quiet; then - echo "Formula unchanged; nothing to publish" + git add Formula/devsy.rb Casks/devsy.rb + if git diff --cached --quiet; then + echo "Formula and cask 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 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..16fdfe07b --- /dev/null +++ b/hack/homebrew_cask/main_test.go @@ -0,0 +1,57 @@ +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" + // sha256("content-Devsy_mac_x64.dmg") + const wantIntel = "f1045e0e16797365c7f23e330cbab90e74218dc48b55a030fe23a93f4e8c6b9e" + + 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 + `"`, + `intel: "` + wantIntel + `"`, + } { + 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") + } +}