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
4 changes: 3 additions & 1 deletion docs/public/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,16 +429,22 @@ ci:
repo: org/my-backend
workflow: external-update.yaml
token: PRIMARY_REPO_TOKEN
deploy_name: artifact-a
environment: staging
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `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
Expand Down
11 changes: 11 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/config/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions internal/generate/external_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions internal/generate/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
Expand Down
4 changes: 3 additions & 1 deletion internal/schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 3 additions & 1 deletion schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Loading