diff --git a/.github/workflows/publish-to-other-than-github.yaml b/.github/workflows/publish-to-other-than-github.yaml index 520a3ddd2e..ed51d7998c 100644 --- a/.github/workflows/publish-to-other-than-github.yaml +++ b/.github/workflows/publish-to-other-than-github.yaml @@ -19,38 +19,28 @@ jobs: REPO: open-component-model/homebrew-tap steps: - name: Ensure proper version - run: | - if [ -n "${{ github.event.inputs.version }}" ]; then - echo "RELEASE_VERSION=$(echo ${{ github.event.inputs.version }} | tr -d ['v'])" >> $GITHUB_ENV - exit 0 - fi - if [ -n "${{ github.event.client_payload.version }}" ]; then - echo "RELEASE_VERSION=$(echo ${{ github.event.client_payload.version }} | tr -d ['v'])" >> $GITHUB_ENV - exit 0 - fi - echo "Version not provided" - exit 1 + 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: Get Update Script - uses: actions/checkout@v4 - with: - path: scripts - sparse-checkout: 'hack/brew' - name: Checkout uses: actions/checkout@v4 with: 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: Update Homebrew Tap run: | formula=$(go run scripts/hack/brew \ --version ${{ env.RELEASE_VERSION }} \ - --template scripts/hack/brew/ocm_formula_template.rb.tpl \ + --template scripts/hack/brew/internal/ocm_formula_template.rb.tpl \ --outputDirectory Formula) mkdir -p Aliases ln -s Formula/$(basename formula) Aliases/ocm @@ -64,8 +54,11 @@ jobs: branch: chore/update-ocm-cli/${{ env.RELEASE_VERSION }} delete-branch: true sign-commits: true + add-paths: | + Formula/* + Aliases/* body: | - Update OCM CLI vendor hash (see: .github/workflows/flake_vendorhash.yaml) + Update OCM CLI to v${{ env.RELEASE_VERSION }}. push-to-aur: name: Update Arch Linux User Repository 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/ocm_formula_template.rb.tpl b/hack/brew/internal/ocm_formula_template.rb.tpl similarity index 100% rename from hack/brew/ocm_formula_template.rb.tpl rename to hack/brew/internal/ocm_formula_template.rb.tpl 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 index 61ed3a0bdb..825cb925bc 100644 --- a/hack/brew/main.go +++ b/hack/brew/main.go @@ -2,24 +2,25 @@ package main import ( "flag" - "fmt" - "io" "log" - "net/http" "os" - "path/filepath" "strings" - "text/template" -) -const ReleaseURL = "https://github.com/open-component-model/ocm/releases/download" + "ocm.software/ocm/hack/brew/internal" +) -const ClassName = "Ocm" +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") - templateFile := flag.String("template", "ocm_formula_template.rb.tpl", "path to the template file") 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() @@ -27,69 +28,14 @@ func main() { log.Fatalf("version is required") } - architectures := []string{"amd64", "arm64"} - oses := []string{"darwin", "linux"} - values := map[string]string{ - "Version": *version, - } - - for _, targetOs := range oses { - for _, arch := range architectures { - url := fmt.Sprintf( - "%s/v%[2]s/ocm-%[2]s-%s-%s.tar.gz.sha256", ReleaseURL, *version, targetOs, arch) - rawDigest, err := http.Get(url) - if err != nil { - log.Fatalf("failed to get digest for %s/%s: %v", targetOs, arch, err) - } - digestBytes, err := io.ReadAll(rawDigest.Body) - if err != nil { - log.Fatalf("failed to read digest for %s/%s: %v", targetOs, arch, err) - } - if err := rawDigest.Body.Close(); err != nil { - log.Fatalf("failed to close response body for %s/%s: %v", targetOs, arch, err) - } - digest := strings.TrimSpace(string(digestBytes)) - - values[fmt.Sprintf("%s_%s_sha256", targetOs, arch)] = digest - } - } - - // Parse and execute the template - 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 { - log.Fatalf("failed to parse template: %v", err) + 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) } - - outputFile := fmt.Sprintf("ocm@%s.rb", *version) - - fi, err := os.Stat(*outputDir) - if os.IsNotExist(err) { - if err := os.MkdirAll(*outputDir, 0755); err != nil { - log.Fatalf("failed to create output directory: %v", err) - } - } else if err != nil { - log.Fatalf("failed to stat output directory: %v", err) - } else if !fi.IsDir() { - log.Fatalf("output directory is not a directory") - } - - versionedFormula, err := os.Create(fmt.Sprintf(filepath.Join(*outputDir, outputFile))) - if err != nil { - log.Fatalf("failed to parse template: %v", err) - } - defer func() { - if err := versionedFormula.Close(); err != nil { - log.Fatalf("failed to close file: %v", err) - } - }() - - if err := tmpl.Execute(versionedFormula, values); err != nil { - log.Fatalf("failed to execute template: %v", err) - } - - println(versionedFormula.Name()) }