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
19 changes: 18 additions & 1 deletion docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,24 @@
"rollout": { "$ref": "#/definitions/rolloutConfig" },
"deploy_target": { "$ref": "#/definitions/deployTarget" },
"optional_depends_on": { "type": "array", "items": { "type": "string" } },
"auto_commits": { "type": "boolean" }
"auto_commits": { "type": "boolean" },
"on_update": { "$ref": "#/definitions/onUpdateConfig" }
}
},
"onUpdateConfig": {
"type": "object",
"additionalProperties": false,
"description": "What the receiver does when this external slot is recorded with a new version. Absent means record-only.",
"properties": {
"deploy": {
"type": "object",
"additionalProperties": false,
"required": ["workflow"],
"description": "Run a scoped deploy in the same receiver run after the slot is recorded, scoped to the updated component.",
"properties": {
"workflow": { "type": "string", "description": "Reusable workflow path, local or cross-repo (org/repo/.github/...@ref)." }
}
}
}
},
"dispatchInput": {
Expand Down
43 changes: 43 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,9 @@ ci:
deploys:
- name: k8s
workflow: org/k8s-manifests/.github/workflows/deploy.yaml@v1
on_update:
deploy:
workflow: org/k8s-manifests/.github/workflows/deploy.yaml@v1
```

| Field | Type | Required | Description |
Expand All @@ -333,13 +336,53 @@ ci:
| `deploys[].name` | string | Yes | Unique deploy identifier |
| `deploys[].workflow` | string | Yes | Workflow path (local or external) |
| `deploys[].triggers` | list | No | File patterns for change detection |
| `deploys[].on_update.deploy.workflow` | string | No | Reusable workflow to run as a scoped deploy when this slot is recorded |

**Workflow paths:**
- Local (`.github/workflows/deploy.yaml`) calls a workflow in the primary repo
- External (`org/repo/.github/workflows/deploy.yaml@ref`) calls a workflow in the external repo

When external deploys are configured, the generated promote workflow includes deploy jobs for each external deploy and the finalize job tracks their state.

#### Deploy on update (opt-in)

By default the receiver is record-only: when a satellite reports a new version,
the primary records the new external state and stops. Setting
`on_update.deploy.workflow` on an external deploy opts that component in to a
scoped deploy that runs synchronously in the same receiver run, right after the
slot is recorded.

```yaml
ci:
config:
external:
- repo: org/cdk-infra
ref: main
deploys:
- name: cdk
workflow: org/cdk-infra/.github/workflows/deploy.yaml
on_update:
deploy:
workflow: org/cdk-infra/.github/workflows/deploy.yaml
```

Behavior:

- **Opt-in and additive.** Omit `on_update` and the receiver stays record-only,
byte-for-byte identical to before. No deploy job is generated.
- **Scoped to the updated component.** The generated receiver emits one
`deploy_<name>` job per opted-in component, each gated on
`inputs.deploy_name` so a single receiver run deploys only the component that
was just recorded. Other components are untouched.
- **Synchronous and gated on the record.** The deploy job runs in the same
receiver run and only after the record step succeeds. A failed record never
triggers a deploy.
- **Reusable-workflow only.** Like `deploys[].workflow`, `on_update.deploy`
accepts a workflow path (local `.github/workflows/x.yaml` or
`org/repo/.github/...@ref`); inline `run:` and `shell:` are not supported. The
scoped deploy receives the recorded `environment`, `sha`, `version`, and
`deploy_name` as inputs and inherits secrets.

### notify Section (Satellite Repos)

For satellite repositories that report deployments back to a primary repo:
Expand Down
104 changes: 104 additions & 0 deletions e2e/scenarios/multi-repo/external-update-deploys-component.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Scenario: external deploy opts in to on_update.deploy and the receiver still
# records new external state, proving the additive on_update config is
# back-compatible with the live record path.
#
# Scope note: the scoped deploy job wiring (a deploy_<name> job, needs: update,
# if gated on needs.update.result == 'success' && inputs.deploy_name == '<name>',
# uses: the declared workflow, secrets: inherit) is asserted at the generator
# level in internal/generate/external_test.go. A live scoped deploy run needs a
# real reusable deploy workflow plus deploy credentials in the satellite repo,
# which is not reproducible in the gitea + act harness, so the actual deploy run
# is covered by the downstream fleet validation rather than forced here. This
# scenario keeps the live coverage focused on what the harness can prove: that
# adding on_update.deploy does not break the receiver's record path.

name: external-update-deploys-component
description: External deploy declares on_update.deploy; receiver still records new external state

repos:
primary-backend:
config:
trunk_branch: master
environments: [dev, test, prod]
deploys:
- name: api
workflow: .github/workflows/deploy-api.yaml
triggers:
- src/**
external:
- repo: example/cdk-infra
ref: main
deploys:
- name: cdk
workflow: example/cdk-infra/.github/workflows/deploy.yaml
on_update:
deploy:
workflow: example/cdk-infra/.github/workflows/deploy.yaml
manifest:
state:
dev:
sha: "primary-initial"
version: "v1.0.0-rc.0"

cdk-infra:
config:
trunk_branch: main
environments: [dev, test, prod]
deploys:
- name: cdk
workflow: .github/workflows/deploy.yaml
triggers:
- cdk/**
notify:
repo: example/primary-backend
workflow: .github/workflows/external-update.yaml
manifest:
state:
dev:
sha: "cdk-initial"
version: "v1.0.0-rc.0"

primary: primary-backend

steps:
# Step 1: Commit changes to satellite CDK repo
- name: update-cdk-stack
repo: cdk-infra
action: commit
commit:
message: "feat: update CDK stack with new resources"
files:
cdk/lib/stack.ts: |
import * as cdk from 'aws-cdk-lib';
export class MyStack extends cdk.Stack {
constructor(scope: cdk.App, id: string) {
super(scope, id);
// New resources added
}
}

# Step 2: Satellite notifies primary after deploy
- name: notify-primary
repo: cdk-infra
action: dispatch
dispatch:
target_repo: primary-backend
workflow: .github/workflows/external-update.yaml
inputs:
source_repo: example/cdk-infra
deploy_name: cdk
environment: dev
sha: "${cdk-infra.head_sha}"
version: "v1.1.0-rc.0"

expect:
repos:
primary-backend:
state:
dev:
external:
cdk:
sha: "${cdk-infra.head_sha}"
version: "v1.1.0-rc.0"
cdk-infra:
tags: [] # No tags created yet (just dev deploy)
4 changes: 4 additions & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,10 @@ func Validate(cfg *TrunkConfig) []string {
errors = append(errors, validateJobIDSafeName(fmt.Sprintf("external[%d].deploys[%d].name", i, j), d.Name)...)
prefix := fmt.Sprintf("external[%d].deploys[%d]", i, j)
errors = append(errors, validateExternalDeployWorkflowOnly(prefix, d.Workflow, d.Run, d.Shell)...)
// on_update.deploy is opt-in and reusable-workflow only, mirroring the
// workflow-only policy enforced above. When the block is present its
// workflow path is required.
errors = append(errors, validateOnUpdate(prefix, d.OnUpdate)...)
// External deploys are always reusable-workflow callbacks.
errors = append(errors, validateJobControlFields(prefix, true, d.RunsOn, d.Concurrency)...)
errors = append(errors, validatePermissions(prefix, d.Permissions)...)
Expand Down
103 changes: 103 additions & 0 deletions internal/config/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -961,3 +961,106 @@ func TestValidate_JobIDSafeNames(t *testing.T) {
})
}
}

// TestExternalDeployOnUpdate_Parse verifies that the on_update.deploy block
// parses into the new types from YAML and that a present block with no workflow
// is rejected while an absent block keeps the receiver record-only.
func TestExternalDeployOnUpdate_Parse(t *testing.T) {
tmpDir := t.TempDir()

writeManifest := func(t *testing.T, body string) string {
t.Helper()
p := filepath.Join(tmpDir, "manifest.yaml")
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
return p
}

const withDeploy = `ci:
config:
trunk_branch: main
environments: [dev, prod]
external:
- repo: example/cdk-infra
ref: main
deploys:
- name: cdk
workflow: example/cdk-infra/.github/workflows/deploy.yaml
on_update:
deploy:
workflow: example/cdk-infra/.github/workflows/deploy.yaml
`

cfg, err := Parse(writeManifest(t, withDeploy))
if err != nil {
t.Fatalf("Parse() error: %v", err)
}
if len(cfg.External) != 1 || len(cfg.External[0].Deploys) != 1 {
t.Fatalf("unexpected external shape: %+v", cfg.External)
}
d := cfg.External[0].Deploys[0]
if d.OnUpdate == nil || d.OnUpdate.Deploy == nil {
t.Fatalf("on_update.deploy did not parse: %+v", d.OnUpdate)
}
if got, want := d.OnUpdate.Deploy.Workflow, "example/cdk-infra/.github/workflows/deploy.yaml"; got != want {
t.Errorf("on_update.deploy.workflow = %q, want %q", got, want)
}
if errs := Validate(cfg); len(errs) != 0 {
t.Errorf("Validate() rejected a valid on_update config: %v", errs)
}
}

// TestExternalDeployOnUpdate_Validation covers the additive validation: absent
// block stays record-only and valid, while on_update.deploy with an empty
// workflow is rejected with the documented message.
func TestExternalDeployOnUpdate_Validation(t *testing.T) {
base := func() *TrunkConfig {
return &TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
External: []ExternalRepoConfig{
{
Repo: "example/cdk-infra",
Ref: "main",
Deploys: []ExternalDeployConfig{
{Name: "cdk", Workflow: "example/cdk-infra/.github/workflows/deploy.yaml"},
},
},
},
}
}

t.Run("absent on_update is record-only and valid", func(t *testing.T) {
cfg := base()
if errs := Validate(cfg); len(errs) != 0 {
t.Errorf("Validate() returned errors for record-only config: %v", errs)
}
})

t.Run("on_update.deploy without workflow is rejected", func(t *testing.T) {
cfg := base()
cfg.External[0].Deploys[0].OnUpdate = &OnUpdateConfig{Deploy: &OnUpdateDeploy{}}
errs := Validate(cfg)
found := false
for _, e := range errs {
if strings.Contains(e, "on_update.deploy.workflow is required when on_update.deploy is set") {
found = true
break
}
}
if !found {
t.Errorf("Validate() missing on_update.deploy.workflow required error, got: %v", errs)
}
})

t.Run("on_update.deploy with workflow is valid", func(t *testing.T) {
cfg := base()
cfg.External[0].Deploys[0].OnUpdate = &OnUpdateConfig{
Deploy: &OnUpdateDeploy{Workflow: "example/cdk-infra/.github/workflows/deploy.yaml"},
}
if errs := Validate(cfg); len(errs) != 0 {
t.Errorf("Validate() rejected a valid on_update config: %v", errs)
}
})
}
21 changes: 21 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,27 @@ type ExternalDeployConfig struct {
DeployTarget *DeployTarget `yaml:"deploy_target,omitempty" json:"deploy_target,omitempty"`
OptionalDependsOn []string `yaml:"optional_depends_on,omitempty" json:"optional_depends_on,omitempty"`
AutoCommits bool `yaml:"auto_commits,omitempty" json:"auto_commits,omitempty"`

// OnUpdate declares what the receiver does when this external slot is
// recorded with a new version. When unset the receiver is record-only, the
// historical default, and no deploy is run.
OnUpdate *OnUpdateConfig `yaml:"on_update,omitempty" json:"on_update,omitempty"`
}

// OnUpdateConfig declares what the receiver does when this external slot is
// recorded with a new version. Absent => record-only (the historical default).
type OnUpdateConfig struct {
// Deploy, when set, runs a scoped deploy in the same receiver run after the
// slot is recorded. When nil the receiver remains record-only.
Deploy *OnUpdateDeploy `yaml:"deploy,omitempty" json:"deploy,omitempty"`
}

// OnUpdateDeploy declares a scoped deploy run in the same receiver run after the
// slot is recorded, scoped to the updated component only.
type OnUpdateDeploy struct {
// Workflow is the reusable-workflow path to run as the scoped deploy, mirroring
// deploys[].workflow (local ".github/workflows/x.yaml" or "org/repo/.github/...@ref").
Workflow string `yaml:"workflow" json:"workflow"`
}

// NotifyConfig defines how a satellite repo notifies its primary after dev deploys
Expand Down
16 changes: 16 additions & 0 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,22 @@ func validateExternalDeployWorkflowOnly(prefix, workflow, run, shell string) []s
return errs
}

// validateOnUpdate enforces the shape of an external deploy's on_update block.
// The block is opt-in: a nil OnUpdate (or a nil Deploy within it) keeps the
// receiver record-only and passes validation. When on_update.deploy is set its
// workflow path is required, consistent with the reusable-workflow-only policy
// enforced on the external deploy itself.
func validateOnUpdate(prefix string, onUpdate *OnUpdateConfig) []string {
if onUpdate == nil || onUpdate.Deploy == nil {
return nil
}
var errs []string
if onUpdate.Deploy.Workflow == "" {
errs = append(errs, fmt.Sprintf("%s: on_update.deploy.workflow is required when on_update.deploy is set", prefix))
}
return errs
}

// validateJobControlFields rejects job-control fields that GHA does not accept on
// a reusable-workflow (jobs.<id>.uses) callback. runs_on and concurrency must be
// set inside the callback workflow itself, not on the calling job.
Expand Down
Loading
Loading