| 1 | name: Build Backend Images |
| 2 | |
| 3 | on: |
| 4 | push: |
| 5 | branches: |
| 6 | - main |
| 7 | paths: |
| 8 | - 'project/aitoearn-backend/**' |
| 9 | - '.github/workflows/backend-build.yml' |
| 10 | workflow_dispatch: |
| 11 | inputs: |
| 12 | build_aitoearn_ai: |
| 13 | description: 'aitoearn-ai' |
| 14 | required: false |
| 15 | type: boolean |
| 16 | default: false |
| 17 | build_aitoearn_server: |
| 18 | description: 'aitoearn-server' |
| 19 | required: false |
| 20 | type: boolean |
| 21 | default: false |
| 22 | push_latest: |
| 23 | description: 'Push as latest tag' |
| 24 | required: false |
| 25 | type: boolean |
| 26 | default: false |
| 27 | workflow_call: |
| 28 | inputs: |
| 29 | base_ref: |
| 30 | description: 'Base branch for affected detection (for PR preview)' |
| 31 | required: false |
| 32 | type: string |
| 33 | default: '' |
| 34 | manual_apps: |
| 35 | description: 'Comma-separated list of apps to build (for manual deployment)' |
| 36 | required: false |
| 37 | type: string |
| 38 | default: '' |
| 39 | outputs: |
| 40 | has_changes: |
| 41 | description: 'Whether any apps were affected' |
| 42 | value: ${{ jobs.generate-matrix.outputs.has_changes }} |
| 43 | |
| 44 | permissions: |
| 45 | contents: read |
| 46 | actions: write |
| 47 | |
| 48 | env: |
| 49 | DOCKER_HUB_USERNAME: aitoearn |
| 50 | NX_NO_CLOUD: true |
| 51 | MONOREPO_DIR: project/aitoearn-backend |
| 52 | |
| 53 | jobs: |
| 54 | generate-matrix: |
| 55 | runs-on: ubuntu-latest |
| 56 | outputs: |
| 57 | matrix: ${{ steps.set-matrix.outputs.matrix }} |
| 58 | has_changes: ${{ steps.set-matrix.outputs.has_changes }} |
| 59 | version: ${{ steps.version.outputs.version }} |
| 60 | |
| 61 | steps: |
| 62 | - name: Checkout |
| 63 | uses: actions/checkout@v5 |
| 64 | with: |
| 65 | fetch-depth: 0 |
| 66 | |
| 67 | - name: Install pnpm |
| 68 | uses: pnpm/action-setup@v4 |
| 69 | with: |
| 70 | version: 10 |
| 71 | |
| 72 | - name: Setup Node.js |
| 73 | uses: actions/setup-node@v4 |
| 74 | with: |
| 75 | node-version: 22 |
| 76 | cache: pnpm |
| 77 | cache-dependency-path: ${{ env.MONOREPO_DIR }}/pnpm-lock.yaml |
| 78 | |
| 79 | - name: Install dependencies |
| 80 | working-directory: ${{ env.MONOREPO_DIR }} |
| 81 | run: pnpm install --frozen-lockfile |
| 82 | |
| 83 | - name: Parse manual app selection |
| 84 | id: parse-manual |
| 85 | run: | |
| 86 | MANUAL_APPS="" |
| 87 | |
| 88 | # If manual_apps passed via workflow_call, use directly |
| 89 | if [ -n "${{ inputs.manual_apps }}" ]; then |
| 90 | MANUAL_APPS="${{ inputs.manual_apps }}" |
| 91 | echo "✅ Using workflow_call manual apps: $MANUAL_APPS" |
| 92 | else |
| 93 | # Parse workflow_dispatch boolean inputs |
| 94 | if [ "${{ inputs.build_aitoearn_ai }}" = "true" ]; then |
| 95 | MANUAL_APPS="aitoearn-ai" |
| 96 | fi |
| 97 | |
| 98 | if [ "${{ inputs.build_aitoearn_server }}" = "true" ]; then |
| 99 | if [ -n "$MANUAL_APPS" ]; then |
| 100 | MANUAL_APPS="$MANUAL_APPS,aitoearn-server" |
| 101 | else |
| 102 | MANUAL_APPS="aitoearn-server" |
| 103 | fi |
| 104 | fi |
| 105 | |
| 106 | if [ -n "$MANUAL_APPS" ]; then |
| 107 | echo "✅ Manually selected apps: $MANUAL_APPS" |
| 108 | else |
| 109 | echo "⚠️ No apps manually selected" |
| 110 | fi |
| 111 | fi |
| 112 | |
| 113 | echo "manual_apps=$MANUAL_APPS" >> $GITHUB_OUTPUT |
| 114 | |
| 115 | - name: Detect affected apps |
| 116 | id: detect-changes |
| 117 | working-directory: ${{ env.MONOREPO_DIR }} |
| 118 | run: | |
| 119 | MANUAL_APPS="${{ steps.parse-manual.outputs.manual_apps }}" |
| 120 | |
| 121 | # Check if manual mode |
| 122 | if [ -n "$MANUAL_APPS" ]; then |
| 123 | echo "🎯 Manual build mode: Using specified app list" |
| 124 | AFFECTED_APPS="$MANUAL_APPS" |
| 125 | echo "✅ Manually selected apps: $AFFECTED_APPS" |
| 126 | echo "affected_apps=$AFFECTED_APPS" >> $GITHUB_OUTPUT |
| 127 | |
| 128 | echo "" |
| 129 | echo "Details:" |
| 130 | for APP in $(echo "$AFFECTED_APPS" | tr ',' ' '); do |
| 131 | echo " - $APP" |
| 132 | done |
| 133 | else |
| 134 | # Set base branch based on trigger type |
| 135 | if [ -n "${{ inputs.base_ref }}" ]; then |
| 136 | BASE_REF="origin/${{ inputs.base_ref }}" |
| 137 | echo "📋 PR mode: Detecting changes from $BASE_REF to HEAD" |
| 138 | else |
| 139 | BASE_REF="HEAD~1" |
| 140 | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then |
| 141 | echo "📋 Manual trigger mode: Detecting changes from last commit" |
| 142 | else |
| 143 | echo "📋 Push mode: Detecting changes from last commit" |
| 144 | fi |
| 145 | fi |
| 146 | |
| 147 | echo "Base: $BASE_REF" |
| 148 | echo "Head: HEAD" |
| 149 | |
| 150 | echo "" |
| 151 | echo "📊 Diagnostic:" |
| 152 | git log --oneline -3 |
| 153 | echo "" |
| 154 | echo "Changed files:" |
| 155 | git diff --name-only $BASE_REF HEAD 2>/dev/null || true |
| 156 | echo "" |
| 157 | |
| 158 | NX_OUTPUT=$(pnpm nx show projects --affected --type=app --sep "," --exclude="@yikart/source" --base=$BASE_REF --head=HEAD 2>&1) |
| 159 | NX_EXIT_CODE=$? |
| 160 | |
| 161 | echo "Nx exit code: $NX_EXIT_CODE" |
| 162 | echo "Nx output: $NX_OUTPUT" |
| 163 | |
| 164 | if [ $NX_EXIT_CODE -ne 0 ]; then |
| 165 | echo "⚠️ Nx affected detection failed (exit code: $NX_EXIT_CODE)" |
| 166 | echo "Falling back to building ALL apps" |
| 167 | AFFECTED_APPS="aitoearn-ai,aitoearn-server" |
| 168 | else |
| 169 | AFFECTED_APPS="$NX_OUTPUT" |
| 170 | fi |
| 171 | |
| 172 | if [ -z "$AFFECTED_APPS" ]; then |
| 173 | echo "ℹ️ No affected apps detected" |
| 174 | echo "affected_apps=" >> $GITHUB_OUTPUT |
| 175 | else |
| 176 | echo "✅ Affected apps: $AFFECTED_APPS" |
| 177 | echo "affected_apps=$AFFECTED_APPS" >> $GITHUB_OUTPUT |
| 178 | |
| 179 | echo "" |
| 180 | echo "Details:" |
| 181 | for APP in $(echo "$AFFECTED_APPS" | tr ',' ' '); do |
| 182 | echo " - $APP" |
| 183 | done |
| 184 | fi |
| 185 | fi |
| 186 | |
| 187 | - name: Generate version |
| 188 | id: version |
| 189 | run: | |
| 190 | VERSION="$(date +%Y%m%d)-$(git rev-parse --short HEAD)" |
| 191 | echo "version=$VERSION" >> $GITHUB_OUTPUT |
| 192 | echo "📦 Version: $VERSION" |
| 193 | |
| 194 | - name: Generate matrix |
| 195 | id: set-matrix |
| 196 | working-directory: ${{ env.MONOREPO_DIR }} |
| 197 | run: | |
| 198 | AFFECTED_APPS="${{ steps.detect-changes.outputs.affected_apps }}" |
| 199 | VERSION="${{ steps.version.outputs.version }}" |
| 200 | |
| 201 | if [ -z "$AFFECTED_APPS" ]; then |
| 202 | echo "has_changes=false" >> $GITHUB_OUTPUT |
| 203 | echo "matrix={\"include\":[]}" >> $GITHUB_OUTPUT |
| 204 | echo "⚠️ No changes, skipping build" |
| 205 | exit 0 |
| 206 | fi |
| 207 | |
| 208 | echo "has_changes=true" >> $GITHUB_OUTPUT |
| 209 | |
| 210 | # Determine if should push latest tag |
| 211 | PUSH_LATEST=false |
| 212 | if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then |
| 213 | PUSH_LATEST=true |
| 214 | echo "📌 Main branch push: Will push latest tag" |
| 215 | elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.push_latest }}" = "true" ]; then |
| 216 | PUSH_LATEST=true |
| 217 | echo "📌 Manual trigger with push_latest: Will push latest tag" |
| 218 | fi |
| 219 | |
| 220 | echo "push_latest=$PUSH_LATEST" >> $GITHUB_OUTPUT |
| 221 | |
| 222 | # Build matrix JSON array |
| 223 | MATRIX_JSON="{" |
| 224 | MATRIX_JSON="$MATRIX_JSON\"include\":[" |
| 225 | |
| 226 | FIRST=true |
| 227 | IFS=',' read -ra APPS <<< "$AFFECTED_APPS" |
| 228 | |
| 229 | for APP in "${APPS[@]}"; do |
| 230 | IMAGE_TAG="${{ env.DOCKER_HUB_USERNAME }}/$APP:$VERSION" |
| 231 | |
| 232 | # Add to matrix |
| 233 | if [ "$FIRST" = true ]; then |
| 234 | FIRST=false |
| 235 | else |
| 236 | MATRIX_JSON="$MATRIX_JSON," |
| 237 | fi |
| 238 | |
| 239 | MATRIX_JSON="$MATRIX_JSON{\"app\":\"$APP\",\"image\":\"$IMAGE_TAG\",\"push_latest\":$PUSH_LATEST}" |
| 240 | done |
| 241 | |
| 242 | MATRIX_JSON="$MATRIX_JSON]}" |
| 243 | |
| 244 | echo "matrix=$MATRIX_JSON" >> $GITHUB_OUTPUT |
| 245 | |
| 246 | echo "" |
| 247 | echo "🎯 Matrix configuration:" |
| 248 | echo "$MATRIX_JSON" | jq . |
| 249 | |
| 250 | build-and-push: |
| 251 | needs: generate-matrix |
| 252 | if: needs.generate-matrix.outputs.has_changes == 'true' |
| 253 | runs-on: ubuntu-latest |
| 254 | strategy: |
| 255 | matrix: ${{ fromJson(needs.generate-matrix.outputs.matrix) }} |
| 256 | fail-fast: false |
| 257 | |
| 258 | steps: |
| 259 | - name: Checkout |
| 260 | uses: actions/checkout@v5 |
| 261 | |
| 262 | - name: Install pnpm |
| 263 | uses: pnpm/action-setup@v4 |
| 264 | with: |
| 265 | version: 10 |
| 266 | |
| 267 | - name: Setup Node.js |
| 268 | uses: actions/setup-node@v4 |
| 269 | with: |
| 270 | node-version: 22 |
| 271 | cache: pnpm |
| 272 | cache-dependency-path: ${{ env.MONOREPO_DIR }}/pnpm-lock.yaml |
| 273 | |
| 274 | - name: Install dependencies |
| 275 | working-directory: ${{ env.MONOREPO_DIR }} |
| 276 | run: pnpm install --frozen-lockfile |
| 277 | |
| 278 | - name: Restore Nx cache |
| 279 | uses: actions/cache@v4 |
| 280 | with: |
| 281 | path: ${{ env.MONOREPO_DIR }}/.nx/cache |
| 282 | key: nx-${{ runner.os }}-${{ hashFiles('project/aitoearn-backend/pnpm-lock.yaml') }}-${{ matrix.app }}-${{ github.sha }} |
| 283 | restore-keys: | |
| 284 | nx-${{ runner.os }}-${{ hashFiles('project/aitoearn-backend/pnpm-lock.yaml') }}-${{ matrix.app }}- |
| 285 | nx-${{ runner.os }}-${{ hashFiles('project/aitoearn-backend/pnpm-lock.yaml') }}- |
| 286 | nx-${{ runner.os }}- |
| 287 | |
| 288 | - name: Build application |
| 289 | working-directory: ${{ env.MONOREPO_DIR }} |
| 290 | run: | |
| 291 | echo "🔨 Building app: ${{ matrix.app }}" |
| 292 | pnpm nx run ${{ matrix.app }}:build |
| 293 | |
| 294 | - name: Prepare Docker context |
| 295 | working-directory: ${{ env.MONOREPO_DIR }} |
| 296 | run: pnpm nx run ${{ matrix.app }}:docker-context |
| 297 | |
| 298 | - name: Login to Docker Hub |
| 299 | uses: docker/login-action@v3 |
| 300 | with: |
| 301 | username: ${{ env.DOCKER_HUB_USERNAME }} |
| 302 | password: ${{ secrets.DOCKER_HUB_TOKEN }} |
| 303 | |
| 304 | - name: Set up QEMU |
| 305 | uses: docker/setup-qemu-action@v3 |
| 306 | |
| 307 | - name: Set up Docker Buildx |
| 308 | uses: docker/setup-buildx-action@v3 |
| 309 | with: |
| 310 | driver-opts: | |
| 311 | network=host |
| 312 | image=moby/buildkit:latest |
| 313 | buildkitd-flags: --debug |
| 314 | |
| 315 | - name: Prepare tags |
| 316 | id: tags |
| 317 | run: | |
| 318 | TAGS="${{ matrix.image }}" |
| 319 | if [ "${{ matrix.push_latest }}" = "true" ]; then |
| 320 | TAGS="$TAGS"$'\n'"${{ env.DOCKER_HUB_USERNAME }}/${{ matrix.app }}:latest" |
| 321 | fi |
| 322 | echo "tags<<EOF" >> $GITHUB_OUTPUT |
| 323 | echo "$TAGS" >> $GITHUB_OUTPUT |
| 324 | echo "EOF" >> $GITHUB_OUTPUT |
| 325 | |
| 326 | - name: Build and push Docker image |
| 327 | uses: docker/build-push-action@v6 |
| 328 | with: |
| 329 | context: ${{ env.MONOREPO_DIR }}/tmp/docker-context |
| 330 | file: ${{ env.MONOREPO_DIR }}/tmp/docker-context/Dockerfile |
| 331 | platforms: linux/amd64,linux/arm64 |
| 332 | build-args: | |
| 333 | APP_NAME=${{ matrix.app }} |
| 334 | tags: ${{ steps.tags.outputs.tags }} |
| 335 | push: true |
| 336 | cache-from: | |
| 337 | type=gha,scope=${{ matrix.app }} |
| 338 | type=registry,ref=${{ env.DOCKER_HUB_USERNAME }}/${{ matrix.app }}:buildcache |
| 339 | cache-to: | |
| 340 | type=gha,mode=max,scope=${{ matrix.app }} |
| 341 | type=inline |
| 342 | provenance: false |
| 343 | sbom: false |
| 344 | |
| 345 | - name: Build summary |
| 346 | run: | |
| 347 | echo "### ✅ Build successful: ${{ matrix.app }}" >> $GITHUB_STEP_SUMMARY |
| 348 | echo "- **Image**: \`${{ matrix.image }}\`" >> $GITHUB_STEP_SUMMARY |
| 349 | if [ "${{ matrix.push_latest }}" = "true" ]; then |
| 350 | echo "- **Latest**: \`${{ env.DOCKER_HUB_USERNAME }}/${{ matrix.app }}:latest\`" >> $GITHUB_STEP_SUMMARY |
| 351 | fi |
| 352 | |
| 353 |