Release and Publish Docker Images #158
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: Release and Publish Docker Images | |
| on: | |
| workflow_run: | |
| workflows: ["Backend Tests (Postgres)"] | |
| types: [completed] | |
| env: | |
| REGISTRY: ghcr.io | |
| API_IMAGE_NAME: ${{ github.repository }}-api | |
| WORKER_IMAGE_NAME: ${{ github.repository }}-worker | |
| permissions: | |
| contents: write | |
| packages: write | |
| pull-requests: read | |
| concurrency: | |
| group: release-${{ github.event.workflow_run.head_branch || github.ref_name }} | |
| cancel-in-progress: false | |
| jobs: | |
| # ── Step 1: Compute version and create git tag ────────────── | |
| release: | |
| if: > | |
| github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.event == 'push' && | |
| (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') | |
| runs-on: ubuntu-latest | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| major_minor: ${{ steps.version.outputs.major_minor }} | |
| next_tag: ${{ steps.version.outputs.next_tag }} | |
| steps: | |
| - name: Determine bump type from associated PR labels | |
| id: bump | |
| uses: actions/github-script@v7 | |
| env: | |
| MERGE_SHA: ${{ github.event.workflow_run.head_sha }} | |
| with: | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const mergeSha = process.env.MERGE_SHA; | |
| let labels = []; | |
| try { | |
| const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ | |
| owner, | |
| repo, | |
| commit_sha: mergeSha, | |
| }); | |
| const mergedPR = (response.data || []).find((pr) => pr.merged_at); | |
| if (mergedPR) { | |
| labels = (mergedPR.labels || []).map((label) => label.name.toLowerCase()); | |
| core.info(`Using labels from PR #${mergedPR.number}`); | |
| } else { | |
| core.info("No merged PR associated with this commit. Defaulting to patch."); | |
| } | |
| } catch (err) { | |
| core.warning(`Unable to resolve PR labels for commit ${mergeSha}: ${err.message}`); | |
| } | |
| core.info(`PR labels: ${labels.join(", ") || "none"}`); | |
| let bump = "patch"; | |
| if (labels.includes("major")) { | |
| bump = "major"; | |
| } else if (labels.includes("minor")) { | |
| bump = "minor"; | |
| } else if (labels.includes("fix") || labels.includes("patch")) { | |
| bump = "patch"; | |
| } else { | |
| core.info("No semver label found. Defaulting to patch bump."); | |
| } | |
| core.setOutput("bump", bump); | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.event.workflow_run.head_sha }} | |
| fetch-depth: 0 | |
| - name: Compute next semantic version | |
| id: version | |
| env: | |
| MERGE_SHA: ${{ github.event.workflow_run.head_sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git fetch --tags --force | |
| existing_tag="" | |
| while IFS= read -r tag; do | |
| if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
| existing_tag="$tag" | |
| break | |
| fi | |
| done < <(git tag --points-at "$MERGE_SHA" --sort=-v:refname) | |
| if [[ -n "$existing_tag" ]]; then | |
| next_tag="$existing_tag" | |
| create_tag="false" | |
| else | |
| latest_tag="$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | sed -n '1p')" | |
| if [[ -z "$latest_tag" ]]; then | |
| latest_tag="v0.0.0" | |
| fi | |
| current="${latest_tag#v}" | |
| IFS='.' read -r major minor patch <<< "$current" | |
| if [[ -z "${major:-}" || -z "${minor:-}" || -z "${patch:-}" ]]; then | |
| echo "Latest tag '$latest_tag' is not semver format (v<major>.<minor>.<patch>)." | |
| exit 1 | |
| fi | |
| case "${{ steps.bump.outputs.bump }}" in | |
| major) | |
| major=$((major + 1)) | |
| minor=0 | |
| patch=0 | |
| ;; | |
| minor) | |
| minor=$((minor + 1)) | |
| patch=0 | |
| ;; | |
| patch) | |
| patch=$((patch + 1)) | |
| ;; | |
| *) | |
| echo "Unknown bump type: ${{ steps.bump.outputs.bump }}" | |
| exit 1 | |
| ;; | |
| esac | |
| next_tag="v${major}.${minor}.${patch}" | |
| create_tag="true" | |
| fi | |
| version="${next_tag#v}" | |
| major_minor="$(echo "$version" | awk -F. '{print $1 "." $2}')" | |
| echo "next_tag=$next_tag" >> "$GITHUB_OUTPUT" | |
| echo "version=$version" >> "$GITHUB_OUTPUT" | |
| echo "major_minor=$major_minor" >> "$GITHUB_OUTPUT" | |
| echo "create_tag=$create_tag" >> "$GITHUB_OUTPUT" | |
| echo "Next tag: $next_tag" | |
| - name: Create and push git tag | |
| if: steps.version.outputs.create_tag == 'true' | |
| env: | |
| NEXT_TAG: ${{ steps.version.outputs.next_tag }} | |
| MERGE_SHA: ${{ github.event.workflow_run.head_sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| git tag "$NEXT_TAG" "$MERGE_SHA" | |
| git push origin "$NEXT_TAG" | |
| # ── Step 2: Build each platform natively in parallel ──────── | |
| build: | |
| needs: release | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - platform: linux/amd64 | |
| runner: ubuntu-latest | |
| slug: amd64 | |
| - platform: linux/arm64 | |
| runner: ubuntu-24.04-arm | |
| slug: arm64 | |
| runs-on: ${{ matrix.runner }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Lowercase image names | |
| run: | | |
| echo "API_IMAGE_NAME=${API_IMAGE_NAME,,}" >> $GITHUB_ENV | |
| echo "WORKER_IMAGE_NAME=${WORKER_IMAGE_NAME,,}" >> $GITHUB_ENV | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push API image (by digest) | |
| id: build-api | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| file: docker/Dockerfile.api | |
| platforms: ${{ matrix.platform }} | |
| outputs: type=image,name=${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true | |
| cache-from: type=gha,scope=api-${{ matrix.slug }} | |
| cache-to: type=gha,scope=api-${{ matrix.slug }},mode=max | |
| - name: Build and push Worker image (by digest) | |
| id: build-worker | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: . | |
| file: docker/Dockerfile.worker | |
| platforms: ${{ matrix.platform }} | |
| outputs: type=image,name=${{ env.REGISTRY }}/${{ env.WORKER_IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true | |
| cache-from: type=gha,scope=worker-${{ matrix.slug }} | |
| cache-to: type=gha,scope=worker-${{ matrix.slug }},mode=max | |
| build-args: | | |
| INSTALL_EXTRAS=qualitative-voice | |
| - name: Export digests | |
| run: | | |
| mkdir -p /tmp/digests/api /tmp/digests/worker | |
| api_digest="${{ steps.build-api.outputs.digest }}" | |
| worker_digest="${{ steps.build-worker.outputs.digest }}" | |
| touch "/tmp/digests/api/${api_digest#sha256:}" | |
| touch "/tmp/digests/worker/${worker_digest#sha256:}" | |
| - name: Upload API digest | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: api-digest-${{ matrix.slug }} | |
| path: /tmp/digests/api/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| - name: Upload Worker digest | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: worker-digest-${{ matrix.slug }} | |
| path: /tmp/digests/worker/* | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # ── Step 3: Merge platform digests into multi-arch manifests ─ | |
| merge: | |
| needs: [release, build] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| - name: Lowercase image names | |
| run: | | |
| echo "API_IMAGE_NAME=${API_IMAGE_NAME,,}" >> $GITHUB_ENV | |
| echo "WORKER_IMAGE_NAME=${WORKER_IMAGE_NAME,,}" >> $GITHUB_ENV | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Log in to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Download API digests | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: /tmp/digests/api | |
| pattern: api-digest-* | |
| merge-multiple: true | |
| - name: Download Worker digests | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: /tmp/digests/worker | |
| pattern: worker-digest-* | |
| merge-multiple: true | |
| - name: Create API multi-arch manifest | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| MAJOR_MINOR: ${{ needs.release.outputs.major_minor }} | |
| IMAGE: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }} | |
| run: | | |
| cd /tmp/digests/api | |
| docker buildx imagetools create \ | |
| -t "$IMAGE:${VERSION}" \ | |
| -t "$IMAGE:${MAJOR_MINOR}" \ | |
| -t "$IMAGE:latest" \ | |
| $(printf "$IMAGE@sha256:%s " *) | |
| - name: Create Worker multi-arch manifest | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| MAJOR_MINOR: ${{ needs.release.outputs.major_minor }} | |
| IMAGE: ${{ env.REGISTRY }}/${{ env.WORKER_IMAGE_NAME }} | |
| run: | | |
| cd /tmp/digests/worker | |
| docker buildx imagetools create \ | |
| -t "$IMAGE:${VERSION}" \ | |
| -t "$IMAGE:${MAJOR_MINOR}" \ | |
| -t "$IMAGE:latest" \ | |
| $(printf "$IMAGE@sha256:%s " *) | |
| - name: Create GitHub release | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| NEXT_TAG: ${{ needs.release.outputs.next_tag }} | |
| MERGE_SHA: ${{ github.event.workflow_run.head_sha }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if gh release view "$NEXT_TAG" >/dev/null 2>&1; then | |
| echo "Release $NEXT_TAG already exists. Skipping." | |
| exit 0 | |
| fi | |
| gh release create "$NEXT_TAG" \ | |
| --target "$MERGE_SHA" \ | |
| --title "$NEXT_TAG" \ | |
| --generate-notes |