From 1d433ed0e4cefd05df0144c7b966cb2c29874278 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Wed, 8 Jul 2026 00:00:14 -0400 Subject: [PATCH] test(generate): add absolute byte-identical baseline gate for single-component output The prior single-component byte-identical checks are all relative oracles (regenerate and compare, so got and want move together): a template edit passes them all. This adds an absolute snapshot gate, TestByteIdenticalBaseline_SingleComponent, that asserts a realistic multi-env single-component manifest emits output byte-for-byte equal to committed golden fixtures, that the emitted and golden file sets match exactly, and that no components-keyed artifact appears. It reds on any single-component byte drift (proven both by flipping a golden byte and by hiding one). Test and fixtures only; no production change. Regenerate with -update. Refs #285. Signed-off-by: Joshua Temple --- .../generate/byte_identical_baseline_test.go | 175 ++++++ ...ctions__manage-release__action.yaml.golden | 101 ++++ ...hub__workflows__cascade-hotfix.yaml.golden | 420 +++++++++++++++ ...b__workflows__cascade-rollback.yaml.golden | 137 +++++ ...ub__workflows__external-update.yaml.golden | 81 +++ ...github__workflows__orchestrate.yaml.golden | 325 ++++++++++++ .../.github__workflows__promote.yaml.golden | 502 ++++++++++++++++++ 7 files changed, 1741 insertions(+) create mode 100644 internal/generate/byte_identical_baseline_test.go create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-hotfix.yaml.golden create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-rollback.yaml.golden create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__workflows__external-update.yaml.golden create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden create mode 100644 internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden diff --git a/internal/generate/byte_identical_baseline_test.go b/internal/generate/byte_identical_baseline_test.go new file mode 100644 index 00000000..c8e9b932 --- /dev/null +++ b/internal/generate/byte_identical_baseline_test.go @@ -0,0 +1,175 @@ +package generate + +import ( + "flag" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stretchr/testify/require" +) + +// updateGolden rewrites the byte-identical baseline goldens when set. Run: +// +// go test ./internal/generate/ -run TestByteIdenticalBaseline -update +// +// A maintainer regenerates the goldens only for a deliberate, reviewed change to +// the single-component generated output. An accidental perturbation reds the gate +// instead of silently updating. +var updateGolden = flag.Bool("update", false, "update golden files") + +// byteIdenticalGoldenDir holds the frozen single-component output snapshot the +// baseline gate compares against. +var byteIdenticalGoldenDir = filepath.Join("testdata", "byte_identical_baseline") + +// TestByteIdenticalBaseline_SingleComponent is the authoritative byte-identical +// baseline gate for the multi-component campaign. The load-bearing invariant it +// protects: a manifest with NO components: block produces output byte-for-byte +// identical to the pre-component behavior, so no future change to the component +// machinery can silently perturb an existing single-component repo. +// +// This is the consolidated capstone over three per-surface increments, each of +// which locks its own layer with a relative oracle: +// +// - config resolution: TestNoComponents_SingleComponentPathUntouched (a manifest +// with no components: reports no components and resolves the untouched path). +// - state serialization: TestWriteManifestState_ByteIdenticalAcrossCases and +// TestWriteManifestState_PublishDropsPrereleaseByteIdentical (the scoped +// serializer equals the whole-node-replace oracle for every single-component +// case, including the publish delete). +// - workflow generation: TestOrchestrateTargets_SingleComponent_ByteIdentical +// (the fan-out seam returns the pre-component generator for a no-components +// manifest) and TestPlan_MatchesGeneratedBytes (Plan equals generate on disk). +// +// Those siblings are all RELATIVE oracles: they prove the component path reduces +// to the pre-component function, but they move together with any deliberate +// template edit, so none freezes the actual bytes. This gate closes that gap for +// the full workflow-output surface. It generates the complete output for a +// realistic multi-environment single-component manifest that exercises the builds, +// deploys, external, promote, release-publish, hotfix, and rollback surfaces +// (hotfix and rollback auto-enable at two or more environments), then asserts: +// +// 1. every emitted file is byte-for-byte equal to a committed golden fixture, +// so any single-component byte change reds the gate; +// 2. the emitted set carries no components-keyed artifact: no +// orchestrate-.yaml fan-out file, exactly one repo-wide orchestrate.yaml, +// no component-namespaced workflow name, and no components: key in any body; +// 3. the golden set matches the emitted set exactly, so adding or dropping an +// output file also reds the gate. +func TestByteIdenticalBaseline_SingleComponent(t *testing.T) { + // Anchor the golden directory to the package directory before chdir moves the + // working directory into the temp repo, so goldens read from and write to the + // committed testdata tree rather than the temporary generate root. + pkgDir, err := os.Getwd() + require.NoError(t, err) + goldenDir := filepath.Join(pkgDir, byteIdenticalGoldenDir) + + dir := writePlanManifest(t) + chdir(t, dir) + // Resolve symlinks so the absolute composite-action path Plan derives from + // os.Getwd is relative to dir (on macOS /var is a symlink to /private/var). + resolved, err := filepath.EvalSymlinks(dir) + require.NoError(t, err) + dir = resolved + + planned, err := Plan(PlanOptions{ + ConfigPath: ".github/manifest.yaml", + ManifestKey: config.DefaultManifestKey, + ActionFolder: "manage-release", + OutputPath: ".github/workflows/orchestrate.yaml", + PromoteOutputPath: ".github/workflows/promote.yaml", + }) + require.NoError(t, err) + require.NotEmpty(t, planned) + + // Normalize every planned path to a key relative to the repo root. Workflow + // paths are already relative; the composite action path is absolute under + // baseDir (== dir), so rebase it onto dir. + byRel := make(map[string]string, len(planned)) + for _, p := range planned { + abs := p.Path + if !filepath.IsAbs(abs) { + abs = filepath.Join(dir, abs) + } + rel, rerr := filepath.Rel(dir, abs) + require.NoError(t, rerr) + byRel[rel] = p.Content + } + + // (2) No components-keyed artifact anywhere in the emitted set. + orchestrateCount := 0 + for rel, content := range byRel { + base := filepath.Base(rel) + if strings.HasPrefix(base, "orchestrate-") { + t.Errorf("single-component output emitted a component fan-out file %q; the no-components path must not fan out", rel) + } + if rel == filepath.Join(".github", "workflows", "orchestrate.yaml") { + orchestrateCount++ + } + require.NotContainsf(t, content, "Orchestrate CI/CD (", "%s carries a component-namespaced workflow name", rel) + for _, line := range strings.Split(content, "\n") { + require.Falsef(t, strings.HasPrefix(line, "components:"), "%s carries a top-level components: key", rel) + } + } + require.Equalf(t, 1, orchestrateCount, "expected exactly one repo-wide orchestrate.yaml, got %d", orchestrateCount) + + // (1) + (3) Byte-for-byte equality against the committed golden set, and the + // golden set matches the emitted set exactly. + if *updateGolden { + regenBaselineGoldens(t, goldenDir, byRel) + } + + wantFiles := listBaselineGoldens(t, goldenDir) + gotKeys := make([]string, 0, len(byRel)) + for rel := range byRel { + gotKeys = append(gotKeys, goldenName(rel)) + } + sort.Strings(gotKeys) + require.Equalf(t, wantFiles, gotKeys, + "emitted file set differs from the committed golden set; run with -update after a deliberate change") + + for rel, got := range byRel { + goldenPath := filepath.Join(goldenDir, goldenName(rel)) + want, rerr := os.ReadFile(goldenPath) + require.NoErrorf(t, rerr, "missing golden for %s; run with -update", rel) + require.Equalf(t, string(want), got, + "single-component output for %s drifted from the byte-identical baseline; run with -update only for a deliberate change", rel) + } +} + +// goldenName flattens a repo-relative output path into a single golden filename so +// the frozen baseline lives in one flat directory. +func goldenName(rel string) string { + return strings.ReplaceAll(rel, string(os.PathSeparator), "__") + ".golden" +} + +// regenBaselineGoldens rewrites the golden directory to exactly the emitted set, +// pruning any stale golden so a removed output file cannot linger. +func regenBaselineGoldens(t *testing.T, goldenDir string, byRel map[string]string) { + t.Helper() + require.NoError(t, os.RemoveAll(goldenDir)) + require.NoError(t, os.MkdirAll(goldenDir, 0o755)) + for rel, content := range byRel { + require.NoError(t, os.WriteFile(filepath.Join(goldenDir, goldenName(rel)), []byte(content), 0o644)) + } +} + +// listBaselineGoldens returns the sorted golden filenames committed under the +// baseline directory. +func listBaselineGoldens(t *testing.T, goldenDir string) []string { + t.Helper() + entries, err := os.ReadDir(goldenDir) + require.NoError(t, err, "golden directory missing; run with -update") + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + return names +} diff --git a/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden new file mode 100644 index 00000000..b888fb73 --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__actions__manage-release__action.yaml.golden @@ -0,0 +1,101 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow + +name: 'Manage Release' +description: 'Create, update, lock, prerelease, publish, or delete GitHub releases' + +inputs: + repo: + description: 'Repository in owner/repo format' + required: true + action: + description: 'Action to perform: create, update, lock, prerelease, publish, delete' + required: true + environment: + description: 'Target environment' + required: true + sha: + description: 'Release commit SHA' + required: true + tag: + description: 'Tag name' + required: true + changelog: + description: 'Release notes markdown' + required: false + default: '' + token: + description: 'GitHub token with repo permissions' + required: true + previous_tag: + description: 'Previous tag for changelog comparison' + required: false + default: '' + new_tag: + description: 'New semver tag for prerelease action' + required: false + default: '' + delete_tag: + description: 'Tag to delete after publish' + required: false + default: '' + create_tag: + description: 'Create git tag on create action' + required: false + default: 'false' + +outputs: + release_id: + description: 'GitHub release ID' + value: ${{ steps.manage.outputs.release_id }} + release_url: + description: 'API URL to the release' + value: ${{ steps.manage.outputs.release_url }} + html_url: + description: 'Browser URL to the release' + value: ${{ steps.manage.outputs.html_url }} + +runs: + using: 'composite' + steps: + - name: Manage Release + id: manage + shell: bash + env: + INPUT_REPO: ${{ inputs.repo }} + INPUT_ACTION: ${{ inputs.action }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_SHA: ${{ inputs.sha }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_CHANGELOG: ${{ inputs.changelog }} + INPUT_PREVIOUS_TAG: ${{ inputs.previous_tag }} + INPUT_NEW_TAG: ${{ inputs.new_tag }} + INPUT_DELETE_TAG: ${{ inputs.delete_tag }} + INPUT_CREATE_TAG: ${{ inputs.create_tag }} + GITHUB_TOKEN: ${{ inputs.token }} + run: | + # Write changelog to temp file to handle multiline content + CHANGELOG_FILE=$(mktemp) + printf '%s' "$INPUT_CHANGELOG" > "$CHANGELOG_FILE" + + # Build command arguments + CMD_ARGS=( + --repo "$INPUT_REPO" + --action "$INPUT_ACTION" + --environment "$INPUT_ENVIRONMENT" + --sha "$INPUT_SHA" + --tag "$INPUT_TAG" + ) + [[ -n "$INPUT_PREVIOUS_TAG" ]] && CMD_ARGS+=(--previous-tag "$INPUT_PREVIOUS_TAG") + [[ -n "$INPUT_NEW_TAG" ]] && CMD_ARGS+=(--new-tag "$INPUT_NEW_TAG") + [[ -n "$INPUT_DELETE_TAG" ]] && CMD_ARGS+=(--delete-tag "$INPUT_DELETE_TAG") + [[ "$INPUT_CREATE_TAG" == "true" ]] && CMD_ARGS+=(--create-tag) + + # Run CLI + OUTPUT=$(cascade manage-release "${CMD_ARGS[@]}" --changelog-file "$CHANGELOG_FILE") + rm -f "$CHANGELOG_FILE" + + # Parse and write outputs + echo "release_id=$(echo "$OUTPUT" | sed -n '1p')" >> "$GITHUB_OUTPUT" + echo "release_url=$(echo "$OUTPUT" | sed -n '2p')" >> "$GITHUB_OUTPUT" + echo "html_url=$(echo "$OUTPUT" | sed -n '3p')" >> "$GITHUB_OUTPUT" diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-hotfix.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-hotfix.yaml.golden new file mode 100644 index 00000000..4b80dbf3 --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-hotfix.yaml.golden @@ -0,0 +1,420 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow --config .github/manifest.yaml +# +# Cascade hotfix workflow. +# +# Cherry-picks a trunk fix onto a diverged intermediate environment. On +# manual dispatch it plans the cherry-pick, replays the commit onto the +# env/ integration branch via a hotfix// branch, and opens a +# resolution pull request. When that pull request merges it builds, deploys, +# and finalizes the hotfix for the target environment. Clean cherry-picks +# auto-merge; conflicting ones open a labeled pull request for a human to +# resolve locally before the build/deploy stages run. + +name: Cascade Hotfix + +on: + workflow_dispatch: + inputs: + commit: + description: 'Trunk commit SHA(s) to hotfix, comma-delimited (must be on trunk)' + required: true + type: string + target_env: + description: 'Target environment' + required: true + type: choice + options: + - staging + - prod + - canary + pr_number: + description: 'Existing hotfix PR number to replay (optional)' + required: false + type: string + dry_run: + description: 'Dry run (validate only, mutate nothing)' + required: false + type: boolean + default: false + pull_request: + types: [closed] + branches: + - 'env/*' + +permissions: + contents: read + actions: read + +concurrency: + group: ${{ github.event_name == 'pull_request' && format('hotfix-finalize-{0}', github.repository) || format('hotfix-{0}', github.event.inputs.target_env) }} + cancel-in-progress: false + +jobs: + plan: + name: Plan Hotfix + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + actions: read + outputs: + branch: ${{ steps.plan.outputs.branch }} + fix_sha: ${{ steps.plan.outputs.fix_sha }} + base_sha: ${{ steps.plan.outputs.base_sha }} + hotfix_version_candidate: ${{ steps.plan.outputs.hotfix_version_candidate }} + conflict_expected: ${{ steps.plan.outputs.conflict_expected }} + no_op: ${{ steps.plan.outputs.no_op }} + env_sequence: ${{ steps.plan.outputs.env_sequence }} + commits_staging: ${{ steps.plan.outputs.commits_staging }} + no_op_staging: ${{ steps.plan.outputs.no_op_staging }} + base_staging: ${{ steps.plan.outputs.base_staging }} + commits_prod: ${{ steps.plan.outputs.commits_prod }} + no_op_prod: ${{ steps.plan.outputs.no_op_prod }} + base_prod: ${{ steps.plan.outputs.base_prod }} + commits_canary: ${{ steps.plan.outputs.commits_canary }} + no_op_canary: ${{ steps.plan.outputs.no_op_canary }} + base_canary: ${{ steps.plan.outputs.base_canary }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + version: v0.1.0 + token: ${{ github.token }} + - name: Fetch env branches and tags + run: | + git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags + - name: Plan hotfix + id: plan + env: + GH_TOKEN: ${{ github.token }} + HOTFIX_COMMIT: ${{ github.event.inputs.commit }} + HOTFIX_TARGET_ENV: ${{ github.event.inputs.target_env }} + HOTFIX_DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + cascade hotfix plan \ + --config .github/manifest.yaml \ + --commits "$HOTFIX_COMMIT" \ + --target-env "$HOTFIX_TARGET_ENV" \ + --repo "${{ github.repository }}" \ + --dry-run="$HOTFIX_DRY_RUN" \ + --gha-output + - name: Surface protection suggestions + if: steps.plan.outputs.protection_suggestions != '' + env: + SUGGESTIONS: ${{ steps.plan.outputs.protection_suggestions }} + run: | + while IFS= read -r line; do + [ -z "$line" ] && continue + echo "::notice::$line" + done <<< "$SUGGESTIONS" + apply: + name: Apply Hotfix Cherry-Pick + needs: plan + if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' && needs.plan.outputs.env_sequence != '' + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOTFIX_COMMIT: ${{ github.event.inputs.commit }} + HOTFIX_TARGET_ENV: ${{ github.event.inputs.target_env }} + ENV_SEQUENCE: ${{ needs.plan.outputs.env_sequence }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + version: v0.1.0 + token: ${{ github.token }} + - name: Fetch env branches and tags + run: | + git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + - name: Check branch protection on env branch + continue-on-error: true + run: | + for env in $(echo "$ENV_SEQUENCE" | tr ',' '\n'); do + PROT_PATH="repos/${{ github.repository }}/branches/env%2F${env}/protection" + PROT=$(gh api "$PROT_PATH" 2>/dev/null || echo '') + CHECKS=$(echo "$PROT" | jq -r '.required_status_checks.contexts[]? // empty' 2>/dev/null || echo '') + if [ -z "$PROT" ] || [ -z "$CHECKS" ]; then + echo "::warning::Branch env/${env} has no required status checks; hotfix auto-merge will NOT be gated by required checks." + echo "::warning::Configure protection: gh api \"$PROT_PATH\" -X PUT -f required_status_checks.strict=true -F required_status_checks.contexts[]=hotfix-check" + fi + done + - name: Ensure hotfix labels exist + run: | + gh label create cascade-hotfix --color B60205 --description "Cascade hotfix resolution PR" || true + gh label create cascade-hotfix-conflict --color D93F0B --description "Cascade hotfix resolution PR with cherry-pick conflicts" || true + - name: Cherry-pick and open resolution PRs + env: + COMMITS_STAGING: ${{ needs.plan.outputs.commits_staging }} + BASE_STAGING: ${{ needs.plan.outputs.base_staging }} + COMMITS_PROD: ${{ needs.plan.outputs.commits_prod }} + BASE_PROD: ${{ needs.plan.outputs.base_prod }} + COMMITS_CANARY: ${{ needs.plan.outputs.commits_canary }} + BASE_CANARY: ${{ needs.plan.outputs.base_canary }} + run: | + REMAINING="$ENV_SEQUENCE" + for env in $(echo "$ENV_SEQUENCE" | tr ',' '\n'); do + REMAINING="${REMAINING#"$env"}" + REMAINING="${REMAINING#,}" + case "$env" in + staging) COMMITS="$COMMITS_STAGING"; BASE="$BASE_STAGING" ;; + prod) COMMITS="$COMMITS_PROD"; BASE="$BASE_PROD" ;; + canary) COMMITS="$COMMITS_CANARY"; BASE="$BASE_CANARY" ;; + esac + if [ -z "$COMMITS" ]; then + echo "::notice::env/${env}: all commits already present, skipping" + continue + fi + FIRST_COMMIT=$(echo "$COMMITS" | cut -d',' -f1) + SHORT_SHA=$(echo "$FIRST_COMMIT" | cut -c1-8) + BRANCH="hotfix/${env}/${SHORT_SHA}" + if ! git rev-parse --verify --quiet "refs/remotes/origin/env/${env}" >/dev/null; then + git push origin "${BASE}:refs/heads/env/${env}" + git fetch origin "+refs/heads/env/${env}:refs/remotes/origin/env/${env}" + fi + git switch -c "$BRANCH" "$BASE" + BODY=$(printf 'Cascade-Hotfix-Target: %s\nCascade-Hotfix-Source: %s\nCascade-Hotfix-Base: %s\n' "$env" "$COMMITS" "$BASE") + CLEAN=true + CONFLICT_COMMIT="" + CONFLICTS="" + for commit in $(echo "$COMMITS" | tr ',' '\n'); do + if ! git cherry-pick -x "$commit"; then + CLEAN=false + CONFLICT_COMMIT="$commit" + CONFLICTS=$(git diff --name-only --diff-filter=U) + git add -A + git -c core.editor=true cherry-pick --continue || git commit -m "hotfix: cherry-pick $(echo "$commit" | cut -c1-8) with conflicts" + break + fi + done + if $CLEAN; then + git push origin "$BRANCH" + gh pr create \ + --base "env/${env}" \ + --head "$BRANCH" \ + --label cascade-hotfix \ + --title "hotfix(${env}): cherry-pick ${SHORT_SHA}" \ + --body "$BODY" + ATTEMPTS=20 + SLEEP=15 + MERGED=false + for i in $(seq 1 "$ATTEMPTS"); do + STATE=$(gh pr view "$BRANCH" --json mergeable,mergeStateStatus -q '.mergeable + " " + .mergeStateStatus' 2>/dev/null || echo "UNKNOWN UNKNOWN") + MERGEABLE=$(echo "$STATE" | cut -d' ' -f1) + STATUS=$(echo "$STATE" | cut -d' ' -f2) + echo "::notice::resolution PR mergeable=$MERGEABLE state=$STATUS (attempt $i/$ATTEMPTS)" + if [ "$MERGEABLE" = "MERGEABLE" ] && [ "$STATUS" != "BLOCKED" ]; then + if gh pr merge --squash --delete-branch "$BRANCH"; then + MERGED=true + break + fi + fi + sleep "$SLEEP" + done + if [ "$MERGED" != "true" ]; then + echo "::error::Resolution PR for $BRANCH did not become mergeable within the timeout; merge it manually to run the hotfix finalize chain" + exit 1 + fi + git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags + else + echo "::warning::Cherry-pick conflicted on env/${env}; opening resolution PR and halting chain" + git push origin "$BRANCH" + CONFLICT_BODY=$(printf '%s\n\nConflicting files:\n%s\n\nThis resolves %s.\n\nEnvironments still pending: %s.\n\nAfter merge, re-engage the hotfix workflow targeting %s.\n\nResolve locally:\n git fetch && git switch %s\n # resolve conflicts, then\n git push --force-with-lease\n' "$BODY" "$CONFLICTS" "$env" "$REMAINING" "$HOTFIX_TARGET_ENV" "$BRANCH") + gh pr create \ + --base "env/${env}" \ + --head "$BRANCH" \ + --label cascade-hotfix-conflict \ + --title "hotfix(${env}): cherry-pick $(echo "$CONFLICT_COMMIT" | cut -c1-8) (conflicts)" \ + --body "$CONFLICT_BODY" + break + fi + done + check: + name: Validate Hotfix PR + if: github.event_name == 'pull_request' && github.event.pull_request.merged != true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + version: v0.1.0 + token: ${{ github.token }} + - name: Validate manifest + run: | + MANIFEST_FILE=".github/manifest.yaml" + RESULT=$(cascade parse-config --config "$MANIFEST_FILE") + echo "$RESULT" + VALID=$(echo "$RESULT" | jq -r '.valid // false') + if [[ "$VALID" != "true" ]]; then + echo "$RESULT" | jq -r '.errors[]? | "::error::" + .' + echo "::error::Manifest validation failed" + exit 1 + fi + echo "::notice::Manifest is valid" + context: + name: Hotfix Context + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + runs-on: ubuntu-latest + outputs: + target_env: ${{ steps.ctx.outputs.target_env }} + fix_sha: ${{ steps.ctx.outputs.fix_sha }} + base_sha: ${{ steps.ctx.outputs.base_sha }} + rollback_sha: ${{ steps.ctx.outputs.rollback_sha }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Derive target environment and hotfix SHAs + id: ctx + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + TARGET_ENV="${BASE_REF#env/}" + FIX_SHA=$(printf '%s\n' "$PR_BODY" | grep -m1 '^Cascade-Hotfix-Source:' | sed 's/^Cascade-Hotfix-Source:[[:space:]]*//' || true) + BASE_SHA=$(printf '%s\n' "$PR_BODY" | grep -m1 '^Cascade-Hotfix-Base:' | sed 's/^Cascade-Hotfix-Base:[[:space:]]*//' || true) + MANIFEST_FILE=".github/manifest.yaml" + MANIFEST_KEY="ci" + ROLLBACK_SHA=$(yq eval ".$MANIFEST_KEY.state.${TARGET_ENV}.sha // \"\"" "$MANIFEST_FILE") + if [ "$ROLLBACK_SHA" = "null" ]; then ROLLBACK_SHA=""; fi + { + echo "target_env=${TARGET_ENV}" + echo "fix_sha=${FIX_SHA}" + echo "base_sha=${BASE_SHA}" + echo "rollback_sha=${ROLLBACK_SHA}" + } >> "$GITHUB_OUTPUT" + build-image: + name: Build image + needs: context + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + permissions: + attestations: write + uses: ./.github/workflows/image-build.yaml + with: + sha: ${{ github.event.pull_request.merge_commit_sha }} + target_env: ${{ needs.context.outputs.target_env }} + + build-bundle: + name: Build bundle + needs: context + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + uses: ./.github/workflows/bundle-build.yaml + with: + sha: ${{ github.event.pull_request.merge_commit_sha }} + target_env: ${{ needs.context.outputs.target_env }} + + build-docs: + name: Build docs + needs: context + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + uses: ./.github/workflows/bundle-build.yaml + with: + sha: ${{ github.event.pull_request.merge_commit_sha }} + target_env: ${{ needs.context.outputs.target_env }} + + deploy-app: + name: Deploy app + needs: [context, build-image, build-bundle, build-docs] + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + environment: ${{ needs.context.outputs.target_env }} + runs-on: ubuntu-latest + steps: + - name: Run deploy app + env: + DEPLOY_ENV: ${{ needs.context.outputs.target_env }} + DEPLOY_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + echo "deploy app via ./.github/workflows/deploy.yaml to $DEPLOY_ENV at $DEPLOY_SHA" + rollback-app: + name: Rollback app + needs: [context, deploy-app] + if: always() && needs.context.outputs.rollback_sha != '' && needs.deploy-app.result == 'failure' + environment: ${{ needs.context.outputs.target_env }} + runs-on: ubuntu-latest + steps: + - name: Rollback deploy app + env: + ROLLBACK_ENV: ${{ needs.context.outputs.target_env }} + ROLLBACK_SHA: ${{ needs.context.outputs.rollback_sha }} + run: | + echo "rollback app in $ROLLBACK_ENV to $ROLLBACK_SHA" + deploy-sidecar: + name: Deploy sidecar + needs: [context, build-image, build-bundle, build-docs] + if: github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + environment: ${{ needs.context.outputs.target_env }} + runs-on: ubuntu-latest + steps: + - name: Run deploy sidecar + env: + DEPLOY_ENV: ${{ needs.context.outputs.target_env }} + DEPLOY_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + echo "deploy sidecar via ./.github/workflows/deploy.yaml to $DEPLOY_ENV at $DEPLOY_SHA" + rollback-sidecar: + name: Rollback sidecar + needs: [context, deploy-sidecar] + if: always() && needs.context.outputs.rollback_sha != '' && needs.deploy-sidecar.result == 'failure' + environment: ${{ needs.context.outputs.target_env }} + runs-on: ubuntu-latest + steps: + - name: Rollback deploy sidecar + env: + ROLLBACK_ENV: ${{ needs.context.outputs.target_env }} + ROLLBACK_SHA: ${{ needs.context.outputs.rollback_sha }} + run: | + echo "rollback sidecar in $ROLLBACK_ENV to $ROLLBACK_SHA" + finalize: + name: Finalize Hotfix + needs: [context, deploy-app, deploy-sidecar] + if: success() && github.event_name == 'pull_request' && github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'cascade-hotfix') + runs-on: ubuntu-latest + permissions: + contents: write + env: + TARGET_ENV: ${{ needs.context.outputs.target_env }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + FIX_SHA: ${{ needs.context.outputs.fix_sha }} + BASE_SHA: ${{ needs.context.outputs.base_sha }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + version: v0.1.0 + token: ${{ github.token }} + - name: Fetch env branches and tags + run: | + git fetch origin '+refs/heads/env/*:refs/remotes/origin/env/*' --tags + - name: Finalize hotfix + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + cascade hotfix finalize \ + --config .github/manifest.yaml \ + --target-env "$TARGET_ENV" \ + --merge-sha "$MERGE_SHA" \ + --fix-sha "$FIX_SHA" \ + --base-sha "$BASE_SHA" diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-rollback.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-rollback.yaml.golden new file mode 100644 index 00000000..8611ed95 --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__cascade-rollback.yaml.golden @@ -0,0 +1,137 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow --config .github/manifest.yaml +# +# Manual rollback: re-deploy a prior version or SHA to an environment. +# +# A read-only preflight resolves the target (from live state, the +# deploy-history ring, or manifest history), the deploy stage re-runs the +# configured deploy callbacks keyed on the resolved SHA, and finalize writes +# the rolled-back state back to trunk, marking the environment diverged until +# a forward promotion rejoins it. + +name: Rollback + +on: + workflow_dispatch: + inputs: + environment: + description: 'Environment to roll back' + required: true + type: choice + options: + - staging + - prod + - canary + target: + description: 'Prior version or SHA (optional; defaults to the previous version)' + required: false + type: string + default: '' + deployable: + description: 'Limit rollback to one deployable (optional)' + required: false + type: string + default: '' + dry_run: + description: 'Resolve and print without deploying' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: "${{ github.workflow }}" + cancel-in-progress: false + +jobs: + preflight: + name: Pre-flight Check + runs-on: ubuntu-latest + outputs: + target_env: ${{ steps.preflight.outputs.target_env }} + target_sha: ${{ steps.preflight.outputs.target_sha }} + target_version: ${{ steps.preflight.outputs.target_version }} + target_source: ${{ steps.preflight.outputs.target_source }} + can_proceed: ${{ steps.preflight.outputs.can_proceed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Resolve Target + id: preflight + env: + ENVIRONMENT: ${{ github.event.inputs.environment }} + TARGET: ${{ github.event.inputs.target }} + DEPLOYABLE: ${{ github.event.inputs.deployable }} + run: | + cascade rollback preflight \ + --config .github/manifest.yaml \ + --env "$ENVIRONMENT" \ + --to "$TARGET" \ + --deployable "$DEPLOYABLE" \ + --gha-output + - name: Fail if Cannot Proceed + if: steps.preflight.outputs.can_proceed == 'false' + run: exit 1 + - name: Report Resolved Source + run: echo "rollback resolved from ${{ steps.preflight.outputs.target_source }}" + + deploy-app: + name: Deploy app + needs: [preflight] + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.deployable == '' || github.event.inputs.deployable == 'app') }} + permissions: + id-token: write + packages: read + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.target_sha }} + + deploy-sidecar: + name: Deploy sidecar + needs: [preflight] + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.deployable == '' || github.event.inputs.deployable == 'sidecar') }} + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.target_sha }} + + finalize: + name: Finalize + needs: [preflight, deploy-app, deploy-sidecar] + if: always() && needs.preflight.result == 'success' && github.event.inputs.dry_run != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Finalize Rollback + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + DEPLOYABLE: ${{ github.event.inputs.deployable }} + DEPLOY_RESULT_APP: ${{ needs.deploy-app.result }} + DEPLOY_RESULT_SIDECAR: ${{ needs.deploy-sidecar.result }} + run: | + cascade rollback finalize \ + --config .github/manifest.yaml \ + --env "${{ needs.preflight.outputs.target_env }}" \ + --to "${{ needs.preflight.outputs.target_sha }}" \ + --deployable "$DEPLOYABLE" \ + --commit-push diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__external-update.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__external-update.yaml.golden new file mode 100644 index 00000000..f648c02d --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__external-update.yaml.golden @@ -0,0 +1,81 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow --config .github/manifest.yaml + +name: External Update + +run-name: External Update ${{ inputs.deploy_name }} ${{ inputs.sha }} + +on: + workflow_dispatch: + inputs: + source_repo: + description: 'Source repository (e.g., org/cdk-infra)' + type: string + required: true + deploy_name: + description: 'Deploy name (e.g., cdk)' + type: string + required: true + environment: + description: 'Target environment' + type: string + required: true + sha: + description: 'Commit SHA from source repo' + type: string + required: true + version: + description: 'Version from source repo (optional)' + type: string + required: false + artifacts: + description: 'Artifacts JSON (optional)' + type: string + required: false + +permissions: + contents: write + +concurrency: + group: cascade-external-${{ inputs.deploy_name }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + update: + name: Update External State + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Update External State + env: + SOURCE_REPO: ${{ inputs.source_repo }} + DEPLOY_NAME: ${{ inputs.deploy_name }} + ENVIRONMENT: ${{ inputs.environment }} + SHA: ${{ inputs.sha }} + VERSION: ${{ inputs.version }} + ARTIFACTS: ${{ inputs.artifacts }} + run: | + cascade external update \ + --config .github/manifest.yaml \ + --manifest-key ci \ + --source-repo "$SOURCE_REPO" \ + --deploy-name "$DEPLOY_NAME" \ + --environment "$ENVIRONMENT" \ + --sha "$SHA" \ + --version "$VERSION" \ + --artifacts "$ARTIFACTS" diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden new file mode 100644 index 00000000..c59686af --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__orchestrate.yaml.golden @@ -0,0 +1,325 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow --config .github/manifest.yaml + +name: Orchestrate CI/CD + +on: + push: + branches: [main] + paths: + - 'bundle/**' + - 'docs/**' + - 'sidecar/**' + - 'src/**' + workflow_dispatch: + inputs: + environment: + description: 'Target environment' + type: choice + options: + - dev + - staging + - prod + - canary + default: 'dev' + dry_run: + description: 'Dry run mode' + type: boolean + default: false + +concurrency: + group: orchestrate-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + setup: + name: Setup + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + run_build_image: ${{ steps.setup.outputs.run_build_image }} + run_build_bundle: ${{ steps.setup.outputs.run_build_bundle }} + run_build_docs: ${{ steps.setup.outputs.run_build_docs }} + run_deploy_app: ${{ steps.setup.outputs.run_deploy_app }} + run_deploy_sidecar: ${{ steps.setup.outputs.run_deploy_sidecar }} + head_sha: ${{ steps.setup.outputs.head_sha }} + version: ${{ steps.setup.outputs.version }} + previous_tag: ${{ steps.setup.outputs.previous_tag }} + changelog_base_sha: ${{ steps.setup.outputs.changelog_base_sha }} + base_build_image: ${{ steps.setup.outputs.base_build_image }} + base_build_bundle: ${{ steps.setup.outputs.base_build_bundle }} + base_build_docs: ${{ steps.setup.outputs.base_build_docs }} + base_deploy_app: ${{ steps.setup.outputs.base_deploy_app }} + base_deploy_sidecar: ${{ steps.setup.outputs.base_deploy_sidecar }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Run Setup + id: setup + env: + ENVIRONMENT: ${{ github.event.inputs.environment || 'dev' }} + run: | + cascade orchestrate setup \ + --environment "$ENVIRONMENT" \ + --config .github/manifest.yaml \ + --gha-output + + build-image: + name: Build (image) + needs: [setup] + if: | + needs.setup.outputs.run_build_image == 'true' + strategy: + matrix: + arch: ["amd64", "arm64"] + os: ["linux", "darwin", "windows"] + permissions: + attestations: write + uses: ./.github/workflows/image-build.yaml + with: + environment: ${{ github.event.inputs.environment || 'dev' }} + arch: ${{ matrix.arch }} + os: ${{ matrix.os }} + + build-bundle: + name: Build (bundle) + needs: [setup, build-image] + if: | + needs.setup.outputs.run_build_bundle == 'true' && + needs.build-image.result == 'success' + uses: ./.github/workflows/bundle-build.yaml + with: + environment: ${{ github.event.inputs.environment || 'dev' }} + image: ${{ needs.build-image.outputs.image }} + + build-docs: + name: Build (docs) + needs: [setup] + if: | + needs.setup.outputs.run_build_docs == 'true' + uses: ./.github/workflows/bundle-build.yaml + with: + environment: ${{ github.event.inputs.environment || 'dev' }} + + deploy-app: + name: Deploy (app) + needs: [setup, build-bundle] + if: | + needs.build-bundle.result == 'success' + permissions: + id-token: write + packages: read + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ github.event.inputs.environment || 'dev' }} + bundle: ${{ needs.build-bundle.outputs.bundle }} + + deploy-sidecar: + name: Deploy (sidecar) + needs: [setup] + if: | + needs.setup.outputs.run_deploy_sidecar == 'true' + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ github.event.inputs.environment || 'dev' }} + + finalize: + name: Finalize + needs: [setup, build-image, build-bundle, build-docs, deploy-app, deploy-sidecar] + if: always() && needs.setup.result == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + outputs: + bundle_bundle: ${{ needs.build-bundle.outputs.bundle }} + docs_bundle: ${{ needs.build-docs.outputs.bundle }} + image_image: ${{ needs.build-image.outputs.image }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Generate Summary + run: | + echo "## Orchestration Complete" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Callback Results" >> "$GITHUB_STEP_SUMMARY" + echo "| Callback | Result | On Failure |" >> "$GITHUB_STEP_SUMMARY" + echo "|----------|--------|------------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Build (image) | ${{ needs.build-image.result }} | abort |" >> "$GITHUB_STEP_SUMMARY" + echo "| Build (bundle) | ${{ needs.build-bundle.result }} | abort |" >> "$GITHUB_STEP_SUMMARY" + echo "| Build (docs) | ${{ needs.build-docs.result }} | abort |" >> "$GITHUB_STEP_SUMMARY" + echo "| Deploy (app) | ${{ needs.deploy-app.result }} | abort |" >> "$GITHUB_STEP_SUMMARY" + echo "| Deploy (sidecar) | ${{ needs.deploy-sidecar.result }} | abort |" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Outputs" >> "$GITHUB_STEP_SUMMARY" + HAS_OUTPUTS=false + if [[ -n "${{ needs.build-bundle.outputs.bundle }}" ]]; then + if [[ "$HAS_OUTPUTS" == "false" ]]; then + echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + HAS_OUTPUTS=true + fi + echo "| bundle_bundle | ${{ needs.build-bundle.outputs.bundle }} |" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -n "${{ needs.build-docs.outputs.bundle }}" ]]; then + if [[ "$HAS_OUTPUTS" == "false" ]]; then + echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + HAS_OUTPUTS=true + fi + echo "| docs_bundle | ${{ needs.build-docs.outputs.bundle }} |" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ -n "${{ needs.build-image.outputs.image }}" ]]; then + if [[ "$HAS_OUTPUTS" == "false" ]]; then + echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" + HAS_OUTPUTS=true + fi + echo "| image_image | ${{ needs.build-image.outputs.image }} |" >> "$GITHUB_STEP_SUMMARY" + fi + if [[ "$HAS_OUTPUTS" == "false" ]]; then + echo "_No outputs produced_" >> "$GITHUB_STEP_SUMMARY" + fi + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Generate Changelog + id: changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Use changelog_base_sha which compares this env to next env + # This shows commits in this env NOT yet promoted to next env + RESULT=$(cascade generate-changelog \ + --base-sha "${{ needs.setup.outputs.changelog_base_sha }}" \ + --head-sha "${{ needs.setup.outputs.head_sha }}" \ + --repo "${{ github.repository }}") + echo "changelog<> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + - name: Manage Release + uses: ./.github/actions/manage-release + with: + repo: ${{ github.repository }} + action: update + tag: ${{ needs.setup.outputs.version }} + create_tag: 'true' + environment: ${{ github.event.inputs.environment || 'dev' }} + sha: ${{ needs.setup.outputs.head_sha }} + changelog: ${{ steps.changelog.outputs.changelog }} + previous_tag: ${{ needs.setup.outputs.previous_tag }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Update Manifest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ needs.setup.outputs.head_sha }} + VERSION: ${{ needs.setup.outputs.version }} + ENVIRONMENT: ${{ github.event.inputs.environment || 'dev' }} + APP_RESULT: ${{ needs.deploy-app.result }} + SIDECAR_RESULT: ${{ needs.deploy-sidecar.result }} + run: | + MANIFEST_FILE=".github/manifest.yaml" + MANIFEST_KEY="ci" + if [[ ! -f "$MANIFEST_FILE" ]]; then + echo "No $MANIFEST_FILE found - skipping state update" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + BRANCH="${GITHUB_REF##refs/heads/}" + + apply_state_edits() { + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + # Update environment-level state (committed, not deployed) + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.sha = \"$HEAD_SHA\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.version = \"$VERSION\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.committed_at = \"$TIMESTAMP\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.committed_by = \"${{ github.actor }}\"" "$MANIFEST_FILE" + if [[ "$APP_RESULT" == "success" ]]; then + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.app.sha = \"$HEAD_SHA\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.app.deployed_at = \"$TIMESTAMP\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.app.deployed_by = \"${{ github.actor }}\"" "$MANIFEST_FILE" + fi + if [[ "$SIDECAR_RESULT" == "success" ]]; then + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.sidecar.sha = \"$HEAD_SHA\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.sidecar.deployed_at = \"$TIMESTAMP\"" "$MANIFEST_FILE" + yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.deploys.sidecar.deployed_by = \"${{ github.actor }}\"" "$MANIFEST_FILE" + fi + } + + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then + # act/gitea e2e: no GitHub API, and the trunk is neither protected nor + # signature-checked, so push the state commit directly with retries. + for attempt in 1 2 3 4 5; do + git fetch origin "$BRANCH" + git reset --hard "origin/$BRANCH" + apply_state_edits + if git diff --quiet "$MANIFEST_FILE"; then + echo "No state changes" + exit 0 + fi + git add "$MANIFEST_FILE" + git commit -m "chore: update state for $ENVIRONMENT [skip ci]" + if git push origin "HEAD:$BRANCH"; then + echo "Pushed state on attempt $attempt" + exit 0 + fi + echo "Push attempt $attempt rejected (likely concurrent run); retrying..." >&2 + sleep $((RANDOM % 5 + 2)) + done + echo "::error::Failed to push state after 5 attempts" >&2 + exit 1 + fi + + # Real GitHub: write state through the Contents REST API. API commits are + # signed by GitHub (Verified) and, with a bypass-capable token, update the + # trunk even when a required status check protects it. + for attempt in 1 2 3 4 5; do + git fetch origin "$BRANCH" + git reset --hard "origin/$BRANCH" + apply_state_edits + if git diff --quiet "$MANIFEST_FILE"; then + echo "No state changes" + exit 0 + fi + CONTENT_B64=$(base64 -w0 "$MANIFEST_FILE" 2>/dev/null || base64 "$MANIFEST_FILE" | tr -d '\n') + CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$MANIFEST_FILE?ref=$BRANCH" --jq '.sha' 2>/dev/null || true) + API_ARGS=("repos/${{ github.repository }}/contents/$MANIFEST_FILE" -X PUT + -f "message=chore: update state for $ENVIRONMENT [skip ci]" + -f "content=$CONTENT_B64" + -f "branch=$BRANCH" + -f "author[name]=github-actions[bot]" + -f "author[email]=github-actions[bot]@users.noreply.github.com" + -f "committer[name]=github-actions[bot]" + -f "committer[email]=github-actions[bot]@users.noreply.github.com") + if [[ -n "$CURRENT_SHA" ]]; then + API_ARGS+=(-f "sha=$CURRENT_SHA") + fi + if gh api "${API_ARGS[@]}" >/dev/null; then + echo "Pushed state via API on attempt $attempt" + exit 0 + fi + echo "State write attempt $attempt failed (likely concurrent run); retrying..." >&2 + sleep $((RANDOM % 5 + 2)) + done + echo "::error::Failed to write state via API after 5 attempts" >&2 + exit 1 + - name: Check for Failures + if: contains(fromJSON('["failure", "cancelled"]'), needs.build-image.result) || contains(fromJSON('["failure", "cancelled"]'), needs.build-bundle.result) || contains(fromJSON('["failure", "cancelled"]'), needs.build-docs.result) || contains(fromJSON('["failure", "cancelled"]'), needs.deploy-app.result) || contains(fromJSON('["failure", "cancelled"]'), needs.deploy-sidecar.result) + run: | + echo "One or more critical callbacks failed or were cancelled" + exit 1 diff --git a/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden new file mode 100644 index 00000000..e78ae498 --- /dev/null +++ b/internal/generate/testdata/byte_identical_baseline/.github__workflows__promote.yaml.golden @@ -0,0 +1,502 @@ +# AUTO-GENERATED by cascade - DO NOT EDIT MANUALLY +# Regenerate with: cascade generate-workflow --config .github/manifest.yaml +# +# Environments: dev → staging → prod → canary +# +# Promotion modes: +# default - Sequential single-step promotion (each env → immediate next) +# Supports --force to continue on failure +# cascade - Atomic cascade from source to target (e.g., dev-to-prod) +# All intermediate environments updated with same artifact +# Fails entirely if any step fails (no partial state) +# +# Release states (based on position): +# prod (second-from-top) = prerelease +# canary (top) = released +# +# Cascade targets (for cascade mode): +# dev-to-staging - Promotes dev → staging +# dev-to-prod - Promotes dev → prod (also updates staging) +# dev-to-canary - Promotes dev → canary (also updates staging, prod) +# staging-to-prod - Promotes staging → prod +# staging-to-canary - Promotes staging → canary (also updates prod) +# prod-to-canary - Promotes prod → canary +# +# Breaking changes: +# Breaking changes block at: pre-release → release AND release → prod +# Check 'allow_breaking_changes' to proceed with breaking changes. + +name: Promote + +on: + workflow_dispatch: + inputs: + mode: + description: 'Promotion mode - default (sequential) or select a cascade target' + type: choice + required: true + options: + - default + - dev-to-staging + - dev-to-prod + - dev-to-canary + - staging-to-prod + - staging-to-canary + - prod-to-canary + default: default + force: + description: 'Continue on failure (default mode only)' + type: boolean + default: false + allow_breaking_changes: + description: 'Required if promoting breaking changes past pre-release → release' + type: boolean + default: false + dry_run: + description: 'Dry run mode' + type: boolean + default: false + deploys: + description: 'Deploys to promote (comma-separated names or "all")' + type: string + default: 'all' + rollback_on_failure: + description: 'Revert successful deploys if any fails (atomic promotion)' + type: boolean + default: true + allow_downgrade: + description: 'Permit promoting an older version (downgrade); prod always requires this' + type: boolean + default: false + # Per-deploy selection (deprecated, use 'deploys' input instead) + deploy_app: + description: '[Deprecated] Include app deployment' + type: boolean + default: true + deploy_sidecar: + description: '[Deprecated] Include sidecar deployment' + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: "${{ github.workflow }}" + cancel-in-progress: false + +jobs: + preflight: + name: Pre-flight Check + runs-on: ubuntu-latest + outputs: + source_env: ${{ steps.preflight.outputs.source_env }} + target_env: ${{ steps.preflight.outputs.target_env }} + source_sha: ${{ steps.preflight.outputs.source_sha }} + source_version: ${{ steps.preflight.outputs.source_version }} + source_image_tag: ${{ steps.preflight.outputs.source_image_tag }} + source_image_digest: ${{ steps.preflight.outputs.source_image_digest }} + changelog_base_sha: ${{ steps.preflight.outputs.changelog_base_sha }} + rollback_sha: ${{ steps.preflight.outputs.rollback_sha }} + rollback_on_failure: ${{ steps.preflight.outputs.rollback_on_failure }} + envs_to_update: ${{ steps.preflight.outputs.envs_to_update }} + skipped_envs: ${{ steps.preflight.outputs.skipped_envs }} + deploys_to_run: ${{ steps.preflight.outputs.deploys_to_run }} + external_deploys_to_run: ${{ steps.preflight.outputs.external_deploys_to_run }} + is_prerelease_env: ${{ steps.preflight.outputs.is_prerelease_env }} + is_final_env: ${{ steps.preflight.outputs.is_final_env }} + is_cascade: ${{ steps.preflight.outputs.is_cascade }} + release_action: ${{ steps.preflight.outputs.release_action }} + has_prod_deployment: ${{ steps.preflight.outputs.has_prod_deployment }} + prod_sha: ${{ steps.preflight.outputs.prod_sha }} + prod_version: ${{ steps.preflight.outputs.prod_version }} + has_breaking: ${{ steps.preflight.outputs.has_breaking }} + can_proceed: ${{ steps.preflight.outputs.can_proceed }} + promotion_result: ${{ steps.preflight.outputs.promotion_result }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Run Preflight + id: preflight + env: + PROMOTION_MODE: ${{ github.event.inputs.mode }} + PROMOTION_FORCE: ${{ github.event.inputs.force }} + ALLOW_BREAKING: ${{ github.event.inputs.allow_breaking_changes }} + DEPLOYS: ${{ github.event.inputs.deploys }} + ROLLBACK_ON_FAILURE: ${{ github.event.inputs.rollback_on_failure }} + ALLOW_DOWNGRADE: ${{ github.event.inputs.allow_downgrade }} + DEPLOY_APP: ${{ github.event.inputs.deploy_app }} + DEPLOY_SIDECAR: ${{ github.event.inputs.deploy_sidecar }} + run: | + cascade promote preflight \ + --mode "${PROMOTION_MODE:-default}" \ + --force="${PROMOTION_FORCE:-false}" \ + --config .github/manifest.yaml \ + --allow-breaking="${ALLOW_BREAKING:-false}" \ + --deploys="${DEPLOYS:-all}" \ + --rollback-on-failure="${ROLLBACK_ON_FAILURE:-true}" \ + --allow-downgrade="${ALLOW_DOWNGRADE:-false}" \ + --gha-output + - name: Fail if Cannot Proceed + if: steps.preflight.outputs.can_proceed == 'false' + run: exit 1 + + promote: + name: Promote + needs: preflight + if: ${{ github.event.inputs.dry_run != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Validate Promotion + env: + MODE: ${{ github.event.inputs.mode }} + run: | + echo "Promotion validated by preflight job" + echo "Mode: $MODE" + echo "Source: ${{ needs.preflight.outputs.source_env }}" + echo "Final Env: ${{ needs.preflight.outputs.target_env }}" + echo "::notice::Promotion validation completed successfully" + + deploy-app: + name: Deploy app + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.deploys_to_run), 'app') }} + permissions: + id-token: write + packages: read + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + + deploy-sidecar: + name: Deploy sidecar + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.deploys_to_run), 'sidecar') }} + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + + deploy-app-prod: + name: Deploy app (canary) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }} + permissions: + id-token: write + packages: read + uses: ./.github/workflows/deploy.yaml + with: + environment: canary + sha: ${{ needs.preflight.outputs.prod_sha }} + + deploy-sidecar-prod: + name: Deploy sidecar (canary) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }} + uses: ./.github/workflows/deploy.yaml + with: + environment: canary + sha: ${{ needs.preflight.outputs.prod_sha }} + + deploy-cdk: + name: Deploy cdk (external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.external_deploys_to_run), 'cdk') }} + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + + deploy-cdk-prod: + name: Deploy cdk (canary, external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }} + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: canary + sha: ${{ needs.preflight.outputs.prod_sha }} + + deploy-dns: + name: Deploy dns (external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.external_deploys_to_run), 'dns') }} + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + + deploy-dns-prod: + name: Deploy dns (canary, external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }} + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: canary + sha: ${{ needs.preflight.outputs.prod_sha }} + + deploy-etl: + name: Deploy etl (external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && contains(fromJSON(needs.preflight.outputs.external_deploys_to_run), 'etl') }} + uses: org/data/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + + deploy-etl-prod: + name: Deploy etl (canary, external) + needs: [preflight, promote] + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.has_prod_deployment == 'true' }} + uses: org/data/.github/workflows/deploy.yaml@main + with: + environment: canary + sha: ${{ needs.preflight.outputs.prod_sha }} + + # Rollback jobs - revert successful deploys if any deploy fails + rollback-app: + name: Rollback app + needs: [preflight, deploy-app, deploy-app-prod, deploy-sidecar, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: | + always() && + needs.preflight.outputs.rollback_on_failure == 'true' && + needs.preflight.outputs.rollback_sha != '' && + needs.deploy-app.result == 'success' && + (needs.deploy-app.result == 'failure' || needs.deploy-app-prod.result == 'failure' || needs.deploy-sidecar.result == 'failure' || needs.deploy-sidecar-prod.result == 'failure' || needs.deploy-cdk.result == 'failure' || needs.deploy-cdk-prod.result == 'failure' || needs.deploy-dns.result == 'failure' || needs.deploy-dns-prod.result == 'failure' || needs.deploy-etl.result == 'failure' || needs.deploy-etl-prod.result == 'failure') + permissions: + id-token: write + packages: read + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.rollback_sha }} + + rollback-sidecar: + name: Rollback sidecar + needs: [preflight, deploy-app, deploy-app-prod, deploy-sidecar, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: | + always() && + needs.preflight.outputs.rollback_on_failure == 'true' && + needs.preflight.outputs.rollback_sha != '' && + needs.deploy-sidecar.result == 'success' && + (needs.deploy-app.result == 'failure' || needs.deploy-app-prod.result == 'failure' || needs.deploy-sidecar.result == 'failure' || needs.deploy-sidecar-prod.result == 'failure' || needs.deploy-cdk.result == 'failure' || needs.deploy-cdk-prod.result == 'failure' || needs.deploy-dns.result == 'failure' || needs.deploy-dns-prod.result == 'failure' || needs.deploy-etl.result == 'failure' || needs.deploy-etl-prod.result == 'failure') + uses: ./.github/workflows/deploy.yaml + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.rollback_sha }} + + rollback-cdk: + name: Rollback cdk (external) + needs: [preflight, deploy-app, deploy-app-prod, deploy-sidecar, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: | + always() && + needs.preflight.outputs.rollback_on_failure == 'true' && + needs.preflight.outputs.rollback_sha != '' && + needs.deploy-cdk.result == 'success' && + (needs.deploy-app.result == 'failure' || needs.deploy-app-prod.result == 'failure' || needs.deploy-sidecar.result == 'failure' || needs.deploy-sidecar-prod.result == 'failure' || needs.deploy-cdk.result == 'failure' || needs.deploy-cdk-prod.result == 'failure' || needs.deploy-dns.result == 'failure' || needs.deploy-dns-prod.result == 'failure' || needs.deploy-etl.result == 'failure' || needs.deploy-etl-prod.result == 'failure') + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.rollback_sha }} + + rollback-dns: + name: Rollback dns (external) + needs: [preflight, deploy-app, deploy-app-prod, deploy-sidecar, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: | + always() && + needs.preflight.outputs.rollback_on_failure == 'true' && + needs.preflight.outputs.rollback_sha != '' && + needs.deploy-dns.result == 'success' && + (needs.deploy-app.result == 'failure' || needs.deploy-app-prod.result == 'failure' || needs.deploy-sidecar.result == 'failure' || needs.deploy-sidecar-prod.result == 'failure' || needs.deploy-cdk.result == 'failure' || needs.deploy-cdk-prod.result == 'failure' || needs.deploy-dns.result == 'failure' || needs.deploy-dns-prod.result == 'failure' || needs.deploy-etl.result == 'failure' || needs.deploy-etl-prod.result == 'failure') + uses: org/infra/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.rollback_sha }} + + rollback-etl: + name: Rollback etl (external) + needs: [preflight, deploy-app, deploy-app-prod, deploy-sidecar, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: | + always() && + needs.preflight.outputs.rollback_on_failure == 'true' && + needs.preflight.outputs.rollback_sha != '' && + needs.deploy-etl.result == 'success' && + (needs.deploy-app.result == 'failure' || needs.deploy-app-prod.result == 'failure' || needs.deploy-sidecar.result == 'failure' || needs.deploy-sidecar-prod.result == 'failure' || needs.deploy-cdk.result == 'failure' || needs.deploy-cdk-prod.result == 'failure' || needs.deploy-dns.result == 'failure' || needs.deploy-dns-prod.result == 'failure' || needs.deploy-etl.result == 'failure' || needs.deploy-etl-prod.result == 'failure') + uses: org/data/.github/workflows/deploy.yaml@main + with: + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.rollback_sha }} + + finalize: + name: Finalize + needs: [preflight, promote, deploy-app, deploy-sidecar, deploy-app-prod, deploy-sidecar-prod, deploy-cdk, deploy-cdk-prod, deploy-dns, deploy-dns-prod, deploy-etl, deploy-etl-prod] + if: always() && needs.preflight.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Setup CLI + uses: stablekernel/cascade/.github/actions/setup-cli@v0.1.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: v0.1.0 + - name: Generate Changelog + id: changelog + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHANGELOG_BASE_SHA: ${{ needs.preflight.outputs.changelog_base_sha }} + SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + run: | + # Use changelog base SHA from preflight (first target env's current state) + TARGET_SHA="$CHANGELOG_BASE_SHA" + if [[ -z "$TARGET_SHA" ]]; then + # First deployment ever - compare against initial commit + TARGET_SHA=$(git rev-list --max-parents=0 HEAD | tail -n 1) + fi + + RESULT=$(cascade generate-changelog --base-sha "$TARGET_SHA" --head-sha "$SOURCE_SHA" --repo "${{ github.repository }}") + echo "changelog<> "$GITHUB_OUTPUT" + echo "$RESULT" | jq -r '.changelog' >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + - name: Extract Release Data + id: release-data + env: + PROMOTION_RESULT: ${{ needs.preflight.outputs.promotion_result }} + run: | + # Extract release_data from promotion result + # This contains the correct values for the version being released: + # - sha: the commit SHA of the release + # - rc_version: the RC tag (e.g., v1.0.0-rc.0) - used for prerelease + # - sem_version: the semver tag (e.g., v1.0.0) - used for publish + RELEASE_DATA=$(echo "$PROMOTION_RESULT" | jq -r '.release_data // {}') + + if [[ "$RELEASE_DATA" != "{}" && "$RELEASE_DATA" != "null" ]]; then + RELEASE_SHA=$(echo "$RELEASE_DATA" | jq -r '.sha // ""') + RC_VERSION=$(echo "$RELEASE_DATA" | jq -r '.rc_version // ""') + SEM_VERSION=$(echo "$RELEASE_DATA" | jq -r '.sem_version // ""') + echo "sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "rc_version=$RC_VERSION" >> "$GITHUB_OUTPUT" + echo "sem_version=$SEM_VERSION" >> "$GITHUB_OUTPUT" + echo "::notice::Release data - SHA: ${RELEASE_SHA:0:7}, RC: $RC_VERSION, Semver: $SEM_VERSION" + else + echo "::notice::No release data (not a prerelease/publish promotion)" + echo "sha=" >> "$GITHUB_OUTPUT" + echo "rc_version=" >> "$GITHUB_OUTPUT" + echo "sem_version=" >> "$GITHUB_OUTPUT" + fi + - name: Update Release + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_prerelease_env != 'true' && needs.preflight.outputs.is_final_env != 'true' }} + uses: ./.github/actions/manage-release + with: + repo: ${{ github.repository }} + action: update + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + tag: ${{ needs.preflight.outputs.source_version }} + changelog: ${{ steps.changelog.outputs.changelog }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Ensure Release Exists + if: ${{ github.event.inputs.dry_run != 'true' && (needs.preflight.outputs.is_prerelease_env == 'true' || needs.preflight.outputs.is_final_env == 'true') }} + uses: ./.github/actions/manage-release + with: + repo: ${{ github.repository }} + action: update + environment: ${{ needs.preflight.outputs.source_env }} + sha: ${{ needs.preflight.outputs.source_sha }} + tag: ${{ needs.preflight.outputs.source_version }} + changelog: ${{ steps.changelog.outputs.changelog }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Create Prerelease + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_prerelease_env == 'true' }} + uses: ./.github/actions/manage-release + with: + repo: ${{ github.repository }} + action: prerelease + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ steps.release-data.outputs.sha }} + tag: ${{ steps.release-data.outputs.rc_version }} + changelog: ${{ steps.changelog.outputs.changelog }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Cleanup Orphaned Releases + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.skipped_envs != '' }} + env: + SKIPPED_ENVS: ${{ needs.preflight.outputs.skipped_envs }} + SOURCE_VERSION: ${{ needs.preflight.outputs.source_version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + IFS=',' read -ra ENVS <<< "$SKIPPED_ENVS" + for ENV in "${ENVS[@]}"; do + echo "Cleaning up orphaned release for $ENV" + cascade manage-release \ + --repo "${{ github.repository }}" \ + --action delete \ + --environment "$ENV" \ + --sha "" \ + --tag "$SOURCE_VERSION" || true + done + - name: Publish Release + if: ${{ github.event.inputs.dry_run != 'true' && needs.preflight.outputs.is_final_env == 'true' }} + uses: ./.github/actions/manage-release + with: + repo: ${{ github.repository }} + action: publish + environment: ${{ needs.preflight.outputs.target_env }} + sha: ${{ steps.release-data.outputs.sha }} + tag: ${{ steps.release-data.outputs.sem_version }} + delete_tag: ${{ steps.release-data.outputs.rc_version }} + changelog: ${{ steps.changelog.outputs.changelog }} + token: ${{ secrets.GITHUB_TOKEN }} + - name: Finalize Promotion + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROMOTION_RESULT: ${{ needs.preflight.outputs.promotion_result }} + DEPLOY_RESULT_APP: ${{ needs.deploy-app.result }} + DEPLOY_RESULT_SIDECAR: ${{ needs.deploy-sidecar.result }} + run: | + cascade promote finalize \ + --config .github/manifest.yaml \ + --promotion-result "$PROMOTION_RESULT" \ + --repo "${{ github.repository }}" \ + --run-id "${{ github.run_id }}" \ + --commit-push + - name: Summary + env: + PROMOTION_MODE: ${{ github.event.inputs.mode }} + SOURCE_ENV: ${{ needs.preflight.outputs.source_env }} + TARGET_ENV: ${{ needs.preflight.outputs.target_env }} + ENVS_UPDATED: ${{ needs.preflight.outputs.envs_to_update }} + SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + { + echo "## Promotion Complete" + echo "" + echo "| Property | Value |" + echo "|----------|-------|" + echo "| Mode | $PROMOTION_MODE |" + echo "| From | $SOURCE_ENV |" + echo "| To | $TARGET_ENV |" + echo "| Environments Updated | $ENVS_UPDATED |" + echo "| SHA | \`$SOURCE_SHA\` |" + if [[ "$DRY_RUN" == "true" ]]; then + echo "| **DRY RUN** | Yes |" + fi + } >> "$GITHUB_STEP_SUMMARY"