Repository: goreleaser/goreleaser
Pull request #2361 (2021-07-23 12:52:36)

feat(snapcraft): manage channel upload
By default, snap store manage "edge", "beta", "candidate" and "stable".
Default to all this channels when package is in grade stable. But only on
"edge" and "beta" for grade "devel".

This fix this TODO: https://github.com/goreleaser/goreleaser/blob/bf19dc1079887d31e58065771bb069953b1704d6/internal/pipe/snapcraft/snapcraft.go#L342

vercel[bot]: @guilhem is attempting to deploy a commit to the **goreleaser** Team on [Vercel](https://vercel.com).

A member of the Team first needs to [authorize it](https://vercel.com/git/authorize?team=goreleaser&type=github&job=%7B%22headInfo%22%3A%7B%22sha%22%3A%22f5ec84b0752621476b22a77d647e6aa23144a2b9%22%7D%2C%22id%22%3A%22QmT2VuKwqCYSjUVPCCTNtwXEnmBMyxeTRS5D8AsKs2TRSm%22%2C%22org%22%3A%22goreleaser%22%2C%22prId%22%3A2361%2C%22repo%22%3A%22goreleaser%22%7D).


caarlos0: Thanks for the PR! Just a couple of comments :) 
codecov[bot]: # [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) Report
> Merging [#2361](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (de2d97b) into [master](https://codecov.io/gh/goreleaser/goreleaser/commit/73641c71ac0d9f78e2409b0cea1bf5b42111b933?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) (73641c7) will **increase** coverage by `0.11%`.
> The diff coverage is `58.13%`.

[![Impacted file tree graph](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/graphs/tree.svg?width=650&height=150&src=pr&token=3bJvv6b7hN&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)

```diff
@@            Coverage Diff             @@
##           master    #2361      +/-   ##
==========================================
+ Coverage   82.83%   82.94%   +0.11%     
==========================================
  Files          80       81       +1     
  Lines        5941     6017      +76     
==========================================
+ Hits         4921     4991      +70     
- Misses        877      882       +5     
- Partials      143      144       +1     
```


| [Impacted Files](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser) | Coverage Δ | |
|---|---|---|
| [pkg/config/config.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-cGtnL2NvbmZpZy9jb25maWcuZ28=) | `91.00% <ø> (ø)` | |
| [internal/pipe/snapcraft/snapcraft.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9zbmFwY3JhZnQvc25hcGNyYWZ0Lmdv) | `79.18% <58.13%> (+0.08%)` | :arrow_up: |
| [internal/logext/writer.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvbG9nZXh0L3dyaXRlci5nbw==) | `96.77% <0.00%> (-3.23%)` | :arrow_down: |
| [internal/pipe/docker/api.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9kb2NrZXIvYXBpLmdv) | `100.00% <0.00%> (ø)` | |
| [internal/pipe/before/before.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9iZWZvcmUvYmVmb3JlLmdv) | `100.00% <0.00%> (ø)` | |
| [internal/gio/safe.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvZ2lvL3NhZmUuZ28=) | `100.00% <0.00%> (ø)` | |
| [internal/pipe/build/build.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9idWlsZC9idWlsZC5nbw==) | `98.29% <0.00%> (+0.03%)` | :arrow_up: |
| [internal/pipe/sign/sign.go](https://codecov.io/gh/goreleaser/goreleaser/pull/2361/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser#diff-aW50ZXJuYWwvcGlwZS9zaWduL3NpZ24uZ28=) | `93.52% <0.00%> (+0.29%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Last update [73641c7...de2d97b](https://codecov.io/gh/goreleaser/goreleaser/pull/2361?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=goreleaser).

guilhem: @caarlos0 I updated to use a template for channels

Patch:
diff --git a/internal/pipe/snapcraft/snapcraft.go b/internal/pipe/snapcraft/snapcraft.go
index c34c0568..a6fd3256 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/pkg/config/config.go b/pkg/config/config.go
index 2c652b7c..313ae65a 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 363c95f0..48ab6789 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:


Test patch:
diff --git a/internal/pipe/snapcraft/snapcraft_test.go b/internal/pipe/snapcraft/snapcraft_test.go
index b568cbf4..e365e5dd 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
-}