Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions cmd/cascade/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
33 changes: 33 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | `<root>/.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.
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<action>@<major-tag>`; `sha` emits `<action>@<commit-sha>` 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 |
Expand Down Expand Up @@ -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 `# <version>` 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.
Expand Down
29 changes: 29 additions & 0 deletions e2e/harness/multistep.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions e2e/harness/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 <step.ChangedFile or MutatePath>` 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 {
Expand Down
82 changes: 82 additions & 0 deletions e2e/pin_reconcile_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading