Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
0ba12a6
feat(containers): fork-safe automatic image build/push and registry-a…
rfay Aug 14, 2026
1ee8eea
docs(containers): note follow-up to automate the full db variant matr…
rfay Aug 14, 2026
7912163
fix(ci): bump actions/download-artifact to v8 to drop the Node 20 dep…
rfay Aug 14, 2026
f03becd
fix(ci): delete intermediary per-arch tags after creating the manifes…
rfay Aug 14, 2026
2d734fb
don't run workflow [skip ci]
rfay Aug 14, 2026
c2cc267
fix(ci): guard GitHub-hosted test workflows against the same image-pu…
rfay Aug 15, 2026
ec700f4
fix(ci): finish wiring the wait-for-images guard into GitHub-hosted t…
rfay Aug 15, 2026
2bdf394
feat(ci): skip the approval gate entirely for non-fork image changes,…
rfay Aug 15, 2026
57cf290
fix(ci): use separate GitHub Environments for the build and push appr…
rfay Aug 15, 2026
a7ec6b5
feat(ci): drop the pre-build approval gate, keep only the push-side a…
rfay Aug 15, 2026
2bdced7
fix(ci): skip the image-push approval gate entirely for non-fork comp…
rfay Aug 15, 2026
28ee766
fix(ci): pass DOCKER_ORG into wait-for-images.sh's GitHub-hosted call…
rfay Aug 15, 2026
32526f2
fix(ci): widen wait-for-images.sh's default retry budget, for #8609 […
rfay Aug 15, 2026
0066f66
fix(ci): recompute wait-for-images.sh's tags instead of trusting vers…
rfay Aug 15, 2026
33c06ba
feat(ci): print a confirmation line when wait-for-images.sh finds eac…
rfay Aug 15, 2026
77d90bd
don't run workflow [skip ci]
rfay Aug 14, 2026
ec98f6d
Minor comment changes [skip ci]
rfay Aug 16, 2026
f246ff4
fix(ci): resolve image tags the way autotag.sh does, for #8609
rfay Aug 16, 2026
c788123
temp add HANDOFF.md [skip ci]
rfay Aug 16, 2026
20be57b
fix(ci): build every db variant when ddev-dbserver changes, for #8609
rfay Aug 16, 2026
e4d707b
docs: record the db variant matrix as a must-test item in HANDOFF.md …
rfay Aug 16, 2026
ce502b6
fix(ci): derive the manual push workflows' tag instead of asking for …
rfay Aug 16, 2026
aa3de1f
feat(images)!: make image tags bare content hashes, for #8609
rfay Aug 16, 2026
bfcbe38
docs: record the hash-only tag migration in HANDOFF.md [skip ci]
rfay Aug 16, 2026
8a91a75
fix(ci): publish the branch alias from the manual push workflows too,…
rfay Aug 16, 2026
2be6c3d
feat(images): add standard OCI metadata as labels and index annotatio…
rfay Aug 16, 2026
ae36357
build(images): retag after the OCI metadata change, for #8609
rfay Aug 16, 2026
df4e002
feat(build): fail staticrequired when versionconstants.go is stale, f…
rfay Aug 16, 2026
a48686a
test: trivial fork containers/ change outside hash paths for #8609 ph…
rfay Aug 16, 2026
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
3 changes: 3 additions & 0 deletions .buildkite/perf.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ if [[ ${BUILDKITE_MESSAGE:-} == *"[skip buildkite]"* ]] || [[ ${BUILDKITE_MESSAG
exit 0
fi

# A changed image may still be waiting on image-push.yml's approval
"$(dirname "$0")/../containers/wait-for-images.sh"

os=$(go env GOOS)

# shellcheck source=lib-provider.sh
Expand Down
3 changes: 3 additions & 0 deletions .buildkite/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ while IFS= read -r varname; do
done < <(MSYS_NO_PATHCONV=1 git ls-tree --name-only refs/public-variables-tmp:.github/public-variables/)
git update-ref -d refs/public-variables-tmp

# A changed image may still be waiting on image-push.yml's approval
"$(dirname "$0")/../containers/wait-for-images.sh"

export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin
os=$(go env GOOS)

Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/container-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ jobs:
- uses: actions/checkout@v7
- name: Run containers/autotag_test.sh
run: containers/autotag_test.sh
- name: Run containers/db_variants_test.sh
run: containers/db_variants_test.sh
- name: Run containers/required_image_tag_test.sh
run: containers/required_image_tag_test.sh
- name: Run containers/registry_tag_exists_test.sh
run: containers/registry_tag_exists_test.sh
- name: Run containers/validate_image_tag_test.sh
run: containers/validate_image_tag_test.sh
- name: Run containers/validate_image_repo_test.sh
run: containers/validate_image_repo_test.sh
- name: Run containers/wait_for_images_test.sh
run: containers/wait_for_images_test.sh

container-build-and-test:
name: ${{ matrix.os }} - Test container ${{ matrix.containers }}
Expand Down
318 changes: 307 additions & 11 deletions .github/workflows/image-build-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ defaults:
run:
shell: bash

# Placeholder for #8609 phase 2 (fork-safe automatic image build/push). This
# do-nothing stub exists only so the workflow name/triggers are registered on
# the default branch before the real detect/approval/build logic lands - see
# the phase 2 PR, which will replace this file's contents via rebase.
# image-push.yml's `workflow_run` trigger only fires for a listener that
# exists on the default branch, so this stub unblocks testing that dependency
# ahead of the full PR.
# In a forked PR, this workflow may run a fork's own Dockerfile/build scripts,
# so the `build` job never references a secret - the trusted side (loading
# the artifact `build` produces and actually pushing it) lives in
# image-push.yml, triggered via workflow_run once this workflow completes,
# gated behind its own approval.
#
# For anything else (a push, or a same-repo PR - `detect`'s `is_fork` output,
# using the same fork-check idiom as push-tagged-image.yml), there's nothing
# a maintainer's own branch could smuggle into a job that every other
# secret-using workflow here doesn't already run unguarded, so
# `build-and-push` builds and pushes directly, with no environment/approval
# gate at all - same trust level as `main-build.yml`.
on:
pull_request:
branches: [main]
Expand All @@ -22,14 +27,305 @@ on:
- "containers/**"
- ".github/workflows/image-build-push.yml"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

env:
DOCKER_ORG: "${{ vars.DOCKER_ORG || 'ddev' }}"

permissions:
contents: read

jobs:
placeholder:
name: "Placeholder (see #8609)"
detect:
name: Detect changed images
runs-on: ubuntu-24.04
outputs:
build_matrix: ${{ steps.detect.outputs.build_matrix }}
manifest_matrix: ${{ steps.detect.outputs.manifest_matrix }}
needs_build: ${{ steps.detect.outputs.needs_build }}
is_fork: ${{ steps.fork.outputs.is_fork }}
steps:
- uses: actions/checkout@v7
- name: Compute per-image build status
id: detect
# github.head_ref is attacker-controlled (a fork may name its branch
# anything git accepts, quotes and backticks included), so it reaches
# the script as data rather than as spliced-in source.
env:
REQUIRED_IMAGE_TAG_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -eu -o pipefail
source containers/image-configs.sh

# build_matrix is one entry per (image, arch) rather than a cross
# product, because the older db variants are amd64-only.
BUILD_JSON="[]"
MANIFEST_JSON="[]"
for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do
IFS='|' read -r repo_suffix tag_var hash_paths make_dir make_target arch_suffixed arches _ extra_repo_suffixes <<< "$entry"
# shellcheck disable=SC2086 # hash_paths is a space-separated path list
read -r state tag <<< "$(containers/required-image-tag.sh "$tag_var" $hash_paths)"
repo="${DOCKER_ORG}/${repo_suffix}"
if containers/registry-tag-exists.sh "$repo" "$tag"; then
echo "detect: ${repo}:${tag} already exists (${state}), nothing to build"
continue
fi
echo "detect: ${repo}:${tag} needs building (${state}) for arches: ${arches}"
for arch in $arches; do
BUILD_JSON="$(echo "$BUILD_JSON" | jq -c \
--arg repo_suffix "$repo_suffix" \
--arg repo "$repo" \
--arg tag "$tag" \
--arg arch "$arch" \
--arg make_dir "$make_dir" \
--arg make_target "$make_target" \
--arg arch_suffixed "$arch_suffixed" \
--arg extra_repo_suffixes "$extra_repo_suffixes" \
'. + [{"repo_suffix": $repo_suffix, "repo": $repo, "tag": $tag, "arch": $arch, "make_dir": $make_dir, "make_target": $make_target, "arch_suffixed": $arch_suffixed, "extra_repo_suffixes": $extra_repo_suffixes}]')"
done
MANIFEST_JSON="$(echo "$MANIFEST_JSON" | jq -c \
--arg repo "$repo" \
--arg tag "$tag" \
--arg arches "$arches" \
--arg extra_repo_suffixes "$extra_repo_suffixes" \
'. + [{"repo": $repo, "tag": $tag, "arches": $arches, "extra_repo_suffixes": $extra_repo_suffixes}]')"
done

echo "build_matrix=${BUILD_JSON}" >> "$GITHUB_OUTPUT"
echo "manifest_matrix=${MANIFEST_JSON}" >> "$GITHUB_OUTPUT"
echo "detect: $(echo "$MANIFEST_JSON" | jq 'length') image(s) to build, $(echo "$BUILD_JSON" | jq 'length') build job(s)"
if [ "$(echo "$BUILD_JSON" | jq 'length')" -gt 0 ]; then
echo "needs_build=true" >> "$GITHUB_OUTPUT"
else
echo "needs_build=false" >> "$GITHUB_OUTPUT"
fi

# Separate from the step above so nothing the per-image loop does can
# reach the output that decides whether the push secret gets loaded.
- name: Determine whether this is a fork
id: fork
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
BASE_OWNER: ${{ github.repository_owner }}
run: |
set -eu -o pipefail
if [ "$EVENT_NAME" = "pull_request" ] && [ "$HEAD_OWNER" != "$BASE_OWNER" ]; then
echo "is_fork=true" >> "$GITHUB_OUTPUT"
else
echo "is_fork=false" >> "$GITHUB_OUTPUT"
fi

# --- Fork PRs: build with no secrets (this job never has registry
# credentials, so there's nothing to gain by gating it - see #8609
# discussion), then hand off to image-push.yml for the trusted,
# approval-gated push. ---

build:
name: Build ${{ matrix.build.repo }} (${{ matrix.build.arch }})
needs: detect
if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true'
strategy:
fail-fast: false
matrix:
build: ${{ fromJson(needs.detect.outputs.build_matrix) }}
runs-on: ${{ matrix.build.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Build ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }}
run: |
set -eu -o pipefail
VERSION="${{ matrix.build.tag }}-${{ matrix.build.arch }}"
MAKE_TARGET="${{ matrix.build.make_target }}"
if [ "${{ matrix.build.arch_suffixed }}" = "true" ]; then
MAKE_TARGET="${MAKE_TARGET}_${{ matrix.build.arch }}"
fi
# DDEV_IMAGE_TAG is the tag people actually pull, recorded in the
# com.ddev.image-tag label; VERSION is the throwaway per-arch tag.
make -C "containers/${{ matrix.build.make_dir }}" "$MAKE_TARGET" \
VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.build.tag }}"

REPOS="${{ matrix.build.repo }}"
for suffix in ${{ matrix.build.extra_repo_suffixes }}; do
REPOS="${REPOS} ${DOCKER_ORG}/${suffix}"
done

REFS=()
: > repos.txt
for repo in $REPOS; do
REFS+=("${repo}:${VERSION}")
echo "$repo" >> repos.txt
done
docker save "${REFS[@]}" -o image.tar
echo -n "${{ matrix.build.tag }}" > tag.txt
echo -n "${{ matrix.build.arch }}" > arch.txt
- uses: actions/upload-artifact@v7
with:
# repo_suffix, not make_dir: all 20 db variants share one make_dir.
name: image-${{ matrix.build.repo_suffix }}-${{ matrix.build.arch }}
path: |
image.tar
repos.txt
tag.txt
arch.txt
# image-push.yml can't download an expired artifact, and the approval
# it waits on is a human one that may not come the same day.
retention-days: 7

# --- Pushes and same-repo PRs: no fork content ever runs here, so build
# and push directly with no environment/approval gate - same trust level
# as main-build.yml, which already uses this same secret unguarded. ---

build-and-push:
name: Build and push ${{ matrix.build.repo }} (${{ matrix.build.arch }})
needs: detect
if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false'
strategy:
fail-fast: false
matrix:
build: ${{ fromJson(needs.detect.outputs.build_matrix) }}
runs-on: ${{ matrix.build.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
steps:
- uses: actions/checkout@v7

- name: Load 1password secret(s)
uses: 1password/load-secrets-action@v4
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: "${{ secrets.PUSH_SERVICE_ACCOUNT_TOKEN }}"
DOCKERHUB_TOKEN: "op://push-secrets/DOCKERHUB_TOKEN/credential"

- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}

- name: Build and push ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }}
run: |
set -eu -o pipefail
VERSION="${{ matrix.build.tag }}-${{ matrix.build.arch }}"
MAKE_TARGET="${{ matrix.build.make_target }}"
if [ "${{ matrix.build.arch_suffixed }}" = "true" ]; then
MAKE_TARGET="${MAKE_TARGET}_${{ matrix.build.arch }}"
fi
# See the DDEV_IMAGE_TAG note in the fork-side `build` job above.
make -C "containers/${{ matrix.build.make_dir }}" "$MAKE_TARGET" \
VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.build.tag }}"

REPOS="${{ matrix.build.repo }}"
for suffix in ${{ matrix.build.extra_repo_suffixes }}; do
REPOS="${REPOS} ${DOCKER_ORG}/${suffix}"
done
for repo in $REPOS; do
docker push "${repo}:${VERSION}"
done

create-manifests:
name: Create manifest for ${{ matrix.manifest.repo }}
needs: [detect, build-and-push]
if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false'
strategy:
fail-fast: false
matrix:
manifest: ${{ fromJson(needs.detect.outputs.manifest_matrix) }}
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- name: Do nothing
- uses: actions/checkout@v7

- name: Load 1password secret(s)
uses: 1password/load-secrets-action@v4
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: "${{ secrets.PUSH_SERVICE_ACCOUNT_TOKEN }}"
DOCKERHUB_TOKEN: "op://push-secrets/DOCKERHUB_TOKEN/credential"

- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}

- name: Create manifest and clean up per-arch tags
id: manifest
env:
# Trusted context, not the matrix: this is the readable alias
# published next to the hash tag, so it must not be fork-controlled
# beyond the branch name itself, which is sanitized below.
ALIAS_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
echo "Placeholder for #8609 phase 2 - no-op until the real workflow lands."
set -eu -o pipefail
TAG="${{ matrix.manifest.tag }}"
# The oldest db variants are amd64-only, so the arch list comes from
# detect rather than being assumed to be both.
ARCHES="${{ matrix.manifest.arches }}"
ALIAS="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')-${TAG}"
if ! containers/validate-image-tag.sh "$ALIAS"; then
echo "Alias '${ALIAS}' rejected; publishing only ${TAG}" >&2
ALIAS=""
fi
# Descriptive metadata on the index the tag points at - the standard
# equivalent of a comment on a tag. See containers/image-metadata.sh.
read -r -a ANNOTATIONS <<< "$(containers/image-metadata.sh annotations "$TAG")"

DOCKERHUB_JWT="$(curl -s -H "Content-Type: application/json" -X POST \
-d '{"username":"'"${{ vars.DOCKERHUB_USERNAME }}"'","password":"'"${DOCKERHUB_TOKEN}"'"}' \
https://hub.docker.com/v2/users/login/ | jq -r .token)"

REPOS="${{ matrix.manifest.repo }}"
for suffix in ${{ matrix.manifest.extra_repo_suffixes }}; do
REPOS="${REPOS} ${DOCKER_ORG}/${suffix}"
done

PUSHED_SUMMARY=""
for repo in $REPOS; do
ARCH_TAGS=()
for arch in $ARCHES; do
ARCH_TAGS+=("${repo}:${TAG}-${arch}")
done
NAMES=(-t "${repo}:${TAG}")
[ -n "$ALIAS" ] && NAMES+=(-t "${repo}:${ALIAS}")
docker buildx imagetools create "${ANNOTATIONS[@]}" "${NAMES[@]}" "${ARCH_TAGS[@]}"
# imagetools inherits the source media type and a Docker manifest
# list has nowhere to put annotations, so say when they were
# dropped rather than leaving it to be discovered later.
if ! docker buildx imagetools inspect --raw "${repo}:${TAG}" | jq -e '.annotations' >/dev/null 2>&1; then
echo "Note: ${repo}:${TAG} is not an OCI index, so index annotations were not stored." >&2
fi
PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${TAG}\`"$'\n'
for arch in $ARCHES; do
echo "Removing intermediary tag ${repo}:${TAG}-${arch}"
curl -s -X DELETE -H "Authorization: JWT ${DOCKERHUB_JWT}" \
"https://hub.docker.com/v2/repositories/${repo}/tags/${TAG}-${arch}/" >/dev/null || true
done
done

{
echo "summary<<IMAGE_PUSH_EOF"
echo "$PUSHED_SUMMARY"
echo "IMAGE_PUSH_EOF"
} >> "$GITHUB_OUTPUT"

- name: Comment on the pull request
if: github.event_name == 'pull_request'
uses: actions/github-script@v9
env:
IMAGE_PUSH_SUMMARY: ${{ steps.manifest.outputs.summary }}
with:
script: |
const summary = (process.env.IMAGE_PUSH_SUMMARY || "").trim();
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Pushed updated container image(s) for this PR:\n\n${summary}`,
});
Loading