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 @@ -438,7 +438,9 @@
"repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." },
"path": { "type": "string", "description": "File to mutate in the target repo." },
"field": { "type": "string", "description": "Field to bump (for example image.tag)." },
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." }
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." },
"branch": { "type": "string", "description": "Target branch for the GitOps write (env-to-branch mapping); default is the target repo's default branch. Reserved." },
"track_sha": { "type": "boolean", "description": "When true, record the post-push HEAD SHA of the target repo into state. Reserved." }
}
},
"publishConfig": {
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ The framework only carries metadata. The publish workflow performs the registry

For repositories that coordinate deployments owned by satellite repos.

`external:` is designed for the **satellite/sibling-repo artifact coordination** pattern: a separate repo (the satellite) owns its own build and deploys to its first environment, then notifies the primary via `workflow_dispatch`. The primary records the satellite's SHA and version in the shared manifest and includes the satellite's deploys in every subsequent promotion. `external:` is **not** a GitOps mirror mechanism - it does not push rendered manifests to a target repo or track a pushed commit in a foreign repo. For GitOps-style deploys where the primary generates and pushes manifests to a dedicated config repo, use a callback workflow instead (see [Deploy Workflow Contract](./callback-contract#deploy-workflow-contract)).
`external:` is designed for the **satellite/sibling-repo artifact coordination** pattern: a separate repo (the satellite) owns its own build and deploys to its first environment, then notifies the primary via `workflow_dispatch`. The primary records the satellite's SHA and version in the shared manifest and includes the satellite's deploys in every subsequent promotion. `external:` is **not** a GitOps mirror mechanism - it does not push rendered manifests to a target repo or track a pushed commit in a foreign repo. The first-class (reserved) home for the GitOps mirror pattern is a deploy's `deploy_target:` block with `mode: gitops`, which reserves the shape for pushing a rendered field into a dedicated config repo and recording the pushed commit (see [Reserved shape: GitOps deploy target](./versioning#reserved-shape-gitops-deploy-target)).

```yaml
ci:
Expand Down
13 changes: 13 additions & 0 deletions docs/src/content/docs/versioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,19 @@ These fields parse and pass structural validation today, but carry no generator

`matrix:` and `rollout:` are separate canonical concerns: `matrix:` describes the fan-out a callback runs across, and `rollout:` describes how a release advances through a callback. There is no shared `strategy:` block that combines them.

## Reserved shape: GitOps deploy target

The manifest reserves the shape for a GitOps-mirror deploy variant on a deploy. A deploy may declare a `deploy_target:` block with a `mode` of `dispatch` (the default, the existing external/notify cross-repo model) or `gitops` (push a rendered field into a dedicated config repo).

The `gitops` variant reserves these enriched fields:

- `branch`, the target branch for the GitOps write (an env-to-branch mapping); the default is the target repo's default branch.
- `track_sha`, a boolean that, when true, records the post-push HEAD SHA of the target repo into state.

A matching per-env deploy state slot, `target_sha`, reserves room to record the reconciled GitOps-repo HEAD SHA so a future implementation can key promotion off it.

`branch` and `track_sha` are meaningful only when `mode` is `gitops`. These fields parse and pass structural validation today, but carry no generator behavior. A manifest declaring them produces byte-identical generated workflows, so the reserved shape is safe to adopt now. Attaching behavior to these fields later is additive and does not bump `schema_version`.

## Migrations

Each `schema_version` bump is recorded with a `Migration` section in [CHANGELOG.md](https://github.com/stablekernel/cascade/blob/main/CHANGELOG.md) describing exactly what changed and the steps to update a manifest from the previous version. There are no migrations yet: the current schema version is the first.
Expand Down
45 changes: 45 additions & 0 deletions e2e/scenarios/24-gitops-deploy-target-reserved.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: "GitOps Deploy Target Reserved Shape"
description: |
Exercises the reserved GitOps deploy_target variant (mode gitops) with the
enriched reserved fields branch and track_sha. This block is reserved and
shape-only today: it parses and passes structural validation, but carries no
generator behavior. The scenario declares the gitops shape on a deploy,
generates the workflows, then regenerates and proves the output is
byte-identical with no drift.

config:
trunk_branch: main
environments: [dev, prod]
builds:
- name: app
workflow: build.yaml
triggers: ["src/**"]
deploys:
- name: app
workflow: deploy.yaml
triggers: ["src/**"]
deploy_target:
mode: gitops
repo: org/gitops-config
path: envs/prod/values.yaml
field: image.tag
value: "v1.2.3"
branch: release/prod
track_sha: true

steps:
- name: "Seed a minimal source tree"
action: commit
commit:
message: "seed source"
files:
src/main.go: |
package main

func main() {}

- name: "Regenerate and confirm no drift"
action: verify
verify:
regenerate: true
expect_exit: 0
8 changes: 8 additions & 0 deletions internal/config/schema_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,14 @@ type DeployTarget struct {
Field string `yaml:"field,omitempty" json:"field,omitempty"`
// Value is the value to write (may be a GHA expression).
Value string `yaml:"value,omitempty" json:"value,omitempty"`
// Branch is the target branch for the GitOps write (env-to-branch
// mapping); default is the target repo's default branch.
// RESERVED - not yet wired to generation; meaningful only when mode is gitops.
Branch string `yaml:"branch,omitempty" json:"branch,omitempty"`
// TrackSHA, when true, records the post-push HEAD SHA of the target repo
// into state. RESERVED - not yet wired to generation; meaningful only when
// mode is gitops.
TrackSHA bool `yaml:"track_sha,omitempty" json:"track_sha,omitempty"`
}

// Deploy target mode constants.
Expand Down
3 changes: 2 additions & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ type DeployState struct {
Version string `yaml:"version,omitempty" json:"version,omitempty"` // Version this deployable deployed (reserved-shape; independent of env-level Version)
DeployedAt string `yaml:"deployed_at,omitempty" json:"deployed_at,omitempty"`
DeployedBy string `yaml:"deployed_by,omitempty" json:"deployed_by,omitempty"`
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"` // state_tags values
Tags map[string]string `yaml:"tags,omitempty" json:"tags,omitempty"` // state_tags values
TargetSHA string `yaml:"target_sha,omitempty" json:"target_sha,omitempty"` // RESERVED - reconciled GitOps-repo HEAD SHA, so a future implementation can key promotion off it. Not yet wired.
}

// ExternalDeployState tracks the state of an external deployable within an environment
Expand Down
123 changes: 123 additions & 0 deletions internal/config/validate_deploytarget_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package config

import "testing"

// TestValidateDeployTargetReservedFields exercises the reserved GitOps fields
// (branch, track_sha) on the deploy_target block. They are meaningful only when
// mode is gitops; branch is also checked for a safe ref shape regardless of mode.
func TestValidateDeployTargetReservedFields(t *testing.T) {
t.Parallel()

tests := []struct {
name string
target *DeployTarget
wantErr bool
errContains string
}{
{
name: "gitops with branch and track_sha is valid",
target: &DeployTarget{
Mode: DeployTargetModeGitOps,
Repo: "org/gitops-config",
Path: "envs/prod/values.yaml",
Field: "image.tag",
Value: "v1.2.3",
Branch: "release/prod",
TrackSHA: true,
},
wantErr: false,
},
{
name: "dispatch with track_sha is rejected",
target: &DeployTarget{Mode: DeployTargetModeDispatch, TrackSHA: true},
wantErr: true,
errContains: "track_sha is only valid when mode is gitops",
},
{
name: "empty mode (default dispatch) with track_sha is rejected",
target: &DeployTarget{TrackSHA: true},
wantErr: true,
errContains: "track_sha is only valid when mode is gitops",
},
{
name: "dispatch with branch is rejected",
target: &DeployTarget{Mode: DeployTargetModeDispatch, Branch: "release/prod"},
wantErr: true,
errContains: "branch is only valid when mode is gitops",
},
{
name: "branch with leading slash is rejected",
target: &DeployTarget{Mode: DeployTargetModeGitOps, Branch: "/bad"},
wantErr: true,
errContains: "must not have leading/trailing slashes or whitespace",
},
{
name: "branch with trailing slash is rejected",
target: &DeployTarget{Mode: DeployTargetModeGitOps, Branch: "bad/"},
wantErr: true,
errContains: "must not have leading/trailing slashes or whitespace",
},
{
name: "branch with whitespace is rejected",
target: &DeployTarget{Mode: DeployTargetModeGitOps, Branch: "has space"},
wantErr: true,
errContains: "must not have leading/trailing slashes or whitespace",
},
{
name: "gitops with internal-slash branch is valid",
target: &DeployTarget{Mode: DeployTargetModeGitOps, Branch: "release/prod"},
wantErr: false,
},
}

for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
errs := validateDeployTarget("deploys[0]", tt.target)
if tt.wantErr {
if len(errs) == 0 {
t.Fatalf("expected an error, got none")
}
if !hasErrContaining(errs, tt.errContains) {
t.Fatalf("expected error containing %q, got %v", tt.errContains, errs)
}
return
}
if len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
})
}
}

// TestValidateDeployTargetReservedFieldsViaManifest asserts a manifest carrying
// the enriched deploy_target shape parses and validates at CurrentSchemaVersion
// without bumping schema_version.
func TestValidateDeployTargetReservedFieldsViaManifest(t *testing.T) {
t.Parallel()

cfg := parseInline(t, `
environments: [dev, prod]
deploys:
- name: app
workflow: .github/workflows/deploy.yaml
deploy_target:
mode: gitops
repo: org/gitops-config
path: envs/prod/values.yaml
field: image.tag
value: "v1.2.3"
branch: release/prod
track_sha: true
`)
if errs := Validate(cfg); len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
if got := cfg.GetSchemaVersion(); got != CurrentSchemaVersion {
t.Fatalf("schema_version = %d, want %d (reserved shape must not bump)", got, CurrentSchemaVersion)
}
if CurrentSchemaVersion != 1 {
t.Fatalf("CurrentSchemaVersion = %d, want 1 (reserved shape must not bump)", CurrentSchemaVersion)
}
}
21 changes: 18 additions & 3 deletions internal/config/validate_v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,16 +242,31 @@ func validateCanaryConfig(prefix string, c *CanaryConfig) []string {
return errs
}

// validateDeployTarget checks deploy_target.mode.
// validateDeployTarget checks deploy_target.mode and the reserved GitOps fields
// (branch, track_sha), which are meaningful only when mode is gitops.
func validateDeployTarget(prefix string, d *DeployTarget) []string {
if d == nil {
return nil
}
var errs []string
mode := d.GetMode()
if mode != DeployTargetModeDispatch && mode != DeployTargetModeGitOps {
return []string{fmt.Sprintf("%s.deploy_target.mode must be one of: dispatch, gitops", prefix)}
errs = append(errs, fmt.Sprintf("%s.deploy_target.mode must be one of: dispatch, gitops", prefix))
}
return nil
if mode == DeployTargetModeDispatch {
if d.TrackSHA {
errs = append(errs, fmt.Sprintf("%s.deploy_target.track_sha is only valid when mode is gitops", prefix))
}
if d.Branch != "" {
errs = append(errs, fmt.Sprintf("%s.deploy_target.branch is only valid when mode is gitops", prefix))
}
}
if d.Branch != "" {
if strings.HasPrefix(d.Branch, "/") || strings.HasSuffix(d.Branch, "/") || strings.ContainsAny(d.Branch, " \t\n\r\f\v") {
errs = append(errs, fmt.Sprintf("%s.deploy_target.branch must not have leading/trailing slashes or whitespace", prefix))
}
}
return errs
}

// validateConfigLevel validates the config-level reserved fields.
Expand Down
77 changes: 77 additions & 0 deletions internal/generate/deploytarget_reserved_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package generate

import (
"bytes"
"testing"

"github.com/stablekernel/cascade/internal/config"
"github.com/stretchr/testify/require"
)

// TestDeployTargetReservedFieldsAreByteIdentical asserts that populating the
// reserved GitOps deploy_target fields (branch, track_sha) produces
// byte-identical output to a config that leaves them empty. These fields are
// reserved shape but not yet wired to generation.
func TestDeployTargetReservedFieldsAreByteIdentical(t *testing.T) {
t.Parallel()

// Case A: reserved GitOps fields populated.
targetWithReserved := &config.DeployTarget{
Mode: config.DeployTargetModeGitOps,
Repo: "org/gitops-config",
Path: "envs/prod/values.yaml",
Field: "image.tag",
Value: "v1.2.3",
Branch: "release/prod",
TrackSHA: true,
}

// Case B: same base fields, reserved GitOps fields absent.
targetWithoutReserved := &config.DeployTarget{
Mode: config.DeployTargetModeGitOps,
Repo: "org/gitops-config",
Path: "envs/prod/values.yaml",
Field: "image.tag",
Value: "v1.2.3",
}

cfgA := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
Deploys: []config.DeployConfig{
{
Name: "app",
Workflow: ".github/workflows/deploy-app.yaml",
DeployTarget: targetWithReserved,
},
},
}

cfgB := &config.TrunkConfig{
TrunkBranch: "main",
Environments: []string{"dev", "prod"},
Deploys: []config.DeployConfig{
{
Name: "app",
Workflow: ".github/workflows/deploy-app.yaml",
DeployTarget: targetWithoutReserved,
},
},
}

gA := NewPromoteGenerator(cfgA, t.TempDir())
outA, err := gA.Generate()
require.NoError(t, err)

gB := NewPromoteGenerator(cfgB, t.TempDir())
outB, err := gB.Generate()
require.NoError(t, err)

// Guard against a vacuous comparison of two empty strings: the generator
// must have produced a substantial workflow for the equality to be meaningful.
require.NotEmpty(t, outA, "generated output must be non-empty")
require.Greater(t, len(outA), 1024, "generated workflow should be substantial")

require.True(t, bytes.Equal([]byte(outA), []byte(outB)),
"reserved deploy_target fields must not affect generated output:\nwith fields:\n%s\nwithout fields:\n%s", outA, outB)
}
4 changes: 3 additions & 1 deletion internal/schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,9 @@
"repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." },
"path": { "type": "string", "description": "File to mutate in the target repo." },
"field": { "type": "string", "description": "Field to bump (for example image.tag)." },
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." }
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." },
"branch": { "type": "string", "description": "Target branch for the GitOps write (env-to-branch mapping); default is the target repo's default branch. Reserved." },
"track_sha": { "type": "boolean", "description": "When true, record the post-push HEAD SHA of the target repo into state. Reserved." }
}
},
"publishConfig": {
Expand Down
4 changes: 3 additions & 1 deletion schema/manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,9 @@
"repo": { "type": "string", "description": "GitOps config repo (for example org/gitops-config)." },
"path": { "type": "string", "description": "File to mutate in the target repo." },
"field": { "type": "string", "description": "Field to bump (for example image.tag)." },
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." }
"value": { "type": "string", "description": "Value to write (may be a GitHub Actions expression)." },
"branch": { "type": "string", "description": "Target branch for the GitOps write (env-to-branch mapping); default is the target repo's default branch. Reserved." },
"track_sha": { "type": "boolean", "description": "When true, record the post-push HEAD SHA of the target repo into state. Reserved." }
}
},
"publishConfig": {
Expand Down
Loading