Skip to content
Open
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
150 changes: 150 additions & 0 deletions .github/actions/build-flavor/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
name: Build one image flavor
description: >
Builds one flavor of one cell for a single architecture and pushes it by digest.
Push-by-digest leaves the manifest untagged; the merge-flavor action is what
assembles the tagged manifest list, so no consumer-facing tag ever points at a
half-published or unsigned image.

inputs:
cell:
description: One build entry emitted by scripts/build_pgedge_images.py (PGEDGE_EMIT_MATRIX=1)
required: true
repo:
description: Image repository to push to
required: true
registry_token:
description: >
Token for the container registry. A composite action has no secrets
context, so this has to be passed in by the calling workflow.
required: true
dry_run:
description: When true, resolve and print the build without pushing
required: false
default: "false"
no_cache:
description: When true, build without cache
required: false
default: "false"

outputs:
digest:
description: Digest of the pushed per-architecture manifest
value: ${{ steps.build.outputs.digest }}

runs:
using: composite
steps:
# No QEMU: the calling job selects a runner native to the target
# architecture, so nothing here is emulated.
# buildx-init only registers the builder; buildx selects it via the
# BUILDX_BUILDER environment variable. Without this the build lands on the
# default docker driver, which rejects the bake file's attestations.
- name: Setup Docker Buildx
shell: bash
run: |
set -o errexit -o pipefail
make buildx-init
echo "BUILDX_BUILDER=$(make -s print-buildx-builder)" >> "$GITHUB_ENV"

- name: Login to the container registry
if: ${{ inputs.dry_run != 'true' }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.registry_token }}

- name: Set up Go
if: ${{ inputs.dry_run != 'true' }}
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: '1.25'
cache-dependency-path: tests/go.sum

- name: Build and push by digest
id: build
shell: bash
env:
CELL: ${{ inputs.cell }}
REPO: ${{ inputs.repo }}
DRY_RUN: ${{ inputs.dry_run }}
NO_CACHE: ${{ inputs.no_cache }}
run: |
set -o errexit -o pipefail -o nounset

target=$(jq -r '.target' <<< "$CELL")
arch=$(jq -r '.arch' <<< "$CELL")
parent=$(jq -r '.parent_build_tag' <<< "$CELL")
build_tag=$(jq -r '.build_tag' <<< "$CELL")

args=(
--file pgedge.docker-bake.hcl
--set "default.platform=linux/${arch}"
--set "default.tags="
--metadata-file metadata.json
)
if [[ "$NO_CACHE" == "true" ]]; then
args+=(--no-cache)
fi
if [[ "$DRY_RUN" == "true" ]]; then
args+=(--print)
else
args+=(--set "default.output=type=image,name=${REPO},push-by-digest=true,name-canonical=true,push=true")
fi

# Every packagelist ARG comes from the cell, so a new flavor needs no
# change here -- only the Dockerfile and the driver's maps.
envs=(
"PACKAGE_RELEASE_CHANNEL=$(jq -r '.package_release_channel' <<< "$CELL")"
"POSTGRES_MAJOR_VERSION=$(jq -r '.postgres_major' <<< "$CELL")"
"TARGET=${target}"
"TAG=${REPO}"
)
while IFS= read -r kv; do
envs+=("$kv")
done < <(jq -r '.package_list_args | to_entries[] | "\(.key)=\(.value)"' <<< "$CELL")

# A chained flavor starts FROM the image the previous wave published.
parent_arg=$(jq -r '.parent_image_arg' <<< "$CELL")
if [[ -n "$parent" && -n "$parent_arg" ]]; then
envs+=("${parent_arg}=${REPO}:${parent}")
fi

env "${envs[@]}" docker buildx bake "${args[@]}"

if [[ "$DRY_RUN" != "true" ]]; then
digest=$(jq -r '.["default"]["containerimage.digest"] // .["containerimage.digest"]' metadata.json)
test -n "$digest" && test "$digest" != "null"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
mkdir -p digests
echo -n "$digest" > "digests/${arch}"
echo "::notice::${target} ${build_tag} ${arch} -> ${digest}"
fi

# Runs against the bytes just pushed, on a runner native to their
# architecture, so a broken image never reaches the merge step. The digest is
# re-tagged locally first: the harness derives the expected spock major from
# the tag and silently skips that assertion for a bare digest reference.
- name: Test the pushed image
if: ${{ inputs.dry_run != 'true' }}
shell: bash
env:
CELL: ${{ inputs.cell }}
REPO: ${{ inputs.repo }}
DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -o errexit -o pipefail -o nounset
target=$(jq -r '.target' <<< "$CELL")
build_tag=$(jq -r '.build_tag' <<< "$CELL")
docker pull -q "${REPO}@${DIGEST}"
docker tag "${REPO}@${DIGEST}" "${REPO}:${build_tag}"
make test-image IMAGE="${REPO}:${build_tag}" FLAVOR="${target}"

- name: Upload the digest for the merge job
if: ${{ inputs.dry_run != 'true' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: digest-${{ fromJSON(inputs.cell).target }}-${{ fromJSON(inputs.cell).build_tag }}-${{ fromJSON(inputs.cell).arch }}
path: digests/*
retention-days: 1
if-no-files-found: error
115 changes: 115 additions & 0 deletions .github/actions/merge-flavor/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Merge one image flavor
description: >
Assembles one flavor's per-architecture digests into a manifest list, signs the
index, and only then applies the mutable tags. Ordering matters: the per-arch
manifests pushed by the build jobs are untagged, and the immutable epoch tag is
created here from an index that is signed before any other tag points at it, so
no consumer-facing tag is ever live without a signature.

inputs:
cell:
description: One merge entry emitted by scripts/build_pgedge_images.py (PGEDGE_EMIT_MATRIX=1)
required: true
repo:
description: Image repository to publish to
required: true
registry_token:
description: >
Token for the container registry. A composite action has no secrets
context, so this has to be passed in by the calling workflow.
required: true
dry_run:
description: When true, print what would be published and exit
required: false
default: "false"

runs:
using: composite
steps:
- name: Install cosign
if: ${{ inputs.dry_run != 'true' }}
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1

# buildx-init only registers the builder; buildx selects it via the
# BUILDX_BUILDER environment variable. Without this the build lands on the
# default docker driver, which rejects the bake file's attestations.
- name: Setup Docker Buildx
shell: bash
run: |
set -o errexit -o pipefail
make buildx-init
echo "BUILDX_BUILDER=$(make -s print-buildx-builder)" >> "$GITHUB_ENV"

- name: Login to the container registry
if: ${{ inputs.dry_run != 'true' }}
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.registry_token }}

- name: Collect the per-architecture digests
if: ${{ inputs.dry_run != 'true' && fromJSON(inputs.cell).needs_build }}
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: digest-*-${{ fromJSON(inputs.cell).build_tag }}-*
path: digests
merge-multiple: true

- name: Create, sign, then tag
if: ${{ inputs.dry_run != 'true' }}
shell: bash
env:
CELL: ${{ inputs.cell }}
REPO: ${{ inputs.repo }}
run: |
set -o errexit -o pipefail -o nounset

build_tag=$(jq -r '.build_tag' <<< "$CELL")

# needs_build is false when the immutable tag was already published and
# republish was not requested. The index is then left exactly as it is --
# not reassembled, not re-signed -- and only the mutable tags are
# refreshed, so a re-dispatch repairs tag drift without rebuilding.
if [[ "$(jq -r '.needs_build' <<< "$CELL")" == "true" ]]; then
# One source per architecture, addressed by digest.
sources=()
while read -r arch; do
digest=$(cat "digests/${arch}")
test -n "$digest"
sources+=("${REPO}@${digest}")
done < <(jq -r '.arches[]' <<< "$CELL")
test "${#sources[@]}" -gt 0

echo "Assembling ${REPO}:${build_tag} from ${#sources[@]} platform manifest(s)"
docker buildx imagetools create --tag "${REPO}:${build_tag}" "${sources[@]}"

# cosign signs the index. Its children are content-addressed by the
# index, so verifying a tag transitively covers every platform.
index_digest=$(docker buildx imagetools inspect "${REPO}:${build_tag}" \
--format '{{ printf "%s" .Manifest.Digest }}')
cosign sign --yes "${REPO}@${index_digest}"
else
echo "${REPO}:${build_tag} is already published; refreshing tags only"
fi

# Mutable tags last, so each points at an already-signed index.
while read -r tag; do
echo "Tagging ${REPO}:${tag}"
docker buildx imagetools create --tag "${REPO}:${tag}" "${REPO}:${build_tag}"
done < <(jq -r '.extra_tags[]' <<< "$CELL")

- name: Dry run summary
if: ${{ inputs.dry_run == 'true' }}
shell: bash
env:
CELL: ${{ inputs.cell }}
REPO: ${{ inputs.repo }}
run: |
if [[ "$(jq -r '.needs_build' <<< "$CELL")" == "true" ]]; then
echo "would assemble and sign ${REPO}:$(jq -r '.build_tag' <<< "$CELL")"
echo " from arches : $(jq -r '.arches | join(", ")' <<< "$CELL")"
else
echo "${REPO}:$(jq -r '.build_tag' <<< "$CELL") already published; tags only"
fi
echo " mutable tags: $(jq -r '.extra_tags | join(", ")' <<< "$CELL")"
Loading
Loading