diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab2060ba3..0acebc656 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,6 +371,78 @@ jobs: files: | ./Devsy.flatpak + publish-homebrew: + name: Publish Homebrew Formula + needs: [build-desktop] + if: ${{ !github.event.release.prerelease }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 + with: + go-version-file: go.mod + + - name: download darwin CLI artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: devsy-darwin + path: ${{ runner.temp }}/devsy-bin/ + + - name: download linux CLI artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: devsy-linux + path: ${{ runner.temp }}/devsy-bin/ + + - name: flatten CLI artifacts + run: go run ./hack/release_artifacts inventory --dir "${{ runner.temp }}/devsy-bin" --flatten + + - name: generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + app-id: ${{ secrets.DEVSY_GITHUB_APP_ID }} + private-key: ${{ secrets.DEVSY_GITHUB_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: homebrew-tap + + - name: generate and push formula + 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 + run: | + set -euo pipefail + + if [ -z "${TAP_TOKEN:-}" ]; then + echo "::error::GitHub App token was not generated" + exit 1 + fi + + git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap + mkdir -p tap/Formula + go run ./hack/homebrew_formula \ + --bin-dir "$BIN_DIR" \ + --repo "$REPO" \ + --tag "$TAG" \ + --out tap/Formula/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" + exit 0 + fi + git add Formula/devsy.rb + git commit -m "devsy ${TAG}" + git push + prerelease: name: Publish Prerelease needs: [build-desktop, build-flatpak, deploy-update-metadata] diff --git a/hack/homebrew_formula/main.go b/hack/homebrew_formula/main.go new file mode 100644 index 000000000..debfd5197 --- /dev/null +++ b/hack/homebrew_formula/main.go @@ -0,0 +1,139 @@ +// Generates the Homebrew formula for the Devsy CLI from the released +// per-platform binaries, embedding each asset's release URL and sha256. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +type platform struct { + OS string // Homebrew block: macos, linux + Arch string // Homebrew block: arm, intel + Binary string // release asset filename + URL string + SHA256 string +} + +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"}, +} + +const formulaTmpl = `class Devsy < Formula + desc "Standardized dev workspaces across Docker, Kubernetes, cloud, and SSH" + homepage "https://www.devsy.sh" + version "{{ .Version }}" + license "MPL-2.0" + + on_macos do + on_arm do + url "{{ (index .Platforms "macos/arm").URL }}" + sha256 "{{ (index .Platforms "macos/arm").SHA256 }}" + end + on_intel do + url "{{ (index .Platforms "macos/intel").URL }}" + sha256 "{{ (index .Platforms "macos/intel").SHA256 }}" + end + end + + on_linux do + on_arm do + url "{{ (index .Platforms "linux/arm").URL }}" + sha256 "{{ (index .Platforms "linux/arm").SHA256 }}" + end + on_intel do + url "{{ (index .Platforms "linux/intel").URL }}" + sha256 "{{ (index .Platforms "linux/intel").SHA256 }}" + end + end + + def install + bin.install Dir["devsy-*"].first => "devsy" + end + + test do + system "#{bin}/devsy", "--version" + end +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 assetURL(repo, tag, filename string) string { + return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, filename) +} + +func render(binDir, repo, tag string) (string, error) { + byKey := make(map[string]platform, len(platforms)) + for _, p := range platforms { + sum, err := fileSHA256(filepath.Join(binDir, p.Binary)) + if err != nil { + return "", fmt.Errorf("checksum %s: %w", p.Binary, err) + } + p.URL = assetURL(repo, tag, p.Binary) + p.SHA256 = sum + byKey[p.OS+"/"+p.Arch] = p + } + + tmpl, err := template.New("formula").Parse(formulaTmpl) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + var sb strings.Builder + data := struct { + Version string + Platforms map[string]platform + }{Version: strings.TrimPrefix(tag, "v"), Platforms: byKey} + if err := tmpl.Execute(&sb, data); err != nil { + return "", fmt.Errorf("render template: %w", err) + } + return sb.String(), nil +} + +func main() { + binDir := flag.String( + "bin-dir", + "", + "directory containing the devsy-- release binaries", + ) + 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 formula") + flag.Parse() + + if *binDir == "" || *repo == "" || *tag == "" || *out == "" { + fmt.Fprintln( + os.Stderr, + "usage: homebrew_formula --bin-dir DIR --repo OWNER/REPO --tag TAG --out FILE", + ) + os.Exit(2) + } + + formula, err := render(*binDir, *repo, *tag) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + // #nosec G306 -- formula is public. + if err := os.WriteFile(*out, []byte(formula), 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Printf("wrote %s\n", *out) +} diff --git a/hack/homebrew_formula/main_test.go b/hack/homebrew_formula/main_test.go new file mode 100644 index 000000000..6f870549e --- /dev/null +++ b/hack/homebrew_formula/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeBinaries(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, p := range platforms { + path := filepath.Join(dir, p.Binary) + if err := os.WriteFile(path, []byte("content-"+p.Binary), 0o644); err != nil { + t.Fatalf("write %s: %v", p.Binary, err) + } + } + return dir +} + +func TestRender(t *testing.T) { + out, err := render(writeBinaries(t), "devsy-org/devsy", "v1.2.3") + if err != nil { + t.Fatalf("render: %v", err) + } + + // sha256("content-devsy-darwin-arm64") + const wantARM = "9bc996e636ac5321e2aae6bd3fb421c5ea82b34a5b20e2c9fd65cc81b2f3753e" + + 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", + `sha256 "` + wantARM + `"`, + `bin.install Dir["devsy-*"].first => "devsy"`, + } { + if !strings.Contains(out, want) { + t.Errorf("formula missing %q\n---\n%s", want, out) + } + } +} + +func TestRenderMissingBinary(t *testing.T) { + dir := writeBinaries(t) + if err := os.Remove(filepath.Join(dir, "devsy-linux-arm64")); err != nil { + t.Fatal(err) + } + if _, err := render(dir, "devsy-org/devsy", "v1.2.3"); err == nil { + t.Fatal("expected error for missing binary, got nil") + } +} diff --git a/sites/docs-devsy-sh/pages/getting-started/install.mdx b/sites/docs-devsy-sh/pages/getting-started/install.mdx index 886b52745..05b3edb29 100644 --- a/sites/docs-devsy-sh/pages/getting-started/install.mdx +++ b/sites/docs-devsy-sh/pages/getting-started/install.mdx @@ -44,7 +44,15 @@ Devsy Desktop needs [WebView 2](https://developer.microsoft.com/en-us/microsoft- ## Install Devsy CLI -Run the command for your platform. You can also install the CLI later from Devsy Desktop. +On macOS or Linux, install with [Homebrew](https://brew.sh): + +```bash +brew install devsy-org/homebrew-tap/devsy +``` + +Upgrade later with `brew upgrade devsy`. + +Otherwise, run the command for your platform below. You can also install the CLI later from Devsy Desktop.