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
40 changes: 24 additions & 16 deletions e2e/harness/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,11 +771,12 @@ func (h *Harness) waitForBranchHead(ctx context.Context, wantSHA string) error {
branchHeadPollAttempts, wantSHA, lastSHA)
}

// assertOrchestrateGenerated confirms that .github/workflows/orchestrate.yaml
// exists and is non-empty in the act container's /tmp/repo immediately after
// generate-workflow. On failure it returns the generate output plus a listing
// of the workflows directory so the missing-file moment is captured with
// context rather than surfacing later as an opaque `cat: ... No such file`.
// assertOrchestrateGenerated confirms that at least one non-empty orchestrate
// workflow (orchestrate.yaml, or the per-component orchestrate-<name>.yaml set)
// exists in the act container's /tmp/repo immediately after generate-workflow. On
// failure it returns the generate output plus a listing of the workflows directory
// so the missing-file moment is captured with context rather than surfacing later
// as an opaque `cat: ... No such file`.
func (h *Harness) assertOrchestrateGenerated(ctx context.Context, genOutput string) error {
present, exitCode, err := h.probeOrchestrateWorkflow(ctx)
if present {
Expand All @@ -789,23 +790,30 @@ func (h *Harness) assertOrchestrateGenerated(ctx context.Context, genOutput stri
}
return fmt.Errorf(
"generate-workflow exited 0 but did not produce %s (exit=%d)\ngenerate output:\n%s\nworkflows dir:\n%s",
orchestrateWorkflowPath, exitCode, strings.TrimSpace(genOutput),
orchestrateWorkflowGlob, exitCode, strings.TrimSpace(genOutput),
strings.TrimSpace(h.workflowsDirListing(ctx)),
)
}

// orchestrateWorkflowPath is the generated workflow whose presence gates every
// orchestrate run.
const orchestrateWorkflowPath = ".github/workflows/orchestrate.yaml"

// probeOrchestrateWorkflow reports whether orchestrate.yaml exists and is
// non-empty in /tmp/repo. The returned error is a docker-exec transport error
// (the probe could not be run), which callers treat as retryable and distinct
// from a clean "file absent" result (present=false, err=nil).
// orchestrateWorkflowGlob matches the generated orchestrate workflow(s) whose
// presence gates every orchestrate run: the single-component orchestrate.yaml, or
// the per-component orchestrate-<name>.yaml set a manifest with components emits
// (in which case no repo-wide orchestrate.yaml exists).
const orchestrateWorkflowGlob = ".github/workflows/orchestrate*.yaml"

// probeOrchestrateWorkflow reports whether at least one non-empty orchestrate
// workflow (orchestrate.yaml or a per-component orchestrate-<name>.yaml) exists in
// /tmp/repo. The returned error is a docker-exec transport error (the probe could
// not be run), which callers treat as retryable and distinct from a clean "file
// absent" result (present=false, err=nil).
func (h *Harness) probeOrchestrateWorkflow(ctx context.Context) (present bool, exitCode int, err error) {
// A non-matching glob stays a literal path, which `test -s` reports absent, so
// found stays 0 and the probe fails, preserving the original "generated no
// orchestrate workflow at all" guard for both the single- and multi-component
// forms.
checkCmd := []string{
"bash", "-c",
"cd /tmp/repo && test -s " + orchestrateWorkflowPath,
"cd /tmp/repo && found=0; for f in " + orchestrateWorkflowGlob + "; do [ -s \"$f\" ] && found=1; done; [ \"$found\" = 1 ]",
}
exitCode, _, err = h.act.Container().Exec(ctx, checkCmd)
if err != nil {
Expand Down Expand Up @@ -1085,7 +1093,7 @@ func (h *Harness) SyncRepoToActContainer(ctx context.Context) error {
lastErr = fmt.Errorf("workflow probe transport error (exit=%d): %w", probeExit, probeErr)
continue
}
lastErr = fmt.Errorf("%s absent after fetch/reset (exit=%d)", orchestrateWorkflowPath, probeExit)
lastErr = fmt.Errorf("%s absent after fetch/reset (exit=%d)", orchestrateWorkflowGlob, probeExit)
}

// Distinct from the generation-phase message: this is a sync/lost-commit
Expand Down
62 changes: 51 additions & 11 deletions e2e/scenarios/44-components-reserved.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: "Components Reserved Shape"
name: "Per-Component Workflow Generation"
description: |
Exercises the reserved per-component descriptor map (config.components, #176).
Each component carries a path subtree and an optional tag_prefix. This block is
reserved and shape-only today: it parses and passes structural validation, but
carries no generator, state, or runtime behavior. The scenario declares two
components, generates the workflows, then regenerates and proves the output is
byte-identical with no drift.
Exercises per-component orchestrate workflow generation (#283). The manifest
declares two components, each owning a path subtree with a distinct tag prefix.
Generation fans the orchestrate lane out to one path-scoped, concurrency-isolated
workflow per component (orchestrate-api.yaml, orchestrate-web.yaml) and emits no
repo-wide orchestrate.yaml. The scenario seeds both subtrees, proves the
multi-component generate then verify roundtrip is drift-free and deterministic,
and proves the per-component files are distinct isolated artifacts. Deep per-file
structure (path filter, per-component concurrency group, namespaced workflow name)
is asserted in the generator unit tests.

config:
trunk_branch: main
Expand All @@ -27,18 +30,55 @@ config:
tag_prefix: web-

steps:
- name: "Seed a minimal source tree"
- name: "Seed both component subtrees"
action: commit
commit:
message: "seed source"
message: "seed component sources"
files:
src/main.go: |
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: "Regenerate and confirm no drift"
- name: "Prove both per-component workflows exist as distinct isolated files"
action: verify
verify:
regenerate: true
expect_exit: 0
# The observable, harness-robust proof of the fan-out is the emitted file set
# itself: generation produced one path-scoped, concurrency-isolated workflow
# per component and no repo-wide orchestrate.yaml. Each file is asserted on the
# concurrency group and path filter, which the harness never rewrites (only the
# top-level name: is suffixed post-generation). not_contains cross-checks that
# neither file is a copy of the other, so the assertion still fails if the set
# collapses to one component or an empty set. File-set isolation on mutation is
# proven at the unit level (TestPlan_Components_MatchesGeneratedBytes and the
# deep-copy no-bleed tests), so it is not re-proven here via a plan diff.
expect:
workflow_files:
- path: ".github/workflows/orchestrate-api.yaml"
contains:
- "group: orchestrate-api-"
- "- 'services/api/**'"
not_contains:
- "group: orchestrate-web-"
- "services/web"
- path: ".github/workflows/orchestrate-web.yaml"
contains:
- "group: orchestrate-web-"
- "- 'services/web/**'"
not_contains:
- "group: orchestrate-api-"
- "services/api"
- path: ".github/workflows/orchestrate.yaml"
not_exists: true
44 changes: 41 additions & 3 deletions internal/config/components.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
package config

import "fmt"
import (
"encoding/json"
"fmt"
"strings"
)

// clone returns a fully independent deep copy of the config via a JSON round
// trip. Every TrunkConfig field carries a json tag and none is json:"-" (the
// sole json:"-" field, ComponentConfig.Extra, lives off this type), so the round
// trip reproduces the value exactly while breaking every pointer, slice, and map
// alias. ResolveComponent needs this: a shallow copy (eff := *c) would leave
// non-overridden pointer/slice/map fields aliasing the shared config, so one
// component's derivation could bleed into a sibling.
func (c *TrunkConfig) clone() (*TrunkConfig, error) {
data, err := json.Marshal(c)
if err != nil {
return nil, fmt.Errorf("cloning config: %w", err)
}
var dup TrunkConfig
if err := json.Unmarshal(data, &dup); err != nil {
return nil, fmt.Errorf("cloning config: %w", err)
}
return &dup, nil
}

// HasComponents reports whether the manifest declares a components: block. When
// it does not, the component dimension does not exist and the single-component
Expand Down Expand Up @@ -46,7 +69,13 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error)
return nil, fmt.Errorf("component %q is not declared", name)
}

eff := *c // shallow copy: global fields carried through verbatim
// Deep-copy the shared config so non-overridden pointer/slice/map fields do
// not alias across sibling components. Global fields are carried through
// verbatim by the copy.
eff, err := c.clone()
if err != nil {
return nil, err
}
eff.Components = nil // an effective per-component config has no nested components

// Required per-component tag namespace.
Expand Down Expand Up @@ -139,7 +168,16 @@ func (c *TrunkConfig) ResolveComponent(name string) (*ResolvedComponent, error)
CancelInProgress: cancel,
}

return &ResolvedComponent{Name: name, Path: comp.Path, Config: &eff}, nil
// Derive a path-scoped push-paths filter from the component subtree when
// neither the component nor the shared defaults set an explicit triggers
// list, so each component's orchestrate workflow fires only on changes under
// its own path. An explicit inherited or per-component triggers list (applied
// above) still wins.
if len(eff.Triggers) == 0 {
eff.Triggers = []string{strings.TrimRight(comp.Path, "/") + "/**"}
}

return &ResolvedComponent{Name: name, Path: comp.Path, Config: eff}, nil
}

// globalOnlyComponentFields is the set of top-level-only (global) manifest keys
Expand Down
123 changes: 123 additions & 0 deletions internal/config/components_deepcopy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package config

import "testing"

// baseComponentConfig returns a shared-default TrunkConfig declaring two
// components that inherit slice, map, and pointer-struct fields from the top
// level. It exercises the aliasing surface ResolveComponent must not leak
// across siblings.
func baseComponentConfig() *TrunkConfig {
return &TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
ActionPins: map[string]string{"actions/checkout": "v4"},
Git: &GitConfig{UserName: "shared-bot", UserEmail: "bot@example.com"},
Components: map[string]ComponentConfig{
"api": {Path: "services/api", TagPrefix: "api-"},
"web": {Path: "services/web", TagPrefix: "web-"},
},
}
}

// TestResolveComponent_NoSiblingBleed proves the resolved effective config is a
// deep copy: mutating one component's slice, map, or pointer-struct field must
// not reach a sibling component's resolved config or the shared source config. A
// shallow copy (eff := *c) would alias the backing array/map/pointer and fail
// every assertion below.
func TestResolveComponent_NoSiblingBleed(t *testing.T) {
c := baseComponentConfig()

api, err := c.ResolveComponent("api")
if err != nil {
t.Fatalf("ResolveComponent(api): %v", err)
}
web, err := c.ResolveComponent("web")
if err != nil {
t.Fatalf("ResolveComponent(web): %v", err)
}

// Slice: overwrite api's inherited environments in place.
api.Config.Environments[0] = "MUTATED"
if web.Config.Environments[0] != "dev" {
t.Errorf("slice bleed: web env[0] = %q, want dev", web.Config.Environments[0])
}
if c.Environments[0] != "dev" {
t.Errorf("slice bleed into source: c env[0] = %q, want dev", c.Environments[0])
}

// Map: mutate api's inherited action pins.
api.Config.ActionPins["actions/checkout"] = "MUTATED"
if web.Config.ActionPins["actions/checkout"] != "v4" {
t.Errorf("map bleed: web pin = %q, want v4", web.Config.ActionPins["actions/checkout"])
}
if c.ActionPins["actions/checkout"] != "v4" {
t.Errorf("map bleed into source: c pin = %q, want v4", c.ActionPins["actions/checkout"])
}

// Pointer struct: mutate api's inherited git identity.
api.Config.Git.UserName = "MUTATED"
if web.Config.Git.UserName != "shared-bot" {
t.Errorf("pointer bleed: web git user = %q, want shared-bot", web.Config.Git.UserName)
}
if c.Git.UserName != "shared-bot" {
t.Errorf("pointer bleed into source: c git user = %q, want shared-bot", c.Git.UserName)
}
}

// TestResolveComponent_DerivesPathTrigger proves a component with no explicit
// triggers gets a push-paths filter scoped to its own subtree, so its
// orchestrate workflow only fires on changes under its path.
func TestResolveComponent_DerivesPathTrigger(t *testing.T) {
c := baseComponentConfig()

api, err := c.ResolveComponent("api")
if err != nil {
t.Fatalf("ResolveComponent(api): %v", err)
}
got := api.Config.GetAllTriggers()
want := []string{"services/api/**"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("api triggers = %v, want %v", got, want)
}

web, err := c.ResolveComponent("web")
if err != nil {
t.Fatalf("ResolveComponent(web): %v", err)
}
if g := web.Config.GetAllTriggers(); len(g) != 1 || g[0] != "services/web/**" {
t.Errorf("web triggers = %v, want [services/web/**]", g)
}
}

// TestResolveComponent_HonorsExplicitTriggers proves an inherited explicit
// triggers list wins over path derivation: the shared default filter is kept, not
// replaced by the component subtree.
func TestResolveComponent_HonorsExplicitTriggers(t *testing.T) {
c := baseComponentConfig()
c.Triggers = []string{"shared/**"}

api, err := c.ResolveComponent("api")
if err != nil {
t.Fatalf("ResolveComponent(api): %v", err)
}
if g := api.Config.GetAllTriggers(); len(g) != 1 || g[0] != "shared/**" {
t.Errorf("api triggers = %v, want [shared/**] (explicit inherited filter honored)", g)
}
}

// TestResolveComponent_PerComponentTrigger proves a per-component triggers
// override wins over both path derivation and the shared default.
func TestResolveComponent_PerComponentTrigger(t *testing.T) {
c := baseComponentConfig()
comp := c.Components["api"]
comp.Triggers = []string{"custom/**"}
c.Components["api"] = comp

api, err := c.ResolveComponent("api")
if err != nil {
t.Fatalf("ResolveComponent(api): %v", err)
}
if g := api.Config.GetAllTriggers(); len(g) != 1 || g[0] != "custom/**" {
t.Errorf("api triggers = %v, want [custom/**] (per-component override honored)", g)
}
}
Loading