Build and Push Docker Images #2516
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Push Docker Images | |
| on: | |
| push: | |
| branches: [main] | |
| tags: ["v*"] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| inputs: | |
| environment: | |
| description: "Deployment environment" | |
| required: true | |
| default: "dev" | |
| type: choice | |
| options: | |
| - dev | |
| - prod | |
| env: | |
| BUILD_PLATFORMS: linux/amd64 #,linux/arm64 | |
| DOCKER_REGISTRY: ghcr.io | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| # `next build` runs with `typescript.ignoreBuildErrors: true` and the Docker | |
| # build never runs `check-types`, so without this gate a `v*` tag (or a | |
| # workflow_dispatch on any branch) could push an image that never | |
| # type-checked. ci.yml already gates `pull_request` and push-to-main with | |
| # the same (stronger — see the two run steps below) checks, so this job | |
| # only needs to re-run for events ci.yml does not cover: a version tag or a | |
| # manual dispatch. That keeps the quality gate traveling with the artifact | |
| # without duplicating ci.yml's jobs on every PR. | |
| check: | |
| name: Type-check, lint, test | |
| if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Set up pnpm | |
| uses: pnpm/action-setup@v6.0.10 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v5 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Set up Turbo cache | |
| uses: rharkor/caching-for-turbo@v2.5.1 | |
| # `pnpm lint` covers everything, cheapest-first: check:agent-instructions, | |
| # `turbo run lint` for apps/builder's i18n check, then repo-wide Biome | |
| # last (slowest). Type-check and test are separate turbo tasks with no | |
| # lint overlap, so they run standalone here instead of double-running | |
| # lint via a combined `turbo run check-types lint test`. | |
| - name: Lint | |
| run: pnpm lint | |
| - name: Type-check and test | |
| run: pnpm turbo run check-types test | |
| prepare-metadata: | |
| runs-on: ubuntu-latest | |
| # Only checks out and computes tag/label strings via docker/metadata- | |
| # action — never touches the registry, so packages: write is unneeded. | |
| permissions: | |
| contents: read | |
| environment: ${{ github.event.inputs.environment || 'dev' }} | |
| outputs: | |
| version: ${{ steps.meta.outputs.version }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| image_namespace: ${{ steps.image_namespace.outputs.owner_lc }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Set image namespace (GHCR requires lowercase) | |
| id: image_namespace | |
| run: echo "owner_lc=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" | |
| - name: Extract metadata | |
| id: meta | |
| uses: docker/metadata-action@v5 | |
| with: | |
| images: | | |
| ${{ env.DOCKER_REGISTRY }}/${{ steps.image_namespace.outputs.owner_lc }}/chatbotx-builder | |
| ${{ env.DOCKER_REGISTRY }}/${{ steps.image_namespace.outputs.owner_lc }}/chatbotx-worker | |
| ${{ env.DOCKER_REGISTRY }}/${{ steps.image_namespace.outputs.owner_lc }}/chatbotx-realtime | |
| ${{ env.DOCKER_REGISTRY }}/${{ steps.image_namespace.outputs.owner_lc }}/chatbotx-mcp | |
| ${{ env.DOCKER_REGISTRY }}/${{ steps.image_namespace.outputs.owner_lc }}/chatbotx-javascript-executor | |
| tags: | | |
| type=ref,event=branch | |
| type=ref,event=pr | |
| type=semver,pattern={{version}} | |
| type=semver,pattern={{major}}.{{minor}} | |
| type=sha,prefix={{branch}}- | |
| build-image: | |
| name: Build ${{ matrix.label }} | |
| runs-on: ${{ matrix.runner }} | |
| needs: | |
| - prepare-metadata | |
| - check | |
| # PRs never push an image (see the "Build image for pull request" step | |
| # below) and ci.yml already gates types/lint/tests, so the PR run's only | |
| # value is a Dockerfile-buildability signal. Keep just the builder leg for | |
| # that on `pull_request` — it's the one most likely to break and has by | |
| # far the longest build (~16 min vs ~1-2 min for the other four). | |
| # | |
| # This gate lives on the "Build image for pull request" step below, NOT | |
| # here at job level: `jobs.<job_id>.if` only has access to the `github`, | |
| # `needs`, `vars`, and `inputs` contexts — `matrix` isn't expanded yet at | |
| # that point, so a job-level `if: matrix.label == 'builder'` is invalid | |
| # YAML that fails workflow validation for every trigger, not just this | |
| # job (this broke the whole file for several hours — see git blame / | |
| # incident notes before changing this again). `runs-on` above CAN see | |
| # `matrix` because it's evaluated after matrix expansion; step-level `if` | |
| # can too, for the same reason. The other four legs still spin up a | |
| # runner on `pull_request` and no-op immediately (all three build steps' | |
| # conditions are false), which costs a few seconds each rather than the | |
| # full build — not free, but the only place GitHub allows this check. | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # The builder compile is CPU-bound and `next build` only ever reports | |
| # 3 workers on a 4-vCPU runner, so a bigger runner is the cheapest | |
| # remaining win — but it needs one that actually exists. GitHub does | |
| # NOT publish an `ubuntu-latest-N-cores` label: larger runners are an | |
| # org-level feature that an admin must create and name, and | |
| # `ubuntu-latest-4-cores` in GitHub's docs is only an example name. | |
| # A public repo makes larger-runner minutes free, not the runners | |
| # themselves. An unmatched label does not fail the job — it queues | |
| # until it times out — so this stays on `ubuntu-latest` until a real | |
| # provisioned label is confirmed (`gh api /orgs/ChatbotXIO/actions/ | |
| # runner-groups` needs admin:org). Then it is a one-word change here. | |
| - label: builder | |
| image: chatbotx-builder | |
| dockerfile: ./apps/builder/docker/Dockerfile | |
| runner: ubuntu-latest | |
| - label: worker | |
| image: chatbotx-worker | |
| dockerfile: ./apps/worker/docker/Dockerfile | |
| runner: ubuntu-latest | |
| - label: realtime | |
| image: chatbotx-realtime | |
| dockerfile: ./apps/realtime/docker/Dockerfile | |
| runner: ubuntu-latest | |
| - label: mcp-server | |
| image: chatbotx-mcp | |
| dockerfile: ./apps/mcp-server/docker/Dockerfile | |
| runner: ubuntu-latest | |
| - label: javascript-executor | |
| image: chatbotx-javascript-executor | |
| dockerfile: ./apps/javascript-executor/docker/Dockerfile | |
| runner: ubuntu-latest | |
| # Baseline builder leg was ~16 min and the others ~1-2 min. Bounds a hung | |
| # build, and an unprovisioned `runner` label, against the 24-hour default. | |
| timeout-minutes: 45 | |
| permissions: | |
| contents: read | |
| packages: write | |
| environment: ${{ github.event.inputs.environment || 'dev' }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v5 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ${{ env.DOCKER_REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push main image | |
| if: github.event_name != 'pull_request' && github.ref_name == 'main' | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: | | |
| ${{ env.DOCKER_REGISTRY }}/${{ needs.prepare-metadata.outputs.image_namespace }}/${{ matrix.image }}:main | |
| labels: ${{ needs.prepare-metadata.outputs.labels }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| platforms: ${{ env.BUILD_PLATFORMS }} | |
| # Turbo remote cache: deliberately NOT passed. buildx rejects an | |
| # empty-valued secret outright, so passing the partial set that exists | |
| # (TURBO_TOKEN/TURBO_TEAM, no TURBO_API) dropped TURBO_API and left | |
| # turbo stalling on its built-in Vercel default — | |
| # `Remote caching unavailable (Could not connect to vercel.com/api)`. | |
| # The Dockerfile's --mount=type=secret lines are optional-safe, so | |
| # restoring these three lines together is all it takes once a | |
| # turborepo-remote-cache is provisioned and TURBO_API is set. | |
| - name: Build and push version image | |
| if: github.event_name != 'pull_request' && startsWith(github.ref, 'refs/tags/v') | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: | | |
| ${{ env.DOCKER_REGISTRY }}/${{ needs.prepare-metadata.outputs.image_namespace }}/${{ matrix.image }}:${{ needs.prepare-metadata.outputs.version }} | |
| ${{ env.DOCKER_REGISTRY }}/${{ needs.prepare-metadata.outputs.image_namespace }}/${{ matrix.image }}:latest | |
| labels: ${{ needs.prepare-metadata.outputs.labels }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| platforms: ${{ env.BUILD_PLATFORMS }} | |
| # Turbo remote cache secrets omitted — see "Build and push main image". | |
| - name: Build image for pull request | |
| if: github.event_name == 'pull_request' && matrix.label == 'builder' | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ${{ matrix.dockerfile }} | |
| push: false | |
| labels: ${{ needs.prepare-metadata.outputs.labels }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| platforms: ${{ env.BUILD_PLATFORMS }} | |
| # Turbo remote cache secrets omitted — see "Build and push main image". | |
| output-image-urls: | |
| runs-on: ubuntu-latest | |
| needs: | |
| - prepare-metadata | |
| - build-image | |
| if: github.event_name != 'pull_request' | |
| # Only prints strings computed from `needs` outputs — no registry call. | |
| permissions: | |
| contents: read | |
| environment: ${{ github.event.inputs.environment || 'dev' }} | |
| steps: | |
| - name: Output image URLs | |
| run: | | |
| VERSION="${{ needs.prepare-metadata.outputs.version }}" | |
| REGISTRY="${{ env.DOCKER_REGISTRY }}/${{ needs.prepare-metadata.outputs.image_namespace }}" | |
| if [[ "${{ github.ref_name }}" == "main" ]]; then | |
| printf '%s\n' \ | |
| "Builder image (main): ${REGISTRY}/chatbotx-builder:main" \ | |
| "Worker image (main): ${REGISTRY}/chatbotx-worker:main" \ | |
| "Realtime image (main): ${REGISTRY}/chatbotx-realtime:main" \ | |
| "MCP Server image (main): ${REGISTRY}/chatbotx-mcp:main" \ | |
| "JavaScript Executor image (main): ${REGISTRY}/chatbotx-javascript-executor:main" | |
| elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then | |
| printf '%s\n' \ | |
| "Builder image (version): ${REGISTRY}/chatbotx-builder:${VERSION}" \ | |
| "Builder image (latest): ${REGISTRY}/chatbotx-builder:latest" \ | |
| "Worker image (version): ${REGISTRY}/chatbotx-worker:${VERSION}" \ | |
| "Worker image (latest): ${REGISTRY}/chatbotx-worker:latest" \ | |
| "Realtime image (version): ${REGISTRY}/chatbotx-realtime:${VERSION}" \ | |
| "Realtime image (latest): ${REGISTRY}/chatbotx-realtime:latest" \ | |
| "MCP Server image (version): ${REGISTRY}/chatbotx-mcp:${VERSION}" \ | |
| "MCP Server image (latest): ${REGISTRY}/chatbotx-mcp:latest" \ | |
| "JavaScript Executor image (version): ${REGISTRY}/chatbotx-javascript-executor:${VERSION}" \ | |
| "JavaScript Executor image (latest): ${REGISTRY}/chatbotx-javascript-executor:latest" | |
| else | |
| echo "No publish tags for ref ${GITHUB_REF}." | |
| fi |