diff --git a/.github/workflows/docker-pr.yml b/.github/workflows/docker-pr.yml new file mode 100644 index 0000000..1fbfc4e --- /dev/null +++ b/.github/workflows/docker-pr.yml @@ -0,0 +1,67 @@ +name: Docker image (PR) + +# Default permissions are least-privilege; no job needs elevated access. +# This satisfies zizmor's excessive-permissions check. +permissions: + contents: read + +# A newer push to the PR cancels any older build that is still running. +concurrency: + group: docker-image-${{ github.ref }} + cancel-in-progress: true + +# Validation-only builds for pull requests: each platform builds natively on +# its own runner, without logging in to GHCR or pushing. Publishing lives in +# docker.yml. +on: + pull_request: + branches: [main] + +jobs: + build-amd64: + name: Build image (amd64) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build + uses: docker/bake-action@76cc8060bdff6d632a465001e4cf300684c5472c # v5.7.0 + with: + files: | + website/docker-bake.hcl + # Use the dedicated PR target, which omits `cache-to` because + # fork PRs lack permission to write to the GHA cache. + targets: synapse-website-pr + set: | + *.platform=linux/amd64 + + build-arm64: + name: Build image (arm64) + runs-on: ubuntu-24.04-arm + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build + uses: docker/bake-action@76cc8060bdff6d632a465001e4cf300684c5472c # v5.7.0 + with: + files: | + website/docker-bake.hcl + targets: synapse-website-pr + set: | + *.platform=linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 82870cf..67f2d39 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,6 @@ name: Docker image -# Default permissions are least-privilege; the push job elevates to +# Default permissions are least-privilege; the push jobs elevate to # packages: write below. This satisfies zizmor's excessive-permissions check. permissions: contents: read @@ -12,12 +12,12 @@ concurrency: group: docker-image-${{ github.ref }} cancel-in-progress: true +# Builds and publishes the multi-arch website image on pushes to main and +# on v* tags. Pull-request validation builds live in docker-pr.yml. on: push: branches: [main] tags: ['v*'] - pull_request: - branches: [main] workflow_dispatch: env: @@ -30,16 +30,22 @@ env: # readability. IMAGE_NAME: ${{ github.repository_owner }}/synapse-website +# Each platform is built natively on its own runner (arm64 on GitHub's free +# arm runner instead of QEMU emulation on an amd64 runner) and pushed to +# GHCR by digest; the merge job then combines the two digests into a single +# multi-arch manifest list with the computed tags. +# +# Pattern documented at +# https://docs.docker.com/build/ci/github-actions/multi-platform/ jobs: - build: - name: Build & push image + build-amd64: + name: Build & push image (amd64) runs-on: ubuntu-latest - # Publish only on direct pushes to main or on v* tags. PRs run the - # build-pr job (push: false, no cache-to) below. - if: github.event_name != 'pull_request' permissions: contents: read packages: write + outputs: + digest: ${{ steps.digest.outputs.digest }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -61,34 +67,54 @@ jobs: uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # The computed tags are only applied by the merge job; this step + # exists so its OCI labels get baked into the pushed image. tags: | type=ref,event=branch type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} - # bake-target makes metadata-action emit a JSON file - # (steps.meta.outputs.bake-file) that overrides the same-named - # target in docker-bake.hcl with the computed tags + labels. + # bake-target makes metadata-action emit a JSON file that supplies + # the labels for the 'synapse-website' target. bake-target: synapse-website - - name: Build and push + - name: Build and push by digest + id: build uses: docker/bake-action@76cc8060bdff6d632a465001e4cf300684c5472c # v5.7.0 with: - # The third entry is the metadata-action-generated JSON; it - # supplies tags + labels for the 'synapse-website' target. + # The metadata file supplies the labels; the set overrides then + # replace its tags with the bare repository name, which is what + # push-by-digest requires (tags are attached by the merge job). files: | website/docker-bake.hcl ${{ steps.meta.outputs.bake-file }} targets: synapse-website - push: true + set: | + *.platform=linux/amd64 + *.tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + *.output=type=image,push-by-digest=true,name-canonical=true,push=true - build-pr: - name: Build (PR only) - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + - name: Record pushed digest + id: digest + env: + BAKE_METADATA: ${{ steps.build.outputs.metadata }} + run: | + digest="$(jq -r '.["synapse-website"]["containerimage.digest"]' <<<"$BAKE_METADATA")" + if [[ -z "$digest" || "$digest" == "null" ]]; then + echo "bake metadata is missing containerimage.digest" >&2 + exit 1 + fi + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + build-arm64: + name: Build & push image (arm64) + runs-on: ubuntu-24.04-arm permissions: contents: read + packages: write + outputs: + digest: ${{ steps.digest.outputs.digest }} steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -98,12 +124,100 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - - name: Build + - name: Log in to GHCR + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + bake-target: synapse-website + + - name: Build and push by digest + id: build uses: docker/bake-action@76cc8060bdff6d632a465001e4cf300684c5472c # v5.7.0 with: files: | website/docker-bake.hcl - # Use the dedicated PR target, which omits `cache-to` because - # fork PRs lack permission to write to the GHA cache. - targets: synapse-website-pr - push: false + ${{ steps.meta.outputs.bake-file }} + targets: synapse-website + set: | + *.platform=linux/arm64 + *.tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + *.output=type=image,push-by-digest=true,name-canonical=true,push=true + + - name: Record pushed digest + id: digest + env: + BAKE_METADATA: ${{ steps.build.outputs.metadata }} + run: | + digest="$(jq -r '.["synapse-website"]["containerimage.digest"]' <<<"$BAKE_METADATA")" + if [[ -z "$digest" || "$digest" == "null" ]]; then + echo "bake metadata is missing containerimage.digest" >&2 + exit 1 + fi + echo "digest=$digest" >> "$GITHUB_OUTPUT" + + merge: + name: Merge manifests & push tags + runs-on: ubuntu-latest + needs: [build-amd64, build-arm64] + permissions: + contents: read + packages: write + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Log in to GHCR + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Create multi-arch manifest list and push + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} + ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} + run: | + tag_args=() + while IFS= read -r tag; do + tag_args+=("-t" "$tag") + done <<<"$(jq -r '.tags[]' <<<"$DOCKER_METADATA_OUTPUT_JSON")" + docker buildx imagetools create "${tag_args[@]}" \ + "$IMAGE@$AMD64_DIGEST" "$IMAGE@$ARM64_DIGEST" + + - name: Inspect image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + VERSION: ${{ steps.meta.outputs.version }} + run: | + # version is the branch name on main pushes and the semver on tag + # pushes (verified in workflow logs), so this always resolves; the + # guard just turns a future trigger change into a clear failure. + docker buildx imagetools inspect "${IMAGE}:${VERSION:?metadata-action produced no version tag}" diff --git a/website/docker-bake.hcl b/website/docker-bake.hcl index c4614bf..3b4bab5 100644 --- a/website/docker-bake.hcl +++ b/website/docker-bake.hcl @@ -1,7 +1,14 @@ -# Build targets consumed by docker/bake-action (see .github/workflows/docker.yml). +# Build targets consumed by docker/bake-action (see .github/workflows/ +# docker.yml for push/tag publishes and docker-pr.yml for PR builds). # Defining the build here keeps the Dockerfile, tags, cache config, and # platform declarations in one file under the website/ source tree. # +# CI builds each platform natively on its own runner (amd64 and arm64 jobs +# override `platform` via `--set`, avoiding QEMU emulation) and merges the +# two pushed digests into one multi-arch manifest list. A plain local +# `docker buildx bake synapse-website` still builds both platforms on a +# single node. +# # The PR build target omits `cache-to` because fork pull requests don't have # permission to write to the GHA cache; trying to export there fails the # required check.