diff --git a/internal/pipe/chocolatey/chocolatey.go b/internal/pipe/chocolatey/chocolatey.go new file mode 100644 index 000000000000..2a7f71d9cb36 --- /dev/null +++ b/internal/pipe/chocolatey/chocolatey.go @@ -0,0 +1,211 @@ +package chocolatey + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "text/template" + + "github.com/goreleaser/goreleaser/internal/artifact" + "github.com/goreleaser/goreleaser/internal/client" + "github.com/goreleaser/goreleaser/internal/tmpl" + "github.com/goreleaser/goreleaser/pkg/config" + "github.com/goreleaser/goreleaser/pkg/context" +) + +// 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" + } + } + + 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 +} + +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 + } + + scriptFile := filepath.Join(toolsPath, "chocolateyinstall.ps1") + script, err := buildScript(ctx, cl, choco, artifacts) + if err != nil { + return err + } + + return os.WriteFile(scriptFile, script, 0o644) +} + +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 buildScript(ctx *context.Context, cl client.Client, choco config.Chocolatey, artifacts []*artifact.Artifact) ([]byte, error) { + tp, err := template.New("install").Parse(scriptTemplate) + if err != nil { + return nil, err + } + + if choco.URLTemplate == "" { + url, err := cl.ReleaseURLTemplate(ctx) + if err != nil { + return nil, err + } + + choco.URLTemplate = url + } + + releases := make([]releasePackage, len(artifacts)) + + for i, artifact := range artifacts { + url, err := tmpl.New(ctx). + WithArtifact(artifact, map[string]string{}). + Apply(choco.URLTemplate) + if err != nil { + return nil, err + } + + sum, err := artifact.Checksum("sha256") + if err != nil { + return nil, err + } + + releases[i] = releasePackage{ + DownloadURL: url, + Checksum: sum, + Arch: artifact.Goarch, + } + } + + var out bytes.Buffer + if err = tp.Execute(&out, releases); err != nil { + return nil, err + } + + return out.Bytes(), nil +} diff --git a/internal/pipe/chocolatey/chocolatey_test.go b/internal/pipe/chocolatey/chocolatey_test.go new file mode 100644 index 000000000000..81b058758c88 --- /dev/null +++ b/internal/pipe/chocolatey/chocolatey_test.go @@ -0,0 +1,173 @@ +package chocolatey + +import ( + "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 + artifacts []artifact.Artifact + 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: "success", + choco: config.Chocolatey{ + Name: "app", + Goamd64: "v1", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + 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) + } + }) + } +} + +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_buildScript(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", + }, + } + choco := config.Chocolatey{} + client := client.NewMock() + 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, + }, + } + + out, err := buildScript(ctx, client, choco, artifacts) + require.NoError(t, err) + + golden.RequireEqualExt(t, out, ".ps1") +} diff --git a/internal/pipe/chocolatey/template.go b/internal/pipe/chocolatey/template.go new file mode 100644 index 000000000000..7784c7745fea --- /dev/null +++ b/internal/pipe/chocolatey/template.go @@ -0,0 +1,34 @@ +package chocolatey + +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 := . }} + {{- 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/Test_buildNuspec.nuspec.golden b/internal/pipe/chocolatey/testdata/Test_buildNuspec.nuspec.golden new file mode 100644 index 000000000000..7601fef7ef6c --- /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_buildScript.ps1.golden b/internal/pipe/chocolatey/testdata/Test_buildScript.ps1.golden new file mode 100644 index 000000000000..779da005c226 --- /dev/null +++ b/internal/pipe/chocolatey/testdata/Test_buildScript.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/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 8709a0fb74e3..d6d6ff5a6d3e 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 67828378ac5a..208a69e1e188 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -917,6 +917,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"` @@ -1103,3 +1104,34 @@ 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"` + 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 bdb625e12ae7..321f64205223 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{}, }