Skip to content

Orchestrate CI/CD

Orchestrate CI/CD #345

Workflow file for this run

# 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:
- 'src/**'
workflow_dispatch:
inputs:
environment:
description: 'Target environment'
type: choice
options:
- staging
- prod
default: 'staging'
dry_run:
description: 'Dry run mode'
type: boolean
default: false
force:
description: "Force re-promote"
type: boolean
default: 'false'
reason:
description: "Operator note for this manual run"
type: string
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_deploy_app: ${{ steps.setup.outputs.run_deploy_app }}
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_deploy_app: ${{ steps.setup.outputs.base_deploy_app }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup CLI
uses: stablekernel/cascade/.github/actions/setup-cli@v0.16.5-rc.2
with:
token: ${{ secrets.CASCADE_STATE_TOKEN }}
version: v0.16.5-rc.2
- name: Run Setup
id: setup
env:
ENVIRONMENT: ${{ github.event.inputs.environment || 'staging' }}
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"]
max-parallel: 2
fail-fast: false
uses: ./.github/workflows/build-image.yaml
with:
environment: ${{ github.event.inputs.environment || 'staging' }}
sha: ${{ needs.setup.outputs.head_sha }}
build-image-upload:
name: Upload artifact build-image
needs: [build-image]
if: needs.build-image.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Collect matrix leg artifacts
uses: actions/download-artifact@v8
with:
pattern: image-*
path: dist
merge-multiple: true
- name: Upload artifact build-image
uses: actions/upload-artifact@v7
with:
name: build-image
path: dist/**
build-bundle-download:
name: Download artifacts for Build (bundle)
needs: [setup, build-image-upload]
if: |
needs.setup.outputs.run_build_bundle == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Download artifact from image
uses: actions/download-artifact@v8
with:
name: build-image
path: build-image
build-bundle:
name: Build (bundle)
needs: [setup, build-bundle-download]
if: |
needs.setup.outputs.run_build_bundle == 'true'
uses: ./.github/workflows/build-bundle.yaml
with:
environment: ${{ github.event.inputs.environment || 'staging' }}
sha: ${{ needs.setup.outputs.head_sha }}
reason: ${{ inputs.reason }}
deploy-app:
name: Deploy (app)
needs: [setup]
if: |
needs.setup.outputs.run_deploy_app == 'true'
uses: ./.github/workflows/deploy-app.yaml
with:
environment: ${{ github.event.inputs.environment || 'staging' }}
sha: ${{ needs.setup.outputs.head_sha }}
finalize:
name: Finalize
needs: [setup, build-image, build-bundle, deploy-app]
if: always() && needs.setup.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
outputs:
build_bundle_artifact_id: ${{ needs.build-bundle.outputs.artifact_id }}
build_image_artifact_id: ${{ needs.build-image.outputs.artifact_id }}
build_image_image_digest: ${{ needs.build-image.outputs.image_digest }}
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 "| Deploy (app) | ${{ needs.deploy-app.result }} | abort |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Outputs" >> "$GITHUB_STEP_SUMMARY"
HAS_OUTPUTS=false
if [[ -n "${{ needs.build-bundle.outputs.artifact_id }}" ]]; then
if [[ "$HAS_OUTPUTS" == "false" ]]; then
echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
HAS_OUTPUTS=true
fi
echo "| build_bundle_artifact_id | ${{ needs.build-bundle.outputs.artifact_id }} |" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${{ needs.build-image.outputs.artifact_id }}" ]]; then
if [[ "$HAS_OUTPUTS" == "false" ]]; then
echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
HAS_OUTPUTS=true
fi
echo "| build_image_artifact_id | ${{ needs.build-image.outputs.artifact_id }} |" >> "$GITHUB_STEP_SUMMARY"
fi
if [[ -n "${{ needs.build-image.outputs.image_digest }}" ]]; then
if [[ "$HAS_OUTPUTS" == "false" ]]; then
echo "| Output | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
HAS_OUTPUTS=true
fi
echo "| build_image_image_digest | ${{ needs.build-image.outputs.image_digest }} |" >> "$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.16.5-rc.2
with:
token: ${{ secrets.CASCADE_STATE_TOKEN }}
version: v0.16.5-rc.2
- name: Generate Changelog
id: changelog
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_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 "$RESULT" | jq -r '.changelog' > "$RUNNER_TEMP/cascade-changelog.md"
- 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 || 'staging' }}
sha: ${{ needs.setup.outputs.head_sha }}
changelog_file: ${{ runner.temp }}/cascade-changelog.md
previous_tag: ${{ needs.setup.outputs.previous_tag }}
token: ${{ secrets.CASCADE_STATE_TOKEN }}
- name: Update Manifest
env:
GH_TOKEN: ${{ secrets.CASCADE_STATE_TOKEN }}
HEAD_SHA: ${{ needs.setup.outputs.head_sha }}
VERSION: ${{ needs.setup.outputs.version }}
ENVIRONMENT: ${{ github.event.inputs.environment || 'staging' }}
APP_RESULT: ${{ needs.deploy-app.result }}
BUILD_ARTIFACT_IMAGE: ${{ needs.build-image.outputs.artifact_id }}
BUILD_ARTIFACT_BUNDLE: ${{ needs.build-bundle.outputs.artifact_id }}
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 [[ -n "$BUILD_ARTIFACT_IMAGE" ]]; then
yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.builds.image.sha = \"$HEAD_SHA\"" "$MANIFEST_FILE"
yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.builds.image.artifact_id = \"$BUILD_ARTIFACT_IMAGE\"" "$MANIFEST_FILE"
fi
if [[ -n "$BUILD_ARTIFACT_BUNDLE" ]]; then
yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.builds.bundle.sha = \"$HEAD_SHA\"" "$MANIFEST_FILE"
yq eval -i ".$MANIFEST_KEY.state.$ENVIRONMENT.builds.bundle.artifact_id = \"$BUILD_ARTIFACT_BUNDLE\"" "$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 6 7 8 9 10; do
echo "cascade-state-write: attempt=$attempt/10"
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"
echo "cascade-state-write: ok attempt=$attempt"
exit 0
fi
echo "Push attempt $attempt rejected (likely concurrent run); retrying..." >&2
backoff=$(( 1 << (attempt - 1) ))
if [ "$backoff" -gt 8 ]; then backoff=8; fi
sleep $(( backoff + RANDOM % 2 ))
done
echo "cascade-state-write: exhausted attempts=10" >&2
echo "::error::Failed to push state after 10 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 6 7 8 9 10; do
echo "cascade-state-write: attempt=$attempt/10"
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
BASE_SHA=$(git rev-parse "origin/$BRANCH:$MANIFEST_FILE" 2>/dev/null || true)
if [[ -z "$BASE_SHA" ]]; then
echo "cascade-state-write: missing-base" >&2
echo "::error::$MANIFEST_FILE has no blob at the fetched origin/$BRANCH tip; refusing an unguarded state write" >&2
exit 1
fi
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')
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 "sha=$BASE_SHA"
-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 API_ERR=$(gh api "${API_ARGS[@]}" 2>&1 >/dev/null); then
echo "Pushed state via API on attempt $attempt"
echo "cascade-state-write: ok attempt=$attempt"
exit 0
fi
echo "$API_ERR" >&2
case "$API_ERR" in
*"HTTP 409"*|*Conflict*)
echo "State write attempt $attempt hit an optimistic-lock conflict (concurrent run); retrying..." >&2
;;
*"HTTP 429"*|*"Too Many Requests"*|*"rate limit"*|*"secondary rate"*|*"abuse"*|*"Retry-After"*|*"retry-after"*)
echo "State write attempt $attempt was rate limited; retrying with backoff..." >&2
;;
*"HTTP 4"*)
echo "cascade-state-write: permanent-error attempt=$attempt" >&2
echo "::error::State write failed with a permanent API error (see log above); not retrying" >&2
exit 1
;;
*)
echo "State write attempt $attempt failed (transient server or network error); retrying..." >&2
;;
esac
backoff=$(( 1 << (attempt - 1) ))
if [ "$backoff" -gt 8 ]; then backoff=8; fi
sleep $(( backoff + RANDOM % 2 ))
done
echo "cascade-state-write: exhausted attempts=10" >&2
echo "::error::Failed to write state via API after 10 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.deploy-app.result)
run: |
echo "One or more critical callbacks failed or were cancelled"
exit 1