Skip to content

Commit 46c3abc

Browse files
bepclaude
andcommitted
Add publish command for releasing drafts and updating Homebrew tap
This adds a new `publish` command that: 1. Publishes a draft GitHub release (sets draft: false) 2. Updates a Homebrew cask in a tap repository with the new release The Homebrew cask update finds the .pkg archive from the release archives using a path pattern matcher (consistent with how builds/archives/releases work), extracts the SHA256 from the checksums file, and commits the cask file to the tap repository. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 512b96c commit 46c3abc

File tree

12 files changed

+562
-6
lines changed

12 files changed

+562
-6
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ go.work.sum
2020
# Dependency directories (remove the comment below to include it)
2121
# vendor/
2222

23-
dist/
23+
dist/
24+
hugoreleaser

cmd/publishcmd/publish.go

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
// Copyright 2026 The Hugoreleaser Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package publishcmd
16+
17+
import (
18+
"bytes"
19+
"context"
20+
"flag"
21+
"fmt"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
"text/template"
26+
27+
"github.com/bep/logg"
28+
"github.com/gohugoio/hugoreleaser/cmd/corecmd"
29+
"github.com/gohugoio/hugoreleaser/internal/common/templ"
30+
"github.com/gohugoio/hugoreleaser/internal/config"
31+
"github.com/gohugoio/hugoreleaser/internal/releases"
32+
"github.com/gohugoio/hugoreleaser/staticfiles"
33+
"github.com/peterbourgon/ff/v3/ffcli"
34+
)
35+
36+
const commandName = "publish"
37+
38+
// New returns a usable ffcli.Command for the publish subcommand.
39+
func New(core *corecmd.Core) *ffcli.Command {
40+
fs := flag.NewFlagSet(corecmd.CommandName+" "+commandName, flag.ExitOnError)
41+
42+
publisher := NewPublisher(core, fs)
43+
44+
core.RegisterFlags(fs)
45+
46+
return &ffcli.Command{
47+
Name: commandName,
48+
ShortUsage: corecmd.CommandName + " publish [flags]",
49+
ShortHelp: "Publish a draft release and update package managers.",
50+
FlagSet: fs,
51+
Exec: publisher.Exec,
52+
}
53+
}
54+
55+
// NewPublisher returns a new Publisher.
56+
func NewPublisher(core *corecmd.Core, fs *flag.FlagSet) *Publisher {
57+
return &Publisher{
58+
core: core,
59+
}
60+
}
61+
62+
// Publisher handles the publish command.
63+
type Publisher struct {
64+
core *corecmd.Core
65+
infoLog logg.LevelLogger
66+
}
67+
68+
// Init initializes the publisher.
69+
func (p *Publisher) Init() error {
70+
p.infoLog = p.core.InfoLog.WithField("cmd", commandName)
71+
return nil
72+
}
73+
74+
// Exec executes the publish command.
75+
func (p *Publisher) Exec(ctx context.Context, args []string) error {
76+
if err := p.Init(); err != nil {
77+
return err
78+
}
79+
80+
// Get release settings from config.
81+
if len(p.core.Config.Releases) == 0 {
82+
return fmt.Errorf("%s: no releases defined in config", commandName)
83+
}
84+
85+
// Use first release for settings (consistent with release command behavior).
86+
release := p.core.Config.Releases[0]
87+
settings := release.ReleaseSettings
88+
89+
logFields := logg.Fields{
90+
{Name: "tag", Value: p.core.Tag},
91+
{Name: "repository", Value: fmt.Sprintf("%s/%s", settings.RepositoryOwner, settings.Repository)},
92+
}
93+
logCtx := p.infoLog.WithFields(logFields)
94+
95+
// Create client.
96+
var client releases.PublishClient
97+
if p.core.Try {
98+
client = &releases.FakeClient{}
99+
} else {
100+
c, err := releases.NewClient(ctx, settings.TypeParsed)
101+
if err != nil {
102+
return fmt.Errorf("%s: failed to create release client: %v", commandName, err)
103+
}
104+
var ok bool
105+
client, ok = c.(releases.PublishClient)
106+
if !ok {
107+
return fmt.Errorf("%s: client does not support publish operations", commandName)
108+
}
109+
}
110+
111+
// Step 1: Check and publish the GitHub release.
112+
logCtx.Log(logg.String("Checking release status"))
113+
114+
releaseID, isDraft, err := client.GetReleaseByTag(ctx, settings.RepositoryOwner, settings.Repository, p.core.Tag)
115+
if err != nil {
116+
return fmt.Errorf("%s: failed to get release: %v", commandName, err)
117+
}
118+
119+
if isDraft {
120+
logCtx.Log(logg.String("Publishing draft release"))
121+
if err := client.PublishRelease(ctx, settings.RepositoryOwner, settings.Repository, releaseID); err != nil {
122+
return fmt.Errorf("%s: failed to publish release: %v", commandName, err)
123+
}
124+
logCtx.Log(logg.String("Release published successfully"))
125+
} else {
126+
logCtx.Log(logg.String("Release is already published"))
127+
}
128+
129+
// Step 2: Update Homebrew cask if enabled.
130+
caskSettings := p.core.Config.PublishSettings.HomebrewCask
131+
if caskSettings.Enabled {
132+
if err := p.updateHomebrewCask(ctx, logCtx, client, release, caskSettings); err != nil {
133+
return fmt.Errorf("%s: failed to update Homebrew cask: %v", commandName, err)
134+
}
135+
}
136+
137+
return nil
138+
}
139+
140+
// HomebrewCaskContext holds data for the Homebrew cask template.
141+
type HomebrewCaskContext struct {
142+
Name string
143+
DisplayName string
144+
Version string
145+
SHA256 string
146+
URL string
147+
Description string
148+
Homepage string
149+
PkgFilename string
150+
BundleIdentifier string
151+
}
152+
153+
func (p *Publisher) updateHomebrewCask(
154+
ctx context.Context,
155+
logCtx logg.LevelLogger,
156+
client releases.PublishClient,
157+
release config.Release,
158+
caskSettings config.HomebrewCaskSettings,
159+
) error {
160+
logCtx = logCtx.WithField("action", "homebrew-cask")
161+
logCtx.Log(logg.String("Updating Homebrew cask"))
162+
163+
releaseSettings := release.ReleaseSettings
164+
version := strings.TrimPrefix(p.core.Tag, "v")
165+
166+
// Find the first .pkg archive matching the path pattern.
167+
pkgFilename, err := p.findPkgArchive(release, caskSettings)
168+
if err != nil {
169+
return err
170+
}
171+
172+
logCtx.WithField("pkg", pkgFilename).Log(logg.String("Found pkg archive"))
173+
174+
// Get SHA256 from checksums file.
175+
sha256, err := p.getSHA256ForFile(pkgFilename)
176+
if err != nil {
177+
return fmt.Errorf("failed to get SHA256 for %s: %v", pkgFilename, err)
178+
}
179+
180+
// Build download URL.
181+
downloadURL := fmt.Sprintf(
182+
"https://github.com/%s/%s/releases/download/%s/%s",
183+
releaseSettings.RepositoryOwner,
184+
releaseSettings.Repository,
185+
p.core.Tag,
186+
pkgFilename,
187+
)
188+
189+
// Build cask context.
190+
caskCtx := HomebrewCaskContext{
191+
Name: caskSettings.Name,
192+
DisplayName: p.core.Config.Project,
193+
Version: version,
194+
SHA256: sha256,
195+
URL: downloadURL,
196+
Description: caskSettings.Description,
197+
Homepage: caskSettings.Homepage,
198+
PkgFilename: pkgFilename,
199+
BundleIdentifier: caskSettings.BundleIdentifier,
200+
}
201+
202+
// Render cask template.
203+
var caskContent bytes.Buffer
204+
var tmpl *template.Template
205+
206+
if caskSettings.TemplateFilename != "" {
207+
templatePath := caskSettings.TemplateFilename
208+
if !filepath.IsAbs(templatePath) {
209+
templatePath = filepath.Join(p.core.ProjectDir, templatePath)
210+
}
211+
b, err := os.ReadFile(templatePath)
212+
if err != nil {
213+
return fmt.Errorf("failed to read custom cask template: %v", err)
214+
}
215+
tmpl, err = templ.Parse(string(b))
216+
if err != nil {
217+
return fmt.Errorf("failed to parse custom cask template: %v", err)
218+
}
219+
} else {
220+
tmpl = staticfiles.HomebrewCaskTemplate
221+
}
222+
223+
if err := tmpl.Execute(&caskContent, caskCtx); err != nil {
224+
return fmt.Errorf("failed to execute cask template: %v", err)
225+
}
226+
227+
// Update file in tap repository.
228+
commitMessage := fmt.Sprintf("Update %s to %s", caskSettings.Name, p.core.Tag)
229+
230+
logCtx.WithFields(logg.Fields{
231+
{Name: "tap", Value: fmt.Sprintf("%s/%s", releaseSettings.RepositoryOwner, caskSettings.TapRepository)},
232+
{Name: "path", Value: caskSettings.CaskPath},
233+
}).Log(logg.String("Committing cask update"))
234+
235+
if p.core.Try {
236+
logCtx.Log(logg.String("Trial run - skipping commit"))
237+
return nil
238+
}
239+
240+
sha, err := client.UpdateFileInRepo(
241+
ctx,
242+
releaseSettings.RepositoryOwner,
243+
caskSettings.TapRepository,
244+
caskSettings.CaskPath,
245+
commitMessage,
246+
caskContent.Bytes(),
247+
)
248+
if err != nil {
249+
return err
250+
}
251+
252+
logCtx.WithField("commit", sha).Log(logg.String("Cask updated successfully"))
253+
return nil
254+
}
255+
256+
// findPkgArchive finds the first .pkg archive for darwin matching the path pattern.
257+
func (p *Publisher) findPkgArchive(release config.Release, caskSettings config.HomebrewCaskSettings) (string, error) {
258+
pathMatcher := caskSettings.PathCompiled
259+
260+
for _, archPath := range release.ArchsCompiled {
261+
// Only consider darwin archives.
262+
if archPath.Arch.Os == nil || archPath.Arch.Os.Goos != "darwin" {
263+
continue
264+
}
265+
266+
// Check if the path matches the pattern.
267+
if pathMatcher != nil && !pathMatcher.Match(archPath.Path) {
268+
continue
269+
}
270+
271+
// Check if it's a .pkg file.
272+
if strings.HasSuffix(archPath.Name, ".pkg") {
273+
return archPath.Name, nil
274+
}
275+
}
276+
277+
return "", fmt.Errorf("no .pkg archive found for darwin matching path pattern %q", caskSettings.Path)
278+
}
279+
280+
// getSHA256ForFile extracts the SHA256 checksum for a specific file from the checksums file.
281+
func (p *Publisher) getSHA256ForFile(filename string) (string, error) {
282+
// Find the release directory.
283+
release := p.core.Config.Releases[0]
284+
releaseDir := filepath.Join(
285+
p.core.DistDir,
286+
p.core.Config.Project,
287+
p.core.Tag,
288+
p.core.DistRootReleases,
289+
filepath.FromSlash(release.Path),
290+
)
291+
292+
// Checksums filename follows the pattern from releasecmd.
293+
checksumFilename := fmt.Sprintf("%s_%s_checksums.txt",
294+
p.core.Config.Project,
295+
strings.TrimPrefix(p.core.Tag, "v"),
296+
)
297+
checksumPath := filepath.Join(releaseDir, checksumFilename)
298+
299+
content, err := os.ReadFile(checksumPath)
300+
if err != nil {
301+
return "", fmt.Errorf("failed to read checksums file %s: %v", checksumPath, err)
302+
}
303+
304+
// Parse checksums file (format: "sha256 filename").
305+
for _, line := range strings.Split(string(content), "\n") {
306+
line = strings.TrimSpace(line)
307+
if line == "" {
308+
continue
309+
}
310+
parts := strings.SplitN(line, " ", 2)
311+
if len(parts) != 2 {
312+
continue
313+
}
314+
if parts[1] == filename {
315+
return parts[0], nil
316+
}
317+
}
318+
319+
return "", fmt.Errorf("checksum not found for file %s in %s", filename, checksumPath)
320+
}

hugoreleaser.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,8 @@ releases:
130130
- paths:
131131
- archives/**
132132
path: myrelease
133+
134+
publish_settings:
135+
homebrew_cask:
136+
enabled: true
137+
bundle_identifier: io.gohugo.hugoreleaser

internal/config/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022 The Hugoreleaser Authors
1+
// Copyright 2026 The Hugoreleaser Authors
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@ type Config struct {
3838
BuildSettings BuildSettings `json:"build_settings"`
3939
ArchiveSettings ArchiveSettings `json:"archive_settings"`
4040
ReleaseSettings ReleaseSettings `json:"release_settings"`
41+
PublishSettings PublishSettings `json:"publish_settings"`
4142
}
4243

4344
func (c Config) FindReleases(filter matchers.Matcher) []Release {

internal/config/decode.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022 The Hugoreleaser Authors
1+
// Copyright 2026 The Hugoreleaser Authors
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -120,6 +120,11 @@ func DecodeAndApplyDefaults(r io.Reader) (Config, error) {
120120
}
121121
}
122122

123+
// Init and validate publish settings.
124+
if err := cfg.PublishSettings.Init(cfg.Project); err != nil {
125+
return *cfg, err
126+
}
127+
123128
// Apply some convenient navigation helpers.
124129
for i := range cfg.Builds {
125130
for j := range cfg.Builds[i].Os {

0 commit comments

Comments
 (0)