Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
},
Expand Down Expand Up @@ -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": {
Expand Down
168 changes: 168 additions & 0 deletions internal/config/components.go
Original file line number Diff line number Diff line change
@@ -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": {},
}
Loading