1313 options:
1414 - production
1515 - staging
16+ skip_build:
17+ description: 'Skip build and use latest images'
18+ type: boolean
19+ default: false
20+ force_build_web:
21+ description: 'Force web image rebuild'
22+ type: boolean
23+ default: false
24+ force_build_crawler:
25+ description: 'Force crawler image rebuild'
26+ type: boolean
27+ default: false
1628
1729env:
1830 NODE_VERSION: '20'
@@ -21,12 +33,103 @@ env:
2133 IMAGE_NAME: ${{ github.repository }}
2234
2335jobs:
36+ # ===========================================
37+ # JOB 0: DETECT CHANGES
38+ # ===========================================
39+ # Smart change detection - only rebuild what changed
40+ detect-changes:
41+ name: Detect Changes
42+ runs-on: ubuntu-latest
43+ outputs:
44+ web: ${{ steps.filter.outputs.web }}
45+ crawler: ${{ steps.filter.outputs.crawler }}
46+ verify: ${{ steps.filter.outputs.verify }}
47+ infra: ${{ steps.filter.outputs.infra }}
48+ should_build_web: ${{ steps.should-build.outputs.web }}
49+ should_build_crawler: ${{ steps.should-build.outputs.crawler }}
50+ steps:
51+ - uses: actions/checkout@v4
52+
53+ - uses: dorny/paths-filter@v2
54+ id: filter
55+ with:
56+ filters: |
57+ web:
58+ - 'apps/web/**'
59+ - 'packages/**'
60+ - 'config/**'
61+ - 'Dockerfile.web'
62+ crawler:
63+ - 'apps/crawler/**'
64+ - 'packages/**'
65+ - 'config/**'
66+ - 'Dockerfile.crawler'
67+ verify:
68+ - 'tools/verify/**'
69+ infra:
70+ - 'docker-compose.yml'
71+ - 'docker-compose.prod.yml'
72+ - 'docker-compose.cloud-prod.yml'
73+ - 'docker/**'
74+
75+ - name: Determine what to build
76+ id: should-build
77+ run: |
78+ # Web: build if web files changed, force requested, or manual dispatch without skip
79+ WEB_CHANGED="${{ steps.filter.outputs.web }}"
80+ CRAWLER_CHANGED="${{ steps.filter.outputs.crawler }}"
81+ SKIP_BUILD="${{ inputs.skip_build }}"
82+ FORCE_WEB="${{ inputs.force_build_web }}"
83+ FORCE_CRAWLER="${{ inputs.force_build_crawler }}"
84+ IS_MANUAL="${{ github.event_name == 'workflow_dispatch' }}"
85+
86+ # Default to building on push if changed, or on manual dispatch unless skip_build
87+ if [ "$SKIP_BUILD" = "true" ]; then
88+ BUILD_WEB="false"
89+ BUILD_CRAWLER="false"
90+ elif [ "$FORCE_WEB" = "true" ]; then
91+ BUILD_WEB="true"
92+ elif [ "$IS_MANUAL" = "true" ] && [ "$WEB_CHANGED" != "true" ]; then
93+ # Manual dispatch without force - still build if explicitly requested
94+ BUILD_WEB="false"
95+ else
96+ BUILD_WEB="$WEB_CHANGED"
97+ fi
98+
99+ if [ "$SKIP_BUILD" = "true" ]; then
100+ BUILD_CRAWLER="false"
101+ elif [ "$FORCE_CRAWLER" = "true" ]; then
102+ BUILD_CRAWLER="true"
103+ elif [ "$IS_MANUAL" = "true" ] && [ "$CRAWLER_CHANGED" != "true" ]; then
104+ BUILD_CRAWLER="false"
105+ else
106+ BUILD_CRAWLER="$CRAWLER_CHANGED"
107+ fi
108+
109+ # On push events, if nothing specific changed, build both (safety)
110+ if [ "$IS_MANUAL" != "true" ] && [ "$WEB_CHANGED" != "true" ] && [ "$CRAWLER_CHANGED" != "true" ]; then
111+ echo "No specific changes detected on push - building both for safety"
112+ BUILD_WEB="true"
113+ BUILD_CRAWLER="true"
114+ fi
115+
116+ echo "web=$BUILD_WEB" >> $GITHUB_OUTPUT
117+ echo "crawler=$BUILD_CRAWLER" >> $GITHUB_OUTPUT
118+
119+ echo "Build decisions:"
120+ echo " Web changed: $WEB_CHANGED -> Build web: $BUILD_WEB"
121+ echo " Crawler changed: $CRAWLER_CHANGED -> Build crawler: $BUILD_CRAWLER"
122+ echo " Skip build: $SKIP_BUILD"
123+ echo " Force web: $FORCE_WEB"
124+ echo " Force crawler: $FORCE_CRAWLER"
125+
24126 # ===========================================
25127 # JOB 1: DETECT CONFIGURATION
26128 # ===========================================
27129 detect-config:
28130 name: Detect Configuration
29131 runs-on: ubuntu-latest
132+ environment: production
30133 outputs:
31134 secrets_source: ${{ steps.detect.outputs.secrets_source }}
32135 ssm_param_name: ${{ steps.detect.outputs.ssm_param_name }}
@@ -115,7 +218,11 @@ jobs:
115218 build-verify-binaries:
116219 name: Build Verification Binaries
117220 runs-on: ubuntu-latest
118- needs: detect-config
221+ needs: [detect-changes, detect-config]
222+ # Only build if web will be built (binaries go in web image) or verify tools changed
223+ if: |
224+ needs.detect-changes.outputs.should_build_web == 'true' ||
225+ needs.detect-changes.outputs.verify == 'true'
119226
120227 steps:
121228 - name: Checkout
@@ -189,8 +296,15 @@ jobs:
189296 build-and-push:
190297 name: Build & Push Images
191298 runs-on: ubuntu-latest
192- needs: [detect-config, build-verify-binaries]
193- if: always() && needs.detect-config.result == 'success' && needs.build-verify-binaries.result == 'success'
299+ needs: [detect-changes, detect-config, build-verify-binaries]
300+ # Run if at least one image needs building and config is ready
301+ # build-verify-binaries may be skipped if only crawler changed
302+ if: |
303+ always() &&
304+ needs.detect-config.result == 'success' &&
305+ (needs.build-verify-binaries.result == 'success' || needs.build-verify-binaries.result == 'skipped') &&
306+ (needs.detect-changes.outputs.should_build_web == 'true' || needs.detect-changes.outputs.should_build_crawler == 'true')
307+ environment: production
194308 permissions:
195309 contents: read
196310 packages: write
@@ -200,14 +314,16 @@ jobs:
200314 - name: Checkout
201315 uses: actions/checkout@v4
202316
203- # Download verification binaries BEFORE building Docker image
317+ # Download verification binaries BEFORE building Docker image (only if building web)
204318 - name: Download verification binaries
319+ if: needs.detect-changes.outputs.should_build_web == 'true' && needs.build-verify-binaries.result == 'success'
205320 uses: actions/download-artifact@v4
206321 with:
207322 name: verify-binaries
208323 path: apps/web/public/verify/
209324
210325 - name: Verify binaries were downloaded
326+ if: needs.detect-changes.outputs.should_build_web == 'true' && needs.build-verify-binaries.result == 'success'
211327 run: |
212328 echo "Verification binaries:"
213329 ls -lh apps/web/public/verify/
@@ -217,6 +333,12 @@ jobs:
217333 fi
218334 echo "Verification binaries ready for Docker build"
219335
336+ - name: Create placeholder for verify binaries (crawler-only build)
337+ if: needs.detect-changes.outputs.should_build_web != 'true'
338+ run: |
339+ echo "Skipping verify binaries - not building web image"
340+ mkdir -p apps/web/public/verify/
341+
220342 # SECURITY: Fetch secrets directly (no artifacts)
221343 - name: Fetch secrets from AWS SSM
222344 if: needs.detect-config.outputs.secrets_source == 'aws-ssm'
@@ -282,6 +404,8 @@ jobs:
282404 echo "NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY" >> $GITHUB_OUTPUT
283405 echo "NEXT_PUBLIC_ENABLE_TURNSTILE=$NEXT_PUBLIC_ENABLE_TURNSTILE" >> $GITHUB_OUTPUT
284406 echo "NEXT_PUBLIC_TURNSTILE_PROTECTED_ACTIONS=$NEXT_PUBLIC_TURNSTILE_PROTECTED_ACTIONS" >> $GITHUB_OUTPUT
407+ echo "NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY" >> $GITHUB_OUTPUT
408+ echo "NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST" >> $GITHUB_OUTPUT
285409
286410 echo "Extracted NEXT_PUBLIC_* variables for build"
287411
@@ -362,6 +486,7 @@ jobs:
362486
363487 - name: Extract metadata for web
364488 id: meta-web
489+ if: needs.detect-changes.outputs.should_build_web == 'true'
365490 uses: docker/metadata-action@v5
366491 with:
367492 images: ${{ steps.registry.outputs.url }}/${{ steps.registry-config.outputs.image_base }}web
@@ -371,6 +496,7 @@ jobs:
371496 type=raw,value=latest,enable={{is_default_branch}}
372497
373498 - name: Build and push web image
499+ if: needs.detect-changes.outputs.should_build_web == 'true'
374500 uses: docker/build-push-action@v6
375501 with:
376502 context: .
@@ -386,9 +512,16 @@ jobs:
386512 NEXT_PUBLIC_TURNSTILE_SITE_KEY=${{ steps.build-env.outputs.NEXT_PUBLIC_TURNSTILE_SITE_KEY }}
387513 NEXT_PUBLIC_ENABLE_TURNSTILE=${{ steps.build-env.outputs.NEXT_PUBLIC_ENABLE_TURNSTILE }}
388514 NEXT_PUBLIC_TURNSTILE_PROTECTED_ACTIONS=${{ steps.build-env.outputs.NEXT_PUBLIC_TURNSTILE_PROTECTED_ACTIONS }}
515+ NEXT_PUBLIC_POSTHOG_KEY=${{ steps.build-env.outputs.NEXT_PUBLIC_POSTHOG_KEY }}
516+ NEXT_PUBLIC_POSTHOG_HOST=${{ steps.build-env.outputs.NEXT_PUBLIC_POSTHOG_HOST }}
517+
518+ - name: Skip web build
519+ if: needs.detect-changes.outputs.should_build_web != 'true'
520+ run: echo "Skipping web image build - no web changes detected"
389521
390522 - name: Extract metadata for crawler
391523 id: meta-crawler
524+ if: needs.detect-changes.outputs.should_build_crawler == 'true'
392525 uses: docker/metadata-action@v5
393526 with:
394527 images: ${{ steps.registry.outputs.url }}/${{ steps.registry-config.outputs.image_base }}crawler
@@ -398,6 +531,7 @@ jobs:
398531 type=raw,value=latest,enable={{is_default_branch}}
399532
400533 - name: Build and push crawler image
534+ if: needs.detect-changes.outputs.should_build_crawler == 'true'
401535 uses: docker/build-push-action@v6
402536 with:
403537 context: .
@@ -408,6 +542,10 @@ jobs:
408542 cache-from: type=gha
409543 cache-to: type=gha,mode=max
410544
545+ - name: Skip crawler build
546+ if: needs.detect-changes.outputs.should_build_crawler != 'true'
547+ run: echo "Skipping crawler image build - no crawler changes detected"
548+
411549 # SECURITY: Clean up deployment.env - do NOT upload as artifact
412550 - name: Clean up secrets
413551 if: always()
@@ -419,11 +557,13 @@ jobs:
419557 backup:
420558 name: Backup Database
421559 runs-on: ubuntu-latest
422- needs: [detect-config, build-and-push]
560+ needs: [detect-changes, detect- config, build-and-push]
423561 if: |
562+ always() &&
424563 needs.detect-config.outputs.backup_enabled == 'true' &&
425564 needs.detect-config.outputs.deployment_mode == 'self-hosted-docker' &&
426- github.ref == 'refs/heads/master'
565+ github.ref == 'refs/heads/master' &&
566+ (needs.build-and-push.result == 'success' || needs.build-and-push.result == 'skipped')
427567 environment: production
428568
429569 steps:
@@ -465,11 +605,11 @@ jobs:
465605 deploy:
466606 name: Deploy to Server
467607 runs-on: ubuntu-latest
468- needs: [detect-config, build-verify-binaries, build-and-push, backup]
608+ needs: [detect-changes, detect- config, build-verify-binaries, build-and-push, backup]
469609 if: |
470610 always() &&
471611 github.ref == 'refs/heads/master' &&
472- needs.build-and-push.result == 'success' &&
612+ ( needs.build-and-push.result == 'success' || needs.build-and-push.result == 'skipped') &&
473613 (needs.backup.result == 'success' || needs.backup.result == 'skipped')
474614 environment: production
475615
@@ -697,7 +837,7 @@ jobs:
697837 health-check:
698838 name: Health Check
699839 runs-on: ubuntu-latest
700- needs: [detect-config, deploy]
840+ needs: [detect-changes, detect- config, deploy]
701841 if: |
702842 always() &&
703843 needs.detect-config.outputs.health_check_enabled == 'true' &&
@@ -748,7 +888,7 @@ jobs:
748888 rollback:
749889 name: Rollback Deployment
750890 runs-on: ubuntu-latest
751- needs: [detect-config, backup, deploy, health-check]
891+ needs: [detect-changes, detect- config, backup, deploy, health-check]
752892 if: |
753893 always() &&
754894 needs.detect-config.outputs.rollback_enabled == 'true' &&
0 commit comments