diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..5dccaf88 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,274 @@ +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 }}/${{ github.event.repository.name }} + +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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + # Normalize IMAGE_NAME to lowercase to satisfy container registry format + - name: ๐Ÿ”ค Normalize image name + run: | + raw="${{ github.repository_owner }}/${{ github.event.repository.name }}" + echo "IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: ๐Ÿงช Determine skip condition + id: skip + run: | + should_skip=false + actor='${{ github.actor }}' + event='${{ github.event_name }}' + head_msg='${{ github.event.head_commit.message }}' + 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@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + 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@v3 + 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 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