name: Build and Push Docker Images on: push: branches: - main paths: - 'apps/scraper/**' - 'apps/hub/**' - '.github/workflows/docker-build-push.yml' - 'infra/**' - '!infra/compose/**' repository_dispatch: types: [submodule-updated] workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false permissions: contents: read env: REGISTRY: ghcr.io IMAGE_NAME_PREFIX: asepharyana/asepharyana-hub jobs: # ────────────────────────────────────────────── # Phase 1: Detect which services have changed # ────────────────────────────────────────────── changes: runs-on: ubuntu-latest timeout-minutes: 10 outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} scraper-api: ${{ steps.filter.outputs['scraper-api'] == 'true' || steps.dispatch.outputs['scraper-api'] == 'true' || github.event_name == 'workflow_dispatch' }} hub: ${{ steps.filter.outputs['hub'] == 'true' || steps.dispatch.outputs['hub'] == 'true' || github.event_name == 'workflow_dispatch' }} steps: - uses: actions/checkout@v7 with: submodules: false fetch-depth: 2 - name: Detect changed services id: filter if: github.event_name == 'push' env: BEFORE: ${{ github.event.before }} AFTER: ${{ github.sha }} run: | set -euo pipefail if [ -z "${BEFORE:-}" ] || [[ "$BEFORE" =~ ^0+$ ]]; then CHANGED_FILES=$(git ls-files) else git fetch --no-tags --depth=2 origin "$BEFORE" || true CHANGED_FILES=$(git diff --name-only "$BEFORE" "$AFTER") fi changed() { printf '%s\n' "$CHANGED_FILES" | grep -Eq "$1" && echo true || echo false } echo "scraper-api=$(changed '^(apps/scraper(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/scraper\.Dockerfile$)')" >> "$GITHUB_OUTPUT" echo "hub=$(changed '^(apps/hub(/|$)|\.github/workflows/docker-build-push\.yml$|infra/docker/hub\.Dockerfile$)')" >> "$GITHUB_OUTPUT" - name: Parse repository_dispatch payload id: dispatch if: github.event_name == 'repository_dispatch' env: SERVICE: ${{ github.event.client_payload.service }} SHA: ${{ github.event.client_payload.sha }} run: | set -euo pipefail if [ -z "${SERVICE:-}" ]; then echo "::error::repository_dispatch payload missing service" exit 1 fi if [ -z "${SHA:-}" ]; then echo "::error::repository_dispatch payload missing sha" exit 1 fi case "$SERVICE" in scraper-api|hub) ;; *) exit 1 ;; esac if ! [[ "$SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "::error::Invalid sha '$SHA'. Expected 40 hex characters"; fi SERVICES=(scraper-api hub) for svc in "${SERVICES[@]}"; do if [ "$SERVICE" = "$svc" ]; then echo "${svc}=true" >> "$GITHUB_OUTPUT" else echo "${svc}=false" >> "$GITHUB_OUTPUT" fi done - name: Set matrix id: set-matrix run: | SERVICES=() add_service() { SERVICES+=("{\"id\":\"$1\",\"target\":\"$2\",\"path\":\"$3\"}") } if [ "${{ steps.filter.outputs['scraper-api'] == 'true' || steps.dispatch.outputs['scraper-api'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "scraper-api" "docker-scraper" "apps/scraper"; fi if [ "${{ steps.filter.outputs['hub'] == 'true' || steps.dispatch.outputs['hub'] == 'true' || github.event_name == 'workflow_dispatch' }}" == "true" ]; then add_service "hub" "docker-hub" "apps/hub"; fi JSON_ARRAY="[$(IFS=,; echo "${SERVICES[*]}")]" echo "matrix=$JSON_ARRAY" >> $GITHUB_OUTPUT wait-submodule-ref: needs: [changes] if: github.event_name == 'repository_dispatch' runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Wait for submodule ref env: SERVICE: ${{ github.event.client_payload.service }} SHA: ${{ github.event.client_payload.sha }} run: | set -euo pipefail case "$SERVICE" in "scraper-api") REPO="https://github.com/asepharyana/asepharyana-hub-scraper.git" ;; "hub") REPO="https://github.com/asepharyana/asepharyana-hub-hub.git" ;; *) echo "::error::Unsupported service '$SERVICE'" exit 1 ;; esac echo "Waiting for $SERVICE commit $SHA in $REPO" TMPDIR=$(mktemp -d) git init "$TMPDIR/probe" >/dev/null git -C "$TMPDIR/probe" remote add origin "$REPO" for attempt in {1..30}; do if git -C "$TMPDIR/probe" fetch --depth=1 origin "$SHA" >/dev/null 2>&1; then echo "Submodule commit $SHA is fetchable for $SERVICE" rm -rf "$TMPDIR" exit 0 fi echo "Attempt $attempt/30: $SHA not fetchable yet; waiting 10s" sleep 10 done rm -rf "$TMPDIR" echo "::error::Submodule commit $SHA for $SERVICE was not fetchable after 300s" exit 1 # ───────────────────────────────────────────────── # Phase 2: Build and Push Images (Matrix) # ───────────────────────────────────────────────── build: needs: [changes, wait-submodule-ref] runs-on: ubuntu-latest timeout-minutes: 30 strategy: fail-fast: false matrix: include: ${{ fromJson(needs.changes.outputs.matrix) }} if: | always() && needs.changes.result == 'success' && (needs.wait-submodule-ref.result == 'success' || needs.wait-submodule-ref.result == 'skipped') && needs.changes.outputs.matrix != '[]' permissions: contents: read packages: write steps: - uses: actions/checkout@v7 with: submodules: false - name: Sync submodule locally env: EVENT_NAME: ${{ github.event_name }} DISPATCH_SHA: ${{ github.event.client_payload.sha }} SUBMODULE_PATH: ${{ matrix.path }} run: | set -euo pipefail git submodule update --init --recursive "$SUBMODULE_PATH" if [ "$EVENT_NAME" = "repository_dispatch" ] && [ -n "${DISPATCH_SHA:-}" ]; then cd "$SUBMODULE_PATH" git fetch origin "$DISPATCH_SHA" git checkout "$DISPATCH_SHA" fi - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Docker metadata id: meta run: | SVC_NAME="${{ matrix.id }}" SHORT=${GITHUB_SHA:0:7} echo "image=${REGISTRY}/${IMAGE_NAME_PREFIX}/${SVC_NAME}" >> $GITHUB_OUTPUT echo "tags=${REGISTRY}/${IMAGE_NAME_PREFIX}/${SVC_NAME}:sha-${SHORT}" >> $GITHUB_OUTPUT echo "cache-registry=${REGISTRY}/${IMAGE_NAME_PREFIX}/${SVC_NAME}:buildcache" >> $GITHUB_OUTPUT case "$SVC_NAME" in "scraper-api") echo "dockerfile=infra/docker/scraper.Dockerfile" >> $GITHUB_OUTPUT ;; "hub") echo "dockerfile=infra/docker/hub.Dockerfile" >> $GITHUB_OUTPUT ;; esac - name: Build and Push Docker image uses: docker/build-push-action@v7 with: context: . file: ${{ steps.meta.outputs.dockerfile }} push: true tags: ${{ steps.meta.outputs.tags }} build-args: | COMMIT_COUNT=${{ env.NR_COMMIT_COUNT || github.run_number }} COMMIT_SHA=${{ env.NR_COMMIT_SHA || github.sha }} cache-from: type=registry,ref=${{ steps.meta.outputs['cache-registry'] }} cache-to: type=registry,ref=${{ steps.meta.outputs['cache-registry'] }},mode=max # ────────────────────────────────────────────── # Phase 3: Update Manifests and Submodule Refs # ────────────────────────────────────────────── update-manifest: needs: [changes, wait-submodule-ref, build] if: | always() && needs.changes.result == 'success' && (needs.wait-submodule-ref.result == 'success' || needs.wait-submodule-ref.result == 'skipped') && (needs.build.result == 'success' || needs.build.result == 'skipped') runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write steps: - uses: actions/checkout@v7 with: submodules: false token: ${{ secrets.GITHUB_TOKEN }} ref: main - name: Update tags and submodules run: | SHORT_SHA=${GITHUB_SHA:0:7} TAG="sha-$SHORT_SHA" CHANGED=false declare -A SERVICES SERVICES["scraper-api"]="scraper.yml" SERVICES["hub"]="hub.yml" declare -A PATHS PATHS["scraper-api"]="apps/scraper" PATHS["hub"]="apps/hub" # Use git config for possible commits git config --local user.email "action@github.com" git config --local user.name "GitHub Action" for id in "${!SERVICES[@]}"; do SHOULD_HAVE_RUN=false if [ "${{ needs.changes.outputs['scraper-api'] }}" == "true" ] && [ "$id" == "scraper-api" ]; then SHOULD_HAVE_RUN=true; fi if [ "${{ needs.changes.outputs['hub'] }}" == "true" ] && [ "$id" == "hub" ]; then SHOULD_HAVE_RUN=true; fi if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then SHOULD_HAVE_RUN=true; fi if [ "$SHOULD_HAVE_RUN" == "true" ]; then COMPOSE_FILE="infra/compose/${SERVICES[$id]}" if [ -f "$COMPOSE_FILE" ]; then echo "Updating $COMPOSE_FILE to $TAG" sed -i "s|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_PREFIX }}/$id:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_PREFIX }}/$id:$TAG|g" "$COMPOSE_FILE" git add "$COMPOSE_FILE" CHANGED=true fi # If it's a repository_dispatch for this specific service, update its submodule pointer if [ "${{ github.event_name }}" == "repository_dispatch" ] && [ "${{ github.event.client_payload.service }}" == "$id" ]; then SHA_DISPATCH="${{ github.event.client_payload.sha }}" SUB_PATH="${PATHS[$id]}" if [ -n "$SHA_DISPATCH" ]; then echo "Updating submodule $SUB_PATH to $SHA_DISPATCH" git submodule update --init "$SUB_PATH" git -C "$SUB_PATH" fetch origin "$SHA_DISPATCH" git -C "$SUB_PATH" checkout "$SHA_DISPATCH" git add "$SUB_PATH" CHANGED=true fi fi fi done if [ "$CHANGED" == "true" ]; then git commit -m "chore: update manifests and submodules [skip ci]" for attempt in {1..3}; do if git pull --rebase origin main && git push origin main; then exit 0 fi echo "Manifest push attempt $attempt/3 failed; retrying" git rebase --abort || true git pull --rebase origin main || true sleep 5 done echo "::error::Failed to push manifest update after 3 attempts" exit 1 else echo "No changes detected." fi