From 7ac86054190bdec1d65921dab89f5da7e17e9f4e Mon Sep 17 00:00:00 2001 From: jakobmoellerdev Date: Thu, 7 Nov 2024 16:34:48 +0100 Subject: [PATCH] chore: allow publishing to Brew via custom script --- .github/config/goreleaser.yaml | 12 -- .../publish-to-other-than-github.yaml | 59 +++++++ .../workflows/retrigger-publish-to-other.yaml | 7 +- hack/brew/go.mod | 3 + hack/brew/internal/generate.go | 116 ++++++++++++++ hack/brew/internal/generate_test.go | 145 ++++++++++++++++++ .../brew/internal/ocm_formula_template.rb.tpl | 55 +++++++ .../internal/testdata/expected_formula.rb | 54 +++++++ hack/brew/main.go | 41 +++++ 9 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 hack/brew/go.mod create mode 100644 hack/brew/internal/generate.go create mode 100644 hack/brew/internal/generate_test.go create mode 100644 hack/brew/internal/ocm_formula_template.rb.tpl create mode 100644 hack/brew/internal/testdata/expected_formula.rb create mode 100644 hack/brew/main.go diff --git a/.github/config/goreleaser.yaml b/.github/config/goreleaser.yaml index 60696bf9f3..351813dd65 100644 --- a/.github/config/goreleaser.yaml +++ b/.github/config/goreleaser.yaml @@ -90,18 +90,6 @@ changelog: - '^docs:' - '^test:' -brews: - - name: ocm - repository: - owner: open-component-model - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - directory: Formula - homepage: "https://ocm.software/" - description: "The OCM CLI makes it easy to create component versions and embed them in build processes." - test: | - system "#{bin}/ocm --version" - nfpms: - id: debian package_name: ocm-cli diff --git a/.github/workflows/publish-to-other-than-github.yaml b/.github/workflows/publish-to-other-than-github.yaml index 4648c8261a..012821ff04 100644 --- a/.github/workflows/publish-to-other-than-github.yaml +++ b/.github/workflows/publish-to-other-than-github.yaml @@ -12,6 +12,65 @@ on: types: [publish-ocm-cli] jobs: + push-to-brew-tap: + name: Update Homebrew Tap + if: github.event.client_payload.push-to-brew-tap && github.event.client_payload.version != '' + runs-on: ubuntu-latest + env: + REPO: open-component-model/homebrew-tap + steps: + - name: Ensure proper version + run: echo "RELEASE_VERSION=$(echo ${{ github.event.client_payload.version }} | tr -d ['v'])" >> $GITHUB_ENV + - name: Generate token + id: generate_token + uses: tibdex/github-app-token@v2 + with: + app_id: ${{ secrets.OCMBOT_APP_ID }} + private_key: ${{ secrets.OCMBOT_PRIV_KEY }} + - name: Checkout + uses: actions/checkout@v4 + with: + path: tap + repository: ${{ env.REPO }} + token: ${{ steps.generate_token.outputs.token }} + - name: Get Update Script + uses: actions/checkout@v4 + with: + path: scripts + sparse-checkout: | + hack/brew + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: ${{ github.workspace }}/scripts/hack/brew/go.mod + cache: false + - name: Build Script + working-directory: ${{ github.workspace }}/scripts/hack/brew + run: go build -o script + - name: Update Homebrew Tap + run: | + formula=$(${{ github.workspace }}/scripts/hack/brew/script \ + --version ${{ env.RELEASE_VERSION }} \ + --template ${{ github.workspace }}/scripts/hack/brew/internal/ocm_formula_template.rb.tpl \ + --outputDirectory ${{ github.workspace }}/tap/Formula) + mkdir -p ${{ github.workspace }}/tap/Aliases + cd ${{ github.workspace }}/tap/Aliases + ln -s ../Formula/$(basename $formula) ./ocm + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + path: tap + token: ${{ steps.generate_token.outputs.token }} + title: "chore: update OCM CLI to v${{ env.RELEASE_VERSION }}" + commit-message: "[github-actions] update OCM CLI to v${{ env.RELEASE_VERSION }}" + branch: chore/update-ocm-cli/${{ env.RELEASE_VERSION }} + delete-branch: true + sign-commits: true + add-paths: | + Formula/* + Aliases/* + body: | + Update OCM CLI to v${{ env.RELEASE_VERSION }}. push-to-aur: name: Update Arch Linux User Repository diff --git a/.github/workflows/retrigger-publish-to-other.yaml b/.github/workflows/retrigger-publish-to-other.yaml index 6226bf3eb4..57219180d2 100644 --- a/.github/workflows/retrigger-publish-to-other.yaml +++ b/.github/workflows/retrigger-publish-to-other.yaml @@ -23,6 +23,11 @@ on: description: Do you want to push to Winget? required: false default: false + push-to-brew-tap: + type: boolean + description: Do you want to push to the Homebrew Tap at https://github.com/open-component-model/homebrew-tap? + required: false + default: false jobs: retrigger: @@ -57,4 +62,4 @@ jobs: token: ${{ steps.generate_token.outputs.token }} repository: ${{ github.repository_owner }}/ocm event-type: publish-ocm-cli - client-payload: '{"version":"${{ env.RELEASE_VERSION }}","push-to-aur":${{ github.event.inputs.push-to-aur }},"push-to-chocolatey":${{ github.event.inputs.push-to-chocolatey }},"push-to-winget":${{ github.event.inputs.push-to-winget }}}' + client-payload: '{"version":"${{ env.RELEASE_VERSION }}","push-to-aur":${{ github.event.inputs.push-to-aur }},"push-to-chocolatey":${{ github.event.inputs.push-to-chocolatey }},"push-to-winget":${{ github.event.inputs.push-to-winget }},"push-to-brew-tap":${{ github.event.inputs.push-to-brew-tap }}}' diff --git a/hack/brew/go.mod b/hack/brew/go.mod new file mode 100644 index 0000000000..e68d38213a --- /dev/null +++ b/hack/brew/go.mod @@ -0,0 +1,3 @@ +module ocm.software/ocm/hack/brew + +go 1.23.2 \ No newline at end of file diff --git a/hack/brew/internal/generate.go b/hack/brew/internal/generate.go new file mode 100644 index 0000000000..2c9f68d1a9 --- /dev/null +++ b/hack/brew/internal/generate.go @@ -0,0 +1,116 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "text/template" +) + +const ClassName = "Ocm" + +// GenerateVersionedHomebrewFormula generates a Homebrew formula for a specific version, +// architecture, and operating system. It fetches the SHA256 digest for each combination +// and uses a template to create the formula file. +func GenerateVersionedHomebrewFormula( + version string, + architectures []string, + operatingSystems []string, + releaseURL string, + templateFile string, + outputDir string, + writer io.Writer, +) error { + values := map[string]string{ + "ReleaseURL": releaseURL, + "Version": version, + } + + for _, targetOs := range operatingSystems { + for _, arch := range architectures { + digest, err := FetchDigestFromGithubRelease(releaseURL, version, targetOs, arch) + if err != nil { + return fmt.Errorf("failed to fetch digest for %s/%s: %w", targetOs, arch, err) + } + values[fmt.Sprintf("%s_%s_sha256", targetOs, arch)] = digest + } + } + + if err := GenerateFormula(templateFile, outputDir, version, values, writer); err != nil { + return fmt.Errorf("failed to generate formula: %w", err) + } + + return nil +} + +// FetchDigestFromGithubRelease retrieves the SHA256 digest for a specific version, operating system, and architecture +// from the given release URL. +func FetchDigestFromGithubRelease(releaseURL, version, targetOs, arch string) (_ string, err error) { + url := fmt.Sprintf("%s/v%s/ocm-%s-%s-%s.tar.gz.sha256", releaseURL, version, version, targetOs, arch) + resp, err := http.Get(url) + if err != nil { + return "", fmt.Errorf("failed to get digest: %w", err) + } + defer func() { + err = errors.Join(err, resp.Body.Close()) + }() + + digestBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read digest: %w", err) + } + + return strings.TrimSpace(string(digestBytes)), nil +} + +// GenerateFormula generates the Homebrew formula file using the provided template and values. +func GenerateFormula(templateFile, outputDir, version string, values map[string]string, writer io.Writer) error { + tmpl, err := template.New(filepath.Base(templateFile)).Funcs(template.FuncMap{ + "classname": func() string { + return fmt.Sprintf("%sAT%s", ClassName, strings.ReplaceAll(version, ".", "")) + }, + }).ParseFiles(templateFile) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + outputFile := fmt.Sprintf("ocm@%s.rb", version) + if err := ensureDirectory(outputDir); err != nil { + return err + } + + versionedFormula, err := os.Create(filepath.Join(outputDir, outputFile)) + if err != nil { + return fmt.Errorf("failed to create output file: %w", err) + } + defer versionedFormula.Close() + + if err := tmpl.Execute(versionedFormula, values); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + if _, err := io.WriteString(writer, versionedFormula.Name()); err != nil { + return fmt.Errorf("failed to write output file path: %w", err) + } + + return nil +} + +// ensureDirectory checks if a directory exists and creates it if it does not. +func ensureDirectory(dir string) error { + fi, err := os.Stat(dir) + if os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + } else if err != nil { + return fmt.Errorf("failed to stat directory: %w", err) + } else if !fi.IsDir() { + return fmt.Errorf("path is not a directory") + } + return nil +} diff --git a/hack/brew/internal/generate_test.go b/hack/brew/internal/generate_test.go new file mode 100644 index 0000000000..02fc0620e7 --- /dev/null +++ b/hack/brew/internal/generate_test.go @@ -0,0 +1,145 @@ +package internal + +import ( + "bytes" + _ "embed" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +//go:embed ocm_formula_template.rb.tpl +var tplFile []byte + +//go:embed testdata/expected_formula.rb +var expectedResolved []byte + +func TestGenerateVersionedHomebrewFormula(t *testing.T) { + version := "1.0.0" + architectures := []string{"amd64", "arm64"} + operatingSystems := []string{"darwin", "linux"} + outputDir := t.TempDir() + + templateFile := filepath.Join(outputDir, "ocm_formula_template.rb.tpl") + if err := os.WriteFile(templateFile, tplFile, os.ModePerm); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + dummyDigest := "dummy-digest" + // Mock server to simulate fetching digests + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(dummyDigest)) + })) + defer server.Close() + expectedResolved = bytes.ReplaceAll(expectedResolved, []byte("$$TEST_SERVER$$"), []byte(server.URL)) + + var buf bytes.Buffer + + err := GenerateVersionedHomebrewFormula( + version, + architectures, + operatingSystems, + server.URL, + templateFile, + outputDir, + &buf, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + file := buf.String() + + fi, err := os.Stat(file) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if fi.Size() == 0 { + t.Fatalf("expected file to be non-empty") + } + if filepath.Ext(file) != ".rb" { + t.Fatalf("expected file to have .rb extension") + } + if !strings.Contains(file, version) { + t.Fatalf("expected file to contain version") + } + + data, err := os.ReadFile(file) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if string(data) != string(expectedResolved) { + t.Fatalf("expected %s, got %s", string(expectedResolved), string(data)) + } +} + +func TestFetchDigest(t *testing.T) { + expectedDigest := "dummy-digest" + version := "1.0.0" + targetOS, arch := "linux", "amd64" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1.0.0/ocm-1.0.0-linux-amd64.tar.gz.sha256" { + t.Fatalf("expected path %s, got %s", fmt.Sprintf("/v%[1]s/ocm-%[1]s-%s-%s.tar.gz.sha256", version, targetOS, arch), r.URL.Path) + } + w.Write([]byte(expectedDigest)) + })) + defer server.Close() + + digest, err := FetchDigestFromGithubRelease(server.URL, version, targetOS, arch) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if digest != expectedDigest { + t.Fatalf("expected %s, got %s", expectedDigest, digest) + } +} + +func TestGenerateFormula(t *testing.T) { + templateContent := `class {{ classname }} < Formula +version "{{ .Version }}" +end` + templateFile := "test_template.rb.tpl" + if err := os.WriteFile(templateFile, []byte(templateContent), 0644); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + defer os.Remove(templateFile) + + outputDir := t.TempDir() + values := map[string]string{"Version": "1.0.0"} + + var buf bytes.Buffer + + if err := GenerateFormula(templateFile, outputDir, "1.0.0", values, &buf); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if buf.String() == "" { + t.Fatalf("expected non-empty output") + } + + outputFile := filepath.Join(outputDir, "ocm@1.0.0.rb") + if _, err := os.Stat(outputFile); os.IsNotExist(err) { + t.Fatalf("expected output file to exist") + } +} + +func TestEnsureDirectory(t *testing.T) { + dir := t.TempDir() + if err := ensureDirectory(dir); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + nonDirFile := filepath.Join(dir, "file") + if err := os.WriteFile(nonDirFile, []byte("content"), 0644); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + if err := ensureDirectory(nonDirFile); err == nil { + t.Fatalf("expected error, got nil") + } +} diff --git a/hack/brew/internal/ocm_formula_template.rb.tpl b/hack/brew/internal/ocm_formula_template.rb.tpl new file mode 100644 index 0000000000..60a7f9013c --- /dev/null +++ b/hack/brew/internal/ocm_formula_template.rb.tpl @@ -0,0 +1,55 @@ +{{- /* Go template for Homebrew Formula */ -}} +# typed: false +# frozen_string_literal: true + +class {{ classname }} < Formula + desc "The OCM CLI makes it easy to create component versions and embed them in build processes." + homepage "https://ocm.software/" + version "{{ .Version }}" + + on_macos do + on_intel do + url "{{ .ReleaseURL }}/v{{ .Version }}/ocm-{{ .Version }}-darwin-amd64.tar.gz" + sha256 "{{ .darwin_amd64_sha256 }}" + + def install + bin.install "ocm" + end + end + on_arm do + url "{{ .ReleaseURL }}/v{{ .Version }}/ocm-{{ .Version }}-darwin-arm64.tar.gz" + sha256 "{{ .darwin_arm64_sha256 }}" + + def install + bin.install "ocm" + end + end + end + + on_linux do + on_intel do + if Hardware::CPU.is_64_bit? + url "{{ .ReleaseURL }}/v{{ .Version }}/ocm-{{ .Version }}-linux-amd64.tar.gz" + sha256 "{{ .linux_amd64_sha256 }}" + + def install + bin.install "ocm" + end + end + end + on_arm do + if Hardware::CPU.is_64_bit? + url "{{ .ReleaseURL }}/v{{ .Version }}/ocm-{{ .Version }}-linux-arm64.tar.gz" + sha256 "{{ .linux_arm64_sha256 }}" + + def install + bin.install "ocm" + end + end + end + end + + test do + system "#{bin}/ocm --version" + end +end diff --git a/hack/brew/internal/testdata/expected_formula.rb b/hack/brew/internal/testdata/expected_formula.rb new file mode 100644 index 0000000000..4adf158cb6 --- /dev/null +++ b/hack/brew/internal/testdata/expected_formula.rb @@ -0,0 +1,54 @@ +# typed: false +# frozen_string_literal: true + +class OcmAT100 < Formula + desc "The OCM CLI makes it easy to create component versions and embed them in build processes." + homepage "https://ocm.software/" + version "1.0.0" + + on_macos do + on_intel do + url "$$TEST_SERVER$$/v1.0.0/ocm-1.0.0-darwin-amd64.tar.gz" + sha256 "dummy-digest" + + def install + bin.install "ocm" + end + end + on_arm do + url "$$TEST_SERVER$$/v1.0.0/ocm-1.0.0-darwin-arm64.tar.gz" + sha256 "dummy-digest" + + def install + bin.install "ocm" + end + end + end + + on_linux do + on_intel do + if Hardware::CPU.is_64_bit? + url "$$TEST_SERVER$$/v1.0.0/ocm-1.0.0-linux-amd64.tar.gz" + sha256 "dummy-digest" + + def install + bin.install "ocm" + end + end + end + on_arm do + if Hardware::CPU.is_64_bit? + url "$$TEST_SERVER$$/v1.0.0/ocm-1.0.0-linux-arm64.tar.gz" + sha256 "dummy-digest" + + def install + bin.install "ocm" + end + end + end + end + + test do + system "#{bin}/ocm --version" + end +end diff --git a/hack/brew/main.go b/hack/brew/main.go new file mode 100644 index 0000000000..825cb925bc --- /dev/null +++ b/hack/brew/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "flag" + "log" + "os" + "strings" + + "ocm.software/ocm/hack/brew/internal" +) + +const DefaultReleaseURL = "https://github.com/open-component-model/ocm/releases/download" +const DefaultFormulaTemplate = "hack/brew/internal/ocm_formula_template.rb.tpl" +const DefaultArchitectures = "amd64,arm64" +const DefaultOperatingSystems = "darwin,linux" + +func main() { + version := flag.String("version", "", "version of the OCM formula") + outputDir := flag.String("outputDirectory", ".", "path to the output directory") + templateFile := flag.String("template", DefaultFormulaTemplate, "path to the template file") + architecturesRaw := flag.String("arch", DefaultArchitectures, "comma-separated list of architectures") + operatingSystemsRaw := flag.String("os", DefaultOperatingSystems, "comma-separated list of operating systems") + releaseURL := flag.String("releaseURL", DefaultReleaseURL, "URL to fetch the release from") + + flag.Parse() + + if *version == "" { + log.Fatalf("version is required") + } + + if err := internal.GenerateVersionedHomebrewFormula(*version, + strings.Split(*architecturesRaw, ","), + strings.Split(*operatingSystemsRaw, ","), + *releaseURL, + *templateFile, + *outputDir, + os.Stdout, + ); err != nil { + log.Fatalf("failed to generate formula: %v", err) + } +}