From e385a480839ee2bf114a6de4f64d16f4d476f3ab Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 14:28:04 +0200 Subject: [PATCH 01/13] feat: channel patterns for package slices files --- internal/setup/channel.go | 191 +++++++++++++++++++++++++++++++ internal/setup/channel_test.go | 203 +++++++++++++++++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 internal/setup/channel.go create mode 100644 internal/setup/channel_test.go diff --git a/internal/setup/channel.go b/internal/setup/channel.go new file mode 100644 index 00000000..60d642e9 --- /dev/null +++ b/internal/setup/channel.go @@ -0,0 +1,191 @@ +package setup + +import ( + "errors" + "fmt" + "slices" + "strings" + "unicode" +) + +// The "channel" field of a slice definition holds patterns selecting which +// concrete "/" channels an entry applies to. The track is a +// literal and only the risk part accepts operators: +// +// * - Any risk of that track +// ! - Any risk of that track but that one +// [,...] - Only those risks of that track +// +// Patterns are kept as written and interpreted on each match, as done for +// globs in the strdist package. They are validated when the release is read so +// that a malformed value is reported early, and rendered back verbatim. + +// Channel is a store channel, as in "/[/]". +type Channel struct { + Track string + Risk string + Branch string +} + +func (c Channel) String() string { + if c.Track == "" { + return "" + } + channel := c.Track + if c.Risk != "" { + channel += "/" + c.Risk + } + if c.Branch != "" { + channel += "/" + c.Branch + } + return channel +} + +// The form a channel pattern must take, as reported to the user. +const channelPatternForm = "/" + +// splitChannel splits a channel or a channel pattern on "/", checking what the +// two have in common: no spaces and no empty segment. How many segments are +// expected and what each one means is left to the caller, as that is where the +// two differ. form is the shape reported on error. +func splitChannel(value, form string) ([]string, error) { + if strings.ContainsFunc(value, unicode.IsSpace) { + return nil, errors.New("must not contain spaces") + } + segments := strings.Split(value, "/") + if slices.Contains(segments, "") { + return nil, fmt.Errorf("must be %s", form) + } + return segments, nil +} + +// knownRisks holds every risk a channel may hold, from the most to the least +// stable. The set is defined by the store and does not depend on its content, +// hence risks are validated as architectures are. +var knownRisks = []string{"stable", "candidate", "beta", "edge"} + +// validateRisk validates a single risk of a channel or of a channel pattern. +func validateRisk(risk string) error { + if !slices.Contains(knownRisks, risk) { + return fmt.Errorf("unknown risk %q, must be one of %s", risk, strings.Join(knownRisks, ", ")) + } + return nil +} + +// validateChannelPatterns validates the values of a "channel" field. A track +// may appear at most once across the values so that the resulting set of +// channels is unambiguous. +func validateChannelPatterns(patterns []string) error { + seen := make(map[string]bool, len(patterns)) + for _, pattern := range patterns { + track, err := validateChannelPattern(pattern) + if err != nil { + return fmt.Errorf("%q: %s", pattern, err) + } + if seen[track] { + return fmt.Errorf("track %q is repeated", track) + } + seen[track] = true + } + return nil +} + +// validateChannelPattern validates a single pattern and returns its track. A +// pattern holds no branch, hence exactly one track and one risk part. +func validateChannelPattern(pattern string) (track string, err error) { + segments, err := splitChannel(pattern, channelPatternForm) + if err != nil { + return "", err + } + if len(segments) != 2 { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + track, riskPart := segments[0], segments[1] + if strings.ContainsAny(track, "*!,") { + return "", errors.New("only the risk accepts '*', '!' and ','") + } + if riskPart != "*" && strings.Contains(riskPart, "*") { + // Checked before the risk part takes one of the forms below, as a + // wildcard is not allowed within any of them. + return "", errors.New("'*' must be the whole risk") + } + + // The risk part takes one of the three forms of the grammar. + switch { + case riskPart == "*": + // Every risk of the track, nothing more to validate. + case strings.HasPrefix(riskPart, "!"): + // Every risk of the track but the excluded one. + except := strings.TrimPrefix(riskPart, "!") + if strings.Contains(except, ",") { + return "", errors.New("'!' cannot be combined with other risks") + } + if except == "" { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + if err := validateRisk(except); err != nil { + return "", err + } + default: + // Only the listed risks of the track. + risks := strings.Split(riskPart, ",") + for i, risk := range risks { + if risk == "" { + return "", fmt.Errorf("must be %s", channelPatternForm) + } + if strings.Contains(risk, "!") { + return "", errors.New("'!' must prefix the whole risk") + } + if slices.Contains(risks[:i], risk) { + return "", fmt.Errorf("risk %q is repeated", risk) + } + if err := validateRisk(risk); err != nil { + return "", err + } + } + } + return track, nil +} + +// MatchChannelPatterns reports whether the concrete "/" channel +// matches any of the patterns. An empty list matches every channel, which means +// the entry is not channel specific. +// +// A branch, as in "//", is ignored. Branches are ephemeral +// and thus never part of a pattern, so an entry applies to every branch of the +// risk it matches. +func MatchChannelPatterns(patterns []string, channel Channel) bool { + if len(patterns) == 0 { + return true + } + if channel.Track == "" || channel.Risk == "" { + // A channel without a risk is not a channel. Never match it, rather + // than treat the missing risk as one that differs from an excluded one. + return false + } + for _, pattern := range patterns { + if matchChannel(pattern, channel.Track, channel.Risk) { + return true + } + } + return false +} + +// matchChannel reports whether the pattern matches the track and the risk of a +// concrete channel. Note that the exclusion form is scoped to its own track, so +// "1.0/!stable" does not match any risk of the "2.0" track. +// +// The pattern is expected to be valid, as ensured when the release is read. +func matchChannel(pattern, track, risk string) bool { + patternTrack, riskPart, _ := strings.Cut(pattern, "/") + if track != patternTrack { + return false + } + if riskPart == "*" { + return true + } + if except, ok := strings.CutPrefix(riskPart, "!"); ok { + return risk != except + } + return slices.Contains(strings.Split(riskPart, ","), risk) +} diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go new file mode 100644 index 00000000..3a3d52b9 --- /dev/null +++ b/internal/setup/channel_test.go @@ -0,0 +1,203 @@ +package setup_test + +import ( + . "gopkg.in/check.v1" + + "github.com/canonical/chisel/internal/setup" +) + +// channelPatternTests covers validating and matching the patterns of a +// "channel" field. The valid patterns come first, then the invalid ones, +// grouped after the validation phase they exercise. Note several of the latter +// share the "must be /" message while entering through different +// phases, hence none is redundant. +var channelPatternTests = []struct { + summary string + values []string + err string + // match maps a concrete channel to whether the patterns match it. + match map[setup.Channel]bool +}{{ + summary: "No pattern matches every channel", + values: nil, + match: map[setup.Channel]bool{ + {"3.0", "stable", ""}: true, + {"2.0", "edge", ""}: true, + }, +}, { + summary: "Precise channel", + values: []string{"0.3/stable"}, + match: map[setup.Channel]bool{ + {"0.3", "stable", ""}: true, + {"0.3", "edge", ""}: false, + {"0.2", "stable", ""}: false, + // Branches are ephemeral, hence never part of a pattern. The entry + // applies to every branch of the risk it matches. + {"0.3", "stable", "mybranch"}: true, + {"0.3", "edge", "mybranch"}: false, + }, +}, { + summary: "All risks of a track", + values: []string{"0.3/*"}, + match: map[setup.Channel]bool{ + {"0.3", "stable", ""}: true, + {"0.3", "edge", ""}: true, + {"0.2", "stable", ""}: false, + }, +}, { + summary: "Excluded risk", + values: []string{"0.2/!stable"}, + match: map[setup.Channel]bool{ + {"0.2", "stable", ""}: false, + {"0.2", "edge", ""}: true, + {"0.2", "beta", ""}: true, + // The exclusion is scoped to its own track, it never means "any + // other track". + {"0.3", "edge", ""}: false, + // The branch is ignored, it must not be taken for part of the risk. + {"0.2", "stable", "mybranch"}: false, + {"0.2", "edge", "mybranch"}: true, + }, +}, { + summary: "Channel without a risk never matches", + values: []string{"0.3/*"}, + match: map[setup.Channel]bool{ + // A channel without a risk is not a channel, it must not match just + // because the missing risk differs from an excluded one. + {Track: "0.3"}: false, + {}: false, + }, +}, { + summary: "Several risks", + values: []string{"0.2/beta,edge"}, + match: map[setup.Channel]bool{ + {"0.2", "beta", ""}: true, + {"0.2", "edge", ""}: true, + {"0.2", "stable", ""}: false, + }, +}, { + summary: "Union of several tracks", + values: []string{"0.2/!stable", "0.3/*"}, + match: map[setup.Channel]bool{ + {"0.2", "edge", ""}: true, + {"0.2", "stable", ""}: false, + {"0.3", "stable", ""}: true, + }, +}, { + summary: "Every known risk", + values: []string{"0.3/stable,candidate,beta,edge"}, + match: map[setup.Channel]bool{ + {"0.3", "stable", ""}: true, + {"0.3", "candidate", ""}: true, + {"0.3", "beta", ""}: true, + {"0.3", "edge", ""}: true, + }, +}, { + // Splitting the pattern, in common with a concrete channel. + summary: "Spaces", + values: []string{"0.3/not stable"}, + err: `"0.3/not stable": must not contain spaces`, +}, { + summary: "Missing risk", + values: []string{"0.3"}, + err: `"0.3": must be /`, +}, { + summary: "Empty risk", + values: []string{"0.3/"}, + err: `"0.3/": must be /`, +}, { + summary: "Empty track", + values: []string{"/stable"}, + err: `"/stable": must be /`, +}, { + // A pattern never holds a branch, it applies to every branch of the risks + // it matches. + summary: "Pattern holding a branch", + values: []string{"0.3/stable/mybranch"}, + err: `"0.3/stable/mybranch": must be /`, +}, { + // The track is a literal, the operators belong to the risk. + summary: "Wildcard track", + values: []string{"*/stable"}, + err: `"\*/stable": only the risk accepts '\*', '!' and ','`, +}, { + summary: "Partial wildcard in track", + values: []string{"0.3-*/stable"}, + err: `"0.3-\*/stable": only the risk accepts '\*', '!' and ','`, +}, { + // A wildcard is the whole risk part or nothing, it is never a glob. + summary: "Wildcard is not a glob", + values: []string{"0.3/e*"}, + err: `"0.3/e\*": '\*' must be the whole risk`, +}, { + // The "!" form. + summary: "Unknown excluded risk", + values: []string{"0.3/!whatever"}, + err: `"0.3/!whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Exclusion combined with other risks", + values: []string{"0.3/!stable,edge"}, + err: `"0.3/!stable,edge": '!' cannot be combined with other risks`, +}, { + summary: "Empty excluded risk", + values: []string{"0.3/!"}, + err: `"0.3/!": must be /`, +}, { + // The "[,]" form. + summary: "Unknown risk", + values: []string{"0.3/whatever"}, + err: `"0.3/whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Unknown risk in a list", + values: []string{"0.3/edge,whatever"}, + err: `"0.3/edge,whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, +}, { + summary: "Risks are case sensitive", + values: []string{"0.3/Stable"}, + err: `"0.3/Stable": unknown risk "Stable", must be one of stable, candidate, beta, edge`, +}, { + summary: "Exclusion not prefixing the risk part", + values: []string{"0.3/edge,!stable"}, + err: `"0.3/edge,!stable": '!' must prefix the whole risk`, +}, { + summary: "Repeated risk", + values: []string{"0.3/edge,edge"}, + err: `"0.3/edge,edge": risk "edge" is repeated`, +}, { + // A trailing or leading comma leaves an empty risk in the list, which the + // split above cannot catch as the risk part is not an empty segment. + summary: "Trailing comma in a list", + values: []string{"0.3/edge,"}, + err: `"0.3/edge,": must be /`, +}, { + summary: "Leading comma in a list", + values: []string{"0.3/,edge"}, + err: `"0.3/,edge": must be /`, +}, { + // Across the patterns of one "channel" field. + summary: "Repeated track", + values: []string{"0.3/*", "0.3/edge"}, + err: `track "0.3" is repeated`, +}, { + summary: "Repeated track with identical values", + values: []string{"0.3/edge", "0.3/edge"}, + err: `track "0.3" is repeated`, +}} + +func (s *S) TestChannelPatterns(c *C) { + for _, test := range channelPatternTests { + c.Logf("Summary: %s", test.summary) + + err := setup.ValidateChannelPatterns(test.values) + if test.err != "" { + c.Assert(err, ErrorMatches, test.err) + continue + } + c.Assert(err, IsNil) + + for channel, expected := range test.match { + c.Assert(setup.MatchChannelPatterns(test.values, channel), Equals, expected, + Commentf("channel %q", channel.String())) + } + } +} From eafa24e46a51c5f0222db25499c619fe7029506b Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 14:28:12 +0200 Subject: [PATCH 02/13] feat: top-level channels in package slices files --- cmd/chisel/cmd_info.go | 1 + internal/setup/channel.go | 15 +-- internal/setup/export_test.go | 2 + internal/setup/setup.go | 3 + internal/setup/setup_test.go | 186 +++++++++++++++++++++++++++++++++ internal/setup/yaml.go | 61 ++++++++++- internal/slicer/slicer_test.go | 3 +- 7 files changed, 259 insertions(+), 12 deletions(-) diff --git a/cmd/chisel/cmd_info.go b/cmd/chisel/cmd_info.go index 67ede89a..50f5af99 100644 --- a/cmd/chisel/cmd_info.go +++ b/cmd/chisel/cmd_info.go @@ -128,6 +128,7 @@ func selectPackageSlices(release *setup.Release, queries []string) (packages []* Archive: releasePkg.Archive, Store: releasePkg.Store, DefaultTrack: releasePkg.DefaultTrack, + Channels: releasePkg.Channels, Slices: make(map[string]*setup.Slice), } for _, sliceName := range pkgSlices[pkgName] { diff --git a/internal/setup/channel.go b/internal/setup/channel.go index 60d642e9..f055f2e1 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -8,9 +8,10 @@ import ( "unicode" ) -// The "channel" field of a slice definition holds patterns selecting which -// concrete "/" channels an entry applies to. The track is a -// literal and only the risk part accepts operators: +// The top-level "channels" key of a package slices file holds patterns +// selecting which concrete "/" channels the package is +// compatible with. The track is a literal and only the risk part accepts +// operators: // // * - Any risk of that track // ! - Any risk of that track but that one @@ -28,12 +29,12 @@ type Channel struct { } func (c Channel) String() string { - if c.Track == "" { + if c == (Channel{}) { return "" } - channel := c.Track - if c.Risk != "" { - channel += "/" + c.Risk + channel := c.Risk + if c.Track != "" { + channel = c.Track + "/" + channel } if c.Branch != "" { channel += "/" + c.Branch diff --git a/internal/setup/export_test.go b/internal/setup/export_test.go index 7fd2d933..ce47de7e 100644 --- a/internal/setup/export_test.go +++ b/internal/setup/export_test.go @@ -2,6 +2,8 @@ package setup type YAMLPath = yamlPath +var ValidateChannelPatterns = validateChannelPatterns + type PathSegment = segment type PathSegmentSlice = segmentSlice type PathNode = node diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 10953ada..d1ad8343 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -65,6 +65,7 @@ type Package struct { Archive string Store string DefaultTrack string + Channels []string // Channels the package is compatible with. Slices map[string]*Slice } @@ -144,6 +145,8 @@ func ParseSliceKey(sliceKey string) (SliceKey, error) { return apacheutil.ParseSliceKey(sliceKey) } +const DefaultRisk = "stable" + func (s *Slice) String() string { return s.Package + "_" + s.Name } // Selection holds the required configuration to create a Build for a selection diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index ec61490b..5b0f033e 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -4091,6 +4091,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] `, }, release: &setup.Release{ @@ -4120,6 +4121,7 @@ var setupTests = []setupTest{{ Path: "bin-slices/mypkg.yaml", Store: "bin", DefaultTrack: "3.0", + Channels: []string{"3.0/*"}, Slices: map[string]*setup.Slice{}, }, }, @@ -4193,6 +4195,181 @@ var setupTests = []setupTest{{ `, }, relerror: `cannot parse package "mypkg": store "non-existing" not defined in release`, +}, { + summary: "Store package with multiple channels is parsed correctly", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["2/*", "3/*", "4/edge"] + `, + }, + release: &setup.Release{ + Format: "v3", + Archives: map[string]*setup.Archive{ + "ubuntu": { + Name: "ubuntu", + Version: "22.04", + Suites: []string{"jammy"}, + Components: []string{"main", "universe"}, + PubKeys: []*packet.PublicKey{testKey.PubKey}, + Maintained: true, + }, + }, + Stores: map[string]*setup.Store{ + "bin": { + Name: "bin", + Kind: "bin", + Version: "26.10", + DefaultPrefix: "bin-", + }, + }, + Packages: map[string]*setup.Package{ + "bin-mypkg": { + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + Channels: []string{"2/*", "3/*", "4/edge"}, + Slices: map[string]*setup.Slice{}, + }, + }, + Maintenance: &setup.Maintenance{ + Standard: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + EndOfLife: time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + }, +}, { + summary: "Store package with single channel value is parsed correctly", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: 2/stable + `, + }, + release: &setup.Release{ + Format: "v3", + Archives: map[string]*setup.Archive{ + "ubuntu": { + Name: "ubuntu", + Version: "22.04", + Suites: []string{"jammy"}, + Components: []string{"main", "universe"}, + PubKeys: []*packet.PublicKey{testKey.PubKey}, + Maintained: true, + }, + }, + Stores: map[string]*setup.Store{ + "bin": { + Name: "bin", + Kind: "bin", + Version: "26.10", + DefaultPrefix: "bin-", + }, + }, + Packages: map[string]*setup.Package{ + "bin-mypkg": { + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + Channels: []string{"2/stable"}, + Slices: map[string]*setup.Slice{}, + }, + }, + Maintenance: &setup.Maintenance{ + Standard: time.Date(2025, time.January, 1, 0, 0, 0, 0, time.UTC), + EndOfLife: time.Date(2100, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + }, +}, { + summary: "Store package missing channels", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + `, + }, + relerror: `cannot parse package "bin-mypkg": 'store' requires 'channels'`, +}, { + summary: "Store package with empty channels list", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + channels: [] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'store' requires 'channels'`, +}, { + summary: "channels without store", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "mypkg": 'channels' requires 'store'`, +}, { + summary: "channels used with older format (v1/v2) is not allowed", + input: map[string]string{ + "chisel.yaml": strings.ReplaceAll(testutil.DefaultChiselYaml, "format: v1", "format: v2"), + "slices/mypkg.yaml": ` + package: mypkg + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "mypkg": 'channels' is unsupported before format v3`, +}, { + // The pattern grammar itself is covered by channel_test.go. This test + // only ensures grammar errors are reported with the package context. + summary: "channels with invalid value report the package context", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "3.0" + channels: ["3.0/foo"] + `, + }, + relerror: `cannot parse package "bin-mypkg": invalid 'channels' value: "3\.0/foo": unknown risk "foo", must be one of stable, candidate, beta, edge`, +}, { + summary: "channels not matching default track", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["3.0/*"] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default track "2"`, +}, { + summary: "channels excluding the default risk", + input: map[string]string{ + "chisel.yaml": testutil.DefaultChiselYamlWithStores, + "bin-slices/mypkg.yaml": ` + package: mypkg + store: bin + default-track: "2" + channels: ["2/!stable"] + `, + }, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default track "2"`, }, { summary: "Store missing version", input: map[string]string{ @@ -4286,6 +4463,7 @@ var setupTests = []setupTest{{ package: curl store: bin default-track: "3.0" + channels: ["3.0/*"] slices: bins: contents: @@ -4340,6 +4518,7 @@ var setupTests = []setupTest{{ Path: "bin-slices/curl.yaml", Store: "bin", DefaultTrack: "3.0", + Channels: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", @@ -4364,6 +4543,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] `, }, release: &setup.Release{ @@ -4393,6 +4573,7 @@ var setupTests = []setupTest{{ Path: "slices/mypkg.yaml", Store: "bin", DefaultTrack: "3.0", + Channels: []string{"3.0/*"}, Slices: map[string]*setup.Slice{}, }, }, @@ -4455,6 +4636,7 @@ var setupTests = []setupTest{{ package: curl store: bin default-track: "3.0" + channels: ["3.0/*"] slices: bins: contents: @@ -4509,6 +4691,7 @@ var setupTests = []setupTest{{ Path: "slices/bins/curl.yaml", Store: "bin", DefaultTrack: "3.0", + Channels: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", @@ -4554,6 +4737,7 @@ var setupTests = []setupTest{{ package: mypkg store: bin default-track: "3.0" + channels: ["3.0/*"] slices: myslice: contents: @@ -4935,6 +5119,7 @@ func (s *S) TestPackageYAMLFormat(c *C) { package: mypkg store: bin default-track: "3.0" + channels: 3.0/* slices: myslice: contents: @@ -4949,6 +5134,7 @@ func (s *S) TestPackageYAMLFormat(c *C) { package: mypkg store: bin default-track: "3.0" + channels: 3.0/* slices: myslice: contents: diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 1760f1b4..78ab9d8f 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -60,10 +60,11 @@ type yamlStore struct { } type yamlPackage struct { - RealName string `yaml:"package"` - Archive string `yaml:"archive,omitempty"` - Store string `yaml:"store,omitempty"` - DefaultTrack string `yaml:"default-track,omitempty"` + RealName string `yaml:"package"` + Archive string `yaml:"archive,omitempty"` + Store string `yaml:"store,omitempty"` + DefaultTrack string `yaml:"default-track,omitempty"` + Channels yamlChannel `yaml:"channels,omitempty"` // For backwards-compatibility reasons with v1 and v2, essential needs // custom logic to be parsed. See [yamlEssentialListMap]. Essential yamlEssentialListMap `yaml:"essential,omitempty"` @@ -197,6 +198,33 @@ func (ya yamlArch) MarshalYAML() (any, error) { var _ yaml.Marshaler = yamlArch{} +type yamlChannel struct { + List []string +} + +func (yc *yamlChannel) UnmarshalYAML(value *yaml.Node) error { + var s string + var l []string + if value.Decode(&s) == nil { + yc.List = []string{s} + } else if value.Decode(&l) == nil { + yc.List = l + } else { + return fmt.Errorf("cannot decode channel") + } + // Validate channel correctness later for a better error message. + return nil +} + +func (yc yamlChannel) MarshalYAML() (any, error) { + if len(yc.List) == 1 { + return yc.List[0], nil + } + return yc.List, nil +} + +var _ yaml.Marshaler = yamlChannel{} + type yamlMode uint func (ym yamlMode) MarshalYAML() (any, error) { @@ -518,6 +546,30 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack return nil, fmt.Errorf("cannot parse package %q: 'default-track' requires 'store'", pkg.Name) } + if len(yamlPkg.Channels.List) > 0 && (release.Format == "v1" || release.Format == "v2") { + return nil, fmt.Errorf("cannot parse package %q: 'channels' is unsupported before format v3", pkg.Name) + } + if pkg.Store == "" { + if len(yamlPkg.Channels.List) > 0 { + return nil, fmt.Errorf("cannot parse package %q: 'channels' requires 'store'", pkg.Name) + } + } else { + if len(yamlPkg.Channels.List) == 0 { + return nil, fmt.Errorf("cannot parse package %q: 'store' requires 'channels'", pkg.Name) + } + if err := validateChannelPatterns(yamlPkg.Channels.List); err != nil { + return nil, fmt.Errorf("cannot parse package %q: invalid 'channels' value: %s", pkg.Name, err) + } + // The default track is resolved with the default risk, so the + // package must be compatible with that channel, otherwise cutting + // it would always fail. + defaultChannel := Channel{Track: pkg.DefaultTrack, Risk: DefaultRisk} + if !MatchChannelPatterns(yamlPkg.Channels.List, defaultChannel) { + return nil, fmt.Errorf("cannot parse package %q: 'channels' must match the default track %q", pkg.Name, pkg.DefaultTrack) + } + pkg.Channels = yamlPkg.Channels.List + } + if release.Format == "v1" || release.Format == "v2" { if yamlPkg.Essential.style != unsetEssential && yamlPkg.Essential.style != listEssential { return nil, fmt.Errorf("cannot parse package %q: essential expects a list", pkg.Name) @@ -758,6 +810,7 @@ func packageToYAML(p *Package) (*yamlPackage, error) { Archive: p.Archive, Store: p.Store, DefaultTrack: p.DefaultTrack, + Channels: yamlChannel{List: p.Channels}, Slices: make(map[string]yamlSlice, len(p.Slices)), } for name, slice := range p.Slices { diff --git a/internal/slicer/slicer_test.go b/internal/slicer/slicer_test.go index d6ef9ca0..f6910c9f 100644 --- a/internal/slicer/slicer_test.go +++ b/internal/slicer/slicer_test.go @@ -1986,7 +1986,8 @@ var slicerTests = []slicerTest{{ "slices/curl.yaml": ` package: curl store: bin - default-track: latest + default-track: 1 + channels: ["1/*"] slices: bin: contents: From ac3c234e68f36db1ad468b4b7d62762cd4226705 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 14:41:25 +0200 Subject: [PATCH 03/13] docs: clean low quality comments --- internal/setup/channel.go | 16 +++++++--------- internal/setup/channel_test.go | 26 +++++--------------------- internal/setup/setup_test.go | 2 -- 3 files changed, 12 insertions(+), 32 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index f055f2e1..cb63b53b 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -8,10 +8,8 @@ import ( "unicode" ) -// The top-level "channels" key of a package slices file holds patterns -// selecting which concrete "/" channels the package is -// compatible with. The track is a literal and only the risk part accepts -// operators: +// Channel patterns follow the "/" form, where the track is +// a literal and only the risk part accepts operators: // // * - Any risk of that track // ! - Any risk of that track but that one @@ -73,9 +71,9 @@ func validateRisk(risk string) error { return nil } -// validateChannelPatterns validates the values of a "channel" field. A track -// may appear at most once across the values so that the resulting set of -// channels is unambiguous. +// validateChannelPatterns validates a list of patterns. A track may appear +// at most once across the values so that the resulting set of channels is +// unambiguous. func validateChannelPatterns(patterns []string) error { seen := make(map[string]bool, len(patterns)) for _, pattern := range patterns { @@ -149,8 +147,8 @@ func validateChannelPattern(pattern string) (track string, err error) { } // MatchChannelPatterns reports whether the concrete "/" channel -// matches any of the patterns. An empty list matches every channel, which means -// the entry is not channel specific. +// matches any of the patterns. An empty list matches every channel, which +// means the patterns carry no restriction. // // A branch, as in "//", is ignored. Branches are ephemeral // and thus never part of a pattern, so an entry applies to every branch of the diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go index 3a3d52b9..fd87de60 100644 --- a/internal/setup/channel_test.go +++ b/internal/setup/channel_test.go @@ -6,11 +6,6 @@ import ( "github.com/canonical/chisel/internal/setup" ) -// channelPatternTests covers validating and matching the patterns of a -// "channel" field. The valid patterns come first, then the invalid ones, -// grouped after the validation phase they exercise. Note several of the latter -// share the "must be /" message while entering through different -// phases, hence none is redundant. var channelPatternTests = []struct { summary string values []string @@ -31,8 +26,7 @@ var channelPatternTests = []struct { {"0.3", "stable", ""}: true, {"0.3", "edge", ""}: false, {"0.2", "stable", ""}: false, - // Branches are ephemeral, hence never part of a pattern. The entry - // applies to every branch of the risk it matches. + // Branches are ephemeral, hence never part of a pattern. {"0.3", "stable", "mybranch"}: true, {"0.3", "edge", "mybranch"}: false, }, @@ -51,10 +45,9 @@ var channelPatternTests = []struct { {"0.2", "stable", ""}: false, {"0.2", "edge", ""}: true, {"0.2", "beta", ""}: true, - // The exclusion is scoped to its own track, it never means "any - // other track". + // The exclusion is scoped to its own track. {"0.3", "edge", ""}: false, - // The branch is ignored, it must not be taken for part of the risk. + // The branch is ignored, it is not part of the risk. {"0.2", "stable", "mybranch"}: false, {"0.2", "edge", "mybranch"}: true, }, @@ -62,8 +55,7 @@ var channelPatternTests = []struct { summary: "Channel without a risk never matches", values: []string{"0.3/*"}, match: map[setup.Channel]bool{ - // A channel without a risk is not a channel, it must not match just - // because the missing risk differs from an excluded one. + // A channel without a risk is not a channel. {Track: "0.3"}: false, {}: false, }, @@ -93,7 +85,6 @@ var channelPatternTests = []struct { {"0.3", "edge", ""}: true, }, }, { - // Splitting the pattern, in common with a concrete channel. summary: "Spaces", values: []string{"0.3/not stable"}, err: `"0.3/not stable": must not contain spaces`, @@ -110,13 +101,10 @@ var channelPatternTests = []struct { values: []string{"/stable"}, err: `"/stable": must be /`, }, { - // A pattern never holds a branch, it applies to every branch of the risks - // it matches. summary: "Pattern holding a branch", values: []string{"0.3/stable/mybranch"}, err: `"0.3/stable/mybranch": must be /`, }, { - // The track is a literal, the operators belong to the risk. summary: "Wildcard track", values: []string{"*/stable"}, err: `"\*/stable": only the risk accepts '\*', '!' and ','`, @@ -125,12 +113,10 @@ var channelPatternTests = []struct { values: []string{"0.3-*/stable"}, err: `"0.3-\*/stable": only the risk accepts '\*', '!' and ','`, }, { - // A wildcard is the whole risk part or nothing, it is never a glob. summary: "Wildcard is not a glob", values: []string{"0.3/e*"}, err: `"0.3/e\*": '\*' must be the whole risk`, }, { - // The "!" form. summary: "Unknown excluded risk", values: []string{"0.3/!whatever"}, err: `"0.3/!whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, @@ -143,7 +129,6 @@ var channelPatternTests = []struct { values: []string{"0.3/!"}, err: `"0.3/!": must be /`, }, { - // The "[,]" form. summary: "Unknown risk", values: []string{"0.3/whatever"}, err: `"0.3/whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, @@ -165,7 +150,7 @@ var channelPatternTests = []struct { err: `"0.3/edge,edge": risk "edge" is repeated`, }, { // A trailing or leading comma leaves an empty risk in the list, which the - // split above cannot catch as the risk part is not an empty segment. + // split cannot catch as the risk part is not an empty segment. summary: "Trailing comma in a list", values: []string{"0.3/edge,"}, err: `"0.3/edge,": must be /`, @@ -174,7 +159,6 @@ var channelPatternTests = []struct { values: []string{"0.3/,edge"}, err: `"0.3/,edge": must be /`, }, { - // Across the patterns of one "channel" field. summary: "Repeated track", values: []string{"0.3/*", "0.3/edge"}, err: `track "0.3" is repeated`, diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 5b0f033e..033d67c9 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -4333,8 +4333,6 @@ var setupTests = []setupTest{{ }, relerror: `cannot parse package "mypkg": 'channels' is unsupported before format v3`, }, { - // The pattern grammar itself is covered by channel_test.go. This test - // only ensures grammar errors are reported with the package context. summary: "channels with invalid value report the package context", input: map[string]string{ "chisel.yaml": testutil.DefaultChiselYamlWithStores, From 71d913f8923eb2679d4ecb475acd1fa3acdec9d3 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 15:02:12 +0200 Subject: [PATCH 04/13] docs: refine comments and naming --- internal/setup/channel.go | 69 ++++++++++++++++------------------ internal/setup/channel_test.go | 14 +++---- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index cb63b53b..55ed5fa0 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -8,17 +8,6 @@ import ( "unicode" ) -// Channel patterns follow the "/" form, where the track is -// a literal and only the risk part accepts operators: -// -// * - Any risk of that track -// ! - Any risk of that track but that one -// [,...] - Only those risks of that track -// -// Patterns are kept as written and interpreted on each match, as done for -// globs in the strdist package. They are validated when the release is read so -// that a malformed value is reported early, and rendered back verbatim. - // Channel is a store channel, as in "/[/]". type Channel struct { Track string @@ -40,13 +29,22 @@ func (c Channel) String() string { return channel } +// Channel patterns follow the "/" form, where the +// track is a literal and only the risk pattern accepts operators: +// +// * - Any risk of that track +// ! - Any risk of that track but that one +// [,...] - Only those risks of that track +// +// Patterns are kept as written and interpreted on each match, as done for +// globs in the strdist package. They are validated when the release is read so +// that a malformed value is reported early, and rendered back verbatim. + // The form a channel pattern must take, as reported to the user. -const channelPatternForm = "/" +const channelPatternForm = "/" -// splitChannel splits a channel or a channel pattern on "/", checking what the -// two have in common: no spaces and no empty segment. How many segments are -// expected and what each one means is left to the caller, as that is where the -// two differ. form is the shape reported on error. +// splitChannel splits a channel or a channel pattern on "/", rejecting values +// holding spaces or an empty segment. func splitChannel(value, form string) ([]string, error) { if strings.ContainsFunc(value, unicode.IsSpace) { return nil, errors.New("must not contain spaces") @@ -59,11 +57,9 @@ func splitChannel(value, form string) ([]string, error) { } // knownRisks holds every risk a channel may hold, from the most to the least -// stable. The set is defined by the store and does not depend on its content, -// hence risks are validated as architectures are. +// stable. The set is defined by the store and does not depend on its content. var knownRisks = []string{"stable", "candidate", "beta", "edge"} -// validateRisk validates a single risk of a channel or of a channel pattern. func validateRisk(risk string) error { if !slices.Contains(knownRisks, risk) { return fmt.Errorf("unknown risk %q, must be one of %s", risk, strings.Join(knownRisks, ", ")) @@ -90,7 +86,7 @@ func validateChannelPatterns(patterns []string) error { } // validateChannelPattern validates a single pattern and returns its track. A -// pattern holds no branch, hence exactly one track and one risk part. +// pattern holds no branch, hence exactly one track and one risk pattern. func validateChannelPattern(pattern string) (track string, err error) { segments, err := splitChannel(pattern, channelPatternForm) if err != nil { @@ -99,23 +95,23 @@ func validateChannelPattern(pattern string) (track string, err error) { if len(segments) != 2 { return "", fmt.Errorf("must be %s", channelPatternForm) } - track, riskPart := segments[0], segments[1] + track, riskPattern := segments[0], segments[1] if strings.ContainsAny(track, "*!,") { return "", errors.New("only the risk accepts '*', '!' and ','") } - if riskPart != "*" && strings.Contains(riskPart, "*") { - // Checked before the risk part takes one of the forms below, as a + if riskPattern != "*" && strings.Contains(riskPattern, "*") { + // Checked before the risk pattern takes one of the forms below, as a // wildcard is not allowed within any of them. return "", errors.New("'*' must be the whole risk") } - // The risk part takes one of the three forms of the grammar. + // The risk pattern takes one of the three forms of the grammar. switch { - case riskPart == "*": + case riskPattern == "*": // Every risk of the track, nothing more to validate. - case strings.HasPrefix(riskPart, "!"): + case strings.HasPrefix(riskPattern, "!"): // Every risk of the track but the excluded one. - except := strings.TrimPrefix(riskPart, "!") + except := strings.TrimPrefix(riskPattern, "!") if strings.Contains(except, ",") { return "", errors.New("'!' cannot be combined with other risks") } @@ -127,7 +123,7 @@ func validateChannelPattern(pattern string) (track string, err error) { } default: // Only the listed risks of the track. - risks := strings.Split(riskPart, ",") + risks := strings.Split(riskPattern, ",") for i, risk := range risks { if risk == "" { return "", fmt.Errorf("must be %s", channelPatternForm) @@ -146,7 +142,7 @@ func validateChannelPattern(pattern string) (track string, err error) { return track, nil } -// MatchChannelPatterns reports whether the concrete "/" channel +// MatchChannelPatterns returns whether the concrete "/" channel // matches any of the patterns. An empty list matches every channel, which // means the patterns carry no restriction. // @@ -158,8 +154,9 @@ func MatchChannelPatterns(patterns []string, channel Channel) bool { return true } if channel.Track == "" || channel.Risk == "" { - // A channel without a risk is not a channel. Never match it, rather - // than treat the missing risk as one that differs from an excluded one. + // A channel without a track or a risk is not a channel. Never match + // it, rather than treat the missing risk as one that differs from an + // excluded one. return false } for _, pattern := range patterns { @@ -170,21 +167,21 @@ func MatchChannelPatterns(patterns []string, channel Channel) bool { return false } -// matchChannel reports whether the pattern matches the track and the risk of a +// matchChannel returns whether the pattern matches the track and the risk of a // concrete channel. Note that the exclusion form is scoped to its own track, so // "1.0/!stable" does not match any risk of the "2.0" track. // // The pattern is expected to be valid, as ensured when the release is read. func matchChannel(pattern, track, risk string) bool { - patternTrack, riskPart, _ := strings.Cut(pattern, "/") + patternTrack, riskPattern, _ := strings.Cut(pattern, "/") if track != patternTrack { return false } - if riskPart == "*" { + if riskPattern == "*" { return true } - if except, ok := strings.CutPrefix(riskPart, "!"); ok { + if except, ok := strings.CutPrefix(riskPattern, "!"); ok { return risk != except } - return slices.Contains(strings.Split(riskPart, ","), risk) + return slices.Contains(strings.Split(riskPattern, ","), risk) } diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go index fd87de60..b7b4bf4a 100644 --- a/internal/setup/channel_test.go +++ b/internal/setup/channel_test.go @@ -91,19 +91,19 @@ var channelPatternTests = []struct { }, { summary: "Missing risk", values: []string{"0.3"}, - err: `"0.3": must be /`, + err: `"0.3": must be /`, }, { summary: "Empty risk", values: []string{"0.3/"}, - err: `"0.3/": must be /`, + err: `"0.3/": must be /`, }, { summary: "Empty track", values: []string{"/stable"}, - err: `"/stable": must be /`, + err: `"/stable": must be /`, }, { summary: "Pattern holding a branch", values: []string{"0.3/stable/mybranch"}, - err: `"0.3/stable/mybranch": must be /`, + err: `"0.3/stable/mybranch": must be /`, }, { summary: "Wildcard track", values: []string{"*/stable"}, @@ -127,7 +127,7 @@ var channelPatternTests = []struct { }, { summary: "Empty excluded risk", values: []string{"0.3/!"}, - err: `"0.3/!": must be /`, + err: `"0.3/!": must be /`, }, { summary: "Unknown risk", values: []string{"0.3/whatever"}, @@ -153,11 +153,11 @@ var channelPatternTests = []struct { // split cannot catch as the risk part is not an empty segment. summary: "Trailing comma in a list", values: []string{"0.3/edge,"}, - err: `"0.3/edge,": must be /`, + err: `"0.3/edge,": must be /`, }, { summary: "Leading comma in a list", values: []string{"0.3/,edge"}, - err: `"0.3/,edge": must be /`, + err: `"0.3/,edge": must be /`, }, { summary: "Repeated track", values: []string{"0.3/*", "0.3/edge"}, From fbcaefb07cc4a94908c007461c161fc3fe6d3876 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 15:27:31 +0200 Subject: [PATCH 05/13] fix: improve error message --- internal/setup/setup_test.go | 4 ++-- internal/setup/yaml.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 033d67c9..6b771ece 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -4355,7 +4355,7 @@ var setupTests = []setupTest{{ channels: ["3.0/*"] `, }, - relerror: `cannot parse package "bin-mypkg": 'channels' must match the default track "2"`, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default channel "2/stable"`, }, { summary: "channels excluding the default risk", input: map[string]string{ @@ -4367,7 +4367,7 @@ var setupTests = []setupTest{{ channels: ["2/!stable"] `, }, - relerror: `cannot parse package "bin-mypkg": 'channels' must match the default track "2"`, + relerror: `cannot parse package "bin-mypkg": 'channels' must match the default channel "2/stable"`, }, { summary: "Store missing version", input: map[string]string{ diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 78ab9d8f..2a9cbcfd 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -565,7 +565,7 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack // it would always fail. defaultChannel := Channel{Track: pkg.DefaultTrack, Risk: DefaultRisk} if !MatchChannelPatterns(yamlPkg.Channels.List, defaultChannel) { - return nil, fmt.Errorf("cannot parse package %q: 'channels' must match the default track %q", pkg.Name, pkg.DefaultTrack) + return nil, fmt.Errorf("cannot parse package %q: 'channels' must match the default channel %q", pkg.Name, defaultChannel) } pkg.Channels = yamlPkg.Channels.List } From 8b2c8c73cc1386adddfa36546fbcf31efc988812 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 16:23:07 +0200 Subject: [PATCH 06/13] docs: clean more comments --- internal/setup/channel.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index 55ed5fa0..5b6f0a7b 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -36,9 +36,7 @@ func (c Channel) String() string { // ! - Any risk of that track but that one // [,...] - Only those risks of that track // -// Patterns are kept as written and interpreted on each match, as done for -// globs in the strdist package. They are validated when the release is read so -// that a malformed value is reported early, and rendered back verbatim. +// Patterns are kept as written and interpreted on each match. // The form a channel pattern must take, as reported to the user. const channelPatternForm = "/" From 2a980f3bbc163dc7d52610c555d7c76085f92fd7 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 17:04:26 +0200 Subject: [PATCH 07/13] fix: improve names consistency --- cmd/chisel/cmd_info.go | 14 +++--- internal/setup/channel.go | 20 +++++---- internal/setup/setup.go | 18 ++++---- internal/setup/setup_test.go | 84 ++++++++++++++++++------------------ internal/setup/yaml.go | 71 +++++++++++++++--------------- 5 files changed, 102 insertions(+), 105 deletions(-) diff --git a/cmd/chisel/cmd_info.go b/cmd/chisel/cmd_info.go index 50f5af99..f360e6d6 100644 --- a/cmd/chisel/cmd_info.go +++ b/cmd/chisel/cmd_info.go @@ -123,13 +123,13 @@ func selectPackageSlices(release *setup.Release, queries []string) (packages []* } else { releasePkg := release.Packages[pkgName] pkg = &setup.Package{ - Name: releasePkg.Name, - RealName: releasePkg.RealName, - Archive: releasePkg.Archive, - Store: releasePkg.Store, - DefaultTrack: releasePkg.DefaultTrack, - Channels: releasePkg.Channels, - Slices: make(map[string]*setup.Slice), + Name: releasePkg.Name, + RealName: releasePkg.RealName, + Archive: releasePkg.Archive, + Store: releasePkg.Store, + DefaultTrack: releasePkg.DefaultTrack, + ChannelPatterns: releasePkg.ChannelPatterns, + Slices: make(map[string]*setup.Slice), } for _, sliceName := range pkgSlices[pkgName] { pkg.Slices[sliceName] = releasePkg.Slices[sliceName] diff --git a/internal/setup/channel.go b/internal/setup/channel.go index 5b6f0a7b..53f9b768 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -29,6 +29,8 @@ func (c Channel) String() string { return channel } +const DefaultRisk = "stable" + // Channel patterns follow the "/" form, where the // track is a literal and only the risk pattern accepts operators: // @@ -158,28 +160,28 @@ func MatchChannelPatterns(patterns []string, channel Channel) bool { return false } for _, pattern := range patterns { - if matchChannel(pattern, channel.Track, channel.Risk) { + if matchChannelPattern(pattern, channel) { return true } } return false } -// matchChannel returns whether the pattern matches the track and the risk of a -// concrete channel. Note that the exclusion form is scoped to its own track, so -// "1.0/!stable" does not match any risk of the "2.0" track. +// matchChannelPattern returns whether the pattern matches the given channel. +// Note that the exclusion form is scoped to its own track, so "1.0/!stable" +// does not match any risk of the "2.0" track. // -// The pattern is expected to be valid, as ensured when the release is read. -func matchChannel(pattern, track, risk string) bool { +// The pattern is expected to be valid. +func matchChannelPattern(pattern string, channel Channel) bool { patternTrack, riskPattern, _ := strings.Cut(pattern, "/") - if track != patternTrack { + if channel.Track != patternTrack { return false } if riskPattern == "*" { return true } if except, ok := strings.CutPrefix(riskPattern, "!"); ok { - return risk != except + return channel.Risk != except } - return slices.Contains(strings.Split(riskPattern, ","), risk) + return slices.Contains(strings.Split(riskPattern, ","), channel.Risk) } diff --git a/internal/setup/setup.go b/internal/setup/setup.go index d1ad8343..a3547631 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -59,14 +59,14 @@ type Archive struct { // Package holds a collection of slices that represent parts of themselves. type Package struct { - Name string // Unique across all known packages (e.g. "bin-curl") - RealName string // Actual name as known in the archive or store (e.g. "curl") - Path string - Archive string - Store string - DefaultTrack string - Channels []string // Channels the package is compatible with. - Slices map[string]*Slice + Name string // Unique across all known packages (e.g. "bin-curl") + RealName string // Actual name as known in the archive or store (e.g. "curl") + Path string + Archive string + Store string + DefaultTrack string + ChannelPatterns []string // Channels patterns the package is compatible with. + Slices map[string]*Slice } // Slice holds the details about a package slice. @@ -145,8 +145,6 @@ func ParseSliceKey(sliceKey string) (SliceKey, error) { return apacheutil.ParseSliceKey(sliceKey) } -const DefaultRisk = "stable" - func (s *Slice) String() string { return s.Package + "_" + s.Name } // Selection holds the required configuration to create a Build for a selection diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go index 6b771ece..e8d62ccf 100644 --- a/internal/setup/setup_test.go +++ b/internal/setup/setup_test.go @@ -4116,13 +4116,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "bin-slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "3.0", - Channels: []string{"3.0/*"}, - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4140,7 +4140,7 @@ var setupTests = []setupTest{{ default-track: "3.0" `, }, - relerror: `cannot parse package "mypkg": 'store' and 'default-track' are unsupported before format v3`, + relerror: `cannot parse package "mypkg": 'store', 'default-track' and 'channels' are unsupported before format v3`, }, { summary: "Store and archive are mutually exclusive", input: map[string]string{ @@ -4228,13 +4228,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "bin-slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "2", - Channels: []string{"2/*", "3/*", "4/edge"}, - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + ChannelPatterns: []string{"2/*", "3/*", "4/edge"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4275,13 +4275,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "bin-slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "2", - Channels: []string{"2/stable"}, - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "bin-slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "2", + ChannelPatterns: []string{"2/stable"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4331,7 +4331,7 @@ var setupTests = []setupTest{{ channels: ["3.0/*"] `, }, - relerror: `cannot parse package "mypkg": 'channels' is unsupported before format v3`, + relerror: `cannot parse package "mypkg": 'store', 'default-track' and 'channels' are unsupported before format v3`, }, { summary: "channels with invalid value report the package context", input: map[string]string{ @@ -4511,12 +4511,12 @@ var setupTests = []setupTest{{ }, }, "bin-curl": { - RealName: "curl", - Name: "bin-curl", - Path: "bin-slices/curl.yaml", - Store: "bin", - DefaultTrack: "3.0", - Channels: []string{"3.0/*"}, + RealName: "curl", + Name: "bin-curl", + Path: "bin-slices/curl.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", @@ -4566,13 +4566,13 @@ var setupTests = []setupTest{{ }, Packages: map[string]*setup.Package{ "bin-mypkg": { - RealName: "mypkg", - Name: "bin-mypkg", - Path: "slices/mypkg.yaml", - Store: "bin", - DefaultTrack: "3.0", - Channels: []string{"3.0/*"}, - Slices: map[string]*setup.Slice{}, + RealName: "mypkg", + Name: "bin-mypkg", + Path: "slices/mypkg.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, + Slices: map[string]*setup.Slice{}, }, }, Maintenance: &setup.Maintenance{ @@ -4684,12 +4684,12 @@ var setupTests = []setupTest{{ }, }, "bin-curl": { - RealName: "curl", - Name: "bin-curl", - Path: "slices/bins/curl.yaml", - Store: "bin", - DefaultTrack: "3.0", - Channels: []string{"3.0/*"}, + RealName: "curl", + Name: "bin-curl", + Path: "slices/bins/curl.yaml", + Store: "bin", + DefaultTrack: "3.0", + ChannelPatterns: []string{"3.0/*"}, Slices: map[string]*setup.Slice{ "bins": { Package: "bin-curl", diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 2a9cbcfd..7ee1320a 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -60,11 +60,11 @@ type yamlStore struct { } type yamlPackage struct { - RealName string `yaml:"package"` - Archive string `yaml:"archive,omitempty"` - Store string `yaml:"store,omitempty"` - DefaultTrack string `yaml:"default-track,omitempty"` - Channels yamlChannel `yaml:"channels,omitempty"` + RealName string `yaml:"package"` + Archive string `yaml:"archive,omitempty"` + Store string `yaml:"store,omitempty"` + DefaultTrack string `yaml:"default-track,omitempty"` + ChannelPatterns yamlChannelPattern `yaml:"channels,omitempty"` // For backwards-compatibility reasons with v1 and v2, essential needs // custom logic to be parsed. See [yamlEssentialListMap]. Essential yamlEssentialListMap `yaml:"essential,omitempty"` @@ -128,8 +128,10 @@ func (es yamlEssentialListMap) MarshalYAML() (any, error) { return es.Values, nil } -var _ yaml.Marshaler = yamlEssentialListMap{} -var _ yaml.Unmarshaler = (*yamlEssentialListMap)(nil) +var ( + _ yaml.Marshaler = yamlEssentialListMap{} + _ yaml.Unmarshaler = (*yamlEssentialListMap)(nil) +) type yamlPath struct { Dir bool `yaml:"make,omitempty"` @@ -198,11 +200,11 @@ func (ya yamlArch) MarshalYAML() (any, error) { var _ yaml.Marshaler = yamlArch{} -type yamlChannel struct { +type yamlChannelPattern struct { List []string } -func (yc *yamlChannel) UnmarshalYAML(value *yaml.Node) error { +func (yc *yamlChannelPattern) UnmarshalYAML(value *yaml.Node) error { var s string var l []string if value.Decode(&s) == nil { @@ -210,20 +212,20 @@ func (yc *yamlChannel) UnmarshalYAML(value *yaml.Node) error { } else if value.Decode(&l) == nil { yc.List = l } else { - return fmt.Errorf("cannot decode channel") + return fmt.Errorf("cannot decode channels") } - // Validate channel correctness later for a better error message. + // Validate channel pattern correctness later for a better error message. return nil } -func (yc yamlChannel) MarshalYAML() (any, error) { +func (yc yamlChannelPattern) MarshalYAML() (any, error) { if len(yc.List) == 1 { return yc.List[0], nil } return yc.List, nil } -var _ yaml.Marshaler = yamlChannel{} +var _ yaml.Marshaler = yamlChannelPattern{} type yamlMode uint @@ -519,8 +521,8 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack if yamlPkg.RealName != pkg.RealName { return nil, fmt.Errorf("%s: filename and 'package' field (%q) disagree", pkgPath, yamlPkg.RealName) } - if (pkg.Store != "" || pkg.DefaultTrack != "") && (release.Format == "v1" || release.Format == "v2") { - return nil, fmt.Errorf("cannot parse package %q: 'store' and 'default-track' are unsupported before format v3", pkg.Name) + if (pkg.Store != "" || pkg.DefaultTrack != "" || len(yamlPkg.ChannelPatterns.List) > 0) && (release.Format == "v1" || release.Format == "v2") { + return nil, fmt.Errorf("cannot parse package %q: 'store', 'default-track' and 'channels' are unsupported before format v3", pkg.Name) } // Derive the package unique name from its store prefix if applicable. @@ -542,32 +544,27 @@ func parsePackage(release *Release, pkgName, pkgPath string, data []byte) (*Pack if strings.Contains(pkg.DefaultTrack, "/") { return nil, fmt.Errorf("cannot parse package %q: 'default-track' must not contain /", pkg.Name) } - } else if pkg.DefaultTrack != "" { - return nil, fmt.Errorf("cannot parse package %q: 'default-track' requires 'store'", pkg.Name) - } - - if len(yamlPkg.Channels.List) > 0 && (release.Format == "v1" || release.Format == "v2") { - return nil, fmt.Errorf("cannot parse package %q: 'channels' is unsupported before format v3", pkg.Name) - } - if pkg.Store == "" { - if len(yamlPkg.Channels.List) > 0 { - return nil, fmt.Errorf("cannot parse package %q: 'channels' requires 'store'", pkg.Name) - } - } else { - if len(yamlPkg.Channels.List) == 0 { + if len(yamlPkg.ChannelPatterns.List) == 0 { return nil, fmt.Errorf("cannot parse package %q: 'store' requires 'channels'", pkg.Name) } - if err := validateChannelPatterns(yamlPkg.Channels.List); err != nil { + if err := validateChannelPatterns(yamlPkg.ChannelPatterns.List); err != nil { return nil, fmt.Errorf("cannot parse package %q: invalid 'channels' value: %s", pkg.Name, err) } // The default track is resolved with the default risk, so the // package must be compatible with that channel, otherwise cutting // it would always fail. defaultChannel := Channel{Track: pkg.DefaultTrack, Risk: DefaultRisk} - if !MatchChannelPatterns(yamlPkg.Channels.List, defaultChannel) { + if !MatchChannelPatterns(yamlPkg.ChannelPatterns.List, defaultChannel) { return nil, fmt.Errorf("cannot parse package %q: 'channels' must match the default channel %q", pkg.Name, defaultChannel) } - pkg.Channels = yamlPkg.Channels.List + pkg.ChannelPatterns = yamlPkg.ChannelPatterns.List + } else { + if pkg.DefaultTrack != "" { + return nil, fmt.Errorf("cannot parse package %q: 'default-track' requires 'store'", pkg.Name) + } + if len(yamlPkg.ChannelPatterns.List) > 0 { + return nil, fmt.Errorf("cannot parse package %q: 'channels' requires 'store'", pkg.Name) + } } if release.Format == "v1" || release.Format == "v2" { @@ -806,12 +803,12 @@ func sliceToYAML(s *Slice) (*yamlSlice, error) { // packageToYAML converts a Package object to a yamlPackage object. func packageToYAML(p *Package) (*yamlPackage, error) { pkg := &yamlPackage{ - RealName: p.RealName, - Archive: p.Archive, - Store: p.Store, - DefaultTrack: p.DefaultTrack, - Channels: yamlChannel{List: p.Channels}, - Slices: make(map[string]yamlSlice, len(p.Slices)), + RealName: p.RealName, + Archive: p.Archive, + Store: p.Store, + DefaultTrack: p.DefaultTrack, + ChannelPatterns: yamlChannelPattern{List: p.ChannelPatterns}, + Slices: make(map[string]yamlSlice, len(p.Slices)), } for name, slice := range p.Slices { yamlSlice, err := sliceToYAML(slice) From d3fca8589fafbe76014bdb42608e303ca21dcce0 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Fri, 9 Oct 2026 17:10:00 +0200 Subject: [PATCH 08/13] docs: fix typos --- internal/setup/channel.go | 2 +- internal/setup/setup.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index 53f9b768..56cc8a65 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -68,7 +68,7 @@ func validateRisk(risk string) error { } // validateChannelPatterns validates a list of patterns. A track may appear -// at most once across the values so that the resulting set of channels is +// at most once across the patterns so that the resulting set of channels is // unambiguous. func validateChannelPatterns(patterns []string) error { seen := make(map[string]bool, len(patterns)) diff --git a/internal/setup/setup.go b/internal/setup/setup.go index a3547631..24c207c9 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -65,7 +65,7 @@ type Package struct { Archive string Store string DefaultTrack string - ChannelPatterns []string // Channels patterns the package is compatible with. + ChannelPatterns []string // Channel patterns the package is compatible with. Slices map[string]*Slice } From 4cd854944f2490d55241df5f2c69eb356a0050f8 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Mon, 12 Oct 2026 16:54:10 +0200 Subject: [PATCH 09/13] docs: improve comments --- internal/setup/channel.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index 56cc8a65..f9667096 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -31,15 +31,6 @@ func (c Channel) String() string { const DefaultRisk = "stable" -// Channel patterns follow the "/" form, where the -// track is a literal and only the risk pattern accepts operators: -// -// * - Any risk of that track -// ! - Any risk of that track but that one -// [,...] - Only those risks of that track -// -// Patterns are kept as written and interpreted on each match. - // The form a channel pattern must take, as reported to the user. const channelPatternForm = "/" @@ -146,6 +137,15 @@ func validateChannelPattern(pattern string) (track string, err error) { // matches any of the patterns. An empty list matches every channel, which // means the patterns carry no restriction. // +// Channel patterns follow the "/" form, where the +// track is a literal and only the risk pattern accepts operators: +// +// - - Any risk of that track +// ! - Any risk of that track but that one +// [,...] - Only those risks of that track +// +// Patterns are kept as written and interpreted on each match. +// // A branch, as in "//", is ignored. Branches are ephemeral // and thus never part of a pattern, so an entry applies to every branch of the // risk it matches. @@ -154,9 +154,9 @@ func MatchChannelPatterns(patterns []string, channel Channel) bool { return true } if channel.Track == "" || channel.Risk == "" { - // A channel without a track or a risk is not a channel. Never match - // it, rather than treat the missing risk as one that differs from an - // excluded one. + // An empty track or risk means the channel is not fully specified, + // so it matches no pattern. In particular, an empty risk must not + // match an exclusion such as "!stable", which it otherwise would. return false } for _, pattern := range patterns { From a88148f662f5616f12ad2d4f4c56c1f5f4f0ab6e Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Mon, 12 Oct 2026 16:58:19 +0200 Subject: [PATCH 10/13] fix: revert unwanted change --- internal/setup/yaml.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 7ee1320a..2a2938e4 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -128,10 +128,8 @@ func (es yamlEssentialListMap) MarshalYAML() (any, error) { return es.Values, nil } -var ( - _ yaml.Marshaler = yamlEssentialListMap{} - _ yaml.Unmarshaler = (*yamlEssentialListMap)(nil) -) +var _ yaml.Marshaler = yamlEssentialListMap{} +var _ yaml.Unmarshaler = (*yamlEssentialListMap)(nil) type yamlPath struct { Dir bool `yaml:"make,omitempty"` From 11bc7e4711389baa23f434da673a3ba403dbfe5f Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Mon, 12 Oct 2026 16:58:58 +0200 Subject: [PATCH 11/13] docs: clean more comments --- internal/setup/channel_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go index b7b4bf4a..e8d24039 100644 --- a/internal/setup/channel_test.go +++ b/internal/setup/channel_test.go @@ -55,7 +55,6 @@ var channelPatternTests = []struct { summary: "Channel without a risk never matches", values: []string{"0.3/*"}, match: map[setup.Channel]bool{ - // A channel without a risk is not a channel. {Track: "0.3"}: false, {}: false, }, @@ -149,8 +148,6 @@ var channelPatternTests = []struct { values: []string{"0.3/edge,edge"}, err: `"0.3/edge,edge": risk "edge" is repeated`, }, { - // A trailing or leading comma leaves an empty risk in the list, which the - // split cannot catch as the risk part is not an empty segment. summary: "Trailing comma in a list", values: []string{"0.3/edge,"}, err: `"0.3/edge,": must be /`, From eb5310924430e6dc72eda58c3599ea3ba15626b3 Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Mon, 12 Oct 2026 17:06:23 +0200 Subject: [PATCH 12/13] test: simplify track values --- internal/setup/channel_test.go | 142 ++++++++++++++++----------------- 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/internal/setup/channel_test.go b/internal/setup/channel_test.go index e8d24039..a63a312f 100644 --- a/internal/setup/channel_test.go +++ b/internal/setup/channel_test.go @@ -16,153 +16,153 @@ var channelPatternTests = []struct { summary: "No pattern matches every channel", values: nil, match: map[setup.Channel]bool{ - {"3.0", "stable", ""}: true, - {"2.0", "edge", ""}: true, + {"3", "stable", ""}: true, + {"2", "edge", ""}: true, }, }, { summary: "Precise channel", - values: []string{"0.3/stable"}, + values: []string{"3/stable"}, match: map[setup.Channel]bool{ - {"0.3", "stable", ""}: true, - {"0.3", "edge", ""}: false, - {"0.2", "stable", ""}: false, + {"3", "stable", ""}: true, + {"3", "edge", ""}: false, + {"2", "stable", ""}: false, // Branches are ephemeral, hence never part of a pattern. - {"0.3", "stable", "mybranch"}: true, - {"0.3", "edge", "mybranch"}: false, + {"3", "stable", "mybranch"}: true, + {"3", "edge", "mybranch"}: false, }, }, { summary: "All risks of a track", - values: []string{"0.3/*"}, + values: []string{"3/*"}, match: map[setup.Channel]bool{ - {"0.3", "stable", ""}: true, - {"0.3", "edge", ""}: true, - {"0.2", "stable", ""}: false, + {"3", "stable", ""}: true, + {"3", "edge", ""}: true, + {"2", "stable", ""}: false, }, }, { summary: "Excluded risk", - values: []string{"0.2/!stable"}, + values: []string{"2/!stable"}, match: map[setup.Channel]bool{ - {"0.2", "stable", ""}: false, - {"0.2", "edge", ""}: true, - {"0.2", "beta", ""}: true, + {"2", "stable", ""}: false, + {"2", "edge", ""}: true, + {"2", "beta", ""}: true, // The exclusion is scoped to its own track. - {"0.3", "edge", ""}: false, + {"3", "edge", ""}: false, // The branch is ignored, it is not part of the risk. - {"0.2", "stable", "mybranch"}: false, - {"0.2", "edge", "mybranch"}: true, + {"2", "stable", "mybranch"}: false, + {"2", "edge", "mybranch"}: true, }, }, { summary: "Channel without a risk never matches", - values: []string{"0.3/*"}, + values: []string{"3/*"}, match: map[setup.Channel]bool{ - {Track: "0.3"}: false, - {}: false, + {Track: "3"}: false, + {}: false, }, }, { summary: "Several risks", - values: []string{"0.2/beta,edge"}, + values: []string{"2/beta,edge"}, match: map[setup.Channel]bool{ - {"0.2", "beta", ""}: true, - {"0.2", "edge", ""}: true, - {"0.2", "stable", ""}: false, + {"2", "beta", ""}: true, + {"2", "edge", ""}: true, + {"2", "stable", ""}: false, }, }, { summary: "Union of several tracks", - values: []string{"0.2/!stable", "0.3/*"}, + values: []string{"2/!stable", "3/*"}, match: map[setup.Channel]bool{ - {"0.2", "edge", ""}: true, - {"0.2", "stable", ""}: false, - {"0.3", "stable", ""}: true, + {"2", "edge", ""}: true, + {"2", "stable", ""}: false, + {"3", "stable", ""}: true, }, }, { summary: "Every known risk", - values: []string{"0.3/stable,candidate,beta,edge"}, + values: []string{"3/stable,candidate,beta,edge"}, match: map[setup.Channel]bool{ - {"0.3", "stable", ""}: true, - {"0.3", "candidate", ""}: true, - {"0.3", "beta", ""}: true, - {"0.3", "edge", ""}: true, + {"3", "stable", ""}: true, + {"3", "candidate", ""}: true, + {"3", "beta", ""}: true, + {"3", "edge", ""}: true, }, }, { summary: "Spaces", - values: []string{"0.3/not stable"}, - err: `"0.3/not stable": must not contain spaces`, + values: []string{"3/not stable"}, + err: `"3/not stable": must not contain spaces`, }, { summary: "Missing risk", - values: []string{"0.3"}, - err: `"0.3": must be /`, + values: []string{"3"}, + err: `"3": must be /`, }, { summary: "Empty risk", - values: []string{"0.3/"}, - err: `"0.3/": must be /`, + values: []string{"3/"}, + err: `"3/": must be /`, }, { summary: "Empty track", values: []string{"/stable"}, err: `"/stable": must be /`, }, { summary: "Pattern holding a branch", - values: []string{"0.3/stable/mybranch"}, - err: `"0.3/stable/mybranch": must be /`, + values: []string{"3/stable/mybranch"}, + err: `"3/stable/mybranch": must be /`, }, { summary: "Wildcard track", values: []string{"*/stable"}, err: `"\*/stable": only the risk accepts '\*', '!' and ','`, }, { summary: "Partial wildcard in track", - values: []string{"0.3-*/stable"}, - err: `"0.3-\*/stable": only the risk accepts '\*', '!' and ','`, + values: []string{"3-*/stable"}, + err: `"3-\*/stable": only the risk accepts '\*', '!' and ','`, }, { summary: "Wildcard is not a glob", - values: []string{"0.3/e*"}, - err: `"0.3/e\*": '\*' must be the whole risk`, + values: []string{"3/e*"}, + err: `"3/e\*": '\*' must be the whole risk`, }, { summary: "Unknown excluded risk", - values: []string{"0.3/!whatever"}, - err: `"0.3/!whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, + values: []string{"3/!whatever"}, + err: `"3/!whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, }, { summary: "Exclusion combined with other risks", - values: []string{"0.3/!stable,edge"}, - err: `"0.3/!stable,edge": '!' cannot be combined with other risks`, + values: []string{"3/!stable,edge"}, + err: `"3/!stable,edge": '!' cannot be combined with other risks`, }, { summary: "Empty excluded risk", - values: []string{"0.3/!"}, - err: `"0.3/!": must be /`, + values: []string{"3/!"}, + err: `"3/!": must be /`, }, { summary: "Unknown risk", - values: []string{"0.3/whatever"}, - err: `"0.3/whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, + values: []string{"3/whatever"}, + err: `"3/whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, }, { summary: "Unknown risk in a list", - values: []string{"0.3/edge,whatever"}, - err: `"0.3/edge,whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, + values: []string{"3/edge,whatever"}, + err: `"3/edge,whatever": unknown risk "whatever", must be one of stable, candidate, beta, edge`, }, { summary: "Risks are case sensitive", - values: []string{"0.3/Stable"}, - err: `"0.3/Stable": unknown risk "Stable", must be one of stable, candidate, beta, edge`, + values: []string{"3/Stable"}, + err: `"3/Stable": unknown risk "Stable", must be one of stable, candidate, beta, edge`, }, { summary: "Exclusion not prefixing the risk part", - values: []string{"0.3/edge,!stable"}, - err: `"0.3/edge,!stable": '!' must prefix the whole risk`, + values: []string{"3/edge,!stable"}, + err: `"3/edge,!stable": '!' must prefix the whole risk`, }, { summary: "Repeated risk", - values: []string{"0.3/edge,edge"}, - err: `"0.3/edge,edge": risk "edge" is repeated`, + values: []string{"3/edge,edge"}, + err: `"3/edge,edge": risk "edge" is repeated`, }, { summary: "Trailing comma in a list", - values: []string{"0.3/edge,"}, - err: `"0.3/edge,": must be /`, + values: []string{"3/edge,"}, + err: `"3/edge,": must be /`, }, { summary: "Leading comma in a list", - values: []string{"0.3/,edge"}, - err: `"0.3/,edge": must be /`, + values: []string{"3/,edge"}, + err: `"3/,edge": must be /`, }, { summary: "Repeated track", - values: []string{"0.3/*", "0.3/edge"}, - err: `track "0.3" is repeated`, + values: []string{"3/*", "3/edge"}, + err: `track "3" is repeated`, }, { summary: "Repeated track with identical values", - values: []string{"0.3/edge", "0.3/edge"}, - err: `track "0.3" is repeated`, + values: []string{"3/edge", "3/edge"}, + err: `track "3" is repeated`, }} func (s *S) TestChannelPatterns(c *C) { From fdfa45a64abe6236441005e96f06feb778d7054c Mon Sep 17 00:00:00 2001 From: Paul Mars Date: Mon, 12 Oct 2026 17:11:34 +0200 Subject: [PATCH 13/13] docs: more cleaning --- internal/setup/channel.go | 6 +++--- internal/setup/yaml.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/setup/channel.go b/internal/setup/channel.go index f9667096..7685dd6d 100644 --- a/internal/setup/channel.go +++ b/internal/setup/channel.go @@ -140,9 +140,9 @@ func validateChannelPattern(pattern string) (track string, err error) { // Channel patterns follow the "/" form, where the // track is a literal and only the risk pattern accepts operators: // -// - - Any risk of that track -// ! - Any risk of that track but that one -// [,...] - Only those risks of that track +// - "*": any risk of that track +// - "!": any risk of that track but that one +// - "[,...]": only those risks of that track // // Patterns are kept as written and interpreted on each match. // diff --git a/internal/setup/yaml.go b/internal/setup/yaml.go index 2a2938e4..d1eefb4f 100644 --- a/internal/setup/yaml.go +++ b/internal/setup/yaml.go @@ -128,7 +128,7 @@ func (es yamlEssentialListMap) MarshalYAML() (any, error) { return es.Values, nil } -var _ yaml.Marshaler = yamlEssentialListMap{} +var _ yaml.Marshaler = yamlEssentialListMap{} var _ yaml.Unmarshaler = (*yamlEssentialListMap)(nil) type yamlPath struct {