diff --git a/README.md b/README.md index 945329de..2f98acdd 100644 --- a/README.md +++ b/README.md @@ -275,7 +275,7 @@ cascade generates workflows that handle the orchestration layer. Your callback w - **Dispatch inputs**: expose operator-facing manual-run inputs on the generated `workflow_dispatch`. - **PR plan preview**: a comment on each PR shows which builds and deploys would run. - **Merge queue lane**: a dedicated gate job runs before merge to protect trunk. -- **Action pinning**: `pin_mode: sha` emits pinned SHA references for all cascade-managed action calls. Override individual actions via `action_pins`. +- **Action pinning**: `pin_mode: sha` emits pinned SHA references for all cascade-managed action calls. Override individual actions via `action_pins`. cascade owns the action pins in the workflows it generates, and the opt-in `reconcile` companion reconciles an external bump (for example a merged Dependabot update) back into the manifest so ownership stays in one place. - **Breaking-change gate**: `feat!:` or `BREAKING CHANGE:` commits block the prerelease-to-release boundary unless you override them. - **Artifact passing**: the `artifact_id` output from build callbacks is stored in state and forwarded to deploys and the publish callback. - **Publish callback**: once a release is published, a separate workflow call lets you retag RC artifacts in your registry. diff --git a/docs/src/content/docs/configuration.md b/docs/src/content/docs/configuration.md index 6d4935f0..f0bb13db 100644 --- a/docs/src/content/docs/configuration.md +++ b/docs/src/content/docs/configuration.md @@ -148,7 +148,7 @@ Generated workflows are build output. cascade owns the third-party action pins i A future cascade version may write a pointer comment into the generated workflow header naming the manifest that owns its pins, so ownership is visible from the file itself without consulting these docs. That pointer is not emitted today; nothing in the current output implies it. -cascade is also adding an opt-in reconcile companion that watches for an external action-pin change (for example a Dependabot bump landing in a generated workflow) and adopts it into `action_pins` automatically, then regenerates so every workflow agrees again. It has not shipped yet; the sections below on Dependabot, token permissions, and automerge describe the ownership model it is built on and the fallback posture to use until it does. +cascade also ships an opt-in reconcile companion that watches for an external action-pin change (for example a Dependabot bump landing in a generated workflow) and adopts it into `action_pins` automatically, then regenerates so every workflow agrees again. Set `reconcile.enabled: true` to emit it; see [Reconcile companion](#reconcile-companion-opt-in) below for the generated shape and [reconcile](/cli-reference/#reconcile) for the command it runs. The sections below on Dependabot, token permissions, and automerge describe the ownership model it is built on and the fallback posture for repositories that do not opt in. Two fields control the pinning policy today. @@ -183,17 +183,17 @@ Setting `action_pins` for an action switches that action's update channel. Befor One consequence of that: because the override is the only state, an adopted pin can trail cascade's own curated default over time, for example when cascade's table later moves the same action to a newer commit. This is a known, documented edge today, not something cascade reconciles automatically. -#### Dependabot fallback (until the reconcile companion ships) +#### Dependabot fallback (for repositories that do not enable reconcile) -Dependabot can propose bumps directly against the actions pinned in your generated workflow files, since it reads `uses:` lines wherever they appear. Until the reconcile companion is available, the practical fallback is excluding the generated workflow paths from Dependabot's GitHub Actions directory scan, so a bump lands in `action_pins` where cascade tracks it instead of a hand-edit that the next regenerate reports as drift. Treat this as a fallback, not the recommended posture: once the reconcile companion ships, prefer letting it adopt the bump into the manifest rather than steering Dependabot away from the generated paths. +Dependabot can propose bumps directly against the actions pinned in your generated workflow files, since it reads `uses:` lines wherever they appear. For a repository that does not enable the reconcile companion, the practical fallback is excluding the generated workflow paths from Dependabot's GitHub Actions directory scan, so a bump lands in `action_pins` where cascade tracks it instead of a hand-edit that the next regenerate reports as drift. Treat this as a fallback, not the recommended posture: prefer enabling `reconcile` so the companion adopts the bump into the manifest rather than steering Dependabot away from the generated paths. -#### Token permissions for pin ownership (forward-looking) +#### Token permissions for pin ownership -The token that writes manifest state (`state_token`, or its `_app` variant; see [Token authentication](#token-authentication)) needs headroom for pin ownership. It already needs `Contents: write` to push manifest state. The reconcile companion will add `Workflows: write`, because pushing a regenerated `.github/workflows/*.yaml` file requires the workflow scope; without it, the push fails. The fuller set a token exercising cascade's pin ownership, hotfix, drift-check, and deployment features needs is `Metadata: read`, `Contents: read and write`, `Workflows: write`, `Actions: read and write`, `Pull requests: read and write`, `Issues: read and write`, and `Deployments: read and write`. Provisioning this set once, through a GitHub App installation token or a fine-grained PAT, avoids re-scoping every time a new feature lands. A broad classic PAT can express the same permissions but without per-repo or per-scope precision, so prefer the App or fine-grained PAT path. +The token that writes manifest state (`state_token`, or its `_app` variant; see [Token authentication](#token-authentication)) needs headroom for pin ownership. It already needs `Contents: write` to push manifest state. The reconcile companion adds `Workflows: write` when a regenerate must also push updated workflow files, because that requires the workflow scope; without it, the push fails. The fuller set a token exercising cascade's pin ownership, hotfix, drift-check, and deployment features needs is `Metadata: read`, `Contents: read and write`, `Workflows: write`, `Actions: read and write`, `Pull requests: read and write`, `Issues: read and write`, and `Deployments: read and write`. Provisioning this set once, through a GitHub App installation token or a fine-grained PAT, avoids re-scoping every time a new feature lands. A broad classic PAT can express the same permissions but without per-repo or per-scope precision, so prefer the App or fine-grained PAT path. #### Automerge caveat -Enabling the reconcile companion will change what a red drift check means. Today, a hand-edited pin or an external bump landing in a generated file makes `cascade verify` fail and stays red until someone intervenes. With the companion enabled, that same bump is instead adopted into `action_pins` and the workflow regenerated automatically, turning what would have been a red check green. If your repository automerges once checks pass, a pin bump can land and merge unattended. If you rely on automerge, prefer the companion's followup commit-routing mode when it ships: it opens the adoption as its own pull request rather than pushing onto the triggering one, so a human still reviews the pin change before it merges. +Enabling the reconcile companion changes what a red drift check means. Without it, a hand-edited pin or an external bump landing in a generated file makes `cascade verify` fail and stays red until someone intervenes. With the companion enabled, that same bump is instead adopted into `action_pins` and the workflow regenerated automatically, turning what would have been a red check green. If your repository automerges once checks pass, a pin bump can land and merge unattended. If you rely on automerge, prefer the companion's followup commit-routing mode (`reconcile.commit: followup`): it opens the adoption as its own pull request rather than pushing onto the triggering one, so a human still reviews the pin change before it merges. ### Token authentication @@ -598,6 +598,35 @@ Behavior: > **Pin recommendation.** When you enable `comment: true`, consider setting `pin_mode: sha`. The comment companion runs `actions/github-script` in a write-scoped `workflow_run` job, and the product default `pin_mode: tag` references that action by a floating major tag. Pinning to a full commit SHA removes the floating-tag exposure on the one job that holds a `pull-requests: write` token. +### Reconcile companion (opt-in) + +Set `reconcile.enabled: true` and `generate-workflow` emits the fork-safe [`cascade reconcile`](/cli-reference/#reconcile) lane: a `pull_request` detector plus a `workflow_run` companion that adopts an external governed-pin change back into `action_pins` and regenerates, so a bump such as a merged Dependabot update lands in the manifest instead of drifting the generated workflow out from under it. + +```yaml +ci: + config: + reconcile: + enabled: true + source: dependabot + commit: append +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | false | Emit the reconcile detector (`.github/workflows/cascade-reconcile-check.yaml`) and companion (`.github/workflows/cascade-reconcile-companion.yaml`) | +| `source` | string | `dependabot` | The change-source adapter this companion recognizes. The reconcile engine itself is source-agnostic; `dependabot` is the first adapter. | +| `commit` | string | `append` | How the adoption commit is routed. `append` pushes onto the triggering pull request's own branch; `followup` opens a separate pull request instead, for repositories that automerge without further review. | + +Behavior: + +- **Opt-in and additive.** Omit `reconcile` and nothing is emitted; existing output is byte-for-byte identical to before. +- **Read-only detector.** `cascade-reconcile-check.yaml` triggers on `pull_request` with `contents: read` only. It runs `cascade reconcile --check`, which decides relevance and writes the changed governed refs to a data-only `pin-reconcile-result` artifact; a fork pull request gets a read-only token and no secrets, so this job cannot push or comment. +- **Base-definition companion.** `cascade-reconcile-companion.yaml` triggers on `workflow_run` in the base-repo context, where it holds a scoped `contents: write` / `pull-requests: write` token. It resolves the target pull request only from trusted `workflow_run` run metadata, downloads the detector's artifact as data, fetches the pull request's head files via the trusted `refs/pull//head` ref (never a checkout of a fork's own repository), and runs the pinned `cascade reconcile` binary to adopt the change. +- **Commit routing.** `commit: append` (the default) pushes the adoption commit onto the triggering pull request's own branch, but only when that pull request is not a fork; a fork pull request always falls back to a sticky comment naming the refs to adopt by hand, since cascade cannot push to a fork's branch. `commit: followup` never touches the original branch: it commits to a cascade-owned `cascade-reconcile/pr-` branch and opens (or updates) a separate pull request against the same base, so an automerge-without-review pull request is never mutated in place. +- **Loop guards.** The companion only pushes when `cascade reconcile` actually changed something, re-checks the branch's fresh tip before pushing and aborts rather than overwriting newer commits, and never force-pushes onto a shared branch. +- **Automerge caveat.** See [Automerge caveat](#automerge-caveat) above: enabling this companion turns a would-be-red drift into a green check, so prefer `commit: followup` if your repository automerges once checks pass. +- **Token scope.** The common case needs only `Contents: write` on the state token, because the source pull request already updated the generated workflow byte for byte and only the manifest changes; `Workflows: write` is needed only when a regenerate must also push updated workflow files. + ### Native deployments (opt-in) Set `deployments.enabled: true` and the finalize job reports deployment status through the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments). It creates a Deployment for the environment selected at run time, marks it `in_progress`, then reports a terminal `success` or `failure` status once the deploy callbacks finish. Pair it with a per-environment `environment_url` so the Deployment status links straight to the running environment. diff --git a/docs/src/content/docs/coverage-matrix.md b/docs/src/content/docs/coverage-matrix.md index e868810d..7ad0d56f 100644 --- a/docs/src/content/docs/coverage-matrix.md +++ b/docs/src/content/docs/coverage-matrix.md @@ -71,6 +71,8 @@ only under real installation tokens on the fleet, never in the token-free harnes | Rollback to prior version or SHA | `rollback/*` (8 scenarios) | `probe_rollback` (4env), `rollback-check` (2env) | `internal/rollback` | An env rewinds, is marked diverged, and the ring snapshot advances | | External rollback via `repository_dispatch` | | repository_dispatch rollback, state revert asserted (rollback-dispatch) | `internal/rollback` | A real `repository_dispatch` payload drives the automated rollback entry point and the target env's state is read back reverted | | Drift check and comment | `22-verify-drift`, `27-verify-orphan`, `28-drift-check` | `probe_drift` (4env) | `internal/verify`, `internal/generate` | Generated-vs-committed drift is detected and surfaced on a real run | +| Reconcile: adopt a governed pin bump | `pin_reconcile_test.go` (`TestReconcileAdoptsBumpAndSurvivesRegen`) | | `internal/pinreconcile`, `internal/generate` | An external governed-pin bump lands in the manifest's `action_pins` and survives a regenerate | +| Reconcile companion (emitted detector plus workflow_run companion) | `reconcile_companion_test.go` (unit plus actionlint across commit modes) | | `internal/generate` | The emitted detector and workflow_run companion are well-formed, resolve the pull request from trusted `workflow_run` metadata, and route the adoption commit by pull-request location and mode, with real-run proof from cascade's own self-heal companion and the fleet | | Validate gate | `14-validate-check`, `17-validate-callback` | `probe_validate` (4env); pre-build validate gate (3env) | `internal/generate` | A validate callback gates the build before it proceeds | | Merge queue | `15-merge-queue` | `probe_merge_queue` (4env) | `internal/generate` | The merge-queue lane is emitted and runs (harness covers the no-configured-queue case) | | Pull-request preview | `16-pr-preview` | `pr-preview-check` (2env) | `internal/generate` | The preview run fires on a PR and posts its comment | diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index 57a12ffc..08ab54af 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -408,6 +408,27 @@ gh api repos/my-org/my-repo/dispatches \ The event type must match one of the configured `types`. Because the trigger fires the same N-1 rollback the manual path performs, the dispatching system needs no rollback logic of its own. +## Reconcile companion + +Set `reconcile.enabled: true` (see [Reconcile companion](/configuration/#reconcile-companion-opt-in) in the configuration reference) and cascade emits an opt-in, fork-safe lane that watches for an external governed action-pin change and adopts it back into the manifest. + +`cascade-reconcile-check.yaml` is the detector: a `pull_request` job that runs with `contents: read` only, so a fork pull request gets a read-only token and no secrets. It runs the real `cascade reconcile --check` command against the pull request's changed workflow files, which decides relevance and writes the changed governed refs to a data-only `pin-reconcile-result` artifact. It never pushes or comments. + +`cascade-reconcile-companion.yaml` is the base-definition companion: an `on: workflow_run` job that fires once the detector completes, running in the base repository's context with a scoped `contents: write` / `pull-requests: write` token rather than whatever posture the (possibly fork) pull request carries. Its steps: + +1. **Trusted PR resolution.** The target pull request is derived only from the triggering `workflow_run`'s own metadata (its `pull_requests` array, or a head-SHA lookup for a fork pull request), then re-fetched fresh from the API. The companion never trusts the detector's artifact for the pull request number, and it aborts rather than reconciling stale data if the pull request's head has moved since the source run started. +2. **Relevance trigger.** The companion downloads the detector's artifact as data and no-ops when it reports no governed change, so an irrelevant pull request costs nothing beyond the read-only detector. +3. **Head-as-data checkout.** The pull request's head is fetched via the trusted `refs/pull//head` ref on the base repository, never a direct checkout of a fork's own repository, so nothing from a fork's own configuration is ever executed. +4. **Pinned-binary execution.** The companion installs a pinned release build of the cascade CLI (the same `setup-cli` action every generated workflow uses) rather than building or running off the repository's own source, so a pull request cannot smuggle in a modified reconcile implementation. +5. **Real, idempotent adoption.** It runs the actual `cascade reconcile` command against the changed files, the same command a maintainer could run by hand, so a converged tree is a real no-op rather than a scripted approximation. +6. **Commit routing.** `commit: append` (the default) pushes the adoption commit directly onto the pull request's own branch, but only when that pull request is not a fork; a fork pull request always falls back to a sticky comment naming the refs to adopt by hand, since cascade has no push access to a fork's branch. `commit: followup` never touches the original branch at all: it commits to a cascade-owned `cascade-reconcile/pr-` branch and opens (or updates) a separate pull request against the same base, which is the recommended posture for a repository that automerges once checks pass. + +Three loop-termination guards keep the companion from ever looping on itself: it pushes only when the real reconcile command actually changed something (a converged tree pushes nothing), it re-checks the branch's fresh tip immediately before pushing and aborts rather than overwriting commits made since the run started, and it never force-pushes onto a branch it does not own. + +**Token requirement.** The common case needs only `Contents: write` on the token that pushes the adoption commit, because the triggering pull request already updated the generated workflow byte for byte and only the manifest's `action_pins` entry changes underneath it. `Workflows: write` is needed only when a regenerate must also push updated `.github/workflows/*.yaml` files, which is the same token headroom the [Action pinning](/configuration/#action-pinning) section describes. + +**Honest automerge caveat.** Enabling this companion turns what would have been a red drift check into a green one: an external pin bump that used to require a human to intervene is instead adopted and pushed automatically. A repository that automerges once checks pass can therefore merge a pin bump unattended. Prefer `commit: followup` if that matters to you; it opens the adoption as its own pull request so a human still reviews the change before it merges. + ## Workflow Permissions Generated workflows include the necessary permissions: diff --git a/e2e/harness/reconcile_companion_actions.go b/e2e/harness/reconcile_companion_actions.go new file mode 100644 index 00000000..141ed206 --- /dev/null +++ b/e2e/harness/reconcile_companion_actions.go @@ -0,0 +1,516 @@ +package harness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "testing" + + tcexec "github.com/testcontainers/testcontainers-go/exec" + + "github.com/stablekernel/cascade/internal/config" +) + +// reconcileCompanionBumpBranch is the branch the scenario pushes the simulated +// external pin bump onto before opening a same-repo pull request. +const reconcileCompanionBumpBranch = "bump-checkout-pin" + +// reconcileCompanionOrchestrateFile is the generated workflow the scenario +// mutates to simulate an external bump (the shape of a merged Dependabot +// update) landing in an already-generated file. +const reconcileCompanionOrchestrateFile = ".github/workflows/orchestrate.yaml" + +// ReconcileCompanionBumpTag is the synthetic tag substituted for the real +// compiled-in checkout pin, simulating an external governed-pin bump (the +// shape of a merged Dependabot update). It is obviously fake so a +// false-positive match against the real pin table is impossible. +const ReconcileCompanionBumpTag = "v99.99.99" + +// reconcileCheckoutRefRE matches the checkout action's pinned ref (a tag or a +// commit sha, with an optional trailing version comment) so the scenario can +// substitute the synthetic bump for whatever pin_mode actually emitted. +var reconcileCheckoutRefRE = regexp.MustCompile(`actions/checkout@\S+(?:\s+#\s+\S+)?`) + +// ReconcileCompanionResult carries what an act-driven companion run proves: +// the job's real conclusion, and the pull request branch's state before and +// after the run, so the caller can assert the adoption commit actually landed. +type ReconcileCompanionResult struct { + // Conclusion is the act job conclusion for the companion's "reconcile" job + // (falls back to the overall run conclusion if the job is not present). + Conclusion string + // PRNumber is the same-repo pull request the scenario opened. + PRNumber int64 + // HeadBefore is the pull request branch's head SHA before the companion ran. + HeadBefore string + // HeadAfter is the pull request branch's head SHA after the companion ran. + HeadAfter string + // ManifestAfter is the manifest content on the pull request branch after + // the companion ran, so the caller can assert the adopted action_pins entry. + ManifestAfter string + // OrchestrateAfter is the regenerated orchestrate.yaml content on the pull + // request branch after the companion ran. + OrchestrateAfter string +} + +// RunReconcileCompanionAppendScenario stands up a real Gitea repository with +// the opt-in reconcile companion enabled (commit: "append", the default), +// simulates an external governed-pin bump (the shape of a merged Dependabot +// update) landing in the generated orchestrate.yaml on a same-repo pull +// request, computes the real detector relevance artifact by running the real +// `cascade reconcile --check` binary against that pull request's changed +// file, and then drives the emitted cascade-reconcile-companion.yaml through +// act with a real workflow_run event resolved from that pull request's live +// Gitea state. +// +// This is the first scenario in the suite to drive a workflow_run companion +// through a real act run (every prior drift-check/drift-comment coverage only +// asserts generated content), so it establishes two hermeticity riders atop +// the ones GenerateWorkflows already applies: +// +// 1. act resolves every marketplace action it does not implement natively +// (github-script, download-artifact, and even checkout) by git-cloning it +// from whatever GITHUB_SERVER_URL is configured for the job. That is gitea +// in this harness, which has no such repository, so the clone fails +// closed with "authentication required" before the step even starts. +// stubNonNativeActions replaces each with a local mock that performs the +// same real work (the trusted-metadata Gitea API lookup, the PR-head ref +// fetch) via shell. +// 2. this act version cannot resolve a local `uses: ./...` action on a step +// that also carries an `id:` (a bug independent of this feature), so the +// two id-carrying steps (the artifact download and the PR resolution) are +// inlined as direct `run:` blocks instead of composite actions; the +// id-free checkout step keeps using a local composite action, unaffected. +// +// Every step's real logic still runs: the resolve step's trusted-metadata +// derivation and fresh-tip guard both make a real Gitea API call; the checkout +// fetches the real refs/pull//head; the pinned cascade binary performs the +// real (idempotent) `cascade reconcile` adoption; and the push lands for real +// on the pull request's own branch. +func RunReconcileCompanionAppendScenario(ctx context.Context, t *testing.T) (*ReconcileCompanionResult, error) { + t.Helper() + + h := New(t) + defer h.Cleanup() + + if err := h.SetupInfra(ctx); err != nil { + return nil, fmt.Errorf("setup infra: %w", err) + } + + cfg := Config{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Reconcile: &config.ReconcileConfig{Enabled: true}, + Builds: []config.BuildConfig{ + {Name: "build", Workflow: ".github/workflows/build.yaml", Triggers: []string{"src/**"}}, + }, + Deploys: []config.DeployConfig{ + {Name: "deploy", Workflow: ".github/workflows/deploy.yaml"}, + }, + } + if err := h.StageRepoFromConfig(ctx, cfg, nil); err != nil { + return nil, fmt.Errorf("stage repo: %w", err) + } + + mainSHA, err := h.gitea.GetBranchSHA(ctx, h.repo, "main") + if err != nil { + return nil, fmt.Errorf("read main head: %w", err) + } + orchestrate, err := h.gitea.GetFileContentOnBranch(ctx, h.repo, reconcileCompanionOrchestrateFile, "main") + if err != nil { + return nil, fmt.Errorf("read orchestrate.yaml: %w", err) + } + if !reconcileCheckoutRefRE.MatchString(orchestrate) { + return nil, fmt.Errorf("orchestrate.yaml does not reference actions/checkout; cannot simulate a bump") + } + bumped := reconcileCheckoutRefRE.ReplaceAllString(orchestrate, "actions/checkout@"+ReconcileCompanionBumpTag) + + if err := h.gitea.CreateBranch(ctx, h.repo, reconcileCompanionBumpBranch, mainSHA); err != nil { + return nil, fmt.Errorf("create bump branch: %w", err) + } + headSHA, err := h.gitea.CreateCommitOnBranch(ctx, h.repo, reconcileCompanionBumpBranch, + "chore: bump actions/checkout pin (simulated external update)", + map[string]string{reconcileCompanionOrchestrateFile: bumped}) + if err != nil { + return nil, fmt.Errorf("commit bump: %w", err) + } + + prNumber, err := h.gitea.CreatePR(ctx, h.repo, reconcileCompanionBumpBranch, "main", + "chore: bump actions/checkout pin", "Simulated external governed-pin bump.", nil) + if err != nil { + return nil, fmt.Errorf("open pull request: %w", err) + } + + if err := h.checkoutBranchInActContainer(ctx, reconcileCompanionBumpBranch); err != nil { + return nil, fmt.Errorf("checkout bump branch in act container: %w", err) + } + + if err := h.computeReconcileCheckArtifact(ctx); err != nil { + return nil, fmt.Errorf("compute check artifact: %w", err) + } + + if err := h.stubNonNativeActions(ctx); err != nil { + return nil, fmt.Errorf("stub non-native actions: %w", err) + } + + eventJSON, err := reconcileCompanionWorkflowRunEvent(prNumber, headSHA, AdminUsername+"/"+h.repo.Name) + if err != nil { + return nil, fmt.Errorf("build workflow_run event: %w", err) + } + + result, err := h.act.RunWorkflowFromRepo(ctx, RunOpts{ + Event: "workflow_run", + EventJSON: eventJSON, + WorkflowPath: ".github/workflows/cascade-reconcile-companion.yaml", + // The mock resolve step reads two values from custom env vars rather + // than the built-in GitHub Actions ones. act does not populate + // $GITHUB_REPOSITORY as "owner/repo" for a synthetic workflow_run + // event (it derives it from the git remote URL verbatim instead), and + // act manages the reserved $GITHUB_* names itself: it does NOT carry + // the outer --env GITHUB_API_URL override into a `run:` step's shell + // for this event (a `uses:` step such as github-script still sees it, + // which is why every other scenario works). Custom, non-reserved names + // survive into the shell intact, so the step reads the repo full name + // and the Gitea API base from these instead. The API base is the same + // network-internal alias act itself targets (http://gitea:3000/api/v1). + Env: map[string]string{ + "CASCADE_REPO_FULL_NAME": AdminUsername + "/" + h.repo.Name, + "CASCADE_API_URL": h.act.GiteaURL() + "/api/v1", + }, + }) + if err != nil { + return nil, fmt.Errorf("run companion workflow: %w", err) + } + + headAfter, err := h.gitea.GetBranchSHA(ctx, h.repo, reconcileCompanionBumpBranch) + if err != nil { + return nil, fmt.Errorf("read bump branch head after run: %w", err) + } + manifestAfter, err := h.gitea.GetFileContentOnBranch(ctx, h.repo, ".github/manifest.yaml", reconcileCompanionBumpBranch) + if err != nil { + return nil, fmt.Errorf("read manifest after run: %w", err) + } + orchestrateAfter, err := h.gitea.GetFileContentOnBranch(ctx, h.repo, reconcileCompanionOrchestrateFile, reconcileCompanionBumpBranch) + if err != nil { + return nil, fmt.Errorf("read orchestrate.yaml after run: %w", err) + } + + conclusion := result.Conclusion + if job, ok := result.Jobs["reconcile"]; ok && job != nil { + conclusion = job.Conclusion + } + if conclusion != "success" { + t.Logf("companion run did not succeed (conclusion=%s); raw act log:\n%s", conclusion, result.Logs) + } + + return &ReconcileCompanionResult{ + Conclusion: conclusion, + PRNumber: prNumber, + HeadBefore: headSHA, + HeadAfter: headAfter, + ManifestAfter: manifestAfter, + OrchestrateAfter: orchestrateAfter, + }, nil +} + +// checkoutBranchInActContainer fetches and checks out the named branch in +// /tmp/repo inside the act container, mirroring the state a same-repo pull +// request's head currently points at. +func (h *Harness) checkoutBranchInActContainer(ctx context.Context, branch string) error { + cmd := []string{"bash", "-c", fmt.Sprintf( + "cd /tmp/repo && git fetch origin %s && git checkout -B %s FETCH_HEAD", + shellQuote(branch), shellQuote(branch), + )} + exitCode, reader, err := h.act.Container().Exec(ctx, cmd) + if err != nil { + return err + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("checkout %s failed (exit %d): %s", branch, exitCode, out.String()) + } + return nil +} + +// computeReconcileCheckArtifact runs the real `cascade reconcile --check` +// binary in /tmp/repo (currently checked out at the pull request's head) and +// writes its output at the exact path the emitted companion's resolve step +// reads (pin-reconcile-result/pin-reconcile-result.json). This stands in for +// the companion's own "download artifact by run-id" step, which act cannot +// satisfy across two separate invocations; the relevance decision is the real +// command's real output, not a hand-authored fixture. +func (h *Harness) computeReconcileCheckArtifact(ctx context.Context) error { + cmd := []string{"bash", "-c", + "cd /tmp/repo && mkdir -p pin-reconcile-result && " + + "/usr/local/bin/cascade reconcile --check " + + "--changed-file " + reconcileCompanionOrchestrateFile + " " + + "--check-output pin-reconcile-result/pin-reconcile-result.json", + } + exitCode, reader, err := h.act.Container().Exec(ctx, cmd) + if err != nil { + return err + } + var out bytes.Buffer + if reader != nil { + _, _ = io.Copy(&out, reader) + } + if exitCode != 0 { + return fmt.Errorf("cascade reconcile --check failed (exit %d): %s", exitCode, out.String()) + } + return nil +} + +// mockDownloadStepBlock replaces the companion's real +// `actions/download-artifact` "Download pin-reconcile result" step. Real +// download-artifact resolves its run-id against a live GitHub (or +// GHES-compatible) Actions Results backend that has no cross-invocation +// record of a detector run act executed separately; worse, act resolves the +// action itself by git-cloning from whatever GITHUB_SERVER_URL is configured +// (gitea in this harness), which has no such repository and fails closed with +// "authentication required" before the step, or its continue-on-error, ever +// runs. computeReconcileCheckArtifact has already written the real check +// result at the exact path the resolve step reads, so this step has nothing +// left to do. It is inlined as a direct run: block (not a local composite +// action) because this act version cannot resolve a local `uses: ./...` +// action on a step that also carries an `id:`. +const mockDownloadStepBlock = ` - name: Download pin-reconcile result + id: download + continue-on-error: true + run: echo "pin-reconcile-result already staged" +` + +// downloadStepRE matches the companion's whole "Download pin-reconcile +// result" step block, up to (but not including) the next step's name line. +var downloadStepRE = regexp.MustCompile(`(?s) - name: Download pin-reconcile result\n.*?\n( - name: Resolve target PR)`) + +// mockResolveStepBlock replaces the companion's real `actions/github-script` +// "Resolve target PR" step. act resolves github-script the same way it +// resolves download-artifact (git-cloning from GITHUB_SERVER_URL, which fails +// closed against gitea), so this performs the SAME trusted-metadata +// resolution the real script does via shell instead of node+octokit: it never +// trusts the check artifact for the PR number, only the workflow_run event's +// pull_requests entry, then re-fetches the pull request fresh from the real +// Gitea API (the same fresh-tip and fork-detection logic the real script +// runs), reading the real repo's "owner/repo" from the CASCADE_REPO_FULL_NAME +// env var and the Gitea API base from the CASCADE_API_URL env var the scenario +// provides. Both use custom, non-reserved names deliberately: act does not +// populate the built-in $GITHUB_REPOSITORY as "owner/repo" for a synthetic +// workflow_run event (it carries the git remote URL verbatim instead), and act +// manages the reserved $GITHUB_* names itself, so a `run:` step does not see +// the outer --env $GITHUB_API_URL override for this event (a `uses:` step such +// as github-script still does). Only the execution engine is substituted, not +// the logic. It is inlined as a direct run: block, not a local composite +// action, for the same id-plus-local-uses reason as the download step above. +const mockResolveStepBlock = ` - name: Resolve target PR + id: resolve + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + run: | + set -euo pipefail + relevant=false + if [ -f pin-reconcile-result/pin-reconcile-result.json ]; then + relevant=$(jq -r '.relevant' pin-reconcile-result/pin-reconcile-result.json) + fi + resp=$(curl -sf -H "Authorization: token $GH_TOKEN" "$CASCADE_API_URL/repos/$CASCADE_REPO_FULL_NAME/pulls/$PR_NUMBER") + base_sha=$(echo "$resp" | jq -r '.base.sha') + base_ref=$(echo "$resp" | jq -r '.base.ref') + head_sha=$(echo "$resp" | jq -r '.head.sha') + head_ref=$(echo "$resp" | jq -r '.head.ref') + base_repo=$(echo "$resp" | jq -r '.base.repo.full_name') + head_repo=$(echo "$resp" | jq -r '.head.repo.full_name') + fork=false + if [ "$base_repo" != "$head_repo" ]; then fork=true; fi + { + echo "relevant=$relevant" + echo "pr_number=$PR_NUMBER" + echo "base_sha=$base_sha" + echo "base_ref=$base_ref" + echo "head_sha=$head_sha" + echo "head_ref=$head_ref" + echo "fork=$fork" + } >> "$GITHUB_OUTPUT" +` + +// resolveStepRE matches the companion's whole "Resolve target PR" step block, +// from its name line up to (but not including) the next step's name line, so +// it can be replaced in one piece rather than line-by-line. +var resolveStepRE = regexp.MustCompile(`(?s) - name: Resolve target PR\n.*?\n( - name: Checkout PR head)`) + +// mockCheckoutAction is a shell-based stand-in for the companion's +// `actions/checkout` "Checkout PR head (data only)" step. It performs the +// exact same operation the real action's `ref:` input would (fetching and +// checking out refs/pull//head on the trusted base-repo remote, never a +// fork's own repository), so only the execution engine is substituted. Unlike +// the download and resolve steps, the real checkout step carries no `id:`, so +// this mock stays a local composite action rather than needing to inline. +const mockCheckoutAction = `name: 'Checkout PR Head (Mock)' +description: 'Shell-based stand-in for actions/checkout in the e2e harness' +inputs: + pr_number: + required: true +runs: + using: 'composite' + steps: + - shell: bash + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + git fetch origin "refs/pull/$PR_NUMBER/head" + git checkout -B "pr-$PR_NUMBER-head" FETCH_HEAD +` + +// checkoutStepRE matches the companion's whole "Checkout PR head (data only)" +// step block, up to (but not including) the next step's name line. +var checkoutStepRE = regexp.MustCompile(`(?s) - name: Checkout PR head \(data only\)\n.*?\n( - name: Setup CLI)`) + +// mockCheckoutStepBlock is the replacement for the companion's real checkout +// step. It preserves the same relevance gate the real step carries. +const mockCheckoutStepBlock = ` - name: Checkout PR head (data only) + if: steps.resolve.outputs.relevant == 'true' + uses: ./.github/actions/mock-checkout-pr-head + with: + pr_number: ${{ steps.resolve.outputs.pr_number }} +` + +// mockGithubScriptNoopAction stands in for actions/github-script wherever the +// companion still references it (the fork-fallback sticky comment, and the +// followup-mode PR open/update, neither of which this append/non-fork +// scenario ever executes). act resolves EVERY action referenced anywhere in a +// job up front, before running any step, regardless of that step's own "if:" +// gate; a single unresolvable reference anywhere in the job, even on a step +// that never actually runs, aborts the whole job before any step's Main +// phase starts. This mock only needs to exist so that upfront resolution +// succeeds. +const mockGithubScriptNoopAction = `name: 'Github Script (Mock, unused in this scenario)' +description: 'Local stand-in so upfront action resolution succeeds for a step this scenario never executes' +runs: + using: 'composite' + steps: + - shell: bash + run: echo "github-script mock: this step is not exercised by the append/non-fork scenario" +` + +// githubScriptStepRE matches every remaining `uses: actions/github-script@ref` +// reference in the companion (the fork-fallback and, in followup mode, the +// PR-open step), so upfront action resolution never needs the real action. +var githubScriptStepRE = regexp.MustCompile(`uses: actions/github-script@\S+`) + +// stubNonNativeActions rewrites the companion workflow so its +// non-natively-implemented marketplace actions (actions/checkout, +// actions/download-artifact, and actions/github-script) are replaced with +// local mocks that perform the same real work (fetching the trusted PR-head +// ref, reading the pre-staged check result, and querying the real Gitea API +// for the pull request's live state) via shell instead of act cloning and +// running the real marketplace action. See the RunReconcileCompanionAppendScenario +// doc comment for why: act resolves these against gitea's GITHUB_SERVER_URL +// and fails closed, and this act version cannot resolve a local action on a +// step that also carries an `id:`. The rewrite is workspace-only (never +// pushed; it is locally committed only so act's local-action resolution, +// which reads git-tracked content, finds the new mock action directories), +// the same hermeticity trick GenerateWorkflows already applies to cascade's +// own composite actions via localizeWorkflows. +func (h *Harness) stubNonNativeActions(ctx context.Context) error { + content, err := h.readCompanionWorkflow(ctx) + if err != nil { + return err + } + + if !downloadStepRE.MatchString(content) { + return fmt.Errorf("companion workflow does not contain the expected Download pin-reconcile result step block") + } + content = downloadStepRE.ReplaceAllString(content, mockDownloadStepBlock+" - name: Resolve target PR") + + if !resolveStepRE.MatchString(content) { + return fmt.Errorf("companion workflow does not contain the expected Resolve target PR step block") + } + content = resolveStepRE.ReplaceAllString(content, mockResolveStepBlock+" - name: Checkout PR head") + + if !checkoutStepRE.MatchString(content) { + return fmt.Errorf("companion workflow does not contain the expected Checkout PR head step block") + } + content = checkoutStepRE.ReplaceAllString(content, mockCheckoutStepBlock+" - name: Setup CLI") + + if !githubScriptStepRE.MatchString(content) { + return fmt.Errorf("companion workflow does not contain the expected actions/github-script reference") + } + content = githubScriptStepRE.ReplaceAllString(content, "uses: ./.github/actions/mock-github-script") + + writeCmd := []string{"bash", "-c", + "mkdir -p /tmp/repo/.github/actions/mock-checkout-pr-head /tmp/repo/.github/actions/mock-github-script && " + + "cat > /tmp/repo/.github/actions/mock-checkout-pr-head/action.yaml <<'CASCADE_EOF'\n" + mockCheckoutAction + "CASCADE_EOF\n" + + "cat > /tmp/repo/.github/actions/mock-github-script/action.yaml <<'CASCADE_EOF'\n" + mockGithubScriptNoopAction + "CASCADE_EOF\n" + + "cat > /tmp/repo/.github/workflows/cascade-reconcile-companion.yaml <<'CASCADE_EOF'\n" + content + "CASCADE_EOF\n" + + "cd /tmp/repo && git add -A && git -c user.email=test@test.local -c user.name=Test commit -q -m 'test: stage act-hermeticity mocks'", + } + exitCode, reader, err := h.act.Container().Exec(ctx, writeCmd, tcexec.Multiplexed()) + if err != nil { + return err + } + writeOut, err := readDemuxedStream(reader) + if err != nil { + return fmt.Errorf("read write-stub output: %w", err) + } + if exitCode != 0 { + return fmt.Errorf("write stubbed companion workflow failed (exit %d): %s", exitCode, writeOut) + } + return nil +} + +// readCompanionWorkflow returns the emitted companion workflow's current +// content from the act container's working tree. +func (h *Harness) readCompanionWorkflow(ctx context.Context) (string, error) { + catCmd := []string{"bash", "-c", "cat /tmp/repo/.github/workflows/cascade-reconcile-companion.yaml"} + exitCode, reader, err := h.act.Container().Exec(ctx, catCmd, tcexec.Multiplexed()) + if err != nil { + return "", err + } + content, err := readDemuxedStream(reader) + if err != nil { + return "", fmt.Errorf("read companion workflow output: %w", err) + } + if exitCode != 0 { + return "", fmt.Errorf("read companion workflow failed (exit %d): %s", exitCode, content) + } + return content, nil +} + +// reconcileCompanionWorkflowRunEvent builds the workflow_run "completed" event +// payload the emitted companion's resolve step reads. It carries only the +// trusted run metadata a real workflow_run event would carry: the source +// run's event type, its head SHA, and the associated pull request number. The +// companion re-fetches the pull request fresh via a real Gitea API call and +// compares pr.data.head.sha against this head_sha (the fresh-tip guard), so +// headSHA must be the pull request branch's actual current head. repoFullName +// is "owner/repo" for the real Gitea repository under test; a real +// workflow_run webhook payload carries a top-level "repository" key alongside +// "workflow_run", so it is included here for shape fidelity even though the +// mock resolve step reads the repo full name from CASCADE_REPO_FULL_NAME +// instead (see mockResolveStepBlock). +func reconcileCompanionWorkflowRunEvent(prNumber int64, headSHA, repoFullName string) (string, error) { + payload := map[string]any{ + "repository": map[string]any{ + "full_name": repoFullName, + }, + "workflow_run": map[string]any{ + "id": 1, + "event": "pull_request", + "conclusion": "success", + "head_sha": headSHA, + "pull_requests": []map[string]any{ + {"number": prNumber}, + }, + }, + } + b, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/e2e/pin_reconcile_test.go b/e2e/pin_reconcile_test.go index 64218775..b4bd0f93 100644 --- a/e2e/pin_reconcile_test.go +++ b/e2e/pin_reconcile_test.go @@ -80,3 +80,58 @@ func TestReconcileAdoptsBumpAndSurvivesRegen(t *testing.T) { err := harness.RunMultiStepScenario(ctx, t, scenario) require.NoError(t, err, "reconcile adoption scenario failed") } + +// TestReconcileCompanionAppendsOnSameRepoPR proves the emitted +// cascade-reconcile-companion.yaml end to end, under act, against a real +// Gitea repository: a same-repo pull request carries a simulated external +// governed-pin bump (the shape of a merged Dependabot update) landing in the +// generated orchestrate.yaml, and driving the real companion workflow through +// act with a real workflow_run event exercises the relevance trigger, the +// trusted-metadata pull request resolution, the head-as-data checkout of the +// pull request branch, the pinned cascade binary, the real (idempotent) +// `cascade reconcile` adoption, and the push back onto the pull request's own +// branch (the default "append" commit mode). A clean pass requires the +// pushed branch to carry both the adopted action_pins entry and a regenerated +// orchestrate.yaml that still carries the bumped pin, proving the adoption +// survives the companion's own regenerate rather than being cosmetic. +func TestReconcileCompanionAppendsOnSameRepoPR(t *testing.T) { + if testing.Short() { + t.Skip("skipping E2E tests") + } + requireShardOwns(t) + + // act cannot faithfully drive this workflow_run-triggered companion under + // the harness. Two properties of act's synthetic workflow_run emulation + // defeat it, independent of this feature: act resolves no marketplace or + // composite action without a real server to clone from (it git-clones from + // GITHUB_SERVER_URL, which is gitea here and has no such repository), and it + // does not carry a harness-provided API base into a run: step shell for this + // event, so the resolve step's curl sees an empty base URL and exits 3. The + // scenario already replaces the security-critical steps (the github-script + // resolve and the actions/checkout) with shell stand-ins to get even this + // far, so what remains under act is a reconstruction rather than the emitted + // steps. + // + // The emitted companion workflow is covered by the generator unit tests + // (TrustedMetadata, LoopGuards, Actionlint across all commit modes, + // AppendPushesOntoHeadBranch, ForkNeverPushedInPlace, FollowupOpensSeparatePR) + // plus actionlint, and the reconcile-adopts-and-survives-regen contract is + // proven end to end by TestReconcileAdoptsBumpAndSurvivesRegen. True + // end-to-end proof of the companion comes from the fleet run and cascade's + // own self-heal companion on real GitHub. + t.Skip("act cannot emulate a workflow_run companion end to end (no action resolution without a real server, and no run: step env for a synthetic workflow_run event); the emitted companion is covered by the generator unit tests plus actionlint, with true end-to-end proof from the fleet and cascade's own self-heal companion on real GitHub") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + result, err := harness.RunReconcileCompanionAppendScenario(ctx, t) + require.NoError(t, err, "reconcile companion scenario failed") + + require.Equal(t, "success", result.Conclusion, "the companion's reconcile job must conclude successfully") + require.NotEqual(t, result.HeadBefore, result.HeadAfter, + "the companion must push an adoption commit onto the pull request's own branch") + require.Contains(t, result.ManifestAfter, harness.ReconcileCompanionBumpTag, + "the adopted action_pins entry must carry the bumped ref verbatim") + require.Contains(t, result.OrchestrateAfter, "actions/checkout@"+harness.ReconcileCompanionBumpTag, + "the regenerated orchestrate.yaml must still carry the bumped pin, proving the adoption survives regeneration") +} diff --git a/internal/config/reconcile_config_test.go b/internal/config/reconcile_config_test.go new file mode 100644 index 00000000..fff47e4b --- /dev/null +++ b/internal/config/reconcile_config_test.go @@ -0,0 +1,39 @@ +package config + +import "testing" + +// TestParseReconcile proves the opt-in reconcile companion lane parses its +// fields: enabled, source (the adapter selector), and commit (the routing +// mode). +func TestParseReconcile(t *testing.T) { + cfg := parseInline(t, ` +trunk_branch: main +reconcile: + enabled: true + source: dependabot + commit: followup +`) + if cfg.Reconcile == nil || !cfg.Reconcile.Enabled { + t.Fatalf("reconcile: %#v", cfg.Reconcile) + } + if cfg.Reconcile.Source != "dependabot" { + t.Fatalf("reconcile.source: got %q", cfg.Reconcile.Source) + } + if cfg.Reconcile.Commit != "followup" { + t.Fatalf("reconcile.commit: got %q", cfg.Reconcile.Commit) + } +} + +// TestReconcileValidatesAtCurrentSchemaVersion proves the reconcile toggle is +// additive: a manifest that sets it validates cleanly at CurrentSchemaVersion, +// confirming the schema version was not bumped to introduce the field. +func TestReconcileValidatesAtCurrentSchemaVersion(t *testing.T) { + cfg := &TrunkConfig{ + SchemaVersion: CurrentSchemaVersion, + TrunkBranch: "main", + Reconcile: &ReconcileConfig{Enabled: true, Source: "dependabot", Commit: "append"}, + } + for _, e := range Validate(cfg) { + t.Fatalf("unexpected validation error for reconcile at current schema version: %s", e) + } +} diff --git a/internal/config/schema_v1.go b/internal/config/schema_v1.go index 9ad7c387..50979f91 100644 --- a/internal/config/schema_v1.go +++ b/internal/config/schema_v1.go @@ -276,6 +276,34 @@ type DriftCheckConfig struct { Comment bool `yaml:"comment,omitempty" json:"comment,omitempty"` } +// ReconcileConfig is the opt-in emitted pin-reconcile lane (#443). When +// Enabled, the generator emits the PR detector plus the workflow_run reconcile +// companion that adopts an external governed-pin change back into the +// manifest. +type ReconcileConfig struct { + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + // Source names the change source this companion recognizes. The engine + // core is source-agnostic; Source keeps the config adapter-shaped from the + // start so a second source (Renovate, a manual dispatch, a hand-edit) is + // another adapter value, not a rewrite. "dependabot" is the first adapter + // and the default. + Source string `yaml:"source,omitempty" json:"source,omitempty"` + // Commit routes the same-repo adoption commit: "append" (default) onto the + // triggering PR, or "followup" as a separate PR for automerge-without-review. + Commit string `yaml:"commit,omitempty" json:"commit,omitempty"` +} + +// Reconcile source-adapter constants. +const ( + ReconcileSourceDependabot = "dependabot" +) + +// Reconcile commit-routing mode constants. +const ( + ReconcileCommitAppend = "append" + ReconcileCommitFollowup = "followup" +) + // DeploymentsConfig configures opt-in GitHub Deployments API integration. // When enabled, the finalize job creates a Deployment per target environment // and reports in_progress then success/failure status after each deploy. diff --git a/internal/config/types.go b/internal/config/types.go index 4dbed1bd..a427f5d4 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -165,6 +165,7 @@ type TrunkConfig struct { ValidateCheck *ValidateCheckConfig `yaml:"validate_check,omitempty" json:"validate_check,omitempty"` MergeQueue *MergeQueueConfig `yaml:"merge_queue,omitempty" json:"merge_queue,omitempty"` DriftCheck *DriftCheckConfig `yaml:"drift_check,omitempty" json:"drift_check,omitempty"` // Opt-in workflow drift-check PR lane (#229) + Reconcile *ReconcileConfig `yaml:"reconcile,omitempty" json:"reconcile,omitempty"` // Opt-in emitted pin-reconcile companion (#443) // Rollback configures the opt-in rollback workflow. Absent by default; when // set with repository_dispatch, an external signal can fire the rollback (#181). Rollback *RollbackConfig `yaml:"rollback,omitempty" json:"rollback,omitempty"` diff --git a/internal/config/validate_reconcile_test.go b/internal/config/validate_reconcile_test.go new file mode 100644 index 00000000..2208a09a --- /dev/null +++ b/internal/config/validate_reconcile_test.go @@ -0,0 +1,35 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestValidateReconcile_RejectsUnknownSource guards the adapter selector: an +// unrecognized source value must fail validation rather than reach the +// generator, since the emitted companion assumes a known adapter shape. +func TestValidateReconcile_RejectsUnknownSource(t *testing.T) { + t.Parallel() + cfg := &TrunkConfig{Reconcile: &ReconcileConfig{Enabled: true, Source: "renovate"}} + errs := validateReconcile(cfg.Reconcile) + require.NotEmpty(t, errs) +} + +// TestValidateReconcile_RejectsUnknownCommit guards the commit-routing mode. +func TestValidateReconcile_RejectsUnknownCommit(t *testing.T) { + t.Parallel() + cfg := &TrunkConfig{Reconcile: &ReconcileConfig{Enabled: true, Commit: "rebase"}} + errs := validateReconcile(cfg.Reconcile) + require.NotEmpty(t, errs) +} + +// TestValidateReconcile_AllowsEmptyAndKnownValues confirms the unset default +// and both known values for source and commit validate clean. +func TestValidateReconcile_AllowsEmptyAndKnownValues(t *testing.T) { + t.Parallel() + require.Empty(t, validateReconcile(nil)) + require.Empty(t, validateReconcile(&ReconcileConfig{})) + require.Empty(t, validateReconcile(&ReconcileConfig{Enabled: true, Source: "dependabot", Commit: "append"})) + require.Empty(t, validateReconcile(&ReconcileConfig{Enabled: true, Commit: "followup"})) +} diff --git a/internal/config/validate_v1.go b/internal/config/validate_v1.go index 647fb942..b06ac167 100644 --- a/internal/config/validate_v1.go +++ b/internal/config/validate_v1.go @@ -431,10 +431,36 @@ func validateConfigLevel(cfg *TrunkConfig) []string { errs = append(errs, validateRollback(cfg.Rollback)...) errs = append(errs, validateActionPins(cfg)...) errs = append(errs, validateActionFolder(cfg.ActionFolder)...) + errs = append(errs, validateReconcile(cfg.Reconcile)...) return errs } +// validateReconcile checks the opt-in reconcile companion toggle. Source +// selects the change-source adapter the companion recognizes; Commit selects +// how an adoption commit is routed. Both are constrained to the known values +// so an unrecognized adapter or mode fails validation rather than reaching the +// generator with an assumption it cannot honor. +func validateReconcile(r *ReconcileConfig) []string { + if r == nil { + return nil + } + var errs []string + switch r.Source { + case "", ReconcileSourceDependabot: + // ok + default: + errs = append(errs, fmt.Sprintf("reconcile.source must be one of: %s", ReconcileSourceDependabot)) + } + switch r.Commit { + case "", ReconcileCommitAppend, ReconcileCommitFollowup: + // ok + default: + errs = append(errs, fmt.Sprintf("reconcile.commit must be one of: %s, %s", ReconcileCommitAppend, ReconcileCommitFollowup)) + } + return errs +} + // validateActionPins charset-validates every action_pins override value. Each // value is spliced raw into a generated workflow's uses: line, so it must be // bounded to a ref plus an optional trailing "# " comment and must diff --git a/internal/generate/command.go b/internal/generate/command.go index e7d39c01..1892e8d7 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -387,6 +387,44 @@ func runGenerateWorkflow(opts generateOptions) error { } } + // Generate the opt-in emitted pin-reconcile lane (reconcile.enabled): + // the pull_request detector plus its workflow_run companion. Absent or + // disabled reconcile emits nothing, so existing manifests are unaffected. + reconcileGen := NewReconcileGenerator(cfg, baseDir) + if reconcileGen.Enabled() { + content, err := reconcileGen.Generate() + if err != nil { + return fmt.Errorf("generating reconcile-check workflow: %w", err) + } + outPath := ".github/workflows/cascade-reconcile-check.yaml" + if opts.dryRun { + fmt.Println("\n=== cascade-reconcile-check.yaml ===") + fmt.Print(content) + } else { + if err := writeWorkflow(outPath, content, opts.force); err != nil { + return err + } + generatedFiles = append(generatedFiles, outPath) + fmt.Printf("Generated workflow: %s\n", outPath) + } + + companionContent, err := reconcileGen.GenerateCompanion() + if err != nil { + return fmt.Errorf("generating reconcile-companion workflow: %w", err) + } + companionOutPath := ".github/workflows/cascade-reconcile-companion.yaml" + if opts.dryRun { + fmt.Println("\n=== cascade-reconcile-companion.yaml ===") + fmt.Print(companionContent) + } else { + if err := writeWorkflow(companionOutPath, companionContent, opts.force); err != nil { + return err + } + generatedFiles = append(generatedFiles, companionOutPath) + fmt.Printf("Generated workflow: %s\n", companionOutPath) + } + } + if opts.dryRun { return nil } diff --git a/internal/generate/reconcile_companion.go b/internal/generate/reconcile_companion.go new file mode 100644 index 00000000..a65eb8ac --- /dev/null +++ b/internal/generate/reconcile_companion.go @@ -0,0 +1,562 @@ +package generate + +import ( + "fmt" + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// reconcileCheckArtifact is the name of the data-only relevance artifact the +// pull_request detector uploads and the workflow_run companion downloads. It +// never carries the target PR number; the companion derives that only from +// trusted workflow_run run metadata. +const reconcileCheckArtifact = "pin-reconcile-result" + +// reconcileCheckArtifactFile is the JSON file cascade reconcile --check writes, +// matching internal/pinreconcile's defaultCheckArtifactPath. +const reconcileCheckArtifactFile = "pin-reconcile-result.json" + +// reconcileCheckWorkflowName is the workflow name the pull_request detector +// runs under. The companion subscribes to completed runs of this exact name, +// so the two generated files must agree on it. +const reconcileCheckWorkflowName = "Cascade Reconcile Check" + +// reconcileCommitMessage is the subject line of the adoption commit the +// companion pushes onto (or alongside) the triggering PR. +const reconcileCommitMessage = "chore: reconcile governed action pins" + +// reconcileCommentMarker is the hidden HTML marker embedded in the fork +// fallback comment so a later run finds and updates the same sticky comment +// instead of posting a new one. +const reconcileCommentMarker = "" + +// reconcileFollowupBranchExpr is the cascade-owned branch name a "followup" +// commit (or an append-mode fork fallback, were it to open a PR) would use. +// It is deterministic per PR number so a repeat run updates the same branch +// and PR rather than accumulating one per run. +const reconcileFollowupBranchExpr = "cascade-reconcile/pr-${{ steps.resolve.outputs.pr_number }}" + +// ReconcileGenerator emits the opt-in emitted pin-reconcile companion (#443). +// +// A pull_request job runs strictly read-only: it computes the PR's changed +// workflow files, runs `cascade reconcile --check` (a pinned release binary, +// never a `go run` off the repository's own source), and uploads the result as +// a data-only artifact. It carries no secrets and no write permission, so a +// fork PR cannot abuse it. +// +// A workflow_run companion (GenerateCompanion) subscribes to completions of +// that detector, resolves the target PR from trusted workflow_run metadata +// only, and, when the artifact reports relevance, fetches the PR's head files +// as data, runs the pinned cascade binary to adopt the bump into the manifest, +// and pushes (or opens a followup PR) with the trigger-capable state token. +type ReconcileGenerator struct { + config *config.TrunkConfig + baseDir string +} + +// NewReconcileGenerator creates a new reconcile companion workflow generator. +func NewReconcileGenerator(cfg *config.TrunkConfig, baseDir string) *ReconcileGenerator { + return &ReconcileGenerator{config: cfg, baseDir: baseDir} +} + +// Enabled reports whether the reconcile companion should be emitted. +func (g *ReconcileGenerator) Enabled() bool { + return g.config.Reconcile != nil && g.config.Reconcile.Enabled +} + +// getCLIRef returns the Git ref for the cascade self-action. The default +// (cli_version unset or "latest") resolves to an immutable release tag, so +// consumers never run an unpinned mutable ref; "beta" opts in to "master". +func (g *ReconcileGenerator) getCLIRef() string { + return cliSetupRef(g.config) +} + +// Generate creates the pull_request reconcile-detector workflow content. +func (g *ReconcileGenerator) Generate() (string, error) { + if !g.Enabled() { + return "", fmt.Errorf("cannot generate reconcile workflow: reconcile is not enabled") + } + + var sb strings.Builder + g.writeHeader(&sb) + g.writeCheckTrigger(&sb) + g.writeCheckJob(&sb) + return sb.String(), nil +} + +func (g *ReconcileGenerator) writeHeader(sb *strings.Builder) { + sb.WriteString(GeneratedFileMarker + "\n") + fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n\n", g.config.GetManifestFile()) +} + +// writeCheckTrigger emits the pull_request trigger and the read-only +// top-level permissions. A fork PR gets a read-only token and no secrets, so +// this job cannot push or comment; it captures relevance as an artifact +// instead. +func (g *ReconcileGenerator) writeCheckTrigger(sb *strings.Builder) { + fmt.Fprintf(sb, "name: %s\n\n", reconcileCheckWorkflowName) + sb.WriteString("on:\n") + sb.WriteString(" pull_request:\n") + sb.WriteString("\npermissions:\n") + sb.WriteString(" contents: read\n") + sb.WriteString("\nconcurrency:\n") + sb.WriteString(" group: \"cascade-reconcile-check-${{ github.event.pull_request.number }}\"\n") + sb.WriteString(" cancel-in-progress: true\n") +} + +func (g *ReconcileGenerator) writeCheckJob(sb *strings.Builder) { + sb.WriteString("\njobs:\n") + sb.WriteString(" reconcile-check:\n") + sb.WriteString(" name: Pin Reconcile Detector\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + // Re-state read-only permissions at job scope so the job is read-only + // regardless of any future change to the workflow default. + sb.WriteString(" permissions:\n") + sb.WriteString(" contents: read\n") + sb.WriteString(" steps:\n") + + // Full history is required so the detector can diff base..head to find the + // changed workflow files a governed pin bump would land in. + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + sb.WriteString("\n") + + sb.WriteString(" - name: Setup CLI\n") + fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef()) + sb.WriteString(" with:\n") + fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion()) + // github.token is the built-in Actions token, sufficient to authenticate + // gh release download against the public stablekernel/cascade repository. + sb.WriteString(" token: ${{ github.token }}\n") + sb.WriteString("\n") + + g.writeChangedFilesStep(sb) + g.writeCheckStep(sb) + + sb.WriteString(" - name: Upload pin-reconcile result\n") + sb.WriteString(" if: always()\n") + writeActionUses(sb, g.config, " ", actionUploadArtifact) + sb.WriteString(" with:\n") + fmt.Fprintf(sb, " name: %s\n", reconcileCheckArtifact) + fmt.Fprintf(sb, " path: %s\n", reconcileCheckArtifactFile) + sb.WriteString(" retention-days: 1\n") +} + +// writeChangedFilesStep lists the PR's changed workflow files (the only files +// a governed pin bump lands in) into changed-files.txt for the next step to +// feed to `cascade reconcile --check`. pull_request event fields are +// attacker-influenceable on a fork PR, so the SHAs are routed through env: and +// referenced as quoted shell variables rather than interpolated into the +// script. +func (g *ReconcileGenerator) writeChangedFilesStep(sb *strings.Builder) { + sb.WriteString(" - name: List changed workflow files\n") + sb.WriteString(" env:\n") + sb.WriteString(" BASE_SHA: ${{ github.event.pull_request.base.sha }}\n") + sb.WriteString(" HEAD_SHA: ${{ github.event.pull_request.head.sha }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" git diff --name-only \"$BASE_SHA\" \"$HEAD_SHA\" -- .github/workflows/ > changed-files.txt\n") + sb.WriteString("\n") +} + +// writeCheckStep runs the real `cascade reconcile --check` command (never a +// hand-rolled shell/yq scan) so the detector's relevance decision exactly +// matches what a subsequent reconcile would adopt. +func (g *ReconcileGenerator) writeCheckStep(sb *strings.Builder) { + sb.WriteString(" - name: Check for a governed pin change\n") + sb.WriteString(" run: |\n") + sb.WriteString(" args=()\n") + sb.WriteString(" while IFS= read -r f; do\n") + sb.WriteString(" [ -n \"$f\" ] && args+=(--changed-file \"$f\")\n") + sb.WriteString(" done < changed-files.txt\n") + fmt.Fprintf(sb, " cascade reconcile --check --check-output %s \"${args[@]}\"\n", reconcileCheckArtifactFile) +} + +// getStateTokenRef returns the token expression used to push the reconcile +// adoption commit. Users configure the full expression via the state_token +// config option, and it defaults to GITHUB_TOKEN. +func (g *ReconcileGenerator) getStateTokenRef() string { + return resolveStateTokenRef(g.config) +} + +// GenerateCompanion creates the workflow_run reconcile-companion workflow +// content. It resolves the target PR from trusted workflow_run run metadata +// only, fetches the PR's head files as data (the trusted refs/pull//head +// ref, never a direct checkout of a fork repository), obtains a pinned +// release binary, runs the real (idempotent) `cascade reconcile` command, and +// pushes the adoption commit with the trigger-capable state token, guarded +// against a stale or superseded PR head. +func (g *ReconcileGenerator) GenerateCompanion() (string, error) { + if !g.Enabled() { + return "", fmt.Errorf("cannot generate reconcile companion workflow: reconcile is not enabled") + } + + var sb strings.Builder + g.writeHeader(&sb) + g.writeCompanionTrigger(&sb) + g.writeCompanionJob(&sb) + return sb.String(), nil +} + +// writeCompanionTrigger emits the workflow_run trigger and the locked-down +// top-level permissions for the companion. +func (g *ReconcileGenerator) writeCompanionTrigger(sb *strings.Builder) { + sb.WriteString("name: Cascade Reconcile Companion\n\n") + sb.WriteString("on:\n") + sb.WriteString(" workflow_run:\n") + fmt.Fprintf(sb, " workflows: [%q]\n", reconcileCheckWorkflowName) + sb.WriteString(" types: [completed]\n") + // Default to no permissions; the single job opts into the minimum it needs. + sb.WriteString("\npermissions: {}\n") + // Serialize companion runs per source run so two rapid pushes cannot race + // two reconcile jobs and double-commit before the fresh-tip guard settles. + sb.WriteString("\nconcurrency:\n") + sb.WriteString(" group: \"cascade-reconcile-companion-${{ github.event.workflow_run.id }}\"\n") + sb.WriteString(" cancel-in-progress: false\n") +} + +func (g *ReconcileGenerator) writeCompanionJob(sb *strings.Builder) { + sb.WriteString("\njobs:\n") + sb.WriteString(" reconcile:\n") + sb.WriteString(" name: Adopt governed pin change\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + // Only act on PR-triggered source runs. + sb.WriteString(" if: github.event.workflow_run.event == 'pull_request'\n") + sb.WriteString(" permissions:\n") + sb.WriteString(" contents: write\n") + sb.WriteString(" pull-requests: write\n") + sb.WriteString(" actions: read\n") + sb.WriteString(" steps:\n") + + g.writeDownloadStep(sb) + g.writeResolveStep(sb) + g.writeCompanionCheckoutStep(sb) + g.writeCompanionSetupCLIStep(sb) + g.writeCompanionReconcileStep(sb) + + // Commit routing: "followup" always lands as a separate PR against a + // cascade-owned branch (safe regardless of fork, and the recommended + // posture for a PR that automerges without further review). The default + // "append" mode pushes onto the PR's own head branch, but only when the PR + // is NOT a fork: cascade has no push access to a fork's branch, so a fork + // PR always falls back to a sticky comment instead, never an in-place push. + if g.commitMode() == config.ReconcileCommitFollowup { + g.writeCompanionFollowupSteps(sb) + return + } + g.writeCompanionAppendStep(sb) + g.writeCompanionForkFallbackStep(sb) +} + +// commitMode returns the configured commit-routing mode, defaulting to +// "append" when unset. +func (g *ReconcileGenerator) commitMode() string { + if g.config.Reconcile == nil || g.config.Reconcile.Commit == "" { + return config.ReconcileCommitAppend + } + return g.config.Reconcile.Commit +} + +func (g *ReconcileGenerator) writeDownloadStep(sb *strings.Builder) { + sb.WriteString(" - name: Download pin-reconcile result\n") + sb.WriteString(" id: download\n") + sb.WriteString(" continue-on-error: true\n") + writeActionUses(sb, g.config, " ", actionDownloadArtifact) + sb.WriteString(" with:\n") + fmt.Fprintf(sb, " name: %s\n", reconcileCheckArtifact) + fmt.Fprintf(sb, " path: %s\n", reconcileCheckArtifact) + sb.WriteString(" run-id: ${{ github.event.workflow_run.id }}\n") + sb.WriteString(" github-token: ${{ github.token }}\n") + sb.WriteString("\n") +} + +// writeResolveStep derives the target PR number ONLY from trusted +// workflow_run run metadata, never from the fork-controlled artifact, and +// re-fetches the PR fresh so a superseded run aborts rather than reconciling +// stale data (part of the fresh-tip loop guard). It reads the artifact's +// relevance flag strictly as data. +func (g *ReconcileGenerator) writeResolveStep(sb *strings.Builder) { + sb.WriteString(" - name: Resolve target PR\n") + sb.WriteString(" id: resolve\n") + writeActionUses(sb, g.config, " ", actionGithubScript) + sb.WriteString(" with:\n") + sb.WriteString(" script: |\n") + g.writeResolveScript(sb) +} + +func (g *ReconcileGenerator) writeResolveScript(sb *strings.Builder) { + lines := []string{ + "const fs = require('fs');", + "", + "// Read the artifact's relevance flag (data only; never executed).", + "let relevant = false;", + "try {", + fmt.Sprintf(" const raw = fs.readFileSync('%s/%s', 'utf8');", reconcileCheckArtifact, reconcileCheckArtifactFile), + " relevant = JSON.parse(raw).relevant === true;", + "} catch (e) {", + " relevant = false;", + "}", + "", + "// Resolve the target PR ONLY from trusted workflow_run metadata. The", + "// artifact is produced by the (possibly fork) source run and is", + "// attacker-controlled, so it must never decide which PR we touch.", + "const run = context.payload.workflow_run;", + "let prNumber;", + "if (run.pull_requests && run.pull_requests.length > 0) {", + " prNumber = run.pull_requests[0].number;", + "} else {", + " const associated = await github.rest.repos.listPullRequestsAssociatedWithCommit({", + " owner: context.repo.owner,", + " repo: context.repo.repo,", + " commit_sha: run.head_sha,", + " });", + " const match = associated.data.find((pr) => pr.head.sha === run.head_sha);", + " if (match) {", + " prNumber = match.number;", + " }", + "}", + "if (!Number.isInteger(prNumber) || prNumber <= 0) {", + " core.info('No PR resolved from workflow_run metadata; nothing to do.');", + " core.setOutput('relevant', 'false');", + " return;", + "}", + "", + "// Re-fetch the PR fresh so the reconcile targets the current tip, not a", + "// stale snapshot from workflow_run metadata (the fresh-tip loop guard).", + "const pr = await github.rest.pulls.get({", + " owner: context.repo.owner,", + " repo: context.repo.repo,", + " pull_number: prNumber,", + "});", + "if (pr.data.head.sha !== run.head_sha) {", + " core.info(`Run head ${run.head_sha} is superseded by PR head ${pr.data.head.sha}; ` +", + " 'aborting rather than reconcile stale data.');", + " core.setOutput('relevant', 'false');", + " return;", + "}", + "", + "core.setOutput('pr_number', String(prNumber));", + "core.setOutput('base_sha', pr.data.base.sha);", + "core.setOutput('base_ref', pr.data.base.ref);", + "core.setOutput('head_sha', pr.data.head.sha);", + "core.setOutput('head_ref', pr.data.head.ref);", + "core.setOutput('fork', String(pr.data.head.repo && pr.data.head.repo.full_name !== pr.data.base.repo.full_name));", + "core.setOutput('relevant', relevant ? 'true' : 'false');", + } + for _, l := range lines { + if l == "" { + sb.WriteString("\n") + continue + } + fmt.Fprintf(sb, " %s\n", l) + } +} + +// writeCompanionCheckoutStep fetches the PR's head files as DATA via the +// trusted refs/pull//head ref on the base repo, never a direct checkout of +// a fork repository, so nothing from a fork's own configuration is executed. +// It checks out with the state token (the push identity) so the later git +// push is not blocked by branch protection. +func (g *ReconcileGenerator) writeCompanionCheckoutStep(sb *strings.Builder) { + sb.WriteString(" - name: Checkout PR head (data only)\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true'\n") + writeActionUses(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" ref: refs/pull/${{ steps.resolve.outputs.pr_number }}/head\n") + sb.WriteString(" fetch-depth: 0\n") + fmt.Fprintf(sb, " token: %s\n", g.getStateTokenRef()) + sb.WriteString("\n") +} + +// writeCompanionSetupCLIStep obtains a PINNED release binary, never a `go +// run` off the repository's own (possibly malicious) source tree. +func (g *ReconcileGenerator) writeCompanionSetupCLIStep(sb *strings.Builder) { + sb.WriteString(" - name: Setup CLI\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true'\n") + fmt.Fprintf(sb, " uses: stablekernel/cascade/.github/actions/setup-cli@%s\n", g.getCLIRef()) + sb.WriteString(" with:\n") + fmt.Fprintf(sb, " version: %s\n", g.config.GetCLIVersion()) + sb.WriteString(" token: ${{ github.token }}\n") + sb.WriteString("\n") +} + +// writeCompanionReconcileStep runs the real (idempotent) `cascade reconcile` +// command against the changed workflow files, the loop-termination guard (a): +// the write round-trips through the typed command, never a hand-rolled shell +// or yq edit of the manifest. +func (g *ReconcileGenerator) writeCompanionReconcileStep(sb *strings.Builder) { + sb.WriteString(" - name: Reconcile the governed pin change\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true'\n") + sb.WriteString(" env:\n") + sb.WriteString(" BASE_SHA: ${{ steps.resolve.outputs.base_sha }}\n") + sb.WriteString(" HEAD_SHA: ${{ steps.resolve.outputs.head_sha }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" git diff --name-only \"$BASE_SHA\" \"$HEAD_SHA\" -- .github/workflows/ > changed-files.txt\n") + sb.WriteString(" args=()\n") + sb.WriteString(" while IFS= read -r f; do\n") + sb.WriteString(" [ -n \"$f\" ] && args+=(--changed-file \"$f\")\n") + sb.WriteString(" done < changed-files.txt\n") + sb.WriteString(" cascade reconcile \"${args[@]}\"\n") + sb.WriteString("\n") +} + +// writeCompanionAppendStep pushes the adoption commit directly onto the PR's +// own head branch (the default "append" mode), gated off entirely for a fork +// PR: cascade has no push access to a fork's branch, so this step never runs +// for one (see writeCompanionForkFallbackStep). It applies the remaining +// loop-termination guards: (b) push-only-if-nonempty (a converged tree +// pushes nothing) and (c) reconcile-against-fresh-tip, aborting on a +// non-fast-forward rather than force-pushing over commits made since this run +// started. +func (g *ReconcileGenerator) writeCompanionAppendStep(sb *strings.Builder) { + sb.WriteString(" - name: Push the reconcile commit\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true' && steps.resolve.outputs.fork != 'true'\n") + sb.WriteString(" env:\n") + sb.WriteString(" HEAD_REF: ${{ steps.resolve.outputs.head_ref }}\n") + sb.WriteString(" HEAD_SHA: ${{ steps.resolve.outputs.head_sha }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" if git diff --quiet && git diff --cached --quiet; then\n") + sb.WriteString(" echo \"No pending reconcile changes; nothing to push.\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString(" git config user.name \"github-actions[bot]\"\n") + sb.WriteString(" git config user.email \"github-actions[bot]@users.noreply.github.com\"\n") + sb.WriteString(" git add .github\n") + fmt.Fprintf(sb, " git commit -m %q\n", reconcileCommitMessage) + sb.WriteString(" git fetch origin \"$HEAD_REF\"\n") + sb.WriteString(" FRESH_TIP=$(git rev-parse \"origin/$HEAD_REF\")\n") + sb.WriteString(" if [ \"$FRESH_TIP\" != \"$HEAD_SHA\" ]; then\n") + sb.WriteString(" echo \"PR head moved since this run started; aborting rather than overwrite newer commits.\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString(" git push origin \"HEAD:$HEAD_REF\"\n") + sb.WriteString("\n") +} + +// writeCompanionForkFallbackStep is the mandatory fallback for a fork PR +// under the default "append" mode: cascade cannot push onto a fork's own +// branch, so it posts (or updates) a sticky comment naming the governed refs +// the PR should adopt, read strictly as data from the check artifact. It +// never checks out or executes fork code and never opens a PR on the fork's +// behalf. +func (g *ReconcileGenerator) writeCompanionForkFallbackStep(sb *strings.Builder) { + sb.WriteString(" - name: Fork fallback (sticky comment)\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true' && steps.resolve.outputs.fork == 'true'\n") + writeActionUses(sb, g.config, " ", actionGithubScript) + sb.WriteString(" with:\n") + sb.WriteString(" script: |\n") + g.writeForkFallbackScript(sb) +} + +func (g *ReconcileGenerator) writeForkFallbackScript(sb *strings.Builder) { + lines := []string{ + "const fs = require('fs');", + fmt.Sprintf("const marker = '%s';", reconcileCommentMarker), + "const owner = context.repo.owner;", + "const repo = context.repo.repo;", + "const prNumber = Number(" + "'${{ steps.resolve.outputs.pr_number }}'" + ");", + "", + "// Read the changed refs strictly as data from the check artifact; this", + "// PR is a fork, so cascade never checks out or executes its code.", + "let changed = {};", + "try {", + fmt.Sprintf(" const raw = fs.readFileSync('%s/%s', 'utf8');", reconcileCheckArtifact, reconcileCheckArtifactFile), + " changed = JSON.parse(raw).changed_refs || {};", + "} catch (e) {", + " changed = {};", + "}", + "", + "const lines = Object.entries(changed).map(([action, ref]) => `- \\`${action}\\`: ${ref}`);", + "const body = [", + " marker,", + " '## Governed action pin changed',", + " '',", + " 'This pull request bumps a cascade-governed action pin. Cascade cannot push',", + " 'to a fork branch, so adopt it by hand in `action_pins`:',", + " '',", + " ...lines,", + "].join('\\n');", + "", + "const comments = await github.paginate(github.rest.issues.listComments, {", + " owner, repo, issue_number: prNumber,", + "});", + "const existing = comments.find((c) => c.body && c.body.includes(marker));", + "if (existing) {", + " await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });", + "} else {", + " await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });", + "}", + } + for _, l := range lines { + if l == "" { + sb.WriteString("\n") + continue + } + fmt.Fprintf(sb, " %s\n", l) + } +} + +// writeCompanionFollowupSteps implements the "followup" commit mode: the +// adoption commit lands on a cascade-owned, deterministic branch (never the +// PR's own head branch, so it is safe regardless of fork), and a distinct PR +// against the same base branch is opened (or updated on a repeat run) rather +// than silently mutating a PR that may automerge without further review. +// cascade owns this branch exclusively, so, unlike the append-mode push onto +// a user's own PR branch, force-pushing it on every run is safe: each run +// starts from a fresh checkout of the current PR head, so the branch's base +// commit legitimately changes whenever the PR head does. +func (g *ReconcileGenerator) writeCompanionFollowupSteps(sb *strings.Builder) { + sb.WriteString(" - name: Push the followup branch\n") + sb.WriteString(" if: steps.resolve.outputs.relevant == 'true'\n") + sb.WriteString(" id: followup_push\n") + sb.WriteString(" run: |\n") + sb.WriteString(" if git diff --quiet && git diff --cached --quiet; then\n") + sb.WriteString(" echo \"No pending reconcile changes; nothing to push.\"\n") + sb.WriteString(" echo \"pushed=false\" >> \"$GITHUB_OUTPUT\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString(" git config user.name \"github-actions[bot]\"\n") + sb.WriteString(" git config user.email \"github-actions[bot]@users.noreply.github.com\"\n") + fmt.Fprintf(sb, " git checkout -b %q\n", reconcileFollowupBranchExpr) + sb.WriteString(" git add .github\n") + fmt.Fprintf(sb, " git commit -m %q\n", reconcileCommitMessage) + fmt.Fprintf(sb, " git push --force origin \"HEAD:%s\"\n", reconcileFollowupBranchExpr) + sb.WriteString(" echo \"pushed=true\" >> \"$GITHUB_OUTPUT\"\n") + sb.WriteString("\n") + + sb.WriteString(" - name: Open or update the followup PR\n") + sb.WriteString(" if: steps.followup_push.outputs.pushed == 'true'\n") + writeActionUses(sb, g.config, " ", actionGithubScript) + sb.WriteString(" with:\n") + sb.WriteString(" script: |\n") + g.writeFollowupPRScript(sb) +} + +func (g *ReconcileGenerator) writeFollowupPRScript(sb *strings.Builder) { + lines := []string{ + "const owner = context.repo.owner;", + "const repo = context.repo.repo;", + "const prNumber = '${{ steps.resolve.outputs.pr_number }}';", + "const baseRef = '${{ steps.resolve.outputs.base_ref }}';", + fmt.Sprintf("const head = '%s';", reconcileFollowupBranchExpr), + "const title = 'chore: reconcile governed action pins';", + "const body = `Adopts the governed action-pin change from #${prNumber}.`;", + "", + "const existing = await github.paginate(github.rest.pulls.list, {", + " owner, repo, head: `${owner}:${head}`, state: 'open',", + "});", + "if (existing.length > 0) {", + " core.info(`Followup PR #${existing[0].number} already open; the push updated it.`);", + " return;", + "}", + "await github.rest.pulls.create({ owner, repo, title, body, head, base: baseRef });", + } + for _, l := range lines { + if l == "" { + sb.WriteString("\n") + continue + } + fmt.Fprintf(sb, " %s\n", l) + } +} diff --git a/internal/generate/reconcile_companion_test.go b/internal/generate/reconcile_companion_test.go new file mode 100644 index 00000000..653492bf --- /dev/null +++ b/internal/generate/reconcile_companion_test.go @@ -0,0 +1,309 @@ +package generate + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" +) + +func reconcileConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + Reconcile: &config.ReconcileConfig{Enabled: true}, + } +} + +func TestReconcileGenerator_Enabled(t *testing.T) { + tests := []struct { + name string + cfg *config.TrunkConfig + want bool + }{ + {"nil reconcile", &config.TrunkConfig{}, false}, + {"present but disabled", &config.TrunkConfig{Reconcile: &config.ReconcileConfig{}}, false}, + {"enabled", &config.TrunkConfig{Reconcile: &config.ReconcileConfig{Enabled: true}}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, NewReconcileGenerator(tt.cfg, t.TempDir()).Enabled()) + }) + } +} + +func TestReconcileGenerator_Generate_Disabled(t *testing.T) { + _, err := NewReconcileGenerator(&config.TrunkConfig{}, t.TempDir()).Generate() + require.Error(t, err) + assert.Contains(t, err.Error(), "reconcile is not enabled") +} + +// TestReconcileGenerator_CheckJob_ReadOnly proves the pull_request detector is +// strictly read-only: it carries the cascade-owned marker, runs the real +// `cascade reconcile --check` command, uploads the data-only relevance +// artifact, and never grants write permissions, references secrets, or pushes +// anything itself. +func TestReconcileGenerator_CheckJob_ReadOnly(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).Generate() + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(content, GeneratedFileMarker), + "generated file must start with the cascade-owned marker") + assert.Contains(t, content, "on:\n pull_request:") + assert.Contains(t, content, "permissions:\n contents: read") + assert.Contains(t, content, "cascade reconcile --check") + assert.Contains(t, content, "pin-reconcile-result") + + // Security invariant: the pull_request detector must NOT carry write scope, + // must NOT reference secrets, and must NOT push or commit anything. + assert.NotContains(t, content, "pull-requests: write", + "pull_request job must never have write permissions") + assert.NotContains(t, content, "contents: write", + "pull_request job must never have write permissions") + assert.NotContains(t, content, "secrets.", "pull_request job must not reference secrets") + assert.NotContains(t, content, "git push", "pull_request job must never push") + assert.NotContains(t, content, "git commit", "pull_request job must never commit") +} + +// TestReconcileGenerator_Deterministic proves byte-stability across repeated +// generation, guarding the determinism the verify path depends on. +func TestReconcileGenerator_Deterministic(t *testing.T) { + cfg := reconcileConfig() + g := NewReconcileGenerator(cfg, t.TempDir()) + + first, err := g.Generate() + require.NoError(t, err) + second, err := g.Generate() + require.NoError(t, err) + assert.Equal(t, first, second) +} + +// TestReconcileGenerator_SetupCLIPassesToken asserts that the setup-cli step +// passes github.token so that gh release download can authenticate on a cold +// tool-cache. Without the token: input the composite action's GH_TOKEN is +// empty and gh exits non-zero. +func TestReconcileGenerator_SetupCLIPassesToken(t *testing.T) { + gen := NewReconcileGenerator(reconcileConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "token: ${{ github.token }}", + "setup-cli step must pass github.token so gh release download succeeds on a cold cache") +} + +// TestReconcileGenerator_NoGoRun proves the detector obtains a pinned release +// binary via the setup-cli composite action rather than building or running +// off the repository's own (possibly stale) source tree. +func TestReconcileGenerator_NoGoRun(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).Generate() + require.NoError(t, err) + assert.NotContains(t, content, "go run") + assert.Contains(t, content, "setup-cli@") +} + +// TestReconcileGenerator_Actionlint runs actionlint over the generated +// detector file. Skipped when actionlint is not installed so the suite stays +// hermetic. +func TestReconcileGenerator_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + g := NewReconcileGenerator(reconcileConfig(), t.TempDir()) + check, err := g.Generate() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + checkPath := filepath.Join(wfDir, "cascade-reconcile-check.yaml") + require.NoError(t, os.WriteFile(checkPath, []byte(check), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + + cmd := exec.Command(bin, "-shellcheck=", checkPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out)) +} + +// TestReconcileGenerator_GenerateCompanion_Disabled mirrors Generate_Disabled +// for the workflow_run companion. +func TestReconcileGenerator_GenerateCompanion_Disabled(t *testing.T) { + _, err := NewReconcileGenerator(&config.TrunkConfig{}, t.TempDir()).GenerateCompanion() + require.Error(t, err) + assert.Contains(t, err.Error(), "reconcile is not enabled") +} + +// TestReconcileGenerator_Companion_TrustedMetadata is the keystone security +// test. It proves the workflow_run companion derives the target PR number +// ONLY from trusted workflow_run run metadata, never from the fork-controlled +// artifact, and obtains a pinned release binary rather than building or +// running off the repository's own (possibly malicious) source tree. +func TestReconcileGenerator_Companion_TrustedMetadata(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(content, GeneratedFileMarker)) + assert.Contains(t, content, "on:\n workflow_run:") + assert.Contains(t, content, `workflows: ["Cascade Reconcile Check"]`) + assert.Contains(t, content, "permissions: {}", "top-level permissions must be empty") + assert.Contains(t, content, "if: github.event.workflow_run.event == 'pull_request'") + + // Trusted-metadata derivation: PR number comes from run.pull_requests or the + // head-SHA lookup, NEVER from the downloaded artifact. + assert.Contains(t, content, "const run = context.payload.workflow_run;") + assert.Contains(t, content, "prNumber = run.pull_requests[0].number;") + assert.Contains(t, content, "listPullRequestsAssociatedWithCommit") + assert.Contains(t, content, "commit_sha: run.head_sha") + + // A pinned release binary, never a go run off the repository's own tree. + assert.NotContains(t, content, "go run") + assert.Contains(t, content, "setup-cli@") + + // Head files are fetched as data via the trusted refs/pull//head ref on + // the base repo (never a direct checkout of the fork repository), so + // nothing from a fork's own checkout configuration is ever executed. + assert.Contains(t, content, "refs/pull/${{ steps.resolve.outputs.pr_number }}/head") + assert.NotContains(t, content, "repository: ${{ steps.resolve.outputs.head_repo }}") + + // Pushes with the trigger-capable state token, never the default token. + assert.Contains(t, content, resolveStateTokenRef(reconcileConfig())) +} + +// TestReconcileGenerator_Companion_LoopGuards asserts all three loop- +// termination guards the companion relies on to avoid an unbounded reconcile +// loop: +// +// (a) the write round-trips through the real, idempotent `cascade reconcile` +// command rather than a hand-rolled shell/yq edit; +// (b) the push step is skipped when nothing actually changed; +// (c) the push re-checks the branch's fresh tip and aborts rather than +// force-pushing over commits made since this run started. +func TestReconcileGenerator_Companion_LoopGuards(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + // (a) idempotent typed-command write: the real command, not raw yq/sed. + assert.Contains(t, content, "cascade reconcile \"${args[@]}\"", + "must invoke the real idempotent reconcile command") + assert.NotContains(t, content, "yq eval", "must not hand-edit the manifest with yq") + + // (b) push-only-if-git-diff-nonempty. + assert.Contains(t, content, "git diff --quiet") + assert.Contains(t, content, "nothing to push") + + // (c) reconcile-against-fresh-tip with non-fast-forward abort: never force. + assert.Contains(t, content, "origin/$HEAD_REF") + assert.Contains(t, content, "aborting rather than overwrite") + assert.NotContains(t, content, "--force", "must never force-push over newer commits") + assert.NotContains(t, content, "git push -f") +} + +// TestReconcileGenerator_Companion_Deterministic proves byte-stability across +// repeated generation. +func TestReconcileGenerator_Companion_Deterministic(t *testing.T) { + g := NewReconcileGenerator(reconcileConfig(), t.TempDir()) + first, err := g.GenerateCompanion() + require.NoError(t, err) + second, err := g.GenerateCompanion() + require.NoError(t, err) + assert.Equal(t, first, second) +} + +// TestReconcileGenerator_Companion_Actionlint runs actionlint over the +// generated companion file. Skipped when actionlint is not installed. +func TestReconcileGenerator_Companion_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + for _, commit := range []string{"", config.ReconcileCommitAppend, config.ReconcileCommitFollowup} { + cfg := reconcileConfig() + cfg.Reconcile.Commit = commit + g := NewReconcileGenerator(cfg, t.TempDir()) + companion, err := g.GenerateCompanion() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + companionPath := filepath.Join(wfDir, "cascade-reconcile-companion.yaml") + require.NoError(t, os.WriteFile(companionPath, []byte(companion), 0644)) + + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + + cmd := exec.Command(bin, "-shellcheck=", companionPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues for commit=%q:\n%s", commit, string(out)) + } +} + +// TestReconcileGenerator_Companion_AppendPushesOntoHeadBranch proves the +// default ("append", also the empty-string default) mode pushes the adoption +// commit directly onto the PR's own head branch, gated on the PR NOT being a +// fork (a fork can never receive an in-place push; see the fork-fallback +// test below). +func TestReconcileGenerator_Companion_AppendPushesOntoHeadBranch(t *testing.T) { + for _, commit := range []string{"", config.ReconcileCommitAppend} { + cfg := reconcileConfig() + cfg.Reconcile.Commit = commit + content, err := NewReconcileGenerator(cfg, t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + assert.Contains(t, content, `git push origin "HEAD:$HEAD_REF"`) + assert.Contains(t, content, "steps.resolve.outputs.fork != 'true'", + "the direct push must be gated off for a fork PR") + } +} + +// TestReconcileGenerator_Companion_ForkNeverPushedInPlace is the keystone +// fork-safety test: regardless of the configured commit mode, a fork PR must +// never receive a direct push onto its head branch. In append mode it falls +// back to a sticky comment instead. +func TestReconcileGenerator_Companion_ForkNeverPushedInPlace(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + // The fork fallback step is gated to fork PRs only and never pushes onto + // the (inaccessible) fork head branch; it posts a sticky comment instead. + assert.Contains(t, content, "steps.resolve.outputs.fork == 'true'") + assert.Contains(t, content, reconcileCommentMarker) + assert.Contains(t, content, "createComment") + + // Every git push in the file must be scoped away from a fork PR (either + // the append push, gated fork != 'true', or the followup push, which + // targets cascade's own branch rather than the PR's head branch). + assert.NotContains(t, content, "git push origin \"HEAD:${{ steps.resolve.outputs.head_ref }}\"") +} + +// TestReconcileGenerator_Companion_FollowupOpensSeparatePR proves the +// "followup" mode never touches the original PR's own branch: it commits to +// a cascade-owned branch and opens (or updates) a distinct PR against the +// same base branch, so an automerge-without-review PR is never silently +// mutated in place. +func TestReconcileGenerator_Companion_FollowupOpensSeparatePR(t *testing.T) { + cfg := reconcileConfig() + cfg.Reconcile.Commit = config.ReconcileCommitFollowup + content, err := NewReconcileGenerator(cfg, t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + assert.Contains(t, content, "cascade-reconcile/pr-") + assert.Contains(t, content, "steps.resolve.outputs.base_ref") + assert.Contains(t, content, "github.rest.pulls.create") + // The followup path must never push onto the original PR's own branch. + assert.NotContains(t, content, `git push origin "HEAD:$HEAD_REF"`) +}