From 8e62364acb4a384381d8f181383ad5cab387c187 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Tue, 7 Jul 2026 21:48:43 -0400 Subject: [PATCH] feat(config): parse, validate, and resolve the components model under schema_version 1 Activate the reserved components block as a live config surface. A manifest may declare named components with a per-component path and tag_prefix, inheriting shared top-level defaults and overriding them per field. Validate that per-component tag namespaces are distinct and reject per-component overrides of global-only fields. A manifest with no components block is unchanged: the single-component path stays byte-identical. Refs #280, #281. Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 34 ++- internal/config/components.go | 168 +++++++++++ internal/config/components_resolve_test.go | 326 +++++++++++++++++++++ internal/config/schema_v1_test.go | 1 + internal/config/types.go | 60 +++- internal/config/validate_v1.go | 70 ++++- internal/schema/manifest.schema.json | 34 ++- schema/manifest.schema.json | 34 ++- 8 files changed, 701 insertions(+), 26 deletions(-) create mode 100644 internal/config/components.go create mode 100644 internal/config/components_resolve_test.go diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index ecb60dc6..c95de554 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -187,7 +187,7 @@ "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, - "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." + "description": "Per-component descriptor map keyed by component name (#176). When present, the top-level config is the shared default set each component inherits, and each component overrides fields where set. Component names must be job-ID-safe." } } }, @@ -520,10 +520,36 @@ "componentConfig": { "type": "object", "additionalProperties": false, - "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "required": ["path", "tag_prefix"], + "description": "Per-component descriptor for independently versioned components sharing one manifest (#176). When a components: block is present the top-level config is the shared default set each component inherits. path and tag_prefix are required per component; the remaining fields override the shared default only where set. Top-level-only (global) fields cannot appear here, and concurrency may carry only cancel_in_progress (the group is derived per component).", "properties": { - "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, - "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix. Required, and distinct across components so their tag namespaces never collide." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, + "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, + "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { "type": "array", "items": { "$ref": "#/definitions/buildConfig" }, "description": "Overrides the shared build callbacks for this component." }, + "deploys": { "type": "array", "items": { "$ref": "#/definitions/deployConfig" }, "description": "Overrides the shared deploy callbacks for this component." }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { "type": "array", "items": { "$ref": "#/definitions/externalRepoConfig" }, "description": "Overrides the shared external repositories for this component." }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "job_timeout_minutes": { "type": "integer", "description": "Overrides the shared default timeout-minutes for this component's cascade-owned jobs." }, + "dispatch_inputs": { "type": "object", "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, "description": "Overrides the shared operator-facing manual-run inputs for this component." }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "rollback": { "$ref": "#/definitions/rollbackConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, + "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, + "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, + "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, + "release_token_app": { "$ref": "#/definitions/appTokenSource" } } }, "changelogConfig": { diff --git a/internal/config/components.go b/internal/config/components.go new file mode 100644 index 00000000..c0757963 --- /dev/null +++ b/internal/config/components.go @@ -0,0 +1,168 @@ +package config + +import "fmt" + +// HasComponents reports whether the manifest declares a components: block. When +// it does not, the component dimension does not exist and the single-component +// code path is used untouched. +func (c *TrunkConfig) HasComponents() bool { + return len(c.Components) > 0 +} + +// ComponentConcurrencyGroup derives the orchestrate concurrency group for a +// named component. Composing the component identity into the group keeps two +// components from serializing against each other on one lane, which a bare +// shared literal would silently do. The exact emitted expression is a generator +// concern and may be refined there; the invariant fixed here is that the +// component identity is always part of the key. +func ComponentConcurrencyGroup(name string) string { + return fmt.Sprintf("orchestrate-%s-${{ github.ref }}", name) +} + +// 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 +// per-component axis with no home on TrunkConfig, so it is carried here rather +// than folded into Config; downstream stages scope version and state work to it. +type ResolvedComponent struct { + Name string + Path string + Config *TrunkConfig +} + +// ResolveComponent returns the effective configuration for the named component: +// a copy of the shared top-level defaults with every inheritable field the +// component overrides applied, the component's required tag prefix set, and a +// concurrency group derived per component so no two components collapse onto one +// serialization lane. Global (top-level-only) fields are carried through from +// the shared config unchanged, and the effective config declares no nested +// components of its own. +// +// It returns an error if the component is not declared. ResolveComponent assumes +// the manifest already passed validateComponents; it does not re-validate. +func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error) { + comp, ok := c.Components[name] + if !ok { + return nil, fmt.Errorf("component %q is not declared", name) + } + + eff := *c // shallow copy: global fields carried through verbatim + eff.Components = nil // an effective per-component config has no nested components + + // Required per-component tag namespace. + eff.TagPrefix = comp.TagPrefix + + // Inheritable overrides: apply only where the component set a value. + if comp.TagGrammar != nil { + eff.TagGrammar = comp.TagGrammar + } + if comp.Environments != nil { + eff.Environments = comp.Environments + } + if comp.ReleaseTrigger != "" { + eff.ReleaseTrigger = comp.ReleaseTrigger + } + if comp.AllowBreakingChanges != nil { + eff.AllowBreakingChanges = *comp.AllowBreakingChanges + } + if comp.Validate != nil { + eff.Validate = comp.Validate + } + if comp.Builds != nil { + eff.Builds = comp.Builds + } + if comp.Deploys != nil { + eff.Deploys = comp.Deploys + } + if comp.Publish != nil { + eff.Publish = comp.Publish + } + if comp.External != nil { + eff.External = comp.External + } + if comp.Notify != nil { + eff.Notify = comp.Notify + } + if comp.Release != nil { + eff.Release = comp.Release + } + if comp.Changelog != nil { + eff.Changelog = comp.Changelog + } + if comp.RunsOn != nil { + eff.RunsOn = comp.RunsOn + } + if comp.JobTimeoutMinutes != nil { + eff.JobTimeoutMinutes = *comp.JobTimeoutMinutes + } + if comp.DispatchInputs != nil { + eff.DispatchInputs = comp.DispatchInputs + } + if comp.ExtraTriggers != nil { + eff.ExtraTriggers = comp.ExtraTriggers + } + if comp.PRPreview != nil { + eff.PRPreview = comp.PRPreview + } + if comp.ValidateCheck != nil { + eff.ValidateCheck = comp.ValidateCheck + } + if comp.Rollback != nil { + eff.Rollback = comp.Rollback + } + if comp.Deployments != nil { + eff.Deployments = comp.Deployments + } + if comp.EnvironmentConfig != nil { + eff.EnvironmentConfig = comp.EnvironmentConfig + } + if comp.Triggers != nil { + eff.Triggers = comp.Triggers + } + if comp.ReleaseToken != "" { + eff.ReleaseToken = comp.ReleaseToken + } + if comp.ReleaseTokenApp != nil { + eff.ReleaseTokenApp = comp.ReleaseTokenApp + } + + // Concurrency: cancel_in_progress is inheritable, the group is derived per + // component. Start from the shared cancel policy, let the component override + // it, and always compose the per-component group so the shared lane trap + // cannot occur. + cancel := c.GetConcurrencyCancelInProgress() + if comp.Concurrency != nil { + cancel = comp.Concurrency.CancelInProgress + } + eff.Concurrency = &ConcurrencyConfig{ + Group: ComponentConcurrencyGroup(name), + CancelInProgress: cancel, + } + + return &ResolvedComponent{Name: name, Path: comp.Path, Config: &eff}, nil +} + +// 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 +// configuration error, not a silent no-op. Keys mirror the yaml names in +// TrunkConfig and the override matrix. +var globalOnlyComponentFields = map[string]struct{}{ + "schema_version": {}, + "trunk_branch": {}, + "cli_version": {}, + "cli_version_sha": {}, + "state_token": {}, + "state_token_app": {}, + "manifest_file": {}, + "manifest_key": {}, + "action_folder": {}, + "git": {}, + "drift_check": {}, + "reconcile": {}, + "pin_mode": {}, + "action_pins": {}, + "telemetry": {}, + "merge_queue": {}, + "components": {}, +} diff --git a/internal/config/components_resolve_test.go b/internal/config/components_resolve_test.go new file mode 100644 index 00000000..1ce71635 --- /dev/null +++ b/internal/config/components_resolve_test.go @@ -0,0 +1,326 @@ +package config + +import "testing" + +func TestResolveComponent_InheritsSharedDefaults(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +release_trigger: dispatch +allow_breaking_changes: true +job_timeout_minutes: 30 +builds: + - name: app + workflow: .github/workflows/build.yaml + triggers: ["src/**"] +components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- +`) + + rc, err := cfg.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent: %v", err) + } + eff := rc.Config + + if rc.Name != "api" { + t.Errorf("name = %q, want api", rc.Name) + } + if rc.Path != "services/api" { + t.Errorf("path = %q, want services/api", rc.Path) + } + if eff.TagPrefix != "api-" { + t.Errorf("tag_prefix = %q, want api-", eff.TagPrefix) + } + // Inherited shared defaults. + if eff.ReleaseTrigger != "dispatch" { + t.Errorf("release_trigger = %q, want inherited dispatch", eff.ReleaseTrigger) + } + if !eff.AllowBreakingChanges { + t.Error("allow_breaking_changes should inherit shared true") + } + if eff.JobTimeoutMinutes != 30 { + t.Errorf("job_timeout_minutes = %d, want inherited 30", eff.JobTimeoutMinutes) + } + if len(eff.Environments) != 2 || eff.Environments[0] != "dev" { + t.Errorf("environments = %v, want inherited [dev prod]", eff.Environments) + } + if len(eff.Builds) != 1 || eff.Builds[0].Name != "app" { + t.Errorf("builds not inherited: %#v", eff.Builds) + } + // The effective config carries no nested components. + if eff.Components != nil { + t.Errorf("effective config should have no nested components, got %v", eff.Components) + } +} + +func TestResolveComponent_OverridesWhereSet(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +release_trigger: push +allow_breaking_changes: true +job_timeout_minutes: 30 +components: + api: + path: services/api + tag_prefix: api- + environments: [dev] + release_trigger: dispatch + allow_breaking_changes: false + job_timeout_minutes: 5 +`) + + rc, err := cfg.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent: %v", err) + } + eff := rc.Config + + if got := eff.Environments; len(got) != 1 || got[0] != "dev" { + t.Errorf("environments override = %v, want [dev]", got) + } + if eff.ReleaseTrigger != "dispatch" { + t.Errorf("release_trigger override = %q, want dispatch", eff.ReleaseTrigger) + } + if eff.AllowBreakingChanges { + t.Error("allow_breaking_changes override to false not applied") + } + if eff.JobTimeoutMinutes != 5 { + t.Errorf("job_timeout_minutes override = %d, want 5", eff.JobTimeoutMinutes) + } + // The shared config is untouched by resolution. + if !cfg.AllowBreakingChanges || cfg.JobTimeoutMinutes != 30 { + t.Error("ResolveComponent mutated the shared config") + } +} + +func TestResolveComponent_DerivesPerComponentConcurrencyGroup(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- +`) + + api, err := cfg.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + web, err := cfg.ResolveComponent("web") + if err != nil { + t.Fatalf("ResolveComponent(web): %v", err) + } + + if api.Config.GetConcurrencyGroup() == web.Config.GetConcurrencyGroup() { + t.Fatalf("components share a concurrency group: %q", api.Config.GetConcurrencyGroup()) + } + if want := ComponentConcurrencyGroup("api"); api.Config.GetConcurrencyGroup() != want { + t.Errorf("api group = %q, want %q", api.Config.GetConcurrencyGroup(), want) + } +} + +func TestResolveComponent_CancelInProgressOverride(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +concurrency: + cancel_in_progress: false +components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- + concurrency: + cancel_in_progress: true +`) + + api, err := cfg.ResolveComponent("api") + if err != nil { + t.Fatalf("ResolveComponent(api): %v", err) + } + web, err := cfg.ResolveComponent("web") + if err != nil { + t.Fatalf("ResolveComponent(web): %v", err) + } + + if api.Config.GetConcurrencyCancelInProgress() { + t.Error("api should inherit shared cancel_in_progress=false") + } + if !web.Config.GetConcurrencyCancelInProgress() { + t.Error("web should override cancel_in_progress=true") + } +} + +func TestResolveComponent_UnknownComponentErrors(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: api- +`) + if _, err := cfg.ResolveComponent("missing"); err == nil { + t.Fatal("expected an error resolving an undeclared component") + } +} + +func TestValidateComponents_RequiresPathAndTagPrefix(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + tag_prefix: api- + web: + path: services/web +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "components.api.path is required") { + t.Errorf("expected api.path required error, got %v", errs) + } + if !hasErrContaining(errs, "components.web.tag_prefix is required") { + t.Errorf("expected web.tag_prefix required error, got %v", errs) + } +} + +func TestValidateComponents_DistinctTagPrefixes(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: svc- + web: + path: services/web + tag_prefix: svc- +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "collides") { + t.Fatalf("expected a tag-prefix collision error, got %v", errs) + } +} + +func TestValidateComponents_RejectsGlobalFieldOverride(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: api- + trunk_branch: other + cli_version: v9.9.9 +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "components.api.trunk_branch is a top-level-only field") { + t.Errorf("expected trunk_branch global-override rejection, got %v", errs) + } + if !hasErrContaining(errs, "components.api.cli_version is a top-level-only field") { + t.Errorf("expected cli_version global-override rejection, got %v", errs) + } +} + +func TestValidateComponents_RejectsUnknownField(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: api- + nonsense: true +`) + errs := Validate(cfg) + if !hasErrContaining(errs, `components.api has unknown field "nonsense"`) { + t.Fatalf("expected unknown-field rejection, got %v", errs) + } +} + +func TestValidateComponents_RejectsPerComponentConcurrencyGroup(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +components: + api: + path: services/api + tag_prefix: api- + concurrency: + group: shared-lane +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "components.api.concurrency.group cannot be overridden") { + t.Fatalf("expected per-component group rejection, got %v", errs) + } +} + +func TestValidateComponents_RejectsGlobalConcurrencyGroupWhenComponentsDeclared(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +concurrency: + group: orchestrate-shared +components: + api: + path: services/api + tag_prefix: api- +`) + errs := Validate(cfg) + if !hasErrContaining(errs, "concurrency.group must not be set to a shared literal when components are declared") { + t.Fatalf("expected manifest-global group rejection, got %v", errs) + } +} + +func TestValidateComponents_SchemaVersionOneAndOmittedAccept(t *testing.T) { + for _, sv := range []string{"", "schema_version: 1\n"} { + cfg := parseInline(t, sv+` +trunk_branch: main +environments: [dev, prod] +components: + api: + path: services/api + tag_prefix: api- + web: + path: services/web + tag_prefix: web- +`) + if errs := Validate(cfg); len(errs) != 0 { + t.Errorf("schema_version %q: expected clean validation, got %v", sv, errs) + } + if got := cfg.GetSchemaVersion(); got != 1 { + t.Errorf("schema_version %q: effective version = %d, want 1", sv, got) + } + } +} + +// TestNoComponents_SingleComponentPathUntouched asserts the byte-identical +// invariant at the config layer: a manifest with no components: block resolves +// exactly as before. The component dimension does not exist, so no accessor +// gains a component axis and ResolveComponent is never reachable. +func TestNoComponents_SingleComponentPathUntouched(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +environments: [dev, prod] +builds: + - name: app + workflow: .github/workflows/build.yaml + triggers: ["src/**"] +`) + if cfg.HasComponents() { + t.Fatal("a manifest without components: must report no components") + } + if cfg.Components != nil { + t.Fatalf("expected nil Components, got %v", cfg.Components) + } + if got := cfg.GetConcurrencyGroup(); got != "orchestrate-${{ github.ref }}" { + t.Errorf("single-component concurrency group changed: %q", got) + } + if errs := Validate(cfg); len(errs) != 0 { + t.Fatalf("single-component manifest should validate clean, got %v", errs) + } +} diff --git a/internal/config/schema_v1_test.go b/internal/config/schema_v1_test.go index 1d579bba..449a6461 100644 --- a/internal/config/schema_v1_test.go +++ b/internal/config/schema_v1_test.go @@ -969,6 +969,7 @@ components: tag_prefix: api-v worker: path: services/worker + tag_prefix: worker-v `) if cfg.Components == nil { t.Fatal("Components map should be parsed") diff --git a/internal/config/types.go b/internal/config/types.go index ec3cf4c9..1b77e3ed 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -1182,14 +1182,64 @@ func (c *TrunkConfig) ResolveDependency(depRef string, fromType string) (string, return JobID(CallbackTypeExternal, depRef), nil } -// ComponentConfig is the reserved per-component descriptor. Only the addressing -// shape is frozen in v1; richer per-component config lands post-1.0 additively. +// ComponentConfig is the per-component descriptor for a manifest that declares a +// components: block. Under schema_version 1 a components: block turns the +// top-level config into the shared defaults every component inherits. +// +// Fields fall into three kinds: +// +// - Required: Path and TagPrefix, which have no sensible shared value and must +// be set on every component. +// - Inheritable overrides: the remaining fields below. A nil pointer, nil map, +// nil slice, or empty string means the component inherits the shared +// top-level default; a set value overrides it for that component only. Use +// ResolveComponent to fold the shared defaults and a component's overrides +// into the component's effective configuration. +// - Rejected: top-level-only (global) fields never appear here. An attempt to +// set one per component lands in Extra and is rejected by validateComponents. +// +// The concurrency group is not overridable to a shared literal: it is derived +// per component (ComponentConcurrencyGroup) so two components never serialize on +// one lane. A component's concurrency block may only carry cancel_in_progress. type ComponentConfig struct { - // Path is the subtree this component owns within the repo (reserved). + // Path is the subtree this component owns within the repo. Required. Path string `yaml:"path,omitempty" json:"path,omitempty"` - // TagPrefix is the per-component version tag prefix (reserved). Empty means - // inherit the manifest-level tag_prefix. + // TagPrefix is the per-component version tag prefix. Required, and distinct + // across components so their tag namespaces never collide. TagPrefix string `yaml:"tag_prefix,omitempty" json:"tag_prefix,omitempty"` + + // Inheritable overrides. Each defaults to the shared top-level value. + TagGrammar *TagGrammarConfig `yaml:"tag_grammar,omitempty" json:"tag_grammar,omitempty"` + Environments []string `yaml:"environments,omitempty" json:"environments,omitempty"` + ReleaseTrigger string `yaml:"release_trigger,omitempty" json:"release_trigger,omitempty"` + AllowBreakingChanges *bool `yaml:"allow_breaking_changes,omitempty" json:"allow_breaking_changes,omitempty"` + Validate *ValidateConfig `yaml:"validate,omitempty" json:"validate,omitempty"` + Builds []BuildConfig `yaml:"builds,omitempty" json:"builds,omitempty"` + Deploys []DeployConfig `yaml:"deploys,omitempty" json:"deploys,omitempty"` + Publish *PublishConfig `yaml:"publish,omitempty" json:"publish,omitempty"` + External []ExternalRepoConfig `yaml:"external,omitempty" json:"external,omitempty"` + Notify *NotifyConfig `yaml:"notify,omitempty" json:"notify,omitempty"` + Release *ReleaseConfig `yaml:"release,omitempty" json:"release,omitempty"` + Changelog *ChangelogConfig `yaml:"changelog,omitempty" json:"changelog,omitempty"` + Concurrency *ConcurrencyConfig `yaml:"concurrency,omitempty" json:"concurrency,omitempty"` + RunsOn *RunsOn `yaml:"runs_on,omitempty" json:"runs_on,omitempty"` + JobTimeoutMinutes *int `yaml:"job_timeout_minutes,omitempty" json:"job_timeout_minutes,omitempty"` + DispatchInputs map[string]DispatchInput `yaml:"dispatch_inputs,omitempty" json:"dispatch_inputs,omitempty"` + ExtraTriggers *ExtraTriggers `yaml:"extra_triggers,omitempty" json:"extra_triggers,omitempty"` + PRPreview *PRPreviewConfig `yaml:"pr_preview,omitempty" json:"pr_preview,omitempty"` + ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"` + Rollback *RollbackConfig `yaml:"rollback,omitempty" json:"rollback,omitempty"` + Deployments *DeploymentsConfig `yaml:"deployments,omitempty" json:"deployments,omitempty"` + EnvironmentConfig map[string]EnvironmentConfig `yaml:"environment_config,omitempty" json:"environment_config,omitempty"` + Triggers []string `yaml:"triggers,omitempty" json:"triggers,omitempty"` + ReleaseToken string `yaml:"release_token,omitempty" json:"release_token,omitempty"` + ReleaseTokenApp *AppTokenSource `yaml:"release_token_app,omitempty" json:"release_token_app,omitempty"` + + // Extra captures any manifest key set on a component that is not a modeled + // per-component field, so a per-component override of a top-level-only + // (global) field is rejected by validateComponents rather than silently + // ignored. It is never serialized. + Extra map[string]any `yaml:",inline" json:"-"` } // ComponentState is the reserved per-component recorded-state entry. diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 45298abf..a1d43cf4 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -769,23 +769,66 @@ func safeSecretName(name string) bool { return true } -// validateComponents validates the reserved top-level components map (#176). -// Rules frozen at v1: component names must be job-ID-safe (so a future -// generator can key job IDs on the name without breakage), and any configured -// Path must be a clean relative path (no leading slash, no ".." segments). +// validateComponents validates the top-level components map (#176). When a +// components: block is present the top-level config is the shared default set +// and each component inherits it, so the rules enforced here are: +// +// - Component names must be job-ID-safe (so a generator can key job IDs on the +// name without breakage). +// - path and tag_prefix are required per component; path must be a clean +// relative path (no leading slash, no ".." segments). +// - Per-component tag prefixes must be distinct, so no two components share a +// tag namespace and reap or read each other's tags. +// - A component may not override a top-level-only (global) field, and may not +// pin the concurrency group to a shared literal (only cancel_in_progress is +// overridable; the group is derived per component). +// - A manifest-global concurrency.group literal is rejected while components +// are declared, because it would collapse every component onto one lane. func validateComponents(cfg *TrunkConfig) []string { if len(cfg.Components) == 0 { return nil } var errs []string + + if cfg.Concurrency != nil && cfg.Concurrency.Group != "" { + errs = append(errs, "concurrency.group must not be set to a shared literal when components are declared; "+ + "the orchestrate group is derived per component so runs never serialize across components") + } + + tagPrefixOwner := make(map[string]string, len(cfg.Components)) for _, name := range sortedComponentKeys(cfg.Components) { errs = append(errs, validateJobIDSafeName("components."+name, name)...) comp := cfg.Components[name] - if comp.Path != "" { - if strings.HasPrefix(comp.Path, "/") { - errs = append(errs, fmt.Sprintf("components.%s.path must be a relative path, not absolute", name)) - } else if strings.Contains(comp.Path, "..") { - errs = append(errs, fmt.Sprintf("components.%s.path must not contain '..' segments", name)) + + if comp.Path == "" { + errs = append(errs, fmt.Sprintf("components.%s.path is required", name)) + } else if strings.HasPrefix(comp.Path, "/") { + errs = append(errs, fmt.Sprintf("components.%s.path must be a relative path, not absolute", name)) + } else if strings.Contains(comp.Path, "..") { + errs = append(errs, fmt.Sprintf("components.%s.path must not contain '..' segments", name)) + } + + if comp.TagPrefix == "" { + errs = append(errs, fmt.Sprintf("components.%s.tag_prefix is required so each component has its own tag namespace", name)) + } else if prior, seen := tagPrefixOwner[comp.TagPrefix]; seen { + errs = append(errs, fmt.Sprintf( + "components.%s.tag_prefix %q collides with component %q; each component needs a distinct tag namespace", + name, comp.TagPrefix, prior)) + } else { + tagPrefixOwner[comp.TagPrefix] = name + } + + if comp.Concurrency != nil && comp.Concurrency.Group != "" { + errs = append(errs, fmt.Sprintf( + "components.%s.concurrency.group cannot be overridden; the orchestrate group is derived per component", name)) + } + + for _, key := range sortedKeys(toAnyKeyed(comp.Extra)) { + if _, global := globalOnlyComponentFields[key]; global { + errs = append(errs, fmt.Sprintf( + "components.%s.%s is a top-level-only field and cannot be overridden per component", name, key)) + } else { + errs = append(errs, fmt.Sprintf("components.%s has unknown field %q", name, key)) } } } @@ -851,3 +894,12 @@ func toEnvKeyed(m map[string]EnvironmentConfig) map[string]string { } return out } + +// toAnyKeyed adapts an inline catch-all map to a string-keyed map for sortedKeys. +func toAnyKeyed(m map[string]any) map[string]string { + out := make(map[string]string, len(m)) + for k := range m { + out[k] = "" + } + return out +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index ecb60dc6..c95de554 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -187,7 +187,7 @@ "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, - "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." + "description": "Per-component descriptor map keyed by component name (#176). When present, the top-level config is the shared default set each component inherits, and each component overrides fields where set. Component names must be job-ID-safe." } } }, @@ -520,10 +520,36 @@ "componentConfig": { "type": "object", "additionalProperties": false, - "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "required": ["path", "tag_prefix"], + "description": "Per-component descriptor for independently versioned components sharing one manifest (#176). When a components: block is present the top-level config is the shared default set each component inherits. path and tag_prefix are required per component; the remaining fields override the shared default only where set. Top-level-only (global) fields cannot appear here, and concurrency may carry only cancel_in_progress (the group is derived per component).", "properties": { - "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, - "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix. Required, and distinct across components so their tag namespaces never collide." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, + "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, + "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { "type": "array", "items": { "$ref": "#/definitions/buildConfig" }, "description": "Overrides the shared build callbacks for this component." }, + "deploys": { "type": "array", "items": { "$ref": "#/definitions/deployConfig" }, "description": "Overrides the shared deploy callbacks for this component." }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { "type": "array", "items": { "$ref": "#/definitions/externalRepoConfig" }, "description": "Overrides the shared external repositories for this component." }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "job_timeout_minutes": { "type": "integer", "description": "Overrides the shared default timeout-minutes for this component's cascade-owned jobs." }, + "dispatch_inputs": { "type": "object", "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, "description": "Overrides the shared operator-facing manual-run inputs for this component." }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "rollback": { "$ref": "#/definitions/rollbackConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, + "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, + "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, + "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, + "release_token_app": { "$ref": "#/definitions/appTokenSource" } } }, "changelogConfig": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index ecb60dc6..c95de554 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -187,7 +187,7 @@ "components": { "type": "object", "additionalProperties": { "$ref": "#/definitions/componentConfig" }, - "description": "Reserved per-component descriptor map keyed by component name (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior." + "description": "Per-component descriptor map keyed by component name (#176). When present, the top-level config is the shared default set each component inherits, and each component overrides fields where set. Component names must be job-ID-safe." } } }, @@ -520,10 +520,36 @@ "componentConfig": { "type": "object", "additionalProperties": false, - "description": "Reserved per-component descriptor for independently versioned components sharing one manifest (#176). Reserved shape only: parse and structural validation, no generator, state, or runtime behavior.", + "required": ["path", "tag_prefix"], + "description": "Per-component descriptor for independently versioned components sharing one manifest (#176). When a components: block is present the top-level config is the shared default set each component inherits. path and tag_prefix are required per component; the remaining fields override the shared default only where set. Top-level-only (global) fields cannot appear here, and concurrency may carry only cancel_in_progress (the group is derived per component).", "properties": { - "path": { "type": "string", "description": "Subtree this component owns within the repo (reserved)." }, - "tag_prefix": { "type": "string", "description": "Per-component version tag prefix (reserved). Empty inherits the manifest-level tag_prefix." } + "path": { "type": "string", "description": "Subtree this component owns within the repo. Required." }, + "tag_prefix": { "type": "string", "description": "Per-component version tag prefix. Required, and distinct across components so their tag namespaces never collide." }, + "tag_grammar": { "$ref": "#/definitions/tagGrammarConfig" }, + "environments": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared promotion environments for this component." }, + "release_trigger": { "type": "string", "enum": ["push", "dispatch"], "description": "Overrides how this component's orchestrate workflow fires." }, + "allow_breaking_changes": { "type": "boolean", "description": "Overrides the shared breaking-change promote gate for this component." }, + "validate": { "$ref": "#/definitions/validateConfig" }, + "builds": { "type": "array", "items": { "$ref": "#/definitions/buildConfig" }, "description": "Overrides the shared build callbacks for this component." }, + "deploys": { "type": "array", "items": { "$ref": "#/definitions/deployConfig" }, "description": "Overrides the shared deploy callbacks for this component." }, + "publish": { "$ref": "#/definitions/publishConfig" }, + "external": { "type": "array", "items": { "$ref": "#/definitions/externalRepoConfig" }, "description": "Overrides the shared external repositories for this component." }, + "notify": { "$ref": "#/definitions/notifyConfig" }, + "release": { "$ref": "#/definitions/releaseConfig" }, + "changelog": { "$ref": "#/definitions/changelogConfig" }, + "concurrency": { "$ref": "#/definitions/concurrencyConfig" }, + "runs_on": { "$ref": "#/definitions/runsOn" }, + "job_timeout_minutes": { "type": "integer", "description": "Overrides the shared default timeout-minutes for this component's cascade-owned jobs." }, + "dispatch_inputs": { "type": "object", "additionalProperties": { "$ref": "#/definitions/dispatchInput" }, "description": "Overrides the shared operator-facing manual-run inputs for this component." }, + "extra_triggers": { "$ref": "#/definitions/extraTriggers" }, + "pr_preview": { "$ref": "#/definitions/prPreviewConfig" }, + "validate_check": { "$ref": "#/definitions/validateCheckConfig" }, + "rollback": { "$ref": "#/definitions/rollbackConfig" }, + "deployments": { "$ref": "#/definitions/deploymentsConfig" }, + "environment_config": { "type": "object", "additionalProperties": { "$ref": "#/definitions/environmentConfig" }, "description": "Overrides the shared per-environment settings for this component." }, + "triggers": { "type": "array", "items": { "type": "string" }, "description": "Overrides the shared orchestrate path filter for this component." }, + "release_token": { "type": "string", "description": "Overrides the shared release-operations token expression for this component." }, + "release_token_app": { "$ref": "#/definitions/appTokenSource" } } }, "changelogConfig": {