From 7f96e75f986dfa87527cab72f7c4bbba9f8fbc25 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 17 Jun 2026 18:43:57 -0400 Subject: [PATCH] feat: run scoped deploy on external-update record When an external deploy declares on_update.deploy.workflow, the generated external-update receiver emits a scoped deploy_ job that runs synchronously in the same receiver run after the slot is recorded. The job needs the update job, is gated on its success and on inputs.deploy_name, and calls the declared reusable workflow with the recorded environment, sha, version, and deploy_name, inheriting secrets. Absent on_update keeps the receiver record-only and byte-identical to before: no deploy job is generated. on_update.deploy is reusable-workflow only and its workflow path is required when the block is present. Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 19 ++- docs/src/content/docs/configuration.md | 43 +++++ .../external-update-deploys-component.yaml | 104 ++++++++++++ internal/config/parse.go | 4 + internal/config/parse_test.go | 103 ++++++++++++ internal/config/types.go | 21 +++ internal/config/validate_v1.go | 16 ++ internal/generate/external.go | 100 ++++++++++++ internal/generate/external_test.go | 150 ++++++++++++++++++ internal/schema/manifest.schema.json | 19 ++- schema/manifest.schema.json | 19 ++- 11 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 e2e/scenarios/multi-repo/external-update-deploys-component.yaml diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 2202d6b..884bd47 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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": { diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 8e465a0..041a12e 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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 | @@ -333,6 +336,7 @@ 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 @@ -340,6 +344,45 @@ ci: 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_` 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: diff --git a/e2e/scenarios/multi-repo/external-update-deploys-component.yaml b/e2e/scenarios/multi-repo/external-update-deploys-component.yaml new file mode 100644 index 0000000..229b065 --- /dev/null +++ b/e2e/scenarios/multi-repo/external-update-deploys-component.yaml @@ -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_ job, needs: update, +# if gated on needs.update.result == 'success' && inputs.deploy_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) diff --git a/internal/config/parse.go b/internal/config/parse.go index e6f8971..b2afcd4 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -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)...) diff --git a/internal/config/parse_test.go b/internal/config/parse_test.go index 5a09040..159ea84 100644 --- a/internal/config/parse_test.go +++ b/internal/config/parse_test.go @@ -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) + } + }) +} diff --git a/internal/config/types.go b/internal/config/types.go index b1ff582..a07817d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 0bf2d62..430c5d0 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -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..uses) callback. runs_on and concurrency must be // set inside the callback workflow itself, not on the calling job. diff --git a/internal/generate/external.go b/internal/generate/external.go index f96468f..fd17e21 100644 --- a/internal/generate/external.go +++ b/internal/generate/external.go @@ -67,10 +67,110 @@ func (g *ExternalUpdateGenerator) Generate() (string, error) { g.writePermissions(&sb) g.writeConcurrency(&sb) g.writeJob(&sb) + // Scoped deploy jobs are additive: writeDeployJobs emits nothing unless at + // least one external deploy declares on_update.deploy. When no external + // deploy opts in, the generated bytes are identical to the record-only + // receiver, preserving back-compat. + g.writeDeployJobs(&sb) return sb.String(), nil } +// hasOnUpdateDeploy reports whether any external deploy declares an +// on_update.deploy block. It is the guard that keeps the receiver byte-identical +// to the record-only default when no component opts in to a scoped deploy. +func (g *ExternalUpdateGenerator) hasOnUpdateDeploy() bool { + for _, ext := range g.config.External { + for _, d := range ext.Deploys { + if d.OnUpdate != nil && d.OnUpdate.Deploy != nil { + return true + } + } + } + return false +} + +// writeDeployJobs emits one scoped deploy job per external deploy that declares +// on_update.deploy. Each job: +// +// - depends on the update job (needs: update) and runs only when that job +// succeeded. The receiver verb records last-writer-wins and does not emit a +// no-op signal, so the strongest gate available is the update step's own +// success: a failed record never deploys. This is a synchronous deploy in +// the same receiver run, not an async dispatch. +// - is scoped to exactly the dispatched component via +// inputs.deploy_name == ''. The receiver is a single workflow keyed by +// the runtime deploy_name, and GitHub uses: cannot be interpolated, so each +// opted-in component gets its own job guarded on the deploy_name match. At +// most one such job runs per receiver run, and it runs in the same run id as +// the record, keeping the deploy correlatable to the update. +// +// When no external deploy opts in, this emits nothing. +func (g *ExternalUpdateGenerator) writeDeployJobs(sb *strings.Builder) { + if !g.hasOnUpdateDeploy() { + return + } + // Iterate in config slice order (external repos, then deploys within each) + // so the generated bytes are deterministic run to run. + for _, ext := range g.config.External { + for _, d := range ext.Deploys { + if d.OnUpdate == nil || d.OnUpdate.Deploy == nil { + continue + } + g.writeDeployJob(sb, ext, d) + } + } +} + +// writeDeployJob emits a single scoped deploy job for one opted-in external +// deploy. The job id is deploy_ so it is deterministic and +// correlatable to the component. The callback workflow path is resolved exactly +// like other external callbacks: a cross-repo path gets the external repo ref +// appended, and a local path is normalized to the canonical ./ form. +func (g *ExternalUpdateGenerator) writeDeployJob(sb *strings.Builder, ext config.ExternalRepoConfig, d config.ExternalDeployConfig) { + // d.Name is already constrained to a job-ID-safe grammar by config validation + // (validateJobIDSafeName), so it can key the job id directly. + jobID := "deploy_" + d.Name + workflow := resolveOnUpdateWorkflowPath(d.OnUpdate.Deploy.Workflow, ext.Ref) + + sb.WriteString("\n") + fmt.Fprintf(sb, " %s:\n", jobID) + fmt.Fprintf(sb, " name: Deploy %s\n", d.Name) + sb.WriteString(" needs: update\n") + // Gate on the update job succeeding and on the dispatched component matching + // this job's component. needs.update.result == 'success' is the success gate; + // inputs.deploy_name == '' scopes the run to exactly the component that + // was recorded, so a single receiver run deploys at most one component. + fmt.Fprintf(sb, " if: ${{ needs.update.result == 'success' && inputs.deploy_name == '%s' }}\n", d.Name) + fmt.Fprintf(sb, " uses: %s\n", workflow) + // Scope the deploy to the recorded component: forward the environment, sha, + // and version the receiver was dispatched with, plus the deploy_name so the + // callback knows which slot it is deploying. + sb.WriteString(" with:\n") + sb.WriteString(" environment: ${{ inputs.environment }}\n") + sb.WriteString(" sha: ${{ inputs.sha }}\n") + sb.WriteString(" version: ${{ inputs.version }}\n") + sb.WriteString(" deploy_name: ${{ inputs.deploy_name }}\n") + // Reusable deploy callbacks inherit secrets so the called workflow can reach + // the same deploy credentials the rest of the pipeline uses. + sb.WriteString(" secrets: inherit\n") +} + +// resolveOnUpdateWorkflowPath resolves an on_update.deploy.workflow path to a +// uses: value, mirroring how promote/external callbacks resolve workflow paths. +// A local path (.github/...) is normalized to the canonical ./ form. A cross-repo +// path (org/repo/.github/...) gets the external repo ref appended when it does +// not already pin one with @. +func resolveOnUpdateWorkflowPath(workflow, ref string) string { + if strings.HasPrefix(workflow, ".github/") || strings.HasPrefix(workflow, "./") { + return normalizeWorkflowPath(workflow) + } + if !strings.Contains(workflow, "@") && ref != "" { + return workflow + "@" + ref + } + return workflow +} + func (g *ExternalUpdateGenerator) writeHeader(sb *strings.Builder) { sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile()) diff --git a/internal/generate/external_test.go b/internal/generate/external_test.go index 272c19f..969172f 100644 --- a/internal/generate/external_test.go +++ b/internal/generate/external_test.go @@ -888,3 +888,153 @@ func TestNotifyPrimaryStep_NoStrayDotInJobsAccessor(t *testing.T) { assert.NotContains(t, content, "context.jobs.[", "github-script accessor must not have a stray dot before the bracket") } + +// onUpdatePrimaryConfig returns a primary config with one external deploy that +// opts in to on_update.deploy and one that does not, so tests can assert the +// scoped deploy job is emitted only for the opted-in component. +func onUpdatePrimaryConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + External: []config.ExternalRepoConfig{ + { + Repo: "example/cdk-infra", + Ref: "main", + Deploys: []config.ExternalDeployConfig{ + { + Name: "cdk", + Workflow: "example/cdk-infra/.github/workflows/deploy.yaml", + OnUpdate: &config.OnUpdateConfig{ + Deploy: &config.OnUpdateDeploy{ + Workflow: "example/cdk-infra/.github/workflows/deploy.yaml", + }, + }, + }, + }, + }, + { + Repo: "example/k8s-manifests", + Ref: "main", + Deploys: []config.ExternalDeployConfig{ + // No on_update: this component stays record-only and must not + // produce a deploy job. + {Name: "k8s", Workflow: "example/k8s-manifests/.github/workflows/deploy.yaml"}, + }, + }, + }, + } +} + +// TestExternalUpdateGenerator_OnUpdateDeploy_ScopedJob verifies that an external +// deploy declaring on_update.deploy produces a scoped deploy_ job that is +// gated on the update job succeeding and on the dispatched component, and that +// the secrets-inherit and with: inputs are wired. +func TestExternalUpdateGenerator_OnUpdateDeploy_ScopedJob(t *testing.T) { + gen := NewExternalUpdateGenerator(onUpdatePrimaryConfig(), "/tmp") + content, err := gen.Generate() + require.NoError(t, err) + + // The opted-in component gets a deterministically named scoped deploy job. + assert.Contains(t, content, " deploy_cdk:") + assert.Contains(t, content, " needs: update\n") + // Gated on the update job succeeding AND the dispatched component matching. + assert.Contains(t, content, + "if: ${{ needs.update.result == 'success' && inputs.deploy_name == 'cdk' }}") + // Cross-repo workflow path keeps its ref. + assert.Contains(t, content, + "uses: example/cdk-infra/.github/workflows/deploy.yaml@main") + // Scoped with: inputs forward the dispatched run's environment/sha/version. + assert.Contains(t, content, "environment: ${{ inputs.environment }}") + assert.Contains(t, content, "sha: ${{ inputs.sha }}") + assert.Contains(t, content, "deploy_name: ${{ inputs.deploy_name }}") + assert.Contains(t, content, "secrets: inherit") + + // The component without on_update must not get a deploy job. + assert.NotContains(t, content, "deploy_k8s:") +} + +// TestExternalUpdateGenerator_NoOnUpdate_RecordOnlyByteIdentical proves the +// back-compat guarantee: when no external deploy declares on_update, the +// generated receiver is byte-identical to the record-only output and contains no +// deploy job at all. +func TestExternalUpdateGenerator_NoOnUpdate_RecordOnlyByteIdentical(t *testing.T) { + // A config identical to the on_update case but with the on_update blocks + // stripped. The generated bytes must match a hand-built record-only baseline. + recordOnly := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + External: []config.ExternalRepoConfig{ + { + Repo: "example/cdk-infra", + Ref: "main", + Deploys: []config.ExternalDeployConfig{ + {Name: "cdk", Workflow: "example/cdk-infra/.github/workflows/deploy.yaml"}, + }, + }, + { + Repo: "example/k8s-manifests", + Ref: "main", + Deploys: []config.ExternalDeployConfig{ + {Name: "k8s", Workflow: "example/k8s-manifests/.github/workflows/deploy.yaml"}, + }, + }, + }, + } + + gen := NewExternalUpdateGenerator(recordOnly, "/tmp") + content, err := gen.Generate() + require.NoError(t, err) + + // No deploy job is emitted when no component opts in. + assert.NotContains(t, content, "deploy_cdk:") + assert.NotContains(t, content, "deploy_k8s:") + assert.NotContains(t, content, "needs: update") + + // The only job is the update job (exactly one job key under jobs:). + assert.Equal(t, 1, strings.Count(content, " update:\n"), + "record-only receiver must contain exactly the update job") +} + +// TestExternalUpdateGenerator_OnUpdateDeploy_Deterministic asserts the generated +// receiver is byte-stable across repeated generations when on_update.deploy is +// configured, matching the package determinism guarantee. +func TestExternalUpdateGenerator_OnUpdateDeploy_Deterministic(t *testing.T) { + cfg := onUpdatePrimaryConfig() + first, err := NewExternalUpdateGenerator(cfg, "/tmp").Generate() + require.NoError(t, err) + for i := 0; i < 20; i++ { + next, err := NewExternalUpdateGenerator(cfg, "/tmp").Generate() + require.NoError(t, err) + require.Equal(t, first, next, "external-update generation must be deterministic") + } +} + +// TestExternalUpdateGenerator_OnUpdateDeploy_LocalWorkflowPath verifies a local +// on_update.deploy.workflow is normalized to the canonical ./ uses: form. +func TestExternalUpdateGenerator_OnUpdateDeploy_LocalWorkflowPath(t *testing.T) { + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + External: []config.ExternalRepoConfig{ + { + Repo: "example/cdk-infra", + Ref: "main", + Deploys: []config.ExternalDeployConfig{ + { + Name: "cdk", + Workflow: "example/cdk-infra/.github/workflows/deploy.yaml", + OnUpdate: &config.OnUpdateConfig{ + Deploy: &config.OnUpdateDeploy{ + Workflow: ".github/workflows/deploy-cdk.yaml", + }, + }, + }, + }, + }, + }, + } + + content, err := NewExternalUpdateGenerator(cfg, "/tmp").Generate() + require.NoError(t, err) + assert.Contains(t, content, "uses: ./.github/workflows/deploy-cdk.yaml") +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 2202d6b..884bd47 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 2202d6b..884bd47 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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": {