ci(cd): stabilize GHCR pushes by using setup-buildx and disabling pro… #16
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: | |
| push: | |
| branches: [ main ] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| packages: write | |
| env: | |
| REGISTRY: ghcr.io | |
| jobs: | |
| build-and-push: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| backend_image: ${{ steps.tags.outputs.backend }} | |
| frontend_image: ${{ steps.tags.outputs.frontend }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Set up Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Derive lowercase image repo | |
| run: | | |
| echo "IMAGE_REPO=$(echo ${{ github.repository }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV | |
| - name: Log in to GHCR | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Set image tags | |
| id: tags | |
| run: | | |
| SHA_TAG=sha-${GITHUB_SHA::7} | |
| echo "backend=${{ env.REGISTRY }}/${IMAGE_REPO}-backend:${SHA_TAG}" >> $GITHUB_OUTPUT | |
| echo "backend_latest=${{ env.REGISTRY }}/${IMAGE_REPO}-backend:latest" >> $GITHUB_OUTPUT | |
| echo "frontend=${{ env.REGISTRY }}/${IMAGE_REPO}-frontend:${SHA_TAG}" >> $GITHUB_OUTPUT | |
| echo "frontend_latest=${{ env.REGISTRY }}/${IMAGE_REPO}-frontend:latest" >> $GITHUB_OUTPUT | |
| - name: Build and push backend | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: backend/Dockerfile | |
| push: true | |
| provenance: false | |
| compression: gzip | |
| tags: | | |
| ${{ steps.tags.outputs.backend }} | |
| ${{ steps.tags.outputs.backend_latest }} | |
| - name: Build and push frontend | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: frontend | |
| file: frontend/Dockerfile | |
| push: true | |
| provenance: false | |
| compression: gzip | |
| tags: | | |
| ${{ steps.tags.outputs.frontend }} | |
| ${{ steps.tags.outputs.frontend_latest }} | |
| - name: Show image tags | |
| run: | | |
| echo "Backend: ${{ steps.tags.outputs.backend }}" | |
| echo "Frontend: ${{ steps.tags.outputs.frontend }}" | |
| deploy-compose: | |
| runs-on: ubuntu-latest | |
| needs: build-and-push | |
| environment: production | |
| concurrency: | |
| group: prod-deploy | |
| cancel-in-progress: false | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Verify required secrets are present | |
| env: | |
| SSH_HOST: ${{ secrets.SSH_HOST }} | |
| SSH_USER: ${{ secrets.SSH_USER }} | |
| SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | |
| SSH_PASSWORD: ${{ secrets.SSH_PASSWORD }} | |
| run: | | |
| if [ -z "$SSH_HOST" ] || [ -z "$SSH_USER" ]; then | |
| echo "Missing SSH_HOST or SSH_USER"; exit 1; fi | |
| if [ -z "$SSH_PRIVATE_KEY" ] && [ -z "$SSH_PASSWORD" ]; then | |
| echo "Provide either SSH_PRIVATE_KEY or SSH_PASSWORD"; exit 1; fi | |
| - name: Set deploy flags | |
| env: | |
| SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} | |
| SSH_PASSWORD: ${{ secrets.SSH_PASSWORD }} | |
| run: | | |
| if [ -n "$SSH_PRIVATE_KEY" ]; then echo "HAS_KEY=true" >> $GITHUB_ENV; else echo "HAS_KEY=false" >> $GITHUB_ENV; fi | |
| if [ -n "$SSH_PASSWORD" ]; then echo "HAS_PASSWORD=true" >> $GITHUB_ENV; else echo "HAS_PASSWORD=false" >> $GITHUB_ENV; fi | |
| - name: Upload docker-compose file (key) | |
| if: ${{ env.HAS_KEY == 'true' }} | |
| uses: appleboy/scp-action@v0.1.7 | |
| with: | |
| host: ${{ secrets.SSH_HOST }} | |
| username: ${{ secrets.SSH_USER }} | |
| key: ${{ secrets.SSH_PRIVATE_KEY }} | |
| passphrase: ${{ secrets.SSH_PASSPHRASE }} | |
| source: "docker-compose.prod.yml" | |
| target: "/tmp/edgeflow-docker-compose.yml" | |
| strip_components: 0 | |
| - name: Upload docker-compose file (password) | |
| if: ${{ env.HAS_KEY != 'true' && env.HAS_PASSWORD == 'true' }} | |
| uses: appleboy/scp-action@v0.1.7 | |
| with: | |
| host: ${{ secrets.SSH_HOST }} | |
| username: ${{ secrets.SSH_USER }} | |
| password: ${{ secrets.SSH_PASSWORD }} | |
| source: "docker-compose.prod.yml" | |
| target: "/tmp/edgeflow-docker-compose.yml" | |
| strip_components: 0 | |
| - name: Deploy via SSH (key) | |
| if: ${{ env.HAS_KEY == 'true' }} | |
| uses: appleboy/ssh-action@v1.0.3 | |
| with: | |
| host: ${{ secrets.SSH_HOST }} | |
| username: ${{ secrets.SSH_USER }} | |
| key: ${{ secrets.SSH_PRIVATE_KEY }} | |
| passphrase: ${{ secrets.SSH_PASSPHRASE }} | |
| script: | | |
| set -euo pipefail | |
| APP_DIR=/opt/edgeflow | |
| sudo mkdir -p ${APP_DIR} | |
| cd ${APP_DIR} | |
| # Move compose file into place | |
| if [ -d docker-compose.yml ]; then sudo rm -rf docker-compose.yml; fi | |
| if [ -f /tmp/edgeflow-docker-compose.yml ]; then mv /tmp/edgeflow-docker-compose.yml docker-compose.yml; fi | |
| # Write .env with image tags | |
| cat > .env << 'EOF' | |
| BACKEND_IMAGE=${{ needs.build-and-push.outputs.backend_image }} | |
| FRONTEND_IMAGE=${{ needs.build-and-push.outputs.frontend_image }} | |
| EOF | |
| # Login to GHCR if credentials provided | |
| if [ -n "${{ secrets.GHCR_USERNAME }}" ] && [ -n "${{ secrets.GHCR_TOKEN }}" ]; then | |
| echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin | |
| fi | |
| # Ensure docker compose is available | |
| (docker compose version || docker-compose version) | |
| # Pull and run | |
| (docker compose --env-file .env -f docker-compose.yml pull || docker-compose --env-file .env -f docker-compose.yml pull) | |
| (docker compose --env-file .env -f docker-compose.yml up -d || docker-compose --env-file .env -f docker-compose.yml up -d) | |
| docker image prune -f || true | |
| # Basic health checks | |
| curl -fsS http://localhost:8000/api/health || (echo 'Backend health failed' && exit 1) | |
| curl -fsS http://localhost:3000/ || (echo 'Frontend health failed' && exit 1) | |
| - name: Deploy via SSH (password) | |
| if: ${{ env.HAS_KEY != 'true' && env.HAS_PASSWORD == 'true' }} | |
| uses: appleboy/ssh-action@v1.0.3 | |
| with: | |
| host: ${{ secrets.SSH_HOST }} | |
| username: ${{ secrets.SSH_USER }} | |
| password: ${{ secrets.SSH_PASSWORD }} | |
| script: | | |
| set -euo pipefail | |
| APP_DIR=/opt/edgeflow | |
| sudo mkdir -p ${APP_DIR} | |
| cd ${APP_DIR} | |
| # Move compose file into place | |
| if [ -d docker-compose.yml ]; then sudo rm -rf docker-compose.yml; fi | |
| if [ -f /tmp/edgeflow-docker-compose.yml ]; then mv /tmp/edgeflow-docker-compose.yml docker-compose.yml; fi | |
| # Write .env with image tags | |
| cat > .env << 'EOF' | |
| BACKEND_IMAGE=${{ needs.build-and-push.outputs.backend_image }} | |
| FRONTEND_IMAGE=${{ needs.build-and-push.outputs.frontend_image }} | |
| EOF | |
| # Login to GHCR if credentials provided | |
| if [ -n "${{ secrets.GHCR_USERNAME }}" ] && [ -n "${{ secrets.GHCR_TOKEN }}" ]; then | |
| echo "${{ secrets.GHCR_TOKEN }}" | docker login ghcr.io -u "${{ secrets.GHCR_USERNAME }}" --password-stdin | |
| fi | |
| (docker compose version || docker-compose version) | |
| (docker compose --env-file .env -f docker-compose.yml pull || docker-compose --env-file .env -f docker-compose.yml pull) | |
| (docker compose --env-file .env -f docker-compose.yml up -d || docker-compose --env-file .env -f docker-compose.yml up -d) | |
| docker image prune -f || true | |
| curl -fsS http://localhost:8000/api/health || (echo 'Backend health failed' && exit 1) | |
| curl -fsS http://localhost:3000/ || (echo 'Frontend health failed' && exit 1) |