diff --git a/internal/pipe/snapcraft/snapcraft.go b/internal/pipe/snapcraft/snapcraft.go index c34c0568f..a6fd32563 100644 --- a/internal/pipe/snapcraft/snapcraft.go +++ b/internal/pipe/snapcraft/snapcraft.go @@ -4,6 +4,7 @@ package snapcraft import ( "errors" "fmt" + "io" "os" "os/exec" "path/filepath" @@ -22,6 +23,8 @@ import ( "github.com/goreleaser/goreleaser/pkg/context" ) +const releasesExtra = "releases" + // ErrNoSnapcraft is shown when snapcraft cannot be found in $PATH. var ErrNoSnapcraft = errors.New("snapcraft not present in $PATH") @@ -80,6 +83,14 @@ func (Pipe) Default(ctx *context.Context) error { if snap.NameTemplate == "" { snap.NameTemplate = defaultNameTemplate } + if len(snap.ChannelTemplates) == 0 { + switch snap.Grade { + case "devel": + snap.ChannelTemplates = []string{"edge", "beta"} + default: + snap.ChannelTemplates = []string{"edge", "beta", "candidate", "stable"} + } + } if len(snap.Builds) == 0 { for _, b := range ctx.Config.Builds { snap.Builds = append(snap.Builds, b.ID) @@ -172,6 +183,11 @@ func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries [ return err } + channels, err := processChannelsTemplates(ctx, snap) + if err != nil { + return err + } + // prime is the directory that then will be compressed to make the .snap package. folderDir := filepath.Join(ctx.Config.Dist, folder) primeDir := filepath.Join(folderDir, "prime") @@ -188,8 +204,9 @@ func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries [ if file.Mode == 0 { file.Mode = 0o644 } - if err := os.MkdirAll(filepath.Join(primeDir, filepath.Dir(file.Destination)), 0o755); err != nil { - return fmt.Errorf("failed to link extra file '%s': %w", file.Source, err) + destinationDir := filepath.Join(primeDir, filepath.Dir(file.Destination)) + if err := os.MkdirAll(destinationDir, 0o755); err != nil { + return fmt.Errorf("failed to create directory '%s': %w", destinationDir, err) } if err := link(file.Source, filepath.Join(primeDir, file.Destination), os.FileMode(file.Mode)); err != nil { return fmt.Errorf("failed to link extra file '%s': %w", file.Source, err) @@ -251,12 +268,9 @@ func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries [ WithField("dst", destBinaryPath). Debug("linking") - if err = os.Link(binary.Path, destBinaryPath); err != nil { + if err = copyFile(binary.Path, destBinaryPath, 0o555); err != nil { return fmt.Errorf("failed to link binary: %w", err) } - if err := os.Chmod(destBinaryPath, 0o555); err != nil { - return fmt.Errorf("failed to change binary permissions: %w", err) - } } // setup the apps: directive for each binary @@ -285,13 +299,12 @@ func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries [ } log.WithField("src", config.Completer). WithField("dst", destCompleterPath). - Debug("linking") - if err := os.Link(config.Completer, destCompleterPath); err != nil { - return fmt.Errorf("failed to link completer: %w", err) - } - if err := os.Chmod(destCompleterPath, 0o644); err != nil { - return fmt.Errorf("failed to change completer permissions: %w", err) + Debug("copy") + + if err := copyFile(config.Completer, destCompleterPath, 0o644); err != nil { + return fmt.Errorf("failed to copy completer: %w", err) } + appMetadata.Completer = config.Completer } @@ -326,6 +339,9 @@ func create(ctx *context.Context, snap config.Snapcraft, arch string, binaries [ Goos: binaries[0].Goos, Goarch: binaries[0].Goarch, Goarm: binaries[0].Goarm, + Extra: map[string]interface{}{ + releasesExtra: channels, + }, }) return nil } @@ -338,10 +354,10 @@ const ( func push(ctx *context.Context, snap *artifact.Artifact) error { log := log.WithField("snap", snap.Name) - log.Info("pushing snap") - // TODO: customize --release based on snap.Grade? + releases := snap.Extra[releasesExtra].([]string) /* #nosec */ - cmd := exec.CommandContext(ctx, "snapcraft", "upload", "--release=stable", snap.Path) + cmd := exec.CommandContext(ctx, "snapcraft", "upload", "--release="+strings.Join(releases, ","), snap.Path) + log.WithField("args", cmd.Args).Info("pushing snap") if out, err := cmd.CombinedOutput(); err != nil { if strings.Contains(string(out), reviewWaitMsg) || strings.Contains(string(out), humanReviewMsg) || strings.Contains(string(out), needsReviewMsg) { log.Warn(reviewWaitMsg) @@ -354,7 +370,7 @@ func push(ctx *context.Context, snap *artifact.Artifact) error { return nil } -// walks the src, recreating dirs and hard-linking files. +// walks the src, recreating dirs and copying files. func link(src, dest string, mode os.FileMode) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { @@ -373,9 +389,53 @@ func link(src, dest string, mode os.FileMode) error { if info.IsDir() { return os.MkdirAll(dst, info.Mode()) } - if err := os.Link(path, dst); err != nil { - return err + if err := copyFile(path, dst, mode); err != nil { + return fmt.Errorf("fail copy file '%s': %w", path, err) } - return os.Chmod(dst, mode) + return nil }) } + +func copyFile(src, dst string, mode os.FileMode) error { + // Open original file + original, err := os.Open(src) + if err != nil { + return fmt.Errorf("fail to open '%s': %w", src, err) + } + defer original.Close() + + // Create new file + new, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("fail to open '%s': %w", dst, err) + } + defer new.Close() + + // This will copy + if _, err := io.Copy(new, original); err != nil { + return fmt.Errorf("fail to copy: %w", err) + } + return nil +} + +func processChannelsTemplates(ctx *context.Context, snap config.Snapcraft) ([]string, error) { + // nolint:prealloc + var channels []string + for _, channeltemplate := range snap.ChannelTemplates { + channel, err := tmpl.New(ctx).Apply(channeltemplate) + if err != nil { + return nil, fmt.Errorf("failed to execute channel template '%s': %w", err, err) + } + if channel == "" { + continue + } + + channels = append(channels, channel) + } + + if len(channels) == 0 { + return channels, errors.New("no image templates found") + } + + return channels, nil +} diff --git a/internal/pipe/snapcraft/snapcraft_test.go b/internal/pipe/snapcraft/snapcraft_test.go index b568cbf4e..e365e5dda 100644 --- a/internal/pipe/snapcraft/snapcraft_test.go +++ b/internal/pipe/snapcraft/snapcraft_test.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "syscall" "testing" "github.com/goreleaser/goreleaser/internal/artifact" @@ -52,25 +51,28 @@ func TestRunPipe(t *testing.T) { Dist: dist, Snapcrafts: []config.Snapcraft{ { - NameTemplate: "foo_{{.Arch}}", - Summary: "test summary", - Description: "test description", - Publish: true, - Builds: []string{"foo"}, + NameTemplate: "foo_{{.Arch}}", + Summary: "test summary", + Description: "test description", + Publish: true, + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, { - NameTemplate: "foo_and_bar_{{.Arch}}", - Summary: "test summary", - Description: "test description", - Publish: true, - Builds: []string{"foo", "bar"}, + NameTemplate: "foo_and_bar_{{.Arch}}", + Summary: "test summary", + Description: "test description", + Publish: true, + Builds: []string{"foo", "bar"}, + ChannelTemplates: []string{"stable"}, }, { - NameTemplate: "bar_{{.Arch}}", - Summary: "test summary", - Description: "test description", - Publish: true, - Builds: []string{"bar"}, + NameTemplate: "bar_{{.Arch}}", + Summary: "test summary", + Description: "test description", + Publish: true, + Builds: []string{"bar"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -92,10 +94,11 @@ func TestRunPipeInvalidNameTemplate(t *testing.T) { Dist: dist, Snapcrafts: []config.Snapcraft{ { - NameTemplate: "foo_{{.Arch}", - Summary: "test summary", - Description: "test description", - Builds: []string{"foo"}, + NameTemplate: "foo_{{.Arch}", + Summary: "test summary", + Description: "test description", + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -114,13 +117,14 @@ func TestRunPipeWithName(t *testing.T) { Dist: dist, Snapcrafts: []config.Snapcraft{ { - NameTemplate: "foo_{{.Arch}}", - Name: "testsnapname", - Base: "core18", - License: "MIT", - Summary: "test summary", - Description: "test description", - Builds: []string{"foo"}, + NameTemplate: "foo_{{.Arch}}", + Name: "testsnapname", + Base: "core18", + License: "MIT", + Summary: "test summary", + Description: "test description", + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -168,7 +172,8 @@ func TestRunPipeMetadata(t *testing.T) { "read": []string{"$HOME/test"}, }, }, - Builds: []string{"foo"}, + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -227,7 +232,8 @@ func TestRunNoArguments(t *testing.T) { Args: "", }, }, - Builds: []string{"foo"}, + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -262,7 +268,8 @@ func TestCompleter(t *testing.T) { Completer: "testdata/foo-completer.bash", }, }, - Builds: []string{"foo", "bar"}, + Builds: []string{"foo", "bar"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -299,7 +306,8 @@ func TestCommand(t *testing.T) { Command: "foo", }, }, - Builds: []string{"foo"}, + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -337,7 +345,8 @@ func TestExtraFile(t *testing.T) { Source: "testdata/extra-file-2.txt", }, }, - Builds: []string{"foo"}, + Builds: []string{"foo"}, + ChannelTemplates: []string{"stable"}, }, }, }) @@ -350,7 +359,7 @@ func TestExtraFile(t *testing.T) { require.NoError(t, err) destFile, err := os.Stat(filepath.Join(dist, "foo_amd64", "prime", "a", "b", "c", "extra-file.txt")) require.NoError(t, err) - require.Equal(t, inode(srcFile), inode(destFile)) + require.Equal(t, srcFile.Size(), destFile.Size()) require.Equal(t, destFile.Mode(), os.FileMode(0o755)) srcFile, err = os.Stat("testdata/extra-file-2.txt") @@ -358,7 +367,7 @@ func TestExtraFile(t *testing.T) { destFileWithDefaults, err := os.Stat(filepath.Join(dist, "foo_amd64", "prime", "testdata", "extra-file-2.txt")) require.NoError(t, err) require.Equal(t, destFileWithDefaults.Mode(), os.FileMode(0o644)) - require.Equal(t, inode(srcFile), inode(destFileWithDefaults)) + require.Equal(t, srcFile.Size(), destFileWithDefaults.Size()) } func TestDefault(t *testing.T) { @@ -385,6 +394,9 @@ func TestPublish(t *testing.T) { Goarch: "amd64", Goos: "linux", Type: artifact.PublishableSnapcraft, + Extra: map[string]interface{}{ + releasesExtra: []string{"stable", "candidate"}, + }, }) err := Pipe{}.Publish(ctx) require.Contains(t, err.Error(), "failed to push nope.snap package") @@ -399,6 +411,9 @@ func TestPublishSkip(t *testing.T) { Goarch: "amd64", Goos: "linux", Type: artifact.PublishableSnapcraft, + Extra: map[string]interface{}{ + releasesExtra: []string{"stable"}, + }, }) testlib.AssertSkipped(t, Pipe{}.Publish(ctx)) } @@ -407,12 +422,69 @@ func TestDefaultSet(t *testing.T) { ctx := context.New(config.Project{ Snapcrafts: []config.Snapcraft{ { + ID: "devel", NameTemplate: "foo", + Grade: "devel", + }, + { + ID: "stable", + NameTemplate: "bar", + Grade: "stable", }, }, }) require.NoError(t, Pipe{}.Default(ctx)) require.Equal(t, "foo", ctx.Config.Snapcrafts[0].NameTemplate) + require.Equal(t, []string{"edge", "beta"}, ctx.Config.Snapcrafts[0].ChannelTemplates) + require.Equal(t, []string{"edge", "beta", "candidate", "stable"}, ctx.Config.Snapcrafts[1].ChannelTemplates) +} + +func Test_processChannelsTemplates(t *testing.T) { + ctx := &context.Context{ + Config: config.Project{ + Builds: []config.Build{ + { + ID: "default", + }, + }, + Snapcrafts: []config.Snapcraft{ + { + Name: "mybin", + ChannelTemplates: []string{ + "{{.Major}}.{{.Minor}}/stable", + "stable", + }, + }, + }, + }, + } + + ctx.SkipPublish = true + ctx.Env = map[string]string{ + "FOO": "123", + } + ctx.Version = "1.0.0" + ctx.Git = context.GitInfo{ + CurrentTag: "v1.0.0", + Commit: "a1b2c3d4", + } + ctx.Semver = context.Semver{ + Major: 1, + Minor: 0, + Patch: 0, + } + + require.NoError(t, Pipe{}.Default(ctx)) + + snap := ctx.Config.Snapcrafts[0] + require.Equal(t, "mybin", snap.Name) + + channels, err := processChannelsTemplates(ctx, snap) + require.NoError(t, err) + require.Equal(t, []string{ + "1.0/stable", + "stable", + }, channels) } func addBinaries(t *testing.T, ctx *context.Context, name, dist string) { @@ -475,8 +547,3 @@ func Test_isValidArch(t *testing.T) { }) } } - -func inode(info os.FileInfo) uint64 { - stat := info.Sys().(*syscall.Stat_t) - return stat.Ino -} diff --git a/pkg/config/config.go b/pkg/config/config.go index 2c652b7cc..313ae65a0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -503,18 +503,19 @@ type Snapcraft struct { Replacements map[string]string `yaml:",omitempty"` Publish bool `yaml:",omitempty"` - ID string `yaml:",omitempty"` - Builds []string `yaml:",omitempty"` - Name string `yaml:",omitempty"` - Summary string `yaml:",omitempty"` - Description string `yaml:",omitempty"` - Base string `yaml:",omitempty"` - License string `yaml:",omitempty"` - Grade string `yaml:",omitempty"` - Confinement string `yaml:",omitempty"` - Layout map[string]SnapcraftLayoutMetadata `yaml:",omitempty"` - Apps map[string]SnapcraftAppMetadata `yaml:",omitempty"` - Plugs map[string]interface{} `yaml:",omitempty"` + ID string `yaml:",omitempty"` + Builds []string `yaml:",omitempty"` + Name string `yaml:",omitempty"` + Summary string `yaml:",omitempty"` + Description string `yaml:",omitempty"` + Base string `yaml:",omitempty"` + License string `yaml:",omitempty"` + Grade string `yaml:",omitempty"` + ChannelTemplates []string `yaml:"channel_templates,omitempty"` + Confinement string `yaml:",omitempty"` + Layout map[string]SnapcraftLayoutMetadata `yaml:",omitempty"` + Apps map[string]SnapcraftAppMetadata `yaml:",omitempty"` + Plugs map[string]interface{} `yaml:",omitempty"` Files []SnapcraftExtraFiles `yaml:"extra_files,omitempty"` } diff --git a/www/docs/customization/snapcraft.md b/www/docs/customization/snapcraft.md index 363c95f0b..48ab67894 100644 --- a/www/docs/customization/snapcraft.md +++ b/www/docs/customization/snapcraft.md @@ -60,12 +60,27 @@ snapcrafts: # store. description: This is the best drum roll application out there. Install it and awe! + # Channels in store where snap will be pushed. + # Default depends on grade: + # * `stable` = ["edge", "beta", "candidate", "stable"] + # * `devel` = ["edge", "beta"] + # More info about channels here: + # https://snapcraft.io/docs/reference/channels + channel_templates: + - edge + - beta + - candidate + - stable + - {{ .Major }}.{{ .Minor }}/edge + - {{ .Major }}.{{ .Minor }}/beta + - {{ .Major }}.{{ .Minor }}/candidate + - {{ .Major }}.{{ .Minor }}/stable + # A guardrail to prevent you from releasing a snap to all your users before # it is ready. # `devel` will let you release only to the `edge` and `beta` channels in the # store. `stable` will let you release also to the `candidate` and `stable` - # channels. More info about channels here: - # https://snapcraft.io/docs/reference/channels + # channels. grade: stable # Snaps can be setup to follow three different confinement policies: