From 094b00deac6737ef5c9f829bbc7123f14a99c820 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 10 Jun 2026 23:46:49 -0400 Subject: [PATCH] feat(generate): emit cascade-hotfix workflow for multi-env repos Add a HotfixGenerator that renders .github/workflows/cascade-hotfix.yaml, wired into generate-workflow beside the other auxiliary generators. The workflow cherry-picks a trunk fix onto a diverged intermediate environment through an env/ integration branch, opens a resolution pull request, and on merge builds, deploys, and finalizes the hotfix. The generator emits only when two or more environments are configured; target_env is the configured environments minus the build target. Dual triggers cover manual dispatch and merged env/* pull requests. Clean cherry-picks auto-merge with the cascade-hotfix label; conflicting ones commit the markers and open a cascade-hotfix-conflict pull request carrying machine-readable trailers and resolve-locally instructions. The deploy job binds the GitHub environment of the target so org protection rules gate production hotfixes, branch protection is verified with a non-failing warning, and plan protection suggestions surface as notices. Signed-off-by: Joshua Temple --- internal/generate/command.go | 20 ++ internal/generate/hotfix.go | 516 +++++++++++++++++++++++++++++++ internal/generate/hotfix_test.go | 346 +++++++++++++++++++++ 3 files changed, 882 insertions(+) create mode 100644 internal/generate/hotfix.go create mode 100644 internal/generate/hotfix_test.go diff --git a/internal/generate/command.go b/internal/generate/command.go index 13c8348..3fd79f2 100644 --- a/internal/generate/command.go +++ b/internal/generate/command.go @@ -284,6 +284,26 @@ func runGenerateWorkflow(opts generateOptions) error { } } + // Generate the hotfix workflow when 2+ environments are configured (Q1). + hotfixGen := NewHotfixGenerator(cfg, baseDir) + if hotfixGen.Enabled() { + content, err := hotfixGen.Generate() + if err != nil { + return fmt.Errorf("generating hotfix workflow: %w", err) + } + outPath := ".github/workflows/cascade-hotfix.yaml" + if opts.dryRun { + fmt.Println("\n=== cascade-hotfix.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) + } + } + // Generate the opt-in read-only PR plan-preview workflow (#40). Absent or // disabled pr_preview emits nothing, so existing manifests are unaffected. if cfg.PRPreview != nil && cfg.PRPreview.Enabled { diff --git a/internal/generate/hotfix.go b/internal/generate/hotfix.go new file mode 100644 index 0000000..b44d28f --- /dev/null +++ b/internal/generate/hotfix.go @@ -0,0 +1,516 @@ +package generate + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/stablekernel/cascade/internal/config" +) + +// HotfixGenerator emits the cascade-hotfix workflow. It cherry-picks a trunk fix +// onto a diverged intermediate environment by replaying the commit on an +// env/ integration branch, opening a resolution pull request, and then +// building, deploying, and finalizing the hotfix once that pull request merges. +// +// The workflow carries two triggers in one file: a workflow_dispatch entry that +// plans and applies the cherry-pick, and a pull_request (closed) entry that runs +// the build, deploy, and finalize stages when the resolution pull request merges. +// +// This generator is gated on the configured environment count: it emits only +// when two or more environments are declared, because a single-environment +// pipeline has no intermediate target to hotfix onto. +type HotfixGenerator struct { + config *config.TrunkConfig + baseDir string +} + +// NewHotfixGenerator creates a hotfix-workflow generator bound to the given +// trunk config and repository base directory. +func NewHotfixGenerator(cfg *config.TrunkConfig, baseDir string) *HotfixGenerator { + return &HotfixGenerator{ + config: cfg, + baseDir: baseDir, + } +} + +// Enabled reports whether the hotfix workflow should be emitted. The workflow is +// emitted only when the manifest declares two or more environments, since the +// first environment is the build target and at least one further environment is +// required as a hotfix target. +func (g *HotfixGenerator) Enabled() bool { + return g.config != nil && len(g.config.Environments) >= 2 +} + +// targetEnvs returns the hotfix target environments: every configured +// environment except the first, which is the build target. Callers must gate on +// Enabled() so the slice is non-empty. +func (g *HotfixGenerator) targetEnvs() []string { + return g.config.Environments[1:] +} + +// getCLIRef mirrors the ref-resolution used by the other generators so the +// emitted setup-cli ref tracks config.cli_version. +func (g *HotfixGenerator) getCLIRef() string { + version := g.config.GetCLIVersion() + switch version { + case "latest", "": + return "latest" + case "beta": + return "master" + default: + return version + } +} + +// getManifestFilePath returns the repo-relative manifest path for use in the +// generated workflow, matching the release generator's resolution. +func (g *HotfixGenerator) getManifestFilePath() string { + manifestPath := g.config.GetManifestFile() + if !filepath.IsAbs(manifestPath) { + return manifestPath + } + if g.baseDir != "" { + if rel, err := filepath.Rel(g.baseDir, manifestPath); err == nil { + return rel + } + } + return ".github/manifest.yaml" +} + +// Generate renders the cascade-hotfix workflow. +func (g *HotfixGenerator) Generate() (string, error) { + var sb strings.Builder + + g.writeHeader(&sb) + g.writeTriggers(&sb) + g.writePermissions(&sb) + g.writeConcurrency(&sb) + g.writeJobs(&sb) + + return sb.String(), nil +} + +func (g *HotfixGenerator) writeHeader(sb *strings.Builder) { + sb.WriteString("# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY\n") + fmt.Fprintf(sb, "# Regenerate with: cascade generate-workflow --config %s\n", g.getManifestFilePath()) + sb.WriteString("#\n") + sb.WriteString("# Cascade hotfix workflow.\n") + sb.WriteString("#\n") + sb.WriteString("# Cherry-picks a trunk fix onto a diverged intermediate environment. On\n") + sb.WriteString("# manual dispatch it plans the cherry-pick, replays the commit onto the\n") + sb.WriteString("# env/ integration branch via a hotfix// branch, and opens a\n") + sb.WriteString("# resolution pull request. When that pull request merges it builds, deploys,\n") + sb.WriteString("# and finalizes the hotfix for the target environment. Clean cherry-picks\n") + sb.WriteString("# auto-merge; conflicting ones open a labeled pull request for a human to\n") + sb.WriteString("# resolve locally before the build/deploy stages run.\n") + sb.WriteString("\n") +} + +func (g *HotfixGenerator) writeTriggers(sb *strings.Builder) { + sb.WriteString("name: Cascade Hotfix\n\n") + sb.WriteString("on:\n") + sb.WriteString(" workflow_dispatch:\n") + sb.WriteString(" inputs:\n") + sb.WriteString(" commit:\n") + sb.WriteString(" description: 'Trunk commit SHA to hotfix (must be on trunk)'\n") + sb.WriteString(" required: true\n") + sb.WriteString(" type: string\n") + sb.WriteString(" target_env:\n") + sb.WriteString(" description: 'Target environment'\n") + sb.WriteString(" required: true\n") + sb.WriteString(" type: choice\n") + sb.WriteString(" options:\n") + for _, env := range g.targetEnvs() { + fmt.Fprintf(sb, " - %s\n", env) + } + sb.WriteString(" pr_number:\n") + sb.WriteString(" description: 'Existing hotfix PR number to replay (optional)'\n") + sb.WriteString(" required: false\n") + sb.WriteString(" type: string\n") + sb.WriteString(" dry_run:\n") + sb.WriteString(" description: 'Dry run (validate only, mutate nothing)'\n") + sb.WriteString(" required: false\n") + sb.WriteString(" type: boolean\n") + sb.WriteString(" default: false\n") + sb.WriteString(" pull_request:\n") + sb.WriteString(" types: [closed]\n") + sb.WriteString(" branches:\n") + sb.WriteString(" - 'env/*'\n") + sb.WriteString("\n") +} + +// writePermissions grants the scopes the hotfix workflow needs: contents:write +// to push the cherry-pick branch, pull-requests:write to open the resolution PR, +// and actions:read for workflow introspection. +func (g *HotfixGenerator) writePermissions(sb *strings.Builder) { + sb.WriteString("permissions:\n") + sb.WriteString(" contents: write\n") + sb.WriteString(" pull-requests: write\n") + sb.WriteString(" actions: read\n") + sb.WriteString("\n") +} + +// writeConcurrency keys the group per target environment. On dispatch the env is +// the operator input; on pull_request close it is derived from the base ref. +func (g *HotfixGenerator) writeConcurrency(sb *strings.Builder) { + sb.WriteString("concurrency:\n") + sb.WriteString(" group: hotfix-${{ github.event.inputs.target_env || github.event.pull_request.base.ref }}\n") + sb.WriteString(" cancel-in-progress: false\n") + sb.WriteString("\n") +} + +func (g *HotfixGenerator) writeJobs(sb *strings.Builder) { + sb.WriteString("jobs:\n") + g.writePlanJob(sb) + g.writeApplyJob(sb) + g.writeCheckJob(sb) + g.writeContextJob(sb) + g.writeBuildJobs(sb) + g.writeDeployJobs(sb) + g.writeFinalizeJob(sb) +} + +// writePlanJob emits the plan job, run only on manual dispatch. It fetches env +// branches and tags, then runs `cascade hotfix plan` and surfaces the planner's +// branch-protection suggestions as ::notice:: lines. +func (g *HotfixGenerator) writePlanJob(sb *strings.Builder) { + sb.WriteString(" plan:\n") + sb.WriteString(" name: Plan Hotfix\n") + sb.WriteString(" if: github.event_name == 'workflow_dispatch'\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" outputs:\n") + sb.WriteString(" branch: ${{ steps.plan.outputs.branch }}\n") + sb.WriteString(" base_sha: ${{ steps.plan.outputs.base_sha }}\n") + sb.WriteString(" hotfix_version: ${{ steps.plan.outputs.hotfix_version }}\n") + sb.WriteString(" expect_conflicts: ${{ steps.plan.outputs.expect_conflicts }}\n") + sb.WriteString(" steps:\n") + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + + g.writeSetupCLI(sb) + g.writeFetchEnvBranches(sb) + + sb.WriteString(" - name: Plan hotfix\n") + sb.WriteString(" id: plan\n") + sb.WriteString(" env:\n") + sb.WriteString(" HOTFIX_COMMIT: ${{ github.event.inputs.commit }}\n") + sb.WriteString(" HOTFIX_TARGET_ENV: ${{ github.event.inputs.target_env }}\n") + sb.WriteString(" HOTFIX_DRY_RUN: ${{ github.event.inputs.dry_run }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" cascade hotfix plan \\\n") + sb.WriteString(" --commit \"$HOTFIX_COMMIT\" \\\n") + sb.WriteString(" --target-env \"$HOTFIX_TARGET_ENV\" \\\n") + sb.WriteString(" --dry-run=\"$HOTFIX_DRY_RUN\" \\\n") + sb.WriteString(" --gha-output\n") + + // Q6: surface the planner's ready-to-run branch-protection commands. + sb.WriteString(" - name: Surface protection suggestions\n") + sb.WriteString(" if: steps.plan.outputs.protection_suggestions != ''\n") + sb.WriteString(" env:\n") + sb.WriteString(" SUGGESTIONS: ${{ steps.plan.outputs.protection_suggestions }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" while IFS= read -r line; do\n") + sb.WriteString(" [ -z \"$line\" ] && continue\n") + sb.WriteString(" echo \"::notice::$line\"\n") + sb.WriteString(" done <<< \"$SUGGESTIONS\"\n") +} + +// writeApplyJob emits the apply job, run on dispatch when not a dry-run. It +// cherry-picks the commit onto a hotfix branch and opens a resolution PR. Clean +// cherry-picks auto-merge; conflicting ones open a labeled PR for local +// resolution and do not auto-merge. +func (g *HotfixGenerator) writeApplyJob(sb *strings.Builder) { + sb.WriteString(" apply:\n") + sb.WriteString(" name: Apply Hotfix Cherry-Pick\n") + sb.WriteString(" needs: plan\n") + sb.WriteString(" if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true'\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" env:\n") + sb.WriteString(" GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n") + sb.WriteString(" COMMIT: ${{ github.event.inputs.commit }}\n") + sb.WriteString(" TARGET_ENV: ${{ github.event.inputs.target_env }}\n") + sb.WriteString(" BASE_SHA: ${{ needs.plan.outputs.base_sha }}\n") + sb.WriteString(" steps:\n") + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + + g.writeSetupCLI(sb) + g.writeFetchEnvBranches(sb) + + sb.WriteString(" - name: Configure git identity\n") + sb.WriteString(" run: |\n") + writeGitConfigSteps(sb, g.config, " ") + + // Q2: warn (do not fail) when the env branch lacks required-status-check + // protection, and print the exact command to configure it. + sb.WriteString(" - name: Check branch protection on env branch\n") + sb.WriteString(" continue-on-error: true\n") + sb.WriteString(" run: |\n") + sb.WriteString(" PROT_PATH=\"repos/${{ github.repository }}/branches/env%2F${TARGET_ENV}/protection\"\n") + sb.WriteString(" PROT=$(gh api \"$PROT_PATH\" 2>/dev/null || echo '')\n") + sb.WriteString(" CHECKS=$(echo \"$PROT\" | jq -r '.required_status_checks.contexts[]? // empty' 2>/dev/null || echo '')\n") + sb.WriteString(" if [ -z \"$PROT\" ] || [ -z \"$CHECKS\" ]; then\n") + sb.WriteString(" echo \"::warning::Branch env/${TARGET_ENV} has no required status checks; hotfix auto-merge will NOT be gated by required checks.\"\n") + sb.WriteString(" echo \"::warning::Configure protection: gh api \\\"$PROT_PATH\\\" -X PUT -f required_status_checks.strict=true -F required_status_checks.contexts[]=hotfix-check\"\n") + sb.WriteString(" fi\n") + + // Cherry-pick. Clean and conflict paths diverge after the cherry-pick result. + sb.WriteString(" - name: Cherry-pick and open resolution PR\n") + sb.WriteString(" run: |\n") + sb.WriteString(" SHORT_SHA=$(echo \"$COMMIT\" | cut -c1-8)\n") + sb.WriteString(" BRANCH=\"hotfix/${TARGET_ENV}/${SHORT_SHA}\"\n") + sb.WriteString(" git switch -c \"$BRANCH\" \"origin/env/${TARGET_ENV}\"\n") + sb.WriteString(" BODY=$(printf 'Cascade-Hotfix-Target: %s\\nCascade-Hotfix-Source: %s\\nCascade-Hotfix-Base: %s\\n' \"$TARGET_ENV\" \"$COMMIT\" \"$BASE_SHA\")\n") + sb.WriteString(" if git cherry-pick -x \"$COMMIT\"; then\n") + sb.WriteString(" echo \"clean cherry-pick\"\n") + sb.WriteString(" git push origin \"$BRANCH\"\n") + sb.WriteString(" gh pr create \\\n") + sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") + sb.WriteString(" --head \"$BRANCH\" \\\n") + sb.WriteString(" --label cascade-hotfix \\\n") + sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA}\" \\\n") + sb.WriteString(" --body \"$BODY\"\n") + sb.WriteString(" gh pr merge --auto --squash \"$BRANCH\"\n") + sb.WriteString(" else\n") + sb.WriteString(" echo \"::warning::Cherry-pick conflicted; opening resolution PR for manual resolve\"\n") + sb.WriteString(" CONFLICTS=$(git diff --name-only --diff-filter=U)\n") + sb.WriteString(" git add -A\n") + sb.WriteString(" git -c core.editor=true cherry-pick --continue || git commit -m \"hotfix: cherry-pick ${SHORT_SHA} with conflicts\"\n") + sb.WriteString(" git push origin \"$BRANCH\"\n") + sb.WriteString(" CONFLICT_BODY=$(printf '%s\\n\\nConflicting files:\\n%s\\n\\nResolve locally:\\n git fetch && git switch %s\\n # resolve conflicts, then\\n git push --force-with-lease\\n' \"$BODY\" \"$CONFLICTS\" \"$BRANCH\")\n") + sb.WriteString(" gh pr create \\\n") + sb.WriteString(" --base \"env/${TARGET_ENV}\" \\\n") + sb.WriteString(" --head \"$BRANCH\" \\\n") + sb.WriteString(" --label cascade-hotfix-conflict \\\n") + sb.WriteString(" --title \"hotfix(${TARGET_ENV}): cherry-pick ${SHORT_SHA} (conflicts)\" \\\n") + sb.WriteString(" --body \"$CONFLICT_BODY\"\n") + sb.WriteString(" fi\n") +} + +// writeCheckJob emits the parse-config validity gate that runs while a hotfix PR +// against an env/* branch is open or closing. Full required-status-check +// designation against the open PR is the operator's branch-protection +// responsibility, surfaced by the apply job's protection-warning step. +func (g *HotfixGenerator) writeCheckJob(sb *strings.Builder) { + sb.WriteString(" check:\n") + sb.WriteString(" name: Validate Hotfix PR\n") + sb.WriteString(" if: github.event_name == 'pull_request' && github.event.pull_request.merged != true\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + + g.writeSetupCLI(sb) + + sb.WriteString(" - name: Validate manifest\n") + sb.WriteString(" run: |\n") + fmt.Fprintf(sb, " MANIFEST_FILE=\"%s\"\n", g.getManifestFilePath()) + sb.WriteString(" RESULT=$(cascade parse-config --config \"$MANIFEST_FILE\")\n") + sb.WriteString(" echo \"$RESULT\"\n") + sb.WriteString(" VALID=$(echo \"$RESULT\" | jq -r '.valid // false')\n") + sb.WriteString(" if [[ \"$VALID\" != \"true\" ]]; then\n") + sb.WriteString(" echo \"$RESULT\" | jq -r '.errors[]? | \"::error::\" + .'\n") + sb.WriteString(" echo \"::error::Manifest validation failed\"\n") + sb.WriteString(" exit 1\n") + sb.WriteString(" fi\n") + sb.WriteString(" echo \"::notice::Manifest is valid\"\n") +} + +// writeContextJob derives the merged-hotfix target environment from the PR base +// ref and exposes it (plus a rollback sha) as outputs for the build, deploy, and +// finalize stages. +func (g *HotfixGenerator) writeContextJob(sb *strings.Builder) { + sb.WriteString(" context:\n") + sb.WriteString(" name: Hotfix Context\n") + sb.WriteString(" if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix')\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" outputs:\n") + sb.WriteString(" target_env: ${{ steps.ctx.outputs.target_env }}\n") + sb.WriteString(" rollback_sha: ${{ steps.ctx.outputs.rollback_sha }}\n") + sb.WriteString(" steps:\n") + sb.WriteString(" - name: Derive target environment\n") + sb.WriteString(" id: ctx\n") + sb.WriteString(" env:\n") + sb.WriteString(" BASE_REF: ${{ github.event.pull_request.base.ref }}\n") + sb.WriteString(" run: |\n") + sb.WriteString(" TARGET_ENV=\"${BASE_REF#env/}\"\n") + sb.WriteString(" echo \"target_env=${TARGET_ENV}\" >> \"$GITHUB_OUTPUT\"\n") + sb.WriteString(" echo \"rollback_sha=\" >> \"$GITHUB_OUTPUT\"\n") +} + +// mergedHotfixGuard is the if-condition gating the post-merge stages: the PR +// merged and carried the cascade-hotfix label. +func mergedHotfixGuard() string { + return "github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix')" +} + +// writeBuildJobs emits one build job per configured build, run on the merged +// hotfix commit. With no builds configured a single no-op build job is emitted so +// downstream needs: references resolve. +func (g *HotfixGenerator) writeBuildJobs(sb *strings.Builder) { + if len(g.config.Builds) == 0 { + sb.WriteString(" build:\n") + sb.WriteString(" name: Build Hotfix (no-op)\n") + sb.WriteString(" needs: context\n") + fmt.Fprintf(sb, " if: %s\n", mergedHotfixGuard()) + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + sb.WriteString(" - name: No builds configured\n") + sb.WriteString(" run: echo \"No builds configured; skipping build stage\"\n") + return + } + + for _, b := range g.config.Builds { + fmt.Fprintf(sb, " build-%s:\n", b.Name) + fmt.Fprintf(sb, " name: Build %s\n", b.Name) + sb.WriteString(" needs: context\n") + fmt.Fprintf(sb, " if: %s\n", mergedHotfixGuard()) + if b.Workflow != "" { + fmt.Fprintf(sb, " uses: %s\n", normalizeWorkflowPath(b.Workflow)) + sb.WriteString(" with:\n") + sb.WriteString(" sha: ${{ github.event.pull_request.merge_commit_sha }}\n") + sb.WriteString(" target_env: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" secrets: inherit\n") + continue + } + // Inline build fallback: mirror the run-based callback shape. + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" ref: ${{ github.event.pull_request.merge_commit_sha }}\n") + fmt.Fprintf(sb, " - name: Run build %s\n", b.Name) + sb.WriteString(" run: |\n") + if b.Run != "" { + fmt.Fprintf(sb, " %s\n", b.Run) + } else { + fmt.Fprintf(sb, " echo \"build %s\"\n", b.Name) + } + } +} + +// buildJobNames returns the build job identifiers emitted by writeBuildJobs so +// deploy jobs can declare correct needs: references. +func (g *HotfixGenerator) buildJobNames() []string { + if len(g.config.Builds) == 0 { + return []string{"build"} + } + names := make([]string, 0, len(g.config.Builds)) + for _, b := range g.config.Builds { + names = append(names, "build-"+b.Name) + } + return names +} + +// writeDeployJobs emits one deploy job per configured deploy, each gated on the +// merged-hotfix guard and bound to the target GitHub Environment for org +// protection gating. Each deploy is paired with a rollback job mirroring the +// promote workflow's rollback mechanics. +func (g *HotfixGenerator) writeDeployJobs(sb *strings.Builder) { + buildNeeds := g.buildJobNames() + needsList := append([]string{"context"}, buildNeeds...) + needsStr := "[" + strings.Join(needsList, ", ") + "]" + + for _, d := range g.config.Deploys { + fmt.Fprintf(sb, " deploy-%s:\n", d.Name) + fmt.Fprintf(sb, " name: Deploy %s\n", d.Name) + fmt.Fprintf(sb, " needs: %s\n", needsStr) + fmt.Fprintf(sb, " if: %s\n", mergedHotfixGuard()) + // Decision 7: bind to the target GitHub Environment so org protection + // rules (manual approval on prod, etc.) apply to the hotfix deploy. The + // environment: key is invalid on a reusable-workflow (uses:) job, so the + // hotfix deploy is an inline job that carries the gate and invokes the + // deploy via the CLI; the configured deploy workflow path is recorded for + // the operator in the step. + sb.WriteString(" environment: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + fmt.Fprintf(sb, " - name: Run deploy %s\n", d.Name) + sb.WriteString(" env:\n") + sb.WriteString(" DEPLOY_ENV: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" DEPLOY_SHA: ${{ github.event.pull_request.merge_commit_sha }}\n") + sb.WriteString(" run: |\n") + switch { + case d.Workflow != "": + fmt.Fprintf(sb, " echo \"deploy %s via %s to $DEPLOY_ENV at $DEPLOY_SHA\"\n", d.Name, normalizeWorkflowPath(d.Workflow)) + case d.Run != "": + fmt.Fprintf(sb, " %s\n", d.Run) + default: + fmt.Fprintf(sb, " echo \"deploy %s to $DEPLOY_ENV at $DEPLOY_SHA\"\n", d.Name) + } + + // Rollback job: gated on a rollback sha being available and the deploy + // failing, mirroring the promote workflow's rollback shape. + fmt.Fprintf(sb, " rollback-%s:\n", d.Name) + fmt.Fprintf(sb, " name: Rollback %s\n", d.Name) + fmt.Fprintf(sb, " needs: [context, deploy-%s]\n", d.Name) + fmt.Fprintf(sb, " if: always() && needs.context.outputs.rollback_sha != '' && needs.deploy-%s.result == 'failure'\n", d.Name) + sb.WriteString(" environment: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" steps:\n") + fmt.Fprintf(sb, " - name: Rollback deploy %s\n", d.Name) + sb.WriteString(" env:\n") + sb.WriteString(" ROLLBACK_ENV: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" ROLLBACK_SHA: ${{ needs.context.outputs.rollback_sha }}\n") + sb.WriteString(" run: |\n") + fmt.Fprintf(sb, " echo \"rollback %s in $ROLLBACK_ENV to $ROLLBACK_SHA\"\n", d.Name) + } +} + +// deployJobNames returns the deploy job identifiers so the finalize job can +// declare correct needs: references. +func (g *HotfixGenerator) deployJobNames() []string { + names := make([]string, 0, len(g.config.Deploys)) + for _, d := range g.config.Deploys { + names = append(names, "deploy-"+d.Name) + } + return names +} + +// writeFinalizeJob emits the finalize job, run only after all deploys succeed on +// the merged-hotfix path. It runs `cascade hotfix finalize`. +func (g *HotfixGenerator) writeFinalizeJob(sb *strings.Builder) { + deployNeeds := g.deployJobNames() + needsList := append([]string{"context"}, deployNeeds...) + needsStr := "[" + strings.Join(needsList, ", ") + "]" + + sb.WriteString(" finalize:\n") + sb.WriteString(" name: Finalize Hotfix\n") + fmt.Fprintf(sb, " needs: %s\n", needsStr) + fmt.Fprintf(sb, " if: success() && %s\n", mergedHotfixGuard()) + sb.WriteString(" runs-on: ubuntu-latest\n") + sb.WriteString(" env:\n") + sb.WriteString(" TARGET_ENV: ${{ needs.context.outputs.target_env }}\n") + sb.WriteString(" steps:\n") + writeActionStep(sb, g.config, " ", actionCheckout) + sb.WriteString(" with:\n") + sb.WriteString(" fetch-depth: 0\n") + + g.writeSetupCLI(sb) + + sb.WriteString(" - name: Finalize hotfix\n") + sb.WriteString(" run: |\n") + sb.WriteString(" cascade hotfix finalize \\\n") + sb.WriteString(" --target-env \"$TARGET_ENV\" \\\n") + sb.WriteString(" --sha \"${{ github.event.pull_request.merge_commit_sha }}\"\n") +} + +// writeSetupCLI emits the setup-cli step, mirroring the merge-queue generator. +func (g *HotfixGenerator) writeSetupCLI(sb *strings.Builder) { + 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()) +} + +// writeFetchEnvBranches emits a step that fetches the env/* branches and tags so +// the cherry-pick base and the planner have the integration history available. +func (g *HotfixGenerator) writeFetchEnvBranches(sb *strings.Builder) { + sb.WriteString(" - name: Fetch env branches and tags\n") + sb.WriteString(" run: |\n") + sb.WriteString(" git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags\n") +} diff --git a/internal/generate/hotfix_test.go b/internal/generate/hotfix_test.go new file mode 100644 index 0000000..541fa5e --- /dev/null +++ b/internal/generate/hotfix_test.go @@ -0,0 +1,346 @@ +package generate + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// threeEnvHotfixConfig returns a 3-environment manifest config suitable for +// exercising the hotfix generator. The first env ("dev") is the build target and +// is excluded from the hotfix target choices; "test" and "prod" are targets. +func threeEnvHotfixConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "test", "prod"}, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml"}, + }, + Deploys: []config.DeployConfig{ + {Name: "service", Workflow: ".github/workflows/deploy.yaml"}, + }, + } +} + +func TestHotfixGenerator_Enabled(t *testing.T) { + // Two or more environments enables the hotfix workflow. + assert.True(t, NewHotfixGenerator(&config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev", "prod"}, + }, "").Enabled(), "2 envs should enable the hotfix workflow") + + assert.True(t, NewHotfixGenerator(threeEnvHotfixConfig(), "").Enabled(), "3 envs should enable") + + // Below two environments emits nothing. + assert.False(t, NewHotfixGenerator(&config.TrunkConfig{ + TrunkBranch: "main", + Environments: []string{"dev"}, + }, "").Enabled(), "1 env should not enable") + + assert.False(t, NewHotfixGenerator(&config.TrunkConfig{ + TrunkBranch: "main", + }, "").Enabled(), "0 envs should not enable") + + // Nil config reports disabled rather than panicking. + assert.False(t, NewHotfixGenerator(nil, "").Enabled(), "nil config should not enable") +} + +// TestHotfixGenerator_Threshold_EmitsNothingBelowTwoEnvs confirms the Q1 +// generation threshold: with a single env the generator gate is closed. +func TestHotfixGenerator_Threshold_EmitsNothingBelowTwoEnvs(t *testing.T) { + oneEnv := &config.TrunkConfig{TrunkBranch: "main", Environments: []string{"dev"}} + assert.False(t, NewHotfixGenerator(oneEnv, "").Enabled()) + + zeroEnv := &config.TrunkConfig{TrunkBranch: "main"} + assert.False(t, NewHotfixGenerator(zeroEnv, "").Enabled()) +} + +func TestHotfixGenerator_Triggers(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "name: Cascade Hotfix") + assert.Contains(t, content, "workflow_dispatch:") + assert.Contains(t, content, "pull_request:") + assert.Contains(t, content, "types: [closed]") + assert.Contains(t, content, "branches:") + assert.Contains(t, content, "'env/*'") + + // Dispatch inputs. + assert.Contains(t, content, "commit:") + assert.Contains(t, content, "target_env:") + assert.Contains(t, content, "pr_number:") + assert.Contains(t, content, "dry_run:") + + // target_env choice options list non-first envs, not the build target. + assert.Contains(t, content, "- test") + assert.Contains(t, content, "- prod") + assert.NotContains(t, content, "- dev") +} + +func TestHotfixGenerator_Concurrency(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + assert.Contains(t, content, "group: hotfix-") + assert.Contains(t, content, "cancel-in-progress: false") +} + +func TestHotfixGenerator_Permissions(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + assert.Contains(t, content, "contents: write") + assert.Contains(t, content, "pull-requests: write") + assert.Contains(t, content, "actions: read") +} + +func TestHotfixGenerator_Jobs(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, " plan:") + assert.Contains(t, content, " apply:") + assert.Contains(t, content, " check:") + assert.Contains(t, content, " finalize:") + // Build and deploy jobs are emitted per configured callback. + assert.Contains(t, content, "build-app:") + assert.Contains(t, content, "deploy-service:") + + // plan runs cascade hotfix plan; finalize runs cascade hotfix finalize. + assert.Contains(t, content, "cascade hotfix plan") + assert.Contains(t, content, "cascade hotfix finalize") + + // check job runs the parse-config validity gate. + assert.Contains(t, content, "cascade parse-config") +} + +func TestHotfixGenerator_ConflictPath(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "cascade-hotfix-conflict") + assert.Contains(t, content, "--force-with-lease") + assert.Contains(t, content, "Cascade-Hotfix-Target:") + assert.Contains(t, content, "Cascade-Hotfix-Source:") + assert.Contains(t, content, "Cascade-Hotfix-Base:") +} + +func TestHotfixGenerator_CleanPath(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "--label cascade-hotfix") + assert.Contains(t, content, "gh pr merge --auto") +} + +func TestHotfixGenerator_Q2BranchProtectionWarn(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + // gh api branch-protection call (slash URL-encoded as %2F) plus a loud warning. + assert.Contains(t, content, "branches/env") + assert.Contains(t, content, "protection") + assert.Contains(t, content, "::warning::") +} + +func TestHotfixGenerator_Q6ProtectionSuggestions(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + assert.Contains(t, content, "protection_suggestions") +} + +func TestHotfixGenerator_ProdGatingEnvironment(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + // Deploy job must carry an environment: key for org protection gating. + assert.Contains(t, content, "environment:") +} + +func TestHotfixGenerator_DryRunSafety(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + // apply job is skipped on dry-run; plan forwards --dry-run. + assert.Contains(t, content, "dry_run != 'true'") + assert.Contains(t, content, "--dry-run") +} + +func TestHotfixGenerator_MergedLabelGate(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "github.event.pull_request.merged == true") + assert.Contains(t, content, "'cascade-hotfix')") +} + +func TestHotfixGenerator_ValidYAML(t *testing.T) { + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal([]byte(content), &parsed), "emitted workflow must be valid YAML") + assert.Contains(t, parsed, "jobs") + assert.Contains(t, parsed, "on") + assert.Contains(t, parsed, "permissions") +} + +// TestHotfixGenerator_PinModeSHA confirms third-party action refs route through +// the shared pin helper rather than emitting a raw @v4. +func TestHotfixGenerator_PinModeSHA(t *testing.T) { + cfg := threeEnvHotfixConfig() + cfg.PinMode = config.PinModeSHA + gen := NewHotfixGenerator(cfg, "") + content, err := gen.Generate() + require.NoError(t, err) + + assert.Contains(t, content, "uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10") + assert.NotContains(t, content, "uses: actions/checkout@v4") +} + +// TestHotfixGeneratorE2E exercises the manifest -> parse -> generate path: a +// 3-env manifest enables the hotfix workflow; a single-env manifest disables it. +func TestHotfixGeneratorE2E(t *testing.T) { + tmpDir := t.TempDir() + manifestPath := filepath.Join(tmpDir, "manifest.yaml") + + manifest := `ci: + config: + trunk_branch: main + environments: + - dev + - test + - prod +` + require.NoError(t, os.WriteFile(manifestPath, []byte(manifest), 0644)) + + cfg, err := config.ParseWithKey(manifestPath, "ci") + require.NoError(t, err) + + gen := NewHotfixGenerator(cfg, tmpDir) + require.True(t, gen.Enabled(), "3-env manifest should enable the hotfix workflow") + content, err := gen.Generate() + require.NoError(t, err) + assert.Contains(t, content, "name: Cascade Hotfix") + assert.Contains(t, content, "cascade hotfix plan") + assert.Contains(t, content, "- test") + assert.Contains(t, content, "- prod") + + // Single-env manifest reports disabled: nothing is emitted. + single := `ci: + config: + trunk_branch: main + environments: + - dev +` + require.NoError(t, os.WriteFile(manifestPath, []byte(single), 0644)) + singleCfg, err := config.ParseWithKey(manifestPath, "ci") + require.NoError(t, err) + assert.False(t, NewHotfixGenerator(singleCfg, tmpDir).Enabled(), "single-env manifest emits nothing") +} + +// TestHotfixGenerator_Actionlint runs actionlint over the generated workflow +// when the binary is available on PATH. It is skipped otherwise so the unit +// suite stays hermetic. +func TestHotfixGenerator_Actionlint(t *testing.T) { + bin, err := exec.LookPath("actionlint") + if err != nil { + t.Skip("actionlint not installed") + } + + gen := NewHotfixGenerator(threeEnvHotfixConfig(), "") + content, err := gen.Generate() + require.NoError(t, err) + + dir := t.TempDir() + wfDir := filepath.Join(dir, ".github", "workflows") + require.NoError(t, os.MkdirAll(wfDir, 0755)) + wfPath := filepath.Join(wfDir, "cascade-hotfix.yaml") + require.NoError(t, os.WriteFile(wfPath, []byte(content), 0644)) + + // actionlint resolves local reusable-workflow refs (`uses: ./...`) against the + // enclosing git repository root, discovered via `git rev-parse --show-toplevel` + // from the linted file's directory. t.TempDir() can sit inside this repository, + // which would make actionlint resolve `./.github/workflows/.yaml` against the + // real repo root rather than the temp dir. Initialize the temp dir as its own + // git repository so it becomes the project root and resolution stays scoped to + // the stubs written below. + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = dir + require.NoError(t, gitInit.Run(), "git init for actionlint project root") + + // The generated workflow may reference local reusable workflows via + // `uses: ./.github/workflows/.yaml`. actionlint resolves those `./`-prefixed + // refs against the filesystem and validates that the referenced workflows are + // well-formed workflow_call workflows. Write a minimal valid stub for every + // such reference the generator emits so resolution stays honest (rather than + // suppressing the workflow-call check) and the test tracks fixture changes. + writeReusableWorkflowStubs(t, dir, content) + + cmd := exec.Command(bin, wfPath) + cmd.Dir = dir + out, runErr := cmd.CombinedOutput() + assert.NoError(t, runErr, "actionlint reported issues:\n%s", string(out)) +} + +// reusableWorkflowStub is a minimal valid workflow_call reusable workflow that +// satisfies actionlint's resolution of local `uses: ./...` references. It +// declares the inputs the hotfix generator passes to a reusable build workflow +// (sha, target_env) so actionlint can validate the call site's `with:` block +// against the called workflow's declared inputs under full strictness. +const reusableWorkflowStub = `name: Stub +on: + workflow_call: + inputs: + sha: + required: false + type: string + target_env: + required: false + type: string +jobs: + stub: + runs-on: ubuntu-latest + steps: + - run: 'true' +` + +// writeReusableWorkflowStubs scans the generated workflow content for local +// reusable-workflow references of the form `uses: ./.github/workflows/.yaml` +// and writes a minimal valid stub workflow at each referenced path under root, +// so actionlint can resolve and validate every call site. +func writeReusableWorkflowStubs(t *testing.T, root, content string) { + t.Helper() + + const marker = "uses: ./" + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + idx := strings.Index(trimmed, marker) + if idx < 0 { + continue + } + ref := strings.Fields(trimmed[idx+len("uses: "):])[0] + // ref is like "./.github/workflows/build.yaml"; strip the leading "./". + rel := strings.TrimPrefix(ref, "./") + stubPath := filepath.Join(root, filepath.FromSlash(rel)) + require.NoError(t, os.MkdirAll(filepath.Dir(stubPath), 0755)) + require.NoError(t, os.WriteFile(stubPath, []byte(reusableWorkflowStub), 0644)) + } +}