Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
102036a
feat(containers): fork-safe automatic image build/push and registry-a…
rfay Aug 14, 2026
602e171
docs(containers): note follow-up to automate the full db variant matr…
rfay Aug 14, 2026
a7f72aa
fix(ci): bump actions/download-artifact to v8 to drop the Node 20 dep…
rfay Aug 14, 2026
403d13e
fix(ci): delete intermediary per-arch tags after creating the manifes…
rfay Aug 14, 2026
6294cf9
don't run workflow [skip ci]
rfay Aug 14, 2026
bd27ad8
fix(ci): guard GitHub-hosted test workflows against the same image-pu…
rfay Aug 15, 2026
03df3a4
fix(ci): finish wiring the wait-for-images guard into GitHub-hosted t…
rfay Aug 15, 2026
9075d21
feat(ci): skip the approval gate entirely for non-fork image changes,…
rfay Aug 15, 2026
a7ad9bb
fix(ci): use separate GitHub Environments for the build and push appr…
rfay Aug 15, 2026
90ed035
feat(ci): drop the pre-build approval gate, keep only the push-side a…
rfay Aug 15, 2026
ccb5e26
fix(ci): skip the image-push approval gate entirely for non-fork comp…
rfay Aug 15, 2026
bbc5ea6
fix(ci): pass DOCKER_ORG into wait-for-images.sh's GitHub-hosted call…
rfay Aug 15, 2026
3ec7bfe
fix(ci): widen wait-for-images.sh's default retry budget, for #8609 […
rfay Aug 15, 2026
d8d3903
fix(ci): recompute wait-for-images.sh's tags instead of trusting vers…
rfay Aug 15, 2026
9a6f5b0
feat(ci): print a confirmation line when wait-for-images.sh finds eac…
rfay Aug 15, 2026
e91c18d
don't run workflow [skip ci]
rfay Aug 14, 2026
4a72dfe
Minor comment changes [skip ci]
rfay Aug 16, 2026
a19620b
fix(ci): resolve image tags the way autotag.sh does, for #8609
rfay Aug 16, 2026
1436cab
temp add HANDOFF.md [skip ci]
rfay Aug 16, 2026
f2c9ee7
fix(ci): build every db variant when ddev-dbserver changes, for #8609
rfay Aug 16, 2026
f621ccb
docs: record the db variant matrix as a must-test item in HANDOFF.md …
rfay Aug 16, 2026
5363cbb
fix(ci): derive the manual push workflows' tag instead of asking for …
rfay Aug 16, 2026
9d74157
feat(images)!: make image tags bare content hashes, for #8609
rfay Aug 16, 2026
be860a5
docs: record the hash-only tag migration in HANDOFF.md [skip ci]
rfay Aug 16, 2026
7adf418
fix(ci): publish the branch alias from the manual push workflows too,…
rfay Aug 16, 2026
07e00e9
feat(images): add standard OCI metadata as labels and index annotatio…
rfay Aug 16, 2026
7fdc357
build(images): retag after the OCI metadata change, for #8609
rfay Aug 16, 2026
0e6fdcc
feat(build): fail staticrequired when versionconstants.go is stale, f…
rfay Aug 16, 2026
841c18c
fix(ci): resolve fork PRs by querying the fork's own repo for the com…
rfay Aug 16, 2026
c7866e5
fix(ci): read DOCKER_ORG from the public-variables branch for fork PR…
rfay Aug 16, 2026
0625d2b
fix(ci): tolerate a missing DOCKER_ORG file on the public-variables b…
rfay Aug 16, 2026
b0dc1a7
docs: remove HANDOFF.md now that its manual test plan is complete, fo…
rfay Aug 17, 2026
d4db3fa
fix(ci): stop the public-variables loader from clobbering a set DOCKE…
rfay Aug 17, 2026
a63c5d2
docs(ci): stop repeating the same comment rationale at multiple call …
rfay Aug 17, 2026
b0ff28d
fix(ci): name ddev-test's DOCKER_ORG inline instead of via public-var…
stasadev Aug 17, 2026
dcf4706
feat(build): add make release-prep and make retag-images, for #8609
stasadev Aug 17, 2026
004b564
feat(ci): publish vX.Y.Z and latest from the release pull request, fo…
stasadev Aug 17, 2026
d805a9d
perf(ci): push the fork-built images in parallel, for #8609
stasadev Aug 17, 2026
dad9750
test(ci): let release_prep_test.sh run on an already-stamped tree, fo…
stasadev Aug 17, 2026
e647b4f
fix(ci): find the downloaded image whatever layout it arrives in, for…
stasadev Aug 17, 2026
11df213
fix(ci): fail a fork pull request that carries a release marker, for …
stasadev Aug 17, 2026
58064e7
Minor comment reduction and docs fix [skip ci]
rfay Aug 18, 2026
ec1c2db
fix(ci): source manifest aliases from an already-published tag, for #…
rfay Aug 18, 2026
7ae96a5
test(images): try out latest image builder
rfay Aug 17, 2026
bce0bd7
fiddle with ddev-dbserver again
rfay Aug 18, 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
Empty file.
1 change: 0 additions & 1 deletion .github/public-variables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ Current variables:
- **Bats tests:** each pattern is matched as a case-sensitive substring against the bats filename (without `.bats`) or the `@test` description. E.g. `sveltekit` skips all tests in `sveltekit.bats`; `Symfony Composer` skips only the Composer-flavored test in `symfony.bats`. Go and bats patterns can be combined: `TestLagoonPull|sveltekit`.
- `workflow_dispatch` runs skip loading the `public-variables` branch entirely, so maintainers can verify fixes without removing them from the embargo list first.
- `DDEV_EMBARGO_PHP_VERSIONS` - comma-separated PHP versions to skip in `TestPHPConfig`, e.g. `7.0,7.1`
- `DOCKER_ORG` - Default `hub.docker.com` organization to use, nearly always `ddev`.

## Adding a new variable

Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/container-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,26 @@ permissions:

jobs:
autotag-unit-tests:
name: Unit tests for containers/autotag.sh and hash-paths.sh
name: Unit tests for the containers/ tag-resolution scripts
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Run containers/autotag_test.sh
run: containers/autotag_test.sh
- name: Run containers/release_prep_test.sh
run: containers/release_prep_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
364 changes: 353 additions & 11 deletions .github/workflows/image-build-push.yml

Large diffs are not rendered by default.

278 changes: 266 additions & 12 deletions .github/workflows/image-push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,281 @@ defaults:
run:
shell: bash

# Placeholder for #8609 phase 2 (fork-safe automatic image build/push). This
# do-nothing stub exists only so `workflow_run` below has a registered
# listener on the default branch before the real load/validate/push logic
# lands - see the phase 2 PR, which will replace this file's contents via
# rebase. `workflow_run` only fires for a listener that already exists on the
# default branch, so this stub unblocks testing that dependency ahead of the
# full PR.
# Trusted side of the fork-safe build/push split for #8609 phase 2.
# Triggered by completion of the "Image build" workflow, using the
# image-push.yml that lives on the default branch - never the fork's copy,
# and this workflow never checks out or executes the triggering PR's code.
# It only loads the artifact "Image build" produced (an inert tarball plus
# metadata) and pushes it, after re-validating the tag.
on:
workflow_run:
workflows: ["Image build"]
types: [completed]

env:
DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}"

permissions:
contents: read

jobs:
placeholder:
name: "Placeholder (see #8609)"
if: github.event.workflow_run.conclusion == 'success'
# Ungated on purpose: asking a maintainer to approve a push only to discover
# the run built nothing trains people to click Approve without looking.
check-artifacts:
name: Check for built images
# Only fork completions of "Image build" ever produce artifacts here -
# the non-fork path pushes directly in that workflow's build-and-push job.
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name
runs-on: ubuntu-24.04
permissions:
actions: read
outputs:
has_artifacts: ${{ steps.check.outputs.has_artifacts }}
images: ${{ steps.check.outputs.images }}
steps:
- name: List artifacts on the triggering run
id: check
uses: actions/github-script@v9
with:
script: |
const artifacts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner: context.repo.owner, repo: context.repo.repo,
run_id: context.payload.workflow_run.id });
const images = artifacts.filter((a) => a.name.startsWith("image-"));
// "Image build" names them image-<repo_suffix>-<arch>; one push job
// per image, since a manifest needs both arches already pushed.
const keys = [...new Set(images.map(
(a) => a.name.replace(/^image-/, "").replace(/-[^-]+$/, "")))].sort();
console.log(`Found ${images.length} image-* artifact(s) for ${keys.length} image(s): ${keys.join(", ")}`);
core.setOutput("has_artifacts", keys.length > 0 ? "true" : "false");
core.setOutput("images", JSON.stringify(keys));

# One gate for the whole run rather than `environment:` on the fan-out below,
# where every image would be a pending deployment of its own to approve.
approve:
name: "Approve: push the built image(s) to DockerHub"
needs: check-artifacts
if: needs.check-artifacts.outputs.has_artifacts == 'true'
runs-on: ubuntu-24.04
environment: image-push
steps:
- name: Do nothing
- name: Record what was approved
env:
IMAGES: ${{ needs.check-artifacts.outputs.images }}
run: echo "Approved; pushing $(jq -r 'join(", ")' <<< "$IMAGES")"

push:
name: Push ${{ matrix.image }}
needs: [check-artifacts, approve]
strategy:
fail-fast: false
matrix:
image: ${{ fromJson(needs.check-artifacts.outputs.images) }}
runs-on: ubuntu-24.04
permissions:
# download-artifact needs actions:read to reach another run's artifacts.
actions: read
contents: read
steps:
- uses: actions/checkout@v7

# No continue-on-error: check-artifacts already established that these
# exist, so a failure here is a real one (an expired artifact, say) and
# must not be reported as "nothing needed pushing".
- name: Download build artifacts
uses: actions/download-artifact@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: image-${{ matrix.image }}-*
path: artifacts

- 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: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

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

- name: Validate, load, and push the built image
env:
# From the workflow_run payload, not the artifact - see the same
# variable's comment in image-build-push.yml's create-manifests job.
ALIAS_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
echo "Placeholder for #8609 phase 2 - no-op until the real workflow lands."
set -eu -o pipefail
SANITIZED_BRANCH="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')"
# The revision is this workflow's own checkout (the default branch),
# not the fork's, so record the commit the artifact was built from.
export DDEV_GIT_REVISION="${{ github.event.workflow_run.head_sha }}"
TAG=""
ARCHES=""
REPOS=""

# download-artifact nests output in a subdirectory only when more than
# one artifact matched the pattern; the amd64-only db variants land flat.

# Everything under artifacts/ was written by a job that may have run
# fork-authored content, so the tag and every repository name is
# validated before it reaches a `docker push`.
while IFS= read -r dir; do
arch="$(cat "$dir/arch.txt")"
tag="$(cat "$dir/tag.txt")"

if ! containers/validate-image-tag.sh "$tag"; then
echo "image-push: refusing to push - invalid tag '$tag' from $dir" >&2
exit 1
fi

while IFS= read -r repo; do
[ -z "$repo" ] && continue
if ! containers/validate-image-repo.sh "$repo"; then
echo "image-push: refusing to push - disallowed repository '$repo' from $dir" >&2
exit 1
fi
done < "$dir/repos.txt"

docker load -i "$dir/image.tar"

while IFS= read -r repo; do
[ -z "$repo" ] && continue
docker push "${repo}:${tag}-${arch}"
done < "$dir/repos.txt"

TAG="$tag"
ARCHES="${ARCHES} ${arch}"
[ -n "$REPOS" ] || REPOS="$(cat "$dir/repos.txt")"
done < <(find artifacts -name tag.txt -printf '%h\n' | sort)

if [ -z "$TAG" ]; then
echo "image-push: the matrix named an image with no artifact" >&2
exit 1
fi

# Docker Hub token for deleting the intermediary per-arch tags below,
# same cleanup push-tagged-image.yml/push-tagged-dbimage.yml already do.
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)"

PUSHED_SUMMARY=""
while IFS= read -r repo; do
[ -z "$repo" ] && continue
arch_tags=()
for arch in $ARCHES; do
arch_tags+=("${repo}:${TAG}-${arch}")
done
names=(-t "${repo}:${TAG}")
alias_tag="${SANITIZED_BRANCH}-${TAG}"
if containers/validate-image-tag.sh "$alias_tag"; then
names+=(-t "${repo}:${alias_tag}")
else
echo "Alias '${alias_tag}' rejected; publishing only ${TAG}" >&2
fi
read -r -a annotations <<< "$(containers/image-metadata.sh annotations "$TAG")"
docker buildx imagetools create "${annotations[@]}" "${names[@]}" "${arch_tags[@]}"
# See the same note in image-build-push.yml: a Docker manifest
# list has nowhere to store annotations.
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 <<< "$REPOS"

printf '%s' "$PUSHED_SUMMARY" > summary.md

# Collected by the comment job below: a matrix job can't hand its own
# output to a later job, since all of them write the same output name.
- uses: actions/upload-artifact@v7
with:
name: push-summary-${{ matrix.image }}
path: summary.md
retention-days: 1

comment:
name: Comment on the pull request
needs: push
runs-on: ubuntu-24.04
permissions:
actions: read
contents: read
pull-requests: write
steps:
- name: Download the per-image summaries
uses: actions/download-artifact@v8
with:
pattern: push-summary-*
path: summaries

- name: Collect the summaries
id: summary
run: |
set -eu -o pipefail
{
echo "summary<<IMAGE_PUSH_EOF"
find summaries -name summary.md -exec cat {} + | sort
echo "IMAGE_PUSH_EOF"
} >> "$GITHUB_OUTPUT"

- name: Comment on the pull request
uses: actions/github-script@v9
env:
IMAGE_PUSH_SUMMARY: ${{ steps.summary.outputs.summary }}
with:
script: |
const headSha = context.payload.workflow_run.head_sha;
const headRepo = context.payload.workflow_run.head_repository;
if (!headRepo) {
console.log(`No head repository on the workflow_run event for ${headSha}, skipping comment.`);
return;
}
// Query the commit's own repository (the fork, for a fork PR) -
// listPullRequestsAssociatedWithCommit only finds a commit within
// the repo it's queried against, and a fork-authored commit never
// lives in the base repo's own history.
const { data: candidatePrs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner: headRepo.owner.login,
repo: headRepo.name,
commit_sha: headSha,
});
const prs = candidatePrs.filter((pr) =>
pr.base.repo.owner.login === context.repo.owner && pr.base.repo.name === context.repo.repo);
if (prs.length === 0) {
console.log(`No pull request into ${context.repo.owner}/${context.repo.repo} associated with ${headSha} (queried ${headRepo.full_name}), skipping comment.`);
return;
}
// Read from env, not spliced into the script source, since the
// summary contains backticks that would break a template literal.
const summary = (process.env.IMAGE_PUSH_SUMMARY || "").trim();
if (!summary) {
core.setFailed("Push step produced no summary - nothing was pushed.");
return;
}
const body = `Pushed updated container image(s) for this PR:\n\n${summary}`;
for (const pr of prs) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
}
Loading
Loading