diff --git a/.github/workflows/pin-reconcile.yaml b/.github/workflows/pin-reconcile.yaml index 33c2019d..fb122ad0 100644 --- a/.github/workflows/pin-reconcile.yaml +++ b/.github/workflows/pin-reconcile.yaml @@ -1,16 +1,18 @@ +# Generated by cascade reconcile --own-repo - DO NOT EDIT MANUALLY # Adopts an external action-pin bump back into cascade's own pin manifest and # regenerates the workflows, so a governed pin that moved in a hand-written # source file (a Dependabot bump, a manual edit) flows into # internal/generate/action_pins.yaml and every generated workflow agrees again. # -# Companion to PR Validation, same shape as the PR Failure Report. PR Validation -# runs on pull_request, so for fork PRs it gets a read-only token and no secrets -# and cannot push. This workflow runs on workflow_run in the BASE repo context, -# resolves the target pull request ONLY from trusted workflow_run metadata, and -# reads the triggering run's uploaded pin-reconcile-result artifact strictly as -# data. It never executes pull request head code: it installs a PINNED cascade -# CLI from a published release asset and runs that trusted binary over the head -# files, which it treats as data. +# Companion to PR Validation, same shape as the emitted user +# companion. PR Validation runs on pull_request, so for fork +# PRs it gets a read-only token and no secrets and cannot push. This workflow +# runs on workflow_run in the BASE repo context, resolves the target pull +# request ONLY from trusted workflow_run metadata, and reads the triggering +# run's uploaded pin-reconcile-result artifact strictly as data. It never +# executes pull request head code: it installs a PINNED cascade CLI from a +# published release asset and runs that trusted binary over the head files, +# which it treats as data. # # The self-heal push is same-repo only. A fork head can neither receive a push # nor be handed the write token, so a fork pull request is skipped. The default @@ -129,9 +131,10 @@ jobs: run: | set -euo pipefail # Install a PINNED cascade CLI from its published release asset, never - # a binary built off pull request head. Resolving the latest release - # tag matches how setup-cli installs the binary downstream. - tag="$(gh release list -R stablekernel/cascade -L 1 --json tagName -q '.[0].tagName')" + # a binary built off pull request head. The --exclude-pre-releases and + # --exclude-drafts filters keep cascade's own CI on a stable release, + # never self-installing one of its own rc or draft tags. + tag="$(gh release list -R stablekernel/cascade --exclude-pre-releases --exclude-drafts -L 1 --json tagName -q '.[0].tagName')" if [ -z "$tag" ]; then echo "::error::no published cascade release to install; cannot reconcile." exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87e49b31..a4e839d3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,7 @@ cascade owns the third-party action pins it emits into generated workflows, and - Path-shaped manifest fields, such as `action_folder` and callback workflow paths, must reject a `..` path segment during validation, so a configured path can only resolve inside the repository tree it is meant to. - A machine-authored commit (a bot or CI job writing on the project's behalf) stages an explicit pathspec allowlist naming exactly the files it intends to change. It never uses a blanket `git add -A` or `git add .`, so an unrelated working-tree change can never ride along. - Generated files are targets, never sources: a pin (or any other value) is read from the manifest and written into generated output, never read back out of a generated file. This keeps generation a pure, offline function of the manifest, which is what makes a regenerate reproducible and a diff meaningful. +- cascade's own self-heal companion is generated, not hand-written. `.github/workflows/pin-reconcile.yaml` is produced by the same reconcile generator that emits a downstream user's companion, in its own-repo variant, and is drift-locked byte-for-byte by a test so a hand-edit fails the suite. The own-repo variant differs from the user emission in exactly three ways: it installs the latest non-prerelease cascade release (never an rc or a draft, so cascade's own CI cannot self-install a prerelease), it scans both the workflow and composite-action trees for a moved pin, and it commits the regenerated workflows alongside the updated `action_pins.yaml`. Change the generator and regenerate the file; never edit the workflow by hand. ## Reporting bugs diff --git a/internal/generate/marker.go b/internal/generate/marker.go index b988c1ab..6c865d06 100644 --- a/internal/generate/marker.go +++ b/internal/generate/marker.go @@ -10,3 +10,13 @@ package generate // The string is load-bearing. Changing it is a breaking change for any repo // whose committed workflows still carry the old marker, so treat it as stable. const GeneratedFileMarker = "# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY" + +// OwnRepoGeneratedFileMarker is the distinct provenance header cascade's own +// self-heal companion carries instead of GeneratedFileMarker. That companion is +// generated (and drift-locked) but lives outside cascade's own manifest +// workflow plan, so it must not be mistaken for a manifest orphan. The verify +// command keys off this exact string to skip such files from the orphan scan +// while still flagging any file that carries the plain GeneratedFileMarker but +// is no longer planned. The two markers deliberately share no substring so a +// GeneratedFileMarker scan never matches an own-repo file, and vice versa. +const OwnRepoGeneratedFileMarker = "# Generated by cascade reconcile --own-repo - DO NOT EDIT MANUALLY" diff --git a/internal/generate/reconcile_companion.go b/internal/generate/reconcile_companion.go index a65eb8ac..17517399 100644 --- a/internal/generate/reconcile_companion.go +++ b/internal/generate/reconcile_companion.go @@ -53,11 +53,32 @@ const reconcileFollowupBranchExpr = "cascade-reconcile/pr-${{ steps.resolve.outp type ReconcileGenerator struct { config *config.TrunkConfig baseDir string + ownRepo bool +} + +// ReconcileOption customizes a ReconcileGenerator. Options are the additive, +// variadic tail of NewReconcileGenerator so new behavior never changes the +// two-argument signature callers already depend on. +type ReconcileOption func(*ReconcileGenerator) + +// WithOwnRepo switches the generator into own-repo mode, which emits cascade's +// self-heal companion for its own repository rather than the companion a +// downstream user adopts. The own-repo companion installs the latest +// non-prerelease cascade release, scans both the workflows and composite-action +// trees for a moved governed pin, and commits the regenerated workflows plus the +// updated pin manifest back onto the triggering branch. +func WithOwnRepo() ReconcileOption { + return func(g *ReconcileGenerator) { g.ownRepo = true } } // NewReconcileGenerator creates a new reconcile companion workflow generator. -func NewReconcileGenerator(cfg *config.TrunkConfig, baseDir string) *ReconcileGenerator { - return &ReconcileGenerator{config: cfg, baseDir: baseDir} +// Optional behavior is supplied through the variadic ReconcileOption tail. +func NewReconcileGenerator(cfg *config.TrunkConfig, baseDir string, opts ...ReconcileOption) *ReconcileGenerator { + g := &ReconcileGenerator{config: cfg, baseDir: baseDir} + for _, opt := range opts { + opt(g) + } + return g } // Enabled reports whether the reconcile companion should be emitted. @@ -192,6 +213,10 @@ func (g *ReconcileGenerator) GenerateCompanion() (string, error) { return "", fmt.Errorf("cannot generate reconcile companion workflow: reconcile is not enabled") } + if g.ownRepo { + return g.generateOwnRepoCompanion(), nil + } + var sb strings.Builder g.writeHeader(&sb) g.writeCompanionTrigger(&sb) diff --git a/internal/generate/reconcile_companion_ownrepo.go b/internal/generate/reconcile_companion_ownrepo.go new file mode 100644 index 00000000..a4d0da2d --- /dev/null +++ b/internal/generate/reconcile_companion_ownrepo.go @@ -0,0 +1,308 @@ +package generate + +import "strings" + +// ownRepoCompanionWorkflowName is the workflow name cascade's own self-heal +// companion runs under. +const ownRepoCompanionWorkflowName = "Pin Reconcile" + +// ownRepoSourceWorkflowName is the name of the detector workflow the own-repo +// companion subscribes to. Cascade's own reconcile detector lives in the +// "PR Validation" workflow (the workflow-drift job), so the companion keys on +// that completed run rather than the standalone "Cascade Reconcile Check" a +// downstream user emits. +const ownRepoSourceWorkflowName = "PR Validation" + +// ownRepoReconcileCommitSubject is the DCO-signed subject of the adoption commit +// the own-repo companion pushes back onto the triggering branch. +const ownRepoReconcileCommitSubject = "ci: reconcile governed action pins" + +// generateOwnRepoCompanion emits cascade's own self-heal companion. It differs +// from the downstream user companion in three ways and is otherwise the same +// workflow_run, trusted-metadata, same-repo-only design: +// +// 1. It installs the latest NON-prerelease cascade release from its published +// asset, so cascade's own CI never self-installs an rc or a draft. +// 2. It scans both .github/workflows/ and .github/actions/ for a moved pin, +// because cascade governs pins in its composite actions as well as its +// workflows. +// 3. It runs `cascade reconcile --own-repo`, which writes the adopted ref into +// internal/generate/action_pins.yaml and regenerates every generated +// workflow, then commits the manifest plus the regenerated workflows. +// +// The output is drift-locked byte-for-byte against +// .github/workflows/pin-reconcile.yaml so a future hand-edit fails the suite. +func (g *ReconcileGenerator) generateOwnRepoCompanion() string { + var sb strings.Builder + + sb.WriteString(OwnRepoGeneratedFileMarker + "\n") + sb.WriteString("# Adopts an external action-pin bump back into cascade's own pin manifest and\n") + sb.WriteString("# regenerates the workflows, so a governed pin that moved in a hand-written\n") + sb.WriteString("# source file (a Dependabot bump, a manual edit) flows into\n") + sb.WriteString("# internal/generate/action_pins.yaml and every generated workflow agrees again.\n") + sb.WriteString("#\n") + sb.WriteString("# Companion to " + ownRepoSourceWorkflowName + ", same shape as the emitted user\n") + sb.WriteString("# companion. " + ownRepoSourceWorkflowName + " runs on pull_request, so for fork\n") + sb.WriteString("# PRs it gets a read-only token and no secrets and cannot push. This workflow\n") + sb.WriteString("# runs on workflow_run in the BASE repo context, resolves the target pull\n") + sb.WriteString("# request ONLY from trusted workflow_run metadata, and reads the triggering\n") + sb.WriteString("# run's uploaded pin-reconcile-result artifact strictly as data. It never\n") + sb.WriteString("# executes pull request head code: it installs a PINNED cascade CLI from a\n") + sb.WriteString("# published release asset and runs that trusted binary over the head files,\n") + sb.WriteString("# which it treats as data.\n") + sb.WriteString("#\n") + sb.WriteString("# The self-heal push is same-repo only. A fork head can neither receive a push\n") + sb.WriteString("# nor be handed the write token, so a fork pull request is skipped. The default\n") + sb.WriteString("# token stays read-only; the branch write uses the trigger-capable state token,\n") + sb.WriteString("# matching the act-image-repin and hotfix trunk jobs. The emitted commit keeps\n") + sb.WriteString("# its DCO signoff and does not GPG-sign, matching the act-image-repin precedent\n") + sb.WriteString("# (GPG signing is a local merge rule, not a CI rule).\n") + sb.WriteString("name: " + ownRepoCompanionWorkflowName + "\n") + sb.WriteString("\n") + + sb.WriteString("on:\n") + sb.WriteString(" workflow_run:\n") + sb.WriteString(" workflows: [\"" + ownRepoSourceWorkflowName + "\"]\n") + sb.WriteString(" types: [completed]\n") + sb.WriteString("\n") + sb.WriteString("permissions: {}\n") + sb.WriteString("\n") + sb.WriteString("concurrency:\n") + sb.WriteString(" group: pin-reconcile-${{ github.event.workflow_run.head_branch }}\n") + sb.WriteString(" cancel-in-progress: false\n") + sb.WriteString("\n") + + sb.WriteString("jobs:\n") + sb.WriteString(" reconcile:\n") + sb.WriteString(" name: Reconcile governed action pins\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" # Only act on PR-triggered source runs.\n") + sb.WriteString(" if: github.event.workflow_run.event == 'pull_request'\n") + sb.WriteString(" permissions:\n") + sb.WriteString(" contents: read\n") + sb.WriteString(" actions: read\n") + sb.WriteString(" pull-requests: read\n") + sb.WriteString(" env:\n") + sb.WriteString(" GH_TOKEN: ${{ github.token }}\n") + sb.WriteString(" steps:\n") + + g.writeOwnRepoDownloadStep(&sb) + g.writeOwnRepoResolveStep(&sb) + g.writeOwnRepoInstallStep(&sb) + g.writeOwnRepoCheckoutStep(&sb) + g.writeOwnRepoReconcileStep(&sb) + g.writeOwnRepoCommitStep(&sb) + + return sb.String() +} + +func (g *ReconcileGenerator) writeOwnRepoDownloadStep(sb *strings.Builder) { + sb.WriteString(" - name: Download reconcile result\n") + sb.WriteString(" id: download\n") + sb.WriteString(" continue-on-error: true\n") + writeActionUses(sb, g.config, " ", actionDownloadArtifact) + sb.WriteString(" with:\n") + sb.WriteString(" name: pin-reconcile-result\n") + sb.WriteString(" path: pin-reconcile-result\n") + sb.WriteString(" run-id: ${{ github.event.workflow_run.id }}\n") + sb.WriteString(" github-token: ${{ github.token }}\n") + sb.WriteString("\n") +} + +func (g *ReconcileGenerator) writeOwnRepoResolveStep(sb *strings.Builder) { + sb.WriteString(" - name: Resolve target pull request and relevance\n") + sb.WriteString(" id: resolve\n") + writeActionUses(sb, g.config, " ", actionGithubScript) + sb.WriteString(" with:\n") + sb.WriteString(" script: |\n") + lines := []string{ + "const fs = require('fs');", + "const owner = context.repo.owner;", + "const repo = context.repo.repo;", + "const run = context.payload.workflow_run;", + "", + "// Read the data-only relevance artifact (never executed). No", + "// governed pin change means there is nothing to adopt.", + "let relevant = false;", + "try {", + " const raw = fs.readFileSync('pin-reconcile-result/pin-reconcile-result.json', 'utf8');", + " relevant = JSON.parse(raw).relevant === true;", + "} catch (e) {", + " core.info(`No reconcile-result artifact to read: ${e.message}`);", + "}", + "if (!relevant) {", + " core.info('No governed pin change to adopt; nothing to do.');", + " core.setOutput('proceed', 'false');", + " return;", + "}", + "", + "// Resolve the target pull request ONLY from trusted workflow_run", + "// metadata. The artifact and the triggering run's contents are", + "// attacker-controlled on a fork PR, so they must never decide which", + "// branch we touch.", + "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, 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 pull request resolved from workflow_run metadata; nothing to do.');", + " core.setOutput('proceed', 'false');", + " return;", + "}", + "", + "const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });", + "const head = pr.data.head;", + "const base = pr.data.base;", + "", + "// Same-repo only. A fork head cannot receive a push and must never", + "// be handed the write token, so it is skipped here.", + "if (!head.repo || head.repo.full_name !== base.repo.full_name) {", + " core.info('Pull request head is on a fork; the self-heal push is same-repo only.');", + " core.setOutput('proceed', 'false');", + " return;", + "}", + "", + "// head_sha guard: skip a superseded completion so a stale run cannot", + "// rewrite a branch that already advanced.", + "if (head.sha !== run.head_sha) {", + " core.info(`Run head ${run.head_sha} is superseded by branch head ${head.sha}; skipping.`);", + " core.setOutput('proceed', 'false');", + " return;", + "}", + "", + "core.setOutput('proceed', 'true');", + "core.setOutput('head_ref', head.ref);", + "core.setOutput('head_sha', head.sha);", + "core.setOutput('base_sha', base.sha);", + } + for _, l := range lines { + if l == "" { + sb.WriteString("\n") + continue + } + sb.WriteString(" " + l + "\n") + } + sb.WriteString("\n") +} + +// writeOwnRepoInstallStep installs the latest NON-prerelease cascade release. +// The --exclude-pre-releases and --exclude-drafts filters are the load-bearing +// difference from a naive `-L 1`: without them cascade's own CI would happily +// self-install an rc or a draft and reconcile against an unpublished binary. +func (g *ReconcileGenerator) writeOwnRepoInstallStep(sb *strings.Builder) { + sb.WriteString(" - name: Install released cascade CLI\n") + sb.WriteString(" if: steps.resolve.outputs.proceed == 'true'\n") + sb.WriteString(" run: |\n") + sb.WriteString(" set -euo pipefail\n") + sb.WriteString(" # Install a PINNED cascade CLI from its published release asset, never\n") + sb.WriteString(" # a binary built off pull request head. The --exclude-pre-releases and\n") + sb.WriteString(" # --exclude-drafts filters keep cascade's own CI on a stable release,\n") + sb.WriteString(" # never self-installing one of its own rc or draft tags.\n") + sb.WriteString(" tag=\"$(gh release list -R stablekernel/cascade --exclude-pre-releases --exclude-drafts -L 1 --json tagName -q '.[0].tagName')\"\n") + sb.WriteString(" if [ -z \"$tag\" ]; then\n") + sb.WriteString(" echo \"::error::no published cascade release to install; cannot reconcile.\"\n") + sb.WriteString(" exit 1\n") + sb.WriteString(" fi\n") + sb.WriteString(" echo \"Installing cascade ${tag} from its released asset.\"\n") + sb.WriteString(" tmp=\"$(mktemp -d)\"\n") + sb.WriteString(" gh release download \"$tag\" \\\n") + sb.WriteString(" -R stablekernel/cascade \\\n") + sb.WriteString(" -p 'cascade_*_linux_amd64.tar.gz' \\\n") + sb.WriteString(" -D \"$tmp\"\n") + sb.WriteString(" tar -xzf \"$tmp\"/*.tar.gz -C \"$tmp\"\n") + sb.WriteString(" install -m 0755 \"$tmp/cascade\" /usr/local/bin/cascade\n") + sb.WriteString(" rm -rf \"$tmp\"\n") + sb.WriteString(" cascade version\n") + sb.WriteString("\n") +} + +func (g *ReconcileGenerator) writeOwnRepoCheckoutStep(sb *strings.Builder) { + sb.WriteString(" - name: Check out the pull request head\n") + sb.WriteString(" if: steps.resolve.outputs.proceed == 'true'\n") + writeActionUses(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" # The head branch is same-repo (guarded above). Full history lets the\n") + sb.WriteString(" # non-fast-forward guard compare against the fresh remote tip. The\n") + sb.WriteString(" # state token carries the branch write; the head files are read as\n") + sb.WriteString(" # data and only the trusted released binary runs over them.\n") + sb.WriteString(" ref: ${{ steps.resolve.outputs.head_ref }}\n") + sb.WriteString(" fetch-depth: 0\n") + sb.WriteString(" persist-credentials: true\n") + sb.WriteString(" token: " + g.getStateTokenRef() + "\n") + sb.WriteString("\n") +} + +// writeOwnRepoReconcileStep runs `cascade reconcile --own-repo`, which adopts the +// moved pin into internal/generate/action_pins.yaml and regenerates the +// workflows. It diffs both the workflow and composite-action trees because +// cascade governs pins in both. +func (g *ReconcileGenerator) writeOwnRepoReconcileStep(sb *strings.Builder) { + sb.WriteString(" - name: Reconcile the pin manifest and regenerate\n") + sb.WriteString(" if: steps.resolve.outputs.proceed == '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(" set -euo pipefail\n") + sb.WriteString(" git config user.name \"cascade-bot\"\n") + sb.WriteString(" git config user.email \"cascade-bot@users.noreply.github.com\"\n") + sb.WriteString(" # List the changed governed source files a pin bump would land in.\n") + sb.WriteString(" # cascade's own governed sources are the hand-written workflows AND\n") + sb.WriteString(" # the composite actions, so diff over both. The SHAs come from trusted\n") + sb.WriteString(" # workflow_run metadata but are still routed through env and quoted.\n") + sb.WriteString(" git diff --name-only \"$BASE_SHA\" \"$HEAD_SHA\" \\\n") + sb.WriteString(" -- .github/workflows/ .github/actions/ > 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(" # Own-repo mode writes the adopted ref into internal/generate/action_pins.yaml\n") + sb.WriteString(" # and regenerates every generated workflow so they agree again.\n") + sb.WriteString(" # --action-pins names the manifest on disk; it has no default, so it\n") + sb.WriteString(" # must be passed explicitly here.\n") + sb.WriteString(" cascade reconcile --own-repo --action-pins internal/generate/action_pins.yaml \"${args[@]}\"\n") + sb.WriteString("\n") +} + +// writeOwnRepoCommitStep stages an explicit manifest-first allowlist (never +// git add -A), commits DCO-only, and pushes onto the same-repo head branch under +// the push-only-if-changed and non-fast-forward loop guards. +func (g *ReconcileGenerator) writeOwnRepoCommitStep(sb *strings.Builder) { + sb.WriteString(" - name: Commit and push the reconciled pins\n") + sb.WriteString(" if: steps.resolve.outputs.proceed == 'true'\n") + sb.WriteString(" env:\n") + sb.WriteString(" HEAD_REF: ${{ steps.resolve.outputs.head_ref }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" set -euo pipefail\n") + sb.WriteString("\n") + sb.WriteString(" # Stage an explicit manifest-first pathspec allowlist, never git add -A,\n") + sb.WriteString(" # so only the pin manifest and the regenerated workflows can ride the\n") + sb.WriteString(" # commit. The workflows glob is included because cascade's own repo\n") + sb.WriteString(" # commits its regenerated workflows.\n") + sb.WriteString(" git add internal/generate/action_pins.yaml '.github/workflows/*.yaml'\n") + sb.WriteString("\n") + sb.WriteString(" # Guard (b): push only when the reconcile actually changed tracked files.\n") + sb.WriteString(" if git diff --cached --quiet; then\n") + sb.WriteString(" echo \"Reconcile produced no change; the branch already agrees with the manifest.\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString("\n") + sb.WriteString(" git commit -s -m \"" + ownRepoReconcileCommitSubject + "\"\n") + sb.WriteString("\n") + sb.WriteString(" # Guard (c): reconcile against the fresh remote tip and refuse a\n") + sb.WriteString(" # non-fast-forward. If the branch advanced while we worked, abort\n") + sb.WriteString(" # rather than overwrite the newer commit; the plain (non-force) push\n") + sb.WriteString(" # enforces the same fast-forward rule as a backstop.\n") + sb.WriteString(" git fetch origin \"$HEAD_REF\"\n") + sb.WriteString(" if ! git merge-base --is-ancestor \"origin/${HEAD_REF}\" HEAD; then\n") + sb.WriteString(" echo \"::warning::${HEAD_REF} advanced during reconcile; aborting the push (non-fast-forward).\"\n") + sb.WriteString(" exit 0\n") + sb.WriteString(" fi\n") + sb.WriteString(" git push origin \"HEAD:${HEAD_REF}\"\n") +} diff --git a/internal/generate/reconcile_companion_ownrepo_test.go b/internal/generate/reconcile_companion_ownrepo_test.go new file mode 100644 index 00000000..d19bb0d7 --- /dev/null +++ b/internal/generate/reconcile_companion_ownrepo_test.go @@ -0,0 +1,185 @@ +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" +) + +// ownRepoReconcileConfig builds the TrunkConfig cascade's own self-heal +// companion is generated from: reconcile enabled, sha pin mode (so the emitted +// action refs are SHA-pinned like the committed workflow), and the +// trigger-capable state token that carries the branch write. +func ownRepoReconcileConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + PinMode: config.PinModeSHA, + StateToken: "${{ secrets.CASCADE_STATE_TOKEN }}", + Reconcile: &config.ReconcileConfig{Enabled: true}, + } +} + +// TestReconcileGenerator_OwnRepoCompanion_StableSelector is the regression test +// for the self-install defect: cascade's own companion must install the latest +// NON-prerelease release, never the newest tag (which can be an rc or a draft). +// It fails before the own-repo variant exists and passes after. +func TestReconcileGenerator_OwnRepoCompanion_StableSelector(t *testing.T) { + content, err := NewReconcileGenerator(ownRepoReconcileConfig(), t.TempDir(), WithOwnRepo()).GenerateCompanion() + require.NoError(t, err) + + // The install selector filters out prereleases and drafts. + assert.Contains(t, content, "--exclude-pre-releases") + assert.Contains(t, content, "--exclude-drafts") + + // It must NOT install via an unfiltered `-L 1` selector that would pick up + // the newest rc or draft tag. + assert.NotContains(t, content, "gh release list -R stablekernel/cascade -L 1 --json", + "own-repo companion must not install the newest tag unfiltered") + + // The versioned asset glob is preserved. + assert.Contains(t, content, "cascade_*_linux_amd64.tar.gz") +} + +// TestReconcileGenerator_OwnRepoCompanion_ScansBothTrees proves the own-repo +// companion diffs the composite-action tree as well as the workflow tree, since +// cascade governs pins in both, and wires the own-repo reconcile invocation. +func TestReconcileGenerator_OwnRepoCompanion_ScansBothTrees(t *testing.T) { + content, err := NewReconcileGenerator(ownRepoReconcileConfig(), t.TempDir(), WithOwnRepo()).GenerateCompanion() + require.NoError(t, err) + + assert.Contains(t, content, "-- .github/workflows/ .github/actions/", + "own-repo companion must scan both the workflow and composite-action trees") + assert.Contains(t, content, "cascade reconcile --own-repo --action-pins internal/generate/action_pins.yaml \"${args[@]}\"") + assert.Contains(t, content, "--changed-file") + + // The commit stages the manifest-first allowlist rather than a blanket add. + assert.Contains(t, content, "git add internal/generate/action_pins.yaml '.github/workflows/*.yaml'") + assert.NotContains(t, content, "git add -A\n") +} + +// TestReconcileGenerator_OwnRepoCompanion_SecurityPosture asserts the own-repo +// companion keeps the same trusted-metadata, same-repo-only, state-token posture +// as the emitted user companion. +func TestReconcileGenerator_OwnRepoCompanion_SecurityPosture(t *testing.T) { + content, err := NewReconcileGenerator(ownRepoReconcileConfig(), t.TempDir(), WithOwnRepo()).GenerateCompanion() + require.NoError(t, err) + + assert.True(t, strings.HasPrefix(content, OwnRepoGeneratedFileMarker), + "own-repo companion must carry the distinct own-repo generated marker") + assert.NotContains(t, content, GeneratedFileMarker, + "own-repo companion must not carry the plain marker, so verify's orphan scan skips it") + assert.Contains(t, content, "on:\n workflow_run:") + assert.Contains(t, content, `workflows: ["PR Validation"]`) + assert.Contains(t, content, "permissions: {}") + assert.Contains(t, content, "if: github.event.workflow_run.event == 'pull_request'") + + // Trusted-metadata PR resolution, never off the fork-controlled artifact. + assert.Contains(t, content, "const run = context.payload.workflow_run;") + assert.Contains(t, content, "prNumber = run.pull_requests[0].number;") + + // Same-repo only: a fork head is skipped, never handed the write token. + assert.Contains(t, content, "the self-heal push is same-repo only") + + // The branch write uses the trigger-capable state token, not the default one. + assert.Contains(t, content, "token: ${{ secrets.CASCADE_STATE_TOKEN }}") + + // A pinned release binary, never a build off the PR head tree. + assert.NotContains(t, content, "go run") + assert.NotContains(t, content, "go build") +} + +// TestReconcileGenerator_UserCompanion_UnchangedByOwnRepoVariant proves the +// own-repo option does not leak into the default user emission: the user +// companion still installs via setup-cli and scans only the workflows tree. +func TestReconcileGenerator_UserCompanion_UnchangedByOwnRepoVariant(t *testing.T) { + content, err := NewReconcileGenerator(reconcileConfig(), t.TempDir()).GenerateCompanion() + require.NoError(t, err) + + assert.Contains(t, content, "setup-cli@", "user companion still installs via setup-cli") + assert.NotContains(t, content, "--exclude-pre-releases", + "user companion must not carry the own-repo install selector") + assert.NotContains(t, content, "--own-repo", "user companion must not run own-repo reconcile") + assert.NotContains(t, content, "-- .github/workflows/ .github/actions/", + "user companion scans only the workflows tree, not the composite-action tree") + assert.Contains(t, content, `workflows: ["Cascade Reconcile Check"]`) +} + +// TestReconcileGenerator_OwnRepoCompanion_Actionlint runs actionlint over the +// generated own-repo companion. Skipped when actionlint is not installed. +func TestReconcileGenerator_OwnRepoCompanion_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + companion, err := NewReconcileGenerator(ownRepoReconcileConfig(), t.TempDir(), WithOwnRepo()).GenerateCompanion() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + path := filepath.Join(wfDir, "pin-reconcile.yaml") + require.NoError(t, os.WriteFile(path, []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=", path) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out)) +} + +// TestOwnRepoReconcileCompanionMatchesCommitted is the ongoing drift lock: the +// generated own-repo companion must equal the committed pin-reconcile.yaml +// byte-for-byte, so a future hand-edit of that file fails the suite. Run with +// UPDATE_GOLDEN=1 to regenerate the committed file after an intentional change. +func TestOwnRepoReconcileCompanionMatchesCommitted(t *testing.T) { + companion, err := NewReconcileGenerator(ownRepoReconcileConfig(), t.TempDir(), WithOwnRepo()).GenerateCompanion() + require.NoError(t, err) + + committedPath := filepath.Join(repoGitHubDir(t), "workflows", "pin-reconcile.yaml") + + if os.Getenv("UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(committedPath, []byte(companion), 0644)) + t.Logf("updated %s from the generator", committedPath) + return + } + + committed, err := os.ReadFile(committedPath) //nolint:gosec // fixed path under the repo's own .github tree. + require.NoError(t, err) + assert.Equal(t, string(committed), companion, + "pin-reconcile.yaml drifted from the own-repo generator; regenerate with UPDATE_GOLDEN=1") +} + +// TestPRWorkflowEmbedsReconcileDetectorShape locks the reconcile-detector shape +// inside the pr.yaml workflow-drift job. The detector is intentionally NOT a +// standalone generated file: cascade's own detector shares the workflow-drift +// job with the source-built verify step (it runs /tmp/cascade, not a release +// binary, under a set +e advisory frame), so generating the whole multi-purpose +// job would destabilize the required PR gate. Instead this test asserts the +// job carries the reconcile-detector invariants: it scans both governed trees, +// runs `reconcile --check --check-output` over --changed-file args, and uploads +// the pin-reconcile-result artifact. +func TestPRWorkflowEmbedsReconcileDetectorShape(t *testing.T) { + prPath := filepath.Join(repoGitHubDir(t), "workflows", "pr.yaml") + raw, err := os.ReadFile(prPath) //nolint:gosec // fixed path under the repo's own .github tree. + require.NoError(t, err) + pr := string(raw) + + assert.Contains(t, pr, "-- .github/workflows/ .github/actions/", + "pr.yaml reconcile detector must scan both governed trees") + assert.Contains(t, pr, "reconcile --check --check-output pin-reconcile-result.json", + "pr.yaml must run the read-only reconcile detector") + assert.Contains(t, pr, "--changed-file", "detector must pass the changed files through") + assert.Contains(t, pr, "name: pin-reconcile-result", + "pr.yaml must upload the pin-reconcile-result artifact for the companion") +} diff --git a/internal/verify/verify.go b/internal/verify/verify.go index 6d2a338a..87a90e07 100644 --- a/internal/verify/verify.go +++ b/internal/verify/verify.go @@ -176,7 +176,10 @@ func Run(o Options, stdout, stderr io.Writer) error { // findOrphans scans the directories that hold the planned workflow files for // cascade-owned files the manifest no longer plans. A file is an orphan only // when it carries generate.GeneratedFileMarker (so hand-written workflows are -// never flagged) and is not itself a planned file. The scan is confined to the +// never flagged) and is not itself a planned file. A file carrying +// generate.OwnRepoGeneratedFileMarker is cascade's own self-heal companion, +// generated and drift-locked but intentionally outside the manifest plan, so it +// is skipped rather than flagged. The scan is confined to the // distinct directories of the planned workflow files (normally // .github/workflows); the composite action directory and the wider repo are // never scanned. It returns the orphans sorted by absolute path for stable @@ -208,6 +211,13 @@ func findOrphans(planned []generate.PlannedFile, plannedPaths map[string]struct{ } return nil, fmt.Errorf("reading %s for orphan check: %w", full, rerr) } + // The own-repo self-heal companion is cascade-generated and + // drift-locked, but it lives outside the manifest workflow plan on + // purpose, so it is never a manifest orphan. Skip it before the plain + // marker check so real orphans still surface. + if bytes.Contains(body, []byte(generate.OwnRepoGeneratedFileMarker)) { + continue + } if bytes.Contains(body, []byte(generate.GeneratedFileMarker)) { orphans = append(orphans, full) } diff --git a/internal/verify/verify_test.go b/internal/verify/verify_test.go index f6687783..13c7c6c4 100644 --- a/internal/verify/verify_test.go +++ b/internal/verify/verify_test.go @@ -203,6 +203,39 @@ func TestRun_HandWrittenFile_NeverOrphan(t *testing.T) { require.NotContains(t, errOut.String(), "ci.yaml") } +func TestRun_OwnRepoMarkedFile_NeverOrphan(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // A file carrying the own-repo self-heal marker is cascade-generated but + // intentionally outside the manifest plan, so it must never be flagged as an + // orphan even though it sits in the scanned workflow directory. + ownRepo := filepath.Join(dir, ".github", "workflows", "pin-reconcile.yaml") + require.NoError(t, os.WriteFile(ownRepo, + []byte(generate.OwnRepoGeneratedFileMarker+"\nname: Pin Reconcile\non: push\n"), 0o644)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.NoError(t, err, "own-repo marked file must not be reported as drift") + require.NotContains(t, errOut.String(), "pin-reconcile.yaml") +} + +func TestRun_PlainMarkedFile_StillOrphan_RegressionGuard(t *testing.T) { + t.Parallel() + dir := newRepo(t) + // Regression guard: adding the own-repo skip must not weaken real orphan + // detection. A file carrying the plain GeneratedFileMarker that the manifest + // no longer plans is still an orphan and must drift. + plain := filepath.Join(dir, ".github", "workflows", "stale.yaml") + require.NoError(t, os.WriteFile(plain, + []byte(generate.GeneratedFileMarker+"\nname: Stale\non: push\n"), 0o644)) + + var out, errOut bytes.Buffer + err := Run(opts(dir), &out, &errOut) + require.True(t, errors.Is(err, ErrDrift), "plain-marked unplanned file must still be an orphan") + require.Contains(t, errOut.String(), "stale.yaml") + require.Contains(t, errOut.String(), "orphaned") +} + func TestRun_MultipleOrphans_SortedDeterministic(t *testing.T) { t.Parallel() dir := newRepo(t)