diff --git a/.gitignore b/.gitignore index 6205708..7c7193a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ go.work.sum # Dependency directories (remove the comment below to include it) # vendor/ -dist/ \ No newline at end of file +dist/ +hugoreleaser \ No newline at end of file diff --git a/cmd/allcmd/allcmd.go b/cmd/allcmd/allcmd.go index 067c123..6820d45 100644 --- a/cmd/allcmd/allcmd.go +++ b/cmd/allcmd/allcmd.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Hugoreleaser Authors +// Copyright 2026 The Hugoreleaser Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -21,6 +21,7 @@ import ( "github.com/gohugoio/hugoreleaser/cmd/archivecmd" "github.com/gohugoio/hugoreleaser/cmd/buildcmd" "github.com/gohugoio/hugoreleaser/cmd/corecmd" + "github.com/gohugoio/hugoreleaser/cmd/publishcmd" "github.com/gohugoio/hugoreleaser/cmd/releasecmd" "github.com/bep/logg" @@ -39,6 +40,7 @@ func New(core *corecmd.Core) *ffcli.Command { builder: builder, archivist: archivecmd.NewArchivist(core), releaser: releasecmd.NewReleaser(core, fs), + publisher: publishcmd.NewPublisher(core, fs), } core.RegisterFlags(fs) @@ -46,7 +48,7 @@ func New(core *corecmd.Core) *ffcli.Command { return &ffcli.Command{ Name: commandName, ShortUsage: corecmd.CommandName + " " + commandName + " [flags] ", - ShortHelp: "Runs the commands build, archive and release in sequence.", + ShortHelp: "Runs the commands build, archive, release and publish in sequence.", FlagSet: fs, Exec: a.Exec, } @@ -59,6 +61,7 @@ type all struct { builder *buildcmd.Builder archivist *archivecmd.Archivist releaser *releasecmd.Releaser + publisher *publishcmd.Publisher } func (a *all) Init() error { @@ -75,6 +78,7 @@ func (a *all) Exec(ctx context.Context, args []string) error { a.builder, a.archivist, a.releaser, + a.publisher, } for _, commandHandler := range commandHandlers { diff --git a/cmd/corecmd/core.go b/cmd/corecmd/core.go index 19b1ead..a83baaa 100644 --- a/cmd/corecmd/core.go +++ b/cmd/corecmd/core.go @@ -413,6 +413,18 @@ func (c *Core) Init() error { } } + // Precompile publisher -> release mappings. + for i := range c.Config.Publishers { + pub := &c.Config.Publishers[i] + for j := range c.Config.Releases { + release := &c.Config.Releases[j] + // If no release paths specified, match all releases. + if pub.ReleasePathsCompiled == nil || pub.ReleasePathsCompiled.Match(release.Path) { + pub.ReleasesCompiled = append(pub.ReleasesCompiled, release) + } + } + } + // Registry for archive plugins. c.PluginsRegistryArchive = make(map[string]*execrpc.Client[apimodel.Config, archiveplugin.Request, any, apimodel.Receipt]) diff --git a/cmd/publishcmd/publish.go b/cmd/publishcmd/publish.go new file mode 100644 index 0000000..a8f4bef --- /dev/null +++ b/cmd/publishcmd/publish.go @@ -0,0 +1,348 @@ +// Copyright 2026 The Hugoreleaser Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package publishcmd + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/bep/logg" + "github.com/gohugoio/hugoreleaser-plugins-api/model" + "github.com/gohugoio/hugoreleaser/cmd/corecmd" + "github.com/gohugoio/hugoreleaser/internal/common/matchers" + "github.com/gohugoio/hugoreleaser/internal/common/templ" + "github.com/gohugoio/hugoreleaser/internal/config" + "github.com/gohugoio/hugoreleaser/internal/publish/publishformats" + "github.com/gohugoio/hugoreleaser/internal/releases" + "github.com/gohugoio/hugoreleaser/staticfiles" + "github.com/peterbourgon/ff/v3/ffcli" +) + +const commandName = "publish" + +// New returns a usable ffcli.Command for the publish subcommand. +func New(core *corecmd.Core) *ffcli.Command { + fs := flag.NewFlagSet(corecmd.CommandName+" "+commandName, flag.ExitOnError) + + publisher := NewPublisher(core, fs) + + core.RegisterFlags(fs) + + return &ffcli.Command{ + Name: commandName, + ShortUsage: corecmd.CommandName + " publish [flags]", + ShortHelp: "Publish releases and update package managers.", + FlagSet: fs, + Exec: publisher.Exec, + } +} + +// NewPublisher returns a new Publisher. +func NewPublisher(core *corecmd.Core, fs *flag.FlagSet) *Publisher { + return &Publisher{ + core: core, + } +} + +// Publisher handles the publish command. +type Publisher struct { + core *corecmd.Core + infoLog logg.LevelLogger +} + +// Init initializes the publisher. +func (p *Publisher) Init() error { + p.infoLog = p.core.InfoLog.WithField("cmd", commandName) + return nil +} + +// Exec executes the publish command. +func (p *Publisher) Exec(ctx context.Context, args []string) error { + if err := p.Init(); err != nil { + return err + } + + if len(p.core.Config.Publishers) == 0 { + p.infoLog.Log(logg.String("No publishers configured")) + return nil + } + + logFields := logg.Fields{ + {Name: "tag", Value: p.core.Tag}, + } + logCtx := p.infoLog.WithFields(logFields) + + // Process each publisher. + for i := range p.core.Config.Publishers { + pub := &p.core.Config.Publishers[i] + + if len(pub.ReleasesCompiled) == 0 { + continue + } + + // Process each release that matches this publisher. + for _, release := range pub.ReleasesCompiled { + if err := p.handlePublisher(ctx, logCtx, pub, release); err != nil { + return err + } + } + } + + return nil +} + +func (p *Publisher) handlePublisher( + ctx context.Context, + logCtx logg.LevelLogger, + pub *config.Publisher, + release *config.Release, +) error { + settings := release.ReleaseSettings + + // Create client. + var client releases.PublishClient + if p.core.Try { + client = &releases.FakeClient{} + } else { + c, err := releases.NewClient(ctx, settings.TypeParsed) + if err != nil { + return fmt.Errorf("%s: failed to create release client: %v", commandName, err) + } + var ok bool + client, ok = c.(releases.PublishClient) + if !ok { + return fmt.Errorf("%s: client does not support publish operations", commandName) + } + } + + switch pub.Type.FormatParsed { + case publishformats.GitHubRelease: + return p.publishGitHubRelease(ctx, logCtx, client, release) + case publishformats.HomebrewCask: + return p.updateHomebrewCask(ctx, logCtx, client, pub, release) + case publishformats.Plugin: + return fmt.Errorf("%s: plugin publishers not yet implemented", commandName) + default: + return fmt.Errorf("%s: unknown publisher format: %s", commandName, pub.Type.Format) + } +} + +func (p *Publisher) publishGitHubRelease( + ctx context.Context, + logCtx logg.LevelLogger, + client releases.PublishClient, + release *config.Release, +) error { + settings := release.ReleaseSettings + + logCtx = logCtx.WithFields(logg.Fields{ + {Name: "action", Value: "github_release"}, + {Name: "repository", Value: fmt.Sprintf("%s/%s", settings.RepositoryOwner, settings.Repository)}, + }) + + logCtx.Log(logg.String("Checking release status")) + + releaseID, isDraft, err := client.GetReleaseByTag(ctx, settings.RepositoryOwner, settings.Repository, p.core.Tag) + if err != nil { + return fmt.Errorf("%s: failed to get release: %v", commandName, err) + } + + if isDraft { + logCtx.Log(logg.String("Publishing draft release")) + if err := client.PublishRelease(ctx, settings.RepositoryOwner, settings.Repository, releaseID); err != nil { + return fmt.Errorf("%s: failed to publish release: %v", commandName, err) + } + logCtx.Log(logg.String("Release published successfully")) + } else { + logCtx.Log(logg.String("Release is already published")) + } + + return nil +} + +// HomebrewCaskSettings holds the custom settings for homebrew_cask publisher. +type HomebrewCaskSettings struct { + BundleIdentifier string `mapstructure:"bundle_identifier"` + TapRepository string `mapstructure:"tap_repository"` + Name string `mapstructure:"name"` + CaskPath string `mapstructure:"cask_path"` + TemplateFilename string `mapstructure:"template_filename"` + Description string `mapstructure:"description"` + Homepage string `mapstructure:"homepage"` +} + +// HomebrewCaskContext holds data for the Homebrew cask template. +type HomebrewCaskContext struct { + Name string + DisplayName string + Version string + SHA256 string + URL string + Description string + Homepage string + PkgFilename string + BundleIdentifier string +} + +func (p *Publisher) updateHomebrewCask( + ctx context.Context, + logCtx logg.LevelLogger, + client releases.PublishClient, + pub *config.Publisher, + release *config.Release, +) error { + logCtx = logCtx.WithField("action", "homebrew_cask") + logCtx.Log(logg.String("Updating Homebrew cask")) + + releaseSettings := release.ReleaseSettings + version := strings.TrimPrefix(p.core.Tag, "v") + + // Read settings from custom_settings. + settings, err := model.FromMap[any, HomebrewCaskSettings](pub.CustomSettings) + if err != nil { + return fmt.Errorf("failed to parse homebrew_cask settings: %w", err) + } + + // Apply defaults. + if settings.TapRepository == "" { + settings.TapRepository = "homebrew-tap" + } + if settings.Name == "" { + settings.Name = p.core.Config.Project + } + if settings.CaskPath == "" { + settings.CaskPath = fmt.Sprintf("Casks/%s.rb", settings.Name) + } + + // Find the first .pkg archive matching the archive paths pattern. + pkgInfo, err := p.findPkgArchive(release, pub.ArchivePathsCompiled) + if err != nil { + return err + } + + logCtx.WithField("pkg", pkgInfo.Name).Log(logg.String("Found pkg archive")) + + // Build download URL. + downloadURL := fmt.Sprintf( + "https://github.com/%s/%s/releases/download/%s/%s", + releaseSettings.RepositoryOwner, + releaseSettings.Repository, + p.core.Tag, + pkgInfo.Name, + ) + + // Build cask context. + caskCtx := HomebrewCaskContext{ + Name: settings.Name, + DisplayName: p.core.Config.Project, + Version: version, + SHA256: pkgInfo.SHA256, + URL: downloadURL, + Description: settings.Description, + Homepage: settings.Homepage, + PkgFilename: pkgInfo.Name, + BundleIdentifier: settings.BundleIdentifier, + } + + // Render cask template. + var caskContent bytes.Buffer + var tmpl *template.Template + + if settings.TemplateFilename != "" { + templatePath := settings.TemplateFilename + if !filepath.IsAbs(templatePath) { + templatePath = filepath.Join(p.core.ProjectDir, templatePath) + } + b, err := os.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("failed to read custom cask template: %v", err) + } + tmpl, err = templ.Parse(string(b)) + if err != nil { + return fmt.Errorf("failed to parse custom cask template: %v", err) + } + } else { + tmpl = staticfiles.HomebrewCaskTemplate + } + + if err := tmpl.Execute(&caskContent, caskCtx); err != nil { + return fmt.Errorf("failed to execute cask template: %v", err) + } + + // Update file in tap repository. + commitMessage := fmt.Sprintf("Update %s to %s", settings.Name, p.core.Tag) + + logCtx.WithFields(logg.Fields{ + {Name: "tap", Value: fmt.Sprintf("%s/%s", releaseSettings.RepositoryOwner, settings.TapRepository)}, + {Name: "path", Value: settings.CaskPath}, + }).Log(logg.String("Committing cask update")) + + if p.core.Try { + logCtx.Log(logg.String("Trial run - skipping commit")) + return nil + } + + sha, err := client.UpdateFileInRepo( + ctx, + releaseSettings.RepositoryOwner, + settings.TapRepository, + settings.CaskPath, + commitMessage, + caskContent.Bytes(), + ) + if err != nil { + return err + } + + logCtx.WithField("commit", sha).Log(logg.String("Cask updated successfully")) + return nil +} + +// pkgArchiveInfo contains information about a .pkg archive. +type pkgArchiveInfo struct { + Name string + SHA256 string +} + +// findPkgArchive finds the first .pkg archive for darwin matching the archive paths pattern. +func (p *Publisher) findPkgArchive(release *config.Release, archivePathsMatcher matchers.Matcher) (pkgArchiveInfo, error) { + for _, archPath := range release.ArchsCompiled { + // Only consider darwin archives. + if archPath.Arch.Os == nil || archPath.Arch.Os.Goos != "darwin" { + continue + } + + // Check if the path matches the pattern. + if archivePathsMatcher != nil && !archivePathsMatcher.Match(archPath.Path) { + continue + } + + // Check if it's a .pkg file. + if strings.HasSuffix(archPath.Name, ".pkg") { + return pkgArchiveInfo{ + Name: archPath.Name, + SHA256: archPath.SHA256, + }, nil + } + } + + return pkgArchiveInfo{}, fmt.Errorf("no .pkg archive found for darwin") +} diff --git a/cmd/releasecmd/release.go b/cmd/releasecmd/release.go index 1dfae07..f835eaa 100644 --- a/cmd/releasecmd/release.go +++ b/cmd/releasecmd/release.go @@ -79,7 +79,6 @@ func (b *Releaser) Init() error { } b.infoLog = b.core.InfoLog.WithField("cmd", commandName) - releaseMatches := b.core.Config.FindReleases(b.core.PathsReleasesCompiled) if len(releaseMatches) == 0 { return fmt.Errorf("%s: no releases found matching -paths %v", commandName, b.core.Paths) @@ -118,13 +117,16 @@ func (b *Releaser) Exec(ctx context.Context, args []string) error { logCtx := b.infoLog.WithFields(logFields) logCtx.Log(logg.String("Finding releases")) - releaseMatches := b.core.Config.FindReleases(b.core.PathsReleasesCompiled) - for _, release := range releaseMatches { + // Iterate over the original releases slice so we can update SHA256 checksums. + for i := range b.core.Config.Releases { + release := &b.core.Config.Releases[i] + if b.core.PathsReleasesCompiled != nil && !b.core.PathsReleasesCompiled.Match(release.Path) { + continue + } if err := b.handleRelease(ctx, logCtx, release); err != nil { return err } - } return nil @@ -138,7 +140,7 @@ type releaseContext struct { Info releases.ReleaseInfo } -func (b *Releaser) handleRelease(ctx context.Context, logCtx logg.LevelLogger, release config.Release) error { +func (b *Releaser) handleRelease(ctx context.Context, logCtx logg.LevelLogger, release *config.Release) error { releaseDir := filepath.Join( b.core.DistDir, b.core.Config.Project, @@ -209,11 +211,18 @@ func (b *Releaser) handleRelease(ctx context.Context, logCtx logg.LevelLogger, r if len(archiveFilenames) > 0 { - checksumFilename, err := b.generateChecksumTxt(rctx, archiveFilenames...) + checksumFilename, checksums, err := b.generateChecksumTxt(rctx, archiveFilenames...) if err != nil { return err } + // Store SHA256 checksums in the ArchsCompiled for use by the publish command. + for i := range release.ArchsCompiled { + if sha, ok := checksums[release.ArchsCompiled[i].Name]; ok { + release.ArchsCompiled[i].SHA256 = sha + } + } + archiveFilenames = append(archiveFilenames, checksumFilename) logCtx.Logf("Prepared %d files to archive: %v", len(archiveFilenames), archiveFilenames) @@ -318,7 +327,6 @@ func (b *Releaser) generateReleaseNotes(rctx releaseContext) (string, error) { } return "", 0, false }) - if err != nil { return "", err } @@ -356,7 +364,6 @@ func (b *Releaser) generateReleaseNotes(rctx releaseContext) (string, error) { } } else { t = staticfiles.ReleaseNotesTemplate - } if err := t.Execute(f, rnc); err != nil { @@ -365,7 +372,6 @@ func (b *Releaser) generateReleaseNotes(rctx releaseContext) (string, error) { return nil }() - if err != nil { return "", fmt.Errorf("%s: failed to create release notes file %q: %s", commandName, releaseNotesFilename, err) } @@ -375,11 +381,11 @@ func (b *Releaser) generateReleaseNotes(rctx releaseContext) (string, error) { return releaseNotesFilename, nil } -func (b *Releaser) generateChecksumTxt(rctx releaseContext, archiveFilenames ...string) (string, error) { +func (b *Releaser) generateChecksumTxt(rctx releaseContext, archiveFilenames ...string) (string, map[string]string, error) { // Create a checksums.txt file. - checksumLines, err := releases.CreateChecksumLines(b.core.Workforce, archiveFilenames...) + checksumResult, err := releases.CreateChecksumLines(b.core.Workforce, archiveFilenames...) if err != nil { - return "", err + return "", nil, err } // This is what Hugo got out of the box from Goreleaser. No settings for now. name := fmt.Sprintf("%s_%s_checksums.txt", rctx.Info.Project, strings.TrimPrefix(rctx.Info.Tag, "v")) @@ -392,7 +398,7 @@ func (b *Releaser) generateChecksumTxt(rctx releaseContext, archiveFilenames ... } defer f.Close() - for _, line := range checksumLines { + for _, line := range checksumResult.Lines { _, err := f.WriteString(line + "\n") if err != nil { return err @@ -401,12 +407,11 @@ func (b *Releaser) generateChecksumTxt(rctx releaseContext, archiveFilenames ... return nil }() - if err != nil { - return "", fmt.Errorf("%s: failed to create checksum file %q: %s", commandName, checksumFilename, err) + return "", nil, fmt.Errorf("%s: failed to create checksum file %q: %s", commandName, checksumFilename, err) } rctx.Log.WithField("filename", checksumFilename).Log(logg.String("Created checksum file")) - return checksumFilename, nil + return checksumFilename, checksumResult.Checksums, nil } diff --git a/go.mod b/go.mod index c3667bf..54c0df0 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/gohugoio/hugoreleaser/plugins v0.1.1-0.20220822083757-38d81884db04 github.com/google/go-github/v45 v45.2.0 github.com/mattn/go-isatty v0.0.20 - github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 github.com/pelletier/go-toml/v2 v2.2.4 github.com/peterbourgon/ff/v3 v3.4.0 github.com/rogpeppe/go-internal v1.14.1 diff --git a/go.sum b/go.sum index 2d78f5b..b332112 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,6 @@ github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= github.com/bep/execrpc v0.10.0 h1:Y8S8pZOFZI/zo95RMqgri98eIFaZEqZ41tbF89hSx/A= github.com/bep/execrpc v0.10.0/go.mod h1:lGHGK8NX0KvfhlRNH/SebW1quHGwXFeE3Ba+2KLtmrM= -github.com/bep/helpers v0.5.0 h1:rneezhnG7GzLFlsEWO/EnleaBRuluBDGFimalO6Y50o= -github.com/bep/helpers v0.5.0/go.mod h1:dSqCzIvHbzsk5YOesp1M7sKAq5xUcvANsRoKdawxH4Q= github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw= github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044= github.com/bep/logg v0.4.0 h1:luAo5mO4ZkhA5M1iDVDqDqnBBnlHjmtZF6VAyTp+nCQ= @@ -11,8 +9,6 @@ github.com/bep/logg v0.4.0/go.mod h1:Ccp9yP3wbR1mm++Kpxet91hAZBEQgmWgFgnXX3GkIV0 github.com/bep/workers v1.1.0 h1:3Xw/1y/Fzjt8KBB4nCHfXcvyWH9h56iEwPquCjKphCc= github.com/bep/workers v1.1.0/go.mod h1:7kIESOB86HfR2379pwoMWNy8B50D7r99fRLUyPSNyCs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.13.0/go.mod h1:qLE0fzW0VuyUAJgPU19zByoIr0HtCHN/r/VLSOOIySU= @@ -20,27 +16,19 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-yaml v1.15.13-0.20241221080047-87aec7d7f886 h1:DGV2XraUpNBGuKdxYuNFmlm4feqfEFMm+48p2YUQnqw= -github.com/goccy/go-yaml v1.15.13-0.20241221080047-87aec7d7f886/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gohugoio/hugoreleaser-plugins-api v0.7.1-0.20241220094410-1f02562cf9b9 h1:JT6XVa3wBdkwrgcV4EYku7fZOfLX2MjWgWWtHtp5IQs= -github.com/gohugoio/hugoreleaser-plugins-api v0.7.1-0.20241220094410-1f02562cf9b9/go.mod h1:Qheg3q6TF7pq9etJBNceTqGaM1VfkYDnm2k2KIIC/Ac= github.com/gohugoio/hugoreleaser-plugins-api v0.8.0 h1:H3qH9Ra7yg5ZZLFtkBYJqTbr1oAO7wkysMvcmSUauV0= github.com/gohugoio/hugoreleaser-plugins-api v0.8.0/go.mod h1:Qheg3q6TF7pq9etJBNceTqGaM1VfkYDnm2k2KIIC/Ac= github.com/gohugoio/hugoreleaser/plugins v0.1.1-0.20220822083757-38d81884db04 h1:VNOiFvTuhXc2eoDvBVQHsfxl1TTS2/EF1wFs1YttIlA= github.com/gohugoio/hugoreleaser/plugins v0.1.1-0.20220822083757-38d81884db04/go.mod h1:P3JlkmIYwGFlTf8/MhkR4P+mvidrYo28Fudx4wcR7f0= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -50,18 +38,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= -github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= -github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterbourgon/ff/v3 v3.4.0 h1:QBvM/rizZM1cB0p0lGMdmR7HxZeI/ZrBWB4DqLkMUBc= @@ -69,43 +51,22 @@ github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyX github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo= -golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU= golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU= -golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= -golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= -golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/hugoreleaser.yaml b/hugoreleaser.yaml index 938bbcd..55748d5 100644 --- a/hugoreleaser.yaml +++ b/hugoreleaser.yaml @@ -130,3 +130,15 @@ releases: - paths: - archives/** path: myrelease + +publishers: + - paths: + - releases/** + type: + format: github_release + - paths: + - releases/**/archives/macos/** + type: + format: homebrew_cask + custom_settings: + bundle_identifier: io.gohugo.hugoreleaser diff --git a/internal/config/archive_config.go b/internal/config/archive_config.go index 2c86daf..ae9656c 100644 --- a/internal/config/archive_config.go +++ b/internal/config/archive_config.go @@ -71,6 +71,10 @@ type BuildArchPath struct { // Any archive aliase names, with the extension. Aliases []string `json:"aliases"` + + // SHA256 is the SHA256 checksum of the archive file. + // This is populated by the release command after computing checksums. + SHA256 string `json:"-"` } type ArchiveSettings struct { diff --git a/internal/config/build_config.go b/internal/config/build_config.go index 0267f1d..9eb30c9 100644 --- a/internal/config/build_config.go +++ b/internal/config/build_config.go @@ -33,6 +33,12 @@ type Build struct { } func (b *Build) Init() error { + // Normalize and validate path. + b.Path = NormalizePath(b.Path) + if err := ValidatePathElement(b.Path); err != nil { + return fmt.Errorf("builds: %w", err) + } + for _, os := range b.Os { for _, arch := range os.Archs { if arch.Goarch == builds.UniversalGoarch && os.Goos != "darwin" { diff --git a/internal/config/config.go b/internal/config/config.go index 4640f9b..1f23546 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Hugoreleaser Authors +// Copyright 2026 The Hugoreleaser Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -17,11 +17,15 @@ package config import ( "fmt" "io/fs" + "strings" "github.com/gohugoio/hugoreleaser/internal/common/matchers" "github.com/gohugoio/hugoreleaser/internal/plugins/plugintypes" ) +// Reserved path element names that cannot be used in path identifiers. +var reservedPathElements = []string{"builds", "archives", "releases"} + type Config struct { // A bucket for anchors that defines reusable YAML fragments. Definitions map[string]any ` json:"definitions"` @@ -31,13 +35,15 @@ type Config struct { GoSettings GoSettings `json:"go_settings"` - Builds Builds `json:"builds"` - Archives Archives `json:"archives"` - Releases Releases `json:"releases"` + Builds Builds `json:"builds"` + Archives Archives `json:"archives"` + Releases Releases `json:"releases"` + Publishers Publishers `json:"publishers"` BuildSettings BuildSettings `json:"build_settings"` ArchiveSettings ArchiveSettings `json:"archive_settings"` ReleaseSettings ReleaseSettings `json:"release_settings"` + PublishSettings PublishSettings `json:"publish_settings"` } func (c Config) FindReleases(filter matchers.Matcher) []Release { @@ -112,3 +118,29 @@ type ArchiveFileInfo struct { TargetPath string `json:"target_path"` Mode fs.FileMode `json:"mode"` } + +// NormalizePath trims leading/trailing slashes from a path. +func NormalizePath(p string) string { + return strings.Trim(p, "/") +} + +// ValidatePathElement checks that a path doesn't contain reserved keywords as path elements. +// Reserved keywords (builds, archives, releases) cause ambiguity when used as path elements. +// Returns an error if the path contains a reserved keyword as a path element. +// Examples: +// - "builds" -> error (reserved keyword) +// - "foo/builds" -> error (builds is a path element) +// - "foo/builds/bar" -> error (builds is a path element) +// - "foo-builds" -> OK (builds is part of a larger element) +// - "mybuilds" -> OK (builds is part of a larger element) +func ValidatePathElement(p string) error { + elements := strings.Split(p, "/") + for _, elem := range elements { + for _, reserved := range reservedPathElements { + if elem == reserved { + return fmt.Errorf("path %q contains reserved keyword %q as a path element", p, reserved) + } + } + } + return nil +} diff --git a/internal/config/decode.go b/internal/config/decode.go index be1b051..42280f9 100644 --- a/internal/config/decode.go +++ b/internal/config/decode.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Hugoreleaser Authors +// Copyright 2026 The Hugoreleaser Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -120,6 +120,18 @@ func DecodeAndApplyDefaults(r io.Reader) (Config, error) { } } + // Init and validate publish settings. + if err := cfg.PublishSettings.Init(); err != nil { + return *cfg, err + } + + // Init and validate publisher configs. + for i := range cfg.Publishers { + if err := cfg.Publishers[i].Init(); err != nil { + return *cfg, err + } + } + // Apply some convenient navigation helpers. for i := range cfg.Builds { for j := range cfg.Builds[i].Os { diff --git a/internal/config/publish_config.go b/internal/config/publish_config.go new file mode 100644 index 0000000..8f43384 --- /dev/null +++ b/internal/config/publish_config.go @@ -0,0 +1,173 @@ +// Copyright 2026 The Hugoreleaser Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "fmt" + "strings" + + "github.com/gohugoio/hugoreleaser/internal/common/matchers" + "github.com/gohugoio/hugoreleaser/internal/publish/publishformats" +) + +// PublishSettings contains shared defaults for publishers. +type PublishSettings struct { + // Shared settings that can be overridden per publisher (extensible). +} + +func (p *PublishSettings) Init() error { + return nil +} + +// Publisher represents a single publish target. +type Publisher struct { + // Paths with glob patterns to match releases and optionally filter archives. + // Format: releases/[/archives/] + // Examples: + // - "releases/**" matches all releases, all archives + // - "releases/myrelease/**" matches specific release, all archives + // - "releases/**/archives/macos/**" matches all releases, only macos archives + // - "releases/myrelease/archives/macos/**" matches specific release, only macos archives + // Default (no paths): matches all releases and all archives. + Paths []string `json:"paths"` + + Type PublishType `json:"type"` + Plugin Plugin `json:"plugin"` + + // CustomSettings contains type-specific settings. + // For homebrew_cask: bundle_identifier, tap_repository, name, cask_path, etc. + CustomSettings map[string]any `json:"custom_settings"` + + // Compiled fields + ReleasePathsCompiled matchers.Matcher `json:"-"` + ArchivePathsCompiled matchers.Matcher `json:"-"` + ReleasesCompiled []*Release `json:"-"` +} + +func (p *Publisher) Init() error { + what := fmt.Sprintf("publishers: %v", p.Paths) + + if err := p.Type.Init(); err != nil { + return fmt.Errorf("%s: %v", what, err) + } + + // Validate format setup. + switch p.Type.FormatParsed { + case publishformats.Plugin: + if err := p.Plugin.Init(); err != nil { + return fmt.Errorf("%s: %v", what, err) + } + default: + // Clear it so we don't need to start it. + p.Plugin.Clear() + } + + // Parse unified path format: releases/[/archives/] + var releasePaths, archivePaths []string + const ( + releasesPrefix = "releases/" + archivesSeparator = "/archives/" + ) + + for _, path := range p.Paths { + if !strings.HasPrefix(path, releasesPrefix) { + return fmt.Errorf("%s: paths must start with %q, got %q", what, releasesPrefix, path) + } + + rest := path[len(releasesPrefix):] + + // Check if path contains "/archives/" separator + if idx := strings.Index(rest, archivesSeparator); idx != -1 { + releasePart := rest[:idx] + archivePart := rest[idx+len(archivesSeparator):] + if releasePart == "" { + releasePart = "**" + } + releasePaths = append(releasePaths, releasePart) + archivePaths = append(archivePaths, archivePart) + } else { + // No archive filter - match all archives + releasePaths = append(releasePaths, rest) + } + } + + // Compile release paths if provided. + if len(releasePaths) > 0 { + var err error + p.ReleasePathsCompiled, err = matchers.Glob(releasePaths...) + if err != nil { + return fmt.Errorf("%s: failed to compile release paths glob: %v", what, err) + } + } + + // Compile archive paths if provided. + if len(archivePaths) > 0 { + var err error + p.ArchivePathsCompiled, err = matchers.Glob(archivePaths...) + if err != nil { + return fmt.Errorf("%s: failed to compile archive paths glob: %v", what, err) + } + } + + // Validate type-specific settings. + switch p.Type.FormatParsed { + case publishformats.HomebrewCask: + if err := p.validateHomebrewCaskSettings(); err != nil { + return fmt.Errorf("%s: %v", what, err) + } + } + + return nil +} + +func (p *Publisher) validateHomebrewCaskSettings() error { + what := "homebrew_cask" + + // bundle_identifier is required. + if _, ok := p.CustomSettings["bundle_identifier"]; !ok { + return fmt.Errorf("%s: bundle_identifier is required in custom_settings", what) + } + + return nil +} + +// PublishType represents the type of publisher. +type PublishType struct { + Format string `json:"format"` // github_release, homebrew_cask, _plugin + + FormatParsed publishformats.Format `json:"-"` +} + +func (t *PublishType) Init() error { + what := "type" + if t.Format == "" { + return fmt.Errorf("%s: has no format", what) + } + + var err error + if t.FormatParsed, err = publishformats.Parse(t.Format); err != nil { + return err + } + + return nil +} + +// IsZero is needed to get the shallow merge correct. +func (t PublishType) IsZero() bool { + return t.Format == "" +} + +// Publishers is a slice of Publisher. +type Publishers []Publisher diff --git a/internal/config/release_config.go b/internal/config/release_config.go index 9695a23..8431ff4 100644 --- a/internal/config/release_config.go +++ b/internal/config/release_config.go @@ -48,7 +48,10 @@ func (a *Release) Init() error { return fmt.Errorf("%s: dir is required", what) } - a.Path = path.Clean(filepath.ToSlash(a.Path)) + a.Path = NormalizePath(path.Clean(filepath.ToSlash(a.Path))) + if err := ValidatePathElement(a.Path); err != nil { + return fmt.Errorf("%s: %w", what, err) + } const prefix = "archives/" for i, p := range a.Paths { diff --git a/internal/publish/publishformats/publishformats.go b/internal/publish/publishformats/publishformats.go new file mode 100644 index 0000000..1ca5b99 --- /dev/null +++ b/internal/publish/publishformats/publishformats.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Hugoreleaser Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package publishformats + +import ( + "fmt" + "strings" + + "github.com/gohugoio/hugoreleaser/internal/common/mapsh" +) + +const ( + InvalidFormat Format = iota + GitHubRelease // Undrafts the GitHub release + HomebrewCask // Updates Homebrew cask file + Plugin // Plugin is a special format handled by an external tool +) + +var formatString = map[Format]string{ + // The string values is what users can specify in the config. + GitHubRelease: "github_release", + HomebrewCask: "homebrew_cask", + Plugin: "_plugin", +} + +var stringFormat = map[string]Format{} + +func init() { + for k, v := range formatString { + stringFormat[v] = k + } +} + +// Parse parses a string into a Format. +func Parse(s string) (Format, error) { + f := stringFormat[strings.ToLower(s)] + if f == InvalidFormat { + return f, fmt.Errorf("invalid publish format %q, must be one of %s", s, mapsh.KeysSorted(formatString)) + } + return f, nil +} + +// MustParse parses a string into a Format and panics if it fails. +func MustParse(s string) Format { + f, err := Parse(s) + if err != nil { + panic(err) + } + return f +} + +// Format represents the type of publisher. +type Format int + +func (f Format) String() string { + return formatString[f] +} diff --git a/internal/releases/checksums.go b/internal/releases/checksums.go index 71e86ed..abfa83a 100644 --- a/internal/releases/checksums.go +++ b/internal/releases/checksums.go @@ -27,11 +27,21 @@ import ( "github.com/bep/workers" ) +// ChecksumResult contains the checksum lines and a map of filename to checksum. +type ChecksumResult struct { + // Lines contains the checksum lines in "sha256 filename" format. + Lines []string + // Checksums maps base filename to its SHA256 checksum. + Checksums map[string]string +} + // CreateChecksumLines writes the SHA256 checksums as lowercase hex digits followed by // two spaces and then the base of filename and returns a sorted slice. -func CreateChecksumLines(w *workers.Workforce, filenames ...string) ([]string, error) { +// It also returns a map of base filename -> checksum for programmatic access. +func CreateChecksumLines(w *workers.Workforce, filenames ...string) (ChecksumResult, error) { var mu sync.Mutex - var result []string + var result ChecksumResult + result.Checksums = make(map[string]string) r, _ := w.Start(context.Background()) @@ -55,8 +65,10 @@ func CreateChecksumLines(w *workers.Workforce, filenames ...string) ([]string, e if err != nil { return err } + baseName := filepath.Base(filename) mu.Lock() - result = append(result, checksum+" "+filepath.Base(filename)) + result.Lines = append(result.Lines, checksum+" "+baseName) + result.Checksums[baseName] = checksum mu.Unlock() return nil @@ -64,10 +76,10 @@ func CreateChecksumLines(w *workers.Workforce, filenames ...string) ([]string, e } if err := r.Wait(); err != nil { - return nil, err + return ChecksumResult{}, err } - sort.Strings(result) + sort.Strings(result.Lines) return result, nil } diff --git a/internal/releases/checksums_test.go b/internal/releases/checksums_test.go index be89baf..573bcb7 100644 --- a/internal/releases/checksums_test.go +++ b/internal/releases/checksums_test.go @@ -45,9 +45,9 @@ func TestCreateChecksumLines(t *testing.T) { filenames = append(filenames, filename) } - checksums, err := CreateChecksumLines(w, filenames...) + result, err := CreateChecksumLines(w, filenames...) c.Assert(err, qt.IsNil) - c.Assert(checksums, qt.DeepEquals, []string{ + c.Assert(result.Lines, qt.DeepEquals, []string{ "196373310827669cb58f4c688eb27aabc40e600dc98615bd329f410ab7430cff file6.txt", "47ea70cf08872bdb4afad3432b01d963ac7d165f6b575cd72ef47498f4459a90 file3.txt", "4e74512f1d8e5016f7a9d9eaebbeedb1549fed5b63428b736eecfea98292d75f file9.txt", @@ -59,4 +59,9 @@ func TestCreateChecksumLines(t *testing.T) { "bd4c6c665a1b8b4745bcfd3d744ea37488237108681a8ba4486a76126327d3f2 file8.txt", "e361a57a7406adee653f1dcff660d84f0ca302907747af2a387f67821acfce33 file4.txt", }) + + // Verify the checksums map for programmatic access. + c.Assert(result.Checksums["file0.txt"], qt.Equals, "5a936ee19a0cf3c70d8cb0006111b7a52f45ec01703e0af8cdc8c6d81ac5850c") + c.Assert(result.Checksums["file9.txt"], qt.Equals, "4e74512f1d8e5016f7a9d9eaebbeedb1549fed5b63428b736eecfea98292d75f") + c.Assert(len(result.Checksums), qt.Equals, 10) } diff --git a/internal/releases/client.go b/internal/releases/client.go index 059ec8e..c22460e 100644 --- a/internal/releases/client.go +++ b/internal/releases/client.go @@ -18,3 +18,19 @@ type Client interface { Release(ctx context.Context, info ReleaseInfo) (int64, error) UploadAssetsFile(ctx context.Context, info ReleaseInfo, f *os.File, releaseID int64) error } + +// PublishClient extends Client with publish-specific operations. +type PublishClient interface { + Client + + // GetReleaseByTag retrieves a release by its tag name. + // Returns the release ID, draft status, and error. + GetReleaseByTag(ctx context.Context, owner, repo, tag string) (releaseID int64, isDraft bool, err error) + + // PublishRelease sets a release from draft to published. + PublishRelease(ctx context.Context, owner, repo string, releaseID int64) error + + // UpdateFileInRepo creates or updates a file in a repository. + // Returns the commit SHA on success. + UpdateFileInRepo(ctx context.Context, owner, repo, path, message string, content []byte) (string, error) +} diff --git a/internal/releases/fakeclient.go b/internal/releases/fakeclient.go index 6723442..68655d8 100644 --- a/internal/releases/fakeclient.go +++ b/internal/releases/fakeclient.go @@ -34,3 +34,22 @@ func (c *FakeClient) UploadAssetsFile(ctx context.Context, info ReleaseInfo, f * } return nil } + +// Ensure FakeClient implements PublishClient. +var _ PublishClient = &FakeClient{} + +func (c *FakeClient) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (int64, bool, error) { + fmt.Printf("fake: GetReleaseByTag: owner=%s repo=%s tag=%s\n", owner, repo, tag) + c.releaseID = rand.Int63() + return c.releaseID, true, nil // Return as draft for testing. +} + +func (c *FakeClient) PublishRelease(ctx context.Context, owner, repo string, releaseID int64) error { + fmt.Printf("fake: PublishRelease: owner=%s repo=%s releaseID=%d\n", owner, repo, releaseID) + return nil +} + +func (c *FakeClient) UpdateFileInRepo(ctx context.Context, owner, repo, path, message string, content []byte) (string, error) { + fmt.Printf("fake: UpdateFileInRepo: owner=%s repo=%s path=%s message=%q\n", owner, repo, path, message) + return "fakesha123", nil +} diff --git a/internal/releases/github.go b/internal/releases/github.go index a30693d..1e55c42 100644 --- a/internal/releases/github.go +++ b/internal/releases/github.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Hugoreleaser Authors +// Copyright 2026 The Hugoreleaser Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -198,6 +198,57 @@ type TemporaryError struct { error } +// Ensure GitHubClient implements PublishClient. +var _ PublishClient = &GitHubClient{} + +func (c *GitHubClient) GetReleaseByTag(ctx context.Context, owner, repo, tag string) (int64, bool, error) { + release, resp, err := c.client.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return 0, false, fmt.Errorf("release not found for tag %q", tag) + } + return 0, false, err + } + + return release.GetID(), release.GetDraft(), nil +} + +func (c *GitHubClient) PublishRelease(ctx context.Context, owner, repo string, releaseID int64) error { + _, _, err := c.client.Repositories.EditRelease(ctx, owner, repo, releaseID, &github.RepositoryRelease{ + Draft: github.Bool(false), + }) + return err +} + +func (c *GitHubClient) UpdateFileInRepo(ctx context.Context, owner, repo, path, message string, content []byte) (string, error) { + // First try to get existing file to get its SHA. + fileContent, _, resp, err := c.client.Repositories.GetContents(ctx, owner, repo, path, nil) + var sha string + if err == nil && fileContent != nil { + sha = fileContent.GetSHA() + } else if resp != nil && resp.StatusCode != http.StatusNotFound { + // Return error only if it's not a 404 (file doesn't exist is OK). + return "", fmt.Errorf("failed to get file %s: %w", path, err) + } + + opts := &github.RepositoryContentFileOptions{ + Message: github.String(message), + Content: content, + } + + if sha != "" { + // File exists, update it. + opts.SHA = github.String(sha) + } + + result, _, err := c.client.Repositories.CreateFile(ctx, owner, repo, path, opts) + if err != nil { + return "", fmt.Errorf("failed to create/update file %s: %w", path, err) + } + + return result.GetSHA(), nil +} + // isTemporaryHttpStatus returns true if the status code is considered temporary, returning // true if not sure. func isTemporaryHttpStatus(status int) bool { diff --git a/main.go b/main.go index 204d8ea..cf4efda 100644 --- a/main.go +++ b/main.go @@ -1,4 +1,4 @@ -// Copyright 2022 The Hugoreleaser Authors +// Copyright 2026 The Hugoreleaser Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -32,6 +32,7 @@ import ( "github.com/gohugoio/hugoreleaser/cmd/archivecmd" "github.com/gohugoio/hugoreleaser/cmd/buildcmd" "github.com/gohugoio/hugoreleaser/cmd/corecmd" + "github.com/gohugoio/hugoreleaser/cmd/publishcmd" "github.com/gohugoio/hugoreleaser/cmd/releasecmd" "github.com/gohugoio/hugoreleaser/internal/common/logging" "github.com/peterbourgon/ff/v3" @@ -68,6 +69,7 @@ func parseAndRun(args []string) (err error) { buildCommand = buildcmd.New(core) archiveCommand = archivecmd.New(core) releaseCommand = releasecmd.New(core) + publishCommand = publishcmd.New(core) allCommand = allcmd.New(core) ) @@ -75,6 +77,7 @@ func parseAndRun(args []string) (err error) { buildCommand, archiveCommand, releaseCommand, + publishCommand, allCommand, newVersionCommand(), } diff --git a/main_test.go b/main_test.go index a35fba2..0f6ffae 100644 --- a/main_test.go +++ b/main_test.go @@ -109,303 +109,283 @@ func testSetupFunc() func(env *testscript.Env) error { } func TestMain(m *testing.M) { - os.Exit( - testscript.RunMain(m, map[string]func() int{ - // The main program. - "hugoreleaser": func() int { - if err := parseAndRun(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } - return 0 - }, - - // dostounix converts \r\n to \n. - "dostounix": func() int { - filename := os.Args[1] - b, err := os.ReadFile(filename) - if err != nil { - fatalf("%v", err) - } - b = bytes.Replace(b, []byte("\r\n"), []byte{'\n'}, -1) - if err := os.WriteFile(filename, b, 0o666); err != nil { - fatalf("%v", err) - } - return 0 - }, - - // log prints to stderr. - "log": func() int { - log.Println(os.Args[1]) - return 0 - }, - "sleep": func() int { - i, err := strconv.Atoi(os.Args[1]) - if err != nil { - i = 1 - } - time.Sleep(time.Duration(i) * time.Second) - return 0 - }, - - // ls lists a directory to stdout. - "ls": func() int { - dirname := os.Args[1] - dir, err := os.Open(dirname) - if err != nil { - fatalf("%v", err) - } - fis, err := dir.Readdir(-1) - if err != nil { - fatalf("%v", err) - } - for _, fi := range fis { - fmt.Printf("%s %04o %s\n", fi.Mode(), fi.Mode().Perm(), fi.Name()) - } - return 0 - }, - - // printarchive prints the contents of an archive to stdout. - "printarchive": func() int { - archiveFilename := os.Args[1] - - if !strings.HasSuffix(archiveFilename, ".tar.gz") { - fatalf("only .tar.gz supported for now, got: %q", archiveFilename) - } - - f, err := os.Open(archiveFilename) - if err != nil { - fatalf("%v", err) - } - defer f.Close() - - gr, err := gzip.NewReader(f) - if err != nil { - fatalf("%v", err) - } - defer gr.Close() - tr := tar.NewReader(gr) - - for { - hdr, err := tr.Next() - if err == io.EOF { - break - } - if err != nil { - fatalf("%v", err) - } - mode := fs.FileMode(hdr.Mode) - fmt.Printf("%s %04o %s\n", mode, mode.Perm(), hdr.Name) - } + testscript.Main(m, map[string]func(){ + // The main program. + "hugoreleaser": func() { + if err := parseAndRun(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, - return 0 - }, + // dostounix converts \r\n to \n. + "dostounix": func() { + filename := os.Args[1] + b, err := os.ReadFile(filename) + if err != nil { + fatalf("%v", err) + } + b = bytes.Replace(b, []byte("\r\n"), []byte{'\n'}, -1) + if err := os.WriteFile(filename, b, 0o666); err != nil { + fatalf("%v", err) + } + }, - // cpdir copies a file. - "cpfile": func() int { - if len(os.Args) != 3 { - fmt.Fprintln(os.Stderr, "usage: cpdir SRC DST") - return 1 - } + // log prints to stderr. + "log": func() { + log.Println(os.Args[1]) + }, + "sleep": func() { + i, err := strconv.Atoi(os.Args[1]) + if err != nil { + i = 1 + } + time.Sleep(time.Duration(i) * time.Second) + }, - fromFile := os.Args[1] - toFile := os.Args[2] + // ls lists a directory to stdout. + "ls": func() { + dirname := os.Args[1] + dir, err := os.Open(dirname) + if err != nil { + fatalf("%v", err) + } + fis, err := dir.Readdir(-1) + if err != nil { + fatalf("%v", err) + } + for _, fi := range fis { + fmt.Printf("%s %04o %s\n", fi.Mode(), fi.Mode().Perm(), fi.Name()) + } + }, - if !filepath.IsAbs(fromFile) { - fromFile = filepath.Join(os.Getenv("SOURCE"), fromFile) - } + // printarchive prints the contents of an archive to stdout. + "printarchive": func() { + archiveFilename := os.Args[1] - if err := os.MkdirAll(filepath.Dir(toFile), 0o755); err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } + if !strings.HasSuffix(archiveFilename, ".tar.gz") { + fatalf("only .tar.gz supported for now, got: %q", archiveFilename) + } - err := filehelpers.CopyFile(fromFile, toFile) - if err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } - return 0 - }, - - // cpdir copies a directory recursively. - "cpdir": func() int { - if len(os.Args) != 3 { - fmt.Fprintln(os.Stderr, "usage: cpdir SRC DST") - return 1 - } + f, err := os.Open(archiveFilename) + if err != nil { + fatalf("%v", err) + } + defer f.Close() - fromDir := os.Args[1] - toDir := os.Args[2] + gr, err := gzip.NewReader(f) + if err != nil { + fatalf("%v", err) + } + defer gr.Close() + tr := tar.NewReader(gr) - if !filepath.IsAbs(fromDir) { - fromDir = filepath.Join(os.Getenv("SOURCE"), fromDir) + for { + hdr, err := tr.Next() + if err == io.EOF { + break } - - err := filehelpers.CopyDir(fromDir, toDir, nil) if err != nil { - fmt.Fprintln(os.Stderr, err) - return 1 - } - return 0 - }, - - // append appends to a file with a leaading newline. - "append": func() int { - if len(os.Args) < 3 { - - fmt.Fprintln(os.Stderr, "usage: append FILE TEXT") - return 1 + fatalf("%v", err) } + mode := fs.FileMode(hdr.Mode) + fmt.Printf("%s %04o %s\n", mode, mode.Perm(), hdr.Name) + } + }, - filename := os.Args[1] - words := os.Args[2:] - for i, word := range words { - words[i] = strings.Trim(word, "\"") - } - text := strings.Join(words, " ") + // cpdir copies a file. + "cpfile": func() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: cpdir SRC DST") + os.Exit(1) + } + + fromFile := os.Args[1] + toFile := os.Args[2] + + if !filepath.IsAbs(fromFile) { + fromFile = filepath.Join(os.Getenv("SOURCE"), fromFile) + } + + if err := os.MkdirAll(filepath.Dir(toFile), 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + err := filehelpers.CopyFile(fromFile, toFile) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, - _, err := os.Stat(filename) - if err != nil { - if os.IsNotExist(err) { - fmt.Fprintln(os.Stderr, "file does not exist:", filename) - return 1 - } - fmt.Fprintln(os.Stderr, err) - return 1 - } + // cpdir copies a directory recursively. + "cpdir": func() { + if len(os.Args) != 3 { + fmt.Fprintln(os.Stderr, "usage: cpdir SRC DST") + os.Exit(1) + } + + fromDir := os.Args[1] + toDir := os.Args[2] + + if !filepath.IsAbs(fromDir) { + fromDir = filepath.Join(os.Getenv("SOURCE"), fromDir) + } + + err := filehelpers.CopyDir(fromDir, toDir, nil) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + }, - f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0o644) - if err != nil { - fmt.Fprintln(os.Stderr, "failed to open file:", filename) - return 1 - } - defer f.Close() + // append appends to a file with a leaading newline. + "append": func() { + if len(os.Args) < 3 { + + fmt.Fprintln(os.Stderr, "usage: append FILE TEXT") + os.Exit(1) + } + + filename := os.Args[1] + words := os.Args[2:] + for i, word := range words { + words[i] = strings.Trim(word, "\"") + } + text := strings.Join(words, " ") + + _, err := os.Stat(filename) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintln(os.Stderr, "file does not exist:", filename) + os.Exit(1) + } + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + f, err := os.OpenFile(filename, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to open file:", filename) + os.Exit(1) + } + defer f.Close() + + _, err = f.WriteString("\n" + text) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to write to file:", filename) + os.Exit(1) + } + }, - _, err = f.WriteString("\n" + text) + // Helpers. + "checkfile": func() { + // The built-in exists does not check for zero size files. + args := os.Args[1:] + var readonly, exec bool + loop: + for len(args) > 0 { + switch args[0] { + case "-readonly": + readonly = true + args = args[1:] + case "-exec": + exec = true + args = args[1:] + default: + break loop + } + } + if len(args) == 0 { + fatalf("usage: checkfile [-readonly] [-exec] file...") + } + + for _, filename := range args { + + fi, err := os.Stat(filename) if err != nil { - fmt.Fprintln(os.Stderr, "failed to write to file:", filename) - return 1 + fmt.Fprintf(os.Stderr, "stat %s: %v\n", filename, err) + os.Exit(1) } - - return 0 - }, - - // Helpers. - "checkfile": func() int { - // The built-in exists does not check for zero size files. - args := os.Args[1:] - var readonly, exec bool - loop: - for len(args) > 0 { - switch args[0] { - case "-readonly": - readonly = true - args = args[1:] - case "-exec": - exec = true - args = args[1:] - default: - break loop - } - } - if len(args) == 0 { - fatalf("usage: checkfile [-readonly] [-exec] file...") + if fi.Size() == 0 { + fmt.Fprintf(os.Stderr, "%s is empty\n", filename) + os.Exit(1) } - - for _, filename := range args { - - fi, err := os.Stat(filename) - if err != nil { - fmt.Fprintf(os.Stderr, "stat %s: %v\n", filename, err) - return -1 - } - if fi.Size() == 0 { - fmt.Fprintf(os.Stderr, "%s is empty\n", filename) - return -1 - } - if readonly && fi.Mode()&0o222 != 0 { - fmt.Fprintf(os.Stderr, "%s is writable\n", filename) - return -1 - } - if exec && runtime.GOOS != "windows" && fi.Mode()&0o111 == 0 { - fmt.Fprintf(os.Stderr, "%s is not executable\n", filename) - return -1 - } + if readonly && fi.Mode()&0o222 != 0 { + fmt.Fprintf(os.Stderr, "%s is writable\n", filename) + os.Exit(1) } - - return 0 - }, - "checkfilecount": func() int { - if len(os.Args) != 3 { - fatalf("usage: checkfilecount count dir") + if exec && runtime.GOOS != "windows" && fi.Mode()&0o111 == 0 { + fmt.Fprintf(os.Stderr, "%s is not executable\n", filename) + os.Exit(1) } - - count, err := strconv.Atoi(os.Args[1]) + } + }, + "checkfilecount": func() { + if len(os.Args) != 3 { + fatalf("usage: checkfilecount count dir") + } + + count, err := strconv.Atoi(os.Args[1]) + if err != nil { + fatalf("invalid count: %v", err) + } + if count < 0 { + fatalf("count must be non-negative") + } + dir := os.Args[2] + + found := 0 + + filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { - fatalf("invalid count: %v", err) - } - if count < 0 { - fatalf("count must be non-negative") + return err } - dir := os.Args[2] - - found := 0 - - filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - found++ + if d.IsDir() { return nil - }) - - if found != count { - fmt.Fprintf(os.Stderr, "found %d files, want %d\n", found, count) - return -1 - } - - return 0 - }, - "gobinary": func() int { - if runtime.GOOS == "windows" { - return 0 - } - if len(os.Args) < 3 { - fatalf("usage: gobinary binary args...") } + found++ + return nil + }) - filename := os.Args[1] - pattern := os.Args[2] - if !strings.HasPrefix(pattern, "(") { - // Multiline matching. - pattern = "(?s)" + pattern - } - re := regexp.MustCompile(pattern) - - cmd := exec.Command("go", "version", "-m", filename) - cmd.Stderr = os.Stderr - - b, err := cmd.Output() - if err != nil { - fmt.Fprintln(os.Stderr, err) - return -1 - } - - output := string(b) - - if !re.MatchString(output) { - fmt.Fprintf(os.Stderr, "expected %q to match %q\n", output, re) - return -1 - } - - return 0 - }, - }), - ) + if found != count { + fmt.Fprintf(os.Stderr, "found %d files, want %d\n", found, count) + os.Exit(1) + } + }, + "gobinary": func() { + if runtime.GOOS == "windows" { + } + if len(os.Args) < 3 { + fatalf("usage: gobinary binary args...") + } + + filename := os.Args[1] + pattern := os.Args[2] + if !strings.HasPrefix(pattern, "(") { + // Multiline matching. + pattern = "(?s)" + pattern + } + re := regexp.MustCompile(pattern) + + cmd := exec.Command("go", "version", "-m", filename) + cmd.Stderr = os.Stderr + + b, err := cmd.Output() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + output := string(b) + + if !re.MatchString(output) { + fmt.Fprintf(os.Stderr, "expected %q to match %q\n", output, re) + os.Exit(1) + } + }, + }) } func fatalf(format string, a ...any) { diff --git a/staticfiles/templates.go b/staticfiles/templates.go index fdaabfd..23f1386 100644 --- a/staticfiles/templates.go +++ b/staticfiles/templates.go @@ -11,10 +11,17 @@ var ( //go:embed templates/release-notes.gotmpl releaseNotesTemplContent []byte + //go:embed templates/homebrew-cask.rb.gotmpl + homebrewCaskTemplContent []byte + // ReleaseNotesTemplate is the template for the release notes. ReleaseNotesTemplate *template.Template + + // HomebrewCaskTemplate is the template for the Homebrew cask file. + HomebrewCaskTemplate *template.Template ) func init() { - ReleaseNotesTemplate = template.Must(template.New("release-notes").Parse(string(releaseNotesTemplContent))).Funcs(templ.BuiltInFuncs) + ReleaseNotesTemplate = template.Must(template.New("release-notes").Funcs(templ.BuiltInFuncs).Parse(string(releaseNotesTemplContent))) + HomebrewCaskTemplate = template.Must(template.New("homebrew-cask").Funcs(templ.BuiltInFuncs).Parse(string(homebrewCaskTemplContent))) } diff --git a/staticfiles/templates/homebrew-cask.rb.gotmpl b/staticfiles/templates/homebrew-cask.rb.gotmpl new file mode 100644 index 0000000..511e9e4 --- /dev/null +++ b/staticfiles/templates/homebrew-cask.rb.gotmpl @@ -0,0 +1,13 @@ +cask "{{ .Name }}" do + version "{{ .Version }}" + sha256 "{{ .SHA256 }}" + + url "{{ .URL }}" + name "{{ .DisplayName }}" + desc "{{ .Description }}" + homepage "{{ .Homepage }}" + + pkg "{{ .PkgFilename }}" + + uninstall pkgutil: "{{ .BundleIdentifier }}" +end diff --git a/testscripts/commands/all.txt b/testscripts/commands/all.txt index 34b9b0d..a7f212f 100644 --- a/testscripts/commands/all.txt +++ b/testscripts/commands/all.txt @@ -6,9 +6,14 @@ env GITHUB_TOKEN=faketoken hugoreleaser all -tag v1.2.0 -commitish main ! stderr . +# Release assertions stdout 'Prepared 2 files' stdout 'Uploading' +# Publish assertions +stdout 'GetReleaseByTag' +stdout 'PublishRelease' + # Test files -- hugoreleaser.yaml -- project: hugo @@ -45,6 +50,11 @@ releases: - paths: - archives/** path: myrelease +publishers: + - paths: + - releases/** + type: + format: github_release -- go.mod -- @@ -57,4 +67,4 @@ func main() { -- README.md -- This is readme. -- license.txt -- -This is license. \ No newline at end of file +This is license. diff --git a/testscripts/commands/publish.txt b/testscripts/commands/publish.txt new file mode 100644 index 0000000..9f2a803 --- /dev/null +++ b/testscripts/commands/publish.txt @@ -0,0 +1,67 @@ + +# faketoken is a magic string that will create a FakeClient. +env GITHUB_TOKEN=faketoken + +# Run publish command with pre-created release artifacts. +hugoreleaser publish -tag v1.2.0 +! stderr . + +# Check that it found and published the release +stdout 'GetReleaseByTag.*owner=bep.*repo=hugoreleaser.*tag=v1.2.0' +stdout 'PublishRelease' + +# Check that it updated the homebrew cask +stdout 'Found pkg archive.*hugo_1.2.0_darwin-universal.pkg' +stdout 'UpdateFileInRepo.*owner=bep.*repo=homebrew-tap.*path=Casks/hugo.rb' + +# Test files +-- hugoreleaser.yaml -- +project: hugo +release_settings: + type: github + repository: hugoreleaser + repository_owner: bep + draft: true +build_settings: + binary: hugo +archive_settings: + name_template: "{{ .Project }}_{{ .Tag | trimPrefix `v` }}_{{ .Goos }}-{{ .Goarch }}" + type: + format: tar.gz + extension: .pkg +builds: + - path: mac + os: + - goos: darwin + archs: + - goarch: universal +archives: + - paths: + - builds/mac/** +releases: + - paths: + - archives/** + path: myrelease +publishers: + - paths: + - releases/** + type: + format: github_release + - paths: + - releases/** + type: + format: homebrew_cask + custom_settings: + bundle_identifier: io.gohugo.hugo + +# Pre-created release artifacts (simulating what release command would create) +-- dist/hugo/v1.2.0/releases/myrelease/hugo_1.2.0_checksums.txt -- +abc123def456 hugo_1.2.0_darwin-universal.pkg + +-- go.mod -- +module foo +-- main.go -- +package main +func main() { + +}