github.com/goreleaser/goreleaser@v1.25.1/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/goreleaser/goreleaser/internal/artifact" 16 "github.com/goreleaser/goreleaser/internal/client" 17 "github.com/goreleaser/goreleaser/internal/commitauthor" 18 "github.com/goreleaser/goreleaser/internal/deprecate" 19 "github.com/goreleaser/goreleaser/internal/pipe" 20 "github.com/goreleaser/goreleaser/internal/skips" 21 "github.com/goreleaser/goreleaser/internal/tmpl" 22 "github.com/goreleaser/goreleaser/pkg/config" 23 "github.com/goreleaser/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 if scoop.Folder != "" { 101 deprecate.Notice(ctx, "scoops.folder") 102 scoop.Directory = scoop.Folder 103 } 104 scoop.CommitAuthor = commitauthor.Default(scoop.CommitAuthor) 105 if scoop.CommitMessageTemplate == "" { 106 scoop.CommitMessageTemplate = "Scoop update for {{ .ProjectName }} version {{ .Tag }}" 107 } 108 if scoop.Goamd64 == "" { 109 scoop.Goamd64 = "v1" 110 } 111 if !reflect.DeepEqual(scoop.Bucket, config.RepoRef{}) { 112 scoop.Repository = scoop.Bucket 113 deprecate.Notice(ctx, "scoops.bucket") 114 } 115 } 116 return nil 117 } 118 119 func runAll(ctx *context.Context, cl client.ReleaseURLTemplater) error { 120 for _, scoop := range ctx.Config.Scoops { 121 err := doRun(ctx, scoop, cl) 122 if err != nil { 123 return err 124 } 125 } 126 return nil 127 } 128 129 func doRun(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater) error { 130 filters := []artifact.Filter{ 131 artifact.ByGoos("windows"), 132 artifact.ByType(artifact.UploadableArchive), 133 artifact.Or( 134 artifact.And( 135 artifact.ByGoarch("amd64"), 136 artifact.ByGoamd64(scoop.Goamd64), 137 ), 138 artifact.ByGoarch("arm64"), 139 artifact.ByGoarch("386"), 140 ), 141 } 142 143 if len(scoop.IDs) > 0 { 144 filters = append(filters, artifact.ByIDs(scoop.IDs...)) 145 } 146 147 filtered := ctx.Artifacts.Filter(artifact.And(filters...)) 148 archives := filtered.List() 149 for _, platArchives := range filtered.GroupByPlatform() { 150 // there might be multiple archives, but only of for each platform 151 if len(platArchives) != 1 { 152 return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives} 153 } 154 } 155 // handle no archives found whatsoever 156 if len(archives) == 0 { 157 return ErrIncorrectArchiveCount{scoop.Goamd64, scoop.IDs, archives} 158 } 159 160 tp := tmpl.New(ctx) 161 162 if err := tp.ApplyAll( 163 &scoop.Name, 164 &scoop.Description, 165 &scoop.Homepage, 166 &scoop.SkipUpload, 167 ); err != nil { 168 return err 169 } 170 171 ref, err := client.TemplateRef(tmpl.New(ctx).Apply, scoop.Repository) 172 if err != nil { 173 return err 174 } 175 scoop.Repository = ref 176 177 data, err := dataFor(ctx, scoop, cl, archives) 178 if err != nil { 179 return err 180 } 181 content, err := doBuildManifest(data) 182 if err != nil { 183 return err 184 } 185 186 filename := scoop.Name + ".json" 187 path := filepath.Join(ctx.Config.Dist, "scoop", scoop.Directory, filename) 188 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 189 return err 190 } 191 log.WithField("manifest", path).Info("writing") 192 if err := os.WriteFile(path, content.Bytes(), 0o644); err != nil { 193 return fmt.Errorf("failed to write scoop manifest: %w", err) 194 } 195 196 ctx.Artifacts.Add(&artifact.Artifact{ 197 Name: filename, 198 Path: path, 199 Type: artifact.ScoopManifest, 200 Extra: map[string]interface{}{ 201 scoopConfigExtra: scoop, 202 }, 203 }) 204 return nil 205 } 206 207 func publishAll(ctx *context.Context, cli client.Client) error { 208 // even if one of them skips, we run them all, and then show return the 209 // skips all at once. this is needed so we actually create the 210 // `dist/foo.json` file, which is useful for debugging. 211 skips := pipe.SkipMemento{} 212 for _, manifest := range ctx.Artifacts.Filter(artifact.ByType(artifact.ScoopManifest)).List() { 213 err := doPublish(ctx, manifest, cli) 214 if err != nil && pipe.IsSkip(err) { 215 skips.Remember(err) 216 continue 217 } 218 if err != nil { 219 return err 220 } 221 } 222 return skips.Evaluate() 223 } 224 225 func doPublish(ctx *context.Context, manifest *artifact.Artifact, cl client.Client) error { 226 scoop, err := artifact.Extra[config.Scoop](*manifest, scoopConfigExtra) 227 if err != nil { 228 return err 229 } 230 231 if strings.TrimSpace(scoop.SkipUpload) == "true" { 232 return pipe.Skip("scoop.skip_upload is true") 233 } 234 if strings.TrimSpace(scoop.SkipUpload) == "auto" && ctx.Semver.Prerelease != "" { 235 return pipe.Skip("release is prerelease") 236 } 237 238 commitMessage, err := tmpl.New(ctx).Apply(scoop.CommitMessageTemplate) 239 if err != nil { 240 return err 241 } 242 243 author, err := commitauthor.Get(ctx, scoop.CommitAuthor) 244 if err != nil { 245 return err 246 } 247 248 content, err := os.ReadFile(manifest.Path) 249 if err != nil { 250 return err 251 } 252 253 repo := client.RepoFromRef(scoop.Repository) 254 gpath := path.Join(scoop.Directory, manifest.Name) 255 256 if scoop.Repository.Git.URL != "" { 257 return client.NewGitUploadClient(repo.Branch). 258 CreateFile(ctx, author, repo, content, gpath, commitMessage) 259 } 260 261 cl, err = client.NewIfToken(ctx, cl, scoop.Repository.Token) 262 if err != nil { 263 return err 264 } 265 266 base := client.Repo{ 267 Name: scoop.Repository.PullRequest.Base.Name, 268 Owner: scoop.Repository.PullRequest.Base.Owner, 269 Branch: scoop.Repository.PullRequest.Base.Branch, 270 } 271 272 // try to sync branch 273 fscli, ok := cl.(client.ForkSyncer) 274 if ok && scoop.Repository.PullRequest.Enabled { 275 if err := fscli.SyncFork(ctx, repo, base); err != nil { 276 log.WithError(err).Warn("could not sync fork") 277 } 278 } 279 280 if err := cl.CreateFile(ctx, author, repo, content, gpath, commitMessage); err != nil { 281 return err 282 } 283 284 if !scoop.Repository.PullRequest.Enabled { 285 log.Debug("scoop.pull_request disabled") 286 return nil 287 } 288 289 log.Info("scoop.pull_request enabled, creating a PR") 290 pcl, ok := cl.(client.PullRequestOpener) 291 if !ok { 292 return fmt.Errorf("client does not support pull requests") 293 } 294 295 return pcl.OpenPullRequest(ctx, base, repo, commitMessage, scoop.Repository.PullRequest.Draft) 296 } 297 298 // Manifest represents a scoop.sh App Manifest. 299 // more info: https://github.com/lukesampson/scoop/wiki/App-Manifests 300 type Manifest struct { 301 Version string `json:"version"` // The version of the app that this manifest installs. 302 Architecture map[string]Resource `json:"architecture"` // `architecture`: If the app has 32- and 64-bit versions, architecture can be used to wrap the differences. 303 Homepage string `json:"homepage,omitempty"` // `homepage`: The home page for the program. 304 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. 305 Description string `json:"description,omitempty"` // Description of the app 306 Persist []string `json:"persist,omitempty"` // Persist data between updates 307 PreInstall []string `json:"pre_install,omitempty"` // An array of strings, of the commands to be executed before an application is installed. 308 PostInstall []string `json:"post_install,omitempty"` // An array of strings, of the commands to be executed after an application is installed. 309 Depends []string `json:"depends,omitempty"` // A string or an array of strings. 310 Shortcuts [][]string `json:"shortcuts,omitempty"` // A two-dimensional array of string, specifies the shortcut values to make available in the startmenu. 311 } 312 313 // Resource represents a combination of a url and a binary name for an architecture. 314 type Resource struct { 315 URL string `json:"url"` // URL to the archive 316 Bin []string `json:"bin"` // name of binary inside the archive 317 Hash string `json:"hash"` // the archive checksum 318 } 319 320 func doBuildManifest(manifest Manifest) (bytes.Buffer, error) { 321 var result bytes.Buffer 322 data, err := json.MarshalIndent(manifest, "", " ") 323 if err != nil { 324 return result, err 325 } 326 _, err = result.Write(data) 327 return result, err 328 } 329 330 func dataFor(ctx *context.Context, scoop config.Scoop, cl client.ReleaseURLTemplater, artifacts []*artifact.Artifact) (Manifest, error) { 331 manifest := Manifest{ 332 Version: ctx.Version, 333 Architecture: map[string]Resource{}, 334 Homepage: scoop.Homepage, 335 License: scoop.License, 336 Description: scoop.Description, 337 Persist: scoop.Persist, 338 PreInstall: scoop.PreInstall, 339 PostInstall: scoop.PostInstall, 340 Depends: scoop.Depends, 341 Shortcuts: scoop.Shortcuts, 342 } 343 344 if scoop.URLTemplate == "" { 345 url, err := cl.ReleaseURLTemplate(ctx) 346 if err != nil { 347 return manifest, err 348 } 349 scoop.URLTemplate = url 350 } 351 352 for _, artifact := range artifacts { 353 if artifact.Goos != "windows" { 354 continue 355 } 356 357 var arch string 358 switch artifact.Goarch { 359 case "386": 360 arch = "32bit" 361 case "amd64": 362 arch = "64bit" 363 case "arm64": 364 arch = "arm64" 365 default: 366 continue 367 } 368 369 url, err := tmpl.New(ctx).WithArtifact(artifact).Apply(scoop.URLTemplate) 370 if err != nil { 371 return manifest, err 372 } 373 374 sum, err := artifact.Checksum("sha256") 375 if err != nil { 376 return manifest, err 377 } 378 379 log. 380 WithField("artifactExtras", artifact.Extra). 381 WithField("fromURLTemplate", scoop.URLTemplate). 382 WithField("templatedBrewURL", url). 383 WithField("sum", sum). 384 Debug("scoop url templating") 385 386 binaries, err := binaries(*artifact) 387 if err != nil { 388 return manifest, err 389 } 390 391 manifest.Architecture[arch] = Resource{ 392 URL: url, 393 Bin: binaries, 394 Hash: sum, 395 } 396 } 397 398 return manifest, nil 399 } 400 401 func binaries(a artifact.Artifact) ([]string, error) { 402 // nolint: prealloc 403 var result []string 404 wrap := artifact.ExtraOr(a, artifact.ExtraWrappedIn, "") 405 bins, err := artifact.Extra[[]string](a, artifact.ExtraBinaries) 406 if err != nil { 407 return nil, err 408 } 409 for _, b := range bins { 410 result = append(result, filepath.Join(wrap, b)) 411 } 412 return result, nil 413 }