diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 8d34cb72..3255a021 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -514,7 +514,9 @@ "properties": { "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, - "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." }, + "deploy_name": { "type": "string", "description": "Deploy name to dispatch when the primary recognizes this satellite under a name that differs from its local deploy/build name. Defaults to the first local deploy name, then the first build name." }, + "environment": { "type": "string", "description": "Environment to dispatch when the primary expects an environment that differs from the satellite's first local environment. Defaults to the first local environment, then dev." } } }, "externalRepoConfig": { diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 423b417e..1e1628d3 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -429,6 +429,8 @@ ci: repo: org/my-backend workflow: external-update.yaml token: PRIMARY_REPO_TOKEN + deploy_name: artifact-a + environment: staging ``` | Field | Type | Required | Description | @@ -436,9 +438,13 @@ ci: | `repo` | string | Yes | Primary repository to notify | | `workflow` | string | No | Workflow name (default: `external-update.yaml`) | | `token` | string | No | Secret name for cross-repo dispatch (default: `PRIMARY_REPO_TOKEN`) | +| `deploy_name` | string | No | Deploy name to dispatch. Set this when the primary recognizes this satellite under a name that differs from its local deploy/build name. Defaults to the first local deploy name, then the first build name. | +| `environment` | string | No | Environment to dispatch. Set this when the primary expects an environment that differs from the satellite's first local environment (for example a build-only satellite with no environments). Defaults to the first local environment, then `dev`. | When configured, the orchestrate workflow's finalize job dispatches to the primary repo after deploying to the first environment. +Use `deploy_name` and `environment` when the satellite's local names do not match the external deploy the primary defines. The primary validates the dispatched `deploy_name` and `environment` against its own config, so a satellite whose local build name or environment differs from what the primary expects must send the parent-recognized values here. + **Important:** A repository cannot be both primary (has `external`) and satellite (has `notify`). ### release Section diff --git a/internal/config/types.go b/internal/config/types.go index 095e0c1d..8c405c77 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -710,6 +710,17 @@ type NotifyConfig struct { Repo string `yaml:"repo" json:"repo"` // Primary repo to notify (e.g., "org/my-backend") Workflow string `yaml:"workflow,omitempty" json:"workflow,omitempty"` // Workflow to dispatch (default: "external-update.yaml") Token string `yaml:"token,omitempty" json:"token,omitempty"` // Secret name for cross-repo dispatch (default: "PRIMARY_REPO_TOKEN") + // DeployName, when set, overrides the dispatched deploy_name with the name the + // primary recognizes for this satellite. Use it when the satellite's local + // build/deploy name differs from the external deploy name in the primary's + // config. When empty the deploy_name is derived from the local deploys/builds. + DeployName string `yaml:"deploy_name,omitempty" json:"deploy_name,omitempty"` + // Environment, when set, overrides the dispatched environment with the + // environment the primary recognizes for this satellite's external deploy. Use + // it when the satellite's first local environment differs from (or is absent + // for build-only satellites) the environment the primary expects. When empty + // the environment is derived from the local environments (or defaults to dev). + Environment string `yaml:"environment,omitempty" json:"environment,omitempty"` } // GetWorkflow returns the notify workflow name or default diff --git a/internal/config/types_test.go b/internal/config/types_test.go index 440ea48b..495ff75c 100644 --- a/internal/config/types_test.go +++ b/internal/config/types_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) @@ -982,6 +983,32 @@ func TestNotifyConfig_GetToken(t *testing.T) { } } +func TestNotifyConfig_DeployNameAndEnvironmentOverrides_RoundTrip(t *testing.T) { + const manifest = `repo: example/primary-backend +workflow: .github/workflows/external-update.yaml +deploy_name: artifact-a +environment: staging +` + + var n NotifyConfig + require.NoError(t, yaml.Unmarshal([]byte(manifest), &n)) + + assert.Equal(t, "example/primary-backend", n.Repo) + assert.Equal(t, "artifact-a", n.DeployName) + assert.Equal(t, "staging", n.Environment) +} + +func TestNotifyConfig_Overrides_OmittedWhenUnset(t *testing.T) { + const manifest = `repo: example/primary-backend +` + + var n NotifyConfig + require.NoError(t, yaml.Unmarshal([]byte(manifest), &n)) + + assert.Empty(t, n.DeployName) + assert.Empty(t, n.Environment) +} + func TestExternalRepoConfig_GetRef(t *testing.T) { tests := []struct { name string diff --git a/internal/generate/external_test.go b/internal/generate/external_test.go index 837bd911..b9811ea6 100644 --- a/internal/generate/external_test.go +++ b/internal/generate/external_test.go @@ -861,6 +861,78 @@ func TestNotifyPrimaryStep_BuildOnlySatellite_EmitsDeployName(t *testing.T) { "build-only satellite must emit a non-empty deploy_name (consumer requires it)") } +// TestNotifyPrimaryStep_OverridesEmitParentNames verifies that when notify +// sets deploy_name and environment overrides, the dispatch to the primary's +// external-update workflow uses the parent-recognized names instead of the +// satellite's locally-derived build name and first environment. A build-only +// satellite named "shared" notifying a primary that knows it as external deploy +// "artifact-a" at environment "staging" must dispatch those parent names, or +// the primary rejects the dispatch ("external deploy 'shared' not found"). +func TestNotifyPrimaryStep_OverridesEmitParentNames(t *testing.T) { + baseDir := t.TempDir() + createMockWorkflow(t, baseDir, ".github/workflows/build-shared.yaml") + + cfg := &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "shared", Workflow: ".github/workflows/build-shared.yaml", Triggers: []string{"src/**"}}, + }, + Notify: &config.NotifyConfig{ + Repo: "example/primary-backend", + Workflow: ".github/workflows/external-update.yaml", + DeployName: "artifact-a", + Environment: "staging", + }, + } + + gen := NewGenerator(cfg, baseDir) + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "deploy_name: 'artifact-a',", + "override must emit the parent-recognized deploy_name, not the local build name") + assert.Contains(t, content, "environment: 'staging',", + "override must emit the parent-recognized environment, not the local first environment") + assert.NotContains(t, content, "deploy_name: 'shared',", + "the local build name must not leak into the dispatch when an override is set") +} + +// TestNotifyPrimaryStep_OverridesOffStateByteIdentical verifies that leaving the +// deploy_name and environment overrides unset produces output byte-identical to +// the existing derivation, so the additive fields are a no-op when absent. +func TestNotifyPrimaryStep_OverridesOffStateByteIdentical(t *testing.T) { + baseDir := t.TempDir() + createMockWorkflow(t, baseDir, ".github/workflows/build-shared.yaml") + + newCfg := func() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + Builds: []config.BuildConfig{ + {Name: "shared", Workflow: ".github/workflows/build-shared.yaml", Triggers: []string{"src/**"}}, + }, + Notify: &config.NotifyConfig{ + Repo: "example/primary-backend", + Workflow: ".github/workflows/external-update.yaml", + }, + } + } + + withoutOverrides, err := NewGenerator(newCfg(), baseDir).Generate() + require.NoError(t, err) + + // Same config but with the override fields explicitly empty: must be identical. + cfg := newCfg() + cfg.Notify.DeployName = "" + cfg.Notify.Environment = "" + withEmptyOverrides, err := NewGenerator(cfg, baseDir).Generate() + require.NoError(t, err) + + assert.Equal(t, withoutOverrides, withEmptyOverrides, + "unset overrides must be byte-identical to the existing derivation") +} + // TestNotifyPrimaryStep_NoStrayDotInJobsAccessor verifies the github-script // context accessor uses context.jobs['id'] form with no stray dot before the // bracket. The earlier context.jobs.['id'] form is invalid JavaScript and would diff --git a/internal/generate/generator.go b/internal/generate/generator.go index c8a1b40c..7f85079b 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -1686,11 +1686,15 @@ func (g *Generator) writeNotifyPrimaryStep(sb *strings.Builder) { sb.WriteString(" source_repo: context.repo.owner + '/' + context.repo.repo,\n") // deploy_name is required: true by the primary's external-update consumer, so - // it must always be present and non-empty. Prefer the first deploy name - // (satellites typically have one deploy); fall back to the first build name - // for build-only artifact satellites; otherwise use the satellite's own repo - // name from the runtime context as a last-resort identifier. + // it must always be present and non-empty. An explicit notify.deploy_name + // override wins: it lets a satellite dispatch the name the primary recognizes + // when that differs from the satellite's local deploy/build name. Otherwise, + // prefer the first deploy name (satellites typically have one deploy); fall + // back to the first build name for build-only artifact satellites; otherwise + // use the satellite's own repo name from the runtime context as a last resort. switch { + case g.config.Notify.DeployName != "": + fmt.Fprintf(sb, " deploy_name: '%s',\n", g.config.Notify.DeployName) case len(g.config.Deploys) > 0: fmt.Fprintf(sb, " deploy_name: '%s',\n", g.config.Deploys[0].Name) case len(g.config.Builds) > 0: @@ -1699,9 +1703,15 @@ func (g *Generator) writeNotifyPrimaryStep(sb *strings.Builder) { sb.WriteString(" deploy_name: context.repo.repo,\n") } - if len(g.config.Environments) > 0 { + // An explicit notify.environment override wins: it lets a satellite dispatch + // the environment the primary recognizes when that differs from the + // satellite's first local environment (or when the satellite has none). + switch { + case g.config.Notify.Environment != "": + fmt.Fprintf(sb, " environment: '%s',\n", g.config.Notify.Environment) + case len(g.config.Environments) > 0: fmt.Fprintf(sb, " environment: context.payload.inputs?.environment || '%s',\n", g.config.Environments[0]) - } else { + default: sb.WriteString(" environment: 'dev',\n") } sb.WriteString(" sha: context.sha,\n") diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 8d34cb72..3255a021 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -514,7 +514,9 @@ "properties": { "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, - "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." }, + "deploy_name": { "type": "string", "description": "Deploy name to dispatch when the primary recognizes this satellite under a name that differs from its local deploy/build name. Defaults to the first local deploy name, then the first build name." }, + "environment": { "type": "string", "description": "Environment to dispatch when the primary expects an environment that differs from the satellite's first local environment. Defaults to the first local environment, then dev." } } }, "externalRepoConfig": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 8d34cb72..3255a021 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -514,7 +514,9 @@ "properties": { "repo": { "type": "string", "description": "Primary repo to notify (for example org/my-backend)." }, "workflow": { "type": "string", "description": "Workflow to dispatch (default: external-update.yaml)." }, - "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." } + "token": { "type": "string", "description": "Secret name for cross-repo dispatch (default: PRIMARY_REPO_TOKEN)." }, + "deploy_name": { "type": "string", "description": "Deploy name to dispatch when the primary recognizes this satellite under a name that differs from its local deploy/build name. Defaults to the first local deploy name, then the first build name." }, + "environment": { "type": "string", "description": "Environment to dispatch when the primary expects an environment that differs from the satellite's first local environment. Defaults to the first local environment, then dev." } } }, "externalRepoConfig": {