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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 14 additions & 11 deletions .github/workflows/pin-reconcile.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions internal/generate/marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
29 changes: 27 additions & 2 deletions internal/generate/reconcile_companion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Loading