diff --git a/e2e/scenarios/45-component-versioning.yaml b/e2e/scenarios/45-component-versioning.yaml new file mode 100644 index 00000000..0aa3bd21 --- /dev/null +++ b/e2e/scenarios/45-component-versioning.yaml @@ -0,0 +1,117 @@ +name: "Per-Component Version Scoping" +description: | + Exercises per-component version scoping (#287). The manifest declares two + components, each owning a path subtree with its own strict tag namespace. Each + generated per-component orchestrate workflow drives its setup step with its own + --component flag, so at runtime that component's version is derived from only its + path-scoped commits and its strict tag prefix (api-* never reads web-*, and vice + versa). The scenario seeds both subtrees, proves the multi-component generate then + verify roundtrip is drift-free, and asserts each orchestrate-.yaml carries + its own --component invocation and path filter and not the sibling's. It then + advances only one component's subtree and reconfirms the drift-free roundtrip, + showing each component advances on its own path independently. The version math + itself (a commit under one component's path bumps only that component, in its own + tag namespace) is asserted in the git, config, version, and orchestrate unit + tests, which drive the computation directly; act cannot yet run a specific + per-component orchestrate workflow, so this scenario proves the generated wiring + and path isolation rather than executing the per-component version calculation. + +config: + trunk_branch: main + environments: [dev, prod] + builds: + - name: app + workflow: build.yaml + triggers: ["services/**"] + deploys: + - name: app + workflow: deploy.yaml + triggers: ["services/**"] + components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- + +steps: + - name: "Seed both component subtrees" + action: commit + commit: + message: "seed component sources" + files: + services/api/main.go: | + package main + + func main() {} + services/web/main.go: | + package main + + func main() {} + + - name: "Regenerate the per-component set and confirm no drift" + action: verify + verify: + regenerate: true + expect_exit: 0 + + - name: "Each per-component workflow scopes its own version derivation" + action: verify + verify: + regenerate: true + expect_exit: 0 + # The observable, harness-robust proof of per-component version scoping is the + # emitted setup invocation: each orchestrate-.yaml runs `cascade + # orchestrate setup ... --component `, the flag that scopes that + # component's version to its own path and strict tag namespace at runtime. The + # run line is never rewritten by the harness (only the top-level name: is + # suffixed and setup-cli@ref localized), so these substrings are stable. The + # path filter and per-component concurrency group cross-check isolation, and + # not_contains proves neither file carries the sibling's scope. + expect: + workflow_files: + - path: ".github/workflows/orchestrate-api.yaml" + contains: + - "--component api" + - "- 'services/api/**'" + - "group: orchestrate-api-" + not_contains: + - "--component web" + - "services/web" + - path: ".github/workflows/orchestrate-web.yaml" + contains: + - "--component web" + - "- 'services/web/**'" + - "group: orchestrate-web-" + not_contains: + - "--component api" + - "services/api" + - path: ".github/workflows/orchestrate.yaml" + not_exists: true + + - name: "Advance only the api subtree" + action: commit + commit: + message: "feat: add api handler" + files: + services/api/handler.go: | + package main + + func handler() {} + + - name: "Roundtrip stays drift-free after an isolated per-component change" + action: verify + verify: + regenerate: true + expect_exit: 0 + # A source-only commit under one component's path does not alter the generated + # workflow set, and the api workflow still owns its own --component scope. This + # demonstrates each component advances on its own path independently. + expect: + workflow_files: + - path: ".github/workflows/orchestrate-api.yaml" + contains: + - "--component api" + not_contains: + - "--component web" diff --git a/internal/config/component_version_test.go b/internal/config/component_version_test.go new file mode 100644 index 00000000..954114c4 --- /dev/null +++ b/internal/config/component_version_test.go @@ -0,0 +1,59 @@ +package config + +import "testing" + +func twoComponentTrunk() *TrunkConfig { + return &TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Components: map[string]ComponentConfig{ + "api": {Path: "services/api", TagPrefix: "api-"}, + "web": {Path: "services/web", TagPrefix: "web-"}, + }, + } +} + +func TestGetComponentTagPrefix(t *testing.T) { + cfg := twoComponentTrunk() + + got, err := cfg.GetComponentTagPrefix("api") + if err != nil { + t.Fatalf("GetComponentTagPrefix(api): %v", err) + } + if got != "api-" { + t.Errorf("GetComponentTagPrefix(api) = %q, want %q", got, "api-") + } + + if _, err := cfg.GetComponentTagPrefix("missing"); err == nil { + t.Errorf("GetComponentTagPrefix(missing): expected error, got nil") + } +} + +// TestResolvedComponent_TagGrammarSpec_ForcesStrictPrefix proves a component's +// derived grammar carries its own prefix AND forces StrictPrefix true, so the +// component reads only its own tag namespace even when no tag_grammar block set +// strict_prefix. This is the HLD Section 5 isolation invariant. +func TestResolvedComponent_TagGrammarSpec_ForcesStrictPrefix(t *testing.T) { + cfg := twoComponentTrunk() + + resolved, err := cfg.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + + spec := resolved.TagGrammarSpec() + if spec.Prefix != "api-" { + t.Errorf("spec.Prefix = %q, want %q", spec.Prefix, "api-") + } + if !spec.StrictPrefix { + t.Errorf("spec.StrictPrefix = false, want true (component must read strictly)") + } + + // The strict api- grammar accepts its own tags and rejects a sibling's. + if !spec.IsVersionTag("api-1.2.3") { + t.Errorf("strict api- spec must accept api-1.2.3") + } + if spec.IsVersionTag("web-1.2.3") { + t.Errorf("strict api- spec must reject web-1.2.3 (namespace isolation)") + } +} diff --git a/internal/config/components.go b/internal/config/components.go index 5f4be0ee..6fb07592 100644 --- a/internal/config/components.go +++ b/internal/config/components.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/stablekernel/cascade/internal/taggrammar" ) // clone returns a fully independent deep copy of the config via a JSON round @@ -42,6 +44,18 @@ func ComponentConcurrencyGroup(name string) string { return fmt.Sprintf("orchestrate-%s-${{ github.ref }}", name) } +// GetComponentTagPrefix returns the declared tag_prefix for the named component, +// the tag namespace that component's versions and tags live under. It errors when +// the component is not declared. Version and tag discovery use this so a +// component's scan is scoped to its own namespace and never a sibling's. +func (c *TrunkConfig) GetComponentTagPrefix(name string) (string, error) { + comp, ok := c.Components[name] + if !ok { + return "", fmt.Errorf("component %q is not declared", name) + } + return comp.TagPrefix, nil +} + // ResolvedComponent is the effective configuration for one component: its // identity (Name), the subtree it owns (Path), and Config, a TrunkConfig holding // the shared defaults with the component's overrides applied. Path is a @@ -180,6 +194,22 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error) return &ResolvedComponent{Name: name, Path: comp.Path, Config: eff}, nil } +// TagGrammarSpec returns the tag grammar a component reads and emits its versions +// under: the component's resolved grammar (carrying its required tag_prefix and +// any inherited or overridden tag_grammar block) with StrictPrefix forced true. +// The strict flip is the HLD Section 5 isolation invariant: a component with a +// declared tag_prefix parses its tags literally so api-1.2.3 and web-1.2.3 never +// cross-match, and a nested-substring prefix (api- vs api-beta-) cannot collide +// either. It is forced on regardless of whether a tag_grammar block set +// strict_prefix, because the per-component namespace boundary is not optional. The +// implicit default (single-component) path does not call this; it keeps the +// permissive ResolveTagGrammar spec so single-component reads are unchanged. +func (r *ResolvedComponent) TagGrammarSpec() taggrammar.Spec { + spec := r.Config.ResolveTagGrammar() + spec.StrictPrefix = true + return spec +} + // globalOnlyComponentFields is the set of top-level-only (global) manifest keys // that must never be overridden per component. It backs the targeted rejection // message in validateComponents; any of these keys set under a component is a diff --git a/internal/generate/component_workflows_test.go b/internal/generate/component_workflows_test.go index 0f6d49c8..3159f9f7 100644 --- a/internal/generate/component_workflows_test.go +++ b/internal/generate/component_workflows_test.go @@ -115,6 +115,32 @@ func TestOrchestrateTargets_Components_FanOut(t *testing.T) { if strings.Contains(api, "orchestrate-web-${{ github.ref }}") { t.Errorf("api workflow must not carry web's concurrency group (isolation)") } + + // Version scoping: each workflow passes its own --component so the setup step + // derives that component's version from its own path and tag namespace. + if !strings.Contains(api, "--component api") { + t.Errorf("api workflow setup missing --component api") + } + if strings.Contains(api, "--component web") { + t.Errorf("api workflow must not carry web's --component (isolation)") + } + if !strings.Contains(web, "--component web") { + t.Errorf("web workflow setup missing --component web") + } +} + +// TestGenerator_SingleComponent_NoComponentFlag proves the single-component +// orchestrate workflow emits no --component flag, keeping its setup invocation +// byte-identical to the pre-component generator. +func TestGenerator_SingleComponent_NoComponentFlag(t *testing.T) { + cfg := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev", "prod"}} + got, err := NewGenerator(cfg, "").Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + if strings.Contains(got, "--component") { + t.Errorf("single-component workflow must not carry a --component flag") + } } // TestPlan_Components_MatchesGeneratedBytes proves that for a components: diff --git a/internal/generate/generator.go b/internal/generate/generator.go index 4b5e699f..2f9631a3 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -938,6 +938,13 @@ func (g *Generator) writeSetupJob(sb *strings.Builder) { sb.WriteString(" cascade orchestrate setup \\\n") } fmt.Fprintf(sb, " --config %s \\\n", g.getManifestFilePath()) + // A per-component orchestrate workflow scopes version derivation to its own + // component (path-scoped commits, strict tag namespace) by passing --component. + // The single-component workflow emits no --component line, so its output stays + // byte-identical. + if g.componentName != "" { + fmt.Fprintf(sb, " --component %s \\\n", g.componentName) + } sb.WriteString(" --gha-output\n") sb.WriteString("\n") diff --git a/internal/git/component_test.go b/internal/git/component_test.go new file mode 100644 index 00000000..f0fc899c --- /dev/null +++ b/internal/git/component_test.go @@ -0,0 +1,135 @@ +package git + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/taggrammar" +) + +// commitFileAt writes a file at a (possibly nested) repo-relative path, staging +// and committing it, and returns the resulting commit SHA. It is the path-scoped +// sibling of commitFile, used to advance one component's subtree in isolation. +func commitFileAt(t *testing.T, repoPath, content, message string) string { + t.Helper() + if dir := filepath.Dir(repoPath); dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.WriteFile(repoPath, []byte(content), 0o600); err != nil { + t.Fatalf("write file %s: %v", repoPath, err) + } + runGit(t, "add", repoPath) + runGit(t, "commit", "-m", message) + out, err := exec.Command("git", "rev-parse", "HEAD").Output() + if err != nil { + t.Fatalf("rev-parse HEAD: %v", err) + } + return strings.TrimSpace(string(out)) +} + +// TestGetCommitsForPaths_ScopesToIncludePaths proves a commit touching only one +// component's subtree appears in that component's range and not a sibling's, so +// per-component version derivation never sees a sibling's commits. +func TestGetCommitsForPaths_ScopesToIncludePaths(t *testing.T) { + newScratchRepo(t) + base := commitFileAt(t, "README.md", "root", "chore: seed") + commitFileAt(t, "services/api/main.go", "package api", "feat: api change") + commitFileAt(t, "services/web/main.go", "package web", "fix: web change") + + apiCommits, err := GetCommitsForPaths(base, "HEAD", []string{"services/api"}) + if err != nil { + t.Fatalf("GetCommitsForPaths(api): %v", err) + } + if len(apiCommits) != 1 { + t.Fatalf("api range: got %d commits, want 1", len(apiCommits)) + } + if apiCommits[0].Subject != "feat: api change" { + t.Errorf("api range subject = %q, want %q", apiCommits[0].Subject, "feat: api change") + } + + webCommits, err := GetCommitsForPaths(base, "HEAD", []string{"services/web"}) + if err != nil { + t.Fatalf("GetCommitsForPaths(web): %v", err) + } + if len(webCommits) != 1 { + t.Fatalf("web range: got %d commits, want 1", len(webCommits)) + } + if webCommits[0].Subject != "fix: web change" { + t.Errorf("web range subject = %q, want %q", webCommits[0].Subject, "fix: web change") + } +} + +// TestGetCommitsForPaths_NilPathsEqualsWholeRepo proves that with no include +// paths GetCommitsForPaths sees every commit in the range, matching +// GetCommits(base, head, nil): the single-component reduction is unchanged. +func TestGetCommitsForPaths_NilPathsEqualsWholeRepo(t *testing.T) { + newScratchRepo(t) + base := commitFileAt(t, "README.md", "root", "chore: seed") + commitFileAt(t, "services/api/main.go", "package api", "feat: api change") + commitFileAt(t, "services/web/main.go", "package web", "fix: web change") + + scoped, err := GetCommitsForPaths(base, "HEAD", nil) + if err != nil { + t.Fatalf("GetCommitsForPaths(nil): %v", err) + } + plain, err := GetCommits(base, "HEAD", nil) + if err != nil { + t.Fatalf("GetCommits(nil): %v", err) + } + if len(scoped) != len(plain) { + t.Fatalf("nil-path scope: got %d commits, want %d (whole repo)", len(scoped), len(plain)) + } + if len(scoped) != 2 { + t.Errorf("nil-path scope: got %d commits, want 2", len(scoped)) + } +} + +// TestGetLatestTagSpec_StrictPrefixIsolatesNamespace proves a component's strict +// prefix accepts only its own tags and rejects a sibling's, so a component's +// current-version lookup never reads across the namespace boundary. +func TestGetLatestTagSpec_StrictPrefixIsolatesNamespace(t *testing.T) { + dir := newScratchRepo(t) + commitFileAt(t, "README.md", "root", "chore: seed") + + tagHead(t, "api-1.2.0") + tagHead(t, "api-1.2.1") + tagHead(t, "web-2.0.0") // sibling namespace, higher base version + tagHead(t, "api-nightly") // foreign tag matching the api glob + + spec := taggrammar.Default() + spec.Prefix = "api-" + spec.StrictPrefix = true + + got, sha, err := GetLatestTagSpec(dir, spec) + if err != nil { + t.Fatalf("GetLatestTagSpec(api): %v", err) + } + if got != "api-1.2.1" { + t.Errorf("GetLatestTagSpec(api) = %q, want %q (must ignore web-* and foreign tags)", got, "api-1.2.1") + } + if sha == "" { + t.Errorf("GetLatestTagSpec(api) returned empty SHA for %q", got) + } +} + +// TestGetLatestTag_StaysPermissive proves the GetLatestTag wrapper preserves its +// historical permissive-prefix behavior for the single-component path. +func TestGetLatestTag_StaysPermissive(t *testing.T) { + dir := newScratchRepo(t) + commitFileAt(t, "README.md", "root", "chore: seed") + tagHead(t, "v0.5.0") + tagHead(t, "v0.5.1") + + got, _, err := GetLatestTag(dir, "v") + if err != nil { + t.Fatalf("GetLatestTag: %v", err) + } + if got != "v0.5.1" { + t.Errorf("GetLatestTag = %q, want %q", got, "v0.5.1") + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 0a8c1376..b2d9bc1d 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -95,6 +95,37 @@ func GetCommits(baseSHA, headSHA string, excludePaths []string) ([]Commit, error return parseCommits(output), nil } +// GetCommitsForPaths returns commits between two SHAs whose changes touch at +// least one of includePaths, emitting `git log ... base..head -- `. It +// is the include-path sibling of GetCommits (which appends pathspecs only as +// exclusions): a component's version derivation passes its own path so a commit +// touching only a sibling component's subtree is invisible to it. With an empty +// includePaths the argv carries no `--` separator, so the result is identical to +// GetCommits(base, head, nil) and the single-component path is unchanged. +func GetCommitsForPaths(baseSHA, headSHA string, includePaths []string) ([]Commit, error) { + format := "%H\x1f%s\x1f%an\x1f%ae\x1f%b\x1e" + + args := []string{"log", fmt.Sprintf("--format=%s", format), fmt.Sprintf("%s..%s", baseSHA, headSHA)} + + // Add include paths as a trailing pathspec so only commits touching one of + // them are reported. + if len(includePaths) > 0 { + args = append(args, "--") + args = append(args, includePaths...) + } + + cmd := exec.Command("git", args...) + output, err := cmd.Output() + if err != nil { + // A non-zero exit is a real git failure (bad base SHA, shallow clone, or + // "not a git repository"); an empty range exits 0. Surface it rather than + // masking it as "no commits" (mirrors GetCommits). + return nil, fmt.Errorf("git log: %w", err) + } + + return parseCommits(output), nil +} + func parseCommits(data []byte) []Commit { var commits []Commit @@ -158,9 +189,25 @@ func GetInitialCommit() (string, error) { // to the process working directory. Returns empty string if no matching tags // found. func GetLatestTag(dir, prefix string) (string, string, error) { - // Get all tags matching prefix, sorted by version descending - // --sort=-v:refname sorts by version in descending order - cmd := exec.Command("git", "tag", "-l", prefix+"*", "--sort=-v:refname") + // The historical contract validates candidates under the default (permissive) + // grammar with the caller's prefix glob, so a hyphenated or foreign-cased tag + // still reads as today. GetLatestTagSpec carries that behavior verbatim when + // given a permissive default spec. + spec := taggrammar.Default() + spec.Prefix = prefix + return GetLatestTagSpec(dir, spec) +} + +// GetLatestTagSpec is GetLatestTag under a caller-supplied grammar. The prefix +// glob widens the candidate set to every tag leading with spec.Prefix; the tag +// predicate then narrows it to a version-parseable tag under spec, so a strict +// per-component prefix (StrictPrefix) accepts only that component's tags and can +// never read a sibling component's namespace. Returns empty string if no +// matching version tag is found. +func GetLatestTagSpec(dir string, spec taggrammar.Spec) (string, string, error) { + // Get all tags matching the prefix glob, sorted by version descending. + // --sort=-v:refname sorts by version in descending order. + cmd := exec.Command("git", "tag", "-l", spec.Prefix+"*", "--sort=-v:refname") cmd.Dir = dir output, err := cmd.Output() if err != nil { @@ -171,9 +218,9 @@ func GetLatestTag(dir, prefix string) (string, string, error) { // The prefix glob can still match non-version tags (for example a // vX.Y.Z-dryrun.N exercise tag or a "vnightly" alias). Skip anything that is - // not a canonical cascade version so it can never be read as the latest one. + // not a version tag under spec so it can never be read as the latest one. for _, tag := range tags { - if !IsValidVersionTag(tag) { + if !IsValidVersionTagSpec(spec, tag) { continue } diff --git a/internal/orchestrate/command.go b/internal/orchestrate/command.go index 1d3c7023..680c96b8 100644 --- a/internal/orchestrate/command.go +++ b/internal/orchestrate/command.go @@ -18,6 +18,7 @@ var ( manifestKey string environment string headSHA string + component string ghaOutput bool ) @@ -59,6 +60,7 @@ Examples: cmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to CI/CD config file (auto-detects .github/manifest.yaml or .github/cicd.yaml)") cmd.PersistentFlags().StringVar(&manifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") cmd.PersistentFlags().StringVar(&environment, "environment", "", "Target environment (empty for no-environment setup)") + cmd.PersistentFlags().StringVar(&component, "component", "", "Declared component to scope this orchestration to (multi-component manifests)") cmd.PersistentFlags().BoolVar(&ghaOutput, "gha-output", false, "Write outputs to $GITHUB_OUTPUT for workflow consumption") // Add subcommands @@ -147,8 +149,10 @@ func runSetup(cmd *cobra.Command, args []string) error { log.Info("%sRunning in dry-run mode", log.DryRunPrefix()) } - // Create orchestrator - orch, err := NewOrchestrator(configPath, manifestKey, environment) + // Create orchestrator. WithComponent("") is a no-op, so the single-component + // path is unchanged; a per-component generated workflow passes --component to + // scope version derivation to that component's path and tag namespace. + orch, err := NewOrchestrator(configPath, manifestKey, environment, WithComponent(component)) if err != nil { return fmt.Errorf("initializing orchestrator: %w", err) } diff --git a/internal/orchestrate/component_version_test.go b/internal/orchestrate/component_version_test.go new file mode 100644 index 00000000..3c687691 --- /dev/null +++ b/internal/orchestrate/component_version_test.go @@ -0,0 +1,148 @@ +package orchestrate + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// componentRepo initializes a git repo in a temp dir with a two-component +// manifest, changes into it for the test, and returns the config path. Both +// components are seeded with a v1.0.0 release tag at a shared base commit so each +// has an isolated namespace to advance from. +func componentRepo(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + if cerr := os.Chdir(orig); cerr != nil { + t.Fatalf("restore cwd: %v", cerr) + } + }) + + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"config", "commit.gpgsign", "false"}, + } { + runGitT(t, args...) + } + + configPath := filepath.Join(dir, ".github", "manifest.yaml") + if err := os.MkdirAll(filepath.Dir(configPath), 0o750); err != nil { + t.Fatalf("mkdir .github: %v", err) + } + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- +` + if err := os.WriteFile(configPath, []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + + // Base commit shared by both components' v1.0.0 release tags. + writeCommitT(t, "README.md", "root", "chore: seed") + runGitT(t, "tag", "api-1.0.0") + runGitT(t, "tag", "web-1.0.0") + + return configPath +} + +func runGitT(t *testing.T, args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func writeCommitT(t *testing.T, repoPath, content, message string) { + t.Helper() + if d := filepath.Dir(repoPath); d != "." { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + if err := os.WriteFile(repoPath, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", repoPath, err) + } + runGitT(t, "add", repoPath) + runGitT(t, "commit", "-m", message) +} + +func componentVersion(t *testing.T, configPath, component string) string { + t.Helper() + orch, err := NewOrchestrator(configPath, "ci", "dev", WithComponent(component)) + if err != nil { + t.Fatalf("NewOrchestrator(%s): %v", component, err) + } + v, err := orch.calculateVersion() + if err != nil { + t.Fatalf("calculateVersion(%s): %v", component, err) + } + return v +} + +// TestCalculateComponentVersion_IsolatesComponents proves that advancing one +// component's path bumps only that component's version, in its own tag namespace, +// while a sibling with no path-scoped commits stays at its own base. This is the +// core per-component isolation invariant (HLD Section 4 row 3, Section 5). +func TestCalculateComponentVersion_IsolatesComponents(t *testing.T) { + configPath := componentRepo(t) + + // Advance only the api subtree with a feature commit. + writeCommitT(t, filepath.Join("services", "api", "handler.go"), "package api", "feat: add api handler") + + if got := componentVersion(t, configPath, "api"); got != "api-1.1.0-rc.0" { + t.Errorf("api version = %q, want %q (minor bump from its own commit)", got, "api-1.1.0-rc.0") + } + // web saw no commit under services/web, so its base does not advance and it + // stays on its own namespace, unaffected by api's feature. + if got := componentVersion(t, configPath, "web"); got != "web-1.0.0-rc.0" { + t.Errorf("web version = %q, want %q (api's commit must not move web)", got, "web-1.0.0-rc.0") + } + + // Now advance only the web subtree with a fix; api must not move. + writeCommitT(t, filepath.Join("services", "web", "server.go"), "package web", "fix: web bug") + + if got := componentVersion(t, configPath, "web"); got != "web-1.0.1-rc.0" { + t.Errorf("web version = %q, want %q (patch bump from its own commit)", got, "web-1.0.1-rc.0") + } + if got := componentVersion(t, configPath, "api"); got != "api-1.1.0-rc.0" { + t.Errorf("api version = %q, want %q (web's commit must not move api)", got, "api-1.1.0-rc.0") + } +} + +// TestCalculateComponentVersion_StrictNamespace proves a component reads only its +// own tag namespace: a sibling's higher-sorting release tag does not become the +// component's base version. +func TestCalculateComponentVersion_StrictNamespace(t *testing.T) { + configPath := componentRepo(t) + + // A much higher web release tag must be invisible to api's base derivation. + runGitT(t, "tag", "web-9.9.9") + writeCommitT(t, filepath.Join("services", "api", "handler.go"), "package api", "feat: add api handler") + + if got := componentVersion(t, configPath, "api"); got != "api-1.1.0-rc.0" { + t.Errorf("api version = %q, want %q (must not read web-9.9.9 as base)", got, "api-1.1.0-rc.0") + } +} diff --git a/internal/orchestrate/orchestrator.go b/internal/orchestrate/orchestrator.go index 30fc0589..b6e0aca0 100644 --- a/internal/orchestrate/orchestrator.go +++ b/internal/orchestrate/orchestrator.go @@ -23,17 +23,36 @@ type Orchestrator struct { environment string cicdFile *config.CICDFile baseDir string + // component, when non-empty, names the declared component this orchestration + // is scoped to. It is set only via WithComponent by a per-component generated + // workflow; an empty value selects the single-component path, byte-identical + // to today. It scopes version derivation to the component's path and strict + // tag namespace (calculateComponentVersion). + component string // pushBackoff is the delay between state-write push retries. A zero value // selects the shared git package default; tests override it to keep the retry // loop fast. It is threaded into git.PushWithRebaseRetry via git.WithBackoff. pushBackoff time.Duration } +// Option configures an Orchestrator at construction. Options are the additive +// extension point so new per-invocation scoping (such as a component selector) is +// never a breaking change to NewOrchestrator's positional signature. +type Option func(*Orchestrator) + +// WithComponent scopes the orchestration to the named declared component, so its +// version is derived from that component's path-scoped commits and strict tag +// namespace rather than the whole repo. An empty name is a no-op, preserving the +// single-component path. +func WithComponent(name string) Option { + return func(o *Orchestrator) { o.component = name } +} + // DefaultStateKey is used for state tracking when no environments are configured. const DefaultStateKey = "prerelease" // NewOrchestrator creates a new Orchestrator. -func NewOrchestrator(configPath, manifestKey, environment string) (*Orchestrator, error) { +func NewOrchestrator(configPath, manifestKey, environment string, opts ...Option) (*Orchestrator, error) { log.Debug("Loading config from %s (key: %s)", configPath, manifestKey) cicdFile, err := config.ParseManifestFile(configPath, manifestKey) @@ -56,12 +75,16 @@ func NewOrchestrator(configPath, manifestKey, environment string) (*Orchestrator log.Debug("Environments: %v", cicdFile.Config.Environments) log.Debug("Base directory: %s", baseDir) - return &Orchestrator{ + o := &Orchestrator{ configPath: configPath, environment: environment, cicdFile: cicdFile, baseDir: baseDir, - }, nil + } + for _, opt := range opts { + opt(o) + } + return o, nil } // Setup runs the setup phase and returns the result. @@ -329,6 +352,14 @@ func (o *Orchestrator) detectChanges(baseSHA, headSHA string, triggers []string) // calculateVersion calculates the next version for the environment. func (o *Orchestrator) calculateVersion() (string, error) { + // A component-scoped orchestration derives its version from only its own + // path-scoped commits and strict tag namespace, so one component advancing + // never moves a sibling. The single-component path (component == "") is + // untouched below. + if o.component != "" { + return o.calculateComponentVersion() + } + envs := o.cicdFile.Config.Environments // Get current environment's version and next env's version @@ -432,6 +463,77 @@ func (o *Orchestrator) calculateVersion() (string, error) { return nextVersion.String(), nil } +// calculateComponentVersion derives the next version for the orchestration's +// component from that component's own path-scoped commits and strict tag +// namespace. The base version and its SHA come from the component's latest +// published release tag (read under the component's strict prefix), the RC counter +// base from the component's latest tag, and the commit range from +// GetCommitsForPaths scoped to the component's path. Both tag lookups scope to the +// component namespace, so component A's release tags and commits never influence +// component B's computed version (HLD Section 4 row 3, Section 5). This mirrors the +// no-environment (tag-derived) single-component path; per-component recorded-state +// promotion coupling is a later stage. +func (o *Orchestrator) calculateComponentVersion() (string, error) { + resolved, err := o.cicdFile.Config.ResolveComponent(o.component) + if err != nil { + return "", fmt.Errorf("resolving component %q: %w", o.component, err) + } + + // Strict per-component grammar: the component reads and emits under its own + // prefix only, so a sibling's tags are invisible to it. + spec := resolved.TagGrammarSpec() + + var currentDevVersion, nextEnvVersion, nextEnvSHA string + + // Current version (RC-counter base) from the component's own tag namespace. + if latestTag, _, terr := git.GetLatestTagSpec(o.baseDir, spec); terr != nil { + log.Warn("Failed to get latest tag for component %s: %v", o.component, terr) + } else if latestTag != "" { + currentDevVersion = latestTag + } + + // Base version from the component's latest published (non-prerelease) release. + if latestRelease, releaseSHA, rerr := git.GetLatestReleaseTagSpec(o.baseDir, spec); rerr != nil { + log.Warn("Failed to get latest release tag for component %s: %v", o.component, rerr) + } else if latestRelease != "" { + nextEnvVersion = latestRelease + nextEnvSHA = releaseSHA + } + + // Commit range: from the component's release base (or the repo's initial + // commit on a fresh component) to HEAD, scoped to the component's path so a + // sibling component's commits never register a bump here. + baseSHA := nextEnvSHA + if baseSHA == "" { + baseSHA, _ = git.GetInitialCommit() + } + + var commits []changelog.ConventionalCommit + if baseSHA != "" { + gitCommits, cerr := git.GetCommitsForPaths(baseSHA, "HEAD", []string{resolved.Path}) + if cerr != nil { + log.Warn("Failed to get commits for component %s: %v", o.component, cerr) + } else { + for _, gc := range gitCommits { + if cc := changelog.ParseCommit(gc); cc != nil { + commits = append(commits, *cc) + } + } + } + } + + log.Debug("Component %s: current=%s base=%s (%d commits under %s)", + o.component, currentDevVersion, nextEnvVersion, len(commits), resolved.Path) + + calc := version.NewCalculatorWithGrammar(spec) + nextVersion, err := calc.CalculateNext(currentDevVersion, nextEnvVersion, commits) + if err != nil { + return "", fmt.Errorf("calculating version for component %q: %w", o.component, err) + } + + return nextVersion.String(), nil +} + // calculateChangelogRefs returns the changelog base SHA and previous tag, // in priority order: // diff --git a/internal/version/command.go b/internal/version/command.go index dcd70be0..ec0dd6fa 100644 --- a/internal/version/command.go +++ b/internal/version/command.go @@ -17,6 +17,7 @@ func NewCommand() *cobra.Command { var environment string var baseSHA string var headSHA string + var component string var outputJSON bool cmd := &cobra.Command{ @@ -48,72 +49,36 @@ Examples: return fmt.Errorf("loading config: %w", err) } - // Find environment index - envIndex := -1 - for i, env := range cfg.Environments { - if env == environment { - envIndex = i - break - } - } - if envIndex == -1 { - return fmt.Errorf("environment %q not found in config", environment) - } - - // Get current dev version and next env version from state - var currentDevVersion, nextEnvVersion, nextEnvSHA string - - // Load state from manifest - cicdFile, err := config.ParseManifestFile(configPath, config.DefaultManifestKey) - if err == nil && cicdFile.State != nil { - // Current environment's version - if state, ok := cicdFile.State[environment]; ok { - currentDevVersion = state.Version - } - - // Next environment's version (for comparison) - if envIndex+1 < len(cfg.Environments) { - nextEnv := cfg.Environments[envIndex+1] - if state, ok := cicdFile.State[nextEnv]; ok { - nextEnvVersion = state.Version - nextEnvSHA = state.SHA - } - } - } - - // Default base SHA to next env's SHA if not specified - if baseSHA == "" { - baseSHA = nextEnvSHA - } - - // Default head SHA to current HEAD + // Default head SHA to current HEAD. if headSHA == "" { headSHA = "HEAD" } - // Get commits between base and head + var currentDevVersion, nextEnvVersion string var commits []changelog.ConventionalCommit - if baseSHA == "" { - // No base SHA - get all commits from initial commit - baseSHA, _ = git.GetInitialCommit() - } - if baseSHA != "" { - gitCommits, err := git.GetCommits(baseSHA, headSHA, nil) + var calc *Calculator + + if component != "" && cfg.HasComponents() { + // Component-scoped derivation: the version comes only from the + // component's path-scoped commits and its strict tag namespace, so + // a sibling component's tags and commits are invisible. This mirrors + // orchestrate.calculateComponentVersion so the two paths agree. + currentDevVersion, nextEnvVersion, commits, calc, err = + componentVersionInputs(cfg, component, baseSHA, headSHA) if err != nil { - return fmt.Errorf("getting commits: %w", err) + return err } - // Parse each commit - for _, gc := range gitCommits { - if cc := changelog.ParseCommit(gc); cc != nil { - commits = append(commits, *cc) - } + } else { + // Single-component path, unchanged: version comes from recorded + // per-environment state and the whole-repo commit range under the + // manifest's resolved (permissive) grammar. + currentDevVersion, nextEnvVersion, commits, calc, err = + singleComponentVersionInputs(cfg, configPath, environment, baseSHA, headSHA) + if err != nil { + return err } } - // 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) @@ -143,6 +108,7 @@ Examples: cmd.Flags().StringVarP(&environment, "environment", "e", "", "Target environment") cmd.Flags().StringVar(&baseSHA, "base-sha", "", "Base SHA for commit analysis (defaults to next env's SHA)") cmd.Flags().StringVar(&headSHA, "head-sha", "", "Head SHA for commit analysis (defaults to HEAD)") + cmd.Flags().StringVar(&component, "component", "", "Declared component to scope the version to (multi-component manifests)") cmd.Flags().BoolVar(&outputJSON, "json", false, "Output as JSON") _ = cmd.MarkFlagRequired("environment") @@ -150,6 +116,104 @@ Examples: return cmd } +// singleComponentVersionInputs derives the version inputs for the single-component +// (or no-component) path: recorded per-environment state supplies the current and +// next-env versions, and the whole-repo commit range under the manifest's resolved +// grammar supplies the bump. This is the historical next-version behavior, factored +// out unchanged. +func singleComponentVersionInputs(cfg *config.TrunkConfig, configPath, environment, baseSHA, headSHA string) (currentDevVersion, nextEnvVersion string, commits []changelog.ConventionalCommit, calc *Calculator, err error) { + envIndex := -1 + for i, env := range cfg.Environments { + if env == environment { + envIndex = i + break + } + } + if envIndex == -1 { + return "", "", nil, nil, fmt.Errorf("environment %q not found in config", environment) + } + + var nextEnvSHA string + cicdFile, perr := config.ParseManifestFile(configPath, config.DefaultManifestKey) + if perr == nil && cicdFile.State != nil { + if state, ok := cicdFile.State[environment]; ok { + currentDevVersion = state.Version + } + if envIndex+1 < len(cfg.Environments) { + nextEnv := cfg.Environments[envIndex+1] + if state, ok := cicdFile.State[nextEnv]; ok { + nextEnvVersion = state.Version + nextEnvSHA = state.SHA + } + } + } + + if baseSHA == "" { + baseSHA = nextEnvSHA + } + if baseSHA == "" { + baseSHA, _ = git.GetInitialCommit() + } + if baseSHA != "" { + gitCommits, cerr := git.GetCommits(baseSHA, headSHA, nil) + if cerr != nil { + return "", "", nil, nil, fmt.Errorf("getting commits: %w", cerr) + } + for _, gc := range gitCommits { + if cc := changelog.ParseCommit(gc); cc != nil { + commits = append(commits, *cc) + } + } + } + + // 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. + return currentDevVersion, nextEnvVersion, commits, NewCalculatorWithGrammar(cfg.ResolveTagGrammar()), nil +} + +// componentVersionInputs derives the version inputs for a named component: its +// current version and release base come from its own strict tag namespace, and the +// commit range is scoped to the component's path. It mirrors +// orchestrate.calculateComponentVersion so the CLI and production paths agree on a +// component's next version. baseSHA, when set, overrides the tag-derived base. +func componentVersionInputs(cfg *config.TrunkConfig, component, baseSHA, headSHA string) (currentDevVersion, nextEnvVersion string, commits []changelog.ConventionalCommit, calc *Calculator, err error) { + resolved, rerr := cfg.ResolveComponent(component) + if rerr != nil { + return "", "", nil, nil, fmt.Errorf("resolving component %q: %w", component, rerr) + } + spec := resolved.TagGrammarSpec() + + if latestTag, _, terr := git.GetLatestTagSpec("", spec); terr == nil && latestTag != "" { + currentDevVersion = latestTag + } + var baseTagSHA string + if latestRelease, releaseSHA, lerr := git.GetLatestReleaseTagSpec("", spec); lerr == nil && latestRelease != "" { + nextEnvVersion = latestRelease + baseTagSHA = releaseSHA + } + + if baseSHA == "" { + baseSHA = baseTagSHA + } + if baseSHA == "" { + baseSHA, _ = git.GetInitialCommit() + } + if baseSHA != "" { + gitCommits, cerr := git.GetCommitsForPaths(baseSHA, headSHA, []string{resolved.Path}) + if cerr != nil { + return "", "", nil, nil, fmt.Errorf("getting commits for component %q: %w", component, cerr) + } + for _, gc := range gitCommits { + if cc := changelog.ParseCommit(gc); cc != nil { + commits = append(commits, *cc) + } + } + } + + return currentDevVersion, nextEnvVersion, commits, NewCalculatorWithGrammar(spec), nil +} + func bumpTypeString(b BumpType) string { switch b { case BumpMajor: diff --git a/internal/version/command_component_test.go b/internal/version/command_component_test.go new file mode 100644 index 00000000..abf53bc5 --- /dev/null +++ b/internal/version/command_component_test.go @@ -0,0 +1,132 @@ +package version + +import ( + "bytes" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// componentRepo initializes a git repo with a two-component manifest and both +// components tagged at a shared v1.0.0 base, changes into it for the test, and +// returns the config path. +func componentRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + orig, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { + if cerr := os.Chdir(orig); cerr != nil { + t.Fatalf("restore cwd: %v", cerr) + } + }) + for _, args := range [][]string{ + {"init"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"config", "commit.gpgsign", "false"}, + } { + runGitV(t, args...) + } + cfgPath := filepath.Join(dir, ".github", "manifest.yaml") + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + - prod + components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- +` + if err := os.WriteFile(cfgPath, []byte(manifest), 0o600); err != nil { + t.Fatalf("write manifest: %v", err) + } + writeCommitV(t, "README.md", "root", "chore: seed") + runGitV(t, "tag", "api-1.0.0") + runGitV(t, "tag", "web-1.0.0") + return cfgPath +} + +func runGitV(t *testing.T, args ...string) { + t.Helper() + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func writeCommitV(t *testing.T, repoPath, content, message string) { + t.Helper() + if d := filepath.Dir(repoPath); d != "." { + if err := os.MkdirAll(d, 0o750); err != nil { + t.Fatalf("mkdir %s: %v", d, err) + } + } + if err := os.WriteFile(repoPath, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", repoPath, err) + } + runGitV(t, "add", repoPath) + runGitV(t, "commit", "-m", message) +} + +// runNextVersion drives the next-version command for a component and returns its +// stdout (the printed version), capturing os.Stdout since the command prints +// directly there. +func runNextVersion(t *testing.T, cfgPath, component string) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + os.Stdout = w + defer func() { os.Stdout = orig }() + + cmd := NewCommand() + cmd.SetArgs([]string{"--config", cfgPath, "--environment", "dev", "--component", component}) + runErr := cmd.Execute() + + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + os.Stdout = orig + + if runErr != nil { + t.Fatalf("next-version --component %s: %v", component, runErr) + } + return strings.TrimSpace(buf.String()) +} + +// TestNextVersion_ComponentScoped_AgreesWithOrchestrator proves the CLI +// next-version path, scoped to a component, computes the same isolated per-component +// version that orchestrate.calculateComponentVersion does for the same repo shape +// (see orchestrate.TestCalculateComponentVersion_IsolatesComponents, which asserts +// the identical literals). This is the "two next-version paths agree" invariant. +func TestNextVersion_ComponentScoped_AgreesWithOrchestrator(t *testing.T) { + cfgPath := componentRepo(t) + + writeCommitV(t, filepath.Join("services", "api", "handler.go"), "package api", "feat: add api handler") + + if got := runNextVersion(t, cfgPath, "api"); got != "api-1.1.0-rc.0" { + t.Errorf("api next-version = %q, want %q", got, "api-1.1.0-rc.0") + } + if got := runNextVersion(t, cfgPath, "web"); got != "web-1.0.0-rc.0" { + t.Errorf("web next-version = %q, want %q (api's commit must not move web)", got, "web-1.0.0-rc.0") + } +}