github.com/windmeup/goreleaser@v1.21.95/internal/pipe/scoop/scoop.go (about) 1 // Package scoop provides a Pipe that generates a scoop.sh App Manifest and pushes it to a bucket. 2 package scoop 3 4 import ( 5 "bytes" 6 "encoding/json" 7 "fmt" 8 "os" 9 "path" 10 "path/filepath" 11 "reflect" 12 "strings" 13 14 "github.com/caarlos0/log" 15 "github.com/windmeup/goreleaser/internal/artifact" 16 "github.com/windmeup/goreleaser/internal/client" 17 "github.com/windmeup/goreleaser/internal/commitauthor" 18 "github.com/windmeup/goreleaser/internal/deprecate" 19 "github.com/windmeup/goreleaser/internal/pipe" 20 "github.com/windmeup/goreleaser/internal/skips" 21 "github.com/windmeup/goreleaser/internal/tmpl" 22 "github.com/windmeup/goreleaser/pkg/config" 23 "github.com/windmeup/goreleaser/pkg/context" 24 ) 25 26 // ErrIncorrectArchiveCount happens when a given filter evaluates 0 or more 27 // than 1 archives. 28 type ErrIncorrectArchiveCount struct { 29 goamd64 string 30 ids []string 31 archives []*artifact.Artifact 32 } 33 34 func (e ErrIncorrectArchiveCount) Error() string { 35 b := strings.Builder{} 36 37 _, _ = b.WriteString("scoop requires a single windows archive, ") 38 if len(e.archives) == 0 { 39 _, _ = b.WriteString("but no archives ") 40 } else { 41 _, _ = b.WriteString(fmt.Sprintf("but found %d archives ", len(e.archives))) 42 } 43 44 _, _ = b.WriteString(fmt.Sprintf("matching the given filters: goos=windows goarch=[386 amd64 arm64] goamd64=%s ids=%s", e.goamd64, e.ids)) 45 46 if len(e.archives) > 0 { 47 names := make([]string, 0, len(e.archives)) 48 for _, a := range e.archives { 49 names = append(names, a.Name) 50 } 51 _, _ = b.WriteString(fmt.Sprintf(": %s", names)) 52 } 53 54 _, _ = b.WriteString("\nLearn more at https://goreleaser.com/errors/scoop-archive\n") 55 return b.String() 56 } 57 58 const scoopConfigExtra = "ScoopConfig" 59 60 // Pipe that builds and publishes scoop manifests. 61 type Pipe struct{} 62 63 func (Pipe) String() string { return "scoop manifests" } 64 func (Pipe) ContinueOnError() bool { return true } 65 func (Pipe) Skip(ctx *context.Context) bool { 66 return skips.Any(ctx, skips.Scoop) || (ctx.Config.Scoop.Repository.Name == "" && len(ctx.Config.Scoops) == 0) 67 } 68 69 // Run creates the scoop manifest locally. 70 func (Pipe) Run(ctx *context.Context) error { 71 cli, err := client.NewReleaseClient(ctx) 72 if err != nil { 73 return err 74 } 75 return runAll(ctx, cli) 76 } 77 78 // Publish scoop manifest. 79 func (Pipe) Publish(ctx *context.Context) error { 80 client, err := client.New(ctx) 81 if err != nil { 82 return err 83 } 84 return publishAll(ctx, client) 85 } 86 87 // Default sets the pipe defaults. 88 func (Pipe) Default(ctx *context.Context) error { 89 if !reflect.DeepEqual(ctx.Config.Scoop.Bucket, config.RepoRef{}) || 90 !reflect.DeepEqual(ctx.Config.Scoop.Repository, config.RepoRef{}) { 91 deprecate.Notice(ctx, "scoop") 92 ctx.Config.Scoops = append(ctx.Config.Scoops, ctx.Config.Scoop) 93 } 94 95 for i := range ctx.Config.Scoops { 96 scoop := &ctx.Config.Scoops[i] 97 if scoop.Name == "" { 98 scoop.Name = ctx.Config.ProjectName 99 } 100 scoop.CommitAuthor = commitauthor.Default(scoop.CommitAuthor) 101 if scoop.CommitMessageTemplate == "" { 102 scoop.CommitMessageTemplate = "Scoop update for {{ .ProjectName }} version {{ .Tag }}" 103 } 104 if scoop.Goamd64 == "" { 105 scoop.Goamd64 = "v1" 106 } 107 if !reflect.DeepEqual(scoop.Bucket, config.RepoRef{}) { 108 scoop.Repository = scoop.Bucket 109 deprecate.Notice(ctx, "scoops.bucket") 110 } 111 } 112 return nil 113 } 114 115 func runAll(ctx *context.Context, cl client.ReleaseURLTemplater) error { 116 for _, scoop := range ctx.Config.Scoops { 117 err := doRun(ctx, scoop, cl) 118 if err != nil { 119 return err 120 } 121 } 122 return nil 123 } 124 125 func doRun(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater) error { 126 filters := []artifact.Filter{ 127 artifact.ByGoos("windows"), 128 artifact.ByType(artifact.UploadableArchive), 129 artifact.Or( 130 artifact.And( 131 artifact.ByGoarch("amd64"), 132 artifact.ByGoamd64(scoop.Goamd64), 133 ), 134 artifact.ByGoarch("arm64"), 135 artifact.ByGoarch("386"), 136 ), 137 } 138 139 if len(scoop.IDs) > 0 { 140 filters = append(filters, artifact.ByIDs(scoop.IDs...)) 141 } 142 143 filtered := ctx.Artifacts.Filter(artifact.And(filters...)) 144 archives := filtered.List() 145 for _, platArchives := range filtered.GroupByPlatform() { 146 // there might be multiple archives, but only of for each platform 147 if len(platArchives) != 1 { 148 return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives} 149 } 150 } 151 // handle no archives found whatsoever 152 if len(archives) == 0 { 153 return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives} 154 } 155 156 tp := tmpl.New(ctx) 157 158 if err := tp.ApplyAll( 159 &scoop.Name, 160 &scoop.Description, 161 &scoop.Homepage, 162 &scoop.SkipUpload, 163 ); err != nil { 164 return err 165 } 166 167 ref, err := client.TemplateRef(tmpl.New(ctx).Apply, scoop.Repository) 168 if err != nil { 169 return err 170 } 171 scoop.Repository = ref 172 173 data, err := dataFor(ctx, scoop, cl, archives) 174 if err != nil { 175 return err 176 } 177 content, err := doBuildManifest(data) 178 if err != nil { 179 return err 180 } 181 182 filename := scoop.Name + ".json" 183 path := filepath.Join(ctx.Config.Dist, "scoop", scoop.Folder, filename) 184 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 185 return err 186 } 187 log.WithField("manifest", path).Info("writing") 188 if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil { 189 return fmt.Errorf("failed to write scoop manifest: %w", err) 190 } 191 192 ctx.Artifacts.Add(&artifact.Artifact{ 193 Name: filename, 194 Path: path, 195 Type: artifact.ScoopManifest, 196 Extra: map[string]interface{}{ 197 scoopConfigExtra: scoop, 198 }, 199 }) 200 return nil 201 } 202 203 func publishAll(ctx *context.Context, cli client.Client) error { 204 // even if one of them skips, we run them all, and then show return the 205 // skips all at once. this is needed so we actually create the 206 // `dist/foo.json` file, which is useful for debugging. 207 skips := pipe.SkipMemento{} 208 for _, manifest := range ctx.Artifacts.Filter(artifact.ByType(artifact.ScoopManifest)).List() { 209 err := doPublish(ctx, manifest, cli) 210 if err != nil && pipe.IsSkip(err) { 211 skips.Remember(err) 212 continue 213 } 214 if err != nil { 215 return err 216 } 217 } 218 return skips.Evaluate() 219 } 220 221 func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Client) error { 222 scoop, err := artifact.Extra[config.Scoop](*manifest, scoopConfigExtra) 223 if err != nil { 224 return err 225 } 226 227 if strings.TrimSpace(scoop.SkipUpload) == "true" { 228 return pipe.Skip("scoop.skip_upload is true") 229 } 230 if strings.TrimSpace(scoop.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" { 231 return pipe.Skip("release is prerelease") 232 } 233 234 commitMessage, err := tmpl.New(ctx).Apply(scoop.CommitMessageTemplate) 235 if err != nil { 236 return err 237 } 238 239 author, err := commitauthor.Get(ctx, scoop.CommitAuthor) 240 if err != nil { 241 return err 242 } 243 244 content, err := os.ReadFile(manifest.Path) 245 if err != nil { 246 return err 247 } 248 249 repo := client.RepoFromRef(scoop.Repository) 250 gpath := path.Join(scoop.Folder, manifest.Name) 251 252 if scoop.Repository.Git.URL != "" { 253 return client.NewGitUploadClient(repo.Branch). 254 CreateFile(ctx, author, repo, content, gpath, commitMessage) 255 } 256 257 cl, err = client.NewIfToken(ctx, cl, scoop.Repository.Token) 258 if err != nil { 259 return err 260 } 261 262 if !scoop.Repository.PullRequest.Enabled { 263 return cl.CreateFile(ctx, author, repo, content, gpath, commitMessage) 264 } 265 266 log.Info("brews.pull_request enabled, creating a PR") 267 pcl, ok := cl.(client.PullRequestOpener) 268 if !ok { 269 return fmt.Errorf("client does not support pull requests") 270 } 271 272 if err := cl.CreateFile(ctx, author, repo, content, gpath, commitMessage); err != nil { 273 return err 274 } 275 276 return pcl.OpenPullRequest(ctx, client.Repo{ 277 Name: scoop.Repository.PullRequest.Base.Name, 278 Owner: scoop.Repository.PullRequest.Base.Owner, 279 Branch: scoop.Repository.PullRequest.Base.Branch, 280 }, repo, commitMessage, scoop.Repository.PullRequest.Draft) 281 } 282 283 // Manifest represents a scoop.sh App Manifest. 284 // more info: https://github.com/lukesampson/scoop/wiki/App-Manifests 285 type Manifest struct { 286 Version string `json:"version"` // The version of the app that this manifest installs. 287 Architecture map[string]Resource `json:"architecture"` // `architecture`: If the app has 32- and 64-bit versions, architecture can be used to wrap the differences. 288 Homepage string `json:"homepage,omitempty"` // `homepage`: The home page for the program. 289 License string `json:"license,omitempty"` // `license`: The software license for the program. For well-known licenses, this will be a string like "MIT" or "GPL2". For custom licenses, this should be the URL of the license. 290 Description string `json:"description,omitempty"` // Description of the app 291 Persist []string `json:"persist,omitempty"` // Persist data between updates 292 PreInstall []string `json:"pre_install,omitempty"` // An array of strings, of the commands to be executed before an application is installed. 293 PostInstall []string `json:"post_install,omitempty"` // An array of strings, of the commands to be executed after an application is installed. 294 Depends []string `json:"depends,omitempty"` // A string or an array of strings. 295 Shortcuts [][]string `json:"shortcuts,omitempty"` // A two-dimensional array of string, specifies the shortcut values to make available in the startmenu. 296 } 297 298 // Resource represents a combination of a url and a binary name for an architecture. 299 type Resource struct { 300 URL string `json:"url"` // URL to the archive 301 Bin []string `json:"bin"` // name of binary inside the archive 302 Hash string `json:"hash"` // the archive checksum 303 } 304 305 func doBuildManifest(manifest Manifest) (bytes.Buffer, error) { 306 var result bytes.Buffer 307 data, err := json.MarshalIndent(manifest, "", " ") 308 if err != nil { 309 return result, err 310 } 311 _, err = result.Write(data) 312 return result, err 313 } 314 315 func dataFor(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (Manifest, error) { 316 manifest := Manifest{ 317 Version: ctx.Version, 318 Architecture: map[string]Resource{}, 319 Homepage: scoop.Homepage, 320 License: scoop.License, 321 Description: scoop.Description, 322 Persist: scoop.Persist, 323 PreInstall: scoop.PreInstall, 324 PostInstall: scoop.PostInstall, 325 Depends: scoop.Depends, 326 Shortcuts: scoop.Shortcuts, 327 } 328 329 if scoop.URLTemplate == "" { 330 url, err := cl.ReleaseURLTemplate(ctx) 331 if err != nil { 332 return manifest, err 333 } 334 scoop.URLTemplate = url 335 } 336 337 for _, artifact := range artifacts { 338 if artifact.Goos != "windows" { 339 continue 340 } 341 342 var arch string 343 switch artifact.Goarch { 344 case "386": 345 arch = "32bit" 346 case "amd64": 347 arch = "64bit" 348 case "arm64": 349 arch = "arm64" 350 default: 351 continue 352 } 353 354 url, err := tmpl.New(ctx).WithArtifact(artifact).Apply(scoop.URLTemplate) 355 if err != nil { 356 return manifest, err 357 } 358 359 sum, err := artifact.Checksum("sha256") 360 if err != nil { 361 return manifest, err 362 } 363 364 log. 365 WithField("artifactExtras", artifact.Extra). 366 WithField("fromURLTemplate", scoop.URLTemplate). 367 WithField("templatedBrewURL", url). 368 WithField("sum", sum). 369 Debug("scoop url templating") 370 371 binaries, err := binaries(*artifact) 372 if err != nil { 373 return manifest, err 374 } 375 376 manifest.Architecture[arch] = Resource{ 377 URL: url, 378 Bin: binaries, 379 Hash: sum, 380 } 381 } 382 383 return manifest, nil 384 } 385 386 func binaries(a artifact.Artifact) ([]string, error) { 387 // nolint: prealloc 388 var result []string 389 wrap := artifact.ExtraOr(a, artifact.ExtraWrappedIn, "") 390 bins, err := artifact.Extra[[]string](a, artifact.ExtraBinaries) 391 if err != nil { 392 return nil, err 393 } 394 for _, b := range bins { 395 result = append(result, filepath.Join(wrap, b)) 396 } 397 return result, nil 398 }