From 2be7ceda86c2ee646bdf676e914a50e6860eb98d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 00:40:39 -0400 Subject: [PATCH] feat(version): derive per-component versions from path-scoped commits and strict tag namespaces Each component now computes its own version from commits under its path and its own strict tag namespace, so one component's tags and commits never influence another's. GetCommitsForPaths scopes the commit range to a component path; GetLatestTagSpec validates candidate tags against the component's strict taggrammar spec (GetLatestTag stays a permissive wrapper, byte-identical). Component workflows pass --component so orchestrate and version compute the scoped version; an empty component keeps the single-component path byte-identical. This closes the versioning half that F-stage generation left component-blind (every per-component workflow previously ran an identical repo-wide setup). Refs #287, #288. Signed-off-by: Joshua Temple --- e2e/scenarios/45-component-versioning.yaml | 117 ++++++++++++ internal/config/component_version_test.go | 59 ++++++ internal/config/components.go | 30 +++ internal/generate/component_workflows_test.go | 26 +++ internal/generate/generator.go | 7 + internal/git/component_test.go | 135 ++++++++++++++ internal/git/git.go | 57 +++++- internal/orchestrate/command.go | 8 +- .../orchestrate/component_version_test.go | 148 +++++++++++++++ internal/orchestrate/orchestrator.go | 108 ++++++++++- internal/version/command.go | 176 ++++++++++++------ internal/version/command_component_test.go | 132 +++++++++++++ 12 files changed, 937 insertions(+), 66 deletions(-) create mode 100644 e2e/scenarios/45-component-versioning.yaml create mode 100644 internal/config/component_version_test.go create mode 100644 internal/git/component_test.go create mode 100644 internal/orchestrate/component_version_test.go create mode 100644 internal/version/command_component_test.go 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") + } +}