From 3ddbcb47ae97faaa860acdb1716cd28da135db3f Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sat, 20 Jun 2026 23:46:08 -0400 Subject: [PATCH] feat: reserve gitops deploy target branch and sha-tracking fields Signed-off-by: Joshua Temple --- docs/public/manifest.schema.json | 4 +- docs/src/content/docs/configuration.md | 2 +- docs/src/content/docs/versioning.md | 13 ++ .../24-gitops-deploy-target-reserved.yaml | 45 +++++++ internal/config/schema_v1.go | 8 ++ internal/config/types.go | 3 +- internal/config/validate_deploytarget_test.go | 123 ++++++++++++++++++ internal/config/validate_v1.go | 21 ++- .../generate/deploytarget_reserved_test.go | 77 +++++++++++ internal/schema/manifest.schema.json | 4 +- schema/manifest.schema.json | 4 +- 11 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 e2e/scenarios/24-gitops-deploy-target-reserved.yaml create mode 100644 internal/config/validate_deploytarget_test.go create mode 100644 internal/generate/deploytarget_reserved_test.go diff --git a/docs/public/manifest.schema.json b/docs/public/manifest.schema.json index 03ad6542..aedc578c 100644 --- a/docs/public/manifest.schema.json +++ b/docs/public/manifest.schema.json @@ -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": { diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 891c354f..f322cea0 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -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: diff --git a/docs/src/content/docs/versioning.md b/docs/src/content/docs/versioning.md index 72d010ea..c6321706 100644 --- a/docs/src/content/docs/versioning.md +++ b/docs/src/content/docs/versioning.md @@ -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. diff --git a/e2e/scenarios/24-gitops-deploy-target-reserved.yaml b/e2e/scenarios/24-gitops-deploy-target-reserved.yaml new file mode 100644 index 00000000..7f3e887d --- /dev/null +++ b/e2e/scenarios/24-gitops-deploy-target-reserved.yaml @@ -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 diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 08f316d8..3b337ada 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -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. diff --git a/internal/config/types.go b/internal/config/types.go index 1564654b..01daa1ab 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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 diff --git a/internal/config/validate_deploytarget_test.go b/internal/config/validate_deploytarget_test.go new file mode 100644 index 00000000..b496ffdd --- /dev/null +++ b/internal/config/validate_deploytarget_test.go @@ -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) + } +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 9a878bc4..efb7a119 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -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. diff --git a/internal/generate/deploytarget_reserved_test.go b/internal/generate/deploytarget_reserved_test.go new file mode 100644 index 00000000..4d6458cb --- /dev/null +++ b/internal/generate/deploytarget_reserved_test.go @@ -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) +} diff --git a/internal/schema/manifest.schema.json b/internal/schema/manifest.schema.json index 03ad6542..aedc578c 100644 --- a/internal/schema/manifest.schema.json +++ b/internal/schema/manifest.schema.json @@ -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": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 03ad6542..aedc578c 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -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": {