From 821dd0e57a0712edaa3779dfb022da717e7ce14e Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:18:42 -0400 Subject: [PATCH 01/10] feat(pinreconcile): surgically edit action_pins in the user manifest Signed-off-by: Joshua Temple --- internal/pinreconcile/manifest_edit.go | 88 +++++++++++++++++++++ internal/pinreconcile/manifest_edit_test.go | 76 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 internal/pinreconcile/manifest_edit.go create mode 100644 internal/pinreconcile/manifest_edit_test.go diff --git a/internal/pinreconcile/manifest_edit.go b/internal/pinreconcile/manifest_edit.go new file mode 100644 index 00000000..ab52a5c8 --- /dev/null +++ b/internal/pinreconcile/manifest_edit.go @@ -0,0 +1,88 @@ +package pinreconcile + +import ( + "bytes" + "fmt" + + "gopkg.in/yaml.v3" +) + +// manifestIndent is the indent width the surgical re-encode uses, matching the +// two-space indent cascade's own manifests are written with. +const manifestIndent = 2 + +// SetActionPinSurgical changes a single action's value under ci.action_pins in +// a user manifest's raw bytes, touching nothing else. It walks the document as +// a yaml.Node tree rather than round-tripping through a typed struct, so every +// unrelated key, comment, and the document's key order survive byte for byte. +// A missing action_pins mapping (or a missing key inside it) is created rather +// than treated as an error, so a manifest that has never carried an override +// still adopts cleanly. +func SetActionPinSurgical(doc []byte, action, ref string) ([]byte, error) { + var root yaml.Node + if err := yaml.Unmarshal(doc, &root); err != nil { + return nil, fmt.Errorf("parsing manifest: %w", err) + } + if len(root.Content) == 0 { + return nil, fmt.Errorf("manifest is empty") + } + + ciMapping := mappingValue(root.Content[0], "ci") + if ciMapping == nil { + return nil, fmt.Errorf("manifest has no top-level %q mapping", "ci") + } + + pinsMapping := mappingValue(ciMapping, "action_pins") + if pinsMapping == nil { + pinsMapping = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + ciMapping.Content = append(ciMapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "action_pins"}, + pinsMapping, + ) + } + + setMappingValue(pinsMapping, action, ref) + + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(manifestIndent) + if err := enc.Encode(&root); err != nil { + return nil, fmt.Errorf("encoding manifest: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("encoding manifest: %w", err) + } + return buf.Bytes(), nil +} + +// mappingValue returns the value node paired with key in mapping's Content, or +// nil when the key is absent. mapping must be a yaml.MappingNode. +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +// setMappingValue sets key's scalar value to value inside mapping, updating an +// existing entry in place (clearing any stale style, tag, and line comment so +// the new value re-encodes cleanly) or appending a new key/value pair when the +// key is absent. +func setMappingValue(mapping *yaml.Node, key, value string) { + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + v := mapping.Content[i+1] + v.Value = value + v.Style = 0 + v.Tag = "!!str" + v.LineComment = "" + return + } + } + mapping.Content = append(mapping.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, + ) +} diff --git a/internal/pinreconcile/manifest_edit_test.go b/internal/pinreconcile/manifest_edit_test.go new file mode 100644 index 00000000..72117aa5 --- /dev/null +++ b/internal/pinreconcile/manifest_edit_test.go @@ -0,0 +1,76 @@ +package pinreconcile + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// decodedActionPins is the minimal shape needed to assert what a standard +// (comment-stripping) yaml.Unmarshal actually sees after a surgical edit, which +// is the guarantee that matters: a sha-plus-version adoption must survive a +// real re-parse, not just look right as raw bytes. +type decodedActionPins struct { + CI struct { + ActionPins map[string]string `yaml:"action_pins"` + } `yaml:"ci"` +} + +func TestSetActionPinSurgical_PreservesCommentsAndOrder(t *testing.T) { + doc := []byte("" + + "ci:\n" + + " pin_mode: sha\n" + + " # keep this comment\n" + + " action_pins:\n" + + " actions/checkout: oldsha # v5.0.0\n" + + " actions/upload-artifact: keepme # v4.0.0\n") + + out, err := SetActionPinSurgical(doc, "actions/checkout", "newsha # v6.0.0") + require.NoError(t, err) + s := string(out) + + // The adopted value carries a literal "#", which yaml.v3 quotes so the + // comment stays part of the string instead of becoming a real YAML + // comment that a later re-parse would silently drop. + require.Contains(t, s, "actions/checkout: 'newsha # v6.0.0'") + require.Contains(t, s, "# keep this comment") // untouched comment survives + require.Contains(t, s, "actions/upload-artifact: keepme # v4.0.0") // untouched key survives, order kept + + var got decodedActionPins + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Equal(t, "newsha # v6.0.0", got.CI.ActionPins["actions/checkout"], + "the adopted sha must keep its version comment through a real re-parse") + require.Equal(t, "keepme", got.CI.ActionPins["actions/upload-artifact"], + "the untouched entry's trailing text was always a plain comment, not adopted data") +} + +func TestSetActionPinSurgical_AddsMissingKey(t *testing.T) { + doc := []byte("ci:\n action_pins:\n actions/checkout: a # v5\n") + out, err := SetActionPinSurgical(doc, "actions/upload-artifact", "b # v4") + require.NoError(t, err) + require.Contains(t, string(out), "actions/upload-artifact: 'b # v4'") + + var got decodedActionPins + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Equal(t, "b # v4", got.CI.ActionPins["actions/upload-artifact"]) + require.Equal(t, "a", got.CI.ActionPins["actions/checkout"], "untouched key is preserved") +} + +func TestSetActionPinSurgical_PlainTagStaysUnquoted(t *testing.T) { + doc := []byte("ci:\n action_pins:\n actions/checkout: v5\n") + out, err := SetActionPinSurgical(doc, "actions/checkout", "v6") + require.NoError(t, err) + require.Contains(t, string(out), "actions/checkout: v6\n", + "a plain tag with no embedded comment token needs no quoting") +} + +func TestSetActionPinSurgical_CreatesMissingActionPinsMapping(t *testing.T) { + doc := []byte("ci:\n pin_mode: sha\n") + out, err := SetActionPinSurgical(doc, "actions/checkout", "v6") + require.NoError(t, err) + + var got decodedActionPins + require.NoError(t, yaml.Unmarshal(out, &got)) + require.Equal(t, "v6", got.CI.ActionPins["actions/checkout"]) +} From fd947aeabfa3b125e65ef11b1644908130f5d839 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:23:35 -0400 Subject: [PATCH 02/10] feat(pinreconcile): reconcile a governed pin into the user manifest and regenerate Signed-off-by: Joshua Temple --- internal/generate/plan.go | 12 ++ internal/pinreconcile/command_run_test.go | 77 +++++++++ internal/pinreconcile/manifest_edit.go | 23 +-- internal/pinreconcile/manifest_edit_test.go | 40 +++-- internal/pinreconcile/run.go | 168 ++++++++++++++++++++ 5 files changed, 296 insertions(+), 24 deletions(-) create mode 100644 internal/pinreconcile/command_run_test.go create mode 100644 internal/pinreconcile/run.go diff --git a/internal/generate/plan.go b/internal/generate/plan.go index ee23f8bc..a03fd8f8 100644 --- a/internal/generate/plan.go +++ b/internal/generate/plan.go @@ -28,6 +28,12 @@ type PlanOptions struct { ActionFolder string OutputPath string PromoteOutputPath string + // PinOverridesPath, when non-empty, names an on-disk action_pins.yaml whose + // pins overlay cfg.ActionPins (via ApplyDiskPinOverrides) before any + // generator runs. A version-pinned reconcile binary uses this to regenerate + // against a repo's current pins instead of the binary's stale compiled-in + // defaultActionPins copy; an explicit user action_pins override still wins. + PinOverridesPath string } // Plan resolves the manifest and returns the complete set of files the generate @@ -52,6 +58,12 @@ func Plan(opts PlanOptions) ([]PlannedFile, error) { return nil, fmt.Errorf("parsing config: %w", err) } + if opts.PinOverridesPath != "" { + if err := ApplyDiskPinOverrides(cfg, opts.PinOverridesPath); err != nil { + return nil, fmt.Errorf("applying pin overrides: %w", err) + } + } + // Parse the full manifest (including state) so generators can resolve // cascade-owned ${{ state.. }} input references. State is // optional; absence is not an error. diff --git a/internal/pinreconcile/command_run_test.go b/internal/pinreconcile/command_run_test.go new file mode 100644 index 00000000..64f901e9 --- /dev/null +++ b/internal/pinreconcile/command_run_test.go @@ -0,0 +1,77 @@ +package pinreconcile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/generate" + "github.com/stretchr/testify/require" +) + +const ( + testOldCheckoutSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + testNewCheckoutSHA = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +// writeReconcileTestRepo lays out a minimal single-environment repo: a build +// workflow stub the orchestrate generator's callback introspection reads, and +// a sha-mode manifest carrying a stale checkout pin. It returns the repo root. +func writeReconcileTestRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "workflows", "build.yaml"), + []byte("name: Build\non:\n workflow_call:\n"), 0o644)) + + manifest := "ci:\n" + + " config:\n" + + " trunk_branch: main\n" + + " environments: [prod]\n" + + " pin_mode: sha\n" + + " action_pins:\n" + + " actions/checkout: " + testOldCheckoutSHA + " # v5.0.0\n" + + " builds:\n" + + " - name: app\n" + + " workflow: .github/workflows/build.yaml\n" + + " triggers: [\"src/**\"]\n" + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "manifest.yaml"), []byte(manifest), 0o644)) + + return dir +} + +// TestRun_AdoptsShaBumpAndRegeneratesClean proves the end-to-end user-repo +// path: a sha bump observed in a changed source file lands verbatim (with its +// version comment) in the manifest's action_pins, and the resulting regenerate +// carries that same pin cleanly, with the manifest's untouched keys intact. +func TestRun_AdoptsShaBumpAndRegeneratesClean(t *testing.T) { + dir := writeReconcileTestRepo(t) + + changed := "dependabot-bump.yaml" + require.NoError(t, os.WriteFile(filepath.Join(dir, changed), + []byte(" - uses: actions/checkout@"+testNewCheckoutSHA+" # v6.0.0\n"), 0o644)) + + ok, err := Run(Options{Root: dir, ChangedFiles: []string{changed}}) + require.NoError(t, err) + require.True(t, ok) + + manifestBytes, err := os.ReadFile(filepath.Join(dir, ".github", "manifest.yaml")) + require.NoError(t, err) + require.Contains(t, string(manifestBytes), testNewCheckoutSHA+" # v6.0.0") + require.Contains(t, string(manifestBytes), "trunk_branch: main", "untouched keys survive") + + orchestrate, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", "orchestrate.yaml")) + require.NoError(t, err) + + // The regenerated file must carry exactly the adopted checkout sha/version + // pair; scanning it against that same pair must report zero drift. Other + // governed actions the file may carry are absent from this table and are + // therefore skipped by ScanUsesForPinDrift, not flagged. + want := map[string]generate.ActionPinEntry{ + "actions/checkout": {SHA: testNewCheckoutSHA, Version: "v6.0.0", Emit: true}, + } + mismatches := generate.ScanUsesForPinDrift("orchestrate.yaml", string(orchestrate), want) + require.Empty(t, mismatches, "regenerated orchestrate.yaml must carry the adopted pin cleanly") +} diff --git a/internal/pinreconcile/manifest_edit.go b/internal/pinreconcile/manifest_edit.go index ab52a5c8..c36b5bbc 100644 --- a/internal/pinreconcile/manifest_edit.go +++ b/internal/pinreconcile/manifest_edit.go @@ -11,13 +11,13 @@ import ( // two-space indent cascade's own manifests are written with. const manifestIndent = 2 -// SetActionPinSurgical changes a single action's value under ci.action_pins in -// a user manifest's raw bytes, touching nothing else. It walks the document as -// a yaml.Node tree rather than round-tripping through a typed struct, so every -// unrelated key, comment, and the document's key order survive byte for byte. -// A missing action_pins mapping (or a missing key inside it) is created rather -// than treated as an error, so a manifest that has never carried an override -// still adopts cleanly. +// SetActionPinSurgical changes a single action's value under +// ci.config.action_pins in a user manifest's raw bytes, touching nothing else. +// It walks the document as a yaml.Node tree rather than round-tripping through +// a typed struct, so every unrelated key, comment, and the document's key +// order survive byte for byte. A missing action_pins mapping (or a missing key +// inside it) is created rather than treated as an error, so a manifest that +// has never carried an override still adopts cleanly. func SetActionPinSurgical(doc []byte, action, ref string) ([]byte, error) { var root yaml.Node if err := yaml.Unmarshal(doc, &root); err != nil { @@ -32,10 +32,15 @@ func SetActionPinSurgical(doc []byte, action, ref string) ([]byte, error) { return nil, fmt.Errorf("manifest has no top-level %q mapping", "ci") } - pinsMapping := mappingValue(ciMapping, "action_pins") + configMapping := mappingValue(ciMapping, "config") + if configMapping == nil { + return nil, fmt.Errorf("manifest has no %q mapping under %q", "config", "ci") + } + + pinsMapping := mappingValue(configMapping, "action_pins") if pinsMapping == nil { pinsMapping = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - ciMapping.Content = append(ciMapping.Content, + configMapping.Content = append(configMapping.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "action_pins"}, pinsMapping, ) diff --git a/internal/pinreconcile/manifest_edit_test.go b/internal/pinreconcile/manifest_edit_test.go index 72117aa5..895ec4b2 100644 --- a/internal/pinreconcile/manifest_edit_test.go +++ b/internal/pinreconcile/manifest_edit_test.go @@ -10,21 +10,25 @@ import ( // decodedActionPins is the minimal shape needed to assert what a standard // (comment-stripping) yaml.Unmarshal actually sees after a surgical edit, which // is the guarantee that matters: a sha-plus-version adoption must survive a -// real re-parse, not just look right as raw bytes. +// real re-parse, not just look right as raw bytes. It mirrors the real +// manifest schema, where every ci field lives under a nested "config" key. type decodedActionPins struct { CI struct { - ActionPins map[string]string `yaml:"action_pins"` + Config struct { + ActionPins map[string]string `yaml:"action_pins"` + } `yaml:"config"` } `yaml:"ci"` } func TestSetActionPinSurgical_PreservesCommentsAndOrder(t *testing.T) { doc := []byte("" + "ci:\n" + - " pin_mode: sha\n" + - " # keep this comment\n" + - " action_pins:\n" + - " actions/checkout: oldsha # v5.0.0\n" + - " actions/upload-artifact: keepme # v4.0.0\n") + " config:\n" + + " pin_mode: sha\n" + + " # keep this comment\n" + + " action_pins:\n" + + " actions/checkout: oldsha # v5.0.0\n" + + " actions/upload-artifact: keepme # v4.0.0\n") out, err := SetActionPinSurgical(doc, "actions/checkout", "newsha # v6.0.0") require.NoError(t, err) @@ -39,26 +43,26 @@ func TestSetActionPinSurgical_PreservesCommentsAndOrder(t *testing.T) { var got decodedActionPins require.NoError(t, yaml.Unmarshal(out, &got)) - require.Equal(t, "newsha # v6.0.0", got.CI.ActionPins["actions/checkout"], + require.Equal(t, "newsha # v6.0.0", got.CI.Config.ActionPins["actions/checkout"], "the adopted sha must keep its version comment through a real re-parse") - require.Equal(t, "keepme", got.CI.ActionPins["actions/upload-artifact"], + require.Equal(t, "keepme", got.CI.Config.ActionPins["actions/upload-artifact"], "the untouched entry's trailing text was always a plain comment, not adopted data") } func TestSetActionPinSurgical_AddsMissingKey(t *testing.T) { - doc := []byte("ci:\n action_pins:\n actions/checkout: a # v5\n") + doc := []byte("ci:\n config:\n action_pins:\n actions/checkout: a # v5\n") out, err := SetActionPinSurgical(doc, "actions/upload-artifact", "b # v4") require.NoError(t, err) require.Contains(t, string(out), "actions/upload-artifact: 'b # v4'") var got decodedActionPins require.NoError(t, yaml.Unmarshal(out, &got)) - require.Equal(t, "b # v4", got.CI.ActionPins["actions/upload-artifact"]) - require.Equal(t, "a", got.CI.ActionPins["actions/checkout"], "untouched key is preserved") + require.Equal(t, "b # v4", got.CI.Config.ActionPins["actions/upload-artifact"]) + require.Equal(t, "a", got.CI.Config.ActionPins["actions/checkout"], "untouched key is preserved") } func TestSetActionPinSurgical_PlainTagStaysUnquoted(t *testing.T) { - doc := []byte("ci:\n action_pins:\n actions/checkout: v5\n") + doc := []byte("ci:\n config:\n action_pins:\n actions/checkout: v5\n") out, err := SetActionPinSurgical(doc, "actions/checkout", "v6") require.NoError(t, err) require.Contains(t, string(out), "actions/checkout: v6\n", @@ -66,11 +70,17 @@ func TestSetActionPinSurgical_PlainTagStaysUnquoted(t *testing.T) { } func TestSetActionPinSurgical_CreatesMissingActionPinsMapping(t *testing.T) { - doc := []byte("ci:\n pin_mode: sha\n") + doc := []byte("ci:\n config:\n pin_mode: sha\n") out, err := SetActionPinSurgical(doc, "actions/checkout", "v6") require.NoError(t, err) var got decodedActionPins require.NoError(t, yaml.Unmarshal(out, &got)) - require.Equal(t, "v6", got.CI.ActionPins["actions/checkout"]) + require.Equal(t, "v6", got.CI.Config.ActionPins["actions/checkout"]) +} + +func TestSetActionPinSurgical_RejectsMissingConfigMapping(t *testing.T) { + doc := []byte("ci:\n state: {}\n") + _, err := SetActionPinSurgical(doc, "actions/checkout", "v6") + require.Error(t, err) } diff --git a/internal/pinreconcile/run.go b/internal/pinreconcile/run.go new file mode 100644 index 00000000..baf0a2d2 --- /dev/null +++ b/internal/pinreconcile/run.go @@ -0,0 +1,168 @@ +package pinreconcile + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// Options configures a user-repo Run. ManifestPath defaults to +// "/.github/manifest.yaml" when empty. ManifestKey defaults to +// config.DefaultManifestKey. ChangedFiles lists the paths (relative to Root, or +// absolute) of the source workflow files that triggered this reconcile; each is +// scanned line by line for governed "uses:" refs. These are the only files ever +// read as a ref SOURCE: every file the manifest itself produces is exclusively +// a regenerate TARGET and is never read back, so generation stays a pure +// offline function of the manifest. +type Options struct { + Root string + ManifestPath string + ManifestKey string + ChangedFiles []string +} + +// Run adopts a governed pin bump observed in opts.ChangedFiles into the user +// manifest's action_pins (verbatim, via SetActionPinSurgical) and regenerates +// every workflow the manifest produces so it agrees again. It reports whether +// anything actually changed; a converged tree is a safe no-op, which is the +// loop-termination guarantee a reconcile companion relies on. +func Run(opts Options) (bool, error) { + manifestPath := opts.ManifestPath + if manifestPath == "" { + manifestPath = filepath.Join(opts.Root, ".github", "manifest.yaml") + } + manifestKey := opts.ManifestKey + if manifestKey == "" { + manifestKey = config.DefaultManifestKey + } + + sourceRefs, err := scanChangedFiles(opts.Root, opts.ChangedFiles) + if err != nil { + return false, err + } + + governed, err := governedActions() + if err != nil { + return false, err + } + + adopts, err := PlanAdoptions(Input{Governed: governed, SourceRefs: sourceRefs}) + if err != nil { + return false, err + } + if !adopts.Relevant() { + return false, nil + } + + cfg, err := config.ParseWithKey(manifestPath, manifestKey) + if err != nil { + return false, fmt.Errorf("parsing manifest: %w", err) + } + + changed := false + for action, ref := range adopts.Pins { + if cfg.ActionPins[action] != ref { + changed = true + break + } + } + if !changed { + return false, nil + } + + doc, err := os.ReadFile(manifestPath) //nolint:gosec // caller-provided repo manifest path. + if err != nil { + return false, fmt.Errorf("reading manifest: %w", err) + } + for action, ref := range adopts.Pins { + doc, err = SetActionPinSurgical(doc, action, ref) + if err != nil { + return false, fmt.Errorf("adopting %s: %w", action, err) + } + } + + if err := os.WriteFile(manifestPath, doc, 0o644); err != nil { //nolint:gosec // manifest is not a secret. + return false, fmt.Errorf("writing manifest: %w", err) + } + + if err := regenerate(manifestPath, manifestKey, ""); err != nil { + return false, err + } + + return true, nil +} + +// scanChangedFiles reads every changed file (resolved against root when not +// already absolute) and extracts every governed "uses:" line it carries into a +// source-ref set keyed by action path, one entry per ref observed. +func scanChangedFiles(root string, changedFiles []string) (map[string][]string, error) { + refs := make(map[string][]string) + for _, rel := range changedFiles { + path := rel + if !filepath.IsAbs(path) { + path = filepath.Join(root, rel) + } + content, err := os.ReadFile(path) //nolint:gosec // caller-provided repo-relative path. + if err != nil { + return nil, fmt.Errorf("reading changed file %s: %w", rel, err) + } + for _, line := range strings.Split(string(content), "\n") { + action, ref, ok := generate.ParseUsesLine(line) + if !ok { + continue + } + refs[action] = append(refs[action], ref) + } + } + return refs, nil +} + +// governedActions returns the full set of actions cascade's built-in pin table +// governs (both emit:true and emit:false), the set PlanAdoptions may adopt a +// bump for. +func governedActions() (map[string]bool, error) { + manifest, err := generate.LoadEmbeddedPinManifest() + if err != nil { + return nil, fmt.Errorf("loading embedded pin manifest: %w", err) + } + governed := make(map[string]bool, len(manifest)) + for action := range manifest { + governed[action] = true + } + return governed, nil +} + +// regenerate writes every file the manifest at manifestPath would produce, +// mirroring the generate command's write step. When pinOverridesPath is +// non-empty, the on-disk pin table there overlays cfg.ActionPins first (the +// own-repo mode seam, see RunOwnRepo), so the regenerate reflects a pin change +// a stale compiled-in binary would otherwise miss. +func regenerate(manifestPath, manifestKey, pinOverridesPath string) error { + planned, err := generate.Plan(generate.PlanOptions{ + ConfigPath: manifestPath, + ManifestKey: manifestKey, + PinOverridesPath: pinOverridesPath, + }) + if err != nil { + return fmt.Errorf("planning regenerate: %w", err) + } + + baseDir := generate.ResolveBaseDir(manifestPath) + for _, p := range planned { + path := p.Path + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("creating directory for %s: %w", path, err) + } + if err := os.WriteFile(path, []byte(p.Content), 0o644); err != nil { //nolint:gosec // generated workflow, not a secret. + return fmt.Errorf("writing %s: %w", path, err) + } + } + return nil +} From 22a6833689b4ebbfa99e5886010a77a383273d1b Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:24:07 -0400 Subject: [PATCH 03/10] feat(pinreconcile): make reconcile idempotent on a converged tree Signed-off-by: Joshua Temple --- internal/pinreconcile/command_run_test.go | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/pinreconcile/command_run_test.go b/internal/pinreconcile/command_run_test.go index 64f901e9..3df746c8 100644 --- a/internal/pinreconcile/command_run_test.go +++ b/internal/pinreconcile/command_run_test.go @@ -75,3 +75,31 @@ func TestRun_AdoptsShaBumpAndRegeneratesClean(t *testing.T) { mismatches := generate.ScanUsesForPinDrift("orchestrate.yaml", string(orchestrate), want) require.Empty(t, mismatches, "regenerated orchestrate.yaml must carry the adopted pin cleanly") } + +// TestRun_IdempotentSecondPass proves a converged tree is a safe no-op: running +// Run again against a manifest that already carries the adopted pin reports no +// change and leaves the manifest byte-identical, which is the loop-termination +// guarantee a reconcile companion relies on to avoid committing forever. +func TestRun_IdempotentSecondPass(t *testing.T) { + dir := writeReconcileTestRepo(t) + + changed := "dependabot-bump.yaml" + require.NoError(t, os.WriteFile(filepath.Join(dir, changed), + []byte(" - uses: actions/checkout@"+testNewCheckoutSHA+" # v6.0.0\n"), 0o644)) + + ok, err := Run(Options{Root: dir, ChangedFiles: []string{changed}}) + require.NoError(t, err) + require.True(t, ok, "the first pass must adopt the bump") + + manifestPath := filepath.Join(dir, ".github", "manifest.yaml") + afterFirst, err := os.ReadFile(manifestPath) + require.NoError(t, err) + + ok, err = Run(Options{Root: dir, ChangedFiles: []string{changed}}) + require.NoError(t, err) + require.False(t, ok, "a converged tree must report no change") + + afterSecond, err := os.ReadFile(manifestPath) + require.NoError(t, err) + require.Equal(t, afterFirst, afterSecond, "a no-op pass must not rewrite the manifest") +} From 80b4718b4f8008d58fb06b19098c6d8ffa5ffa45 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:24:45 -0400 Subject: [PATCH 04/10] feat(pinreconcile): emit a data-only relevance artifact in check mode Signed-off-by: Joshua Temple --- internal/pinreconcile/check.go | 39 ++++++++++++++++++++++++++++ internal/pinreconcile/check_test.go | 40 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 internal/pinreconcile/check.go create mode 100644 internal/pinreconcile/check_test.go diff --git a/internal/pinreconcile/check.go b/internal/pinreconcile/check.go new file mode 100644 index 00000000..48e33de4 --- /dev/null +++ b/internal/pinreconcile/check.go @@ -0,0 +1,39 @@ +package pinreconcile + +import ( + "encoding/json" + "fmt" + "os" +) + +// CheckResult is the data-only detector output. The companions read it +// strictly as data (never executed) to decide whether to run and, if so, +// which governed refs changed. It never carries the target PR number: the +// companion derives that only from trusted workflow_run metadata. +type CheckResult struct { + Relevant bool `json:"relevant"` + ChangedRefs map[string]string `json:"changed_refs"` +} + +// Check computes relevance without writing anything, so a fork-safe read-only +// job can run it. It reuses PlanAdoptions, so relevance and the changed set +// are exactly what a subsequent reconcile would adopt. +func Check(in Input) (CheckResult, error) { + adopts, err := PlanAdoptions(in) + if err != nil { + return CheckResult{}, err + } + return CheckResult{Relevant: adopts.Relevant(), ChangedRefs: adopts.Pins}, nil +} + +// WriteCheckArtifact serializes the detector result for the companion to read. +func WriteCheckArtifact(path string, res CheckResult) error { + b, err := json.Marshal(res) + if err != nil { + return fmt.Errorf("marshaling check result: %w", err) + } + if err := os.WriteFile(path, b, 0o600); err != nil { + return fmt.Errorf("writing check artifact %s: %w", path, err) + } + return nil +} diff --git a/internal/pinreconcile/check_test.go b/internal/pinreconcile/check_test.go new file mode 100644 index 00000000..1cc34da6 --- /dev/null +++ b/internal/pinreconcile/check_test.go @@ -0,0 +1,40 @@ +package pinreconcile + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheck_ReportsRelevanceAndRefs(t *testing.T) { + res, err := Check(Input{ + Governed: map[string]bool{"actions/checkout": true}, + SourceRefs: map[string][]string{"actions/checkout": {"v6"}}, + }) + require.NoError(t, err) + require.True(t, res.Relevant) + require.Equal(t, map[string]string{"actions/checkout": "v6"}, res.ChangedRefs) +} + +func TestCheck_NoGovernedChangeIsIrrelevant(t *testing.T) { + res, err := Check(Input{ + Governed: map[string]bool{"actions/checkout": true}, + SourceRefs: map[string][]string{"some/other": {"v1"}}, + }) + require.NoError(t, err) + require.False(t, res.Relevant) +} + +func TestWriteCheckArtifact_RoundTrips(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pin-reconcile-result.json") + require.NoError(t, WriteCheckArtifact(path, CheckResult{Relevant: true, ChangedRefs: map[string]string{"actions/checkout": "v6"}})) + var got CheckResult + b, err := os.ReadFile(path) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(b, &got)) + require.True(t, got.Relevant) +} From 2c416e9b431de1e5689615ee3ac82858d5c8674d Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:28:28 -0400 Subject: [PATCH 05/10] feat(pinreconcile): reconcile cascade's own action_pins manifest Signed-off-by: Joshua Temple --- internal/pinreconcile/ownrepo.go | 174 ++++++++++++++++++++++++++ internal/pinreconcile/ownrepo_test.go | 132 +++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 internal/pinreconcile/ownrepo.go create mode 100644 internal/pinreconcile/ownrepo_test.go diff --git a/internal/pinreconcile/ownrepo.go b/internal/pinreconcile/ownrepo.go new file mode 100644 index 00000000..dd89306c --- /dev/null +++ b/internal/pinreconcile/ownrepo.go @@ -0,0 +1,174 @@ +package pinreconcile + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// pinManifestFile is the on-disk shape of action_pins.yaml: every action +// cascade pins, keyed by action path. It mirrors internal/generate's unexported +// actionPinsManifest so this package can read and rewrite the full action set +// (both emit:true and emit:false) without internal/generate exposing a +// disk-write seam of its own. +type pinManifestFile struct { + Actions map[string]generate.ActionPinEntry `yaml:"actions"` +} + +// OwnRepoOptions configures a RunOwnRepo. ManifestPath defaults to +// "/.github/manifest.yaml". ActionPinsPath names the on-disk +// action_pins.yaml cascade's own repo owns; it has no default because its +// location (internal/generate/action_pins.yaml in cascade's own tree) is not a +// convention any other repo shares. ChangedFiles lists the paths (relative to +// Root, or absolute) of the source files that triggered this reconcile: hand- +// written workflows and composite action definitions alike, since both carry +// governed pins. +type OwnRepoOptions struct { + Root string + ManifestPath string + ManifestKey string + ActionPinsPath string + ChangedFiles []string +} + +// RunOwnRepo adopts a governed pin bump observed in opts.ChangedFiles into +// cascade's own on-disk action_pins.yaml (a full re-marshal, since cascade owns +// that file outright, unlike the surgical user-manifest edit Run performs) and +// regenerates every workflow cascade's own manifest produces so it agrees +// again. It reports whether anything actually changed. +func RunOwnRepo(opts OwnRepoOptions) (bool, error) { + manifestPath := opts.ManifestPath + if manifestPath == "" { + manifestPath = filepath.Join(opts.Root, ".github", "manifest.yaml") + } + manifestKey := opts.ManifestKey + if manifestKey == "" { + manifestKey = config.DefaultManifestKey + } + + sourceRefs, err := scanChangedFiles(opts.Root, opts.ChangedFiles) + if err != nil { + return false, err + } + + diskManifest, err := loadFullPinManifest(opts.ActionPinsPath) + if err != nil { + return false, err + } + governed := make(map[string]bool, len(diskManifest)) + for action := range diskManifest { + governed[action] = true + } + + adopts, err := PlanAdoptions(Input{Governed: governed, SourceRefs: sourceRefs}) + if err != nil { + return false, err + } + if !adopts.Relevant() { + return false, nil + } + + changed := false + for action, ref := range adopts.Pins { + sha, version := splitRef(ref) + entry := diskManifest[action] + if entry.SHA != sha || (version != "" && entry.Version != version) { + changed = true + break + } + } + if !changed { + return false, nil + } + + for action, ref := range adopts.Pins { + sha, version := splitRef(ref) + entry := diskManifest[action] + entry.SHA = sha + if version != "" { + entry.Version = version + } + diskManifest[action] = entry + } + + if err := writeFullPinManifest(opts.ActionPinsPath, diskManifest); err != nil { + return false, err + } + + if err := regenerate(manifestPath, manifestKey, opts.ActionPinsPath); err != nil { + return false, err + } + + return true, nil +} + +// splitRef separates a verbatim adopted ref into its sha (or tag) and its +// trailing "# " comment, if present. A ref with no comment returns an +// empty version so the caller can leave the manifest's existing version field +// untouched rather than blanking it. +func splitRef(ref string) (value, version string) { + if idx := strings.Index(ref, " # "); idx >= 0 { + return ref[:idx], ref[idx+len(" # "):] + } + return ref, "" +} + +// loadFullPinManifest parses an action_pins.yaml from disk into its full +// action set (both emit:true and emit:false), the shape RunOwnRepo needs to +// adopt a bump for any governed action, not just the emit:true subset the +// generator renders into user workflows. +func loadFullPinManifest(path string) (map[string]generate.ActionPinEntry, error) { + data, err := os.ReadFile(path) //nolint:gosec // caller supplies cascade's own repo-relative path. + if err != nil { + return nil, fmt.Errorf("reading action pins %s: %w", path, err) + } + var file pinManifestFile + if err := yaml.Unmarshal(data, &file); err != nil { + return nil, fmt.Errorf("parsing action pins %s: %w", path, err) + } + return file.Actions, nil +} + +// pinManifestHeaderEnd returns the byte offset just past the leading comment +// block of an action_pins.yaml (everything before the top-level "actions:" +// key). A full re-marshal preserves that header verbatim by re-attaching it +// rather than round-tripping it through the YAML encoder, which has no +// concept of a file's leading comment block. +func pinManifestHeaderEnd(data []byte) int { + idx := bytes.Index(data, []byte("\nactions:")) + if idx < 0 { + return 0 + } + return idx + 1 +} + +// writeFullPinManifest re-marshals the full action set to path, re-attaching +// the file's original header comment block so it survives the rewrite. +func writeFullPinManifest(path string, actions map[string]generate.ActionPinEntry) error { + original, err := os.ReadFile(path) //nolint:gosec // caller supplies cascade's own repo-relative path. + if err != nil { + return fmt.Errorf("reading action pins %s: %w", path, err) + } + header := original[:pinManifestHeaderEnd(original)] + + body, err := yaml.Marshal(pinManifestFile{Actions: actions}) + if err != nil { + return fmt.Errorf("marshaling action pins: %w", err) + } + + var out bytes.Buffer + out.Write(header) + out.Write(body) + + if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil { //nolint:gosec // manifest is not a secret. + return fmt.Errorf("writing action pins %s: %w", path, err) + } + return nil +} diff --git a/internal/pinreconcile/ownrepo_test.go b/internal/pinreconcile/ownrepo_test.go new file mode 100644 index 00000000..d4d5e888 --- /dev/null +++ b/internal/pinreconcile/ownrepo_test.go @@ -0,0 +1,132 @@ +package pinreconcile + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stablekernel/cascade/internal/generate" + "github.com/stretchr/testify/require" +) + +const ( + ownRepoHeader = "# SINGLE SOURCE OF TRUTH for every third-party action version cascade pins.\n" + + "#\n" + + "# This header must survive a full re-marshal of the file.\n" + + ownRepoOldCheckoutSHA = "1111111111111111111111111111111111111111" + ownRepoNewCheckoutSHA = "2222222222222222222222222222222222222222" + ownRepoOldUploadSHA = "3333333333333333333333333333333333333333" + ownRepoNewUploadSHA = "4444444444444444444444444444444444444444" +) + +// writeOwnRepoTestRepo lays out a minimal cascade-shaped repo: a disk +// action_pins.yaml (with a header comment block) carrying stale SHAs for +// actions/checkout and actions/upload-artifact, a build workflow stub, and a +// two-environment manifest so regenerate produces both orchestrate.yaml and +// promote.yaml. It returns the repo root and the action_pins.yaml path. +func writeOwnRepoTestRepo(t *testing.T) (dir, pinsPath string) { + t.Helper() + dir = t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "workflows"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "generate"), 0o755)) + + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "workflows", "build.yaml"), + []byte("name: Build\non:\n workflow_call:\n"), 0o644)) + + manifest := "ci:\n" + + " config:\n" + + " trunk_branch: main\n" + + " environments: [dev, prod]\n" + + " pin_mode: sha\n" + + " builds:\n" + + " - name: app\n" + + " workflow: .github/workflows/build.yaml\n" + + " triggers: [\"src/**\"]\n" + + " deploys: []\n" + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".github", "manifest.yaml"), []byte(manifest), 0o644)) + + pinsPath = filepath.Join(dir, "internal", "generate", "action_pins.yaml") + pinsYAML := ownRepoHeader + + "actions:\n" + + " actions/checkout: { tag: v7, sha: " + ownRepoOldCheckoutSHA + ", version: v7.0.0, emit: true }\n" + + " actions/upload-artifact: { tag: v7, sha: " + ownRepoOldUploadSHA + ", version: v7.0.1, emit: true }\n" + require.NoError(t, os.WriteFile(pinsPath, []byte(pinsYAML), 0o644)) + + return dir, pinsPath +} + +// TestRunOwnRepo_AdoptsWorkflowAndCompositeActionBumps proves the own-repo +// mode: a bump landing in a hand-written workflow AND one landing only in a +// composite action are both adopted into the on-disk action_pins.yaml (a full +// re-marshal, since cascade owns that file), its header survives, and every +// regenerated file (not just orchestrate.yaml) agrees with the adopted pins. +func TestRunOwnRepo_AdoptsWorkflowAndCompositeActionBumps(t *testing.T) { + dir, pinsPath := writeOwnRepoTestRepo(t) + + handWritten := filepath.Join(".github", "workflows", "hand-written.yaml") + require.NoError(t, os.WriteFile(filepath.Join(dir, handWritten), + []byte(" - uses: actions/checkout@"+ownRepoNewCheckoutSHA+" # v8.0.0\n"), 0o644)) + + compositeAction := filepath.Join(".github", "actions", "setup-cli", "action.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".github", "actions", "setup-cli"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, compositeAction), + []byte("runs:\n using: composite\n steps:\n - uses: actions/upload-artifact@"+ownRepoNewUploadSHA+" # v8.0.1\n"), 0o644)) + + changed, err := RunOwnRepo(OwnRepoOptions{ + Root: dir, + ActionPinsPath: pinsPath, + ChangedFiles: []string{handWritten, compositeAction}, + }) + require.NoError(t, err) + require.True(t, changed) + + pinsBytes, err := os.ReadFile(pinsPath) + require.NoError(t, err) + pins := string(pinsBytes) + + require.Contains(t, pins, ownRepoNewCheckoutSHA, "the workflow bump must be adopted") + require.Contains(t, pins, ownRepoNewUploadSHA, "the composite-action bump must be adopted, not just the workflow one") + require.Contains(t, pins, "This header must survive a full re-marshal of the file.", + "the header comment block must survive the re-marshal") + + for _, workflow := range []string{"orchestrate.yaml", "promote.yaml"} { + content, err := os.ReadFile(filepath.Join(dir, ".github", "workflows", workflow)) + require.NoError(t, err) + + want := map[string]generate.ActionPinEntry{ + "actions/checkout": {SHA: ownRepoNewCheckoutSHA, Version: "v8.0.0", Emit: true}, + } + mismatches := generate.ScanUsesForPinDrift(workflow, string(content), want) + require.Emptyf(t, mismatches, "%s must carry the adopted pin cleanly", workflow) + } +} + +// TestRunOwnRepo_ConvergedTreeIsNoOp mirrors Run's idempotency guarantee for +// the own-repo mode: re-running against an already-adopted tree changes nothing. +func TestRunOwnRepo_ConvergedTreeIsNoOp(t *testing.T) { + dir, pinsPath := writeOwnRepoTestRepo(t) + + handWritten := filepath.Join(".github", "workflows", "hand-written.yaml") + require.NoError(t, os.WriteFile(filepath.Join(dir, handWritten), + []byte(" - uses: actions/checkout@"+ownRepoNewCheckoutSHA+" # v8.0.0\n"), 0o644)) + + opts := OwnRepoOptions{Root: dir, ActionPinsPath: pinsPath, ChangedFiles: []string{handWritten}} + + changed, err := RunOwnRepo(opts) + require.NoError(t, err) + require.True(t, changed) + + before, err := os.ReadFile(pinsPath) + require.NoError(t, err) + + changed, err = RunOwnRepo(opts) + require.NoError(t, err) + require.False(t, changed, "a converged tree must report no change") + + after, err := os.ReadFile(pinsPath) + require.NoError(t, err) + require.Equal(t, before, after, "a no-op pass must not rewrite action_pins.yaml") +} From 3316a570dd28c60c80bba042cdc8c79963f58b85 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:30:33 -0400 Subject: [PATCH 06/10] feat(cli): add the reconcile command Signed-off-by: Joshua Temple --- cmd/cascade/main.go | 2 + internal/pinreconcile/command.go | 104 ++++++++++++++++++++++++++ internal/pinreconcile/command_test.go | 44 +++++++++++ 3 files changed, 150 insertions(+) create mode 100644 internal/pinreconcile/command.go create mode 100644 internal/pinreconcile/command_test.go diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 2a052564..0bbd6f45 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -20,6 +20,7 @@ import ( initcmd "github.com/stablekernel/cascade/internal/initcmd" "github.com/stablekernel/cascade/internal/log" "github.com/stablekernel/cascade/internal/orchestrate" + "github.com/stablekernel/cascade/internal/pinreconcile" "github.com/stablekernel/cascade/internal/plan" "github.com/stablekernel/cascade/internal/promote" "github.com/stablekernel/cascade/internal/release" @@ -87,6 +88,7 @@ change detection, and changelog generation.`, rootCmd.AddCommand(hotfix.NewCommand()) rootCmd.AddCommand(initcmd.NewCommand()) rootCmd.AddCommand(orchestrate.NewCommand()) + rootCmd.AddCommand(pinreconcile.NewCommand()) rootCmd.AddCommand(promote.NewCommand()) rootCmd.AddCommand(release.NewCommand()) rootCmd.AddCommand(reset.NewCommand()) diff --git a/internal/pinreconcile/command.go b/internal/pinreconcile/command.go new file mode 100644 index 00000000..f3639885 --- /dev/null +++ b/internal/pinreconcile/command.go @@ -0,0 +1,104 @@ +package pinreconcile + +import ( + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" +) + +// defaultCheckArtifactPath is where --check writes its data-only relevance +// artifact when --check-output is not set. +const defaultCheckArtifactPath = "pin-reconcile-result.json" + +// NewCommand creates the "reconcile" command: adopting an external governed +// action-pin change (for example a Dependabot bump landing in a generated +// workflow) back into the manifest's action_pins, then regenerating so every +// workflow the manifest produces agrees with it again. reconcile never +// pushes, commits, or merges; that stays a caller's job (a CI companion or a +// human). +func NewCommand() *cobra.Command { + var configPath string + var manifestKey string + var actionPinsPath string + var checkOutput string + var root string + var check bool + var ownRepo bool + var changedFiles []string + + cmd := &cobra.Command{ + Use: "reconcile", + Short: "Adopt a governed action-pin change back into the manifest", + Long: `reconcile adopts an external action-pin change (for example a Dependabot +bump landing in a generated workflow) back into the manifest's action_pins and +regenerates every workflow the manifest produces, so cascade's owned output +agrees with it again. It reads the changed source files passed via +--changed-file and the manifest; it never reads a pin back out of a generated +file, and it never pushes, commits, or merges. + +Three modes: + (default) Reconcile a user repo's manifest. + --check Read-only detector: reports relevance and writes a data-only + JSON artifact, writing nothing else. + --own-repo Reconcile cascade's own action_pins.yaml manifest.`, + RunE: func(_ *cobra.Command, _ []string) error { + key := manifestKey + if key == "" { + key = config.DefaultManifestKey + } + + if check { + return runCheck(root, checkOutput, changedFiles) + } + if ownRepo { + _, err := RunOwnRepo(OwnRepoOptions{ + Root: root, + ManifestPath: configPath, + ManifestKey: key, + ActionPinsPath: actionPinsPath, + ChangedFiles: changedFiles, + }) + return err + } + + _, err := Run(Options{ + Root: root, + ManifestPath: configPath, + ManifestKey: key, + ChangedFiles: changedFiles, + }) + return err + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "", "Path to the manifest file (default: /.github/manifest.yaml)") + cmd.Flags().StringVar(&manifestKey, "manifest-key", config.DefaultManifestKey, "Key in the manifest file containing the CI config") + cmd.Flags().StringVar(&root, "root", ".", "Repository root the reconcile scans and writes relative to") + cmd.Flags().BoolVar(&check, "check", false, "Read-only detector mode: report relevance and write a JSON artifact") + cmd.Flags().BoolVar(&ownRepo, "own-repo", false, "Reconcile cascade's own action_pins.yaml manifest") + cmd.Flags().StringVar(&actionPinsPath, "action-pins", "", "Path to action_pins.yaml (own-repo mode)") + cmd.Flags().StringVar(&checkOutput, "check-output", defaultCheckArtifactPath, "Path to write the check-mode JSON artifact") + cmd.Flags().StringSliceVar(&changedFiles, "changed-file", nil, "A changed source file to scan for a governed pin bump (repeatable)") + + cmd.MarkFlagsMutuallyExclusive("check", "own-repo") + + return cmd +} + +// runCheck computes relevance for the changed files against the governed set +// and writes the data-only artifact companions read. +func runCheck(root, output string, changedFiles []string) error { + sourceRefs, err := scanChangedFiles(root, changedFiles) + if err != nil { + return err + } + governed, err := governedActions() + if err != nil { + return err + } + res, err := Check(Input{Governed: governed, SourceRefs: sourceRefs}) + if err != nil { + return err + } + return WriteCheckArtifact(output, res) +} diff --git a/internal/pinreconcile/command_test.go b/internal/pinreconcile/command_test.go new file mode 100644 index 00000000..9960c440 --- /dev/null +++ b/internal/pinreconcile/command_test.go @@ -0,0 +1,44 @@ +package pinreconcile + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCommand(t *testing.T) { + cmd := NewCommand() + + assert.Equal(t, "reconcile", cmd.Use) + + checkFlag := cmd.Flags().Lookup("check") + require.NotNil(t, checkFlag) + assert.Equal(t, "bool", checkFlag.Value.Type()) + assert.Equal(t, "false", checkFlag.DefValue) + + ownRepoFlag := cmd.Flags().Lookup("own-repo") + require.NotNil(t, ownRepoFlag) + assert.Equal(t, "bool", ownRepoFlag.Value.Type()) + assert.Equal(t, "false", ownRepoFlag.DefValue) + + rootFlag := cmd.Flags().Lookup("root") + require.NotNil(t, rootFlag) + + configFlag := cmd.Flags().Lookup("config") + require.NotNil(t, configFlag) +} + +// TestNewCommand_RejectsCheckAndOwnRepoTogether asserts --check (a read-only +// detector) and --own-repo (a write mode) cannot be combined: cobra's +// mutually-exclusive flag annotation must reject the pair before RunE runs. +func TestNewCommand_RejectsCheckAndOwnRepoTogether(t *testing.T) { + cmd := NewCommand() + cmd.SetArgs([]string{"--check", "--own-repo"}) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "own-repo") +} From c400b4fd33b415813729818bc69ee0373059c1f9 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:52:13 -0400 Subject: [PATCH 07/10] test(e2e): prove a governed pin bump survives reconcile and regenerate Adds a reconcile step to the multi-step harness DSL and a scenario that stages a real two-environment pipeline, mutates the generated orchestrate.yaml's checkout pin in place to simulate an external bump, runs cascade reconcile, and requires both that the regenerated file carries the adopted pin and that a subsequent cascade verify stays clean. Running the scenario under Docker surfaced a real bug: Run and RunOwnRepo resolved the default manifest path relative to Root, while verify's auto-detect always resolves through the working directory to an absolute path. Since generate.Plan embeds that path verbatim in the generated header's "Regenerate with" comment, the two commands wrote different header text for the same file, so a reconcile-then-verify cycle reported spurious drift on that line alone. Both now resolve the default manifest path the same way, through a shared helper. Signed-off-by: Joshua Temple --- e2e/harness/multistep.go | 29 ++++++++ e2e/harness/runner.go | 111 +++++++++++++++++++++++++++++++ e2e/pin_reconcile_test.go | 82 +++++++++++++++++++++++ internal/pinreconcile/ownrepo.go | 6 +- internal/pinreconcile/run.go | 28 ++++++-- 5 files changed, 247 insertions(+), 9 deletions(-) create mode 100644 e2e/pin_reconcile_test.go diff --git a/e2e/harness/multistep.go b/e2e/harness/multistep.go index a6eb92a8..a86b4c10 100644 --- a/e2e/harness/multistep.go +++ b/e2e/harness/multistep.go @@ -99,6 +99,12 @@ type Step struct { // orphan env/* branches on the Gitea remote, then asserts the JSON report and // the resulting remote branch set. Consistency *ConsistencyStep `yaml:"consistency,omitempty"` + // Reconcile configures a "reconcile" action: a `cascade reconcile` run + // against a generated workflow file that was mutated in place to simulate an + // external governed-pin bump (the shape of a merged Dependabot update) + // landing in cascade-owned output. It asserts the adopted pin lands in the + // regenerated file and that a subsequent `cascade verify` stays clean. + Reconcile *ReconcileStep `yaml:"reconcile,omitempty"` // ExpectFailure marks a step whose workflow is expected to conclude in // failure (for example an orchestrate run whose build exits non-zero). When // set, a failure conclusion is the success path and a success conclusion is @@ -267,6 +273,29 @@ type PlanStep struct { ExpectNotContains []string `yaml:"expect_not_contains,omitempty"` } +// ReconcileStep defines a "reconcile" action: `cascade reconcile` run against +// the synced repo to prove a governed pin bump landing in an already-generated +// workflow file (simulating an external change such as a merged Dependabot +// bump) is adopted into the manifest's action_pins and survives a regenerate. +// MutatePath is the generated file to bump before reconcile runs; MutateFind +// and MutateReplace are a sed pattern and its replacement (delimited by "|", +// so neither may contain that character) substituted into MutatePath to +// simulate the bump landing there. ChangedFile is the path reconcile scans as +// the bump's source and defaults to MutatePath when empty. ExpectExit is the +// exit code `cascade reconcile` must return (0 by default). ExpectContains, when +// set, are substrings the regenerated MutatePath must contain afterward. The +// step always finishes with a `cascade verify` run that must exit clean, +// proving the adopted pin survives regeneration rather than drifting back out +// of it. +type ReconcileStep struct { + MutatePath string `yaml:"mutate_path"` + MutateFind string `yaml:"mutate_find"` + MutateReplace string `yaml:"mutate_replace"` + ChangedFile string `yaml:"changed_file,omitempty"` + ExpectExit int `yaml:"expect_exit"` + ExpectContains []string `yaml:"expect_contains,omitempty"` +} + // ConsistencyStep defines a "consistency" action: a `cascade status consistency` // run against the synced repo whose origin is the Gitea remote. SeedBranches are // created on the remote before the run so the command observes them as remote diff --git a/e2e/harness/runner.go b/e2e/harness/runner.go index 976a142b..fb9f60d2 100644 --- a/e2e/harness/runner.go +++ b/e2e/harness/runner.go @@ -143,6 +143,16 @@ func (r *Runner) ValidateScenario(scenario *MultiStepScenario) error { if step.Consistency == nil { return fmt.Errorf("step %d (%s): consistency action requires consistency config", i, step.Name) } + case "reconcile": + if step.Reconcile == nil { + return fmt.Errorf("step %d (%s): reconcile action requires reconcile config", i, step.Name) + } + if step.Reconcile.MutatePath == "" { + return fmt.Errorf("step %d (%s): reconcile requires mutate_path", i, step.Name) + } + if step.Reconcile.MutateFind == "" { + return fmt.Errorf("step %d (%s): reconcile requires mutate_find", i, step.Name) + } default: return fmt.Errorf("step %d (%s): unknown action %q", i, step.Name, step.Action) } @@ -383,6 +393,8 @@ func (r *Runner) executeStep(ctx context.Context, step *Step, config Config) err return r.executePlan(ctx, step.Plan) case "consistency": return r.executeConsistency(ctx, step.Consistency) + case "reconcile": + return r.executeReconcile(ctx, step.Reconcile) default: return fmt.Errorf("unknown action: %s", step.Action) } @@ -558,6 +570,105 @@ func (r *Runner) executePlan(ctx context.Context, step *PlanStep) error { return nil } +// executeReconcile runs `cascade reconcile` in the synced repo to prove a +// governed pin bump landing in one already-generated workflow file (simulating +// an external change such as a merged Dependabot bump) is adopted into the +// manifest and survives regeneration. It first substitutes step.MutateFind for +// step.MutateReplace in step.MutatePath (a sed pattern, not a literal match), +// simulating the bump landing in that file; it then runs `cascade reconcile +// --changed-file ` and asserts its exit code, +// then asserts the regenerated MutatePath contains every ExpectContains +// substring, and finally runs `cascade verify` and requires a clean exit, +// proving the adopted pin survives regeneration rather than drifting back out +// of it. +func (r *Runner) executeReconcile(ctx context.Context, step *ReconcileStep) error { + if r.harness == nil || r.harness.act == nil { + r.t.Logf(" Would run cascade reconcile (no harness)") + return nil + } + + if err := r.harness.SyncRepoToActContainer(ctx); err != nil { + return fmt.Errorf("reconcile: failed to sync repo: %w", err) + } + + mutateCmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && sed -i %s %s", + shellQuote(fmt.Sprintf("s|%s|%s|", step.MutateFind, step.MutateReplace)), + shellQuote(step.MutatePath), + )} + exitCode, reader, err := r.harness.act.Container().Exec(ctx, mutateCmd) + if err != nil { + return fmt.Errorf("reconcile: mutate exec failed: %w", err) + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("reconcile: mutate failed (exit %d): %s", exitCode, out.String()) + } + + changedFile := step.ChangedFile + if changedFile == "" { + changedFile = step.MutatePath + } + + reconcileCmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && /usr/local/bin/cascade reconcile --changed-file %s", + shellQuote(changedFile), + )} + exitCode, reader, err = r.harness.act.Container().Exec(ctx, reconcileCmd) + if err != nil { + return fmt.Errorf("reconcile: exec failed: %w", err) + } + out.Reset() + if reader != nil { + _, _ = io.Copy(&out, reader) + } + output := out.String() + r.t.Logf(" Reconcile: exit=%d (expected %d): %s", exitCode, step.ExpectExit, output) + if exitCode != step.ExpectExit { + return fmt.Errorf("reconcile: expected exit %d, got %d: %s", step.ExpectExit, exitCode, output) + } + + if len(step.ExpectContains) > 0 { + catCmd := []string{"bash", "-c", "cd /tmp/repo && cat " + shellQuote(step.MutatePath)} + catExit, catReader, catErr := r.harness.act.Container().Exec(ctx, catCmd) + if catErr != nil { + return fmt.Errorf("reconcile: reading regenerated %s failed: %w", step.MutatePath, catErr) + } + var catOut bytes.Buffer + if catReader != nil { + _, _ = io.Copy(&catOut, catReader) + } + if catExit != 0 { + return fmt.Errorf("reconcile: cat %s failed (exit %d): %s", step.MutatePath, catExit, catOut.String()) + } + content := catOut.String() + for _, want := range step.ExpectContains { + if !strings.Contains(content, want) { + return fmt.Errorf("reconcile: regenerated %s missing expected substring %q:\n%s", step.MutatePath, want, content) + } + } + } + + verifyCmd := []string{"bash", "-c", "cd /tmp/repo && /usr/local/bin/cascade verify"} + verifyExit, verifyReader, verifyErr := r.harness.act.Container().Exec(ctx, verifyCmd) + if verifyErr != nil { + return fmt.Errorf("reconcile: verify exec failed: %w", verifyErr) + } + var verifyOut bytes.Buffer + if verifyReader != nil { + _, _ = io.Copy(&verifyOut, verifyReader) + } + r.t.Logf(" Verify after reconcile: exit=%d: %s", verifyExit, verifyOut.String()) + if verifyExit != 0 { + return fmt.Errorf("reconcile: cascade verify was not clean after regenerate (exit %d): %s", verifyExit, verifyOut.String()) + } + + return nil +} + // shellQuote wraps a string in single quotes for safe interpolation into a // bash -c command, escaping embedded single quotes. func shellQuote(s string) string { diff --git a/e2e/pin_reconcile_test.go b/e2e/pin_reconcile_test.go new file mode 100644 index 00000000..64218775 --- /dev/null +++ b/e2e/pin_reconcile_test.go @@ -0,0 +1,82 @@ +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/e2e/harness" + "github.com/stablekernel/cascade/internal/config" +) + +// bumpedCheckoutRef is the synthetic sha/version pair the scenario substitutes +// for the real compiled-in checkout pin, simulating an external governed-pin +// bump (the shape of a merged Dependabot update) landing in the generated +// orchestrate.yaml. It is obviously fake so a false-positive match against the +// real pin table is impossible. +const bumpedCheckoutRef = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee # v99.99.99" + +// TestReconcileAdoptsBumpAndSurvivesRegen proves cascade reconcile's +// user-facing contract end to end: an external governed-pin bump landing in an +// already-generated workflow is adopted into the manifest's action_pins and +// the resulting regenerate carries that same pin, so the bump SURVIVES +// regeneration rather than drifting back out on the next generate. The +// scenario stages a real two-environment pipeline, mutates the generated +// orchestrate.yaml's checkout pin in place (simulating the bump), runs +// `cascade reconcile`, and requires both that the regenerated file carries the +// adopted pin and that a subsequent `cascade verify` stays clean; any +// divergence between the adopted manifest and the regenerated workflow would +// fail verify, so a clean verify is the proof the adoption is real rather than +// cosmetic. +func TestReconcileAdoptsBumpAndSurvivesRegen(t *testing.T) { + if testing.Short() { + t.Skip("skipping E2E tests") + } + requireShardOwns(t) + + cfg := harness.Config{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + PinMode: config.PinModeSHA, + Builds: []config.BuildConfig{ + { + Name: "build", + Workflow: ".github/workflows/build.yaml", + Triggers: []string{"src/**"}, + }, + }, + Deploys: []config.DeployConfig{ + { + Name: "deploy", + Workflow: ".github/workflows/deploy.yaml", + }, + }, + } + + scenario := &harness.MultiStepScenario{ + Name: "Reconcile Adopts A Governed Pin Bump And Survives Regen", + Description: "an external checkout pin bump landing in orchestrate.yaml is adopted by cascade reconcile and survives a regenerate", + Config: cfg, + Steps: []harness.Step{ + { + Name: "Reconcile the bumped checkout pin", + Action: "reconcile", + Reconcile: &harness.ReconcileStep{ + MutatePath: ".github/workflows/orchestrate.yaml", + MutateFind: "actions/checkout@.*", + MutateReplace: "actions/checkout@" + bumpedCheckoutRef, + ExpectExit: 0, + ExpectContains: []string{"actions/checkout@" + bumpedCheckoutRef}, + }, + }, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + err := harness.RunMultiStepScenario(ctx, t, scenario) + require.NoError(t, err, "reconcile adoption scenario failed") +} diff --git a/internal/pinreconcile/ownrepo.go b/internal/pinreconcile/ownrepo.go index dd89306c..12e190f7 100644 --- a/internal/pinreconcile/ownrepo.go +++ b/internal/pinreconcile/ownrepo.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "os" - "path/filepath" "strings" "gopkg.in/yaml.v3" @@ -44,10 +43,7 @@ type OwnRepoOptions struct { // regenerates every workflow cascade's own manifest produces so it agrees // again. It reports whether anything actually changed. func RunOwnRepo(opts OwnRepoOptions) (bool, error) { - manifestPath := opts.ManifestPath - if manifestPath == "" { - manifestPath = filepath.Join(opts.Root, ".github", "manifest.yaml") - } + manifestPath := resolveManifestPath(opts.Root, opts.ManifestPath) manifestKey := opts.ManifestKey if manifestKey == "" { manifestKey = config.DefaultManifestKey diff --git a/internal/pinreconcile/run.go b/internal/pinreconcile/run.go index baf0a2d2..733c381f 100644 --- a/internal/pinreconcile/run.go +++ b/internal/pinreconcile/run.go @@ -31,10 +31,7 @@ type Options struct { // anything actually changed; a converged tree is a safe no-op, which is the // loop-termination guarantee a reconcile companion relies on. func Run(opts Options) (bool, error) { - manifestPath := opts.ManifestPath - if manifestPath == "" { - manifestPath = filepath.Join(opts.Root, ".github", "manifest.yaml") - } + manifestPath := resolveManifestPath(opts.Root, opts.ManifestPath) manifestKey := opts.ManifestKey if manifestKey == "" { manifestKey = config.DefaultManifestKey @@ -96,6 +93,29 @@ func Run(opts Options) (bool, error) { return true, nil } +// resolveManifestPath returns the manifest path Run and RunOwnRepo read and +// write, and the value threaded through to generate.Plan as its ConfigPath. A +// caller-supplied manifestPath (an explicit --config) is used verbatim. When +// it is unset, the default resolves to an absolute path anchored at the +// process working directory (matching root when root is relative), rather +// than a root-relative join. generate.Plan embeds this exact string verbatim +// into the generated header's "Regenerate with: cascade generate-workflow +// --config " comment, and config.FindConfigFile (the auto-detect path +// verify and generate-workflow fall back to with no --config flag) always +// resolves through os.Getwd(), so a relative default here would make that +// header comment differ from what a subsequent verify recomputes: byte-for- +// byte the same file, reported as spurious drift. +func resolveManifestPath(root, manifestPath string) string { + if manifestPath != "" { + return manifestPath + } + absRoot, err := filepath.Abs(root) + if err != nil { + absRoot = root + } + return filepath.Join(absRoot, ".github", "manifest.yaml") +} + // scanChangedFiles reads every changed file (resolved against root when not // already absolute) and extracts every governed "uses:" line it carries into a // source-ref set keyed by action path, one entry per ref observed. From 4f5eb6be393ba31383bb92af76c00742634fc23a Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:52:18 -0400 Subject: [PATCH 08/10] docs(cli-reference): document the reconcile command Adds a reconcile section alongside verify: the three modes (default user-repo reconcile, --check read-only detector, --own-repo for cascade's own repo), the real flags, what it reads and writes, and that it never pushes, commits, or merges. Signed-off-by: Joshua Temple --- docs/src/content/docs/cli-reference.md | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 2b73e461..ba519a93 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -267,6 +267,39 @@ cascade verify Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and `generate-workflow` emits the drift-check workflow for you. See [Drift-check workflow](/configuration/#drift-check-workflow-opt-in). +### reconcile + +Adopt an external governed action-pin change (for example a Dependabot bump landing in a generated workflow) back into the manifest's `action_pins`, then regenerate every workflow the manifest produces so cascade's owned output agrees with it again. `reconcile` never pushes, commits, or merges; wiring a CI job (or running it by hand) to drive it, and to commit and push its result, stays the caller's job. + +```bash +cascade reconcile --changed-file .github/workflows/orchestrate.yaml +``` + +`reconcile` reads the files named by `--changed-file` (repeatable) as data, scanning each line by line for a governed `uses:` reference, plus the manifest itself. It never reads a pin back out of a file the manifest generates: every generated file is exclusively a regenerate target, so a run that touches nothing relevant is a safe no-op and generation stays a pure offline function of the manifest. When a changed file carries a bump for an action cascade governs, `reconcile` writes that ref verbatim into the manifest's `action_pins`, keyed by action path, and regenerates. See [Action pinning](/configuration/#action-pinning) for what that write looks like under `pin_mode: tag` and `pin_mode: sha`. + +`reconcile` has three modes, selected by flag: + +| Mode | Flag | Behavior | +|------|------|----------| +| Default | (none) | Reconciles a user repo's manifest: adopts the bump into `action_pins` and regenerates. | +| Detector | `--check` | Read-only: reports whether a governed pin changed and writes a data-only JSON artifact (`--check-output`) naming the changed refs; writes nothing else. | +| Own-repo | `--own-repo` | Reconciles cascade's own `action_pins.yaml` manifest (a full re-marshal, since cascade owns that file) rather than a user manifest, and regenerates. | + +`--check` and `--own-repo` are mutually exclusive: the detector is read-only, and own-repo mode is a second write target, so combining them is rejected. + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config`, `-c` | string | `/.github/manifest.yaml` | Path to the manifest file | +| `--manifest-key` | string | `ci` | Top-level key inside the manifest | +| `--root` | string | `.` | Repository root `reconcile` scans and writes relative to | +| `--changed-file` | string (repeatable) | - | A changed source file to scan for a governed pin bump | +| `--check` | bool | false | Read-only detector mode: report relevance and write a JSON artifact | +| `--check-output` | string | `pin-reconcile-result.json` | Path to write the check-mode JSON artifact | +| `--own-repo` | bool | false | Reconcile cascade's own `action_pins.yaml` manifest | +| `--action-pins` | string | - | Path to `action_pins.yaml` (own-repo mode) | + ### plan Preview, as a per-file unified diff, what `generate-workflow` would change in the committed workflow and action files, without writing anything. `plan` is read-only: it never writes files, runs git, or modifies the repository. From 669ba58e3dc929b442cbec02f039b7ab67ef78b1 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:52:22 -0400 Subject: [PATCH 09/10] docs(configuration): note action_pins as the reconcile write target Extends the action_pins table row and the Action pinning prose to name action_pins as the storage target cascade reconcile writes an adopted external pin bump into, verbatim, keyed by action path, under both pin_mode: tag and pin_mode: sha. Notes that a sha adoption's trailing "# " comment is part of a YAML-quoted scalar value so it survives being re-parsed, and that the generator still emits it correctly. Signed-off-by: Joshua Temple --- docs/src/content/docs/configuration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 36963cf2..6d4935f0 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -87,7 +87,7 @@ ci: | `triggers` | list | No | - | Global path patterns that activate orchestration | | `release_trigger` | string | No | `push` | How the orchestrate workflow fires. `push` keeps the push-on-trunk plus `workflow_dispatch` triggers; `dispatch` drops the `push:` trigger so releases run only on manual `workflow_dispatch`. See [Release trigger](#release-trigger). | | `pin_mode` | string | No | `tag` | Third-party action pin policy. `tag` emits `@`; `sha` emits `@` with the version as a trailing comment. See [Action pinning](#action-pinning). | -| `action_pins` | map | No | - | Per-action ref overrides keyed by action path (e.g. `actions/checkout`), applied regardless of `pin_mode`. See [Action pinning](#action-pinning). | +| `action_pins` | map | No | - | Per-action ref overrides keyed by action path (e.g. `actions/checkout`), applied regardless of `pin_mode`. This is also the storage target `cascade reconcile` writes an adopted external pin bump into (see [reconcile](/cli-reference/#reconcile)). See [Action pinning](#action-pinning). | | `tag_prefix` | string | No | `v` | Version tag prefix | | `release_token` | string | No | `state_token` if set, else `${{ secrets.GITHUB_TOKEN }}` | Token expression for release API calls and the rc tag; inherits `state_token` when unset so the rc-to-release chain has a trigger-capable token | | `state_token` | string | No | `${{ secrets.GITHUB_TOKEN }}` | Token expression for writing manifest state to the trunk branch | @@ -173,6 +173,10 @@ ci: That emits `uses: actions/checkout@0123456789abcdef0123456789abcdef01234567`. An action that is neither in the built-in table nor overridden is emitted unchanged. +#### `action_pins` is also the reconcile write target + +You do not have to hand-author every `action_pins` entry yourself. The [`cascade reconcile`](/cli-reference/#reconcile) command writes here too: when it adopts an external governed-pin change (for example a Dependabot bump landing in a generated workflow), it sets that action's `action_pins` entry to the incoming ref verbatim, keyed by action path, exactly as if you had written the override by hand. Under `pin_mode: tag` the adopted value is a bare tag (for example `v6`); under `pin_mode: sha` it is the commit sha with its trailing `# ` comment (for example `abc123def4567890abc123def4567890abc12345 # v6.0.1`). That whole string, comment included, is stored as a single YAML-quoted scalar, not a bare value followed by a real YAML comment, so it survives being re-parsed on the next reconcile or regenerate; the generator still emits it correctly as `actions/checkout@abc123def4567890abc123def4567890abc12345 # v6.0.1` in the generated workflow, identical to a hand-written sha override. + #### Overriding a pin switches its update channel Setting `action_pins` for an action switches that action's update channel. Before the override, the action tracks cascade's own curated pin table (`internal/generate/action_pins.yaml`), which cascade updates as it ships new releases. Once you set an override, that action's future updates come from wherever you or your tooling point the override, not from cascade's table anymore. The override is the only state cascade keeps for that action: there is no separate record of when or why it was set, and no path back to the curated default other than removing the override yourself. From 8fbda7058858478c40fdcb55f9749a524756b204 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Sun, 5 Jul 2026 21:52:27 -0400 Subject: [PATCH 10/10] docs(contributing): codify the governed-pin input-validation standard Adds a Governed action pins standard: pins are single-source in the manifest, a spliced pin value must be charset-validated and never carry a newline, path-shaped manifest fields must reject .. traversal, a machine-authored commit stages an explicit pathspec allowlist rather than git add -A, and generated files are targets never sources so generation stays a pure offline function of the manifest. Signed-off-by: Joshua Temple --- CONTRIBUTING.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c016d10c..87e49b31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,16 @@ golangci-lint run ./... Public APIs follow a functional-options style: required inputs are positional and optional or extensible behavior arrives as a variadic `...Option` tail, so new capability is additive and never a breaking signature change. Cross-cutting concerns are small interfaces with no-op defaults rather than forced dependencies. +## Governed action pins + +cascade owns the third-party action pins it emits into generated workflows, and that ownership rests on a few rules that any code touching pins, manifest paths, or machine-authored commits must keep: + +- Governed action pins are single-source: the manifest's `action_pins` (or, for cascade's own repo, `action_pins.yaml`) is the one place a pin value lives. A generated workflow is a rendering of that source, never a second copy to reconcile against. +- A pin value gets spliced verbatim into generated YAML, so it must be charset-validated before it is accepted and must never carry a newline. Validate at the point a pin value enters the manifest, not at render time. +- Path-shaped manifest fields, such as `action_folder` and callback workflow paths, must reject a `..` path segment during validation, so a configured path can only resolve inside the repository tree it is meant to. +- A machine-authored commit (a bot or CI job writing on the project's behalf) stages an explicit pathspec allowlist naming exactly the files it intends to change. It never uses a blanket `git add -A` or `git add .`, so an unrelated working-tree change can never ride along. +- Generated files are targets, never sources: a pin (or any other value) is read from the manifest and written into generated output, never read back out of a generated file. This keeps generation a pure, offline function of the manifest, which is what makes a regenerate reproducible and a diff meaningful. + ## Reporting bugs Open an issue with the manifest config, the generated workflow (if relevant), and what you expected versus what happened. A minimal reproduction helps a lot.