From d31efe86f5d5b0df0a6451bad133f4db3dc4097a Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:11:08 -0400 Subject: [PATCH 01/17] feat(config): add optional tag_grammar block (schema_version 1) Signed-off-by: Joshua Temple --- internal/config/command.go | 1 + internal/config/tag_grammar.go | 63 ++++++++++ internal/config/tag_grammar_test.go | 175 ++++++++++++++++++++++++++++ internal/config/types.go | 4 + internal/config/validate_v1.go | 66 +++++++++++ 5 files changed, 309 insertions(+) create mode 100644 internal/config/tag_grammar.go create mode 100644 internal/config/tag_grammar_test.go diff --git a/internal/config/command.go b/internal/config/command.go index c6cede87..57abf9a4 100644 --- a/internal/config/command.go +++ b/internal/config/command.go @@ -44,6 +44,7 @@ func runParseConfig(configPath, outputFormat string) error { errors := Validate(cfg) warnings, _ := cfg.ValidateSchemaVersion() + warnings = append(warnings, cfg.TagGrammarWarnings()...) for _, w := range warnings { log.Warn("%s", w) } diff --git a/internal/config/tag_grammar.go b/internal/config/tag_grammar.go new file mode 100644 index 00000000..40ad0906 --- /dev/null +++ b/internal/config/tag_grammar.go @@ -0,0 +1,63 @@ +package config + +import "github.com/stablekernel/cascade/internal/taggrammar" + +// TagGrammarConfig is the optional, additive manifest block that reshapes the +// release tag grammar. Every field is a pointer so an omitted key is +// distinguishable from an explicit empty value and inherits cascade's historical +// default. The block is scoped to tag shape only; sibling concerns such as +// version constraints land as their own optional blocks rather than folding in +// here, so this stays a focused, non-breaking seam. +type TagGrammarConfig struct { + Prefix *string `yaml:"prefix,omitempty" json:"prefix,omitempty"` + PreReleaseToken *string `yaml:"prerelease_token,omitempty" json:"prerelease_token,omitempty"` + PreReleaseSeparator *string `yaml:"prerelease_separator,omitempty" json:"prerelease_separator,omitempty"` + DryRunToken *string `yaml:"dryrun_token,omitempty" json:"dryrun_token,omitempty"` + StrictPrefix bool `yaml:"strict_prefix,omitempty" json:"strict_prefix,omitempty"` +} + +// ResolveTagGrammar folds the manifest's tag configuration into a single +// taggrammar.Spec. It starts from the historical default, applies the legacy +// tag_prefix, then layers any tag_grammar block on top. With no tag_grammar +// block and no tag_prefix the result is byte-identical to taggrammar.Default, +// so default behavior is unchanged. When both tag_prefix and tag_grammar.prefix +// are set the block wins; TagGrammarWarnings surfaces the redundancy. +func (c *TrunkConfig) ResolveTagGrammar() taggrammar.Spec { + spec := taggrammar.Default() + if c.TagPrefix != "" { + spec.Prefix = c.TagPrefix + } + g := c.TagGrammar + if g == nil { + return spec + } + if g.Prefix != nil { + spec.Prefix = *g.Prefix + } + if g.PreReleaseToken != nil { + spec.PreReleaseToken = *g.PreReleaseToken + } + if g.PreReleaseSeparator != nil { + spec.PreReleaseSeparator = *g.PreReleaseSeparator + } + if g.DryRunToken != nil { + spec.DryRunToken = *g.DryRunToken + } + spec.StrictPrefix = g.StrictPrefix + return spec +} + +// TagGrammarWarnings returns non-fatal advisories about the tag configuration. +// Today it flags the one redundant case: setting both tag_prefix and +// tag_grammar.prefix. Resolution is well defined (the block wins), so this is an +// advisory, not an error, emitted on the same warnings path as the schema +// version advisory. +func (c *TrunkConfig) TagGrammarWarnings() []string { + if c.TagPrefix != "" && c.TagGrammar != nil && c.TagGrammar.Prefix != nil { + return []string{ + "both tag_prefix and tag_grammar.prefix are set; tag_grammar.prefix wins. " + + "Drop tag_prefix to remove this ambiguity.", + } + } + return nil +} diff --git a/internal/config/tag_grammar_test.go b/internal/config/tag_grammar_test.go new file mode 100644 index 00000000..2602bb9d --- /dev/null +++ b/internal/config/tag_grammar_test.go @@ -0,0 +1,175 @@ +package config + +import ( + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/taggrammar" +) + +func strptr(s string) *string { return &s } + +// A nil tag_grammar block resolves to the historical default grammar, and an +// existing tag_prefix still lands on Spec.Prefix so the legacy knob keeps +// working unchanged. +func TestResolveTagGrammar_NilUsesDefaultWithPrefix(t *testing.T) { + def := taggrammar.Default() + + cfg := &TrunkConfig{} + got := cfg.ResolveTagGrammar() + if got != def { + t.Fatalf("nil tag_grammar: got %+v, want default %+v", got, def) + } + + cfg = &TrunkConfig{TagPrefix: "release"} + got = cfg.ResolveTagGrammar() + want := def + want.Prefix = "release" + if got != want { + t.Fatalf("tag_prefix only: got %+v, want %+v", got, want) + } +} + +// A populated tag_grammar block overrides the token, separator, and dryrun +// token independently. +func TestResolveTagGrammar_PopulatedOverrides(t *testing.T) { + cfg := &TrunkConfig{ + TagGrammar: &TagGrammarConfig{ + Prefix: strptr("ver"), + PreReleaseToken: strptr("beta"), + PreReleaseSeparator: strptr("-"), + DryRunToken: strptr("rehearsal"), + StrictPrefix: true, + }, + } + got := cfg.ResolveTagGrammar() + want := taggrammar.Spec{ + Prefix: "ver", + PreReleaseToken: "beta", + PreReleaseSeparator: "-", + DryRunToken: "rehearsal", + StrictPrefix: true, + } + if got != want { + t.Fatalf("populated block: got %+v, want %+v", got, want) + } +} + +// When both tag_prefix and tag_grammar.prefix are set, the block wins in the +// resolved spec and a redundancy advisory names both keys. +func TestResolveTagGrammar_RedundantPrefixWarnsAndBlockWins(t *testing.T) { + cfg := &TrunkConfig{ + TagPrefix: "old", + TagGrammar: &TagGrammarConfig{Prefix: strptr("new")}, + } + + got := cfg.ResolveTagGrammar() + if got.Prefix != "new" { + t.Fatalf("block should win: got prefix %q, want %q", got.Prefix, "new") + } + + warns := cfg.TagGrammarWarnings() + if len(warns) != 1 { + t.Fatalf("want exactly one advisory, got %d: %v", len(warns), warns) + } + w := warns[0] + if !strings.Contains(w, "tag_prefix") || !strings.Contains(w, "tag_grammar.prefix") { + t.Fatalf("advisory must name both keys, got %q", w) + } +} + +// No redundancy advisory when only one prefix source is set. +func TestTagGrammarWarnings_NoRedundancyWhenSingleSource(t *testing.T) { + cases := []*TrunkConfig{ + {}, + {TagPrefix: "v"}, + {TagGrammar: &TagGrammarConfig{Prefix: strptr("v")}}, + {TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("beta")}}, + } + for i, cfg := range cases { + if w := cfg.TagGrammarWarnings(); len(w) != 0 { + t.Fatalf("case %d: want no advisory, got %v", i, w) + } + } +} + +func TestValidateTagGrammar(t *testing.T) { + tests := []struct { + name string + cfg *TrunkConfig + wantErr string // substring; "" means expect no error + }{ + { + name: "nil block is valid", + cfg: &TrunkConfig{}, + }, + { + name: "default-shaped block is valid", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{ + PreReleaseToken: strptr("rc"), + }}, + }, + { + name: "empty prerelease token rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("")}}, + wantErr: "prerelease_token", + }, + { + name: "prerelease token with slash rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("r/c")}}, + wantErr: "prerelease_token", + }, + { + name: "prefix with whitespace rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{Prefix: strptr("re lease")}}, + wantErr: "prefix", + }, + { + name: "separator with regex metachar rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseSeparator: strptr("*")}}, + wantErr: "prerelease_separator", + }, + { + name: "dryrun equal to prerelease token rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("beta"), DryRunToken: strptr("beta")}}, + wantErr: "dryrun_token", + }, + { + name: "dryrun defaulting to collide with prerelease rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{DryRunToken: strptr("rc")}}, + wantErr: "dryrun_token", + }, + { + name: "custom separator empty is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseSeparator: strptr("")}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := validateTagGrammar(tt.cfg) + if tt.wantErr == "" { + if len(errs) != 0 { + t.Fatalf("want no errors, got %v", errs) + } + return + } + joined := strings.Join(errs, "\n") + if !strings.Contains(joined, tt.wantErr) { + t.Fatalf("want error containing %q, got %v", tt.wantErr, errs) + } + }) + } +} + +// validateTagGrammar is reachable through the top-level Validate flow. +func TestValidate_WiresTagGrammar(t *testing.T) { + cfg := &TrunkConfig{ + TrunkBranch: "main", + TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("")}, + } + errs := Validate(cfg) + joined := strings.Join(errs, "\n") + if !strings.Contains(joined, "prerelease_token") { + t.Fatalf("Validate must surface tag_grammar errors, got %v", errs) + } +} diff --git a/internal/config/types.go b/internal/config/types.go index a427f5d4..b72d73fd 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -132,6 +132,10 @@ type TrunkConfig struct { // annotated cli_version tag to its commit and writes this. CLIVersionSHA string `yaml:"cli_version_sha,omitempty" json:"cli_version_sha,omitempty"` TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` // Version tag prefix (default: "v") + // TagGrammar optionally reshapes the release tag grammar (prefix, pre-release + // token and separator, dryrun token). Absent by default; when absent the + // historical grammar is used. Additive and scoped to tag shape only. + TagGrammar *TagGrammarConfig `yaml:"tag_grammar,omitempty" json:"tag_grammar,omitempty"` ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` // GitHub secret name for release operations (default: "GITHUB_TOKEN") StateToken string `yaml:"state_token,omitempty" json:"state_token,omitempty"` // Token expression for writing manifest state to the trunk branch (default: "GITHUB_TOKEN") // ReleaseTokenApp optionally backs the release-token seam with a GitHub App diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index b06ac167..4d47b106 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -432,6 +432,72 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, validateActionPins(cfg)...) errs = append(errs, validateActionFolder(cfg.ActionFolder)...) errs = append(errs, validateReconcile(cfg.Reconcile)...) + errs = append(errs, validateTagGrammar(cfg)...) + + return errs +} + +// tagGrammarUnsafeRe matches any character that would break a regex or a git +// ref if spliced into the tag grammar: whitespace, control characters, and the +// git ref / regex metacharacters. A tag component carrying one of these could +// not be compiled into a pattern or created as a git tag, so it is rejected up +// front rather than failing opaquely at tag time. +func tagGrammarUnsafeChar(s string) bool { + for _, r := range s { + if unicode.IsControl(r) || unicode.IsSpace(r) { + return true + } + switch r { + case '/', '~', '^', ':', '?', '*', '[', '\\': + return true + } + } + return false +} + +// validateTagGrammar structurally validates the optional tag_grammar block. +// These are hard errors, not advisories: an empty pre-release token, a +// component carrying a regex- or git-ref-breaking character, or a dryrun token +// that collides with the pre-release token would each produce a grammar that +// cannot round-trip, so generation must not proceed. A nil block is valid and +// preserves the historical grammar. The redundant-prefix case is handled +// separately as a non-fatal advisory (see TrunkConfig.TagGrammarWarnings). +func validateTagGrammar(cfg *TrunkConfig) []string { + g := cfg.TagGrammar + if g == nil { + return nil + } + var errs []string + + if g.PreReleaseToken != nil { + if *g.PreReleaseToken == "" { + errs = append(errs, "tag_grammar.prerelease_token must not be empty") + } else if tagGrammarUnsafeChar(*g.PreReleaseToken) { + errs = append(errs, fmt.Sprintf( + "tag_grammar.prerelease_token %q contains a character that breaks a regex or a git ref", *g.PreReleaseToken)) + } + } + if g.Prefix != nil && tagGrammarUnsafeChar(*g.Prefix) { + errs = append(errs, fmt.Sprintf( + "tag_grammar.prefix %q contains a character that breaks a regex or a git ref", *g.Prefix)) + } + if g.PreReleaseSeparator != nil && tagGrammarUnsafeChar(*g.PreReleaseSeparator) { + errs = append(errs, fmt.Sprintf( + "tag_grammar.prerelease_separator %q contains a character that breaks a regex or a git ref", *g.PreReleaseSeparator)) + } + if g.DryRunToken != nil && tagGrammarUnsafeChar(*g.DryRunToken) { + errs = append(errs, fmt.Sprintf( + "tag_grammar.dryrun_token %q contains a character that breaks a regex or a git ref", *g.DryRunToken)) + } + + // The dryrun token must stay distinguishable from the pre-release token, or + // a rehearsal tag would parse as a real pre-release. Compare the resolved + // values so a token that collides only after defaulting is still caught. + spec := cfg.ResolveTagGrammar() + if spec.DryRunToken == spec.PreReleaseToken { + errs = append(errs, fmt.Sprintf( + "tag_grammar.dryrun_token %q must differ from the prerelease_token so rehearsal tags stay distinguishable", spec.DryRunToken)) + } return errs } From 7f759010141c0b8acf02182b3c1f4a31cddd5c39 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:14:17 -0400 Subject: [PATCH 02/17] feat: honor the manifest tag_grammar in orchestrate and the version command Signed-off-by: Joshua Temple --- internal/orchestrate/orchestrator.go | 15 +++- internal/orchestrate/tag_grammar_test.go | 60 +++++++++++++++ internal/version/command.go | 6 +- internal/version/tag_grammar_command_test.go | 79 ++++++++++++++++++++ 4 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 internal/orchestrate/tag_grammar_test.go create mode 100644 internal/version/tag_grammar_command_test.go diff --git a/internal/orchestrate/orchestrator.go b/internal/orchestrate/orchestrator.go index 66d290d4..30fc0589 100644 --- a/internal/orchestrate/orchestrator.go +++ b/internal/orchestrate/orchestrator.go @@ -334,12 +334,19 @@ func (o *Orchestrator) calculateVersion() (string, error) { // Get current environment's version and next env's version var currentDevVersion, nextEnvVersion, nextEnvSHA string + // Resolve the manifest's tag grammar once so tag discovery and version + // calculation share one shape. With no tag_grammar block this is the + // historical default, keeping behavior byte-identical. + spec := o.cicdFile.Config.ResolveTagGrammar() + // For no-environment setup (library/CLI projects), use the provided environment key // for state tracking but don't require it to be in the environments list if len(envs) == 0 { // No environments - this is a library/CLI project // All builds go to pre-release, version bumps based on conventional commits - tagPrefix := o.cicdFile.Config.GetTagPrefix() + // The resolved Spec.Prefix IS the prefix; the tag glob below widens on it + // and the version predicate narrows, so a custom grammar stays consistent. + tagPrefix := spec.Prefix // Get current dev version from state (for RC number tracking) if state := o.cicdFile.State[o.environment]; state != nil { @@ -359,7 +366,7 @@ func (o *Orchestrator) calculateVersion() (string, error) { // Get latest published release (non-RC) as base version for version calculation // This ensures we continue from v1.0.0 → v1.0.1-rc.0, not restart at v0.1.0-rc.0 - latestRelease, releaseSHA, err := git.GetLatestReleaseTag(o.baseDir, tagPrefix) + latestRelease, releaseSHA, err := git.GetLatestReleaseTagSpec(o.baseDir, spec) if err != nil { log.Warn("Failed to get latest release tag: %v", err) } else if latestRelease != "" { @@ -415,8 +422,8 @@ func (o *Orchestrator) calculateVersion() (string, error) { log.Debug("Found %d conventional commits for version calculation", len(commits)) - // Calculate next version - calc := version.NewCalculator(o.cicdFile.Config.GetTagPrefix()) + // Calculate next version under the resolved tag grammar shared with discovery. + calc := version.NewCalculatorWithGrammar(spec) nextVersion, err := calc.CalculateNext(currentDevVersion, nextEnvVersion, commits) if err != nil { return "", fmt.Errorf("calculating version: %w", err) diff --git a/internal/orchestrate/tag_grammar_test.go b/internal/orchestrate/tag_grammar_test.go new file mode 100644 index 00000000..6e4dc922 --- /dev/null +++ b/internal/orchestrate/tag_grammar_test.go @@ -0,0 +1,60 @@ +package orchestrate + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/version" +) + +func tgPtr(s string) *string { return &s } + +// A manifest with a custom tag_grammar makes orchestrate calculate the next +// version in the custom shape, and that result is identical to the one the +// version command would compute from the same manifest (both resolve one spec). +func TestCalculateVersion_CustomTagGrammar(t *testing.T) { + repoDir, head := initRepo(t) + + orig, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(repoDir)) + t.Cleanup(func() { require.NoError(t, os.Chdir(orig)) }) + + cfg := &config.TrunkConfig{ + Environments: []string{"dev", "prod"}, + TagGrammar: &config.TagGrammarConfig{ + Prefix: tgPtr("ver"), + PreReleaseToken: tgPtr("beta"), + }, + } + o := &Orchestrator{ + environment: "dev", + baseDir: repoDir, + cicdFile: &config.CICDFile{ + Config: cfg, + State: map[string]*config.EnvState{ + "dev": {Version: "v1.0.0-rc.0"}, + "prod": {Version: "v0.9.0", SHA: head}, + }, + }, + } + + got, err := o.calculateVersion() + require.NoError(t, err) + + if !strings.HasPrefix(got, "ver") || !strings.Contains(got, "-beta.") { + t.Fatalf("orchestrate did not honor tag_grammar: got %q, want ver...-beta. shape", got) + } + + // The version command builds its calculator from the same resolved spec, so + // it must produce byte-identical output for the same inputs. + calc := version.NewCalculatorWithGrammar(cfg.ResolveTagGrammar()) + want, err := calc.CalculateNext("v1.0.0-rc.0", "v0.9.0", nil) + require.NoError(t, err) + assert.Equal(t, want.String(), got, "orchestrate and version command must resolve the same spec") +} diff --git a/internal/version/command.go b/internal/version/command.go index 3f0ac0d4..dcd70be0 100644 --- a/internal/version/command.go +++ b/internal/version/command.go @@ -110,8 +110,10 @@ Examples: } } - // Calculate next version - calc := NewCalculator(cfg.GetTagPrefix()) + // Calculate next version under the manifest's resolved tag grammar so + // a custom prefix, pre-release token, or separator is honored. With no + // tag_grammar block this resolves to the historical default. + calc := NewCalculatorWithGrammar(cfg.ResolveTagGrammar()) nextVersion, err := calc.CalculateNext(currentDevVersion, nextEnvVersion, commits) if err != nil { return fmt.Errorf("calculating version: %w", err) diff --git a/internal/version/tag_grammar_command_test.go b/internal/version/tag_grammar_command_test.go new file mode 100644 index 00000000..a80527a1 --- /dev/null +++ b/internal/version/tag_grammar_command_test.go @@ -0,0 +1,79 @@ +package version + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureStdout redirects os.Stdout for the duration of f and returns what was +// written. The next-version command prints its result to os.Stdout directly, so +// asserting the emitted version requires capturing the real stream. +func captureStdout(t *testing.T, f func()) string { + t.Helper() + orig := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + defer func() { os.Stdout = orig }() + + f() + + require.NoError(t, w.Close()) + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + return buf.String() +} + +// The next-version command honors a manifest tag_grammar: with a custom prefix +// and pre-release token, and an empty base..head range so the calculation is +// deterministic, it emits the next version in the custom shape. +func TestVersionNewCommand_HonorsTagGrammar(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "manifest.yaml") + content := `ci: + config: + trunk_branch: main + tag_grammar: + prefix: ver + prerelease_token: beta + environments: + - dev + - test + state: + dev: + version: v1.0.0-rc.3 + test: + version: v0.9.0 + sha: HEAD +` + require.NoError(t, os.WriteFile(configPath, []byte(content), 0o600)) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--environment", "dev", + "--config", configPath, + "--base-sha", "HEAD", + "--head-sha", "HEAD", + "--json", + }) + + var runErr error + stdout := captureStdout(t, func() { runErr = cmd.Execute() }) + require.NoError(t, runErr) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + got, _ := result["version"].(string) + assert.Equal(t, "ver0.9.0-beta.0", got) +} From 5c74eef3f3d9ea202c9c42b84616b959b3ce791c Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:16:23 -0400 Subject: [PATCH 03/17] feat(version): tolerate foreign prereleases and build metadata on read Signed-off-by: Joshua Temple --- internal/version/version.go | 66 +++++++++++++++++++++++++++++++- internal/version/version_test.go | 53 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/internal/version/version.go b/internal/version/version.go index 0ab09951..b41700e1 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -69,7 +69,65 @@ const ( // The prefix fragment comes from the spec so a custom prefix widens or narrows // the accepted set consistently with the strict parser. func baseRegex(spec taggrammar.Spec) *regexp.Regexp { - return regexp.MustCompile(fmt.Sprintf(`^(%s)(\d+)\.(\d+)\.(\d+)(?:-.+)?$`, prefixPattern(spec))) + // [-+].+ tolerates either a pre-release suffix (-rc.4, -beta.1) or bare build + // metadata (+build.5) after the core. Both are discarded; only the numeric + // core and prefix are captured. + return regexp.MustCompile(fmt.Sprintf(`^(%s)(\d+)\.(\d+)\.(\d+)(?:[-+].+)?$`, prefixPattern(spec))) +} + +// tolerantReadRegex matches a version tag on the read side, accepting shapes the +// strict emit grammar rejects: any foreign pre-release identifier after the core +// (for example -beta.1 or -rc1) and optional +build metadata. Group 5 captures +// the pre-release identifier (empty when absent); build metadata is matched but +// never captured, so it is discarded. This is deliberately distinct from the +// strict grammar so discovery can see historical tags without cascade emitting +// them. +func tolerantReadRegex(spec taggrammar.Spec) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf( + `^(%s)(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`, + prefixPattern(spec))) +} + +// ParseTolerant parses s on the read side under the default grammar, tolerating +// a foreign pre-release identifier and discarding build metadata. See +// ParseTolerantWithGrammar for the full contract. +func ParseTolerant(s string) (*Version, error) { + return ParseTolerantWithGrammar(defaultSpec, s) +} + +// ParseTolerantWithGrammar parses s on the read side under spec. It recognizes +// the numeric core plus an optional foreign pre-release identifier and optional +// build metadata. A recognized pre-release is marked present (PreRelease >= 0) so +// it sorts below its release; the specific identifier is not interpreted, since +// the read-side contract is only "any pre-release sorts below the release" plus +// cascade's own rc counter, which the calculator handles separately. Build +// metadata is discarded and never stored, so it is never emitted. It errors only +// when s lacks a valid numeric core. +func ParseTolerantWithGrammar(spec taggrammar.Spec, s string) (*Version, error) { + m := tolerantReadRegex(spec).FindStringSubmatch(s) + if m == nil { + return nil, fmt.Errorf("invalid version format: %s", s) + } + + major, _ := strconv.Atoi(m[2]) + minor, _ := strconv.Atoi(m[3]) + patch, _ := strconv.Atoi(m[4]) + + preRelease := -1 + if m[5] != "" { + // A foreign pre-release identifier is marked present so it sorts below + // the release. Its value is not interpreted (see the contract above). + preRelease = 0 + } + + return &Version{ + Major: major, + Minor: minor, + Patch: patch, + PreRelease: preRelease, + Hotfix: -1, + Prefix: leadingPrefix(s), + }, nil } // preReleaseSuffixRegex captures the pre-release number from a version whose @@ -384,7 +442,11 @@ func GetLatestRelease(tags []string) (*Version, error) { var latest *Version for _, tag := range tags { - v, err := Parse(tag) + // Read tolerantly so a repo whose history carries a foreign pre-release + // shape or build metadata is still visible to discovery. Build metadata + // is discarded to the numeric core; a recognized pre-release is skipped + // just like a native -rc tag. + v, err := ParseTolerant(tag) if err != nil { continue // Skip non-semver tags } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index e2f9bbd7..b68a413c 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -352,6 +352,18 @@ func TestGetLatestRelease(t *testing.T) { want: "v2.0.0", wantErr: false, }, + { + name: "tolerate build metadata on a release", + tags: []string{"v1.2.3+build.5"}, + want: "v1.2.3", + wantErr: false, + }, + { + name: "release with build metadata beats a lower release", + tags: []string{"v1.0.0", "v1.2.3+build.5"}, + want: "v1.2.3", + wantErr: false, + }, } for _, tt := range tests { @@ -367,6 +379,47 @@ func TestGetLatestRelease(t *testing.T) { } } +// TestParseTolerant covers the read-side tolerance for foreign pre-releases and +// build metadata. These shapes may exist in a repo's history; discovery must see +// them without cascade ever emitting them. +func TestParseTolerant(t *testing.T) { + // Build metadata is discarded: the parsed core matches v1.2.3 and renders + // without the +build segment. + t.Run("build metadata discarded to same core", func(t *testing.T) { + v, err := ParseTolerant("v1.2.3+build.5") + require.NoError(t, err) + assert.Equal(t, 1, v.Major) + assert.Equal(t, 2, v.Minor) + assert.Equal(t, 3, v.Patch) + assert.Equal(t, -1, v.PreRelease, "build metadata is not a pre-release") + assert.Equal(t, "v1.2.3", v.String(), "build metadata is never emitted") + }) + + // Any recognized pre-release sorts below its release. + t.Run("foreign pre-release sorts below release", func(t *testing.T) { + pre, err := ParseTolerant("v1.2.3-beta.1") + require.NoError(t, err) + rel, err := ParseTolerant("v1.2.3") + require.NoError(t, err) + assert.True(t, pre.PreRelease >= 0, "beta.1 must be recognized as a pre-release") + assert.Equal(t, -1, pre.Compare(rel), "v1.2.3-beta.1 must sort below v1.2.3") + assert.Equal(t, 1, rel.Compare(pre)) + }) + + // A separator-less foreign token still reads as a pre-release. + t.Run("separatorless foreign token is a pre-release", func(t *testing.T) { + v, err := ParseTolerant("v1.2.3-rc1") + require.NoError(t, err) + assert.True(t, v.PreRelease >= 0) + }) + + // A tag without a numeric core is not a version. + t.Run("non-version rejected", func(t *testing.T) { + _, err := ParseTolerant("not-a-version") + assert.Error(t, err) + }) +} + func TestStripRC(t *testing.T) { tests := []struct { input string From fa95a2553c836ad5fee54c90733db40889cdef10 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:23:07 -0400 Subject: [PATCH 04/17] docs(manifest): document the tag_grammar block Signed-off-by: Joshua Temple --- docs/src/content/docs/reference/manifest.md | 49 +++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md index b74cd061..9db20dfc 100644 --- a/docs/src/content/docs/reference/manifest.md +++ b/docs/src/content/docs/reference/manifest.md @@ -112,6 +112,50 @@ The `environments` list is fully configurable. cascade attaches no meaning to sp Workflow-level trigger types beyond `push` are set under [`extra_triggers`](#extra_triggers). +### tag_grammar + +Optional, additive block that reshapes the release tag grammar. A manifest that omits it +produces cascade's historical grammar exactly: `vX.Y.Z` releases, `-rc.N` pre-releases, and +`.hotfix.M` hotfixes, so existing repositories are unaffected. + +```yaml +ci: + config: + tag_grammar: + prefix: v + prerelease_token: rc + prerelease_separator: "." + dryrun_token: dryrun + strict_prefix: false +``` + +| Field | Status | Type | Default | Description | +|-------|--------|------|---------|-------------| +| `prefix` | emitted | string | `v` | Literal prefix cascade puts on every new tag. | +| `prerelease_token` | emitted | string | `rc` | Token that marks a release-candidate tag. | +| `prerelease_separator` | emitted | string | `.` | Separator between the token and its number. `.` yields `rc.4`; an empty string yields `rc4`. | +| `dryrun_token` | emitted | string | `dryrun` | Token that marks a rehearsal tag. | +| `strict_prefix` | emitted | bool | false | When false, reads accept any alphabetic prefix so historical and foreign-cased tags still parse. When true, reads require the exact configured prefix. | + +**Relationship to `tag_prefix`.** `tag_prefix` still sets the prefix on its own when +`tag_grammar` is absent. When both `tag_prefix` and `tag_grammar.prefix` are set, +`tag_grammar.prefix` wins, and `cascade parse-config` emits a non-fatal warning naming both +keys so the redundancy is visible. Resolution is well defined either way; the warning is +advisory only. + +**Reading pre-existing tags.** On read, cascade tolerates a pre-existing foreign pre-release +shape (for example `beta.1` or `rc1`) and build metadata (for example `+build.5`) so a +repository whose history predates `tag_grammar` stays visible to version discovery. cascade +never emits those shapes itself, and a recognized foreign pre-release always sorts below its +release. + +**cascade's own releases.** cascade's own self-release workflows stay pinned to the default +grammar regardless of what a driven repository configures. `tag_grammar` reshapes the tags a +driven repository's pipeline cuts, not cascade's own release process. + +See [Versioning and schema](/cascade/reference/versioning/) for the hotfix version grammar +and the full reserved-shapes catalog. + ## CLI pinning These fields pin the cascade CLI and third-party actions the generated workflows install. @@ -812,6 +856,11 @@ The implicit `release` slot tracks the most recently published (non-draft) GitHu - A repository cannot set both `external` (primary) and `notify` (satellite). - A per-callback `permissions` block is the complete permission set for that caller job and replaces the workflow default rather than merging. - `cli_version_sha` takes effect only under `pin_mode: sha`. +- `tag_grammar.prerelease_token` must not be empty. `tag_grammar.prefix`, + `prerelease_token`, `prerelease_separator`, and `dryrun_token` must not contain + whitespace, control characters, or a git-ref-unsafe character (any of `/`, `~`, `^`, `:`, + `?`, `*`, `[`, or a backslash). The resolved `dryrun_token` must differ from the resolved + `prerelease_token`. ## What to read next From 2703f1ab0731195f54b26ebaf910a40c0eae31e2 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:23:18 -0400 Subject: [PATCH 05/17] docs(manifest): note tag grammar versus version constraints Signed-off-by: Joshua Temple --- docs/src/content/docs/reference/manifest.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/src/content/docs/reference/manifest.md b/docs/src/content/docs/reference/manifest.md index 9db20dfc..b9eacba5 100644 --- a/docs/src/content/docs/reference/manifest.md +++ b/docs/src/content/docs/reference/manifest.md @@ -156,6 +156,13 @@ driven repository's pipeline cuts, not cascade's own release process. See [Versioning and schema](/cascade/reference/versioning/) for the hotfix version grammar and the full reserved-shapes catalog. +:::note[Tag grammar is not version selection] +`tag_grammar` answers how a tag is shaped, not which versions are selected. Constraints or +ranges over versions, such as a selector accepting only `>=v1.2`, are a different concern +and are out of scope here. Should that capability arrive, it lands as its own additive +optional block rather than being folded into `tag_grammar`. +::: + ## CLI pinning These fields pin the cascade CLI and third-party actions the generated workflows install. From 30f43b32e358feb98c963298f073e393067d859e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:23:41 -0400 Subject: [PATCH 06/17] docs(cli): note next-version honors tag_grammar Signed-off-by: Joshua Temple --- docs/src/content/docs/reference/cli.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/src/content/docs/reference/cli.md b/docs/src/content/docs/reference/cli.md index ba4223aa..1c8e38fa 100644 --- a/docs/src/content/docs/reference/cli.md +++ b/docs/src/content/docs/reference/cli.md @@ -844,6 +844,27 @@ Bump rules: - Fix (`fix`) triggers a patch bump. - Pre-release environments append an RC suffix (e.g., `v1.3.0-rc.0`). +`next-version` resolves [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) from the +manifest and formats the calculated version under that grammar, so its output matches what +`orchestrate` cuts and what the generated release workflow publishes. With no `tag_grammar` +block the output keeps the historical `rc.N` shape shown above. A manifest with a custom +`prerelease_token` and `prerelease_separator`: + +```yaml +ci: + config: + tag_grammar: + prerelease_token: pre + prerelease_separator: "" +``` + +changes the emitted shape: + +```bash +cascade next-version --environment prod --base-sha abc123 --head-sha def456 +# v1.3.0-pre0 +``` + ### generate-changelog Generate a markdown changelog from conventional commits. From d389449bcb80d237fc0e42059bcbd2fdd8754fc3 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:28:13 -0400 Subject: [PATCH 07/17] docs(versioning): generalize tag-shape prose for configurable grammar Explain that the hotfix segment's prefix, prerelease token, and separator come from the resolved tag_grammar (default v/rc/.), add a Tag grammar subsection covering read tolerance and the clean-boundary rule, and cross-link the manifest reference. Signed-off-by: Joshua Temple --- docs/src/content/docs/reference/versioning.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/versioning.md b/docs/src/content/docs/reference/versioning.md index 88165f44..56a35e99 100644 --- a/docs/src/content/docs/reference/versioning.md +++ b/docs/src/content/docs/reference/versioning.md @@ -140,11 +140,11 @@ Older tags outside the current release line do not receive backported fixes. See ## Hotfix version grammar -A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the [hotfix guide](/cascade/guides/hotfix/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (an rc) or already published. +A hotfix applies one or more trunk commits onto an environment pinned to an older trunk base (see the [hotfix guide](/cascade/guides/hotfix/)). The version cascade allocates for a hotfix depends on whether the environment's current version is still in flight (a pre-release) or already published. ### rc-based (unpublished) base -When the environment holds an rc version, the hotfix appends a nested `hotfix.M` segment: +When the environment holds a pre-release version, the hotfix appends a nested `hotfix.M` segment. With the default grammar (prerelease token `rc`, separator `.`): ``` v1.4.0-rc.2 -> v1.4.0-rc.2.hotfix.1 (first hotfix) @@ -157,7 +157,7 @@ The dotted form is deliberate. Under semver precedence the pre-release field lis v1.4.0-rc.2 < v1.4.0-rc.2.hotfix.1 < v1.4.0-rc.2.hotfix.2 < v1.4.0-rc.3 ``` -A hotfix version therefore slots cleanly between its base rc and the next rc, and it never collides with the orchestrator's rc sequence. The rc-shaped tag and draft cleanup logic matches the plain `X.Y.Z-rc.N` shape for the configured `tag_prefix` (the default `v`, a custom prefix such as `rel-`, or no prefix), so it is inert on hotfix tags; hotfix tags and drafts are cleaned up explicitly when the divergence ends. +A hotfix version therefore slots cleanly between its base pre-release and the next one, and it never collides with the orchestrator's pre-release sequence. The general shape is `X.Y.Z-N.hotfix.M`, where the prefix, token, and separator come from the resolved [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) (`v`, `rc`, and `.` unless configured otherwise); the nested `hotfix.M` segment itself is fixed and not reshaped by `tag_grammar`. The pre-release-shaped tag and draft cleanup logic matches this same resolved shape, so it is inert on hotfix tags; hotfix tags and drafts are cleaned up explicitly when the divergence ends. ### Published (no rc) base @@ -168,7 +168,18 @@ v1.3.0 -> v1.3.1 (first hotfix) v1.3.1 -> v1.3.2 (next free patch) ``` -cascade allocates the next free patch by reconciling against existing tags, so the hotfix does not collide with a patch the normal release flow may also mint. There is no `vX.Y.Z-hotfix.M` form; the nested `hotfix.M` segment applies only to rc-based, still-in-flight versions. +cascade allocates the next free patch by reconciling against existing tags, so the hotfix does not collide with a patch the normal release flow may also mint. There is no `vX.Y.Z-hotfix.M` form; the nested `hotfix.M` segment applies only to still-in-flight, pre-release versions. + +## Tag grammar + +The `-rc.N` shape used throughout this page is cascade's default pre-release grammar, not a fixed rule. An optional [`tag_grammar`](/cascade/reference/manifest/#tag_grammar) manifest block reshapes the prefix, the pre-release token, and the separator between the token and its number, so the general tag shape is `X.Y.Z-N[.hotfix.M]`. A manifest that omits `tag_grammar` reproduces the historical grammar shown above byte-identically. + +Two rules bound how far this configurability goes: + +- **Read tolerance.** On read, cascade also recognizes a foreign pre-release shape (for example `beta.1` or `rc1`) and build metadata (for example `+build.5`) left over from before `tag_grammar` was adopted, so an existing repository's tag history stays visible to version discovery. cascade never emits those shapes itself, and a recognized foreign pre-release always sorts below its release. +- **Clean release boundary only.** Changing `tag_grammar` is supported at a clean release boundary, when no version is currently in flight. There is no mid-flight migration window that mixes two grammars across the same in-progress release. + +See the [manifest reference](/cascade/reference/manifest/#tag_grammar) for the full field list and defaults. ## Version bump reference From 0c653a13fa1d8be2eaa75834f4df6f493695534f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:29:07 -0400 Subject: [PATCH 08/17] docs(release): qualify rc-shape claims as the default grammar Three docs stated the rc suffix as an unconditional rule (orchestrate output, promote's boundary, the hotfix short form). Qualify each as the default and link to tag_grammar. internals/release-orchestration.md and simulate-and-verify.md were swept too: their rc/dryrun mentions describe cascade's own pinned self-release process or are illustrative examples, so they are left as-is. Signed-off-by: Joshua Temple --- docs/src/content/docs/guides/hotfix.md | 2 +- docs/src/content/docs/guides/promote.md | 2 +- docs/src/content/docs/reference/generated-workflows.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/guides/hotfix.md b/docs/src/content/docs/guides/hotfix.md index 4a288cb4..b2448d04 100644 --- a/docs/src/content/docs/guides/hotfix.md +++ b/docs/src/content/docs/guides/hotfix.md @@ -49,7 +49,7 @@ Re-dispatch targeting the same environment afterward to resume the chain from wh ## Version grammar -A hotfix allocates its own version segment so it sorts correctly relative to the rc sequence it interrupts. See [Hotfix version grammar](/cascade/reference/versioning/#hotfix-version-grammar) for the full derivation; the short form is `-rc.N.hotfix.M` for an unpublished (rc) base, or the next free patch for an already-published base. +A hotfix allocates its own version segment so it sorts correctly relative to the pre-release sequence it interrupts. See [Hotfix version grammar](/cascade/reference/versioning/#hotfix-version-grammar) for the full derivation; the short form is `-rc.N.hotfix.M` by default (configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)) for an unpublished base, or the next free patch for an already-published base. ## What to watch diff --git a/docs/src/content/docs/guides/promote.md b/docs/src/content/docs/guides/promote.md index 033de373..fc203cec 100644 --- a/docs/src/content/docs/guides/promote.md +++ b/docs/src/content/docs/guides/promote.md @@ -56,7 +56,7 @@ Promote also skips a deploy on its own when there is nothing to do: it compares When the manifest has a `publish:` callback, crossing the prerelease-to-release boundary adds a publish step once per configured build. See [Publish](/cascade/reference/generated-workflows/#publish) for the exact dispatch payload. -For the release stage, version is the latest semver tag auto-incremented from conventional commits since that tag (major for a breaking change, minor for a feature, patch for a fix), or an explicit `version_override` input when you need to force a specific bump. The rc suffix is dropped at this boundary. +For the release stage, version is the latest semver tag auto-incremented from conventional commits since that tag (major for a breaking change, minor for a feature, patch for a fix), or an explicit `version_override` input when you need to force a specific bump. The prerelease suffix (`-rc.N` by default; configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)) is dropped at this boundary. ## What to watch diff --git a/docs/src/content/docs/reference/generated-workflows.md b/docs/src/content/docs/reference/generated-workflows.md index ed5be5f2..01032a33 100644 --- a/docs/src/content/docs/reference/generated-workflows.md +++ b/docs/src/content/docs/reference/generated-workflows.md @@ -59,7 +59,7 @@ Orchestrate takes no manual inputs; it runs automatically on push. Its outputs: | `release_url` | URL to the GitHub release. | | `execution_plan` | JSON execution plan with dependency-ordered waves. | -The setup job reads the manifest's recorded SHA, diffs it against the current head, matches changed files against each callback's triggers, and builds an execution plan that respects `depends_on`. Version is computed from conventional commits since the last release: `feat!:`/`BREAKING CHANGE:` bumps major, `feat:` bumps minor, `fix:`/`perf:` bumps patch. The first environment always gets an rc suffix (`v1.2.0-rc.0`); each further orchestrate run increments the rc counter. +The setup job reads the manifest's recorded SHA, diffs it against the current head, matches changed files against each callback's triggers, and builds an execution plan that respects `depends_on`. Version is computed from conventional commits since the last release: `feat!:`/`BREAKING CHANGE:` bumps major, `feat:` bumps minor, `fix:`/`perf:` bumps patch. The first environment always gets a pre-release suffix (`v1.2.0-rc.0` by default; configurable via [`tag_grammar`](/cascade/reference/manifest/#tag_grammar)); each further orchestrate run increments the pre-release counter. ## Promote workflow anatomy From e8e9485779d0bf6227caccb90feca5b47bbd2805 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:29:17 -0400 Subject: [PATCH 09/17] docs(readme): mention configurable tag grammar Add a Capabilities row noting the release-tag grammar (prefix, prerelease token, separator) is configurable via tag_grammar, with an omitted block reproducing the historical shape. Full field reference stays in the docs site. Signed-off-by: Joshua Temple --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 68499f3c..3237b255 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Opt-in companions (drift-check, PR-preview, pin-reconcile) are emitted only when | Action pinning | `pin_mode: tag` (default) or `sha`, with an embedded action-pins manifest and an opt-in reconcile companion. | | PR plan preview | An opt-in comment on each PR shows which builds and deploys would run. | | Breaking-change gate | `feat!:` or `BREAKING CHANGE:` commits block the prerelease-to-release boundary unless overridden. | +| Tag grammar | Release tags follow `vX.Y.Z-rc.N` by default; the prefix, prerelease token, and separator are configurable via `tag_grammar`, and an omitted block reproduces today's shape exactly. | | Artifact passing | The `artifact_id` output from a build is stored in state and forwarded to deploys and publish. | | GitHub Environments | The `environments` command emits per-environment config (`required_reviewers`, `wait_timer`, `branch_policy`) for you to apply. | | Schema enforcement | Every CLI invocation checks `schema_version` and rejects incompatible manifests with a clear error. | From 09d5000f7bbbb414fab6937402fa8ae4263399c9 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:29:39 -0400 Subject: [PATCH 10/17] docs(contributing): require a single canonical tag-grammar source Codify internal/taggrammar as the one place a release-tag shape is defined, so no tag sink (parser, git predicate, promote strip, generated template, hotfix) hand-copies a regex or format string instead of deriving from the resolved spec. Signed-off-by: Joshua Temple --- CONTRIBUTING.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e778f8cf..5dc010ed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,6 +55,15 @@ cascade owns the third-party action pins it emits into generated workflows, and - Generated files are targets, never sources: a pin (or any other value) is read from the manifest and written into generated output, never read back out of a generated file. This keeps generation a pure, offline function of the manifest, which is what makes a regenerate reproducible and a diff meaningful. - cascade's own self-heal companion is generated, not hand-written. `.github/workflows/pin-reconcile.yaml` is produced by the same reconcile generator that emits a downstream user's companion, in its own-repo variant, and is drift-locked byte-for-byte by a test so a hand-edit fails the suite. The own-repo variant differs from the user emission in exactly three ways: it installs the latest non-prerelease cascade release (never an rc or a draft, so cascade's own CI cannot self-install a prerelease), it scans both the workflow and composite-action trees for a moved pin, and it commits the regenerated workflows alongside the updated `action_pins.yaml`. Change the generator and regenerate the file; never edit the workflow by hand. +## Tag grammar + +cascade owns one canonical shape for its release tags, and that ownership rests on a few rules that any code touching version tags must keep: + +- `internal/taggrammar` is the single source of truth for the shape of a release tag: the prefix, the pre-release token, the separator, and the dry-run token. No other package hand-copies a tag regex or a format string; every tag sink (version parsing, the git tag predicate, the promote-boundary strip, generated workflow templates, hotfix segment allocation) derives its behavior from a resolved `taggrammar.Spec`, never a re-implementation of it. +- A manifest's `tag_grammar` block resolves to exactly one `taggrammar.Spec` per repository (`internal/config`), and that resolved spec is threaded through, not re-read piecemeal from manifest fields at each call site. +- Read-side tolerance (recognizing a foreign pre-release shape or build metadata left over from before `tag_grammar` was adopted) lives in the shared grammar package too, so every consumer stays consistent about what counts as a version tag. +- cascade's own self-release tooling (`nightly-release.yaml`, `release.yaml`, and the fleet) stays pinned to the default grammar (`taggrammar.Default()`) regardless of what a driven repository configures; it never resolves a manifest's `tag_grammar` for cascade's own tags. + ## Documentation quality A change that alters behavior, CLI surface, flags, config or manifest fields, generated output, or the release flow updates the affected docs in the same pull request: the docs site under `docs/src/content/docs/`, the root `README.md`, and any other affected Markdown file. The docs site follows these rules: From 822ef44d972f12d53530a0fcadedabc0d3b0e698 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:24:25 -0400 Subject: [PATCH 11/17] fix(promote): strip and enforce monotonicity using the configured tag grammar Signed-off-by: Joshua Temple --- internal/promote/preflight.go | 5 ++- internal/promote/promote.go | 40 ++++++++++++++------ internal/promote/tag_grammar_test.go | 55 ++++++++++++++++++++++++++++ internal/taggrammar/grammar.go | 14 +++++++ internal/taggrammar/grammar_test.go | 31 ++++++++++++++++ 5 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 internal/promote/tag_grammar_test.go diff --git a/internal/promote/preflight.go b/internal/promote/preflight.go index c2d38a14..114edbd3 100644 --- a/internal/promote/preflight.go +++ b/internal/promote/preflight.go @@ -308,8 +308,9 @@ func (p *Preflighter) checkDowngrade(promotions []EnvPromotion, result *Prefligh continue } - incoming, errIn := version.Parse(incomingStr) - current, errCur := version.Parse(currentStr) + spec := resolveTagGrammar(p.cicdFile) + incoming, errIn := version.ParseWithGrammar(spec, incomingStr) + current, errCur := version.ParseWithGrammar(spec, currentStr) if errIn != nil || errCur != nil { // FAIL-OPEN: non-semver version -> warn and continue. result.Warnings = append(result.Warnings, fmt.Sprintf( diff --git a/internal/promote/promote.go b/internal/promote/promote.go index 26e19a33..29502582 100644 --- a/internal/promote/promote.go +++ b/internal/promote/promote.go @@ -9,6 +9,7 @@ import ( "github.com/stablekernel/cascade/internal/config" "github.com/stablekernel/cascade/internal/git" + "github.com/stablekernel/cascade/internal/taggrammar" ) // PromotionMode defines how the promotion operates @@ -229,7 +230,7 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { if rs := preState["release"]; rs != nil && rs.SHA == sourceState.SHA { break } - semVersion := stripRCSuffix(sourceState.Version) + semVersion := p.stripPreRelease(sourceState.Version) promo := EnvPromotion{ Environment: "release", SourceEnv: sourceEnv, @@ -289,7 +290,7 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { result.ReleaseData = &ReleaseData{ SHA: sourceState.SHA, RCVersion: sourceState.Version, - SemVersion: stripRCSuffix(sourceState.Version), + SemVersion: p.stripPreRelease(sourceState.Version), } } } @@ -328,7 +329,7 @@ func (p *Promoter) defaultPromotion() (*PromotionResult, error) { result.ReleaseData = &ReleaseData{ SHA: sourceState.SHA, RCVersion: sourceState.Version, - SemVersion: stripRCSuffix(sourceState.Version), + SemVersion: p.stripPreRelease(sourceState.Version), } } } @@ -437,7 +438,7 @@ func (p *Promoter) noEnvironmentPromotion() (*PromotionResult, error) { Environment: "release", SourceEnv: "prerelease", SHA: sourceState.SHA, - Version: stripRCSuffix(sourceState.Version), // Use semver for release + Version: p.stripPreRelease(sourceState.Version), // Use semver for release NeedsDeploy: false, // No deployment for library/CLI projects } @@ -452,7 +453,7 @@ func (p *Promoter) noEnvironmentPromotion() (*PromotionResult, error) { ReleaseData: &ReleaseData{ SHA: sourceState.SHA, RCVersion: sourceState.Version, - SemVersion: stripRCSuffix(sourceState.Version), + SemVersion: p.stripPreRelease(sourceState.Version), }, } @@ -465,7 +466,7 @@ func (p *Promoter) noEnvironmentPromotion() (*PromotionResult, error) { p.cicdFile.State["release"] = &config.EnvState{} } p.cicdFile.State["release"].SHA = sourceState.SHA - p.cicdFile.State["release"].Version = stripRCSuffix(sourceState.Version) + p.cicdFile.State["release"].Version = p.stripPreRelease(sourceState.Version) p.cicdFile.State["release"].CommittedAt = timestamp p.cicdFile.State["release"].CommittedBy = p.actor @@ -572,7 +573,7 @@ func (p *Promoter) cascadePromotion(target string) (*PromotionResult, error) { publishEnv = envs[len(envs)-1] } prodEnv := envs[len(envs)-1] - semVersion := stripRCSuffix(sourceState.Version) + semVersion := p.stripPreRelease(sourceState.Version) // Build promotions for envs[sourceIdx+1..targetIdx]. Materialize "release" // as its own promotion either when it's in the env list or when crossing @@ -722,11 +723,28 @@ func (r *PromotionResult) ToJSON() string { // Helper functions -func stripRCSuffix(version string) string { - if idx := strings.Index(version, "-rc."); idx != -1 { - return version[:idx] +// resolveTagGrammar folds a manifest into its tag grammar, falling back to the +// historical default when the manifest or its config is absent so callers never +// dereference a nil config. +func resolveTagGrammar(f *config.CICDFile) taggrammar.Spec { + if f == nil || f.Config == nil { + return taggrammar.Default() } - return version + return f.Config.ResolveTagGrammar() +} + +// stripPreRelease reduces a version to its base under this promoter's configured +// tag grammar, so a custom pre-release token (for example "beta") is stripped +// just as the historical "rc" is, instead of publishing the pre-release shape. +func (p *Promoter) stripPreRelease(version string) string { + return resolveTagGrammar(p.cicdFile).StripPreRelease(version) +} + +// stripRCSuffix strips the default-grammar pre-release segment. It is retained +// for callers with no manifest in scope; grammar-aware promotion uses +// stripPreRelease so a custom token is honored. +func stripRCSuffix(version string) string { + return taggrammar.Default().StripPreRelease(version) } func indexOf(slice []string, item string) int { diff --git a/internal/promote/tag_grammar_test.go b/internal/promote/tag_grammar_test.go new file mode 100644 index 00000000..195fbc49 --- /dev/null +++ b/internal/promote/tag_grammar_test.go @@ -0,0 +1,55 @@ +package promote + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +// betaConfig returns a manifest whose tag grammar names a "beta" pre-release +// token instead of the historical "rc". +func betaConfig(state map[string]*config.EnvState) *config.CICDFile { + return &config.CICDFile{ + Config: &config.TrunkConfig{ + Environments: []string{"dev", "test", "uat", "prod"}, + Deploys: []config.DeployConfig{{Name: "app"}}, + TagGrammar: &config.TagGrammarConfig{ + PreReleaseToken: strPtr("beta"), + }, + }, + State: state, + } +} + +// TestStripPreRelease_CustomToken proves the promote strip honors the configured +// pre-release token: a beta pre-release is reduced to its base, where the +// hardcoded "-rc." cut would have published the pre-release shape unchanged. +func TestStripPreRelease_CustomToken(t *testing.T) { + p := &Promoter{cicdFile: betaConfig(nil)} + if got := p.stripPreRelease("1.4.0-beta.2"); got != "1.4.0" { + t.Errorf("stripPreRelease(%q) = %q, want %q", "1.4.0-beta.2", got, "1.4.0") + } +} + +// TestPreflight_MonotonicityEnforcedUnderCustomToken proves the downgrade guard +// stays active under a custom token: a 1.4.0-beta.2 promotion onto a +// 1.5.0-beta.1 env is a downgrade and must be BLOCKED, not fail-open warned. +func TestPreflight_MonotonicityEnforcedUnderCustomToken(t *testing.T) { + cfg := betaConfig(map[string]*config.EnvState{ + "test": {Version: "1.5.0-beta.1"}, + }) + p := NewPreflighter(PreflighterOptions{ + Config: cfg, + Mode: ModeDefault, + }) + result := &PreflightResult{} + promotions := []EnvPromotion{{Environment: "test", Version: "1.4.0-beta.2"}} + + err := p.checkDowngrade(promotions, result, "prod") + require.Error(t, err) + require.Contains(t, err.Error(), "test") + require.Empty(t, result.Warnings) +} diff --git a/internal/taggrammar/grammar.go b/internal/taggrammar/grammar.go index 7d674fc4..9543431d 100644 --- a/internal/taggrammar/grammar.go +++ b/internal/taggrammar/grammar.go @@ -8,6 +8,7 @@ import ( "fmt" "regexp" "strconv" + "strings" ) // Spec describes the shape of a release tag. The zero value is not usable; @@ -122,6 +123,19 @@ func (s Spec) Format(p Parsed) string { return out } +// StripPreRelease returns tag with its pre-release segment (and anything after +// it, such as a nested hotfix) removed, leaving just the prefix and numeric +// core. It cuts at this spec's pre-release marker ("-" + token + separator), so +// the default grammar cuts at "-rc." exactly as the historical literal strip +// did, byte for byte. A tag without the marker is returned unchanged. +func (s Spec) StripPreRelease(tag string) string { + marker := "-" + s.PreReleaseToken + s.PreReleaseSeparator + if idx := strings.Index(tag, marker); idx != -1 { + return tag[:idx] + } + return tag +} + // atoi converts a submatch known to be all digits. The grammar guarantees the // input, so any error is discarded and yields 0. func atoi(s string) int { diff --git a/internal/taggrammar/grammar_test.go b/internal/taggrammar/grammar_test.go index 17ebb4af..bf5aa298 100644 --- a/internal/taggrammar/grammar_test.go +++ b/internal/taggrammar/grammar_test.go @@ -67,6 +67,37 @@ func TestFormat_DefaultRoundTripsHistoricalStrings(t *testing.T) { } } +func TestStripPreRelease_DefaultMatchesHistoricalStrip(t *testing.T) { + s := Default() + cases := map[string]string{ + "v1.0.0-rc.0": "v1.0.0", + "v1.0.0-rc.5": "v1.0.0", + "v2.3.4-rc.123": "v2.3.4", + "v1.0.0": "v1.0.0", + "v1.0.0-beta": "v1.0.0-beta", + "v1.4.0-rc.2.hotfix.1": "v1.4.0", + } + for in, want := range cases { + if got := s.StripPreRelease(in); got != want { + t.Errorf("StripPreRelease(%q) = %q, want %q", in, got, want) + } + } +} + +func TestStripPreRelease_CustomToken(t *testing.T) { + s := Default() + s.PreReleaseToken = "beta" + s.PreReleaseSeparator = "." + if got := s.StripPreRelease("1.4.0-beta.2"); got != "1.4.0" { + t.Errorf("StripPreRelease(%q) = %q, want %q", "1.4.0-beta.2", got, "1.4.0") + } + + s.PreReleaseSeparator = "" + if got := s.StripPreRelease("1.4.0-beta2"); got != "1.4.0" { + t.Errorf("StripPreRelease(%q) = %q, want %q", "1.4.0-beta2", got, "1.4.0") + } +} + func TestParseFormat_NonDefaultSpec(t *testing.T) { s := Default() s.Prefix = "" From b467db6df78c34cf8f532c8dfc675d71888bd080 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:26:17 -0400 Subject: [PATCH 12/17] fix(generate): derive the release-tag strip pattern from tag_grammar Signed-off-by: Joshua Temple --- internal/generate/release.go | 2 +- internal/generate/release_test.go | 23 +++++++++++++++++++++++ internal/taggrammar/grammar.go | 26 ++++++++++++++++++++++++++ internal/taggrammar/grammar_test.go | 13 +++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/internal/generate/release.go b/internal/generate/release.go index dc8f0428..d943f902 100644 --- a/internal/generate/release.go +++ b/internal/generate/release.go @@ -226,7 +226,7 @@ func (g *ReleaseGenerator) writePreflightJob(sb *strings.Builder) { sb.WriteString(" SOURCE_VERSION: ${{ steps.validate.outputs.source_version }}\n") sb.WriteString(" run: |\n") sb.WriteString(" # Strip RC suffix (e.g., v1.2.0-rc.3 -> v1.2.0)\n") - sb.WriteString(" SEMVER_TAG=$(echo \"$SOURCE_VERSION\" | sed 's/-rc\\.[0-9]*$//')\n") + fmt.Fprintf(sb, " SEMVER_TAG=$(echo \"$SOURCE_VERSION\" | sed 's/%s//')\n", g.config.ResolveTagGrammar().PreReleaseStripSedBRE()) sb.WriteString(" echo \"semver_tag=$SEMVER_TAG\" >> \"$GITHUB_OUTPUT\"\n") sb.WriteString(" echo \"::notice::Semver tag: $SEMVER_TAG (from $SOURCE_VERSION)\"\n") diff --git a/internal/generate/release_test.go b/internal/generate/release_test.go index 0ed929b1..349d8ccf 100644 --- a/internal/generate/release_test.go +++ b/internal/generate/release_test.go @@ -205,6 +205,29 @@ func TestReleaseGenerator_SemverTagCalculation(t *testing.T) { } +// TestReleaseGenerator_SemverTagCalculation_CustomToken proves the emitted strip +// pattern follows the configured tag grammar: a beta token with an empty +// separator yields the beta-shaped sed expression, not the rc one. +func TestReleaseGenerator_SemverTagCalculation_CustomToken(t *testing.T) { + betaToken := "beta" + emptySep := "" + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"prod"}, + TagGrammar: &config.TagGrammarConfig{ + PreReleaseToken: &betaToken, + PreReleaseSeparator: &emptySep, + }, + } + + gen := NewReleaseGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "sed 's/-beta[0-9]*$//'") + assert.NotContains(t, content, "sed 's/-rc\\.[0-9]*$//'") +} + func TestReleaseGenerator_ChangelogGeneration(t *testing.T) { cfg := &config.TrunkConfig{ TrunkBranch: "main", diff --git a/internal/taggrammar/grammar.go b/internal/taggrammar/grammar.go index 9543431d..e5e6d857 100644 --- a/internal/taggrammar/grammar.go +++ b/internal/taggrammar/grammar.go @@ -136,6 +136,32 @@ func (s Spec) StripPreRelease(tag string) string { return tag } +// PreReleaseStripSedBRE returns the anchored sed basic-regular-expression that +// matches this spec's pre-release segment at the end of a version string, for +// example "-rc\.[0-9]*$" for the default grammar and "-beta[0-9]*$" for a beta +// token with an empty separator. The token is emitted literally; the separator +// is escaped so a metacharacter such as "." matches literally. Code generators +// bake this into the driven repo's release workflow so the emitted strip follows +// the configured grammar. +func (s Spec) PreReleaseStripSedBRE() string { + return "-" + s.PreReleaseToken + sedBREEscape(s.PreReleaseSeparator) + "[0-9]*$" +} + +// sedBREEscape backslash-escapes the characters that carry special meaning in a +// sed basic regular expression so a literal separator matches literally. The +// backslash is escaped first so it is not doubled by a later pass. +func sedBREEscape(s string) string { + const special = `\.[]*^$` + var b strings.Builder + for _, r := range s { + if strings.ContainsRune(special, r) { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + // atoi converts a submatch known to be all digits. The grammar guarantees the // input, so any error is discarded and yields 0. func atoi(s string) int { diff --git a/internal/taggrammar/grammar_test.go b/internal/taggrammar/grammar_test.go index bf5aa298..bd326f4a 100644 --- a/internal/taggrammar/grammar_test.go +++ b/internal/taggrammar/grammar_test.go @@ -98,6 +98,19 @@ func TestStripPreRelease_CustomToken(t *testing.T) { } } +func TestPreReleaseStripSedBRE(t *testing.T) { + if got := Default().PreReleaseStripSedBRE(); got != `-rc\.[0-9]*$` { + t.Errorf("Default PreReleaseStripSedBRE() = %q, want %q", got, `-rc\.[0-9]*$`) + } + + s := Default() + s.PreReleaseToken = "beta" + s.PreReleaseSeparator = "" + if got := s.PreReleaseStripSedBRE(); got != `-beta[0-9]*$` { + t.Errorf("beta PreReleaseStripSedBRE() = %q, want %q", got, `-beta[0-9]*$`) + } +} + func TestParseFormat_NonDefaultSpec(t *testing.T) { s := Default() s.Prefix = "" From fcd1856653fbc5804c78095912d27691e650c848 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:33:17 -0400 Subject: [PATCH 13/17] fix(hotfix): thread the configured tag grammar through allocation and cleanup Signed-off-by: Joshua Temple --- internal/hotfix/finalize.go | 4 +- internal/hotfix/hotfix_grammar_test.go | 57 ++++++++++++++++++++++++++ internal/hotfix/lifecycle.go | 40 ++++++++++++------ internal/hotfix/lifecycle_test.go | 4 +- internal/hotfix/plan.go | 12 +++--- internal/promote/finalize.go | 1 + internal/promote/rejoin.go | 11 ++++- internal/version/version.go | 50 ++++++++++++++-------- 8 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 internal/hotfix/hotfix_grammar_test.go diff --git a/internal/hotfix/finalize.go b/internal/hotfix/finalize.go index b554b449..6b55b5a1 100644 --- a/internal/hotfix/finalize.go +++ b/internal/hotfix/finalize.go @@ -561,10 +561,12 @@ func (f *Finalizer) allocateVersion(priorVersion string) (string, error) { if priorVersion == "" { return "", fmt.Errorf("target environment has no recorded version; cannot allocate a hotfix version") } - v, err := version.Parse(priorVersion) + spec := resolveTagGrammar(f.cicd) + v, err := version.ParseWithGrammar(spec, priorVersion) if err != nil { return "", fmt.Errorf("parsing target version %q: %w", priorVersion, err) } + v = v.WithGrammar(spec) tags, err := f.tagLister.ListTags() if err != nil { diff --git a/internal/hotfix/hotfix_grammar_test.go b/internal/hotfix/hotfix_grammar_test.go new file mode 100644 index 00000000..b9c53d8e --- /dev/null +++ b/internal/hotfix/hotfix_grammar_test.go @@ -0,0 +1,57 @@ +package hotfix + +import ( + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/taggrammar" + "github.com/stretchr/testify/require" +) + +func sptr(s string) *string { return &s } + +// betaSpec is the tag grammar for a repo whose pre-release token is "beta" +// instead of the historical "rc". +func betaSpec() taggrammar.Spec { + cfg := &config.TrunkConfig{ + TagGrammar: &config.TagGrammarConfig{PreReleaseToken: sptr("beta")}, + } + return cfg.ResolveTagGrammar() +} + +// TestAllocateVersion_CustomToken proves the finalize allocator can parse and +// advance a beta-token pre-release: the default-spec version.Parse would fail on +// the beta token and abort the hotfix entirely. +func TestAllocateVersion_CustomToken(t *testing.T) { + f := &Finalizer{ + cicd: &config.CICDFile{Config: &config.TrunkConfig{ + TagGrammar: &config.TagGrammarConfig{PreReleaseToken: sptr("beta")}, + }}, + tagLister: stubTagLister{}, + } + + got, err := f.allocateVersion("1.4.0-beta.2") + require.NoError(t, err) + require.Equal(t, "1.4.0-beta.2.hotfix.1", got) +} + +// TestHotfixTagsForBase_CustomToken proves cleanup collects beta-token hotfix +// tags for their beta base; the default-spec parse would reject the token and +// return nothing, leaking the tags. +func TestHotfixTagsForBase_CustomToken(t *testing.T) { + tags := []string{ + "1.4.0-beta.2.hotfix.1", + "1.4.0-beta.3.hotfix.1", + "v2.0.0", + } + got := HotfixTagsForBase(betaSpec(), "1.4.0-beta.2", tags) + require.Equal(t, []string{"1.4.0-beta.2.hotfix.1"}, got) +} + +// TestHotfixVersionCandidate_CustomToken proves the planner computes the next +// hotfix version under a custom token instead of hard-failing on parse. +func TestHotfixVersionCandidate_CustomToken(t *testing.T) { + got, err := hotfixVersionCandidate(betaSpec(), "1.4.0-beta.2") + require.NoError(t, err) + require.Equal(t, "1.4.0-beta.2.hotfix.1", got) +} diff --git a/internal/hotfix/lifecycle.go b/internal/hotfix/lifecycle.go index 27353c62..6f015808 100644 --- a/internal/hotfix/lifecycle.go +++ b/internal/hotfix/lifecycle.go @@ -5,9 +5,20 @@ import ( "strings" "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/stablekernel/cascade/internal/version" ) +// resolveTagGrammar folds a manifest into its tag grammar, falling back to the +// historical default when the manifest or its config is absent so callers never +// dereference a nil config. +func resolveTagGrammar(f *config.CICDFile) taggrammar.Spec { + if f == nil || f.Config == nil { + return taggrammar.Default() + } + return f.Config.ResolveTagGrammar() +} + // EnvBranchPrefix is the prefix of the per-environment integration branches a // hotfix creates (for example env/test). A branch carrying this prefix exists // only while its environment is diverged; once the env rejoins trunk the branch @@ -62,31 +73,34 @@ func HealOrphanEnvBranches(branches []string, state map[string]*config.EnvState, return healed, nil } -// HotfixTagsForBase returns the hotfix tags in tags that belong to the rc base -// of baseVersion. A hotfix tag has the dotted shape vX.Y.Z-rc.N.hotfix.M; it -// shares the rc base (vX.Y.Z-rc.N) of the version the environment held while -// diverged. The RC-shaped cleanup in internal/release deliberately cannot see -// these tags (it matches only ^vX.Y.Z-rc.N$), so divergence-end cleanup must -// collect them explicitly. +// HotfixTagsForBase returns the hotfix tags in tags that belong to the +// pre-release base of baseVersion under spec. A hotfix tag has the dotted shape +// -N.hotfix.M (for example vX.Y.Z-rc.N.hotfix.M); it shares the +// pre-release base of the version the environment held while diverged. The +// pre-release-shaped cleanup in internal/release deliberately cannot see these +// tags, so divergence-end cleanup must collect them explicitly. // -// baseVersion may itself be a hotfix version (vX.Y.Z-rc.N.hotfix.M); it is -// normalized to its rc base before matching. Tags that do not parse, are not -// hotfix tags, or belong to a different rc base are excluded. The result is nil -// when nothing matches. -func HotfixTagsForBase(baseVersion string, tags []string) []string { - base, err := version.Parse(baseVersion) +// baseVersion may itself be a hotfix version; it is normalized to its +// pre-release base before matching. Parsing uses spec so a custom pre-release +// token still resolves. Tags that do not parse, are not hotfix tags, or belong to +// a different pre-release base are excluded. The result is nil when nothing +// matches. +func HotfixTagsForBase(spec taggrammar.Spec, baseVersion string, tags []string) []string { + base, err := version.ParseWithGrammar(spec, baseVersion) if err != nil || base.PreRelease < 0 { return nil } + base = base.WithGrammar(spec) // Normalize to the rc base so a hotfix baseVersion matches its siblings. rcBase := base.WithRC(base.PreRelease).String() var matched []string for _, tag := range tags { - v, err := version.Parse(tag) + v, err := version.ParseWithGrammar(spec, tag) if err != nil || v.Hotfix < 0 { continue } + v = v.WithGrammar(spec) if v.WithRC(v.PreRelease).String() == rcBase { matched = append(matched, tag) } diff --git a/internal/hotfix/lifecycle_test.go b/internal/hotfix/lifecycle_test.go index d79bec7b..fff47d2e 100644 --- a/internal/hotfix/lifecycle_test.go +++ b/internal/hotfix/lifecycle_test.go @@ -4,6 +4,8 @@ import ( "reflect" "testing" + "github.com/stablekernel/cascade/internal/taggrammar" + "github.com/stablekernel/cascade/internal/config" ) @@ -215,7 +217,7 @@ func TestHotfixTagsForBase(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := HotfixTagsForBase(tt.baseVersion, tt.tags) + got := HotfixTagsForBase(taggrammar.Default(), tt.baseVersion, tt.tags) if !reflect.DeepEqual(got, tt.want) { t.Fatalf("HotfixTagsForBase() = %v, want %v", got, tt.want) } diff --git a/internal/hotfix/plan.go b/internal/hotfix/plan.go index ed75b1cf..a9479fcf 100644 --- a/internal/hotfix/plan.go +++ b/internal/hotfix/plan.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/stablekernel/cascade/internal/version" ) @@ -324,7 +325,7 @@ func (p *Planner) Plan(fixRef, targetEnv string) (*PlanResult, error) { } // Compute the hotfix version candidate from the env's current version. - candidate, err := hotfixVersionCandidate(state.Version) + candidate, err := hotfixVersionCandidate(resolveTagGrammar(p.cicd), state.Version) if err != nil { return nil, err } @@ -448,16 +449,17 @@ func envTipDivergenceError(branch, tip, baseSHA string, diverged bool) error { } // hotfixVersionCandidate returns the next free hotfix version over the base of -// envVersion. An rc version yields its first nested hotfix segment. -func hotfixVersionCandidate(envVersion string) (string, error) { +// envVersion under spec. A pre-release version yields its first nested hotfix +// segment, rendered in the configured grammar. +func hotfixVersionCandidate(spec taggrammar.Spec, envVersion string) (string, error) { if envVersion == "" { return "", fmt.Errorf("target environment has no recorded version; cannot compute hotfix version") } - v, err := version.Parse(envVersion) + v, err := version.ParseWithGrammar(spec, envVersion) if err != nil { return "", fmt.Errorf("parsing target version %q: %w", envVersion, err) } - return v.NextHotfix().String(), nil + return v.WithGrammar(spec).NextHotfix().String(), nil } // envBranch returns the integration branch name for an environment. diff --git a/internal/promote/finalize.go b/internal/promote/finalize.go index 93cf9ab8..2ff17e5c 100644 --- a/internal/promote/finalize.go +++ b/internal/promote/finalize.go @@ -154,6 +154,7 @@ func (f *Finalizer) runLifecycleCleanup() error { Environment: ev.env, BaseVersion: ev.baseVersion, SHA: ev.sha, + Spec: resolveTagGrammar(f.cicdFile), }); err != nil { fmt.Printf("Warning: rejoin cleanup for %s: cleaning hotfix releases: %v\n", ev.env, err) } diff --git a/internal/promote/rejoin.go b/internal/promote/rejoin.go index 7586ff53..c25c81fa 100644 --- a/internal/promote/rejoin.go +++ b/internal/promote/rejoin.go @@ -8,6 +8,7 @@ import ( "github.com/stablekernel/cascade/internal/git" "github.com/stablekernel/cascade/internal/hotfix" "github.com/stablekernel/cascade/internal/release" + "github.com/stablekernel/cascade/internal/taggrammar" ) // CleanReleasesRequest describes the hotfix release objects to remove when an @@ -22,6 +23,10 @@ type CleanReleasesRequest struct { // release whose tag was already deleted by a prior partial run can still be // resolved by its target_commitish and removed, rather than leaking. SHA string + // Spec is the resolved tag grammar. It lets the hotfix-tag match parse a + // custom pre-release token. A zero value is normalized to the historical + // default by the cleaner, so callers with no custom grammar can omit it. + Spec taggrammar.Spec } // LifecycleCleaner performs the side effects of ending a divergence: deleting @@ -157,7 +162,11 @@ func (c *gitReleaseCleaner) CleanHotfixReleases(req CleanReleasesRequest) error if err != nil { return fmt.Errorf("listing tags for hotfix cleanup: %w", err) } - hotfixTags := hotfix.HotfixTagsForBase(req.BaseVersion, tags) + spec := req.Spec + if spec == (taggrammar.Spec{}) { + spec = taggrammar.Default() + } + hotfixTags := hotfix.HotfixTagsForBase(spec, req.BaseVersion, tags) var firstErr error for _, tag := range hotfixTags { diff --git a/internal/version/version.go b/internal/version/version.go index b41700e1..314197b8 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -259,27 +259,40 @@ func (v *Version) BaseVersion() *Version { } } +// WithGrammar returns a copy of v that renders under spec, so a version parsed +// with ParseWithGrammar carries its grammar through subsequent copy operations +// (WithHotfix, WithRC, Bump) and renders its configured pre-release shape. The +// zero/default grammar is preserved as-is, so a default version is unchanged. +func (v *Version) WithGrammar(spec taggrammar.Spec) *Version { + out := *v + s := spec + out.grammarSpec = &s + return &out +} + // WithRC returns a copy with the specified RC number func (v *Version) WithRC(rc int) *Version { return &Version{ - Major: v.Major, - Minor: v.Minor, - Patch: v.Patch, - PreRelease: rc, - Hotfix: -1, - Prefix: v.Prefix, + Major: v.Major, + Minor: v.Minor, + Patch: v.Patch, + PreRelease: rc, + Hotfix: -1, + Prefix: v.Prefix, + grammarSpec: v.grammarSpec, } } // Bump returns a new version with the specified bump applied func (v *Version) Bump(bump BumpType) *Version { result := &Version{ - Major: v.Major, - Minor: v.Minor, - Patch: v.Patch, - PreRelease: -1, - Hotfix: -1, - Prefix: v.Prefix, + Major: v.Major, + Minor: v.Minor, + Patch: v.Patch, + PreRelease: -1, + Hotfix: -1, + Prefix: v.Prefix, + grammarSpec: v.grammarSpec, } switch bump { @@ -480,12 +493,13 @@ func GetLatestRelease(tags []string) (*Version, error) { // preserving the major, minor, patch, pre-release, and prefix. func (v *Version) WithHotfix(m int) *Version { return &Version{ - Major: v.Major, - Minor: v.Minor, - Patch: v.Patch, - PreRelease: v.PreRelease, - Hotfix: m, - Prefix: v.Prefix, + Major: v.Major, + Minor: v.Minor, + Patch: v.Patch, + PreRelease: v.PreRelease, + Hotfix: m, + Prefix: v.Prefix, + grammarSpec: v.grammarSpec, } } From d97f958e1265bf77151b5ae9ab500b7101b3746f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:41:47 -0400 Subject: [PATCH 14/17] test(e2e): drive release and promote on a non-default tag grammar Signed-off-by: Joshua Temple --- .../content/docs/internals/coverage-matrix.md | 1 + ...48-tag-grammar-custom-release-promote.yaml | 170 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 e2e/scenarios/48-tag-grammar-custom-release-promote.yaml diff --git a/docs/src/content/docs/internals/coverage-matrix.md b/docs/src/content/docs/internals/coverage-matrix.md index 32255df8..0a3912fa 100644 --- a/docs/src/content/docs/internals/coverage-matrix.md +++ b/docs/src/content/docs/internals/coverage-matrix.md @@ -66,6 +66,7 @@ only under real installation tokens on the fleet, never in the token-free harnes | Default promotion (env to next env) | `04`, `promote/cascade-deploy-enabled` | `promote-staging` (2env, 3env, primary) | `internal/promote` | One promotion step copies source state into the target on a real release object | | Cascade-mode promotion (atomic multi-step) | `04-cascade-promotion` | `lifecycle` dev to prod (4env) | `internal/promote` | The full ladder advances through intermediates and publishes at the top | | Standalone release lane (draft, prerelease, publish) | `05-publish-callback`, `37`, `38` | dispatch prerelease then release (single-env); `release-only` | `internal/release` | A real release transitions draft to prerelease to published with RC reaping | +| Non-default tag grammar (custom prefix, token, separator) | `48-tag-grammar-custom-release-promote` | | `internal/taggrammar`, `internal/promote`, `internal/orchestrate` | A manifest `tag_grammar` reshapes the emitted candidate tag and recorded state, and promotion strips the custom pre-release token to publish | | Hotfix clean apply | `hotfix/hotfix-clean-apply`, `hotfix-multi-commit-clean`, `hotfix-multi-env-clean`, `hotfix-rejoin` | hotfix plan, apply, PR merge, finalize (3env) | `internal/hotfix` | A pinned-env fix lands, diverges state, and rejoins on real branches and PRs | | Hotfix cherry-pick conflict and halt | `hotfix/hotfix-conflict-resolution`, `hotfix-multi-env-conflict-halt` | `probe_hotfix_conflict` (4env) | `internal/hotfix` | A guaranteed conflict raises the conflict label and halts the downstream lane | | Rollback to prior version or SHA | `rollback/*` (8 scenarios) | `probe_rollback` (4env), `rollback-check` (2env) | `internal/rollback` | An env rewinds, is marked diverged, and the ring snapshot advances | diff --git a/e2e/scenarios/48-tag-grammar-custom-release-promote.yaml b/e2e/scenarios/48-tag-grammar-custom-release-promote.yaml new file mode 100644 index 00000000..5f9032a7 --- /dev/null +++ b/e2e/scenarios/48-tag-grammar-custom-release-promote.yaml @@ -0,0 +1,170 @@ +name: "Custom Tag Grammar Release and Promotion" +description: "A non-default tag_grammar reshapes the release-candidate tag and recorded state, and promotion strips the custom pre-release token to publish" + +config: + trunk_branch: main + environments: [dev, qa, uat, prod] + # A non-default tag grammar. The prefix stays "v" but the pre-release is named + # "beta" with an empty separator, so a candidate reads "v0.2.0-beta0" instead + # of the historical "v0.2.0-rc.0". The rehearsal token is renamed too so it + # stays distinguishable from the pre-release token. + tag_grammar: + prefix: v + prerelease_token: beta + prerelease_separator: "" + dryrun_token: rehearsal + builds: + - name: app + workflow: build.yaml + triggers: ["src/**"] + deploys: + - name: cdk + workflow: deploy.yaml + triggers: ["cdk/**"] + +# Starting state: a mature repo with v0.1.0 already published. The candidate +# environments carry the custom-shape pre-release, and the published tiers carry +# the plain base version. The v0.1.0-beta0 record was cleaned at publish time, so +# only the v0.1.0 tag remains. +setup: + state: + dev: + version: "v0.1.0-beta0" + qa: + version: "v0.1.0-beta0" + uat: + version: "v0.1.0-beta0" + release: + version: "v0.1.0" + prod: + version: "v0.1.0" + tags: + - "v0.1.0" + releases: + - tag: "v0.1.0" + prerelease: false + +steps: + # Step 1: New feature commit drives the next candidate. + - name: "New app feature for v0.2.0" + action: commit + commit: + message: "feat: add new feature" + files: + src/app.ts: | + export function main() { + console.log("App v0.2.0 with new feature"); + } + + # Step 2: Orchestrate dev. The candidate tag and recorded state must be in the + # custom shape "v0.2.0-beta0", not the historical "v0.2.0-rc.0". + - name: "Orchestrate dev after feature" + action: orchestrate + expect: + state: + dev: + sha: commit1 + version: "v0.2.0-beta0" + jobs: + build-app: success + deploy-cdk: skipped + releases: + - tag: "v0.2.0-beta0" + prerelease: true + draft: true + tags: + exist: ["v0.1.0", "v0.2.0-beta0"] + + # Step 3: Cascade promote dev to qa. The candidate is published (draft: false) + # while keeping its custom shape. + - name: "Cascade promote dev to qa" + action: promote + promote: + mode: cascade + target: qa + expect: + state: + qa: + sha: commit1 + version: "v0.2.0-beta0" + dev: + unchanged: true + uat: + unchanged: true + prod: + unchanged: true + releases: + - tag: "v0.2.0-beta0" + prerelease: true + draft: false + jobs: + deploy-cdk: skipped + + # Step 4: Cascade promote qa to uat. Still the custom-shape candidate. + - name: "Cascade promote qa to uat" + action: promote + promote: + mode: cascade + target: uat + expect: + state: + uat: + sha: commit1 + version: "v0.2.0-beta0" + qa: + unchanged: true + dev: + unchanged: true + prod: + unchanged: true + releases: + - tag: "v0.2.0-beta0" + prerelease: true + draft: false + + # Step 5: Standard promote from uat to release crosses the publish boundary. + # The custom pre-release token is stripped, so the published version is + # "v0.2.0" and the custom-shape candidate tags are reaped. + - name: "Promote uat to release" + action: promote + promote: + mode: default + expect: + state: + release: + sha: commit1 + version: "v0.2.0" + uat: + unchanged: true + releases: + - tag: "v0.2.0" + prerelease: false + draft: false + latest: true + tags: + exist: ["v0.1.0", "v0.2.0"] + deleted: ["v0.1.0-beta0", "v0.2.0-beta0"] + + # Step 6: Standard promote from release to prod carries the published version + # forward, confirming promotion advances correctly past the boundary. + - name: "Promote release to prod" + action: promote + promote: + mode: default + expect: + state: + prod: + sha: commit1 + version: "v0.2.0" + jobs: + deploy-cdk: skipped + +# FINAL STATE: +# dev: commit1 @ v0.2.0-beta0 +# qa: commit1 @ v0.2.0-beta0 +# uat: commit1 @ v0.2.0-beta0 +# release: commit1 @ v0.2.0 +# prod: commit1 @ v0.2.0 +# - The candidate tag and recorded state used the custom "v0.2.0-beta0" shape +# - Promotion stripped the "beta" token to publish "v0.2.0" +# - Latest release: v0.2.0 (published, not prerelease) From 2ae800fdaa3bb8747e74938090fd392f1177aced Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 22:53:27 -0400 Subject: [PATCH 15/17] fix(config): restrict tag_grammar values to shell- and regex-safe characters Signed-off-by: Joshua Temple --- internal/config/tag_grammar_test.go | 29 +++++++++++++++++++ internal/config/validate_v1.go | 45 +++++++++++++++++------------ internal/taggrammar/grammar.go | 11 +++---- internal/taggrammar/grammar_test.go | 12 ++++++++ 4 files changed, 73 insertions(+), 24 deletions(-) diff --git a/internal/config/tag_grammar_test.go b/internal/config/tag_grammar_test.go index 2602bb9d..f132b07d 100644 --- a/internal/config/tag_grammar_test.go +++ b/internal/config/tag_grammar_test.go @@ -143,6 +143,35 @@ func TestValidateTagGrammar(t *testing.T) { name: "custom separator empty is allowed", cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseSeparator: strptr("")}}, }, + { + name: "prerelease token with embedded single quote rejected", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("b'ad")}}, + wantErr: "prerelease_token", + }, + { + name: "prefix v is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{Prefix: strptr("v")}}, + }, + { + name: "prerelease token beta is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("beta")}}, + }, + { + name: "prerelease token pre is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseToken: strptr("pre")}}, + }, + { + name: "separator dot is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{PreReleaseSeparator: strptr(".")}}, + }, + { + name: "dryrun token dryrun is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{DryRunToken: strptr("dryrun")}}, + }, + { + name: "dryrun token rehearsal is allowed", + cfg: &TrunkConfig{TagGrammar: &TagGrammarConfig{DryRunToken: strptr("rehearsal")}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 4d47b106..45298abf 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -437,18 +437,25 @@ func validateConfigLevel(cfg *TrunkConfig) []string { return errs } -// tagGrammarUnsafeRe matches any character that would break a regex or a git -// ref if spliced into the tag grammar: whitespace, control characters, and the -// git ref / regex metacharacters. A tag component carrying one of these could -// not be compiled into a pattern or created as a git tag, so it is rejected up -// front rather than failing opaquely at tag time. +// tagGrammarAllowedChars is the allowlist of characters a tag_grammar +// component may contain. Every character in it is simultaneously safe in a +// git ref, a regex literal, and a single-quoted shell string, so a value built +// only from this set can never break the resolved regex, the created git tag, +// or the single-quoted `sed '...'` cascade's own generated release workflow +// emits. A blocklist would need to anticipate every shell and regex +// metacharacter (including a bare single quote, which terminates the emitted +// shell string); an allowlist rejects everything it does not explicitly +// admit, so nothing new can slip through. +const tagGrammarAllowedChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-" + +// tagGrammarUnsafeChar reports whether s contains any character outside +// tagGrammarAllowedChars. A tag component carrying such a character could not +// be compiled into a regex, created as a git tag, or spliced into the +// generated release workflow's single-quoted sed expression, so it is +// rejected up front rather than failing opaquely at tag time. func tagGrammarUnsafeChar(s string) bool { for _, r := range s { - if unicode.IsControl(r) || unicode.IsSpace(r) { - return true - } - switch r { - case '/', '~', '^', ':', '?', '*', '[', '\\': + if !strings.ContainsRune(tagGrammarAllowedChars, r) { return true } } @@ -457,11 +464,11 @@ func tagGrammarUnsafeChar(s string) bool { // validateTagGrammar structurally validates the optional tag_grammar block. // These are hard errors, not advisories: an empty pre-release token, a -// component carrying a regex- or git-ref-breaking character, or a dryrun token -// that collides with the pre-release token would each produce a grammar that -// cannot round-trip, so generation must not proceed. A nil block is valid and -// preserves the historical grammar. The redundant-prefix case is handled -// separately as a non-fatal advisory (see TrunkConfig.TagGrammarWarnings). +// component carrying a character outside tagGrammarAllowedChars, or a dryrun +// token that collides with the pre-release token would each produce a +// grammar that cannot round-trip, so generation must not proceed. A nil block +// is valid and preserves the historical grammar. The redundant-prefix case is +// handled separately as a non-fatal advisory (see TrunkConfig.TagGrammarWarnings). func validateTagGrammar(cfg *TrunkConfig) []string { g := cfg.TagGrammar if g == nil { @@ -474,20 +481,20 @@ func validateTagGrammar(cfg *TrunkConfig) []string { errs = append(errs, "tag_grammar.prerelease_token must not be empty") } else if tagGrammarUnsafeChar(*g.PreReleaseToken) { errs = append(errs, fmt.Sprintf( - "tag_grammar.prerelease_token %q contains a character that breaks a regex or a git ref", *g.PreReleaseToken)) + "tag_grammar.prerelease_token %q must contain only letters, digits, '.', '_', and '-'", *g.PreReleaseToken)) } } if g.Prefix != nil && tagGrammarUnsafeChar(*g.Prefix) { errs = append(errs, fmt.Sprintf( - "tag_grammar.prefix %q contains a character that breaks a regex or a git ref", *g.Prefix)) + "tag_grammar.prefix %q must contain only letters, digits, '.', '_', and '-'", *g.Prefix)) } if g.PreReleaseSeparator != nil && tagGrammarUnsafeChar(*g.PreReleaseSeparator) { errs = append(errs, fmt.Sprintf( - "tag_grammar.prerelease_separator %q contains a character that breaks a regex or a git ref", *g.PreReleaseSeparator)) + "tag_grammar.prerelease_separator %q must contain only letters, digits, '.', '_', and '-'", *g.PreReleaseSeparator)) } if g.DryRunToken != nil && tagGrammarUnsafeChar(*g.DryRunToken) { errs = append(errs, fmt.Sprintf( - "tag_grammar.dryrun_token %q contains a character that breaks a regex or a git ref", *g.DryRunToken)) + "tag_grammar.dryrun_token %q must contain only letters, digits, '.', '_', and '-'", *g.DryRunToken)) } // The dryrun token must stay distinguishable from the pre-release token, or diff --git a/internal/taggrammar/grammar.go b/internal/taggrammar/grammar.go index e5e6d857..838d2a9f 100644 --- a/internal/taggrammar/grammar.go +++ b/internal/taggrammar/grammar.go @@ -139,12 +139,13 @@ func (s Spec) StripPreRelease(tag string) string { // PreReleaseStripSedBRE returns the anchored sed basic-regular-expression that // matches this spec's pre-release segment at the end of a version string, for // example "-rc\.[0-9]*$" for the default grammar and "-beta[0-9]*$" for a beta -// token with an empty separator. The token is emitted literally; the separator -// is escaped so a metacharacter such as "." matches literally. Code generators -// bake this into the driven repo's release workflow so the emitted strip follows -// the configured grammar. +// token with an empty separator. Both the token and the separator are +// escaped, so a metacharacter such as "." (a value the config allowlist +// otherwise permits in either field) matches literally instead of as a +// wildcard. Code generators bake this into the driven repo's release +// workflow so the emitted strip follows the configured grammar. func (s Spec) PreReleaseStripSedBRE() string { - return "-" + s.PreReleaseToken + sedBREEscape(s.PreReleaseSeparator) + "[0-9]*$" + return "-" + sedBREEscape(s.PreReleaseToken) + sedBREEscape(s.PreReleaseSeparator) + "[0-9]*$" } // sedBREEscape backslash-escapes the characters that carry special meaning in a diff --git a/internal/taggrammar/grammar_test.go b/internal/taggrammar/grammar_test.go index bd326f4a..d1c8fdcc 100644 --- a/internal/taggrammar/grammar_test.go +++ b/internal/taggrammar/grammar_test.go @@ -111,6 +111,18 @@ func TestPreReleaseStripSedBRE(t *testing.T) { } } +// A pre-release token that itself carries a sed-BRE metacharacter (a literal +// "." is allowed by the config allowlist) must be escaped the same way the +// separator already is, or the emitted sed would match more than the literal +// token intends. +func TestPreReleaseStripSedBRE_EscapesTokenMetachar(t *testing.T) { + s := Default() + s.PreReleaseToken = "r.c" + if got := s.PreReleaseStripSedBRE(); got != `-r\.c\.[0-9]*$` { + t.Errorf("PreReleaseStripSedBRE() = %q, want %q", got, `-r\.c\.[0-9]*$`) + } +} + func TestParseFormat_NonDefaultSpec(t *testing.T) { s := Default() s.Prefix = "" From d604f438aef070f656ec461c747f0fc8685c7f68 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 23:00:25 -0400 Subject: [PATCH 16/17] fix(schema): declare the tag_grammar block in the manifest json schema Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 32 +++++++++++++++ internal/schema/manifest.schema.json | 32 +++++++++++++++ internal/schema/schema_test.go | 58 ++++++++++++++++++++++++++++ schema/manifest.schema.json | 32 +++++++++++++++ 4 files changed, 154 insertions(+) diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 64380c92..0251838e 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -93,6 +93,7 @@ "type": "string", "description": "Version tag prefix (default: \"v\")." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, "release_token": { "type": "string", "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." @@ -696,6 +697,37 @@ "comment": { "type": "boolean" } } }, + "tagGrammarConfig": { + "type": "object", + "additionalProperties": false, + "description": "Optional, additive reshaping of the release tag grammar. Every field is optional; an omitted field inherits cascade's historical default, so an empty block leaves the default grammar (for example v1.2.3, v1.2.3-rc.1, v1.2.3-dryrun.1) unchanged. Component values are restricted to letters, digits, '.', '_', and '-' so every emitted tag stays a valid git ref and shell-safe token.", + "properties": { + "prefix": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Version tag prefix (default: \"v\"). When set, this wins over the manifest-level tag_prefix. May be empty for a prefix-less grammar." + }, + "prerelease_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a pre-release tag (default: \"rc\"). Must be non-empty and must differ from dryrun_token so rehearsal tags stay distinguishable." + }, + "prerelease_separator": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Separator between the base version and the pre-release token (default: \"-\"). May be empty." + }, + "dryrun_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a dry-run rehearsal tag (default: \"dryrun\"). Must differ from prerelease_token." + }, + "strict_prefix": { + "type": "boolean", + "description": "Require the configured prefix on parse rather than accepting a prefix-less version (default: false)." + } + } + }, "rollbackConfig": { "type": "object", "additionalProperties": false, diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 64380c92..0251838e 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -93,6 +93,7 @@ "type": "string", "description": "Version tag prefix (default: \"v\")." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, "release_token": { "type": "string", "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." @@ -696,6 +697,37 @@ "comment": { "type": "boolean" } } }, + "tagGrammarConfig": { + "type": "object", + "additionalProperties": false, + "description": "Optional, additive reshaping of the release tag grammar. Every field is optional; an omitted field inherits cascade's historical default, so an empty block leaves the default grammar (for example v1.2.3, v1.2.3-rc.1, v1.2.3-dryrun.1) unchanged. Component values are restricted to letters, digits, '.', '_', and '-' so every emitted tag stays a valid git ref and shell-safe token.", + "properties": { + "prefix": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Version tag prefix (default: \"v\"). When set, this wins over the manifest-level tag_prefix. May be empty for a prefix-less grammar." + }, + "prerelease_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a pre-release tag (default: \"rc\"). Must be non-empty and must differ from dryrun_token so rehearsal tags stay distinguishable." + }, + "prerelease_separator": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Separator between the base version and the pre-release token (default: \"-\"). May be empty." + }, + "dryrun_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a dry-run rehearsal tag (default: \"dryrun\"). Must differ from prerelease_token." + }, + "strict_prefix": { + "type": "boolean", + "description": "Require the configured prefix on parse rather than accepting a prefix-less version (default: false)." + } + } + }, "rollbackConfig": { "type": "object", "additionalProperties": false, diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 12551c07..35f27865 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -280,6 +280,64 @@ func TestSchema_AcceptsRunsOnUnionForms(t *testing.T) { } } +func TestSchema_AcceptsTagGrammarBlock(t *testing.T) { + sch := compileSchema(t) + + good := map[string]any{ + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "tag_grammar": map[string]any{ + "prefix": "release-", + "prerelease_token": "beta", + "prerelease_separator": ".", + "dryrun_token": "rehearsal", + "strict_prefix": true, + }, + }}, + } + if err := sch.Validate(toJSONValue(t, good)); err != nil { + t.Fatalf("valid tag_grammar block must validate: %v", err) + } +} + +// TestSchema_RejectsInvalidTagGrammar proves the JSON Schema enforces the same +// character allowlist as config.validateTagGrammar: a token carrying a +// disallowed character or an empty prerelease_token must be rejected, so the +// schema and the Go validator agree. +func TestSchema_RejectsInvalidTagGrammar(t *testing.T) { + sch := compileSchema(t) + + cases := map[string]map[string]any{ + "prerelease_token with quote": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "tag_grammar": map[string]any{"prerelease_token": "r'c"}, + }}, + }, + "empty prerelease_token": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "tag_grammar": map[string]any{"prerelease_token": ""}, + }}, + }, + "unknown tag_grammar key": { + "ci": map[string]any{"config": map[string]any{ + "trunk_branch": "main", + "tag_grammar": map[string]any{"bogus": "x"}, + }}, + }, + } + + for name, doc := range cases { + doc := doc + t.Run(name, func(t *testing.T) { + if err := sch.Validate(toJSONValue(t, doc)); err == nil { + t.Fatalf("expected validation to fail for %q, but it passed", name) + } + }) + } +} + func TestSchema_OnDiskCopiesAreByteIdentical(t *testing.T) { root := repoRoot(t) paths := []string{ diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 64380c92..0251838e 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -93,6 +93,7 @@ "type": "string", "description": "Version tag prefix (default: \"v\")." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, "release_token": { "type": "string", "description": "GitHub Actions secret expression for release operations (default: ${{ secrets.GITHUB_TOKEN }})." @@ -696,6 +697,37 @@ "comment": { "type": "boolean" } } }, + "tagGrammarConfig": { + "type": "object", + "additionalProperties": false, + "description": "Optional, additive reshaping of the release tag grammar. Every field is optional; an omitted field inherits cascade's historical default, so an empty block leaves the default grammar (for example v1.2.3, v1.2.3-rc.1, v1.2.3-dryrun.1) unchanged. Component values are restricted to letters, digits, '.', '_', and '-' so every emitted tag stays a valid git ref and shell-safe token.", + "properties": { + "prefix": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Version tag prefix (default: \"v\"). When set, this wins over the manifest-level tag_prefix. May be empty for a prefix-less grammar." + }, + "prerelease_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a pre-release tag (default: \"rc\"). Must be non-empty and must differ from dryrun_token so rehearsal tags stay distinguishable." + }, + "prerelease_separator": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]*$", + "description": "Separator between the base version and the pre-release token (default: \"-\"). May be empty." + }, + "dryrun_token": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Token marking a dry-run rehearsal tag (default: \"dryrun\"). Must differ from prerelease_token." + }, + "strict_prefix": { + "type": "boolean", + "description": "Require the configured prefix on parse rather than accepting a prefix-less version (default: false)." + } + } + }, "rollbackConfig": { "type": "object", "additionalProperties": false, From 551dc850a555e8c558d957472c86822aa9c16f88 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 23:24:47 -0400 Subject: [PATCH 17/17] fix(e2e): make the release-cleanup reaper honor the configured tag grammar Signed-off-by: Joshua Temple --- e2e/harness/gitea.go | 32 ++++++++++++++++++---------- e2e/harness/gitea_test.go | 45 +++++++++++++++++++++++++++++++++++++++ e2e/harness/runner.go | 7 ++++-- 3 files changed, 71 insertions(+), 13 deletions(-) diff --git a/e2e/harness/gitea.go b/e2e/harness/gitea.go index 067342a0..936997d9 100644 --- a/e2e/harness/gitea.go +++ b/e2e/harness/gitea.go @@ -11,6 +11,7 @@ import ( "sort" "time" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) @@ -642,18 +643,22 @@ func (g *GiteaContainer) DeleteTag(ctx context.Context, repo *Repo, tag string) return nil } -// DeleteRCTags deletes all RC tags matching a base version (e.g., v1.0.0-rc.*) -// This simulates what the GitHub manage-release action does after publishing -func (g *GiteaContainer) DeleteRCTags(ctx context.Context, repo *Repo, baseVersion string) error { +// DeleteRCTags deletes all pre-release tags matching a base version under the +// given grammar (e.g., v1.0.0-rc.* by default, or v0.2.0-beta* for a custom +// token/separator). This simulates what the GitHub manage-release action does +// after publishing, so it must honor the same tag grammar the release feature +// honors. Pass taggrammar.Default() to reproduce the historical "-rc." behavior. +func (g *GiteaContainer) DeleteRCTags(ctx context.Context, repo *Repo, baseVersion string, spec taggrammar.Spec) error { tags, err := g.GetTags(ctx, repo) if err != nil { return fmt.Errorf("failed to get tags: %w", err) } for _, tag := range tags { - // Check if this is an RC tag for the given base version - // e.g., "v1.0.0-rc.0", "v1.0.0-rc.1" match base "v1.0.0" - if isRCTagForBase(tag, baseVersion) { + // Check if this is a pre-release tag for the given base version. + // e.g., under the default grammar "v1.0.0-rc.0", "v1.0.0-rc.1" + // match base "v1.0.0". + if isRCTagForBase(tag, baseVersion, spec) { if err := g.DeleteTag(ctx, repo, tag); err != nil { return fmt.Errorf("failed to delete RC tag %s: %w", tag, err) } @@ -663,11 +668,16 @@ func (g *GiteaContainer) DeleteRCTags(ctx context.Context, repo *Repo, baseVersi return nil } -// isRCTagForBase checks if a tag is an RC tag for a given base version -// e.g., "v1.0.0-rc.0" is an RC tag for "v1.0.0" -func isRCTagForBase(tag, baseVersion string) bool { - // Tag must start with the base version + "-rc." - prefix := baseVersion + "-rc." +// isRCTagForBase checks if a tag is a pre-release tag for a given base version +// under spec. The candidate prefix is derived from the grammar as +// baseVersion + "-" + token + separator, so the default grammar (token "rc", +// separator ".") yields the historical "-rc." prefix while a custom grammar +// (e.g. token "beta", separator "") yields "-beta". Everything after the prefix +// must be digits, so "v1.2.3-rc.0" matches base "v1.2.3" but nested tags such as +// "v1.2.3-rc.4.hotfix.1" and unrelated tags do not. +func isRCTagForBase(tag, baseVersion string, spec taggrammar.Spec) bool { + // Tag must start with the base version + "-" + pre-release token + separator. + prefix := baseVersion + "-" + spec.PreReleaseToken + spec.PreReleaseSeparator if len(tag) <= len(prefix) { return false } diff --git a/e2e/harness/gitea_test.go b/e2e/harness/gitea_test.go index 4724ced2..9cea36e0 100644 --- a/e2e/harness/gitea_test.go +++ b/e2e/harness/gitea_test.go @@ -5,10 +5,55 @@ import ( "testing" "time" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// TestIsRCTagForBase locks the release-cleanup reaper predicate. The default +// grammar must reap exactly the tags it reaped before the grammar was threaded +// through (byte-identical), and a custom grammar must reap its own pre-release +// shape rather than the hardcoded "-rc." shape. +func TestIsRCTagForBase(t *testing.T) { + tests := []struct { + name string + tag string + base string + spec taggrammar.Spec + want bool + }{ + // Default grammar (rc / "."): byte-identical to the pre-grammar reaper. + {"default rc zero", "v1.2.3-rc.0", "v1.2.3", taggrammar.Default(), true}, + {"default rc multi digit", "v1.2.3-rc.10", "v1.2.3", taggrammar.Default(), true}, + {"default bare release", "v1.2.3", "v1.2.3", taggrammar.Default(), false}, + {"default empty suffix", "v1.2.3-rc.", "v1.2.3", taggrammar.Default(), false}, + {"default different base", "v1.2.4-rc.0", "v1.2.3", taggrammar.Default(), false}, + {"default nested hotfix rejected", "v1.2.3-rc.4.hotfix.1", "v1.2.3", taggrammar.Default(), false}, + {"default foreign token", "v1.2.3-beta0", "v1.2.3", taggrammar.Default(), false}, + {"default unrelated", "release-1", "v1.2.3", taggrammar.Default(), false}, + + // Custom grammar (beta / ""): reaps v-beta. + {"custom beta zero", "v0.2.0-beta0", "v0.2.0", betaSpec(), true}, + {"custom beta multi digit", "v0.2.0-beta12", "v0.2.0", betaSpec(), true}, + {"custom bare release", "v0.2.0", "v0.2.0", betaSpec(), false}, + {"custom rejects default rc", "v0.2.0-rc.0", "v0.2.0", betaSpec(), false}, + {"custom empty suffix", "v0.2.0-beta", "v0.2.0", betaSpec(), false}, + {"custom different base", "v0.3.0-beta0", "v0.2.0", betaSpec(), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isRCTagForBase(tt.tag, tt.base, tt.spec)) + }) + } +} + +func betaSpec() taggrammar.Spec { + s := taggrammar.Default() + s.PreReleaseToken = "beta" + s.PreReleaseSeparator = "" + return s +} + func TestGiteaContainer_Start(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index fb9f60d2..7169a2b4 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -1143,9 +1143,12 @@ func (r *Runner) syncStateFromGitea(ctx context.Context, config Config) error { } } - // Delete RC tags for each final release (simulating publish cleanup) + // Delete RC tags for each final release (simulating publish cleanup). + // Honor the configured tag grammar so custom pre-release tokens (e.g. + // "beta" with no separator) are reaped, not just the default "-rc." shape. + spec := config.ResolveTagGrammar() for _, finalTag := range finalReleaseTags { - if err := r.harness.gitea.DeleteRCTags(ctx, r.harness.repo, finalTag); err != nil { + if err := r.harness.gitea.DeleteRCTags(ctx, r.harness.repo, finalTag, spec); err != nil { r.t.Logf(" Warning: failed to cleanup RC tags for %s: %v", finalTag, err) } else { r.t.Logf(" Cleaned up RC tags for %s", finalTag)