Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 143 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
70 changes: 56 additions & 14 deletions .github/workflows/publish-pr.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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'
Loading