// Package release publishes a tagged repo to the self-hosted Homebrew tap: // archive the tag → tarball in the download dir → render a source-build formula // → commit it to the tap repo. It is the in-process equivalent of // scripts/brew-release.sh, driven by the Soft Serve tag webhook. package release import ( "crypto/sha256" "encoding/hex" "fmt" "io" "net/url" "os" "os/exec" "path/filepath" "regexp" "strings" "text/template" "custard/internal/gitread" "custard/internal/license" "gopkg.in/yaml.v3" ) // RepoConfig is the `.custard.yaml` at a repo's root. type RepoConfig struct { Brew struct { Enabled bool `yaml:"enabled"` Package string `yaml:"package"` // go build path, default "." } `yaml:"brew"` CI []string `yaml:"ci"` // check commands, run in order; nonzero exit = fail Deploy struct { Preview string `yaml:"preview"` // command that deploys a preview, prints URL on stdout Promote string `yaml:"promote"` // command to promote a preview; {{url}} is substituted } `yaml:"deploy"` } var configNames = []string{".custard.yaml", ".custard.yml"} // LoadLocal reads `.custard.yaml` from a local directory (the CLI's cwd). func LoadLocal(dir string) (RepoConfig, error) { var c RepoConfig for _, fn := range configNames { data, err := os.ReadFile(filepath.Join(dir, fn)) if err != nil { continue } return c, yaml.Unmarshal(data, &c) } return c, fmt.Errorf("no .custard.yaml in %s", dir) } // ReadRepoConfig loads `.custard.yaml` at ref. ok=false when absent/invalid. func ReadRepoConfig(s *gitread.Store, repo, ref string) (RepoConfig, bool) { var c RepoConfig for _, fn := range configNames { data, err := s.Blob(repo, ref, fn) if err != nil { continue } if yaml.Unmarshal(data, &c) == nil { return c, true } } return c, false } // semverTag matches vMAJOR.MINOR.PATCH (no prerelease) — the only tags we ship. var semverTag = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) // IsReleaseTag reports whether a tag name should trigger a release. func IsReleaseTag(tag string) bool { return semverTag.MatchString(tag) } // Options drives a single publish. type Options struct { Store *gitread.Store ReposPath string // bare repos dir (for git archive + tap repo) DLPath string // tarball output dir TapRepo string // tap bare repo name (e.g. homebrew-tap) BaseURL string // e.g. https://codex.humdrum.me Repo string // repo name Tag string // vX.Y.Z Desc string // repo description (from webhook payload) Package string // go build path; default "." } // Publish archives the tag, writes the tarball, renders the formula, and commits // it to the tap. Returns the published version on success. func Publish(o Options) (string, error) { ver := strings.TrimPrefix(o.Tag, "v") pkg := o.Package if pkg == "" { pkg = "." } bare := filepath.Join(o.ReposPath, o.Repo+".git") tarName := fmt.Sprintf("%s-%s.tar.gz", o.Repo, ver) if err := os.MkdirAll(o.DLPath, 0o755); err != nil { return "", err } tarPath := filepath.Join(o.DLPath, tarName) if err := gitArchive(bare, o.Repo+"-"+ver, o.Tag, tarPath); err != nil { return "", err } sha, err := sha256File(tarPath) if err != nil { return "", err } f := formula{ Class: className(o.Repo), Repo: o.Repo, Desc: formulaDesc(o.Desc), Homepage: o.BaseURL + "/r/" + o.Repo, URL: o.BaseURL + "/dl/" + tarName, SHA: sha, License: detectLicense(o.Store, o.Repo, o.Tag), Package: pkg, } rendered, err := f.render() if err != nil { return "", err } if err := commitToTap(o, rendered); err != nil { return "", err } return ver, nil } func gitArchive(bareRepo, prefix, tag, outPath string) error { out, err := os.Create(outPath) if err != nil { return err } defer out.Close() cmd := exec.Command("git", "-C", bareRepo, "archive", "--format=tar.gz", "--prefix="+prefix+"/", tag) cmd.Stdout = out cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("git archive %s@%s: %w", bareRepo, tag, err) } return nil } func commitToTap(o Options, formulaText string) error { tapBare := filepath.Join(o.ReposPath, o.TapRepo+".git") tmp, err := os.MkdirTemp("", "custard-tap-") if err != nil { return err } defer os.RemoveAll(tmp) if out, err := exec.Command("git", "clone", "--quiet", tapBare, tmp).CombinedOutput(); err != nil { return fmt.Errorf("clone tap: %w: %s", err, out) } fdir := filepath.Join(tmp, "Formula") if err := os.MkdirAll(fdir, 0o755); err != nil { return err } if err := os.WriteFile(filepath.Join(fdir, o.Repo+".rb"), []byte(formulaText), 0o644); err != nil { return err } host := o.BaseURL if u, err := url.Parse(o.BaseURL); err == nil && u.Host != "" { host = u.Host } git := func(args ...string) error { c := exec.Command("git", append([]string{"-C", tmp}, args...)...) c.Env = append(os.Environ(), "GIT_AUTHOR_NAME=custard", "GIT_AUTHOR_EMAIL=custard@"+host, "GIT_COMMITTER_NAME=custard", "GIT_COMMITTER_EMAIL=custard@"+host) if out, err := c.CombinedOutput(); err != nil { return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, out) } return nil } if err := git("add", "Formula/"+o.Repo+".rb"); err != nil { return err } // Nothing staged (identical formula) → no-op, not an error. if exec.Command("git", "-C", tmp, "diff", "--cached", "--quiet").Run() == nil { return nil } if err := git("commit", "-m", o.Repo+" "+o.Tag); err != nil { return err } return git("push", "origin", "HEAD") } func sha256File(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() h := sha256.New() if _, err := io.Copy(h, f); err != nil { return "", err } return hex.EncodeToString(h.Sum(nil)), nil } // detectLicense returns an SPDX id for the formula, or "" if undetectable. func detectLicense(s *gitread.Store, repo, ref string) string { _, data, ok := s.LicenseFile(repo, ref) if !ok { return "" } spdx := license.Detect(data).SPDX // GNU licenses are deprecated as bare ids in brew; prefer the -or-later form. switch spdx { case "GPL-3.0", "LGPL-3.0", "AGPL-3.0", "GPL-2.0": return spdx + "-or-later" } return spdx } func className(repo string) string { var b strings.Builder up := true for _, r := range repo { if r == '-' || r == '_' { up = true continue } if up && r >= 'a' && r <= 'z' { r -= 32 } up = false b.WriteRune(r) } return b.String() } // formulaDesc cleans a repo description for `brew audit`: no trailing period, // under 80 chars (dropping a parenthetical / em-dash clause, then truncating). func formulaDesc(d string) string { d = strings.ReplaceAll(strings.TrimSpace(d), `"`, "") if d == "" { d = "Command-line application" } d = strings.TrimRight(d, ".") if len(d) > 78 { if i := strings.LastIndex(d, " ("); i > 0 { d = strings.TrimSpace(d[:i]) } } if len(d) > 78 { if i := strings.Index(d, " — "); i > 0 { d = strings.TrimSpace(d[:i]) } } if len(d) > 78 { d = d[:78] if i := strings.LastIndex(d, " "); i > 0 { d = d[:i] } } return strings.TrimRight(d, " —-") } type formula struct { Class, Repo, Desc, Homepage, URL, SHA, License, Package string } var formulaTmpl = template.Must(template.New("f").Parse(`# Generated by custard — do not edit by hand. class {{.Class}} < Formula desc "{{.Desc}}" homepage "{{.Homepage}}" url "{{.URL}}" sha256 "{{.SHA}}" {{- if .License}} license "{{.License}}" {{- end}} depends_on "go" => :build def install system "go", "build", *std_go_args(ldflags: "-s -w -X main.version=#{version}"), "{{.Package}}" end test do assert_path_exists bin/"{{.Repo}}" end end `)) func (f formula) render() (string, error) { var b strings.Builder if err := formulaTmpl.Execute(&b, f); err != nil { return "", err } return b.String(), nil }