Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: allow publishing to Brew via custom script #1059

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions .github/config/goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions .github/workflows/publish-to-other-than-github.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
hilmarf marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/retrigger-publish-to-other.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}}'
3 changes: 3 additions & 0 deletions hack/brew/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module ocm.software/ocm/hack/brew

go 1.23.2
116 changes: 116 additions & 0 deletions hack/brew/internal/generate.go
Original file line number Diff line number Diff line change
@@ -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
}
145 changes: 145 additions & 0 deletions hack/brew/internal/generate_test.go
Original file line number Diff line number Diff line change
@@ -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, "[email protected]")
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")
}
}
Loading