CD #13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CD | |
| on: | |
| workflow_run: | |
| workflows: | |
| - CI | |
| types: | |
| - completed | |
| branches: | |
| - master | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| deploy_mode: | |
| description: "Deployment mode" | |
| required: true | |
| default: deploy | |
| type: choice | |
| options: | |
| - deploy | |
| - rollback | |
| rollback_tag: | |
| description: "Required when deploy_mode=rollback (for example, 1a2b3c4d5e6f)" | |
| required: false | |
| type: string | |
| permissions: | |
| contents: read | |
| packages: write | |
| concurrency: | |
| group: production-deploy | |
| cancel-in-progress: true | |
| jobs: | |
| prepare: | |
| name: Prepare Deployment | |
| if: > | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') | |
| runs-on: ubuntu-latest | |
| outputs: | |
| mode: ${{ steps.vars.outputs.mode }} | |
| should_build: ${{ steps.vars.outputs.should_build }} | |
| image_tag: ${{ steps.vars.outputs.image_tag }} | |
| ghcr_owner: ${{ steps.vars.outputs.ghcr_owner }} | |
| source_ref: ${{ steps.vars.outputs.source_ref }} | |
| steps: | |
| - name: Resolve deployment variables | |
| id: vars | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| ghcr_owner="${GITHUB_REPOSITORY_OWNER,,}" | |
| if [ "${{ github.event_name }}" = "workflow_run" ]; then | |
| mode="deploy" | |
| should_build="true" | |
| source_ref="${{ github.event.workflow_run.head_sha }}" | |
| image_tag="${source_ref:0:12}" | |
| else | |
| mode="${{ inputs.deploy_mode }}" | |
| source_ref="${GITHUB_SHA}" | |
| if [ "$mode" = "rollback" ]; then | |
| if [ -z "${{ inputs.rollback_tag }}" ]; then | |
| echo "rollback_tag is required when deploy_mode=rollback" >&2 | |
| exit 1 | |
| fi | |
| should_build="false" | |
| image_tag="${{ inputs.rollback_tag }}" | |
| else | |
| should_build="true" | |
| image_tag="${GITHUB_SHA::12}" | |
| fi | |
| fi | |
| if ! [[ "$image_tag" =~ ^[A-Za-z0-9._-]+$ ]]; then | |
| echo "Invalid image tag: $image_tag" >&2 | |
| exit 1 | |
| fi | |
| { | |
| echo "mode=$mode" | |
| echo "should_build=$should_build" | |
| echo "image_tag=$image_tag" | |
| echo "ghcr_owner=$ghcr_owner" | |
| echo "source_ref=$source_ref" | |
| } >> "$GITHUB_OUTPUT" | |
| build-and-push: | |
| name: Build and Push Images | |
| if: github.repository == 'Binary-ShadowMonarch/bookapp' | |
| needs: prepare | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 90 | |
| outputs: | |
| image_tag: ${{ needs.prepare.outputs.image_tag }} | |
| ghcr_owner: ${{ needs.prepare.outputs.ghcr_owner }} | |
| steps: | |
| - name: Checkout code | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 | |
| with: | |
| ref: ${{ needs.prepare.outputs.source_ref }} | |
| - name: Setup QEMU | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 | |
| with: | |
| platforms: arm64,amd64 | |
| - name: Setup Docker Buildx | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f | |
| - name: Login to GHCR | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push frontend image | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 | |
| with: | |
| context: ./frontend | |
| file: ./frontend/Dockerfile | |
| push: true | |
| platforms: linux/amd64,linux/arm64 | |
| provenance: true | |
| sbom: true | |
| cache-from: type=gha,scope=frontend | |
| cache-to: type=gha,scope=frontend,mode=max | |
| tags: | | |
| ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-frontend:${{ needs.prepare.outputs.image_tag }} | |
| ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-frontend:latest | |
| - name: Build and push backend image | |
| if: needs.prepare.outputs.should_build == 'true' | |
| uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 | |
| with: | |
| context: ./backend | |
| file: ./backend/Dockerfile | |
| push: true | |
| platforms: linux/amd64,linux/arm64 | |
| provenance: true | |
| sbom: true | |
| cache-from: type=gha,scope=backend | |
| cache-to: type=gha,scope=backend,mode=max | |
| tags: | | |
| ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-backend:${{ needs.prepare.outputs.image_tag }} | |
| ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-backend:latest | |
| - name: Verify image manifests | |
| if: needs.prepare.outputs.should_build == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| frontend_ref="ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-frontend:${{ needs.prepare.outputs.image_tag }}" | |
| backend_ref="ghcr.io/${{ needs.prepare.outputs.ghcr_owner }}/bookapp-backend:${{ needs.prepare.outputs.image_tag }}" | |
| frontend_manifest="$(docker buildx imagetools inspect "$frontend_ref")" | |
| backend_manifest="$(docker buildx imagetools inspect "$backend_ref")" | |
| echo "$frontend_manifest" | grep -q "linux/amd64" | |
| echo "$frontend_manifest" | grep -q "linux/arm64" | |
| echo "$backend_manifest" | grep -q "linux/amd64" | |
| echo "$backend_manifest" | grep -q "linux/arm64" | |
| echo "Verified multi-arch manifests for $frontend_ref and $backend_ref" | |
| - name: Skip image build for rollback | |
| if: needs.prepare.outputs.should_build != 'true' | |
| run: echo "Rollback mode selected, skipping image build and push." | |
| deploy: | |
| name: Deploy to Production | |
| if: github.repository == 'Binary-ShadowMonarch/bookapp' | |
| runs-on: | |
| - self-hosted | |
| - linux | |
| - bookapp-prod | |
| needs: | |
| - prepare | |
| - build-and-push | |
| env: | |
| COMPOSE_ENV_FILE: ${{ vars.BOOKAPP_ENV_FILE }} | |
| FRONTEND_SMOKE_URL: ${{ vars.BOOKAPP_SMOKE_URL }} | |
| BACKEND_SMOKE_URL: ${{ vars.BOOKAPP_API_SMOKE_URL }} | |
| GHCR_OWNER: ${{ needs.prepare.outputs.ghcr_owner }} | |
| IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} | |
| DEPLOY_MODE: ${{ needs.prepare.outputs.mode }} | |
| SOURCE_REF: ${{ needs.prepare.outputs.source_ref }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 | |
| with: | |
| ref: ${{ env.SOURCE_REF }} | |
| clean: false | |
| - name: Validate deployment variables | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$COMPOSE_ENV_FILE" ]; then | |
| echo "Repository variable BOOKAPP_ENV_FILE is required (absolute path to production .env on the runner host)." >&2 | |
| exit 1 | |
| fi | |
| if [ ! -f "$COMPOSE_ENV_FILE" ]; then | |
| echo "No env file found at $COMPOSE_ENV_FILE" >&2 | |
| exit 1 | |
| fi | |
| - name: Login to GHCR | |
| uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Pull deployment images | |
| shell: bash | |
| run: | | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| pull frontend backend | |
| - name: Deploy stack | |
| shell: bash | |
| run: | | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| up -d --no-build --remove-orphans | |
| - name: Smoke checks | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| frontend_url="${FRONTEND_SMOKE_URL:-http://localhost:4353/}" | |
| backend_url="${BACKEND_SMOKE_URL:-http://localhost:4353/api/healthz}" | |
| check_url() { | |
| local name="$1" | |
| local url="$2" | |
| local attempt | |
| local curl_extra=() | |
| if [[ "$url" == http://127.0.0.1:* || "$url" == https://127.0.0.1:* || "$url" == http://localhost:* || "$url" == https://localhost:* ]]; then | |
| curl_extra=(-H "Host: localhost") | |
| fi | |
| for attempt in $(seq 1 20); do | |
| if curl --fail --silent --show-error --connect-timeout 5 --max-time 10 "${curl_extra[@]}" "$url" > /dev/null; then | |
| echo "$name smoke check passed: $url" | |
| return 0 | |
| fi | |
| echo "$name smoke check attempt $attempt/20 failed, retrying in 3s..." | |
| sleep 3 | |
| done | |
| echo "$name smoke check failed after retries: $url" >&2 | |
| return 1 | |
| } | |
| check_url "Frontend" "$frontend_url" | |
| check_url "Backend" "$backend_url" | |
| - name: Deployment diagnostics on failure | |
| if: failure() | |
| shell: bash | |
| run: | | |
| set +e | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| ps | |
| echo "----- nginx logs -----" | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| logs --tail=150 nginx | |
| echo "----- frontend logs -----" | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| logs --tail=150 frontend | |
| echo "----- backend logs -----" | |
| docker compose \ | |
| --env-file "$COMPOSE_ENV_FILE" \ | |
| -f docker-compose.yml \ | |
| -f docker-compose.prod.yml \ | |
| logs --tail=150 backend | |
| - name: Cleanup dangling images | |
| run: docker image prune -f | |
| - name: Deployment summary | |
| shell: bash | |
| run: | | |
| { | |
| echo "## Deployment Completed" | |
| echo "- Mode: $DEPLOY_MODE" | |
| echo "- Tag: $IMAGE_TAG" | |
| echo "- GHCR owner: $GHCR_OWNER" | |
| echo "- Runner: $RUNNER_NAME" | |
| } >> "$GITHUB_STEP_SUMMARY" |