From f2281e8ff237e679c6f461a05d1eda7e3a426a3e Mon Sep 17 00:00:00 2001 From: Fabio Ribeiro Date: Sat, 12 Nov 2022 03:52:32 +0100 Subject: [PATCH] feat: chocolatey support (#3509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for generating the structure used to pack and push Chocolatey Packages. And will solve the #3154 Is not ready for merge yet, but has the main structure, and ready for comments. Accordingly to Chocolatey, in order to build a package, it's necessary a `.nuspec` and `chocolateyinstall.ps1` files at least, having these ones, we could pack and distribute without adding the binary inside the final package and that was implemented here. To complete, will be necessary to define the package build and distribute, however will be required to have Chocolatey installed (Windows Only). One of alternatives that I thought was, publish the files like Scoop and Brew in a separate repository, and there we could use `chocolatey` through [crazy-max/ghaction-chocolatey](https://github.com/crazy-max/ghaction-chocolatey). Chocolatey has a lot of good examples of repositories: https://github.com/chocolatey-community/chocolatey-packages/tree/master/automatic/curl A final compilation of the missing parts: - [x] How to pack and push (chocolatey) - [x] Documentation Sorry for the long description😄 All feedback very welcome! Co-authored-by: Carlos Alexandro Becker --- internal/artifact/artifact.go | 4 + internal/pipe/chocolatey/chocolatey.go | 340 ++++++++++++++++++ internal/pipe/chocolatey/chocolatey_test.go | 330 +++++++++++++++++ internal/pipe/chocolatey/nuspec.go | 87 +++++ internal/pipe/chocolatey/nuspec_test.go | 45 +++ internal/pipe/chocolatey/template.go | 38 ++ .../testdata/TestNuspecBytes.nuspec.golden | 29 ++ .../testdata/Test_buildNuspec.nuspec.golden | 20 ++ .../Test_buildTemplate.script.ps1.golden | 20 ++ internal/pipe/publish/publish.go | 2 + internal/pipeline/pipeline.go | 3 + pkg/config/config.go | 35 ++ pkg/defaults/defaults.go | 2 + www/docs/customization/chocolatey.md | 133 +++++++ www/docs/static/schema.json | 102 ++++++ www/mkdocs.yml | 1 + 16 files changed, 1191 insertions(+) create mode 100644 internal/pipe/chocolatey/chocolatey.go create mode 100644 internal/pipe/chocolatey/chocolatey_test.go create mode 100644 internal/pipe/chocolatey/nuspec.go create mode 100644 internal/pipe/chocolatey/nuspec_test.go create mode 100644 internal/pipe/chocolatey/template.go create mode 100644 internal/pipe/chocolatey/testdata/TestNuspecBytes.nuspec.golden create mode 100644 internal/pipe/chocolatey/testdata/Test_buildNuspec.nuspec.golden create mode 100644 internal/pipe/chocolatey/testdata/Test_buildTemplate.script.ps1.golden create mode 100644 www/docs/customization/chocolatey.md 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