diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11b8b08..22c76cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,65 +109,184 @@ jobs: - run: cd scripts/config && npm install - run: npx --prefix scripts/config jest --coverage --config scripts/config/package.json + # Each platform builds on a runner of its own architecture, so nothing + # is emulated. Neither job tags the image: each pushes an untagged + # manifest addressed only by its digest, and docker-manifest below + # joins the two digests into one multi-platform tag. docker-build: needs: [lint, test] - runs-on: ubuntu-latest + # Fork PRs cannot push; they take the archive path in + # docker-build-fork below. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write - env: - IS_FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} steps: - uses: actions/checkout@v4 with: # hatch-vcs derives the version from git tags in the build # context; a shallow checkout omits the history it needs. fetch-depth: 0 + # GITHUB_REPOSITORY keeps the owner's capitalization; a registry + # reference has to be lowercase. metadata-action does this for its + # own output, but the exporter below needs the name directly. + - name: Resolve image reference + id: image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: echo "ref=${IMAGE,,}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to registry - if: env.IS_FORK_PR != 'true' uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + # Labels only. The tags are applied to the manifest list, not to + # the per-platform images. - name: Generate image metadata id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern=v{{version}} - type=sha,format=long - - name: Build and push - if: env.IS_FORK_PR != 'true' + images: ${{ steps.image.outputs.ref }} + - name: Build and push by digest + id: build uses: docker/build-push-action@v6 with: context: . file: deploy/Dockerfile target: production - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - # Fork PRs run with a read-only token and cannot push. Build to an - # OCI archive and upload it; the publish-pr workflow (running in - # this repo's context) pushes it under controlled tags. - - name: Build image archive (fork PR) - if: env.IS_FORK_PR == 'true' + outputs: type=image,name=${{ steps.image.outputs.ref }},push-by-digest=true,name-canonical=true,push=true + # The digest is carried to the merge job as a filename; the file + # itself is empty. + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "${{ runner.temp }}/digests" + touch "${{ runner.temp }}/digests/${DIGEST#sha256:}" + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digest-${{ matrix.arch }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + docker-manifest: + needs: docker-build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + - name: Resolve image reference + id: image + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: echo "ref=${IMAGE,,}" >> "$GITHUB_OUTPUT" + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.ref }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern=v{{version}} + type=sha,format=long + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN || secrets.GITHUB_TOKEN }} + # One manifest list per tag, referencing both per-platform digests + # pushed above. DOCKER_METADATA_OUTPUT_JSON is set by + # metadata-action. + - name: Create and push manifest list + working-directory: ${{ runner.temp }}/digests + env: + IMAGE: ${{ steps.image.outputs.ref }} + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${IMAGE}@sha256:%s " *) + - name: Inspect manifest list + env: + VERSION: ${{ steps.meta.outputs.version }} + IMAGE: ${{ steps.image.outputs.ref }} + run: docker buildx imagetools inspect "${IMAGE}:${VERSION}" + + # Fork PRs run with a read-only token and cannot push, so the digest + # merge above is unavailable to them: it needs both images in the + # registry. Each platform still builds on a runner of its own + # architecture — the runner label is not restricted by the token — and + # uploads an archive of its own. The publish-pr workflow, running in + # this repo's context, joins those archives into one multi-platform + # image and pushes it under tags it controls. + docker-build-fork: + needs: [lint, test] + if: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + runs-on: ${{ matrix.runner }} + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Generate image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + - name: Build image archive uses: docker/build-push-action@v6 with: context: . file: deploy/Dockerfile target: production - outputs: type=oci,dest=${{ runner.temp }}/pr-image.tar + platforms: ${{ matrix.platform }} + outputs: type=oci,dest=${{ runner.temp }}/pr-image-${{ matrix.arch }}.tar labels: ${{ steps.meta.outputs.labels }} - - name: Upload image artifact (fork PR) - if: env.IS_FORK_PR == 'true' + - name: Upload image artifact uses: actions/upload-artifact@v4 with: - name: pr-image - path: ${{ runner.temp }}/pr-image.tar + name: pr-image-${{ matrix.arch }} + path: ${{ runner.temp }}/pr-image-${{ matrix.arch }}.tar + if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/publish-pr.yml b/.github/workflows/publish-pr.yml index db6a514..0f1c27b 100644 --- a/.github/workflows/publish-pr.yml +++ b/.github/workflows/publish-pr.yml @@ -1,9 +1,10 @@ # Second stage of fork PR image publishing. The CI workflow builds fork -# PRs with a read-only token and uploads the image as an artifact; this -# workflow runs in the base repo's context and pushes that archive to -# the registry under tags it derives from the GitHub API — never from -# anything the fork controls. It downloads and copies the archive but -# never checks out or executes fork code. +# PRs with a read-only token and uploads one image archive per platform +# as an artifact; this workflow runs in the base repo's context, pushes +# those archives and merges them into a multi-platform image under tags +# it derives from the GitHub API — never from anything the fork +# controls. It downloads and copies the archives but never checks out or +# executes fork code. name: Publish fork PR image on: @@ -36,7 +37,7 @@ jobs: repo: context.repo.repo, run_id: context.payload.workflow_run.id, }); - const found = artifacts.data.artifacts.some(a => a.name === 'pr-image'); + const found = artifacts.data.artifacts.some(a => a.name.startsWith('pr-image-')); core.setOutput('found', found ? 'true' : 'false'); # Resolve the PR number from the API by head SHA, not from run # contents, so a malicious PR cannot spoof another PR's tag. @@ -59,24 +60,65 @@ jobs: } core.setOutput('number', pr.number); core.setOutput('head_sha', headSha); - - name: Download image artifact + - name: Download image artifacts if: steps.artifact.outputs.found == 'true' uses: actions/download-artifact@v4 with: - name: pr-image + pattern: pr-image-* + merge-multiple: true run-id: ${{ github.event.workflow_run.id }} github-token: ${{ secrets.GITHUB_TOKEN }} - path: ${{ runner.temp }} - - name: Push image + path: ${{ runner.temp }}/archives + - name: Install crane + if: steps.artifact.outputs.found == 'true' + env: + CRANE_VERSION: v0.21.7 + BASE_URL: https://github.com/google/go-containerregistry/releases/download + run: | + set -euo pipefail + archive=go-containerregistry_Linux_x86_64.tar.gz + curl -fsSL -o "${archive}" "${BASE_URL}/${CRANE_VERSION}/${archive}" + curl -fsSL -o checksums.txt "${BASE_URL}/${CRANE_VERSION}/checksums.txt" + grep " ${archive}\$" checksums.txt | sha256sum -c - + tar -xzf "${archive}" crane + sudo install -m 0755 crane /usr/local/bin/crane + crane version + # The per-platform archives are combined into one OCI layout: the + # blobs are content-addressed, so they share a directory as-is, and + # only the index.json manifests arrays are joined. Each descriptor + # carries its own platform, which is what makes the merged index + # resolvable. + # + # The platform list comes from the downloaded filenames, so adding + # a platform to the CI matrix needs no change here. + - name: Merge and push image if: steps.artifact.outputs.found == 'true' env: REGISTRY_USER: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ steps.pr.outputs.number }} HEAD_SHA: ${{ steps.pr.outputs.head_sha }} - ARCHIVE: ${{ runner.temp }}/pr-image.tar + ARCHIVE_DIR: ${{ runner.temp }}/archives + LAYOUT: ${{ runner.temp }}/merged run: | + set -euo pipefail IMAGE="${REGISTRY}/${GITHUB_REPOSITORY,,}" - echo "${REGISTRY_TOKEN}" | skopeo login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin - skopeo copy --all "oci-archive:${ARCHIVE}" "docker://${IMAGE}:pr-${PR_NUMBER}" - skopeo copy --all "oci-archive:${ARCHIVE}" "docker://${IMAGE}:sha-${HEAD_SHA}" + printf '%s' "${REGISTRY_TOKEN}" | + crane auth login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin + + mkdir -p "${LAYOUT}/blobs/sha256" + manifests='[]' + for archive in "${ARCHIVE_DIR}"/pr-image-*.tar; do + work="$(mktemp -d)" + tar -xf "${archive}" -C "${work}" + cp -a "${work}/blobs/sha256/." "${LAYOUT}/blobs/sha256/" + manifests="$(jq -c --argjson acc "${manifests}" '$acc + .manifests' "${work}/index.json")" + done + printf '{"imageLayoutVersion":"1.0.0"}\n' > "${LAYOUT}/oci-layout" + jq -n --argjson m "${manifests}" \ + '{schemaVersion: 2, mediaType: "application/vnd.oci.image.index.v1+json", manifests: $m}' \ + > "${LAYOUT}/index.json" + + crane push --index "${LAYOUT}" "${IMAGE}:pr-${PR_NUMBER}" + crane tag "${IMAGE}:pr-${PR_NUMBER}" "sha-${HEAD_SHA}" + crane manifest "${IMAGE}:pr-${PR_NUMBER}" | jq -c '.manifests[].platform'