name: Build and Push Docker Images on: push: branches: - main # Pushes to main โ†’ tags as "latest" - development # Pushes to development โ†’ tags as "dev" tags: - 'v*.*.*' # Version tags (v1.0.0, v1.2.3, etc.) โ†’ tags as version number workflow_dispatch: # Allows manual trigger from GitHub UI env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository_owner }}/cpmp jobs: build-and-push: name: Build and Push Docker Image runs-on: ubuntu-latest concurrency: group: docker-build-${{ github.ref }} cancel-in-progress: true outputs: skip_build: ${{ steps.skip.outputs.skip_build }} permissions: contents: read packages: write security-events: write steps: # Step 1: Download the code - name: ๐Ÿ“ฅ Checkout code uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6 - name: ๐Ÿงช Determine skip condition id: skip env: ACTOR: ${{ github.actor }} EVENT: ${{ github.event_name }} HEAD_MSG: ${{ github.event.head_commit.message }} run: | should_skip=false pr_title="" if [ "$EVENT" = "pull_request" ]; then pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '') fi if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi if echo "$HEAD_MSG" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi if echo "$HEAD_MSG" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi echo "skip_build=$should_skip" >> $GITHUB_OUTPUT if [ "$should_skip" = true ]; then echo "Skipping heavy docker build for actor=$ACTOR event=$EVENT (message/title matched)" else echo "Proceeding with full docker build" fi # Step 2: Set up QEMU for multi-platform builds (ARM, AMD64, etc.) - name: ๐Ÿ”ง Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 # Step 3: Set up Docker Buildx (advanced Docker builder) - name: ๐Ÿ”ง Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 # Resolve immutable digest for Caddy base - name: ๐Ÿ“ฆ Resolve Caddy base digest id: caddy run: | docker pull caddy:2-alpine DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' caddy:2-alpine) echo "image=$DIGEST" >> $GITHUB_OUTPUT # Step 4: Log in to GitHub Container Registry - name: ๐Ÿ” Log in to GitHub Container Registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.PROJECT_TOKEN }} # Step 5: Figure out what tags to use - name: ๐Ÿท๏ธ Extract metadata (tags, labels) id: meta uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | # Tag "latest" for main branch type=raw,value=latest,enable={{is_default_branch}} # Tag "dev" for development branch type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }} # Tag version numbers from git tags (v1.0.0 โ†’ 1.0.0) type=semver,pattern={{version}} # Tag major.minor from git tags (v1.2.3 โ†’ 1.2) type=semver,pattern={{major}}.{{minor}} # Tag major from git tags (v1.2.3 โ†’ 1) type=semver,pattern={{major}} # Ephemeral tag for pull requests (derive number from GITHUB_REF if available) type=raw,value=pr-${{ github.ref_name }},enable=${{ github.event_name == 'pull_request' }} # Short SHA tag as fallback (for non-default non-dev push events) type=sha,format=short,enable=${{ github.event_name != 'pull_request' }} # Step 6: Build the frontend first - name: ๐ŸŽจ Build frontend if: steps.skip.outputs.skip_build != 'true' run: | cd frontend npm ci npm run build # Step 7: Build and push Docker image - name: ๐Ÿณ Build and push Docker image if: steps.skip.outputs.skip_build != 'true' uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 id: build with: context: . file: ./Dockerfile platforms: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | CADDY_IMAGE=${{ steps.caddy.outputs.image }} # Step 8: Run Trivy scan (table output first for visibility) - name: ๐Ÿ“‹ Run Trivy scan (table output) if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' id: trivy_table continue-on-error: true uses: aquasecurity/trivy-action@master with: image-ref: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} format: 'table' severity: 'CRITICAL,HIGH' exit-code: '0' # Step 9: Run Trivy security scan (SARIF) - name: ๐Ÿ” Run Trivy vulnerability scanner if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' id: trivy continue-on-error: true uses: aquasecurity/trivy-action@master with: image-ref: ${{ fromJSON(steps.meta.outputs.json).tags[0] }} format: 'sarif' output: 'trivy-results.sarif' exit-code: '0' severity: 'CRITICAL,HIGH' # Step 10: Upload Trivy results to GitHub Security tab - name: ๐Ÿ“ค Upload Trivy results to GitHub Security uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4 if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && (steps.trivy.outcome == 'success' || steps.trivy.outcome == 'failure') with: sarif_file: 'trivy-results.sarif' # Step 11: Fail if vulnerabilities found - name: โŒ Check for vulnerabilities if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy_table.outcome == 'failure' run: | echo "::error::CRITICAL or HIGH vulnerabilities found in image" exit 1 # Step 11: Create a summary - name: ๐Ÿ“‹ Create summary if: steps.skip.outputs.skip_build != 'true' run: | echo "## ๐ŸŽ‰ Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### ๐Ÿ“ฆ Image Details" >> $GITHUB_STEP_SUMMARY echo "- **Registry**: GitHub Container Registry (ghcr.io)" >> $GITHUB_STEP_SUMMARY echo "- **Repository**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### ๐Ÿš€ How to Use" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY echo "docker run -d -p 8080:8080 -v caddy_data:/app/data ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - name: ๐Ÿ“‹ Create skip summary if: steps.skip.outputs.skip_build == 'true' run: | echo "## ๐Ÿšซ Docker Build Skipped" >> $GITHUB_STEP_SUMMARY echo "Reason: renovate bot or chore(deps)/chore commit/PR title." >> $GITHUB_STEP_SUMMARY echo "Actor: ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY echo "Event: ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY test-image: name: Test Docker Image needs: build-and-push runs-on: ubuntu-latest if: needs.build-and-push.outputs.skip_build != 'true' steps: # Ensure normalized lowercase IMAGE_NAME for this job as well - name: ๐Ÿ”ค Normalize image name run: | raw="${{ github.repository_owner }}/${{ github.event.repository.name }}" echo "IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV # Step 1: Figure out which tag to test - name: ๐Ÿท๏ธ Determine image tag id: tag run: | if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then echo "tag=latest" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then echo "tag=dev" >> $GITHUB_OUTPUT elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT else echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT fi # Step 1.5: Log in to GitHub Container Registry (Required for private/internal images) - name: ๐Ÿ” Log in to GitHub Container Registry uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.PROJECT_TOKEN }} # yamllint disable-line rule:line-length # Step 2: Pull the image we just built - name: ๐Ÿ“ฅ Pull Docker image run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} # Step 3: Start the container - name: ๐Ÿš€ Run container run: | docker run -d \ --name test-container \ -p 8080:8080 \ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }} # Step 4/5: Wait and check health with retries - name: ๐Ÿฅ Test health endpoint (retries) run: | set +e for i in $(seq 1 30); do code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/v1/health || echo "000") if [ "$code" = "200" ]; then echo "โœ… Health check passed on attempt $i" exit 0 fi echo "Attempt $i/30: health not ready (code=$code); waiting..." sleep 2 done echo "โŒ Health check failed after retries" docker logs test-container || true exit 1 # Step 6: Check the logs for errors - name: ๐Ÿ“‹ Check container logs if: always() run: docker logs test-container # Step 7: Clean up - name: ๐Ÿงน Stop container if: always() run: docker stop test-container && docker rm test-container # Step 8: Summary - name: ๐Ÿ“‹ Create test summary if: always() run: | echo "## ๐Ÿงช Docker Image Test Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Image**: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY echo "- **Health Check**: ${{ job.status == 'success' && 'โœ… Passed' || 'โŒ Failed' }}" >> $GITHUB_STEP_SUMMARY