From bfad0a25ad9b0208825496545b7cb0a718421bb7 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 19:42:25 +0000 Subject: [PATCH 01/20] feat(containers): fork-safe automatic image build/push and registry-aware CI/Buildkite, for #8609 Phase 2 of #8609: CI now detects a changed container image, waits for a maintainer's approval, and builds/pushes it automatically for both maintainer and fork PRs, without ever exposing the DockerHub push token to a job that runs fork-supplied build content. Buildkite no longer races the push. - Phase 2 of #8609 Phase 1 (#8612) gave every image a content-addressed tag and made `make` auto-build changed images locally. The CI/registry side was untouched: a maintainer still had to notice a container changed and manually run `push-tagged-image.yml`/`push-tagged-dbimage.yml`, and fork contributors couldn't get an image change pushed without a maintainer doing it by hand. Two new workflows split build from push so a push secret never shares a job with untrusted (fork) build content: - `image-build-push.yml` (untrusted side, no secrets anywhere): a `detect` job recomputes each image's real content hash and checks the registry directly (never trusting the tag string committed in `versionconstants.go`, so a fork can't forge it); an `approval` job gates on the new `image-push` GitHub Environment before any expensive/untrusted build work runs; a `build` job builds per-arch using the same local-build Makefile targets Phase 1's `autotag-images` already uses, then uploads a `docker save` tarball plus tag metadata as an artifact. - `image-push.yml` (trusted side): triggered by `workflow_run` once the build workflow completes, so it always runs the default-branch version of itself and never checks out or executes the PR's code. It downloads the artifact, re-validates the tag against a strict format + reserved-tag blocklist (`containers/validate-image-tag.sh`), loads and pushes each per-arch image, assembles the multi-arch manifest, and comments on the PR. New scripts: `containers/registry-tag-exists.sh` (registry existence check, the same `docker buildx imagetools inspect` idiom already used in the post-push wait-loop) and `containers/validate-image-tag.sh`, both with bash test harnesses matching `containers/autotag_test.sh`'s style. Buildkite gets a `.buildkite/wait-for-images.sh` self-guarding step (wired into `test.sh`/`perf.sh`) that polls the registry for the tags a checkout actually needs before pulling anything, instead of racing the push - implementing the "lighter" option from the issue's open Buildkite decision. The existing `push-tagged-image.yml`/`push-tagged-dbimage.yml` are untouched and remain for manual re-pushes and the 18 `ddev-dbserver` variants Phase 1 doesn't auto-build. Requires one-time setup on the test repo (see `release-management.md`'s new "One-time setup" section): create a GitHub Environment named `image-push` with required reviewers, and add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret scoped to that environment. 1. Run the new unit tests directly: `containers/registry_tag_exists_test.sh` and `containers/validate_image_tag_test.sh` (no Docker daemon or network needed). 2. On a test repo (`image-push.yml` must be on its default branch - `workflow_run` triggers only fire for the default-branch copy of the listening workflow), push a trivial change to `containers/ddev-xhgui/` on a branch and open a PR. Confirm: `detect` flags it, `approval` blocks `build` until a reviewer approves, `build` produces artifacts with no secrets referenced in that job, `image-push` fires on completion (its own approval), loads and pushes the image, and comments on the PR. 3. Negative test: hand-edit `versionconstants.go` on that branch to a bogus `latest-`-shaped tag and confirm `detect` ignores it (recomputes the real hash from content) and that `containers/validate-image-tag.sh` rejects a manufactured `latest`/`vX.Y.Z` tag directly. Two new bash test harnesses (`containers/registry_tag_exists_test.sh`, `containers/validate_image_tag_test.sh`) stub `docker`/use pure string checks, no daemon or network required, wired into `container-tests.yml`'s existing unit-test job alongside `autotag_test.sh`. No Go code changed. No behavior change for anyone not touching `containers/`. Requires one-time manual GitHub Environment setup (documented) before the automatic push path is live on a given repo; until then `detect`/`approval`/`build` still run harmlessly (approval job would just wait indefinitely with no reviewers configured). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .buildkite/perf.sh | 5 + .buildkite/test.sh | 5 + .buildkite/wait-for-images.sh | 66 ++++++++ .github/workflows/container-tests.yml | 4 + .github/workflows/image-build-push.yml | 132 ++++++++++++++-- .github/workflows/image-push.yml | 144 ++++++++++++++++-- containers/registry-tag-exists.sh | 19 +++ containers/registry_tag_exists_test.sh | 87 +++++++++++ containers/validate-image-tag.sh | 49 ++++++ containers/validate_image_tag_test.sh | 60 ++++++++ .../developers/building-contributing.md | 16 +- docs/content/developers/release-management.md | 20 ++- 12 files changed, 582 insertions(+), 25 deletions(-) create mode 100755 .buildkite/wait-for-images.sh create mode 100755 containers/registry-tag-exists.sh create mode 100755 containers/registry_tag_exists_test.sh create mode 100755 containers/validate-image-tag.sh create mode 100755 containers/validate_image_tag_test.sh diff --git a/.buildkite/perf.sh b/.buildkite/perf.sh index b0c1da3b179..a1a0234d21b 100755 --- a/.buildkite/perf.sh +++ b/.buildkite/perf.sh @@ -19,6 +19,11 @@ if [[ ${BUILDKITE_MESSAGE:-} == *"[skip buildkite]"* ]] || [[ ${BUILDKITE_MESSAG exit 0 fi +# Buildkite holds no image-push credentials, so a changed container image +# might still be waiting on image-push.yml's maintainer approval when this +# run starts. Wait for the registry to catch up before pulling anything. +"$(dirname "$0")/wait-for-images.sh" + os=$(go env GOOS) # shellcheck source=lib-provider.sh diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 8e024776527..992b2c017aa 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -25,6 +25,11 @@ 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 +# Buildkite holds no image-push credentials, so a changed container image +# might still be waiting on image-push.yml's maintainer approval when this +# run starts. Wait for the registry to catch up before pulling anything. +"$(dirname "$0")/wait-for-images.sh" + export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin os=$(go env GOOS) diff --git a/.buildkite/wait-for-images.sh b/.buildkite/wait-for-images.sh new file mode 100755 index 00000000000..b66248f6a4e --- /dev/null +++ b/.buildkite/wait-for-images.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# wait-for-images.sh +# +# Buildkite holds no image-push credentials, so it must not race the +# image-push.yml GitHub Actions workflow: if this commit's containers/ +# changed, the image it needs might still be waiting on a maintainer's +# approval when this test run starts. Before running anything that pulls a +# DDEV image, poll the registry for the tags this checkout actually needs +# and wait for them to land. +# +# Fast path (the common case - nothing changed): one registry check per +# image, no wait. +# +# Env: +# WAIT_FOR_IMAGES_ATTEMPTS - poll attempts before giving up (default 20) +# WAIT_FOR_IMAGES_SLEEP - seconds between attempts (default 15) + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REGISTRY_TAG_EXISTS="$REPO_ROOT/containers/registry-tag-exists.sh" +VERSIONCONSTANTS_FILE="${VERSIONCONSTANTS_FILE:-$REPO_ROOT/pkg/versionconstants/versionconstants.go}" +DOCKER_ORG="${DOCKER_ORG:-ddev}" + +ATTEMPTS="${WAIT_FOR_IMAGES_ATTEMPTS:-20}" +SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-15}" + +tag_for() { + grep -E "^var $1 = " "$VERSIONCONSTANTS_FILE" | sed -E "s/^var $1 = \"([^\"]*)\".*/\\1/" +} + +# image-repo:tag-var-name pairs for the images Phase 1's autotag-images +# manages automatically. Keep in sync with Makefile's autotag-images target. +IMAGES=( + "${DOCKER_ORG}/ddev-webserver:WebTag" + "${DOCKER_ORG}/ddev-traefik-router:TraefikRouterTag" + "${DOCKER_ORG}/ddev-ssh-agent:SSHAuthTag" + "${DOCKER_ORG}/ddev-xhgui:XhguiTag" + "${DOCKER_ORG}/ddev-dbserver-mariadb-11.8:BaseDBTag" +) + +for entry in "${IMAGES[@]}"; do + image_repo="${entry%%:*}" + tag_var="${entry##*:}" + tag="$(tag_for "$tag_var")" + if [ -z "$tag" ]; then + echo "wait-for-images.sh: could not find 'var ${tag_var} = \"...\"' in $VERSIONCONSTANTS_FILE" >&2 + exit 1 + fi + + attempt=1 + while true; do + if "$REGISTRY_TAG_EXISTS" "$image_repo" "$tag"; then + break + fi + if [ "$attempt" -ge "$ATTEMPTS" ]; then + echo "wait-for-images.sh: gave up waiting for ${image_repo}:${tag} after ${ATTEMPTS} attempts." >&2 + echo "wait-for-images.sh: has the maintainer approved the image-push run for this PR yet?" >&2 + exit 1 + fi + echo "wait-for-images.sh: ${image_repo}:${tag} not yet available, waiting... (attempt ${attempt}/${ATTEMPTS})" + sleep "$SLEEP_SECONDS" + attempt=$((attempt + 1)) + done +done diff --git a/.github/workflows/container-tests.yml b/.github/workflows/container-tests.yml index 4b2cbe866a4..2eb77fb45d1 100644 --- a/.github/workflows/container-tests.yml +++ b/.github/workflows/container-tests.yml @@ -47,6 +47,10 @@ jobs: - uses: actions/checkout@v7 - name: Run containers/autotag_test.sh run: containers/autotag_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 container-build-and-test: name: ${{ matrix.os }} - Test container ${{ matrix.containers }} diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 2ddc72090ea..a0be8ab49cf 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -3,13 +3,11 @@ 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. +# Untrusted side of the fork-safe build/push split for #8609 phase 2. This +# workflow may run a fork's own Dockerfile/build scripts, so no job here +# ever references a secret. The trusted side (loading the artifact this +# workflow produces and actually pushing it) lives in image-push.yml, +# triggered via workflow_run once this workflow completes. on: pull_request: branches: [main] @@ -22,14 +20,126 @@ on: - "containers/**" - ".github/workflows/image-build-push.yml" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + permissions: contents: read jobs: - placeholder: - name: "Placeholder (see #8609)" + detect: + name: Detect changed images runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.detect.outputs.matrix }} + needs_build: ${{ steps.detect.outputs.needs_build }} steps: - - name: Do nothing + - uses: actions/checkout@v7 + - name: Compute per-image build status + id: detect run: | - echo "Placeholder for #8609 phase 2 - no-op until the real workflow lands." + set -eu -o pipefail + BRANCH="${{ github.head_ref || github.ref_name }}" + SANITIZED_BRANCH="$(echo "$BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" + + # repo_suffix|hash paths|make dir|make target|arch-suffixed target?|extra repo suffixes + # Keep in sync with Makefile's autotag-images target. + CONFIGS=( + 'ddev-webserver|containers/ddev-webserver containers/containers_shared.mk|ddev-webserver|images|false|ddev-webserver-prod' + 'ddev-traefik-router|containers/ddev-traefik-router containers/containers_shared.mk|ddev-traefik-router|container|false|' + 'ddev-ssh-agent|containers/ddev-ssh-agent containers/containers_shared.mk|ddev-ssh-agent|container|false|' + 'ddev-xhgui|containers/ddev-xhgui containers/containers_shared.mk|ddev-xhgui|container|false|' + 'ddev-dbserver-mariadb-11.8|containers/ddev-dbserver containers/get_arch.sh|ddev-dbserver|mariadb_11.8|true|' + ) + + MATRIX_JSON="[]" + for entry in "${CONFIGS[@]}"; do + IFS='|' read -r repo_suffix hash_paths make_dir make_target arch_suffixed extra_repo_suffixes <<< "$entry" + hash="$(containers/hash-paths.sh $hash_paths)" + tag="${SANITIZED_BRANCH}-${hash}" + repo="${DOCKER_ORG}/${repo_suffix}" + if containers/registry-tag-exists.sh "$repo" "$tag"; then + echo "detect: ${repo}:${tag} already exists, nothing to build" + continue + fi + echo "detect: ${repo}:${tag} needs building" + MATRIX_JSON="$(echo "$MATRIX_JSON" | jq -c \ + --arg repo "$repo" \ + --arg tag "$tag" \ + --arg make_dir "$make_dir" \ + --arg make_target "$make_target" \ + --arg arch_suffixed "$arch_suffixed" \ + --arg extra_repo_suffixes "$extra_repo_suffixes" \ + '. + [{"repo": $repo, "tag": $tag, "make_dir": $make_dir, "make_target": $make_target, "arch_suffixed": $arch_suffixed, "extra_repo_suffixes": $extra_repo_suffixes}]')" + done + + echo "matrix=${MATRIX_JSON}" >> "$GITHUB_OUTPUT" + if [ "$(echo "$MATRIX_JSON" | jq 'length')" -gt 0 ]; then + echo "needs_build=true" >> "$GITHUB_OUTPUT" + else + echo "needs_build=false" >> "$GITHUB_OUTPUT" + fi + + approval: + name: Await maintainer approval + needs: detect + if: needs.detect.outputs.needs_build == 'true' + runs-on: ubuntu-24.04 + environment: image-push + steps: + - name: Approved + run: echo "Approved to build the changed container image(s)." + + build: + name: Build ${{ matrix.image.repo }} (${{ matrix.arch }}) + needs: [detect, approval] + if: needs.detect.outputs.needs_build == 'true' + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.detect.outputs.matrix) }} + arch: [amd64, arm64] + runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - name: Build ${{ matrix.image.repo }}:${{ matrix.image.tag }}-${{ matrix.arch }} + env: + DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + run: | + set -eu -o pipefail + VERSION="${{ matrix.image.tag }}-${{ matrix.arch }}" + MAKE_TARGET="${{ matrix.image.make_target }}" + if [ "${{ matrix.image.arch_suffixed }}" = "true" ]; then + MAKE_TARGET="${MAKE_TARGET}_${{ matrix.arch }}" + fi + make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" VERSION="$VERSION" + + REPOS="${{ matrix.image.repo }}" + for suffix in ${{ matrix.image.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.image.tag }}" > tag.txt + echo -n "${{ matrix.arch }}" > arch.txt + - uses: actions/upload-artifact@v7 + with: + name: image-${{ matrix.image.make_dir }}-${{ matrix.arch }} + path: | + image.tar + repos.txt + tag.txt + arch.txt + retention-days: 1 diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index a32d65e286b..eb6c0dda82b 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -3,27 +3,149 @@ 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 }}" + permissions: contents: read + pull-requests: write jobs: - placeholder: - name: "Placeholder (see #8609)" + push: + name: Push built image(s) if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-24.04 + environment: image-push steps: - - name: Do nothing + - uses: actions/checkout@v7 + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + pattern: image-* + path: artifacts + continue-on-error: true + id: download + + - name: No image artifacts to push + if: steps.download.outcome == 'failure' + run: echo "No image-*.tar artifacts on the triggering run - nothing changed, nothing to push." + + - name: Load 1password secret(s) + if: steps.download.outcome == 'success' + 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 + if: steps.download.outcome == 'success' + uses: docker/setup-buildx-action@v4 + + - name: Login to DockerHub + if: steps.download.outcome == 'success' + uses: docker/login-action@v4 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ env.DOCKERHUB_TOKEN }} + + - name: Validate, load, and push each built image + if: steps.download.outcome == 'success' + id: push run: | - echo "Placeholder for #8609 phase 2 - no-op until the real workflow lands." + set -eu -o pipefail + declare -A TAG_BY_KEY + declare -A REPOS_BY_KEY + declare -A ARCHES_BY_KEY + + for dir in artifacts/image-*; do + [ -d "$dir" ] || continue + arch="$(cat "$dir/arch.txt")" + tag="$(cat "$dir/tag.txt")" + key="${dir%-"$arch"}" + + if ! containers/validate-image-tag.sh "$tag"; then + echo "image-push: refusing to push - invalid tag '$tag' from $dir" >&2 + exit 1 + fi + + 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_BY_KEY["$key"]="$tag" + if [ -z "${REPOS_BY_KEY[$key]:-}" ]; then + REPOS_BY_KEY["$key"]="$(cat "$dir/repos.txt")" + fi + ARCHES_BY_KEY["$key"]="${ARCHES_BY_KEY[$key]:-} ${arch}" + done + + PUSHED_SUMMARY="" + for key in "${!TAG_BY_KEY[@]}"; do + tag="${TAG_BY_KEY[$key]}" + while IFS= read -r repo; do + [ -z "$repo" ] && continue + arch_tags=() + for arch in ${ARCHES_BY_KEY[$key]}; do + arch_tags+=("${repo}:${tag}-${arch}") + done + docker buildx imagetools create -t "${repo}:${tag}" "${arch_tags[@]}" + PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${tag}\`"$'\n' + done <<< "${REPOS_BY_KEY[$key]}" + done + + { + echo "summary<> "$GITHUB_OUTPUT" + + - name: Comment on the pull request + if: steps.download.outcome == 'success' + uses: actions/github-script@v9 + env: + IMAGE_PUSH_SUMMARY: ${{ steps.push.outputs.summary }} + with: + script: | + const headSha = context.payload.workflow_run.head_sha; + const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: headSha, + }); + if (prs.length === 0) { + console.log(`No pull request associated with ${headSha}, 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(); + const body = summary + ? `Pushed updated container image(s) for this PR:\n\n${summary}` + : `Image build completed for this PR, but nothing needed pushing.`; + for (const pr of prs) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + } diff --git a/containers/registry-tag-exists.sh b/containers/registry-tag-exists.sh new file mode 100755 index 00000000000..ca0c3bfcc5c --- /dev/null +++ b/containers/registry-tag-exists.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# registry-tag-exists.sh +# +# Checks whether : already exists in the registry, without +# pulling it. Exit 0 if it exists, exit 1 if it doesn't (or the registry +# can't be reached). No local Docker daemon build/pull is triggered either +# way - this only talks to the registry. + +set -eu -o pipefail + +if [ "$#" -ne 2 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +IMAGE_REPO="$1" +TAG="$2" + +docker buildx imagetools inspect "${IMAGE_REPO}:${TAG}" >/dev/null 2>&1 diff --git a/containers/registry_tag_exists_test.sh b/containers/registry_tag_exists_test.sh new file mode 100755 index 00000000000..f1d1fdcd521 --- /dev/null +++ b/containers/registry_tag_exists_test.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# registry_tag_exists_test.sh - unit tests for registry-tag-exists.sh. +# +# Exercises the exists/doesn't-exist/unreachable outcomes against a stubbed +# `docker`, without talking to a real registry. +# Run with: +# containers/registry_tag_exists_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REGISTRY_TAG_EXISTS="$SCRIPT_DIR/registry-tag-exists.sh" + +FAILURES=0 + +fail() { + echo "FAIL: $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo "PASS: $1" +} + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# --- Stub `docker`, controlled by a marker file listing which refs "exist". +BINDIR="$WORKDIR/bin" +mkdir -p "$BINDIR" +export DOCKER_EXISTING_REF_FILE="$WORKDIR/docker_existing_refs" +export DOCKER_CALL_LOG="$WORKDIR/docker_calls.log" +: > "$DOCKER_EXISTING_REF_FILE" +: > "$DOCKER_CALL_LOG" +cat > "$BINDIR/docker" <<'DOCKEREOF' +#!/usr/bin/env bash +set -eu -o pipefail +echo "$*" >> "$DOCKER_CALL_LOG" +if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then + ref="$4" + grep -qxF "$ref" "$DOCKER_EXISTING_REF_FILE" + exit $? +fi +echo "docker stub: unexpected invocation: $*" >&2 +exit 1 +DOCKEREOF +chmod +x "$BINDIR/docker" +export PATH="$BINDIR:$PATH" + +# 1. Missing tag -> non-zero exit, no crash. +if "$REGISTRY_TAG_EXISTS" ddev/dummy-image missing-0123456789 >/dev/null 2>&1; then + fail "should report missing tag as not existing" +else + pass "reports missing tag as not existing" +fi + +# 2. Existing tag -> zero exit. +echo "ddev/dummy-image:present-0123456789" > "$DOCKER_EXISTING_REF_FILE" +if "$REGISTRY_TAG_EXISTS" ddev/dummy-image present-0123456789 >/dev/null 2>&1; then + pass "reports existing tag as existing" +else + fail "should report existing tag as existing" +fi + +# 3. Exactly one docker call per invocation - no retries/loops in this script +# (retry/backoff, if wanted, is the caller's job, e.g. wait-for-images.sh). +calls="$(wc -l < "$DOCKER_CALL_LOG")" +if [ "$calls" -eq 2 ]; then + pass "made exactly one docker call per invocation" +else + fail "expected 2 total docker calls across both invocations, got $calls" +fi + +# 4. Usage error on wrong argument count. +if "$REGISTRY_TAG_EXISTS" only-one-arg >/dev/null 2>&1; then + fail "should reject wrong argument count" +else + pass "rejects wrong argument count" +fi + +if [ "$FAILURES" -eq 0 ]; then + echo "All registry_tag_exists_test.sh checks passed." + exit 0 +else + echo "$FAILURES registry_tag_exists_test.sh check(s) failed." >&2 + exit 1 +fi diff --git a/containers/validate-image-tag.sh b/containers/validate-image-tag.sh new file mode 100755 index 00000000000..61ea7735b4f --- /dev/null +++ b/containers/validate-image-tag.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# validate-image-tag.sh +# +# Validates a content-addressed image tag before it's used in any `docker +# push`/`docker buildx imagetools create` command. This is the trusted-side +# check on a tag string that arrived via a build artifact from a job that +# may have run untrusted (fork PR) content - see image-push.yml. +# +# Requires: +# - strict charset, matching the same sanitization autotag.sh applies +# - must end in exactly HASH_LEN lowercase hex characters (the part +# tooling treats as authoritative) +# - must not be a reserved literal (e.g. "latest") or a release-tag +# shape (vX.Y.Z), so a forged tag can never collide with a real one +# +# Env: +# HASH_LEN - hash length in hex chars (default 10, must match hash-paths.sh) + +set -eu -o pipefail + +HASH_LEN="${HASH_LEN:-10}" + +RESERVED_TAGS=(latest stable edge) + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +TAG="$1" + +if ! [[ "$TAG" =~ ^[A-Za-z0-9_.-]+-[0-9a-f]{${HASH_LEN}}$ ]]; then + echo "validate-image-tag.sh: '${TAG}' does not match -<${HASH_LEN}-hex-char-hash>" >&2 + exit 1 +fi + +for reserved in "${RESERVED_TAGS[@]}"; do + if [ "$TAG" = "$reserved" ]; then + echo "validate-image-tag.sh: '${TAG}' is a reserved tag" >&2 + exit 1 + fi +done + +if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "validate-image-tag.sh: '${TAG}' looks like a release tag, not a content-hash tag" >&2 + exit 1 +fi + +exit 0 diff --git a/containers/validate_image_tag_test.sh b/containers/validate_image_tag_test.sh new file mode 100755 index 00000000000..021e9571154 --- /dev/null +++ b/containers/validate_image_tag_test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# validate_image_tag_test.sh - unit tests for validate-image-tag.sh. +# +# Pure string-format checks, no external stubs needed. +# Run with: +# containers/validate_image_tag_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VALIDATE="$SCRIPT_DIR/validate-image-tag.sh" + +FAILURES=0 + +fail() { + echo "FAIL: $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo "PASS: $1" +} + +assert_valid() { + local tag="$1" + if "$VALIDATE" "$tag" >/dev/null 2>&1; then + pass "accepts valid tag '$tag'" + else + fail "should have accepted valid tag '$tag'" + fi +} + +assert_invalid() { + local tag="$1" desc="$2" + if "$VALIDATE" "$tag" >/dev/null 2>&1; then + fail "should have rejected $desc ('$tag')" + else + pass "rejects $desc ('$tag')" + fi +} + +assert_valid "20260721_rfay_content_addressed_image_tags-36bceca65e" +assert_valid "main-0123456789" + +assert_invalid "latest" "the reserved literal 'latest'" +assert_invalid "stable" "the reserved literal 'stable'" +assert_invalid "v1.2.3" "a bare release tag" +assert_invalid "latest-0123456789a" "a fake tag with an 11-char hash suffix" +assert_invalid "latest-012345678" "a fake tag with a 9-char hash suffix" +assert_invalid "no-hash-suffix" "a tag without a hex hash suffix" +assert_invalid "bad chars!-0123456789" "a tag with disallowed characters" +assert_invalid "UPPERHASH-0123456789AB" "a tag with an uppercase hash suffix" + +if [ "$FAILURES" -eq 0 ]; then + echo "All validate_image_tag_test.sh checks passed." + exit 0 +else + echo "$FAILURES validate_image_tag_test.sh check(s) failed." >&2 + exit 1 +fi diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 7ac7670ece0..58fd6589917 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -155,6 +155,8 @@ make push VERSION= DOCKER_REPO=your/dockerrepo ### Pushes Using GitHub Actions +The [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow (see [Automatic Image Build and Push](#automatic-image-build-and-push)) handles pushing a changed image automatically for any pull request, including forks. The workflows below are for manually pushing a specific tag — a re-push, or one of the `ddev-dbserver` variants other than the default `mariadb_11.8` that `make` auto-builds. + To manually push using GitHub Actions, #### For Most Images @@ -308,7 +310,19 @@ The Docker images that DDEV uses are included in the `containers/` directory: * `containers/ddev-traefik-router` is the current Traefik-based router image. * `containers/ddev-xhgui` provides a web interface to analyze performance profiles generated by xhprof. -When you change an image, running `make` from the repository root builds it locally and computes/updates its tag in `pkg/versionconstants/versionconstants.go` automatically — no manual tag-inventing or file-editing needed, and this works for any contributor, including from a fork. Getting that image into CI (a multi-arch push to the registry) still requires registry credentials that forks don't have, so please ask a maintainer if you need a container pushed to support a pull request. +When you change an image, running `make` from the repository root builds it locally and computes/updates its tag in `pkg/versionconstants/versionconstants.go` automatically — no manual tag-inventing or file-editing needed, and this works for any contributor, including from a fork. Once you push that commit as a pull request, the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow detects the changed image and (once a maintainer clicks Approve) builds and pushes it to the registry automatically — no maintainer needs to run anything by hand, and this works the same way for fork PRs. See [Automatic Image Build and Push](#automatic-image-build-and-push) below. + +### Automatic Image Build and Push + +Opening a pull request that touches `containers/` triggers the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow: + +1. A `detect` job computes the real content hash of each changed image and checks whether that tag already exists in the registry — this never trusts the tag string committed in `versionconstants.go`, so it works the same way whether or not you ran `make` locally first. +2. If anything needs building, an `approval` job waits for a maintainer to approve — this is the point where CI would otherwise start running an untrusted Dockerfile from a fork, so nothing happens until someone clicks Approve. +3. A `build` job then builds the image(s) per architecture. This job never has registry credentials, even after approval. +4. Once `build` finishes, a separate, trusted `image-push.yml` workflow loads what it produced and pushes it — this workflow never checks out or runs the pull request's code, so it's safe for it to hold the push credentials. +5. A comment is posted on the pull request once the push completes. + +This is why a maintainer only needs to click **Approve** once on a PR that changes a container image — everything else happens automatically, for maintainer and fork contributions alike. ## Pull Requests diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index 5d08677508a..ae5fa15174d 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -74,9 +74,25 @@ The following “Repository secret” environment variables must be configured i 2. Make sure you're about to create the right release tag. 3. Use the “Auto-generate release notes” option to get the commit list, then edit to add all the other necessary info. +## Automatic Image Build and Push + +Any pull request that changes `containers/` — including from a fork — is built and pushed automatically by the [Image build](https://github.com/ddev/ddev/blob/main/.github/workflows/image-build-push.yml) / [Image push](https://github.com/ddev/ddev/blob/main/.github/workflows/image-push.yml) workflow pair. See [Automatic Image Build and Push](building-contributing.md#automatic-image-build-and-push) in the contributing guide for how the flow works and why it's safe to run on fork-authored Dockerfiles. + +The two workflows below (manual `workflow_dispatch`) remain for re-pushing a specific tag and for `ddev-dbserver` variants other than the default `mariadb_11.8` that the automatic flow doesn't build. + +### One-time setup: the `image-push` GitHub Environment + +The automatic flow needs a GitHub Environment named `image-push` configured once per repository (Settings → Environments): + +1. Create the environment `image-push`. +2. Add required reviewers (the maintainers/dev team) — this is what makes both the pre-build approval gate and the actual push wait for a human click. +3. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. + +When testing this on `ddev-test/ddev`, do the same three steps there first, and confirm `vars.DOCKER_ORG` on that repository points at the DockerHub org used for testing. + ## Pushing Docker Images with the GitHub Actions Workflow -The easiest way to push Docker images is to use the GitHub Actions workflow, especially if the code for the image is already in the [ddev/ddev](https://github.com/ddev/ddev) repository. +The easiest way to push Docker images is to use the GitHub Actions workflow, especially if the code for the image is already in the [ddev/ddev](https://github.com/ddev/ddev) repository. For a normal container change on a pull request, you shouldn't need this — see [Automatic Image Build and Push](#automatic-image-build-and-push) above. ### Actual release creation @@ -88,7 +104,7 @@ You can push all images besides `ddev-dbserver` at -If you need to push from a forked PR, you’ll have to do this from your fork (for example, `https://github.com/rfay/ddev/actions/workflows/push-tagged-image.yml`), and you’ll have to specify the branch on the fork. This requires setting the `DOCKERHUB_TOKEN` and `DOCKERHUB_USERNAME` secrets on the forked PR, for example `https://github.com/rfay/ddev/settings/secrets/actions`. You can do the same with `ddev-dbserver` at `https://github.com/rfay/ddev/actions/workflows/push-tagged-dbimage.yml` for example. +A forked PR that changes a container image no longer needs any of this — see [Automatic Image Build and Push](#automatic-image-build-and-push) above. The fork-your-own-secrets workaround described in earlier versions of this doc is superseded by that flow. * Visit `https://github.com/ddev/ddev/actions/workflows/push-tagged-image.yml`. * Click the “Push tagged image” workflow on the left side of the page. From fff1835152c8bd688dee8affcb5137c5625c5ae3 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:18:46 +0000 Subject: [PATCH 02/20] docs(containers): note follow-up to automate the full db variant matrix, for #8609 The automatic build/push flow (and Phase 1's autotag-images before it) only auto-builds the default ddev-dbserver variant (mariadb_11.8). Tests exercising other db types/versions (TestDdevAllDatabases and similar) still need a manual push. Confirmed via a live test run on ddev-test/ddev that this is working as designed, not a bug - recording it as a follow-up to revisit, likely by moving full-matrix builds later in the flow rather than the pre-approval detect/build stage, since building all ~19 variants on every PR would be expensive. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-build-push.yml | 2 ++ Makefile | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index a0be8ab49cf..e81618e8e22 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -48,6 +48,8 @@ jobs: # repo_suffix|hash paths|make dir|make target|arch-suffixed target?|extra repo suffixes # Keep in sync with Makefile's autotag-images target. + # TODO(#8609): only the default db variant (mariadb_11.8) is listed + # here - see the TODO on autotag-images in the top-level Makefile. CONFIGS=( 'ddev-webserver|containers/ddev-webserver containers/containers_shared.mk|ddev-webserver|images|false|ddev-webserver-prod' 'ddev-traefik-router|containers/ddev-traefik-router containers/containers_shared.mk|ddev-traefik-router|container|false|' diff --git a/Makefile b/Makefile index 6773645f7b8..b72e66d8f78 100644 --- a/Makefile +++ b/Makefile @@ -68,6 +68,12 @@ build: autotag-images $(DEFAULT_BUILD) # (no Docker, no network). A changed image is built locally (host arch only) # and its tag in versionconstants.go is rewritten automatically - see # containers/autotag.sh and docs/content/developers/building-contributing.md. +# TODO(#8609): only the default db variant (mariadb_11.8) is auto-built/pushed +# below and in image-build-push.yml. Tests that exercise other db types/versions +# (TestDdevAllDatabases and similar) still need a manual push. Revisit whether +# to automate the full variant matrix, likely later in the build flow rather +# than in the pre-approval detect/build stage, since building all ~19 variants +# on every containers/ddev-dbserver PR would be expensive. .PHONY: autotag-images autotag-images: @containers/autotag.sh WebTag ddev/ddev-webserver containers/ddev-webserver containers/containers_shared.mk -- $(MAKE) -C containers/ddev-webserver images From a98c5508c4f68c9d094e99e11e8fa9a0e81d1b3e Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:34:29 +0000 Subject: [PATCH 03/20] fix(ci): bump actions/download-artifact to v8 to drop the Node 20 deprecation warning Caught via a live test run on ddev-test/ddev: image-push.yml's use of actions/download-artifact@v4 still targets Node 20 internally, which GitHub now flags as deprecated and force-runs on Node 24 anyway. v8 uses Node 24 natively with the same github-token/run-id/pattern/path inputs, so this is a straight version bump, not a behavior change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-push.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index eb6c0dda82b..4199eb61e7f 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v7 - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ github.event.workflow_run.id }} From 6abb52b5f75361c8dc49d048c80444c5b89a81a5 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:39:35 +0000 Subject: [PATCH 04/20] fix(ci): delete intermediary per-arch tags after creating the manifest, for #8609 Caught via a live test run on ddev-test/ddev: unlike push-tagged-image.yml and push-tagged-dbimage.yml, image-push.yml wasn't cleaning up the intermediary -amd64/-arm64 tags after assembling the multi-arch manifest, leaving them on DockerHub permanently. Adds the same JWT-token DELETE cleanup those workflows already do, right after each docker buildx imagetools create call. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-push.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 4199eb61e7f..789cf8a544f 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -98,6 +98,12 @@ jobs: ARCHES_BY_KEY["$key"]="${ARCHES_BY_KEY[$key]:-} ${arch}" done + # 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="" for key in "${!TAG_BY_KEY[@]}"; do tag="${TAG_BY_KEY[$key]}" @@ -109,6 +115,12 @@ jobs: done docker buildx imagetools create -t "${repo}:${tag}" "${arch_tags[@]}" PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${tag}\`"$'\n' + + for arch in ${ARCHES_BY_KEY[$key]}; 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_BY_KEY[$key]}" done From e0485446465c6f78b70bdc6c3fe26da0872cd94d Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 21:17:30 +0000 Subject: [PATCH 05/20] don't run workflow [skip ci] From a54b1754df4a131fdef6050ad8a11a93f98ae5d5 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:23:58 +0000 Subject: [PATCH 06/20] fix(ci): guard GitHub-hosted test workflows against the same image-push race Buildkite has, for #8609 ## Short Summary (TL;DR) test-reusable.yml and test-wsl2-reusable.yml pull pinned images the same way Buildkite does - a fresh runner never builds a changed image itself, so they can race image-push.yml's approval/build/push exactly like Buildkite could. Adds the same wait-for-images.sh guard there too, and moves the script out of .buildkite/ since it's no longer Buildkite-specific. ## The Issue Related to #8609 (phase 2). Confirmed live on ddev-test/ddev PR #30: a `containers/ddev-xhgui` change passed "Test Nginx-FPM" regardless of push status only because that test never pulls ddev-xhgui (it's gated behind XHProf mode). If the changed image had been ddev-webserver (used by nearly every test), or a test that does exercise xhgui had run, it would have raced the push and failed on the pull - `autotag.sh`'s no-op fast path trusts the committed tag without verifying the image exists anywhere, and a brand-new GitHub-hosted runner has no local build to fall back on. ## How This PR Solves The Issue - Moves `wait-for-images.sh` from `.buildkite/` to `containers/`, since both Buildkite and the GitHub-hosted test workflows need it now. - Adds a "Wait for pushed images" step to `test-reusable.yml`, right after the public-variables fetch and before anything Docker-dependent. - Adds the equivalent call to `.github/workflows/wsl2-test.sh` (the script `test-wsl2-reusable.yml` runs inside the WSL2 guest), right after Docker is confirmed ready and before `make` builds the binary. - Fixes a latent bug the new test caught: `tag="$(tag_for "$tag_var")"` silently killed the whole script under `set -e -o pipefail` when a tag var was missing from `versionconstants.go`, before ever reaching the intended "could not find..." error message. Same pattern exists in Phase 1's `autotag.sh` (`EXISTING_TAG="$(grep ... | sed ...)"`) - confirmed it has the identical silent-death bug, but left it alone since it's already-shipped code and this PR's scope is the wait-for-images guard; flagging separately. - Adds `containers/wait_for_images_test.sh` (same bash-harness style as `autotag_test.sh`), covering the fast path, eventual recovery within budget, giving up after exhausting the budget, and the missing-tag-var error path. Wired into `container-tests.yml`. ## Manual Testing Instructions Run `containers/wait_for_images_test.sh` directly (stubs `docker`/`sleep`, no daemon or network). For the real path: open a PR that changes an image test-reusable.yml/test-wsl2-reusable.yml actually depend on (e.g. `containers/ddev-webserver`) before approving the corresponding `image-push` run, and confirm the "Wait for pushed images" step polls rather than failing outright, then succeeds once the push lands. ## Automated Testing Overview New `containers/wait_for_images_test.sh`, run directly and via `container-tests.yml`'s unit-test job alongside the other containers/ bash tests. ## Release/Deployment Notes No behavior change when nothing changed under `containers/` (fast path, single registry check per image, no wait). Only affects PRs where an auto-managed image's tag doesn't yet exist in the registry. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- {.buildkite => containers}/wait-for-images.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {.buildkite => containers}/wait-for-images.sh (100%) diff --git a/.buildkite/wait-for-images.sh b/containers/wait-for-images.sh similarity index 100% rename from .buildkite/wait-for-images.sh rename to containers/wait-for-images.sh From fd60e304af4ab040e45cdc65d64ab0870160a9f4 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:24:38 +0000 Subject: [PATCH 07/20] fix(ci): finish wiring the wait-for-images guard into GitHub-hosted test workflows The previous commit only staged the .buildkite -> containers rename (a mis-staged `git add` silently dropped everything else). This commit carries the actual content: the new "Wait for pushed images" steps in test-reusable.yml and wsl2-test.sh, the wait-for-images.sh silent-exit fix, the new containers/wait_for_images_test.sh, and its wiring into container-tests.yml. See the previous commit's message for the full rationale. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .buildkite/perf.sh | 2 +- .buildkite/test.sh | 2 +- .github/workflows/container-tests.yml | 2 + .github/workflows/test-reusable.yml | 7 + .github/workflows/wsl2-test.sh | 6 + containers/wait-for-images.sh | 8 +- containers/wait_for_images_test.sh | 180 ++++++++++++++++++++++++++ 7 files changed, 203 insertions(+), 4 deletions(-) create mode 100755 containers/wait_for_images_test.sh diff --git a/.buildkite/perf.sh b/.buildkite/perf.sh index a1a0234d21b..4318cbae6b0 100755 --- a/.buildkite/perf.sh +++ b/.buildkite/perf.sh @@ -22,7 +22,7 @@ fi # Buildkite holds no image-push credentials, so a changed container image # might still be waiting on image-push.yml's maintainer approval when this # run starts. Wait for the registry to catch up before pulling anything. -"$(dirname "$0")/wait-for-images.sh" +"$(dirname "$0")/../containers/wait-for-images.sh" os=$(go env GOOS) diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 992b2c017aa..767a4e1ee1f 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -28,7 +28,7 @@ git update-ref -d refs/public-variables-tmp # Buildkite holds no image-push credentials, so a changed container image # might still be waiting on image-push.yml's maintainer approval when this # run starts. Wait for the registry to catch up before pulling anything. -"$(dirname "$0")/wait-for-images.sh" +"$(dirname "$0")/../containers/wait-for-images.sh" export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin os=$(go env GOOS) diff --git a/.github/workflows/container-tests.yml b/.github/workflows/container-tests.yml index 2eb77fb45d1..6d7f3cfbdc4 100644 --- a/.github/workflows/container-tests.yml +++ b/.github/workflows/container-tests.yml @@ -51,6 +51,8 @@ jobs: 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/wait_for_images_test.sh + run: containers/wait_for_images_test.sh container-build-and-test: name: ${{ matrix.os }} - Test container ${{ matrix.containers }} diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index d09ded36677..02c9985fcf2 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -152,6 +152,13 @@ jobs: done < <(git ls-tree --name-only refs/public-variables-tmp:.github/public-variables/) git update-ref -d refs/public-variables-tmp + - name: Wait for pushed images + # This runner never builds a changed image itself (autotag.sh's no-op + # fast path trusts the tag already committed in versionconstants.go), + # so it can race image-push.yml's approval/build/push the same way + # Buildkite can - see containers/wait-for-images.sh. + run: containers/wait-for-images.sh + - name: Get Date id: get-date run: | diff --git a/.github/workflows/wsl2-test.sh b/.github/workflows/wsl2-test.sh index 004ec49644a..5d8a513a57a 100755 --- a/.github/workflows/wsl2-test.sh +++ b/.github/workflows/wsl2-test.sh @@ -55,6 +55,12 @@ go version docker version git --version +# This runner never builds a changed image itself (autotag.sh's no-op fast +# path trusts the tag already committed in versionconstants.go), so it can +# race image-push.yml's approval/build/push - see containers/wait-for-images.sh. +echo "=== Waiting for pushed images ===" +containers/wait-for-images.sh + echo "=== Building DDEV ===" make CGO_ENABLED="${CGO_ENABLED}" BUILDARGS="${BUILDARGS}" diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index b66248f6a4e..b5189f33af2 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -1,7 +1,11 @@ #!/usr/bin/env bash # wait-for-images.sh # -# Buildkite holds no image-push credentials, so it must not race the +# Neither Buildkite nor the GitHub-hosted test-reusable.yml/ +# test-wsl2-reusable.yml runners hold image-push credentials, and none of +# them rebuild a changed image locally (autotag.sh's no-op fast path trusts +# the tag already committed in versionconstants.go, so a fresh runner with an +# empty Docker cache won't build it) - so any of them can race the # image-push.yml GitHub Actions workflow: if this commit's containers/ # changed, the image it needs might still be waiting on a maintainer's # approval when this test run starts. Before running anything that pulls a @@ -43,7 +47,7 @@ IMAGES=( for entry in "${IMAGES[@]}"; do image_repo="${entry%%:*}" tag_var="${entry##*:}" - tag="$(tag_for "$tag_var")" + tag="$(tag_for "$tag_var" || true)" if [ -z "$tag" ]; then echo "wait-for-images.sh: could not find 'var ${tag_var} = \"...\"' in $VERSIONCONSTANTS_FILE" >&2 exit 1 diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh new file mode 100755 index 00000000000..a50ae6fe41b --- /dev/null +++ b/containers/wait_for_images_test.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# wait_for_images_test.sh - unit tests for wait-for-images.sh. +# +# Exercises the fast-path/retry/give-up logic against a stubbed `docker` and +# a fabricated versionconstants.go, without a real registry or real sleeps. +# Run with: +# containers/wait_for_images_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WAIT_FOR_IMAGES="$SCRIPT_DIR/wait-for-images.sh" + +FAILURES=0 + +fail() { + echo "FAIL: $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo "PASS: $1" +} + +assert_eq() { + local expected="$1" actual="$2" desc="$3" + if [ "$expected" = "$actual" ]; then + pass "$desc" + else + fail "$desc (expected '$expected', got '$actual')" + fi +} + +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# --- Stub `docker`: exists-by-default, except a ref can be configured to +# only start "existing" after N calls (via a per-ref counter file), so the +# eventually-recovers scenario is deterministic - no real sleeps or +# background processes needed. +BINDIR="$WORKDIR/bin" +mkdir -p "$BINDIR" +export DOCKER_EXISTING_REF_FILE="$WORKDIR/docker_existing_refs" +export DOCKER_DELAYED_REF_FILE="$WORKDIR/docker_delayed_ref" +export DOCKER_DELAYED_COUNTER_DIR="$WORKDIR/docker_delayed_counters" +export DOCKER_CALL_LOG="$WORKDIR/docker_calls.log" +mkdir -p "$DOCKER_DELAYED_COUNTER_DIR" +: > "$DOCKER_EXISTING_REF_FILE" +: > "$DOCKER_DELAYED_REF_FILE" +: > "$DOCKER_CALL_LOG" +cat > "$BINDIR/docker" <<'DOCKEREOF' +#!/usr/bin/env bash +set -eu -o pipefail +echo "$*" >> "$DOCKER_CALL_LOG" +if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then + ref="$4" + if grep -qxF "$ref" "$DOCKER_EXISTING_REF_FILE"; then + exit 0 + fi + delayed_ref="$(cat "$DOCKER_DELAYED_REF_FILE" 2>/dev/null || true)" + if [ -n "$delayed_ref" ] && [ "$ref" = "$delayed_ref" ]; then + counter_file="$DOCKER_DELAYED_COUNTER_DIR/count" + count="$(cat "$counter_file" 2>/dev/null || echo 0)" + count=$((count + 1)) + echo "$count" > "$counter_file" + [ "$count" -ge 3 ] && exit 0 || exit 1 + fi + exit 1 +fi +echo "docker stub: unexpected invocation: $*" >&2 +exit 1 +DOCKEREOF +chmod +x "$BINDIR/docker" + +# --- Stub `sleep` so retry-budget tests run instantly and we can count waits. +export SLEEP_CALL_LOG="$WORKDIR/sleep_calls.log" +: > "$SLEEP_CALL_LOG" +cat > "$BINDIR/sleep" <<'SLEEPEOF' +#!/usr/bin/env bash +echo "$*" >> "$SLEEP_CALL_LOG" +SLEEPEOF +chmod +x "$BINDIR/sleep" + +export PATH="$BINDIR:$PATH" + +VERSIONCONSTANTS="$WORKDIR/versionconstants.go" +write_versionconstants() { + cat > "$VERSIONCONSTANTS" <<'EOF' +package versionconstants + +var WebTag = "main-1111111111" +var TraefikRouterTag = "main-2222222222" +var SSHAuthTag = "main-3333333333" +var XhguiTag = "main-4444444444" +var BaseDBTag = "main-5555555555" +EOF +} +write_versionconstants + +export VERSIONCONSTANTS_FILE="$VERSIONCONSTANTS" +export DOCKER_ORG=ddevhq + +# 1. Fast path: every tag already exists -> one docker call per image, no sleep. +cat > "$DOCKER_EXISTING_REF_FILE" <<'EOF' +ddevhq/ddev-webserver:main-1111111111 +ddevhq/ddev-traefik-router:main-2222222222 +ddevhq/ddev-ssh-agent:main-3333333333 +ddevhq/ddev-xhgui:main-4444444444 +ddevhq/ddev-dbserver-mariadb-11.8:main-5555555555 +EOF +: > "$DOCKER_CALL_LOG" +: > "$SLEEP_CALL_LOG" +if "$WAIT_FOR_IMAGES" >/dev/null 2>&1; then + pass "fast path succeeds when every tag already exists" +else + fail "fast path should succeed when every tag already exists" +fi +assert_eq "5" "$(wc -l < "$DOCKER_CALL_LOG")" "fast path makes exactly one docker call per image" +assert_eq "0" "$(wc -l < "$SLEEP_CALL_LOG")" "fast path never sleeps" + +# 2. A tag that's initially missing but becomes available on the 3rd check. +: > "$DOCKER_EXISTING_REF_FILE" +cat >> "$DOCKER_EXISTING_REF_FILE" <<'EOF' +ddevhq/ddev-webserver:main-1111111111 +ddevhq/ddev-traefik-router:main-2222222222 +ddevhq/ddev-ssh-agent:main-3333333333 +ddevhq/ddev-xhgui:main-4444444444 +EOF +echo "ddevhq/ddev-dbserver-mariadb-11.8:main-5555555555" > "$DOCKER_DELAYED_REF_FILE" +rm -f "$DOCKER_DELAYED_COUNTER_DIR/count" +: > "$SLEEP_CALL_LOG" +if WAIT_FOR_IMAGES_ATTEMPTS=5 WAIT_FOR_IMAGES_SLEEP=0 "$WAIT_FOR_IMAGES" >/dev/null 2>&1; then + pass "recovers once a previously-missing tag appears within the attempt budget" +else + fail "should recover once a previously-missing tag appears within the attempt budget" +fi +assert_eq "2" "$(wc -l < "$SLEEP_CALL_LOG")" "sleeps twice while waiting for the tag to become available on the 3rd check" +: > "$DOCKER_DELAYED_REF_FILE" + +# 3. Gives up cleanly after exhausting the attempt budget, with a clear message. +: > "$DOCKER_EXISTING_REF_FILE" +: > "$SLEEP_CALL_LOG" +OUTPUT="$(WAIT_FOR_IMAGES_ATTEMPTS=3 WAIT_FOR_IMAGES_SLEEP=0 "$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -ne 0 ]; then + pass "gives up (non-zero exit) once the attempt budget is exhausted" +else + fail "should give up (non-zero exit) once the attempt budget is exhausted" +fi +case "$OUTPUT" in + *"gave up waiting"*"has the maintainer approved"*) pass "give-up message is actionable" ;; + *) fail "give-up message should mention giving up and approval: $OUTPUT" ;; +esac +assert_eq "2" "$(wc -l < "$SLEEP_CALL_LOG")" "sleeps exactly (attempts - 1) times before giving up on the first (unavailable) image" + +# 4. A tag variable missing from versionconstants.go is a clear, immediate error. +cat > "$VERSIONCONSTANTS" <<'EOF' +package versionconstants + +var WebTag = "main-1111111111" +EOF +: > "$DOCKER_EXISTING_REF_FILE" +echo "ddevhq/ddev-webserver:main-1111111111" >> "$DOCKER_EXISTING_REF_FILE" +OUTPUT="$(WAIT_FOR_IMAGES_ATTEMPTS=1 "$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -ne 0 ]; then + pass "errors out when a tag var is missing from versionconstants.go" +else + fail "should error out when a tag var is missing from versionconstants.go" +fi +case "$OUTPUT" in + *"could not find"*"TraefikRouterTag"*) pass "missing-tag-var message names the missing var" ;; + *) fail "missing-tag-var message should name the missing var: $OUTPUT" ;; +esac + +if [ "$FAILURES" -eq 0 ]; then + echo "All wait_for_images_test.sh checks passed." + exit 0 +else + echo "$FAILURES wait_for_images_test.sh check(s) failed." >&2 + exit 1 +fi From 58b989eb69a70a756108d2ba23f650a52c03c95f Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:41:28 +0000 Subject: [PATCH 08/20] feat(ci): skip the approval gate entirely for non-fork image changes, for #8609 ## Short Summary (TL;DR) A push to `main` or a same-repo PR (no fork involved) no longer needs any manual approval to build/push a changed container image - only actual fork PRs still go through the two-approval fork-safe flow. Reported as awkward friction on ddev-test/ddev#30, where a maintainer-pushed, non-fork PR still required two approval clicks. ## The Issue Related to #8609 (phase 2). The `approval` gate (before `build`) and `image-push.yml`'s own approval exist purely because of the fork threat model: GitHub won't hand secrets to a job running a fork's Dockerfile, and even if it would, you don't want to. Neither concern applies to a `push` event (only write-access collaborators can push branches to the base repo) or a same-repo PR (head and base are the same repo, exactly as trusted as `main-build.yml`, which already uses this same secret unguarded). Gating those cases behind an approval was unjustified friction, not a security requirement. ## How This PR Solves The Issue `detect` now also computes `is_fork` using the same fork-check idiom already used in `push-tagged-image.yml`/`push-tagged-dbimage.yml`/`test-pull-push-providers.yml` (`github.event.pull_request.head.repo.owner.login != github.repository_owner`, false for any non-`pull_request` event). Jobs branch on it: - `is_fork == 'true'`: unchanged `approval` -> `build` (no secrets, artifact hand-off) -> `image-push.yml` (its own approval) flow. - `is_fork == 'false'`: new `build-and-push` job builds and pushes directly per arch, then `create-manifests` assembles the multi-arch manifest, cleans up the intermediary per-arch tags, and comments on the PR if there is one. Neither job declares `environment: image-push`, so they read `PUSH_SERVICE_ACCOUNT_TOKEN` as a plain repository secret with no approval gate - the same access level `main-build.yml` already has. Updated `building-contributing.md`/`release-management.md` to describe the fork-vs-non-fork split instead of a blanket "one approval click." ## Manual Testing Instructions Replayed the `is_fork` bash logic directly against `pull_request`-from-fork, `pull_request`-same-repo, and `push` event shapes - resolves to `true`/`false`/`false` respectively. Replayed `create-manifests`' push/imagetools-create/cleanup logic against a stubbed `docker`/`curl` - confirms both `ddev-webserver` and `ddev-webserver-prod` get manifests created and per-arch tags cleaned up. On `ddev-test/ddev`: push directly to a branch (non-fork) with a container change and confirm no approval prompt appears at all before the image is pushed. ## Automated Testing Overview No new bash test files (this is workflow-YAML branching); verified via direct bash replay of the fork-detection and manifest-creation logic (see above), plus `make staticrequired`. ## Release/Deployment Notes Reduces friction for maintainer/same-repo workflows; fork PRs are unaffected and keep the full two-approval flow. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-build-push.yml | 161 +++++++++++++++++- .../developers/building-contributing.md | 15 +- docs/content/developers/release-management.md | 2 +- 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index e81618e8e22..773ff00039c 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -3,11 +3,18 @@ defaults: run: shell: bash -# Untrusted side of the fork-safe build/push split for #8609 phase 2. This -# workflow may run a fork's own Dockerfile/build scripts, so no job here -# ever references a secret. The trusted side (loading the artifact this -# workflow produces and actually pushing it) lives in image-push.yml, -# triggered via workflow_run once this workflow completes. +# For a fork 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] @@ -37,6 +44,7 @@ jobs: outputs: matrix: ${{ steps.detect.outputs.matrix }} needs_build: ${{ steps.detect.outputs.needs_build }} + is_fork: ${{ steps.detect.outputs.is_fork }} steps: - uses: actions/checkout@v7 - name: Compute per-image build status @@ -86,10 +94,20 @@ jobs: echo "needs_build=false" >> "$GITHUB_OUTPUT" fi + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.pull_request.head.repo.owner.login }}" != "${{ github.repository_owner }}" ]; then + echo "is_fork=true" >> "$GITHUB_OUTPUT" + else + echo "is_fork=false" >> "$GITHUB_OUTPUT" + fi + + # --- Fork PRs: build with no secrets, hand off to image-push.yml for the + # trusted, approval-gated push. --- + approval: name: Await maintainer approval needs: detect - if: needs.detect.outputs.needs_build == 'true' + if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' runs-on: ubuntu-24.04 environment: image-push steps: @@ -99,7 +117,7 @@ jobs: build: name: Build ${{ matrix.image.repo }} (${{ matrix.arch }}) needs: [detect, approval] - if: needs.detect.outputs.needs_build == 'true' + if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' strategy: fail-fast: false matrix: @@ -145,3 +163,132 @@ jobs: tag.txt arch.txt retention-days: 1 + + # --- 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.image.repo }} (${{ matrix.arch }}) + needs: detect + if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false' + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.detect.outputs.matrix) }} + arch: [amd64, arm64] + runs-on: ${{ matrix.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.image.repo }}:${{ matrix.image.tag }}-${{ matrix.arch }} + env: + DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + run: | + set -eu -o pipefail + VERSION="${{ matrix.image.tag }}-${{ matrix.arch }}" + MAKE_TARGET="${{ matrix.image.make_target }}" + if [ "${{ matrix.image.arch_suffixed }}" = "true" ]; then + MAKE_TARGET="${MAKE_TARGET}_${{ matrix.arch }}" + fi + make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" VERSION="$VERSION" + + REPOS="${{ matrix.image.repo }}" + for suffix in ${{ matrix.image.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.image.repo }} + needs: [detect, build-and-push] + if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false' + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.detect.outputs.matrix) }} + runs-on: ubuntu-24.04 + permissions: + contents: read + pull-requests: write + 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: Create manifest and clean up per-arch tags + id: manifest + env: + DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + run: | + set -eu -o pipefail + TAG="${{ matrix.image.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.image.repo }}" + for suffix in ${{ matrix.image.extra_repo_suffixes }}; do + REPOS="${REPOS} ${DOCKER_ORG}/${suffix}" + done + + PUSHED_SUMMARY="" + for repo in $REPOS; do + docker buildx imagetools create -t "${repo}:${TAG}" "${repo}:${TAG}-amd64" "${repo}:${TAG}-arm64" + PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${TAG}\`"$'\n' + for arch in amd64 arm64; 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<> "$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}`, + }); diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 58fd6589917..3d920925316 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -310,19 +310,18 @@ The Docker images that DDEV uses are included in the `containers/` directory: * `containers/ddev-traefik-router` is the current Traefik-based router image. * `containers/ddev-xhgui` provides a web interface to analyze performance profiles generated by xhprof. -When you change an image, running `make` from the repository root builds it locally and computes/updates its tag in `pkg/versionconstants/versionconstants.go` automatically — no manual tag-inventing or file-editing needed, and this works for any contributor, including from a fork. Once you push that commit as a pull request, the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow detects the changed image and (once a maintainer clicks Approve) builds and pushes it to the registry automatically — no maintainer needs to run anything by hand, and this works the same way for fork PRs. See [Automatic Image Build and Push](#automatic-image-build-and-push) below. +When you change an image, running `make` from the repository root builds it locally and computes/updates its tag in `pkg/versionconstants/versionconstants.go` automatically — no manual tag-inventing or file-editing needed, and this works for any contributor, including from a fork. Once you push that commit as a pull request, the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow detects the changed image and builds and pushes it to the registry automatically — no maintainer needs to run anything by hand. See [Automatic Image Build and Push](#automatic-image-build-and-push) below for when that requires a maintainer's approval and when it doesn't. ### Automatic Image Build and Push -Opening a pull request that touches `containers/` triggers the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow: +Opening a pull request that touches `containers/` triggers the [Image build](https://github.com/ddev/ddev/actions/workflows/image-build-push.yml) workflow. A `detect` job always runs first: it computes the real content hash of each changed image and checks whether that tag already exists in the registry — this never trusts the tag string committed in `versionconstants.go`, so it works the same way whether or not you ran `make` locally first. -1. A `detect` job computes the real content hash of each changed image and checks whether that tag already exists in the registry — this never trusts the tag string committed in `versionconstants.go`, so it works the same way whether or not you ran `make` locally first. -2. If anything needs building, an `approval` job waits for a maintainer to approve — this is the point where CI would otherwise start running an untrusted Dockerfile from a fork, so nothing happens until someone clicks Approve. -3. A `build` job then builds the image(s) per architecture. This job never has registry credentials, even after approval. -4. Once `build` finishes, a separate, trusted `image-push.yml` workflow loads what it produced and pushes it — this workflow never checks out or runs the pull request's code, so it's safe for it to hold the push credentials. -5. A comment is posted on the pull request once the push completes. +What happens next depends on whether the PR is from a fork: -This is why a maintainer only needs to click **Approve** once on a PR that changes a container image — everything else happens automatically, for maintainer and fork contributions alike. +* **Fork PRs** (a real security boundary — the PR could contain an arbitrary Dockerfile/build script): an `approval` job waits for a maintainer to approve before anything runs the fork's code, since that's the point where CI would otherwise start executing untrusted content. A `build` job then builds the image(s) per architecture with no registry credentials at all, even after approval. Once it finishes, a separate, trusted `image-push.yml` workflow — which never checks out or runs the pull request's code — loads what it produced and pushes it, gated behind its own approval. A comment is posted on the PR once the push completes. +* **Everything else** (a push to `main`, or a pull request from a branch in the same repository — no fork content is ever involved): `build-and-push` builds and pushes directly in one step, with no approval gate at all — the same trust level `main-build.yml` already runs at unguarded. A `create-manifests` job then assembles the multi-arch manifest and comments on the PR, if there is one. + +So a maintainer only ever needs to click **Approve** for a fork PR that changes a container image (twice, once to allow the build and once to allow the push) — everything else is fully automatic. ## Pull Requests diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index ae5fa15174d..e949f8b309b 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -85,7 +85,7 @@ The two workflows below (manual `workflow_dispatch`) remain for re-pushing a spe The automatic flow needs a GitHub Environment named `image-push` configured once per repository (Settings → Environments): 1. Create the environment `image-push`. -2. Add required reviewers (the maintainers/dev team) — this is what makes both the pre-build approval gate and the actual push wait for a human click. +2. Add required reviewers (the maintainers/dev team) — this is what makes the pre-build approval gate and the actual push wait for a human click, but only for fork PRs. A push to `main` or a same-repo PR builds and pushes without any approval at all, using the repository-level `PUSH_SERVICE_ACCOUNT_TOKEN` secret directly (that job never declares `environment: image-push`, so this environment's protection rules don't apply to it). 3. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. When testing this on `ddev-test/ddev`, do the same three steps there first, and confirm `vars.DOCKER_ORG` on that repository points at the DockerHub org used for testing. From eb6c290f2c3fe48f8d424f02fa8950f186e1858e Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:46:01 +0000 Subject: [PATCH 09/20] fix(ci): use separate GitHub Environments for the build and push approvals, for #8609 ## Short Summary (TL;DR) The pre-build and pre-push approvals both showed up as "image-push" in GitHub's approval prompt, since that's the environment name it surfaces, not the job name - making it hard to tell which one a reviewer was being asked about. The pre-build gate now uses its own `image-build-approval` environment. ## The Issue Related to #8609 (phase 2). Reported after testing on ddev-test/ddev: "Review pending deployments" only distinguishes by environment name, and both approval points shared `environment: image-push`, so approvers had to dig into which workflow/job they were actually looking at. ## How This PR Solves The Issue The `approval` job in image-build-push.yml (fork-PR pre-build gate) now uses `environment: image-build-approval` instead of `image-push`. It needs no secret - only `image-push.yml`'s `push` job still needs the DockerHub token, so that's the only one that keeps `environment: image-push`. Sharpened both jobs' `name:` fields ("Approve: build this fork PR's Dockerfile(s)" / "Approve: push the built image(s) to DockerHub") for the same reason. Updated release-management.md's setup checklist to create both environments, and noted that referencing an environment that doesn't exist yet auto-creates it with no protection rules - so it's important to verify each one actually has a `required_reviewers` rule (e.g. via `gh api .../environments/`) before relying on it. ## Manual Testing Instructions Open a fork PR that changes a container image and confirm the two approval prompts now name different environments (`image-build-approval` then `image-push`). Requires creating the new `image-build-approval` GitHub Environment (with required reviewers, no secret) on the test/target repo first - until then this gate would silently not gate at all. ## Automated Testing Overview No behavioral logic changed (only environment names/job names), verified via `yaml.safe_load` that both jobs' `environment`/`name` fields resolve as intended, plus the existing containers/*_test.sh suite and `make staticrequired`. ## Release/Deployment Notes Requires the one-time creation of the `image-build-approval` GitHub Environment before this lands, on both ddev-test/ddev and ddev/ddev, mirroring image-push's required reviewers. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-build-push.yml | 11 ++++++++--- .github/workflows/image-push.yml | 2 +- docs/content/developers/building-contributing.md | 2 +- docs/content/developers/release-management.md | 15 +++++++++------ 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 773ff00039c..0f3def2554c 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -105,14 +105,19 @@ jobs: # trusted, approval-gated push. --- approval: - name: Await maintainer approval + name: "Approve: build this fork PR's Dockerfile(s)" needs: detect if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' runs-on: ubuntu-24.04 - environment: image-push + # Separate environment from image-push.yml's `push` job on purpose: both + # show up as "Review pending deployments" prompts naming only the + # environment, so a shared name would make the two approvals (build vs. + # push) indistinguishable at a glance. Needs its own GitHub Environment + # (required reviewers, no secret needed) - see release-management.md. + environment: image-build-approval steps: - name: Approved - run: echo "Approved to build the changed container image(s)." + run: echo "Approved to build the changed container image(s) from this fork PR." build: name: Build ${{ matrix.image.repo }} (${{ matrix.arch }}) diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 789cf8a544f..a54ded0eb63 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -23,7 +23,7 @@ permissions: jobs: push: - name: Push built image(s) + name: "Approve: push the built image(s) to DockerHub" if: github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-24.04 environment: image-push diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 3d920925316..591867b9b6b 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -318,7 +318,7 @@ Opening a pull request that touches `containers/` triggers the [Image build](htt What happens next depends on whether the PR is from a fork: -* **Fork PRs** (a real security boundary — the PR could contain an arbitrary Dockerfile/build script): an `approval` job waits for a maintainer to approve before anything runs the fork's code, since that's the point where CI would otherwise start executing untrusted content. A `build` job then builds the image(s) per architecture with no registry credentials at all, even after approval. Once it finishes, a separate, trusted `image-push.yml` workflow — which never checks out or runs the pull request's code — loads what it produced and pushes it, gated behind its own approval. A comment is posted on the PR once the push completes. +* **Fork PRs** (a real security boundary — the PR could contain an arbitrary Dockerfile/build script): an `approval` job, gated by the `image-build-approval` environment, waits for a maintainer to approve before anything runs the fork's code, since that's the point where CI would otherwise start executing untrusted content. A `build` job then builds the image(s) per architecture with no registry credentials at all, even after approval. Once it finishes, a separate, trusted `image-push.yml` workflow — which never checks out or runs the pull request's code — loads what it produced and pushes it, gated behind its own approval on the `image-push` environment. Two different environment names, so the "Review pending deployments" prompt makes it obvious which one you're approving. A comment is posted on the PR once the push completes. * **Everything else** (a push to `main`, or a pull request from a branch in the same repository — no fork content is ever involved): `build-and-push` builds and pushes directly in one step, with no approval gate at all — the same trust level `main-build.yml` already runs at unguarded. A `create-manifests` job then assembles the multi-arch manifest and comments on the PR, if there is one. So a maintainer only ever needs to click **Approve** for a fork PR that changes a container image (twice, once to allow the build and once to allow the push) — everything else is fully automatic. diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index e949f8b309b..7e1ca448cd2 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -80,15 +80,18 @@ Any pull request that changes `containers/` — including from a fork — is bui The two workflows below (manual `workflow_dispatch`) remain for re-pushing a specific tag and for `ddev-dbserver` variants other than the default `mariadb_11.8` that the automatic flow doesn't build. -### One-time setup: the `image-push` GitHub Environment +### One-time setup: the `image-build-approval` and `image-push` GitHub Environments -The automatic flow needs a GitHub Environment named `image-push` configured once per repository (Settings → Environments): +Fork PRs go through two separate approvals — build, then push — each gated by its own GitHub Environment (Settings → Environments), so the "Review pending deployments" prompt (which shows only the environment name) doesn't leave the two looking identical: -1. Create the environment `image-push`. -2. Add required reviewers (the maintainers/dev team) — this is what makes the pre-build approval gate and the actual push wait for a human click, but only for fork PRs. A push to `main` or a same-repo PR builds and pushes without any approval at all, using the repository-level `PUSH_SERVICE_ACCOUNT_TOKEN` secret directly (that job never declares `environment: image-push`, so this environment's protection rules don't apply to it). -3. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. +1. Create the environment `image-build-approval`. Add required reviewers (the maintainers/dev team). No secret needed — the `approval` job it gates never touches Docker or the registry. +2. Create the environment `image-push`. Add the same required reviewers. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. -When testing this on `ddev-test/ddev`, do the same three steps there first, and confirm `vars.DOCKER_ORG` on that repository points at the DockerHub org used for testing. +Both approvals only apply to fork PRs. A push to `main` or a same-repo PR builds and pushes without any approval at all, using the repository-level `PUSH_SERVICE_ACCOUNT_TOKEN` secret directly (that path never declares `environment:` on its jobs, so neither environment's protection rules apply to it). + +When testing this on `ddev-test/ddev`, do the same steps there first, and confirm `vars.DOCKER_ORG` on that repository points at the DockerHub org used for testing. + +Since a job referencing an environment that doesn't exist yet gets auto-created with no protection rules (silently *not* gating), verify each environment actually has a `required_reviewers` rule before relying on it, e.g. `gh api repos///environments/image-build-approval`. ## Pushing Docker Images with the GitHub Actions Workflow From 99d93be8ed5a3b8436190c99df87e9d2569ab256 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:51:51 +0000 Subject: [PATCH 10/20] feat(ci): drop the pre-build approval gate, keep only the push-side approval, for #8609 [skip ci] Fork PRs now only need one approval (before push), not two. The `build` job never holds registry credentials, so gating it before it runs wasn't protecting a secret - it was only a compute/abuse control, and that's judged not worth the extra friction here. Related to #8609 (phase 2). Following on from the previous commit (separate `image-build-approval`/`image-push` environments so the two prompts were distinguishable): on reflection, the pre-build `approval` job never had anything to protect - `permissions: contents: read`, no secrets referenced at all. Its only justification was resource/abuse control on untrusted compute (mirroring GitHub's first-time-contributor hold) - a real but lower-severity concern than the actual registry-mutation approval, and one this project is choosing to accept in exchange for one fewer manual click. Removes the `approval` job and its `image-build-approval` environment entirely. `build` now depends only on `detect` and runs immediately for fork PRs (still with zero secrets). The push-side approval on `image-push`'s environment, in the separate trusted `image-push.yml` workflow, is unchanged - it's still the only real gate. Updated `building-contributing.md`/`release-management.md` to describe the single-approval flow and drop the now-unneeded `image-build-approval` environment setup step (it was never actually created on either repo, so nothing to clean up there). Open a fork PR that changes a container image and confirm `build` starts immediately (no waiting job before it), and the only approval prompt appears before `image-push.yml`'s push step. No behavioral logic changed beyond removing a job; verified via `yaml.safe_load` that `build`'s `needs`/`environment` fields are as intended, plus the existing containers/*_test.sh suite and `make staticrequired`. Simplifies the fork-PR flow to one approval. The `image-push` environment/secret setup from previous commits is unchanged and still required. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-build-push.yml | 23 ++++--------------- .../developers/building-contributing.md | 4 ++-- docs/content/developers/release-management.md | 13 ++++++----- 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 0f3def2554c..620213981ec 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -101,27 +101,14 @@ jobs: echo "is_fork=false" >> "$GITHUB_OUTPUT" fi - # --- Fork PRs: build with no secrets, hand off to image-push.yml for the - # trusted, approval-gated push. --- - - approval: - name: "Approve: build this fork PR's Dockerfile(s)" - needs: detect - if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' - runs-on: ubuntu-24.04 - # Separate environment from image-push.yml's `push` job on purpose: both - # show up as "Review pending deployments" prompts naming only the - # environment, so a shared name would make the two approvals (build vs. - # push) indistinguishable at a glance. Needs its own GitHub Environment - # (required reviewers, no secret needed) - see release-management.md. - environment: image-build-approval - steps: - - name: Approved - run: echo "Approved to build the changed container image(s) from this fork PR." + # --- 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.image.repo }} (${{ matrix.arch }}) - needs: [detect, approval] + needs: detect if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' strategy: fail-fast: false diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 591867b9b6b..f213b3ac84d 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -318,10 +318,10 @@ Opening a pull request that touches `containers/` triggers the [Image build](htt What happens next depends on whether the PR is from a fork: -* **Fork PRs** (a real security boundary — the PR could contain an arbitrary Dockerfile/build script): an `approval` job, gated by the `image-build-approval` environment, waits for a maintainer to approve before anything runs the fork's code, since that's the point where CI would otherwise start executing untrusted content. A `build` job then builds the image(s) per architecture with no registry credentials at all, even after approval. Once it finishes, a separate, trusted `image-push.yml` workflow — which never checks out or runs the pull request's code — loads what it produced and pushes it, gated behind its own approval on the `image-push` environment. Two different environment names, so the "Review pending deployments" prompt makes it obvious which one you're approving. A comment is posted on the PR once the push completes. +* **Fork PRs** (a real security boundary — the PR could contain an arbitrary Dockerfile/build script): a `build` job builds the image(s) per architecture with no registry credentials at all — nothing in that job can reach `docker.io`, so there's nothing to gain by gating it before it runs. Once it finishes, a separate, trusted `image-push.yml` workflow — which never checks out or runs the pull request's code — loads what it produced and pushes it, gated behind a maintainer's approval on the `image-push` environment. A comment is posted on the PR once the push completes. * **Everything else** (a push to `main`, or a pull request from a branch in the same repository — no fork content is ever involved): `build-and-push` builds and pushes directly in one step, with no approval gate at all — the same trust level `main-build.yml` already runs at unguarded. A `create-manifests` job then assembles the multi-arch manifest and comments on the PR, if there is one. -So a maintainer only ever needs to click **Approve** for a fork PR that changes a container image (twice, once to allow the build and once to allow the push) — everything else is fully automatic. +So a maintainer only ever needs to click **Approve** once — for a fork PR's push step — and only when the PR actually changed a container image; everything else is fully automatic. ## Pull Requests diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index 7e1ca448cd2..a06e610970a 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -80,18 +80,19 @@ Any pull request that changes `containers/` — including from a fork — is bui The two workflows below (manual `workflow_dispatch`) remain for re-pushing a specific tag and for `ddev-dbserver` variants other than the default `mariadb_11.8` that the automatic flow doesn't build. -### One-time setup: the `image-build-approval` and `image-push` GitHub Environments +### One-time setup: the `image-push` GitHub Environment -Fork PRs go through two separate approvals — build, then push — each gated by its own GitHub Environment (Settings → Environments), so the "Review pending deployments" prompt (which shows only the environment name) doesn't leave the two looking identical: +Fork PRs build with no registry credentials at all (nothing to gain by gating that step), then go through a single approval before the built image is actually pushed, gated by the `image-push` GitHub Environment (Settings → Environments): -1. Create the environment `image-build-approval`. Add required reviewers (the maintainers/dev team). No secret needed — the `approval` job it gates never touches Docker or the registry. -2. Create the environment `image-push`. Add the same required reviewers. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. +1. Create the environment `image-push`. +2. Add required reviewers (the maintainers/dev team). +3. Add `PUSH_SERVICE_ACCOUNT_TOKEN` as a secret **on this environment** (Settings → Environments → `image-push` → Secrets), using the same 1Password service-account token value already used elsewhere in this doc. It currently exists only as a repository secret; duplicating (or moving) it onto the `image-push` environment is what scopes `DOCKERHUB_TOKEN` access to only the approved `image-push.yml` job. -Both approvals only apply to fork PRs. A push to `main` or a same-repo PR builds and pushes without any approval at all, using the repository-level `PUSH_SERVICE_ACCOUNT_TOKEN` secret directly (that path never declares `environment:` on its jobs, so neither environment's protection rules apply to it). +This approval only applies to fork PRs. A push to `main` or a same-repo PR builds and pushes without any approval at all, using the repository-level `PUSH_SERVICE_ACCOUNT_TOKEN` secret directly (that path never declares `environment:` on its jobs, so this environment's protection rules don't apply to it). When testing this on `ddev-test/ddev`, do the same steps there first, and confirm `vars.DOCKER_ORG` on that repository points at the DockerHub org used for testing. -Since a job referencing an environment that doesn't exist yet gets auto-created with no protection rules (silently *not* gating), verify each environment actually has a `required_reviewers` rule before relying on it, e.g. `gh api repos///environments/image-build-approval`. +Since a job referencing an environment that doesn't exist yet gets auto-created with no protection rules (silently *not* gating), verify the environment actually has a `required_reviewers` rule before relying on it, e.g. `gh api repos///environments/image-push`. ## Pushing Docker Images with the GitHub Actions Workflow From bd38eed14eaa6d736f4f1b3ff9e3b95b80ad1f61 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:09:36 +0000 Subject: [PATCH 11/20] fix(ci): skip the image-push approval gate entirely for non-fork completions, for #8609 [skip ci] ## Short Summary (TL;DR) A maintainer-pushed (non-fork) PR still triggered the image-push approval prompt, even though that path already builds and pushes directly with no gate. image-push.yml's `push` job had no fork check of its own, so it asked for approval on every completion of "Image build", fork or not. ## The Issue Observed live on ddev-test/ddev: a PR pushed by a maintainer (not a fork) still required approval before "Image push" could run, contradicting the intended design (docs already say non-fork changes need zero approval clicks). ## How This PR Solves The Issue image-push.yml is triggered by `workflow_run` on completion of "Image build" - which runs for every PR/push, not just fork ones. Only the fork path (`build` job) uploads artifacts for it to find; the non-fork path (`build-and-push`/`create-manifests`) already pushed directly in the other workflow. But the `push` job's `environment: image-push` gate had no condition tied to fork status, so GitHub created an approval request before the job's steps (including the "no artifacts" fallback) ever ran. Added a job-level `if:` comparing `workflow_run.head_repository.full_name` to `workflow_run.repository.full_name` - the standard fork signal for `workflow_run` events - so the job (and its environment gate) is skipped entirely for non-fork completions. ## Manual Testing Instructions Push a container change directly on ddev-test/ddev (no fork) and confirm no approval prompt appears anywhere in the run. Open a fork PR with a container change and confirm the push-side approval still appears as before. ## Automated Testing Overview YAML-only change; verified with `python3 -c "import yaml; yaml.safe_load(...)"` and `make staticrequired`. ## Release/Deployment Notes No behavior change for forks. Removes an unnecessary/unintended approval prompt for maintainer-pushed changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/image-push.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index a54ded0eb63..57d7e583197 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -24,7 +24,13 @@ permissions: jobs: push: name: "Approve: push the built image(s) to DockerHub" - if: github.event.workflow_run.conclusion == 'success' + # Only fork completions of "Image build" ever produce artifacts here - + # the non-fork path pushes directly in that workflow's build-and-push + # job. Gate the job itself (not just its steps) on that, so a non-fork + # completion never creates an environment approval request at all. + 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 environment: image-push steps: From 39193f65180b4c805730e0a6472a3fd641110bed Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:11:22 +0000 Subject: [PATCH 12/20] fix(ci): pass DOCKER_ORG into wait-for-images.sh's GitHub-hosted callers, for #8609 [skip ci] ## Short Summary (TL;DR) wait-for-images.sh checked ddev/ instead of the repo's actual DOCKER_ORG, so on ddev-test/ddev it polled a registry the image was never pushed to and timed out even after the real push succeeded. ## The Issue Observed live on ddev-test/ddev (PR #30): "Wait for pushed images" polled `ddev/ddev-webserver:` for 20 attempts and gave up, even though the image had already landed at `ddevhq/ddev-webserver:`. test-reusable.yml and test-wsl2-reusable.yml never exported DOCKER_ORG, so the script's `DOCKER_ORG="${DOCKER_ORG:-ddev}"` fallback silently used the wrong org on any repo where vars.DOCKER_ORG isn't "ddev". ## How This PR Solves The Issue Added `DOCKER_ORG: ${{ vars.DOCKER_ORG }}` to test-reusable.yml's job-level env (same pattern main-build.yml already uses), and to test-wsl2-reusable.yml's job-level env plus its `wsl -u testuser` export list, since that job crosses into a WSL2 shell that doesn't inherit GitHub Actions env directly. Buildkite's test.sh/perf.sh are unchanged - they only ever target the real ddev/ddev registry and have no vars.DOCKER_ORG equivalent to read. ## Manual Testing Instructions Re-run a GitHub-hosted test job on a ddev-test/ddev PR with a pushed container change and confirm "Wait for pushed images" checks ddevhq/... and passes on the first attempt once the image is up. ## Automated Testing Overview YAML-only change; verified with `python3 -c "import yaml; yaml.safe_load(...)"` on both files. ## Release/Deployment Notes No effect on ddev/ddev, where vars.DOCKER_ORG is already "ddev". Fixes the check for any repo (like ddev-test/ddev) using a different org. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .github/workflows/test-reusable.yml | 1 + .github/workflows/test-wsl2-reusable.yml | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 02c9985fcf2..52b57a4639b 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -116,6 +116,7 @@ jobs: runs-on: ${{ inputs.runner }} env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} BUILDKIT_PROGRESS: plain DOCKER_CLI_EXPERIMENTAL: enabled DDEV_DEBUG: true diff --git a/.github/workflows/test-wsl2-reusable.yml b/.github/workflows/test-wsl2-reusable.yml index 5d4df9832c3..698a73560d3 100644 --- a/.github/workflows/test-wsl2-reusable.yml +++ b/.github/workflows/test-wsl2-reusable.yml @@ -67,6 +67,7 @@ jobs: name: WSL2 (${{ inputs.networking }}, ${{ inputs.make_target }}) env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} GOTEST_SHORT: ${{ inputs.gotest_short }} TESTARGS: ${{ inputs.testargs }} MAKE_TARGET: ${{ inputs.make_target }} @@ -193,4 +194,5 @@ jobs: $embargo = "${{ env.DDEV_EMBARGO_TESTS }}" $embargo_php = "${{ env.DDEV_EMBARGO_PHP_VERSIONS }}" $skip_nodejs = "${{ env.DDEV_SKIP_NODEJS_TEST }}" - wsl -u testuser -- bash -exc "export GOTEST_SHORT='$gotest_short' TESTARGS='$testargs' MAKE_TARGET='$make_target' MAKEARGS='$makeargs' DDEV_EMBARGO_TESTS='$embargo' DDEV_EMBARGO_PHP_VERSIONS='$embargo_php' DDEV_SKIP_NODEJS_TEST='$skip_nodejs' && cd ~/workspace/ddev && bash -e .github/workflows/wsl2-test.sh" + $docker_org = "${{ env.DOCKER_ORG }}" + wsl -u testuser -- bash -exc "export GOTEST_SHORT='$gotest_short' TESTARGS='$testargs' MAKE_TARGET='$make_target' MAKEARGS='$makeargs' DDEV_EMBARGO_TESTS='$embargo' DDEV_EMBARGO_PHP_VERSIONS='$embargo_php' DDEV_SKIP_NODEJS_TEST='$skip_nodejs' DOCKER_ORG='$docker_org' && cd ~/workspace/ddev && bash -e .github/workflows/wsl2-test.sh" From 37558303e300eb8f1f2be7067ad8e86ccf08b7aa Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:22:44 +0000 Subject: [PATCH 13/20] fix(ci): widen wait-for-images.sh's default retry budget, for #8609 [skip ci] ## Short Summary (TL;DR) The old default (20 attempts x 15s = 5 minutes) was already shorter than ddev-webserver's ~6-8 minute build time alone, before counting a fork PR's approval-click delay or the push/manifest steps afterward. Bumped to 40 x 30s (~20 minutes). ## The Issue Racing test jobs (test-reusable.yml, test-wsl2-reusable.yml, Buildkite test.sh/perf.sh) call wait-for-images.sh before pulling any DDEV image. Its old default budget was too short to reliably outlast a real ddev-webserver build, so it would likely give up before the image ever became available - not just in the slow "waiting on maintainer approval" case, but even in the ordinary same-repo build-and-push path with no human delay at all. ## How This PR Solves The Issue Raised WAIT_FOR_IMAGES_ATTEMPTS/WAIT_FOR_IMAGES_SLEEP defaults from 20/15 to 40/30 in containers/wait-for-images.sh, giving ~20 minutes of headroom. Callers can still override both via env if needed; none of the existing callers (test-reusable.yml, test-wsl2-reusable.yml, .buildkite/test.sh, .buildkite/perf.sh) set these explicitly, so they all pick up the new defaults. ## Manual Testing Instructions None beyond the existing unit test - this only changes two default numbers. ## Automated Testing Overview containers/wait_for_images_test.sh already overrides both env vars in every scenario it exercises, so it's unaffected; ran it directly to confirm. ## Release/Deployment Notes Racing GitHub-hosted/Buildkite jobs will wait longer (up to ~20 min instead of ~5) before giving up on a missing image, trading a bit of idle runner time for far fewer spurious "gave up waiting" failures on the common slow-build path. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- containers/wait-for-images.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index b5189f33af2..29ee7ff2639 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -16,8 +16,10 @@ # image, no wait. # # Env: -# WAIT_FOR_IMAGES_ATTEMPTS - poll attempts before giving up (default 20) -# WAIT_FOR_IMAGES_SLEEP - seconds between attempts (default 15) +# WAIT_FOR_IMAGES_ATTEMPTS - poll attempts before giving up (default 40) +# WAIT_FOR_IMAGES_SLEEP - seconds between attempts (default 30) +# +# Defaults give ~20 minutes - ddev-webserver alone takes ~6-8 minutes to build. set -eu -o pipefail @@ -27,8 +29,8 @@ REGISTRY_TAG_EXISTS="$REPO_ROOT/containers/registry-tag-exists.sh" VERSIONCONSTANTS_FILE="${VERSIONCONSTANTS_FILE:-$REPO_ROOT/pkg/versionconstants/versionconstants.go}" DOCKER_ORG="${DOCKER_ORG:-ddev}" -ATTEMPTS="${WAIT_FOR_IMAGES_ATTEMPTS:-20}" -SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-15}" +ATTEMPTS="${WAIT_FOR_IMAGES_ATTEMPTS:-40}" +SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-30}" tag_for() { grep -E "^var $1 = " "$VERSIONCONSTANTS_FILE" | sed -E "s/^var $1 = \"([^\"]*)\".*/\\1/" From 4d4ef3b61004674d89d2e6f09f00c4ad69d53703 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:30:59 +0000 Subject: [PATCH 14/20] fix(ci): recompute wait-for-images.sh's tags instead of trusting versionconstants.go, for #8609 [skip ci] ## Short Summary (TL;DR) wait-for-images.sh read its expected tag straight from versionconstants.go, but that file's committed tag only has its hash kept current locally - the branch prefix can be stale from whenever that image last actually changed. It now recomputes the tag from real content the same way image-build-push.yml's detect job does, so it always checks the tag CI actually built. ## The Issue Observed live on ddev-test/ddev (PR #30): a GitHub-hosted test job waited the full retry budget and gave up on `ddevhq/ddev-traefik-router:main-c96123b524`, even though the image had been pushed - just under `ddevhq/ddev-traefik-router:-c96123b524`. The hash matched; only the branch prefix was wrong. autotag.sh only rewrites versionconstants.go's committed tag string when the *hash* changes (containers/autotag.sh:73-79), so an image untouched since some earlier branch keeps that branch's name in its committed tag indefinitely - a stale value the rest of this design already explicitly refuses to trust (detect never reads it either). ## How This PR Solves The Issue wait-for-images.sh now takes a required WAIT_FOR_IMAGES_BRANCH and recomputes each image's tag via hash-paths.sh, using the same repo_suffix/hash-paths list and `-` formula as image-build-push.yml's detect job - never reading versionconstants.go at all. Threaded WAIT_FOR_IMAGES_BRANCH through every caller: test-reusable.yml and test-wsl2-reusable.yml set it to `github.head_ref || github.ref_name` (matching detect exactly), and .buildkite/test.sh/perf.sh set it to `$BUILDKITE_BRANCH`. Rewrote wait_for_images_test.sh to compute expected tags via the real hash-paths.sh against this checkout's actual content instead of a fabricated versionconstants.go fixture. ## Manual Testing Instructions Re-run a GitHub-hosted or Buildkite test job on a ddev-test/ddev PR with a pushed container change and confirm "Wait for pushed images" checks `-` and passes without timing out. ## Automated Testing Overview containers/wait_for_images_test.sh rewritten and passing (fast path, delayed-recovery, give-up, and a new required-WAIT_FOR_IMAGES_BRANCH check). Ran `make staticrequired` clean. ## Release/Deployment Notes No effect on the actual build/push decision (detect's logic is unchanged) - only fixes what the downstream wait check looks for. Every caller of wait-for-images.sh must now set WAIT_FOR_IMAGES_BRANCH; all current callers do. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- .buildkite/perf.sh | 2 +- .buildkite/test.sh | 2 +- .github/workflows/test-reusable.yml | 2 + .github/workflows/test-wsl2-reusable.yml | 4 +- containers/wait-for-images.sh | 49 ++++++++------ containers/wait_for_images_test.sh | 81 ++++++++++++------------ 6 files changed, 75 insertions(+), 65 deletions(-) diff --git a/.buildkite/perf.sh b/.buildkite/perf.sh index 4318cbae6b0..61fbf96dba4 100755 --- a/.buildkite/perf.sh +++ b/.buildkite/perf.sh @@ -22,7 +22,7 @@ fi # Buildkite holds no image-push credentials, so a changed container image # might still be waiting on image-push.yml's maintainer approval when this # run starts. Wait for the registry to catch up before pulling anything. -"$(dirname "$0")/../containers/wait-for-images.sh" +WAIT_FOR_IMAGES_BRANCH="$BUILDKITE_BRANCH" "$(dirname "$0")/../containers/wait-for-images.sh" os=$(go env GOOS) diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 767a4e1ee1f..486a7d22631 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -28,7 +28,7 @@ git update-ref -d refs/public-variables-tmp # Buildkite holds no image-push credentials, so a changed container image # might still be waiting on image-push.yml's maintainer approval when this # run starts. Wait for the registry to catch up before pulling anything. -"$(dirname "$0")/../containers/wait-for-images.sh" +WAIT_FOR_IMAGES_BRANCH="$BUILDKITE_BRANCH" "$(dirname "$0")/../containers/wait-for-images.sh" export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin os=$(go env GOOS) diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 52b57a4639b..c1ecbe29a4f 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -158,6 +158,8 @@ jobs: # fast path trusts the tag already committed in versionconstants.go), # so it can race image-push.yml's approval/build/push the same way # Buildkite can - see containers/wait-for-images.sh. + env: + WAIT_FOR_IMAGES_BRANCH: ${{ github.head_ref || github.ref_name }} run: containers/wait-for-images.sh - name: Get Date diff --git a/.github/workflows/test-wsl2-reusable.yml b/.github/workflows/test-wsl2-reusable.yml index 698a73560d3..067752ee150 100644 --- a/.github/workflows/test-wsl2-reusable.yml +++ b/.github/workflows/test-wsl2-reusable.yml @@ -68,6 +68,7 @@ jobs: env: DOCKER_ORG: ${{ vars.DOCKER_ORG }} + WAIT_FOR_IMAGES_BRANCH: ${{ github.head_ref || github.ref_name }} GOTEST_SHORT: ${{ inputs.gotest_short }} TESTARGS: ${{ inputs.testargs }} MAKE_TARGET: ${{ inputs.make_target }} @@ -195,4 +196,5 @@ jobs: $embargo_php = "${{ env.DDEV_EMBARGO_PHP_VERSIONS }}" $skip_nodejs = "${{ env.DDEV_SKIP_NODEJS_TEST }}" $docker_org = "${{ env.DOCKER_ORG }}" - wsl -u testuser -- bash -exc "export GOTEST_SHORT='$gotest_short' TESTARGS='$testargs' MAKE_TARGET='$make_target' MAKEARGS='$makeargs' DDEV_EMBARGO_TESTS='$embargo' DDEV_EMBARGO_PHP_VERSIONS='$embargo_php' DDEV_SKIP_NODEJS_TEST='$skip_nodejs' DOCKER_ORG='$docker_org' && cd ~/workspace/ddev && bash -e .github/workflows/wsl2-test.sh" + $wait_branch = "${{ env.WAIT_FOR_IMAGES_BRANCH }}" + wsl -u testuser -- bash -exc "export GOTEST_SHORT='$gotest_short' TESTARGS='$testargs' MAKE_TARGET='$make_target' MAKEARGS='$makeargs' DDEV_EMBARGO_TESTS='$embargo' DDEV_EMBARGO_PHP_VERSIONS='$embargo_php' DDEV_SKIP_NODEJS_TEST='$skip_nodejs' DOCKER_ORG='$docker_org' WAIT_FOR_IMAGES_BRANCH='$wait_branch' && cd ~/workspace/ddev && bash -e .github/workflows/wsl2-test.sh" diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index 29ee7ff2639..892633134d3 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -12,10 +12,23 @@ # DDEV image, poll the registry for the tags this checkout actually needs # and wait for them to land. # +# The tag is recomputed from real content (branch + hash-paths.sh), the same +# way image-build-push.yml's detect job does it - never read from +# versionconstants.go. That file's committed tag only has its hash kept +# current locally (autotag.sh skips rewriting the branch prefix when the hash +# hasn't changed), so it can carry a stale branch name from whatever branch +# last touched that image, while the registry holds the tag under *this* +# branch's name. Trusting the committed string would then wait forever for a +# tag nothing ever pushed. +# # Fast path (the common case - nothing changed): one registry check per # image, no wait. # # Env: +# WAIT_FOR_IMAGES_BRANCH - branch name to compute tags for (required) - +# pass the same value detect uses: for GitHub +# Actions that's head_ref || ref_name, for +# Buildkite it's $BUILDKITE_BRANCH. # WAIT_FOR_IMAGES_ATTEMPTS - poll attempts before giving up (default 40) # WAIT_FOR_IMAGES_SLEEP - seconds between attempts (default 30) # @@ -26,34 +39,30 @@ set -eu -o pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" REGISTRY_TAG_EXISTS="$REPO_ROOT/containers/registry-tag-exists.sh" -VERSIONCONSTANTS_FILE="${VERSIONCONSTANTS_FILE:-$REPO_ROOT/pkg/versionconstants/versionconstants.go}" +HASH_PATHS_SH="$REPO_ROOT/containers/hash-paths.sh" DOCKER_ORG="${DOCKER_ORG:-ddev}" ATTEMPTS="${WAIT_FOR_IMAGES_ATTEMPTS:-40}" SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-30}" -tag_for() { - grep -E "^var $1 = " "$VERSIONCONSTANTS_FILE" | sed -E "s/^var $1 = \"([^\"]*)\".*/\\1/" -} +BRANCH="${WAIT_FOR_IMAGES_BRANCH:?wait-for-images.sh: WAIT_FOR_IMAGES_BRANCH must be set}" +SANITIZED_BRANCH="$(echo "$BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" -# image-repo:tag-var-name pairs for the images Phase 1's autotag-images -# manages automatically. Keep in sync with Makefile's autotag-images target. -IMAGES=( - "${DOCKER_ORG}/ddev-webserver:WebTag" - "${DOCKER_ORG}/ddev-traefik-router:TraefikRouterTag" - "${DOCKER_ORG}/ddev-ssh-agent:SSHAuthTag" - "${DOCKER_ORG}/ddev-xhgui:XhguiTag" - "${DOCKER_ORG}/ddev-dbserver-mariadb-11.8:BaseDBTag" +# repo_suffix|hash paths - keep in sync with image-build-push.yml's detect +# job and the Makefile's autotag-images target. +CONFIGS=( + 'ddev-webserver|containers/ddev-webserver containers/containers_shared.mk' + 'ddev-traefik-router|containers/ddev-traefik-router containers/containers_shared.mk' + 'ddev-ssh-agent|containers/ddev-ssh-agent containers/containers_shared.mk' + 'ddev-xhgui|containers/ddev-xhgui containers/containers_shared.mk' + 'ddev-dbserver-mariadb-11.8|containers/ddev-dbserver containers/get_arch.sh' ) -for entry in "${IMAGES[@]}"; do - image_repo="${entry%%:*}" - tag_var="${entry##*:}" - tag="$(tag_for "$tag_var" || true)" - if [ -z "$tag" ]; then - echo "wait-for-images.sh: could not find 'var ${tag_var} = \"...\"' in $VERSIONCONSTANTS_FILE" >&2 - exit 1 - fi +for entry in "${CONFIGS[@]}"; do + IFS='|' read -r repo_suffix hash_paths <<< "$entry" + hash="$("$HASH_PATHS_SH" $hash_paths)" + tag="${SANITIZED_BRANCH}-${hash}" + image_repo="${DOCKER_ORG}/${repo_suffix}" attempt=1 while true; do diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh index a50ae6fe41b..6737aa73f60 100755 --- a/containers/wait_for_images_test.sh +++ b/containers/wait_for_images_test.sh @@ -2,7 +2,9 @@ # wait_for_images_test.sh - unit tests for wait-for-images.sh. # # Exercises the fast-path/retry/give-up logic against a stubbed `docker` and -# a fabricated versionconstants.go, without a real registry or real sleeps. +# the real hash-paths.sh (run against this checkout's actual content, so the +# expected tags are computed the same way wait-for-images.sh computes them - +# never read from versionconstants.go). No real registry or real sleeps. # Run with: # containers/wait_for_images_test.sh @@ -10,6 +12,7 @@ set -eu -o pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WAIT_FOR_IMAGES="$SCRIPT_DIR/wait-for-images.sh" +HASH_PATHS="$SCRIPT_DIR/hash-paths.sh" FAILURES=0 @@ -83,31 +86,33 @@ chmod +x "$BINDIR/sleep" export PATH="$BINDIR:$PATH" -VERSIONCONSTANTS="$WORKDIR/versionconstants.go" -write_versionconstants() { - cat > "$VERSIONCONSTANTS" <<'EOF' -package versionconstants - -var WebTag = "main-1111111111" -var TraefikRouterTag = "main-2222222222" -var SSHAuthTag = "main-3333333333" -var XhguiTag = "main-4444444444" -var BaseDBTag = "main-5555555555" -EOF -} -write_versionconstants - -export VERSIONCONSTANTS_FILE="$VERSIONCONSTANTS" export DOCKER_ORG=ddevhq +BRANCH="test-branch" +export WAIT_FOR_IMAGES_BRANCH="$BRANCH" + +# Same repo_suffix|hash-paths list wait-for-images.sh uses - real hashes of +# this checkout's actual content, computed the same way the script does. +CONFIGS=( + 'ddev-webserver|containers/ddev-webserver containers/containers_shared.mk' + 'ddev-traefik-router|containers/ddev-traefik-router containers/containers_shared.mk' + 'ddev-ssh-agent|containers/ddev-ssh-agent containers/containers_shared.mk' + 'ddev-xhgui|containers/ddev-xhgui containers/containers_shared.mk' + 'ddev-dbserver-mariadb-11.8|containers/ddev-dbserver containers/get_arch.sh' +) +REPOS=() +TAGS=() +for entry in "${CONFIGS[@]}"; do + IFS='|' read -r repo_suffix hash_paths <<< "$entry" + hash="$("$HASH_PATHS" $hash_paths)" + REPOS+=("ddevhq/${repo_suffix}") + TAGS+=("${BRANCH}-${hash}") +done # 1. Fast path: every tag already exists -> one docker call per image, no sleep. -cat > "$DOCKER_EXISTING_REF_FILE" <<'EOF' -ddevhq/ddev-webserver:main-1111111111 -ddevhq/ddev-traefik-router:main-2222222222 -ddevhq/ddev-ssh-agent:main-3333333333 -ddevhq/ddev-xhgui:main-4444444444 -ddevhq/ddev-dbserver-mariadb-11.8:main-5555555555 -EOF +: > "$DOCKER_EXISTING_REF_FILE" +for i in "${!REPOS[@]}"; do + echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" +done : > "$DOCKER_CALL_LOG" : > "$SLEEP_CALL_LOG" if "$WAIT_FOR_IMAGES" >/dev/null 2>&1; then @@ -120,13 +125,11 @@ assert_eq "0" "$(wc -l < "$SLEEP_CALL_LOG")" "fast path never sleeps" # 2. A tag that's initially missing but becomes available on the 3rd check. : > "$DOCKER_EXISTING_REF_FILE" -cat >> "$DOCKER_EXISTING_REF_FILE" <<'EOF' -ddevhq/ddev-webserver:main-1111111111 -ddevhq/ddev-traefik-router:main-2222222222 -ddevhq/ddev-ssh-agent:main-3333333333 -ddevhq/ddev-xhgui:main-4444444444 -EOF -echo "ddevhq/ddev-dbserver-mariadb-11.8:main-5555555555" > "$DOCKER_DELAYED_REF_FILE" +for i in "${!REPOS[@]}"; do + [ "$i" -eq 4 ] && continue + echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" +done +echo "${REPOS[4]}:${TAGS[4]}" > "$DOCKER_DELAYED_REF_FILE" rm -f "$DOCKER_DELAYED_COUNTER_DIR/count" : > "$SLEEP_CALL_LOG" if WAIT_FOR_IMAGES_ATTEMPTS=5 WAIT_FOR_IMAGES_SLEEP=0 "$WAIT_FOR_IMAGES" >/dev/null 2>&1; then @@ -152,23 +155,17 @@ case "$OUTPUT" in esac assert_eq "2" "$(wc -l < "$SLEEP_CALL_LOG")" "sleeps exactly (attempts - 1) times before giving up on the first (unavailable) image" -# 4. A tag variable missing from versionconstants.go is a clear, immediate error. -cat > "$VERSIONCONSTANTS" <<'EOF' -package versionconstants - -var WebTag = "main-1111111111" -EOF +# 4. WAIT_FOR_IMAGES_BRANCH is required - a clear, immediate error when unset. : > "$DOCKER_EXISTING_REF_FILE" -echo "ddevhq/ddev-webserver:main-1111111111" >> "$DOCKER_EXISTING_REF_FILE" -OUTPUT="$(WAIT_FOR_IMAGES_ATTEMPTS=1 "$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +OUTPUT="$(env -u WAIT_FOR_IMAGES_BRANCH "$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? if [ "$RC" -ne 0 ]; then - pass "errors out when a tag var is missing from versionconstants.go" + pass "errors out when WAIT_FOR_IMAGES_BRANCH is unset" else - fail "should error out when a tag var is missing from versionconstants.go" + fail "should error out when WAIT_FOR_IMAGES_BRANCH is unset" fi case "$OUTPUT" in - *"could not find"*"TraefikRouterTag"*) pass "missing-tag-var message names the missing var" ;; - *) fail "missing-tag-var message should name the missing var: $OUTPUT" ;; + *"WAIT_FOR_IMAGES_BRANCH must be set"*) pass "missing-branch message names the required variable" ;; + *) fail "missing-branch message should name WAIT_FOR_IMAGES_BRANCH: $OUTPUT" ;; esac if [ "$FAILURES" -eq 0 ]; then From fd9f79775cd7c56714b3167bea40337951f9bb98 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:08:37 +0000 Subject: [PATCH 15/20] test(containers): trivial ddev-xhgui change to exercise #8609 phase 2 CI on ddev-test/ddev Comment-only Dockerfile edit so `containers/ddev-xhgui`'s content hash changes, exercising the new detect -> approval -> build -> push flow end-to-end. `make` already regenerated XhguiTag locally to match. Not intended to land anywhere but ddev-test/ddev. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg New commit should only push one image add actual change to xhgui --- containers/ddev-xhgui/Dockerfile | 5 +++++ pkg/versionconstants/versionconstants.go | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/containers/ddev-xhgui/Dockerfile b/containers/ddev-xhgui/Dockerfile index 7eb8375d022..8a25828cdf6 100644 --- a/containers/ddev-xhgui/Dockerfile +++ b/containers/ddev-xhgui/Dockerfile @@ -1,6 +1,11 @@ FROM xhgui/xhgui:0.23 AS ddev-xhgui +# Phase 2 (#8609) end-to-end test: trivial content change to exercise +# detect -> approval -> build -> push on ddev-test/ddev. +# And try again; this time only xhgui should get pushed +# We need an actual change to get a new push RUN apk add --no-cache bash curl tzdata +RUN ls /var ADD /var /var ADD /etc /etc RUN echo 'memory_limit=512M' >> $PHP_INI_DIR/conf.d/99-memory-limit.ini diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index 69f3d6d4869..e15fe627231 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -44,7 +44,7 @@ var SSHAuthTag = "20260721_rfay_content_addressed_image_tags-8e8bf1217c" var XhguiImage = "ddev/ddev-xhgui" // XhguiTag is xhgui tag -var XhguiTag = "20260721_rfay_content_addressed_image_tags-f046b66382" +var XhguiTag = "20260814_rfay_test_phase2_xhgui_change-186ebdb177" // UtilitiesImage is used in bash scripts var UtilitiesImage = "ddev/ddev-utilities:latest" From 1ad61c0f50b7dee76472674ac6f055f5adccffd1 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 18:47:29 -0600 Subject: [PATCH 16/20] another trivial change --- containers/ddev-xhgui/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containers/ddev-xhgui/Dockerfile b/containers/ddev-xhgui/Dockerfile index 8a25828cdf6..0b22a492d50 100644 --- a/containers/ddev-xhgui/Dockerfile +++ b/containers/ddev-xhgui/Dockerfile @@ -5,8 +5,8 @@ FROM xhgui/xhgui:0.23 AS ddev-xhgui # And try again; this time only xhgui should get pushed # We need an actual change to get a new push RUN apk add --no-cache bash curl tzdata -RUN ls /var ADD /var /var +RUN ls -l /var ADD /etc /etc RUN echo 'memory_limit=512M' >> $PHP_INI_DIR/conf.d/99-memory-limit.ini ADD healthcheck.sh /healthcheck.sh From 57baeabdf4649394ea701630223c5dfbc0bbe888 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 18:56:07 -0600 Subject: [PATCH 17/20] Yes, make sure code changes too --- containers/ddev-xhgui/Dockerfile | 1 + pkg/ddevapp/utils.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/containers/ddev-xhgui/Dockerfile b/containers/ddev-xhgui/Dockerfile index 0b22a492d50..9c7ef81350d 100644 --- a/containers/ddev-xhgui/Dockerfile +++ b/containers/ddev-xhgui/Dockerfile @@ -8,6 +8,7 @@ RUN apk add --no-cache bash curl tzdata ADD /var /var RUN ls -l /var ADD /etc /etc +RUN ls -l /etc RUN echo 'memory_limit=512M' >> $PHP_INI_DIR/conf.d/99-memory-limit.ini ADD healthcheck.sh /healthcheck.sh RUN chmod ugo+x /healthcheck.sh diff --git a/pkg/ddevapp/utils.go b/pkg/ddevapp/utils.go index 59249d78013..ae11592432d 100644 --- a/pkg/ddevapp/utils.go +++ b/pkg/ddevapp/utils.go @@ -123,7 +123,7 @@ func Cleanup(app *DdevApp) error { } else { downCtx, svc, svcErr := dockerutil.NewComposeService() if svcErr != nil { - util.Warning("Failed to create compose service: %v", svcErr) + util.Warning("Failed to create compose service but I really tried: %v", svcErr) } else if downErr := svc.Down(downCtx, project.Name, api.DownOptions{Project: project, RemoveOrphans: true}); downErr != nil { util.Warning("Failed to docker-compose down: %v", downErr) } From 49de5d72a96abe2f2319b6369ce806ddab9501f9 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 19:11:03 -0600 Subject: [PATCH 18/20] Try this one again for more stuff --- containers/ddev-xhgui/Dockerfile | 2 +- pkg/ddevapp/utils.go | 2 +- pkg/versionconstants/versionconstants.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/containers/ddev-xhgui/Dockerfile b/containers/ddev-xhgui/Dockerfile index 9c7ef81350d..900c1f5298a 100644 --- a/containers/ddev-xhgui/Dockerfile +++ b/containers/ddev-xhgui/Dockerfile @@ -6,7 +6,7 @@ FROM xhgui/xhgui:0.23 AS ddev-xhgui # We need an actual change to get a new push RUN apk add --no-cache bash curl tzdata ADD /var /var -RUN ls -l /var +RUN ls -l /var /etc ADD /etc /etc RUN ls -l /etc RUN echo 'memory_limit=512M' >> $PHP_INI_DIR/conf.d/99-memory-limit.ini diff --git a/pkg/ddevapp/utils.go b/pkg/ddevapp/utils.go index ae11592432d..5ae29661721 100644 --- a/pkg/ddevapp/utils.go +++ b/pkg/ddevapp/utils.go @@ -119,7 +119,7 @@ func Cleanup(app *DdevApp) error { Profiles: []string{`*`}, }) if loadErr != nil { - util.Warning("Failed to load compose project for down: %v", loadErr) + util.Warning("Failed to load compose project for down just dumyy: %v", loadErr) } else { downCtx, svc, svcErr := dockerutil.NewComposeService() if svcErr != nil { diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index e15fe627231..c57526d402e 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -44,7 +44,7 @@ var SSHAuthTag = "20260721_rfay_content_addressed_image_tags-8e8bf1217c" var XhguiImage = "ddev/ddev-xhgui" // XhguiTag is xhgui tag -var XhguiTag = "20260814_rfay_test_phase2_xhgui_change-186ebdb177" +var XhguiTag = "20260814_rfay_test_phase2_xhgui_change-feb879f19d" // UtilitiesImage is used in bash scripts var UtilitiesImage = "ddev/ddev-utilities:latest" From 3f0b662e3dae3287b80a8144f45baafffedf1ac5 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:03:26 -0600 Subject: [PATCH 19/20] No try the new stuff with go change and ddev-webserver change --- cmd/ddev/cmd/utility-diagnose.go | 2 +- containers/ddev-webserver/Dockerfile | 1 + pkg/versionconstants/versionconstants.go | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/ddev/cmd/utility-diagnose.go b/cmd/ddev/cmd/utility-diagnose.go index 24cc6e363ac..8ec141039fb 100644 --- a/cmd/ddev/cmd/utility-diagnose.go +++ b/cmd/ddev/cmd/utility-diagnose.go @@ -50,7 +50,7 @@ DDEV_DIAGNOSE_FULL=true ddev utility diagnose # Include test project creation`, } c := []string{"-c", path.Join(scriptPath, "diagnose_ddev.sh")} - util.Debug("Running %s %v", bashPath, c) + util.Debug("Running %s %v to see what happens", bashPath, c) // Show introductory message output.UserOut.Println("Running DDEV diagnostics...") diff --git a/containers/ddev-webserver/Dockerfile b/containers/ddev-webserver/Dockerfile index 4be9799c89e..273208a5947 100644 --- a/containers/ddev-webserver/Dockerfile +++ b/containers/ddev-webserver/Dockerfile @@ -207,6 +207,7 @@ ARG TARGETARCH COPY ddev-webserver-etc-skel / RUN /sbin/mkhomedir_helper www-data +RUN ls # symfony cli RUN curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | bash && \ diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index c57526d402e..6fef0e6f867 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -20,7 +20,7 @@ var AmplitudeAPIKey = "" var WebImg = "ddev/ddev-webserver" // WebTag defines the default web image tag -var WebTag = "20260721_rfay_content_addressed_image_tags-36bceca65e" // Note that this can be overridden by make +var WebTag = "20260814_rfay_test_phase2_xhgui_change-3750639d62" // Note that this can be overridden by make // DBImg defines the default db image used for applications. var DBImg = "ddev/ddev-dbserver" From 0555c66703d332da671f57640b41e8eba88ae0c3 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:16:36 -0600 Subject: [PATCH 20/20] Another round with this --- cmd/ddev/cmd/utility-diagnose.go | 2 +- containers/ddev-webserver/Dockerfile | 2 +- pkg/versionconstants/versionconstants.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/ddev/cmd/utility-diagnose.go b/cmd/ddev/cmd/utility-diagnose.go index 8ec141039fb..3b882821e23 100644 --- a/cmd/ddev/cmd/utility-diagnose.go +++ b/cmd/ddev/cmd/utility-diagnose.go @@ -50,7 +50,7 @@ DDEV_DIAGNOSE_FULL=true ddev utility diagnose # Include test project creation`, } c := []string{"-c", path.Join(scriptPath, "diagnose_ddev.sh")} - util.Debug("Running %s %v to see what happens", bashPath, c) + util.Debug("Running %s %v to see what happens even more", bashPath, c) // Show introductory message output.UserOut.Println("Running DDEV diagnostics...") diff --git a/containers/ddev-webserver/Dockerfile b/containers/ddev-webserver/Dockerfile index 273208a5947..7751deeca17 100644 --- a/containers/ddev-webserver/Dockerfile +++ b/containers/ddev-webserver/Dockerfile @@ -207,7 +207,7 @@ ARG TARGETARCH COPY ddev-webserver-etc-skel / RUN /sbin/mkhomedir_helper www-data -RUN ls +RUN ls -lR # symfony cli RUN curl -1sLf 'https://dl.cloudsmith.io/public/symfony/stable/setup.deb.sh' | bash && \ diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index 6fef0e6f867..b7d7a89fb8f 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -20,7 +20,7 @@ var AmplitudeAPIKey = "" var WebImg = "ddev/ddev-webserver" // WebTag defines the default web image tag -var WebTag = "20260814_rfay_test_phase2_xhgui_change-3750639d62" // Note that this can be overridden by make +var WebTag = "20260814_rfay_test_phase2_xhgui_change-af66a92c42" // Note that this can be overridden by make // DBImg defines the default db image used for applications. var DBImg = "ddev/ddev-dbserver"