diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go index c917c13c228..b351339b389 100644 --- a/internal/artifact/artifact.go +++ b/internal/artifact/artifact.go @@ -66,6 +66,8 @@ const ( ScoopManifest // SBOM is a Software Bill of Materials file. SBOM + // PublishableChocolatey is a chocolatey package yet to be published. + PublishableChocolatey // Header is a C header file, generated for CGo library builds. Header // CArchive is a C static library, generated via a CGo build with buildmode=c-archive. @@ -110,6 +112,8 @@ func (t Type) String() string { return "PKGBUILD" case SrcInfo: return "SRCINFO" + case PublishableChocolatey: + return "Chocolatey" case Header: return "C Header" case CArchive: diff --git a/internal/pipe/chocolatey/chocolatey.go b/internal/pipe/chocolatey/chocolatey.go new file mode 100644 index 00000000000..bf0a47cce15 --- /dev/null +++ b/internal/pipe/chocolatey/chocolatey.go @@ -0,0 +1,340 @@ +package chocolatey + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "text/template" + + "github.com/caarlos0/log" + "github.com/goreleaser/goreleaser/internal/artifact" + "github.com/goreleaser/goreleaser/internal/client" + "github.com/goreleaser/goreleaser/internal/pipe" + "github.com/goreleaser/goreleaser/internal/tmpl" + "github.com/goreleaser/goreleaser/pkg/config" + "github.com/goreleaser/goreleaser/pkg/context" +) + +// nuget package extension. +const nupkgFormat = "nupkg" + +// custom chocolatey config placed in artifact. +const chocoConfigExtra = "ChocolateyConfig" + +// cmd represents a command executor. +var cmd cmder = stdCmd{} + +// Pipe for chocolatey packaging. +type Pipe struct{} + +func (Pipe) String() string { return "chocolatey packages" } +func (Pipe) Skip(ctx *context.Context) bool { return len(ctx.Config.Chocolateys) == 0 } + +// Default sets the pipe defaults. +func (Pipe) Default(ctx *context.Context) error { + for i := range ctx.Config.Chocolateys { + choco := &ctx.Config.Chocolateys[i] + + if choco.Name == "" { + choco.Name = ctx.Config.ProjectName + } + + if choco.Title == "" { + choco.Title = ctx.Config.ProjectName + } + + if choco.Goamd64 == "" { + choco.Goamd64 = "v1" + } + + if choco.SourceRepo == "" { + choco.SourceRepo = "https://push.chocolatey.org/" + } + } + + return nil +} + +// Run the pipe. +func (Pipe) Run(ctx *context.Context) error { + client, err := client.New(ctx) + if err != nil { + return err + } + + for _, choco := range ctx.Config.Chocolateys { + if err := doRun(ctx, client, choco); err != nil { + return err + } + } + + return nil +} + +// Publish packages. +func (Pipe) Publish(ctx *context.Context) error { + if ctx.SkipPublish { + return pipe.ErrSkipPublishEnabled + } + + artifacts := ctx.Artifacts.Filter( + artifact.ByType(artifact.PublishableChocolatey), + ).List() + + for _, artifact := range artifacts { + if err := doPush(ctx, artifact); err != nil { + return err + } + } + + return nil +} + +func doRun(ctx *context.Context, cl client.Client, choco config.Chocolatey) error { + filters := []artifact.Filter{ + artifact.ByGoos("windows"), + artifact.ByType(artifact.UploadableArchive), + artifact.Or( + artifact.And( + artifact.ByGoarch("amd64"), + artifact.ByGoamd64(choco.Goamd64), + ), + artifact.ByGoarch("386"), + ), + } + + if len(choco.IDs) > 0 { + filters = append(filters, artifact.ByIDs(choco.IDs...)) + } + + artifacts := ctx.Artifacts. + Filter(artifact.And(filters...)). + List() + + if len(artifacts) == 0 { + return errors.New("chocolatey requires a windows build and archive") + } + + // folderDir is the directory that then will be compressed to make the + // chocolatey package. + folderPath := filepath.Join(ctx.Config.Dist, choco.Name+".choco") + toolsPath := filepath.Join(folderPath, "tools") + if err := os.MkdirAll(toolsPath, 0o755); err != nil { + return err + } + + nuspecFile := filepath.Join(folderPath, choco.Name+".nuspec") + nuspec, err := buildNuspec(ctx, choco) + if err != nil { + return err + } + + if err = os.WriteFile(nuspecFile, nuspec, 0o644); err != nil { + return err + } + + data, err := dataFor(ctx, cl, choco, artifacts) + if err != nil { + return err + } + + script, err := buildTemplate(choco.Name, scriptTemplate, data) + if err != nil { + return err + } + + scriptFile := filepath.Join(toolsPath, "chocolateyinstall.ps1") + log.WithField("file", scriptFile).Debug("creating") + if err = os.WriteFile(scriptFile, script, 0o644); err != nil { + return err + } + + log.WithField("nuspec", nuspecFile).Info("packing") + out, err := cmd.Exec(ctx, "choco", "pack", nuspecFile, "--out", ctx.Config.Dist) + if err != nil { + return fmt.Errorf("failed to generate chocolatey package: %w: %s", err, string(out)) + } + + if choco.SkipPublish { + return nil + } + + pkgFile := fmt.Sprintf("%s.%s.%s", choco.Name, ctx.Version, nupkgFormat) + + ctx.Artifacts.Add(&artifact.Artifact{ + Type: artifact.PublishableChocolatey, + Path: filepath.Join(ctx.Config.Dist, pkgFile), + Name: pkgFile, + Extra: map[string]interface{}{ + artifact.ExtraFormat: nupkgFormat, + chocoConfigExtra: choco, + }, + }) + + return nil +} + +func doPush(ctx *context.Context, art *artifact.Artifact) error { + choco, err := artifact.Extra[config.Chocolatey](*art, chocoConfigExtra) + if err != nil { + return err + } + + key, err := tmpl.New(ctx).Apply(choco.APIKey) + if err != nil { + return err + } + + log := log.WithField("name", choco.Name) + if key == "" { + log.Warn("skip pushing: no api key") + return nil + } + + log.Info("pushing package") + + args := []string{ + "push", + "--source", + choco.SourceRepo, + "--api-key", + key, + art.Path, + } + + if out, err := cmd.Exec(ctx, "choco", args...); err != nil { + return fmt.Errorf("failed to push chocolatey package: %w: %s", err, string(out)) + } + + log.Info("package sent") + + return nil +} + +func buildNuspec(ctx *context.Context, choco config.Chocolatey) ([]byte, error) { + tpl := tmpl.New(ctx) + summary, err := tpl.Apply(choco.Summary) + if err != nil { + return nil, err + } + + description, err := tpl.Apply(choco.Description) + if err != nil { + return nil, err + } + + releaseNotes, err := tpl.Apply(choco.ReleaseNotes) + if err != nil { + return nil, err + } + + m := &Nuspec{ + Xmlns: schema, + Metadata: Metadata{ + ID: choco.Name, + Version: ctx.Version, + PackageSourceURL: choco.PackageSourceURL, + Owners: choco.Owners, + Title: choco.Title, + Authors: choco.Authors, + ProjectURL: choco.ProjectURL, + IconURL: choco.IconURL, + Copyright: choco.Copyright, + LicenseURL: choco.LicenseURL, + RequireLicenseAcceptance: choco.RequireLicenseAcceptance, + ProjectSourceURL: choco.ProjectSourceURL, + DocsURL: choco.DocsURL, + BugTrackerURL: choco.BugTrackerURL, + Tags: choco.Tags, + Summary: summary, + Description: description, + ReleaseNotes: releaseNotes, + }, + Files: Files{File: []File{ + {Source: "tools\\**", Target: "tools"}, + }}, + } + + deps := make([]Dependency, len(choco.Dependencies)) + for i, dep := range choco.Dependencies { + deps[i] = Dependency{ID: dep.ID, Version: dep.Version} + } + + if len(deps) > 0 { + m.Metadata.Dependencies = &Dependencies{Dependency: deps} + } + + return m.Bytes() +} + +func buildTemplate(name string, text string, data templateData) ([]byte, error) { + tp, err := template.New(name).Parse(text) + if err != nil { + return nil, err + } + + var out bytes.Buffer + if err = tp.Execute(&out, data); err != nil { + return nil, err + } + + return out.Bytes(), nil +} + +func dataFor(ctx *context.Context, cl client.Client, choco config.Chocolatey, artifacts []*artifact.Artifact) (templateData, error) { + result := templateData{} + + if choco.URLTemplate == "" { + url, err := cl.ReleaseURLTemplate(ctx) + if err != nil { + return result, err + } + + choco.URLTemplate = url + } + + for _, artifact := range artifacts { + sum, err := artifact.Checksum("sha256") + if err != nil { + return result, err + } + + url, err := tmpl.New(ctx). + WithArtifact(artifact, map[string]string{}). + Apply(choco.URLTemplate) + if err != nil { + return result, err + } + + pkg := releasePackage{ + DownloadURL: url, + Checksum: sum, + Arch: artifact.Goarch, + } + + result.Packages = append(result.Packages, pkg) + } + + return result, nil +} + +// cmder is a special interface to execute external commands. +// +// The intention is to be used to wrap the standard exec and provide the +// ability to create a fake one for testing. +type cmder interface { + // Exec executes an command. + Exec(*context.Context, string, ...string) ([]byte, error) +} + +// stdCmd uses the standard golang exec. +type stdCmd struct{} + +var _ cmder = &stdCmd{} + +func (stdCmd) Exec(ctx *context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} diff --git a/internal/pipe/chocolatey/chocolatey_test.go b/internal/pipe/chocolatey/chocolatey_test.go new file mode 100644 index 00000000000..c89f0049757 --- /dev/null +++ b/internal/pipe/chocolatey/chocolatey_test.go @@ -0,0 +1,330 @@ +package chocolatey + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/goreleaser/goreleaser/internal/artifact" + "github.com/goreleaser/goreleaser/internal/client" + "github.com/goreleaser/goreleaser/internal/golden" + "github.com/goreleaser/goreleaser/internal/testlib" + "github.com/goreleaser/goreleaser/pkg/config" + "github.com/goreleaser/goreleaser/pkg/context" + "github.com/stretchr/testify/require" +) + +func TestDescription(t *testing.T) { + require.NotEmpty(t, Pipe{}.String()) +} + +func TestSkip(t *testing.T) { + ctx := context.New(config.Project{}) + require.True(t, Pipe{}.Skip(ctx)) +} + +func TestDefault(t *testing.T) { + testlib.Mktmp(t) + + ctx := &context.Context{ + TokenType: context.TokenTypeGitHub, + Config: config.Project{ + ProjectName: "myproject", + Chocolateys: []config.Chocolatey{ + {}, + }, + }, + } + + require.NoError(t, Pipe{}.Default(ctx)) + require.Equal(t, ctx.Config.ProjectName, ctx.Config.Chocolateys[0].Name) + require.Equal(t, ctx.Config.ProjectName, ctx.Config.Chocolateys[0].Title) + require.Equal(t, "v1", ctx.Config.Chocolateys[0].Goamd64) +} + +func Test_doRun(t *testing.T) { + folder := t.TempDir() + file := filepath.Join(folder, "archive") + require.NoError(t, os.WriteFile(file, []byte("lorem ipsum"), 0o644)) + + tests := []struct { + name string + choco config.Chocolatey + exec func() ([]byte, error) + published int + err string + }{ + { + name: "no artifacts", + choco: config.Chocolatey{ + Name: "app", + IDs: []string{"no-app"}, + Goamd64: "v1", + }, + err: "chocolatey requires a windows build and archive", + }, + { + name: "choco command not found", + choco: config.Chocolatey{ + Name: "app", + Goamd64: "v1", + }, + exec: func() ([]byte, error) { + return nil, errors.New(`exec: "choco.exe": executable file not found in $PATH`) + }, + err: `failed to generate chocolatey package: exec: "choco.exe": executable file not found in $PATH: `, + }, + { + name: "skip publish", + choco: config.Chocolatey{ + Name: "app", + Goamd64: "v1", + SkipPublish: true, + }, + exec: func() ([]byte, error) { + return []byte("success"), nil + }, + }, + { + name: "success", + choco: config.Chocolatey{ + Name: "app", + Goamd64: "v1", + }, + exec: func() ([]byte, error) { + return []byte("success"), nil + }, + published: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd = fakeCmd{execFn: tt.exec} + t.Cleanup(func() { + cmd = stdCmd{} + }) + + ctx := &context.Context{ + Git: context.GitInfo{ + CurrentTag: "v1.0.1", + }, + Version: "1.0.1", + Artifacts: artifact.New(), + Config: config.Project{ + Dist: folder, + ProjectName: "run-all", + }, + } + + ctx.Artifacts.Add(&artifact.Artifact{ + Name: "app_1.0.1_windows_amd64.zip", + Path: file, + Goos: "windows", + Goarch: "amd64", + Goamd64: "v1", + Type: artifact.UploadableArchive, + Extra: map[string]interface{}{ + artifact.ExtraID: "app", + artifact.ExtraFormat: "zip", + }, + }) + + client := client.NewMock() + got := doRun(ctx, client, tt.choco) + + var err string + if got != nil { + err = got.Error() + } + if tt.err != err { + t.Errorf("Unexpected error: %s (expected %s)", err, tt.err) + } + + list := ctx.Artifacts.Filter(artifact.ByType(artifact.PublishableChocolatey)).List() + require.Len(t, list, tt.published) + }) + } +} + +func Test_buildNuspec(t *testing.T) { + ctx := &context.Context{ + Version: "1.12.3", + } + choco := config.Chocolatey{ + Name: "goreleaser", + IDs: []string{}, + Title: "GoReleaser", + Authors: "caarlos0", + ProjectURL: "https://goreleaser.com/", + Tags: "go docker homebrew golang package", + Summary: "Deliver Go binaries as fast and easily as possible", + Description: "GoReleaser builds Go binaries for several platforms, creates a GitHub release and then pushes a Homebrew formula to a tap repository. All that wrapped in your favorite CI.", + Dependencies: []config.ChocolateyDependency{ + {ID: "nfpm"}, + }, + } + + out, err := buildNuspec(ctx, choco) + require.NoError(t, err) + + golden.RequireEqualExt(t, out, ".nuspec") +} + +func Test_buildTemplate(t *testing.T) { + folder := t.TempDir() + file := filepath.Join(folder, "archive") + require.NoError(t, os.WriteFile(file, []byte("lorem ipsum"), 0o644)) + + ctx := &context.Context{ + Version: "1.0.0", + Git: context.GitInfo{ + CurrentTag: "v1.0.0", + }, + } + + artifacts := []*artifact.Artifact{ + { + Name: "app_1.0.0_windows_386.zip", + Goos: "windows", + Goarch: "386", + Goamd64: "v1", + Path: file, + }, + { + Name: "app_1.0.0_windows_amd64.zip", + Goos: "windows", + Goarch: "amd64", + Goamd64: "v1", + Path: file, + }, + } + + choco := config.Chocolatey{ + Name: "app", + } + + client := client.NewMock() + + data, err := dataFor(ctx, client, choco, artifacts) + if err != nil { + t.Error(err) + } + + out, err := buildTemplate(choco.Name, scriptTemplate, data) + require.NoError(t, err) + + golden.RequireEqualExt(t, out, ".script.ps1") +} + +func TestPublish(t *testing.T) { + folder := t.TempDir() + file := filepath.Join(folder, "archive") + require.NoError(t, os.WriteFile(file, []byte("lorem ipsum"), 0o644)) + + tests := []struct { + name string + artifacts []artifact.Artifact + exec func() ([]byte, error) + skip bool + err string + }{ + { + name: "skip publish", + skip: true, + err: "publishing is disabled", + }, + { + name: "no artifacts", + }, + { + name: "no api key", + artifacts: []artifact.Artifact{ + { + Type: artifact.PublishableChocolatey, + Name: "app.1.0.1.nupkg", + Extra: map[string]interface{}{ + artifact.ExtraFormat: nupkgFormat, + chocoConfigExtra: config.Chocolatey{}, + }, + }, + }, + }, + { + name: "push error", + artifacts: []artifact.Artifact{ + { + Type: artifact.PublishableChocolatey, + Name: "app.1.0.1.nupkg", + Extra: map[string]interface{}{ + artifact.ExtraFormat: nupkgFormat, + chocoConfigExtra: config.Chocolatey{ + APIKey: "abcd", + }, + }, + }, + }, + exec: func() ([]byte, error) { + return nil, errors.New(`unable to push`) + }, + err: "failed to push chocolatey package: unable to push: ", + }, + { + name: "success", + artifacts: []artifact.Artifact{ + { + Type: artifact.PublishableChocolatey, + Name: "app.1.0.1.nupkg", + Extra: map[string]interface{}{ + artifact.ExtraFormat: nupkgFormat, + chocoConfigExtra: config.Chocolatey{ + APIKey: "abcd", + }, + }, + }, + }, + exec: func() ([]byte, error) { + return []byte("success"), nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd = fakeCmd{execFn: tt.exec} + t.Cleanup(func() { + cmd = stdCmd{} + }) + + ctx := &context.Context{ + SkipPublish: tt.skip, + Artifacts: artifact.New(), + } + + for _, artifact := range tt.artifacts { + ctx.Artifacts.Add(&artifact) + } + + got := Pipe{}.Publish(ctx) + + var err string + if got != nil { + err = got.Error() + } + if tt.err != err { + t.Errorf("Unexpected error: %s (expected %s)", err, tt.err) + } + }) + } +} + +type fakeCmd struct { + execFn func() ([]byte, error) +} + +var _ cmder = fakeCmd{} + +func (f fakeCmd) Exec(ctx *context.Context, name string, args ...string) ([]byte, error) { + return f.execFn() +} diff --git a/internal/pipe/chocolatey/nuspec.go b/internal/pipe/chocolatey/nuspec.go new file mode 100644 index 00000000000..85d6637ac78 --- /dev/null +++ b/internal/pipe/chocolatey/nuspec.go @@ -0,0 +1,87 @@ +package chocolatey + +import ( + "bytes" + "encoding/xml" + "strings" +) + +const schema = "http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd" + +// Nuspec represents a Nuget/Chocolatey Nuspec. +// More info: https://learn.microsoft.com/en-us/nuget/reference/nuspec +// https://docs.chocolatey.org/en-us/create/create-packages +type Nuspec struct { + XMLName xml.Name `xml:"package"` + Xmlns string `xml:"xmlns,attr,omitempty"` + Metadata Metadata `xml:"metadata"` + Files Files `xml:"files,omitempty"` +} + +// Metadata contains information about a single package. +type Metadata struct { + ID string `xml:"id"` + Version string `xml:"version"` + PackageSourceURL string `xml:"packageSourceUrl,omitempty"` + Owners string `xml:"owners,omitempty"` + Title string `xml:"title,omitempty"` + Authors string `xml:"authors"` + ProjectURL string `xml:"projectUrl,omitempty"` + IconURL string `xml:"iconUrl,omitempty"` + Copyright string `xml:"copyright,omitempty"` + LicenseURL string `xml:"licenseUrl,omitempty"` + RequireLicenseAcceptance bool `xml:"requireLicenseAcceptance"` + ProjectSourceURL string `xml:"projectSourceUrl,omitempty"` + DocsURL string `xml:"docsUrl,omitempty"` + BugTrackerURL string `xml:"bugTrackerUrl,omitempty"` + Tags string `xml:"tags,omitempty"` + Summary string `xml:"summary,omitempty"` + Description string `xml:"description"` + ReleaseNotes string `xml:"releaseNotes,omitempty"` + Dependencies *Dependencies `xml:"dependencies,omitempty"` +} + +// Dependency represents a dependency element. +type Dependency struct { + ID string `xml:"id,attr"` + Version string `xml:"version,attr,omitempty"` +} + +// Dependencies represents a collection zero or more dependency elements. +type Dependencies struct { + Dependency []Dependency `xml:"dependency"` +} + +// File represents a file to be copied. +type File struct { + Source string `xml:"src,attr"` + Target string `xml:"target,attr,omitempty"` +} + +// Files represents files that will be copied during packaging. +type Files struct { + File []File `xml:"file"` +} + +// Bytes marshals the Nuspec into XML format and return as []byte. +func (m *Nuspec) Bytes() ([]byte, error) { + b := &bytes.Buffer{} + b.WriteString(strings.ToLower(xml.Header)) + + enc := xml.NewEncoder(b) + enc.Indent("", " ") + + if err := enc.Encode(m); err != nil { + return nil, err + } + + out := b.Bytes() + + // Follows the nuget specification of self-closing xml tags. + tags := []string{"dependency", "file"} + for _, tag := range tags { + out = bytes.ReplaceAll(out, []byte(">"), []byte(" />")) + } + + return out, nil +} diff --git a/internal/pipe/chocolatey/nuspec_test.go b/internal/pipe/chocolatey/nuspec_test.go new file mode 100644 index 00000000000..3d51e0e1b57 --- /dev/null +++ b/internal/pipe/chocolatey/nuspec_test.go @@ -0,0 +1,45 @@ +package chocolatey + +import ( + "testing" + + "github.com/goreleaser/goreleaser/internal/golden" + "github.com/stretchr/testify/require" +) + +func TestNuspecBytes(t *testing.T) { + m := &Nuspec{ + Xmlns: schema, + Metadata: Metadata{ + ID: "goreleaser", + Version: "1.12.3", + PackageSourceURL: "https://github.com/goreleaser/goreleaser", + Owners: "caarlos0", + Title: "GoReleaser", + Authors: "caarlos0", + ProjectURL: "https://goreleaser.com/", + IconURL: "https://raw.githubusercontent.com/goreleaser/goreleaser/main/www/docs/static/avatar.png", + Copyright: "2016-2022 Carlos Alexandro Becker", + LicenseURL: "https://github.com/goreleaser/goreleaser/blob/main/LICENSE.md", + RequireLicenseAcceptance: true, + ProjectSourceURL: "https://github.com/goreleaser/goreleaser", + DocsURL: "https://github.com/goreleaser/goreleaser/blob/main/README.md", + BugTrackerURL: "https://github.com/goreleaser/goreleaser/issues", + Tags: "go docker homebrew golang package", + Summary: "Deliver Go binaries as fast and easily as possible", + Description: "GoReleaser builds Go binaries for several platforms, creates a GitHub release and then pushes a Homebrew formula to a tap repository. All that wrapped in your favorite CI.", + ReleaseNotes: "This tag is only to keep version parity with the pro version, which does have a couple of bugfixes.", + Dependencies: &Dependencies{Dependency: []Dependency{ + {ID: "nfpm", Version: "2.20.0"}, + }}, + }, + Files: Files{File: []File{ + {Source: "tools\\**", Target: "tools"}, + }}, + } + + out, err := m.Bytes() + require.NoError(t, err) + + golden.RequireEqualExt(t, out, ".nuspec") +} diff --git a/internal/pipe/chocolatey/template.go b/internal/pipe/chocolatey/template.go new file mode 100644 index 00000000000..252950f1cbc --- /dev/null +++ b/internal/pipe/chocolatey/template.go @@ -0,0 +1,38 @@ +package chocolatey + +type templateData struct { + Packages []releasePackage +} + +type releasePackage struct { + DownloadURL string + Checksum string + Arch string +} + +const scriptTemplate = `# This file was generated by GoReleaser. DO NOT EDIT. +$ErrorActionPreference = 'Stop'; + +$version = $env:chocolateyPackageVersion +$packageName = $env:chocolateyPackageName +$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" + +$packageArgs = @{ + packageName = $packageName + unzipLocation = $toolsDir + fileType = 'exe' + {{- range $release := .Packages }} + {{- if eq $release.Arch "amd64" }} + url64bit = '{{ $release.DownloadURL }}' + checksum64 = '{{ $release.Checksum }}' + checksumType64 = 'sha256' + {{- else }} + url = '{{ $release.DownloadURL }}' + checksum = '{{ $release.Checksum }}' + checksumType = 'sha256' + {{- end }} + {{- end }} +} + +Install-ChocolateyZipPackage @packageArgs +` diff --git a/internal/pipe/chocolatey/testdata/TestNuspecBytes.nuspec.golden b/internal/pipe/chocolatey/testdata/TestNuspecBytes.nuspec.golden new file mode 100644 index 00000000000..02968097568 --- /dev/null +++ b/internal/pipe/chocolatey/testdata/TestNuspecBytes.nuspec.golden @@ -0,0 +1,29 @@ + + + + goreleaser + 1.12.3 + https://github.com/goreleaser/goreleaser + caarlos0 + GoReleaser + caarlos0 + https://goreleaser.com/ + https://raw.githubusercontent.com/goreleaser/goreleaser/main/www/docs/static/avatar.png + 2016-2022 Carlos Alexandro Becker + https://github.com/goreleaser/goreleaser/blob/main/LICENSE.md + true + https://github.com/goreleaser/goreleaser + https://github.com/goreleaser/goreleaser/blob/main/README.md + https://github.com/goreleaser/goreleaser/issues + go docker homebrew golang package + Deliver Go binaries as fast and easily as possible + GoReleaser builds Go binaries for several platforms, creates a GitHub release and then pushes a Homebrew formula to a tap repository. All that wrapped in your favorite CI. + This tag is only to keep version parity with the pro version, which does have a couple of bugfixes. + + + + + + + + \ No newline at end of file diff --git a/internal/pipe/chocolatey/testdata/Test_buildNuspec.nuspec.golden b/internal/pipe/chocolatey/testdata/Test_buildNuspec.nuspec.golden new file mode 100644 index 00000000000..7601fef7ef6 --- /dev/null +++ b/internal/pipe/chocolatey/testdata/Test_buildNuspec.nuspec.golden @@ -0,0 +1,20 @@ + + + + goreleaser + 1.12.3 + GoReleaser + caarlos0 + https://goreleaser.com/ + false + go docker homebrew golang package + Deliver Go binaries as fast and easily as possible + GoReleaser builds Go binaries for several platforms, creates a GitHub release and then pushes a Homebrew formula to a tap repository. All that wrapped in your favorite CI. + + + + + + + + \ No newline at end of file diff --git a/internal/pipe/chocolatey/testdata/Test_buildTemplate.script.ps1.golden b/internal/pipe/chocolatey/testdata/Test_buildTemplate.script.ps1.golden new file mode 100644 index 00000000000..779da005c22 --- /dev/null +++ b/internal/pipe/chocolatey/testdata/Test_buildTemplate.script.ps1.golden @@ -0,0 +1,20 @@ +# This file was generated by GoReleaser. DO NOT EDIT. +$ErrorActionPreference = 'Stop'; + +$version = $env:chocolateyPackageVersion +$packageName = $env:chocolateyPackageName +$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)" + +$packageArgs = @{ + packageName = $packageName + unzipLocation = $toolsDir + fileType = 'exe' + url = 'https://dummyhost/download/v1.0.0/app_1.0.0_windows_386.zip' + checksum = '5e2bf57d3f40c4b6df69daf1936cb766f832374b4fc0259a7cbff06e2f70f269' + checksumType = 'sha256' + url64bit = 'https://dummyhost/download/v1.0.0/app_1.0.0_windows_amd64.zip' + checksum64 = '5e2bf57d3f40c4b6df69daf1936cb766f832374b4fc0259a7cbff06e2f70f269' + checksumType64 = 'sha256' +} + +Install-ChocolateyZipPackage @packageArgs diff --git a/internal/pipe/publish/publish.go b/internal/pipe/publish/publish.go index cb4c288cb10..cb4c0a9a0c4 100644 --- a/internal/pipe/publish/publish.go +++ b/internal/pipe/publish/publish.go @@ -11,6 +11,7 @@ import ( "github.com/goreleaser/goreleaser/internal/pipe/aur" "github.com/goreleaser/goreleaser/internal/pipe/blob" "github.com/goreleaser/goreleaser/internal/pipe/brew" + "github.com/goreleaser/goreleaser/internal/pipe/chocolatey" "github.com/goreleaser/goreleaser/internal/pipe/custompublishers" "github.com/goreleaser/goreleaser/internal/pipe/docker" "github.com/goreleaser/goreleaser/internal/pipe/krew" @@ -48,6 +49,7 @@ var publishers = []Publisher{ aur.Pipe{}, krew.Pipe{}, scoop.Pipe{}, + chocolatey.Pipe{}, milestone.Pipe{}, } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 9d5dd684368..10f61ed4721 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -12,6 +12,7 @@ import ( "github.com/goreleaser/goreleaser/internal/pipe/build" "github.com/goreleaser/goreleaser/internal/pipe/changelog" "github.com/goreleaser/goreleaser/internal/pipe/checksums" + "github.com/goreleaser/goreleaser/internal/pipe/chocolatey" "github.com/goreleaser/goreleaser/internal/pipe/defaults" "github.com/goreleaser/goreleaser/internal/pipe/dist" "github.com/goreleaser/goreleaser/internal/pipe/docker" @@ -106,6 +107,8 @@ var Pipeline = append( krew.Pipe{}, // create scoop buckets scoop.Pipe{}, + // create chocolatey pkg and publish + chocolatey.Pipe{}, // create and push docker images docker.Pipe{}, // creates a metadata.json and an artifacts.json files in the dist folder diff --git a/pkg/config/config.go b/pkg/config/config.go index 29a803b9f56..56895435a4d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -930,6 +930,7 @@ type Project struct { GoMod GoMod `yaml:"gomod,omitempty" json:"gomod,omitempty"` Announce Announce `yaml:"announce,omitempty" json:"announce,omitempty"` SBOMs []SBOM `yaml:"sboms,omitempty" json:"sboms,omitempty"` + Chocolateys []Chocolatey `yaml:"chocolateys,omitempty" json:"chocolatey,omitempty"` UniversalBinaries []UniversalBinary `yaml:"universal_binaries,omitempty" json:"universal_binaries,omitempty"` @@ -1116,3 +1117,37 @@ func (a *SlackAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error func (a SlackAttachment) MarshalJSON() ([]byte, error) { return json.Marshal(a.Internal) } + +// Chocolatey contains the chocolatey section. +type Chocolatey struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + IDs []string `yaml:"ids,omitempty" json:"ids,omitempty"` + PackageSourceURL string `yaml:"package_source_url,omitempty" json:"package_source_url,omitempty"` + Owners string `yaml:"owners,omitempty" json:"authoers,omitempty"` + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Authors string `yaml:"authors,omitempty" json:"authors,omitempty"` + ProjectURL string `yaml:"project_url,omitempty" json:"project_url,omitempty"` + URLTemplate string `yaml:"url_template,omitempty" json:"url_template,omitempty"` + IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"` + Copyright string `yaml:"copyright,omitempty" json:"copyright,omitempty"` + LicenseURL string `yaml:"license_url,omitempty" json:"license_url,omitempty"` + RequireLicenseAcceptance bool `yaml:"require_license_acceptance,omitempty" json:"require_license_acceptance,omitempty"` + ProjectSourceURL string `yaml:"project_source_url,omitempty" json:"project_source_url,omitempty"` + DocsURL string `yaml:"docs_url,omitempty" json:"docs_url,omitempty"` + BugTrackerURL string `yaml:"bug_tracker_url,omitempty" json:"bug_tracker_url,omitempty"` + Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` + Summary string `yaml:"summary,omitempty" json:"summary,omitempty"` + Description string `yaml:"description,omitempty" json:"description,omitempty"` + ReleaseNotes string `yaml:"release_notes,omitempty" json:"release_notes,omitempty"` + Dependencies []ChocolateyDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"` + SkipPublish bool `yaml:"skip_publish,omitempty" json:"skip_publish,omitempty"` + APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` + SourceRepo string `yaml:"source_repo,omitempty" json:"source_repo,omitempty"` + Goamd64 string `yaml:"goamd64,omitempty" json:"goamd64,omitempty"` +} + +// ChcolateyDependency represents Chocolatey dependency. +type ChocolateyDependency struct { + ID string `yaml:"id,omitempty" json:"id,omitempty"` + Version string `yaml:"version,omitempty" json:"version,omitempty"` +} diff --git a/pkg/defaults/defaults.go b/pkg/defaults/defaults.go index bdb625e12ae..321f6420522 100644 --- a/pkg/defaults/defaults.go +++ b/pkg/defaults/defaults.go @@ -12,6 +12,7 @@ import ( "github.com/goreleaser/goreleaser/internal/pipe/brew" "github.com/goreleaser/goreleaser/internal/pipe/build" "github.com/goreleaser/goreleaser/internal/pipe/checksums" + "github.com/goreleaser/goreleaser/internal/pipe/chocolatey" "github.com/goreleaser/goreleaser/internal/pipe/discord" "github.com/goreleaser/goreleaser/internal/pipe/docker" "github.com/goreleaser/goreleaser/internal/pipe/gomod" @@ -84,4 +85,5 @@ var Defaulters = []Defaulter{ linkedin.Pipe{}, telegram.Pipe{}, webhook.Pipe{}, + chocolatey.Pipe{}, } diff --git a/www/docs/customization/chocolatey.md b/www/docs/customization/chocolatey.md new file mode 100644 index 00000000000..5ea9ff95c2d --- /dev/null +++ b/www/docs/customization/chocolatey.md @@ -0,0 +1,133 @@ +# Chocolatey Packages + +GoReleaser can also generate `nupkg` packages. +[Chocolatey](http://chocolatey.org/) are packages based on `nupkg` format, that +will let you publish your project directly to the Chocolatey Repository. From +there it will be able to install locally or in Windows distributions. + +You can read more about it in the [chocolatey docs](https://docs.chocolatey.org/). + +Available options: + +```yaml +# .goreleaser.yaml +chocolateys: + - + # Your app's package name. + # The value may not contain spaces or character that are not valid for a URL. + # If you want a good separator for words, use '-', not '.'. + # + # Defaults to `ProjectName`. + name: foo + + # IDs of the archives to use. + # Defaults to empty, which includes all artifacts. + ids: + - foo + - bar + + # Your app's owner. + # It basically means your. + # Defaults empty. + owners: Drum Roll Inc + + # The app's title. + # A human-friendly title of the package. + # Defaults to `ProjectName`. + title: Foo Bar + + # Your app's authors (probably you). + # Defaults are shown below. + authors: Drummer + + # Your app's project url. + # It is a required field. + project_url: https://example.com/ + + # Template for the url which is determined by the given Token (github, + # gitlab or gitea) + # Default depends on the client. + url_template: "https://github.com/foo/bar/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + + # App's icon. + # Default is empty. + icon_url: 'https://rawcdn.githack.com/foo/bar/efbdc760-395b-43f1-bf69-ba25c374d473/icon.png' + + # Your app's copyright details. + # Default is empty. + copyright: 2022 Drummer Roll Inc + + # App's license information url. + license_url: https://github.com/foo/bar/blob/main/LICENSE + + # Your apps's require license acceptance: + # Specify whether the client must prompt the consumer to accept the package + # license before installing. + # Default is false. + require_license_acceptance: false + + # Your app's source url. + # Default is empty. + project_source_url: https://github.com/foo/bar + + # Your app's documentation url. + # Default is empty. + docs_url: https://github.com/foo/bar/blob/main/README.md + + # App's bugtracker url. + # Default is empty. + bug_tracker_url: https://github.com/foo/barr/issues + + # Your app's tag list. + # Default is empty. + tags: "foo bar baz" + + # Your app's summary: + summary: Software to create fast and easy drum rolls. + + # This the description of your chocolatey package. + # Supports markdown. + description: | + {{ .ProjectName }} installer package. + Software to create fast and easy drum rolls. + + # Your app's release notes. + # A description of the changes made in this release of the package. + # Supports markdown. To prevent the need to continually update this field, + # providing a URL to an external list of Release Notes is perfectly + # acceptable. + # Default is empty. + release_notes: "https://github.com/foo/bar/releases/tag/v{{ .Version }}" + + # App's dependencies + # Default is empty. Version is not required. + dependencies: + - id: nfpm + version: 2.20.0 + + # The api key that should be used to push to the chocolatey repository. + # + # WARNING: do not expose your api key in the configuration file! + api_key: '{{ .Env.CHOCOLATEY_API_KEY }}' + + # The source repository that will push the package to. + # + # Defaults are shown below. + source_repo: "https://push.chocolatey.org/" + + # Setting this will prevent goreleaser to actually try to push the package + # to chocolatey repository, leaving the responsability of publishing it to + # the user. + skip_publish: false + + # GOAMD64 to specify which amd64 version to use if there are multiple + # versions from the build section. + # Default is v1. + goamd64: v1 +``` + +!!! tip + Learn more about the [name template engine](/customization/templates/). + +!!! note + GoReleaser will not install `chocolatey` nor any of its dependencies for you. diff --git a/www/docs/static/schema.json b/www/docs/static/schema.json index 277295cbaac..fac2bfe888f 100644 --- a/www/docs/static/schema.json +++ b/www/docs/static/schema.json @@ -496,6 +496,102 @@ "additionalProperties": false, "type": "object" }, + "Chocolatey": { + "properties": { + "name": { + "type": "string" + }, + "ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "package_source_url": { + "type": "string" + }, + "authoers": { + "type": "string" + }, + "title": { + "type": "string" + }, + "authors": { + "type": "string" + }, + "project_url": { + "type": "string" + }, + "url_template": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "copyright": { + "type": "string" + }, + "license_url": { + "type": "string" + }, + "require_license_acceptance": { + "type": "boolean" + }, + "project_source_url": { + "type": "string" + }, + "docs_url": { + "type": "string" + }, + "bug_tracker_url": { + "type": "string" + }, + "tags": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "release_notes": { + "type": "string" + }, + "dependencies": { + "items": { + "$ref": "#/$defs/ChocolateyDependency" + }, + "type": "array" + }, + "skip_publish": { + "type": "boolean" + }, + "api_key": { + "type": "string" + }, + "source_repo": { + "type": "string" + }, + "goamd64": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, + "ChocolateyDependency": { + "properties": { + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false, + "type": "object" + }, "CommitAuthor": { "properties": { "name": { @@ -1747,6 +1843,12 @@ }, "type": "array" }, + "chocolatey": { + "items": { + "$ref": "#/$defs/Chocolatey" + }, + "type": "array" + }, "universal_binaries": { "items": { "$ref": "#/$defs/UniversalBinary" diff --git a/www/mkdocs.yml b/www/mkdocs.yml index 8f67dce1c09..b036ef4081f 100644 --- a/www/mkdocs.yml +++ b/www/mkdocs.yml @@ -96,6 +96,7 @@ nav: - customization/nfpm.md - customization/checksum.md - customization/snapcraft.md + - customization/chocolatey.md - customization/docker.md - customization/docker_manifest.md - customization/sbom.md