From 102036ad297448aff6763550caf8d7d99a2eb6c0 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 19:42:25 +0000 Subject: [PATCH 01/36] 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 602e171ee2d764806755c5f9c615510b63a2b573 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:18:46 +0000 Subject: [PATCH 02/36] 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 a7f72aac7fb61a367e5dbb16b7143c73ae097c4c Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:34:29 +0000 Subject: [PATCH 03/36] 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 403d13ef942b7ddb0dcc0b92ceb6e3ed45612dac Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 20:39:35 +0000 Subject: [PATCH 04/36] 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 6294cf9910a8ee7cc0961162be140cb0b1668070 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 21:17:30 +0000 Subject: [PATCH 05/36] don't run workflow [skip ci] From bd27ad8f6beee27e7c7138300be846926c32a522 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:23:58 +0000 Subject: [PATCH 06/36] 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 03df3a42f90d325ed2a6003b6cee5040502a52a8 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:24:38 +0000 Subject: [PATCH 07/36] 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 3052183d6ca..c0cc8c47dca 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 9075d21cbd24fba17050f1c4fa71b31ed9e68fb0 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:41:28 +0000 Subject: [PATCH 08/36] 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 a7ad9bb23f45280b5e20b16991fa5d19e9ace1fa Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:46:01 +0000 Subject: [PATCH 09/36] 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 90ed03519f42ef561bcc654269be1e9569119cb9 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 01:51:51 +0000 Subject: [PATCH 10/36] 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 ccb5e2678bbc32670d4925a35e842ef2a4af5712 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:09:36 +0000 Subject: [PATCH 11/36] 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 bbc5ea667a076547395e4f3a029dd8a8ac5d3788 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:11:22 +0000 Subject: [PATCH 12/36] 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 c0cc8c47dca..7c8b591472e 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 3ec7bfecade0ca8f2e82660363b038f8429d59c4 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:22:44 +0000 Subject: [PATCH 13/36] 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 d8d39037d44f02f2db2e22de74c6fb82b03050ad Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:30:59 +0000 Subject: [PATCH 14/36] 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 7c8b591472e..b335ad28da6 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 9a6f5b056ed27c29d68d1c0be18d96dee4b25d28 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sat, 15 Aug 2026 02:35:32 +0000 Subject: [PATCH 15/36] feat(ci): print a confirmation line when wait-for-images.sh finds each tag, for #8609 [skip ci] ## Short Summary (TL;DR) wait-for-images.sh previously only logged while waiting or giving up; a run where every image was already available printed nothing at all, making it hard to confirm at a glance which tags it actually checked. ## The Issue Useful while diagnosing the tag-mismatch bug fixed in the previous commit: there was no positive confirmation of which image:tag the script found, only silence on success or a wait/give-up message on failure. ## How This PR Solves The Issue Added `echo "wait-for-images.sh: found ${image_repo}:${tag}"` right after the existence check succeeds, for every image, not just ones that had to wait. ## Manual Testing Instructions Run containers/wait-for-images.sh in a repo where all tags already exist and confirm one "found ..." line prints per image. ## Automated Testing Overview Extended wait_for_images_test.sh's fast-path case to assert the confirmation line appears; ran the suite. ## Release/Deployment Notes Log-output only; no behavior change. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WqmDPsdWxQjwGu3LFNzGLg --- containers/wait-for-images.sh | 1 + containers/wait_for_images_test.sh | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index 892633134d3..f9e8efeb04e 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -67,6 +67,7 @@ for entry in "${CONFIGS[@]}"; do attempt=1 while true; do if "$REGISTRY_TAG_EXISTS" "$image_repo" "$tag"; then + echo "wait-for-images.sh: found ${image_repo}:${tag}" break fi if [ "$attempt" -ge "$ATTEMPTS" ]; then diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh index 6737aa73f60..902bd3b4ab9 100755 --- a/containers/wait_for_images_test.sh +++ b/containers/wait_for_images_test.sh @@ -115,13 +115,18 @@ for i in "${!REPOS[@]}"; do done : > "$DOCKER_CALL_LOG" : > "$SLEEP_CALL_LOG" -if "$WAIT_FOR_IMAGES" >/dev/null 2>&1; then +OUTPUT="$("$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -eq 0 ]; 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" +case "$OUTPUT" in + *"found ${REPOS[0]}:${TAGS[0]}"*) pass "prints confirmation for each found tag" ;; + *) fail "should print confirmation for each found tag: $OUTPUT" ;; +esac # 2. A tag that's initially missing but becomes available on the 3rd check. : > "$DOCKER_EXISTING_REF_FILE" From e91c18d4dc90569a08a1f3175ff363daf11fc51e Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Fri, 14 Aug 2026 21:17:30 +0000 Subject: [PATCH 16/36] don't run workflow [skip ci] From 4a72dfe8b24fec43adbc37202ea3240b8b745288 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 08:52:01 -0600 Subject: [PATCH 17/36] Minor comment changes [skip ci] --- .buildkite/perf.sh | 4 +--- .buildkite/test.sh | 4 +--- .github/workflows/image-build-push.yml | 2 +- docs/content/developers/building-contributing.md | 4 ++-- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.buildkite/perf.sh b/.buildkite/perf.sh index 61fbf96dba4..53fd00b7460 100755 --- a/.buildkite/perf.sh +++ b/.buildkite/perf.sh @@ -19,9 +19,7 @@ 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. +# A new image tag may have been pushed, find out which 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 486a7d22631..7a506763df0 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -25,9 +25,7 @@ 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. +# New images may have been pushed, need branch to know when they have appeared WAIT_FOR_IMAGES_BRANCH="$BUILDKITE_BRANCH" "$(dirname "$0")/../containers/wait-for-images.sh" export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 620213981ec..1e128a0de43 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -3,7 +3,7 @@ defaults: run: shell: bash -# For a fork PR, this workflow may run a fork's own Dockerfile/build scripts, +# In a forked PR, this workflow may run a fork's own Dockerfile/build scripts, # so the `build` job never references a secret - the trusted side (loading # the artifact `build` produces and actually pushing it) lives in # image-push.yml, triggered via workflow_run once this workflow completes, diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index f213b3ac84d..db272b5363e 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -155,7 +155,7 @@ 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. +Normally 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, @@ -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): 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. +* **Fork PRs** (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** once — for a fork PR's push step — and only when the PR actually changed a container image; everything else is fully automatic. From a19620bbba4e39fb7797b381a2fca97f6948e872 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 09:11:01 -0600 Subject: [PATCH 18/36] fix(ci): resolve image tags the way autotag.sh does, for #8609 wait-for-images.sh computed -, but the tag ddev pulls is the one committed in versionconstants.go, and autotag.sh rewrites that line (branch prefix included) only when the hash changes. The two agree only on a branch that changed the image, so any pull request not touching containers/ would poll for 20 minutes and fail every Buildkite and GitHub test job. detect had the mirror-image bug: it re-pushed all five images under a fresh branch prefix on any containers/ change, no-op or not. containers/required-image-tag.sh now resolves the tag once, for both callers, and says which case applies: "committed" (hash still matches, wait for that exact tag) or "recomputed" (content changed, make builds it locally, nothing to wait for). wait-for-images.sh no longer needs a branch name at all, which removed the WAIT_FOR_IMAGES_BRANCH plumbing from its four callers. Also from review: - github.head_ref reached a run: block spliced into the script. Git ref names permit quotes and backticks, and detect emits is_fork, so injected code could set is_fork=false and route fork content into build-and-push, the job that loads PUSH_SERVICE_ACCOUNT_TOKEN. It now arrives via env:, and is_fork moved to its own step. - image-push.yml validated the tag but pushed to whatever repository names the fork-produced artifact listed. validate-image-repo.sh constrains them to $DOCKER_ORG plus a known suffix. - image-push.yml lacked actions: read for a cross-run artifact download, and continue-on-error turned that into a "nothing needed pushing" comment. A new ungated check-artifacts job gates the environment job instead, so a fork build with nothing to push no longer asks for an approval, and a failed download is now fatal. - Artifact retention 1 -> 7 days; the gate is a human approval. - DDEV_IMAGE_TAG was not passed, so com.ddev.image-tag recorded - rather than the tag people pull, which imageVersionMismatch() compares against. - DOCKER_ORG falls back to ddev instead of producing "/ddev-webserver". - validate-image-tag.sh's reserved-literal and vX.Y.Z checks were unreachable behind the format check; they now test the part before the hash, rejecting latest-0123456789 and v1.2.3-0123456789. - containers/image-configs.sh replaces the image list that was duplicated across wait-for-images.sh, image-build-push.yml, and a test. - BSD wc -l padding failed 5 checks on macOS. Co-Authored-By: Claude Opus 5 (1M context) --- .buildkite/perf.sh | 4 +- .buildkite/test.sh | 4 +- .github/workflows/container-tests.yml | 4 + .github/workflows/image-build-push.yml | 68 ++++----- .github/workflows/image-push.yml | 74 +++++++--- .github/workflows/test-reusable.yml | 8 +- .github/workflows/test-wsl2-reusable.yml | 4 +- .github/workflows/wsl2-test.sh | 5 +- Makefile | 2 +- containers/autotag_test.sh | 11 +- containers/image-configs.sh | 27 ++++ containers/registry_tag_exists_test.sh | 3 +- containers/required-image-tag.sh | 54 +++++++ containers/required_image_tag_test.sh | 92 ++++++++++++ containers/validate-image-repo.sh | 50 +++++++ containers/validate-image-tag.sh | 38 +++-- containers/validate_image_repo_test.sh | 84 +++++++++++ containers/validate_image_tag_test.sh | 20 +++ containers/wait-for-images.sh | 71 ++++----- containers/wait_for_images_test.sh | 137 +++++++++++------- .../developers/building-contributing.md | 6 +- 21 files changed, 584 insertions(+), 182 deletions(-) create mode 100644 containers/image-configs.sh create mode 100755 containers/required-image-tag.sh create mode 100755 containers/required_image_tag_test.sh create mode 100755 containers/validate-image-repo.sh create mode 100755 containers/validate_image_repo_test.sh diff --git a/.buildkite/perf.sh b/.buildkite/perf.sh index 53fd00b7460..d624a13f463 100755 --- a/.buildkite/perf.sh +++ b/.buildkite/perf.sh @@ -19,8 +19,8 @@ if [[ ${BUILDKITE_MESSAGE:-} == *"[skip buildkite]"* ]] || [[ ${BUILDKITE_MESSAG exit 0 fi -# A new image tag may have been pushed, find out which -WAIT_FOR_IMAGES_BRANCH="$BUILDKITE_BRANCH" "$(dirname "$0")/../containers/wait-for-images.sh" +# A changed image may still be waiting on image-push.yml's approval +"$(dirname "$0")/../containers/wait-for-images.sh" os=$(go env GOOS) diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 7a506763df0..3adbc442073 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -25,8 +25,8 @@ 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 -# New images may have been pushed, need branch to know when they have appeared -WAIT_FOR_IMAGES_BRANCH="$BUILDKITE_BRANCH" "$(dirname "$0")/../containers/wait-for-images.sh" +# A changed image may still be waiting on image-push.yml's approval +"$(dirname "$0")/../containers/wait-for-images.sh" export PATH=$PATH:/home/linuxbrew/.linuxbrew/bin os=$(go env GOOS) diff --git a/.github/workflows/container-tests.yml b/.github/workflows/container-tests.yml index 6d7f3cfbdc4..5481bebacd3 100644 --- a/.github/workflows/container-tests.yml +++ b/.github/workflows/container-tests.yml @@ -47,10 +47,14 @@ jobs: - uses: actions/checkout@v7 - name: Run containers/autotag_test.sh run: containers/autotag_test.sh + - name: Run containers/required_image_tag_test.sh + run: containers/required_image_tag_test.sh - name: Run containers/registry_tag_exists_test.sh run: containers/registry_tag_exists_test.sh - name: Run containers/validate_image_tag_test.sh run: containers/validate_image_tag_test.sh + - name: Run containers/validate_image_repo_test.sh + run: containers/validate_image_repo_test.sh - name: Run containers/wait_for_images_test.sh run: containers/wait_for_images_test.sh diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 1e128a0de43..e71a0756f1c 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || 'ddev' }}" permissions: contents: read @@ -44,39 +44,31 @@ jobs: outputs: matrix: ${{ steps.detect.outputs.matrix }} needs_build: ${{ steps.detect.outputs.needs_build }} - is_fork: ${{ steps.detect.outputs.is_fork }} + is_fork: ${{ steps.fork.outputs.is_fork }} steps: - uses: actions/checkout@v7 - name: Compute per-image build status id: detect + # github.head_ref is attacker-controlled (a fork may name its branch + # anything git accepts, quotes and backticks included), so it reaches + # the script as data rather than as spliced-in source. + env: + REQUIRED_IMAGE_TAG_BRANCH: ${{ github.head_ref || github.ref_name }} run: | set -eu -o pipefail - 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. - # 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|' - '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|' - ) + source containers/image-configs.sh 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}" + for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r repo_suffix tag_var hash_paths make_dir make_target arch_suffixed extra_repo_suffixes <<< "$entry" + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + read -r state tag <<< "$(containers/required-image-tag.sh "$tag_var" $hash_paths)" repo="${DOCKER_ORG}/${repo_suffix}" if containers/registry-tag-exists.sh "$repo" "$tag"; then - echo "detect: ${repo}:${tag} already exists, nothing to build" + echo "detect: ${repo}:${tag} already exists (${state}), nothing to build" continue fi - echo "detect: ${repo}:${tag} needs building" + echo "detect: ${repo}:${tag} needs building (${state})" MATRIX_JSON="$(echo "$MATRIX_JSON" | jq -c \ --arg repo "$repo" \ --arg tag "$tag" \ @@ -94,8 +86,17 @@ 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 + # Separate from the step above so nothing the per-image loop does can + # reach the output that decides whether the push secret gets loaded. + - name: Determine whether this is a fork + id: fork + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_OWNER: ${{ github.event.pull_request.head.repo.owner.login }} + BASE_OWNER: ${{ github.repository_owner }} + run: | + set -eu -o pipefail + if [ "$EVENT_NAME" = "pull_request" ] && [ "$HEAD_OWNER" != "$BASE_OWNER" ]; then echo "is_fork=true" >> "$GITHUB_OUTPUT" else echo "is_fork=false" >> "$GITHUB_OUTPUT" @@ -121,8 +122,6 @@ jobs: 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 }}" @@ -130,7 +129,10 @@ jobs: 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" + # DDEV_IMAGE_TAG is the tag people actually pull, recorded in the + # com.ddev.image-tag label; VERSION is the throwaway per-arch tag. + make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" \ + VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.image.tag }}" REPOS="${{ matrix.image.repo }}" for suffix in ${{ matrix.image.extra_repo_suffixes }}; do @@ -154,7 +156,9 @@ jobs: repos.txt tag.txt arch.txt - retention-days: 1 + # image-push.yml can't download an expired artifact, and the approval + # it waits on is a human one that may not come the same day. + retention-days: 7 # --- Pushes and same-repo PRs: no fork content ever runs here, so build # and push directly with no environment/approval gate - same trust level @@ -188,8 +192,6 @@ jobs: 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 }}" @@ -197,7 +199,9 @@ jobs: 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" + # See the DDEV_IMAGE_TAG note in the fork-side `build` job above. + make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" \ + VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.image.tag }}" REPOS="${{ matrix.image.repo }}" for suffix in ${{ matrix.image.extra_repo_suffixes }}; do @@ -238,8 +242,6 @@ jobs: - 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 }}" diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 57d7e583197..a64df075e06 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -15,27 +15,57 @@ on: types: [completed] env: - DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || 'ddev' }}" permissions: contents: read - pull-requests: write jobs: - push: - name: "Approve: push the built image(s) to DockerHub" + # Ungated on purpose: asking a maintainer to approve a push only to discover + # the run built nothing trains people to click Approve without looking. + check-artifacts: + name: Check for built images # Only fork completions of "Image build" ever produce artifacts here - - # the non-fork path pushes directly in that workflow's build-and-push - # job. Gate the job itself (not just its steps) on that, so a non-fork - # completion never creates an environment approval request at all. + # the non-fork path pushes directly in that workflow's build-and-push job. if: >- github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name runs-on: ubuntu-24.04 + permissions: + actions: read + outputs: + has_artifacts: ${{ steps.check.outputs.has_artifacts }} + steps: + - name: List artifacts on the triggering run + id: check + uses: actions/github-script@v9 + with: + script: | + const artifacts = await github.paginate( + github.rest.actions.listWorkflowRunArtifacts, + { owner: context.repo.owner, repo: context.repo.repo, + run_id: context.payload.workflow_run.id }); + const images = artifacts.filter((a) => a.name.startsWith("image-")); + console.log(`Found ${images.length} image-* artifact(s): ${images.map((a) => a.name).join(", ")}`); + core.setOutput("has_artifacts", images.length > 0 ? "true" : "false"); + + push: + name: "Approve: push the built image(s) to DockerHub" + needs: check-artifacts + if: needs.check-artifacts.outputs.has_artifacts == 'true' + runs-on: ubuntu-24.04 environment: image-push + permissions: + # download-artifact needs actions:read to reach another run's artifacts. + actions: read + contents: read + pull-requests: write steps: - uses: actions/checkout@v7 + # No continue-on-error: check-artifacts already established that these + # exist, so a failure here is a real one (an expired artifact, say) and + # must not be reported as "nothing needed pushing". - name: Download build artifacts uses: actions/download-artifact@v8 with: @@ -43,15 +73,8 @@ jobs: 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 @@ -60,18 +83,15 @@ jobs: 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: | set -eu -o pipefail @@ -79,6 +99,9 @@ jobs: declare -A REPOS_BY_KEY declare -A ARCHES_BY_KEY + # Everything read out of artifacts/ was written by a job that may + # have run fork-authored content, so the tag and every repository + # name is validated before it reaches a `docker push`. for dir in artifacts/image-*; do [ -d "$dir" ] || continue arch="$(cat "$dir/arch.txt")" @@ -90,6 +113,14 @@ jobs: exit 1 fi + while IFS= read -r repo; do + [ -z "$repo" ] && continue + if ! containers/validate-image-repo.sh "$repo"; then + echo "image-push: refusing to push - disallowed repository '$repo' from $dir" >&2 + exit 1 + fi + done < "$dir/repos.txt" + docker load -i "$dir/image.tar" while IFS= read -r repo; do @@ -137,7 +168,6 @@ jobs: } >> "$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 }} @@ -156,9 +186,11 @@ jobs: // 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.`; + if (!summary) { + core.setFailed("Push step produced no summary - nothing was pushed."); + return; + } + const body = `Pushed updated container image(s) for this PR:\n\n${summary}`; for (const pr of prs) { await github.rest.issues.createComment({ owner: context.repo.owner, diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index b335ad28da6..e5820fb0532 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -154,12 +154,8 @@ jobs: 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. - env: - WAIT_FOR_IMAGES_BRANCH: ${{ github.head_ref || github.ref_name }} + # This runner holds no push credentials, so it can race + # image-push.yml's approval/build/push - see containers/wait-for-images.sh. 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 067752ee150..698a73560d3 100644 --- a/.github/workflows/test-wsl2-reusable.yml +++ b/.github/workflows/test-wsl2-reusable.yml @@ -68,7 +68,6 @@ 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 }} @@ -196,5 +195,4 @@ jobs: $embargo_php = "${{ env.DDEV_EMBARGO_PHP_VERSIONS }}" $skip_nodejs = "${{ env.DDEV_SKIP_NODEJS_TEST }}" $docker_org = "${{ env.DOCKER_ORG }}" - $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" + 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" diff --git a/.github/workflows/wsl2-test.sh b/.github/workflows/wsl2-test.sh index 5d8a513a57a..c40bc4b3dcd 100755 --- a/.github/workflows/wsl2-test.sh +++ b/.github/workflows/wsl2-test.sh @@ -55,9 +55,8 @@ 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. +# This runner holds no push credentials, 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 diff --git a/Makefile b/Makefile index b72e66d8f78..b7f8fe0d77b 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ build: autotag-images $(DEFAULT_BUILD) # 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 +# below and in containers/image-configs.sh. 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 diff --git a/containers/autotag_test.sh b/containers/autotag_test.sh index 19f0e020fe8..f9fff49ee6a 100755 --- a/containers/autotag_test.sh +++ b/containers/autotag_test.sh @@ -32,6 +32,11 @@ assert_eq() { fi } +# BSD wc pads its output with spaces; GNU wc does not. +count_lines() { + wc -l < "$1" | tr -d '[:space:]' +} + WORKDIR="$(mktemp -d)" trap 'rm -rf "$WORKDIR"' EXIT @@ -135,7 +140,7 @@ case "$candidate" in *-"$current_hash") pass "--print-only candidate tag ends with the current hash" ;; *) fail "--print-only candidate tag '$candidate' doesn't end with hash '$current_hash'" ;; esac -calls="$(wc -l < "$DOCKER_CALL_LOG")" +calls="$(count_lines "$DOCKER_CALL_LOG")" assert_eq "0" "$calls" "--print-only makes no docker calls" # 6. Change detected (today's fixture tag is "v1.0.0", never a real hash), @@ -163,10 +168,10 @@ esac # rewrite, and (the key design property) no docker call at all. rm -f "$BUILD_MARKER" before2="$(cat "$VERSIONCONSTANTS")" -calls_before="$(wc -l < "$DOCKER_CALL_LOG")" +calls_before="$(count_lines "$DOCKER_CALL_LOG")" "$AUTOTAG" WebTag ddev/dummy-image imgdir -- bash -c "touch '$BUILD_MARKER'" after2="$(cat "$VERSIONCONSTANTS")" -calls_after="$(wc -l < "$DOCKER_CALL_LOG")" +calls_after="$(count_lines "$DOCKER_CALL_LOG")" if [ -f "$BUILD_MARKER" ]; then fail "unexpected rebuild on unchanged content" else diff --git a/containers/image-configs.sh b/containers/image-configs.sh new file mode 100644 index 00000000000..5b61b614cb4 --- /dev/null +++ b/containers/image-configs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# image-configs.sh - the set of images the automatic build/push flow covers. +# +# Sourced (not executed) by wait-for-images.sh and by image-build-push.yml's +# detect job so the two can't drift apart. Keep in sync with the Makefile's +# autotag-images target. +# +# Fields, pipe-separated: +# repo_suffix Docker Hub repository under $DOCKER_ORG +# tag_var variable name in pkg/versionconstants/versionconstants.go +# hash_paths space-separated paths fed to hash-paths.sh +# make_dir directory under containers/ to run make in +# make_target make target that builds it +# arch_suffixed "true" if the target name takes an _ suffix +# extra_repo_suffixes further repositories the same target produces +# +# TODO(#8609): only the default db variant (mariadb_11.8) is listed - see the +# TODO on autotag-images in the top-level Makefile. + +# shellcheck disable=SC2034 # consumed by whatever sources this file +DDEV_IMAGE_CONFIGS=( + 'ddev-webserver|WebTag|containers/ddev-webserver containers/containers_shared.mk|ddev-webserver|images|false|ddev-webserver-prod' + 'ddev-traefik-router|TraefikRouterTag|containers/ddev-traefik-router containers/containers_shared.mk|ddev-traefik-router|container|false|' + 'ddev-ssh-agent|SSHAuthTag|containers/ddev-ssh-agent containers/containers_shared.mk|ddev-ssh-agent|container|false|' + 'ddev-xhgui|XhguiTag|containers/ddev-xhgui containers/containers_shared.mk|ddev-xhgui|container|false|' + 'ddev-dbserver-mariadb-11.8|BaseDBTag|containers/ddev-dbserver containers/get_arch.sh|ddev-dbserver|mariadb_11.8|true|' +) diff --git a/containers/registry_tag_exists_test.sh b/containers/registry_tag_exists_test.sh index f1d1fdcd521..dab6b7c28b4 100755 --- a/containers/registry_tag_exists_test.sh +++ b/containers/registry_tag_exists_test.sh @@ -64,7 +64,8 @@ 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")" +# BSD wc pads its output with spaces; GNU wc does not. +calls="$(wc -l < "$DOCKER_CALL_LOG" | tr -d '[:space:]')" if [ "$calls" -eq 2 ]; then pass "made exactly one docker call per invocation" else diff --git a/containers/required-image-tag.sh b/containers/required-image-tag.sh new file mode 100755 index 00000000000..1b2f2dbe072 --- /dev/null +++ b/containers/required-image-tag.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# required-image-tag.sh [ ...] +# +# Prints " " for the image tag this checkout actually resolves to, +# so callers agree with what `ddev` will pull and with what autotag.sh would do: +# +# committed versionconstants.go's tag still ends in the current +# content hash, so `make` leaves it alone and that exact +# tag - branch prefix and all - is what gets pulled. +# recomputed the content changed, so autotag.sh rewrites +# versionconstants.go to and builds it locally. +# +# The branch prefix is only meaningful in the recomputed state: autotag.sh +# rewrites the whole tag when the hash changes, so a committed tag whose hash +# still matches keeps whatever branch last changed that image. Recomputing the +# prefix in the committed state produces a tag nothing ever pushed. +# +# Env: +# HASH_LEN - hash length in hex chars (default 10, must +# match hash-paths.sh) +# VERSIONCONSTANTS_FILE - path to versionconstants.go +# REQUIRED_IMAGE_TAG_BRANCH - branch for the recomputed prefix (default: the +# current git branch) + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(git rev-parse --show-toplevel)" +HASH_LEN="${HASH_LEN:-10}" +VERSIONCONSTANTS_FILE="${VERSIONCONSTANTS_FILE:-$REPO_ROOT/pkg/versionconstants/versionconstants.go}" + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [ ...]" >&2 + exit 2 +fi + +TAG_VAR="$1"; shift + +CURRENT_HASH="$(HASH_LEN="$HASH_LEN" "$SCRIPT_DIR/hash-paths.sh" "$@")" + +EXISTING_TAG="$(grep -E "^var ${TAG_VAR} = " "$VERSIONCONSTANTS_FILE" 2>/dev/null | sed -E "s/^var ${TAG_VAR} = \"([^\"]*)\".*/\\1/" || true)" +if [ -z "$EXISTING_TAG" ]; then + echo "required-image-tag.sh: could not find 'var ${TAG_VAR} = \"...\"' in $VERSIONCONSTANTS_FILE" >&2 + exit 1 +fi + +if [ "${EXISTING_TAG: -${HASH_LEN}}" = "$CURRENT_HASH" ]; then + echo "committed ${EXISTING_TAG}" + exit 0 +fi + +BRANCH="${REQUIRED_IMAGE_TAG_BRANCH:-$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)}" +SANITIZED_BRANCH="$(echo "$BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" +echo "recomputed ${SANITIZED_BRANCH}-${CURRENT_HASH}" diff --git a/containers/required_image_tag_test.sh b/containers/required_image_tag_test.sh new file mode 100755 index 00000000000..dddd67bf817 --- /dev/null +++ b/containers/required_image_tag_test.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# required_image_tag_test.sh - unit tests for required-image-tag.sh. +# +# Runs against a throwaway versionconstants.go and this checkout's real +# content hashes. No Docker, no network. +# Run with: +# containers/required_image_tag_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REQUIRED_IMAGE_TAG="$SCRIPT_DIR/required-image-tag.sh" +HASH_PATHS="$SCRIPT_DIR/hash-paths.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 + +export VERSIONCONSTANTS_FILE="$WORKDIR/versionconstants.go" +HASH_PATH_ARGS=(containers/ddev-xhgui containers/containers_shared.mk) +CURRENT_HASH="$("$HASH_PATHS" "${HASH_PATH_ARGS[@]}")" + +# 1. Committed tag still matches the content: returned as-is, keeping the +# branch prefix of whatever branch last changed the image. This is the case +# that makes wait-for-images.sh wait for a tag that actually exists. +echo "var XhguiTag = \"an_old_branch-${CURRENT_HASH}\" // trailing comment" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH=current_branch "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "committed an_old_branch-${CURRENT_HASH}" "$OUTPUT" "keeps the committed tag when the hash still matches" + +# 2. Content no longer matches: the tag autotag.sh would rewrite it to, +# prefixed with the current branch. +echo "var XhguiTag = \"an_old_branch-0000000000\"" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH=current_branch "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "recomputed current_branch-${CURRENT_HASH}" "$OUTPUT" "recomputes a branch-prefixed tag when the hash changed" + +# 3. Branch names are sanitized to the tag charset, the same way autotag.sh +# does it - a fork may name its branch anything git accepts. +# shellcheck disable=SC2016 # the un-expanded $(id) is the point +OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH='feature/oh no$(id)' "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "recomputed feature-oh-no-id--${CURRENT_HASH}" "$OUTPUT" "sanitizes the branch name into the tag charset" +if "$SCRIPT_DIR/validate-image-tag.sh" "${OUTPUT#recomputed }" >/dev/null 2>&1; then + pass "a sanitized hostile branch name still yields a pushable tag" +else + fail "sanitized branch name should still yield a tag validate-image-tag.sh accepts: $OUTPUT" +fi + +# 4. A missing tag variable is a hard error, not an empty tag. +echo "var SomethingElse = \"whatever\"" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -ne 0 ]; then + pass "errors out when the tag variable isn't in versionconstants.go" +else + fail "should error out when the tag variable isn't in versionconstants.go" +fi +case "$OUTPUT" in + *"could not find"*XhguiTag*) pass "missing-variable message names the variable" ;; + *) fail "missing-variable message should name the variable: $OUTPUT" ;; +esac + +# 5. Usage error on too few arguments. +if "$REQUIRED_IMAGE_TAG" XhguiTag >/dev/null 2>&1; then + fail "should reject a missing hash path" +else + pass "rejects a missing hash path" +fi + +if [ "$FAILURES" -eq 0 ]; then + echo "All required_image_tag_test.sh checks passed." + exit 0 +else + echo "$FAILURES required_image_tag_test.sh check(s) failed." >&2 + exit 1 +fi diff --git a/containers/validate-image-repo.sh b/containers/validate-image-repo.sh new file mode 100755 index 00000000000..61097e3a227 --- /dev/null +++ b/containers/validate-image-repo.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# validate-image-repo.sh +# +# Validates a `/` repository string before it's used in any `docker +# push`/`docker buildx imagetools create` command. The companion to +# validate-image-tag.sh: image-push.yml reads the repository list out of a +# build artifact produced by a job that may have run untrusted (fork PR) +# content, so without this a fork could name any repository the push +# credential can write to. +# +# Env: +# DOCKER_ORG - the only organization a push is allowed to target (required) + +set -eu -o pipefail + +ALLOWED_SUFFIXES=( + ddev-webserver + ddev-webserver-prod + ddev-traefik-router + ddev-ssh-agent + ddev-xhgui +) + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +REPO="$1" +DOCKER_ORG="${DOCKER_ORG:?validate-image-repo.sh: DOCKER_ORG must be set}" + +if [ "${REPO%%/*}" != "$DOCKER_ORG" ] || [ "$REPO" = "${REPO#*/}" ]; then + echo "validate-image-repo.sh: '${REPO}' is not under the '${DOCKER_ORG}/' organization" >&2 + exit 1 +fi + +SUFFIX="${REPO#*/}" + +for allowed in "${ALLOWED_SUFFIXES[@]}"; do + if [ "$SUFFIX" = "$allowed" ]; then + exit 0 + fi +done + +if [[ "$SUFFIX" =~ ^ddev-dbserver-(mariadb|mysql)-[0-9]+\.[0-9]+$ ]]; then + exit 0 +fi + +echo "validate-image-repo.sh: '${REPO}' is not one of the repositories this flow may push" >&2 +exit 1 diff --git a/containers/validate-image-tag.sh b/containers/validate-image-tag.sh index 61ea7735b4f..9e0cba2755f 100755 --- a/containers/validate-image-tag.sh +++ b/containers/validate-image-tag.sh @@ -7,11 +7,13 @@ # may have run untrusted (fork PR) content - see image-push.yml. # # Requires: -# - strict charset, matching the same sanitization autotag.sh applies +# - strict charset, matching the same sanitization autotag.sh applies, and +# a leading character Docker actually accepts in a tag # - 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 +# - neither the whole tag nor the part before the hash may be a reserved +# literal ("latest") or a release-tag shape (vX.Y.Z), so a forged tag can +# never be mistaken for a real one # # Env: # HASH_LEN - hash length in hex chars (default 10, must match hash-paths.sh) @@ -29,21 +31,29 @@ 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 +reject_reserved() { + local candidate="$1" what="$2" + for reserved in "${RESERVED_TAGS[@]}"; do + if [ "$candidate" = "$reserved" ]; then + echo "validate-image-tag.sh: '${TAG}' ${what} is the reserved tag '${reserved}'" >&2 + exit 1 + fi + done + if [[ "$candidate" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "validate-image-tag.sh: '${TAG}' ${what} looks like a release tag, not a content-hash 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 +reject_reserved "$TAG" "is" + +if ! [[ "$TAG" =~ ^[A-Za-z0-9_][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 +# Without this, "latest-0123456789" or "v1.2.3-0123456789" would sail through +# the format check above and land next to the real tags in the registry. +reject_reserved "${TAG%-*}" "starts with what" + exit 0 diff --git a/containers/validate_image_repo_test.sh b/containers/validate_image_repo_test.sh new file mode 100755 index 00000000000..36a1e3f36e1 --- /dev/null +++ b/containers/validate_image_repo_test.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# validate_image_repo_test.sh - unit tests for validate-image-repo.sh. +# +# Pure string checks, no external stubs needed. +# Run with: +# containers/validate_image_repo_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VALIDATE="$SCRIPT_DIR/validate-image-repo.sh" + +FAILURES=0 + +fail() { + echo "FAIL: $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +pass() { + echo "PASS: $1" +} + +export DOCKER_ORG=ddev + +assert_valid() { + local repo="$1" + if "$VALIDATE" "$repo" >/dev/null 2>&1; then + pass "accepts '$repo'" + else + fail "should have accepted '$repo'" + fi +} + +assert_invalid() { + local repo="$1" desc="$2" + if "$VALIDATE" "$repo" >/dev/null 2>&1; then + fail "should have rejected $desc ('$repo')" + else + pass "rejects $desc ('$repo')" + fi +} + +assert_valid "ddev/ddev-webserver" +assert_valid "ddev/ddev-webserver-prod" +assert_valid "ddev/ddev-traefik-router" +assert_valid "ddev/ddev-ssh-agent" +assert_valid "ddev/ddev-xhgui" +assert_valid "ddev/ddev-dbserver-mariadb-11.8" +assert_valid "ddev/ddev-dbserver-mysql-8.0" + +assert_invalid "someoneelse/ddev-webserver" "another organization" +assert_invalid "ddev-webserver" "a bare name with no organization" +assert_invalid "ddev/ddev-webserver-evil" "an unknown repository in the right org" +assert_invalid "ddev/ddev-dbserver-postgres-16" "a db engine the flow doesn't build" +assert_invalid "ddev/../../etc/passwd" "a path-traversal attempt" +assert_invalid "ddevhq/ddev-webserver" "an org that merely starts the same" + +# The org comes from a trusted workflow variable, so a differing DOCKER_ORG +# must move the whole allowlist rather than widen it. +if DOCKER_ORG=ddevhq "$VALIDATE" "ddevhq/ddev-webserver" >/dev/null 2>&1; then + pass "accepts the configured org when DOCKER_ORG differs" +else + fail "should accept the configured org when DOCKER_ORG differs" +fi +if DOCKER_ORG=ddevhq "$VALIDATE" "ddev/ddev-webserver" >/dev/null 2>&1; then + fail "should reject the default org when DOCKER_ORG points elsewhere" +else + pass "rejects the default org when DOCKER_ORG points elsewhere" +fi + +if env -u DOCKER_ORG "$VALIDATE" "ddev/ddev-webserver" >/dev/null 2>&1; then + fail "should refuse to run with DOCKER_ORG unset" +else + pass "refuses to run with DOCKER_ORG unset" +fi + +if [ "$FAILURES" -eq 0 ]; then + echo "All validate_image_repo_test.sh checks passed." + exit 0 +else + echo "$FAILURES validate_image_repo_test.sh check(s) failed." >&2 + exit 1 +fi diff --git a/containers/validate_image_tag_test.sh b/containers/validate_image_tag_test.sh index 021e9571154..6a1501abbc8 100755 --- a/containers/validate_image_tag_test.sh +++ b/containers/validate_image_tag_test.sh @@ -39,8 +39,21 @@ assert_invalid() { fi } +assert_rejected_because() { + local tag="$1" needle="$2" desc="$3" + local output + if output="$("$VALIDATE" "$tag" 2>&1)"; then + fail "should have rejected $desc ('$tag')" + elif [[ "$output" == *"$needle"* ]]; then + pass "rejects $desc ('$tag') as expected" + else + fail "rejected $desc ('$tag') for the wrong reason: $output" + fi +} + assert_valid "20260721_rfay_content_addressed_image_tags-36bceca65e" assert_valid "main-0123456789" +assert_valid "v1.2.3-rc1-0123456789" assert_invalid "latest" "the reserved literal 'latest'" assert_invalid "stable" "the reserved literal 'stable'" @@ -50,6 +63,13 @@ 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" +assert_invalid "-leading-dash-0123456789" "a tag Docker would reject for its leading dash" +assert_invalid ".leading-dot-0123456789" "a tag Docker would reject for its leading dot" + +# A well-formed hash suffix must not be a way to smuggle a tag that reads as +# an official one; these are the checks a format-only validator would miss. +assert_rejected_because "latest-0123456789" "reserved tag" "'latest' dressed up with a hash suffix" +assert_rejected_because "v1.2.3-0123456789" "release tag" "a release tag dressed up with a hash suffix" if [ "$FAILURES" -eq 0 ]; then echo "All validate_image_tag_test.sh checks passed." diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index f9e8efeb04e..41d07aa90a6 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -2,33 +2,26 @@ # wait-for-images.sh # # 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 -# DDEV image, poll the registry for the tags this checkout actually needs -# and wait for them to land. +# test-wsl2-reusable.yml runners hold image-push credentials, so any of them +# can race the image-push.yml GitHub Actions workflow: if this commit needs an +# image tag that only just got built, that tag 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 needs. # -# 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. +# Only the tags this checkout will actually *pull* are waited for, which +# required-image-tag.sh resolves the same way autotag.sh does: # -# Fast path (the common case - nothing changed): one registry check per -# image, no wait. +# - hash unchanged -> versionconstants.go's committed tag is what gets +# pulled, so wait for exactly that, branch prefix and all. Recomputing a +# - tag here would wait forever on most pull requests, +# since nothing pushes a tag under a branch that didn't change the image. +# - hash changed -> `make` rebuilds the image locally on this runner, so +# there is nothing to wait for. +# +# 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) # @@ -37,33 +30,27 @@ 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" -HASH_PATHS_SH="$REPO_ROOT/containers/hash-paths.sh" +REGISTRY_TAG_EXISTS="$SCRIPT_DIR/registry-tag-exists.sh" +REQUIRED_IMAGE_TAG="$SCRIPT_DIR/required-image-tag.sh" DOCKER_ORG="${DOCKER_ORG:-ddev}" ATTEMPTS="${WAIT_FOR_IMAGES_ATTEMPTS:-40}" SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-30}" -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')" - -# 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' -) +# shellcheck source=containers/image-configs.sh +source "$SCRIPT_DIR/image-configs.sh" -for entry in "${CONFIGS[@]}"; do - IFS='|' read -r repo_suffix hash_paths <<< "$entry" - hash="$("$HASH_PATHS_SH" $hash_paths)" - tag="${SANITIZED_BRANCH}-${hash}" +for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r repo_suffix tag_var hash_paths _ <<< "$entry" + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + read -r state tag <<< "$("$REQUIRED_IMAGE_TAG" "$tag_var" $hash_paths)" image_repo="${DOCKER_ORG}/${repo_suffix}" + if [ "$state" != "committed" ]; then + echo "wait-for-images.sh: ${image_repo} content differs from versionconstants.go; make builds ${tag} locally, not waiting" + continue + fi + attempt=1 while true; do if "$REGISTRY_TAG_EXISTS" "$image_repo" "$tag"; then diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh index 902bd3b4ab9..448ee64b01e 100755 --- a/containers/wait_for_images_test.sh +++ b/containers/wait_for_images_test.sh @@ -1,10 +1,10 @@ #!/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 -# 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. +# Exercises the fast-path/retry/give-up logic against a stubbed `docker` and a +# throwaway versionconstants.go, so the committed-vs-recomputed decision can be +# driven both ways without touching this checkout. No real registry, no real +# sleeps. # Run with: # containers/wait_for_images_test.sh @@ -34,13 +34,17 @@ assert_eq() { fi } +# BSD wc pads its output with spaces; GNU wc does not. +count_lines() { + wc -l < "$1" | tr -d '[:space:]' +} + 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. +# --- Stub `docker`: a ref can be configured to "exist" outright, or to only +# start existing after N calls (via a 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" @@ -87,48 +91,96 @@ chmod +x "$BINDIR/sleep" export PATH="$BINDIR:$PATH" 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' -) + +# shellcheck source=containers/image-configs.sh +source "$SCRIPT_DIR/image-configs.sh" + +# --- A throwaway versionconstants.go, so the tags waited for are whatever this +# test says they are rather than whatever the checkout happens to carry. +export VERSIONCONSTANTS_FILE="$WORKDIR/versionconstants.go" +COMMITTED_PREFIX="some_older_branch" + REPOS=() TAGS=() -for entry in "${CONFIGS[@]}"; do - IFS='|' read -r repo_suffix hash_paths <<< "$entry" +HASHES=() +TAG_VARS=() +: > "$VERSIONCONSTANTS_FILE" +for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r repo_suffix tag_var hash_paths _ <<< "$entry" + # shellcheck disable=SC2086 # hash_paths is a space-separated path list hash="$("$HASH_PATHS" $hash_paths)" REPOS+=("ddevhq/${repo_suffix}") - TAGS+=("${BRANCH}-${hash}") + TAGS+=("${COMMITTED_PREFIX}-${hash}") + HASHES+=("$hash") + TAG_VARS+=("$tag_var") + echo "var ${tag_var} = \"${COMMITTED_PREFIX}-${hash}\"" >> "$VERSIONCONSTANTS_FILE" done -# 1. Fast path: every tag already exists -> one docker call per image, no sleep. -: > "$DOCKER_EXISTING_REF_FILE" -for i in "${!REPOS[@]}"; do - echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" -done +write_versionconstants() { + : > "$VERSIONCONSTANTS_FILE" + for i in "${!TAG_VARS[@]}"; do + echo "var ${TAG_VARS[$i]} = \"${TAGS[$i]}\"" >> "$VERSIONCONSTANTS_FILE" + done +} + +mark_all_existing() { + : > "$DOCKER_EXISTING_REF_FILE" + for i in "${!REPOS[@]}"; do + echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" + done +} + +# 1. Fast path: every committed tag already exists -> one docker call per +# image, no sleep. This is the shape of every pull request that doesn't +# change a container image. +mark_all_existing : > "$DOCKER_CALL_LOG" : > "$SLEEP_CALL_LOG" OUTPUT="$("$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? if [ "$RC" -eq 0 ]; then - pass "fast path succeeds when every tag already exists" + pass "fast path succeeds when every committed tag already exists" else - fail "fast path should succeed when every tag already exists" + fail "fast path should succeed when every committed tag already exists: $OUTPUT" 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" +assert_eq "5" "$(count_lines "$DOCKER_CALL_LOG")" "fast path makes exactly one docker call per image" +assert_eq "0" "$(count_lines "$SLEEP_CALL_LOG")" "fast path never sleeps" case "$OUTPUT" in *"found ${REPOS[0]}:${TAGS[0]}"*) pass "prints confirmation for each found tag" ;; *) fail "should print confirmation for each found tag: $OUTPUT" ;; esac -# 2. A tag that's initially missing but becomes available on the 3rd check. +# 2. The regression that made every non-containers pull request hang: the +# committed tag's branch prefix belongs to whatever branch last changed the +# image, and must be waited for as-is rather than recomputed from the +# current branch. +case "$OUTPUT" in + *"${COMMITTED_PREFIX}-${HASHES[0]}"*) pass "waits for the committed tag's own branch prefix" ;; + *) fail "should wait for the committed prefix '${COMMITTED_PREFIX}', not the current branch: $OUTPUT" ;; +esac + +# 3. Content that no longer matches versionconstants.go is built locally by +# make, so there is nothing to wait for and no registry call at all. +TAGS[0]="${COMMITTED_PREFIX}-0000000000" +write_versionconstants +mark_all_existing +: > "$DOCKER_CALL_LOG" +: > "$SLEEP_CALL_LOG" +OUTPUT="$("$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -eq 0 ]; then + pass "succeeds without waiting when content differs from versionconstants.go" +else + fail "should succeed when content differs from versionconstants.go: $OUTPUT" +fi +assert_eq "4" "$(count_lines "$DOCKER_CALL_LOG")" "skips the registry check for the locally-built image" +assert_eq "0" "$(count_lines "$SLEEP_CALL_LOG")" "never sleeps for a locally-built image" +case "$OUTPUT" in + *"not waiting"*) pass "says why it isn't waiting for the changed image" ;; + *) fail "should explain why it isn't waiting: $OUTPUT" ;; +esac +TAGS[0]="${COMMITTED_PREFIX}-${HASHES[0]}" +write_versionconstants + +# 4. A tag that's initially missing but becomes available on the 3rd check. : > "$DOCKER_EXISTING_REF_FILE" for i in "${!REPOS[@]}"; do [ "$i" -eq 4 ] && continue @@ -142,10 +194,10 @@ if WAIT_FOR_IMAGES_ATTEMPTS=5 WAIT_FOR_IMAGES_SLEEP=0 "$WAIT_FOR_IMAGES" >/dev/n 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" +assert_eq "2" "$(count_lines "$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. +# 5. 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=$? @@ -158,20 +210,7 @@ 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. WAIT_FOR_IMAGES_BRANCH is required - a clear, immediate error when unset. -: > "$DOCKER_EXISTING_REF_FILE" -OUTPUT="$(env -u WAIT_FOR_IMAGES_BRANCH "$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? -if [ "$RC" -ne 0 ]; then - pass "errors out when WAIT_FOR_IMAGES_BRANCH is unset" -else - fail "should error out when WAIT_FOR_IMAGES_BRANCH is unset" -fi -case "$OUTPUT" in - *"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 +assert_eq "2" "$(count_lines "$SLEEP_CALL_LOG")" "sleeps exactly (attempts - 1) times before giving up on the first (unavailable) image" if [ "$FAILURES" -eq 0 ]; then echo "All wait_for_images_test.sh checks passed." diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index db272b5363e..356d911e4db 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -314,11 +314,13 @@ When you change an image, running `make` from the repository root builds it loca ### 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. 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. +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 recomputes each image's content hash and resolves the tag this checkout actually needs, exactly the way `make` does — the tag committed in `versionconstants.go` if its hash still matches the content, otherwise a fresh `-` tag. If that tag is already in the registry there's nothing to build, so a pull request that touches `containers/` without changing an image costs one registry lookup per image and no build. + +The same resolution drives `containers/wait-for-images.sh`, which every test runner calls before pulling anything: it waits only for tags this commit will genuinely pull, and doesn't wait at all for an image whose content changed, since `make` builds that one locally on the runner. What happens next depends on whether the PR is from a fork: -* **Fork PRs** (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. +* **Fork PRs** (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. Both the tag and every repository name in that artifact are re-validated first (`containers/validate-image-tag.sh`, `containers/validate-image-repo.sh`), so an approval can only ever publish a hash-shaped tag under a known DDEV repository. A comment is posted on the PR once the push completes. If the build produced nothing to push, no approval is requested at all. * **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** once — for a fork PR's push step — and only when the PR actually changed a container image; everything else is fully automatic. From 1436cab3d4e3a8639b8e98ade91060494a0e1de7 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 09:15:47 -0600 Subject: [PATCH 19/36] temp add HANDOFF.md [skip ci] --- HANDOFF.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000000..825f5dca93c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,204 @@ +# HANDOFF — PR #8707 review fixes (#8609 phase 2) + +Temporary file. Delete before merging. + +Working tree on branch `20260814_rfay_docker_update_phase_2`, uncommitted. +Nothing has been pushed. + +## What changed and why + +### 1. `wait-for-images.sh` waited for a tag nothing ever pushes — blocking + +It computed `-`, but the tag `ddev` actually pulls is the +one committed in `versionconstants.go`. `autotag.sh` rewrites that line only +when the *hash* changes, and when it does it rewrites the whole tag including +the branch prefix — so the two agree only on a branch that changed the image. + +Every test runner calls this script unconditionally, while +`image-build-push.yml` only triggers on `containers/**`. So any PR not touching +`containers/` would poll 20 minutes and fail every Buildkite and GitHub test +job. Reproduced on this branch before the fix: + +```text +versionconstants.go WebTag = 20260721_rfay_content_addressed_image_tags-36bceca65e → EXISTS +wait-for-images.sh computed = 20260814_rfay_docker_update_phase_2-36bceca65e → MISSING +``` + +Fix: new `containers/required-image-tag.sh` resolves the tag the same way +`autotag.sh` does and reports which case applies: + +* `committed ` — hash still matches, so that exact tag (stale branch + prefix and all) is what gets pulled and what must exist in the registry. +* `recomputed ` — content changed, so `make` builds it locally on the + runner and there is nothing to wait for. + +`wait-for-images.sh` no longer needs a branch name at all, which removed the +`WAIT_FOR_IMAGES_BRANCH` plumbing from four callers. + +### 2. `detect` rebuilt and re-pushed unchanged images + +Same root cause: `detect` checked `-`, so any PR touching +`containers/` re-pushed all five images under a fresh branch-prefixed tag even +with no image change — and, on a fork, asked a maintainer to approve that +no-op. It now uses `required-image-tag.sh` too. Verified: `detect` on this PR +now yields `matrix=[]`. + +### 3. Script injection via `github.head_ref` — security + +`BRANCH="${{ github.head_ref || github.ref_name }}"` spliced attacker-controlled +text into a `run:` block (git ref names permit `"`, backtick, `$`, `;`). +`actionlint` flagged it independently. `detect` has no secrets itself, but it +emits `is_fork`; injected code could set `is_fork=false` and route fork content +into `build-and-push`, the job that loads `PUSH_SERVICE_ACCOUNT_TOKEN`. + +Fixed by passing it through `env:`. `is_fork` also moved into its own step, so +nothing the per-image loop does can reach the output that decides whether the +push secret loads. The `WAIT_FOR_IMAGES_BRANCH` removal in (1) also deleted a +PowerShell/bash splice of the same value in `test-wsl2-reusable.yml`. + +### 4. `image-push.yml` validated the tag but not the repository — security + +`repos.txt` comes straight out of the fork-produced artifact and was pushed to +verbatim, so an approved fork build could publish to any repo the credential +can write. New `containers/validate-image-repo.sh` enforces +`$DOCKER_ORG/` (with a pattern for `ddev-dbserver--`). + +### 5. Silent-failure paths in `image-push.yml` + +* Added `actions: read` — `download-artifact@v8` needs it to reach another + run's artifacts, and `continue-on-error: true` was masking that as + "nothing needed pushing". +* New ungated `check-artifacts` job lists artifacts via the API and gates the + environment job, so a fork PR with nothing to push no longer requests an + approval. Approving something that turns out to be a no-op trains people to + click without looking. +* Removed `continue-on-error` from the download; an empty push summary now + fails the job instead of commenting success. +* Artifact `retention-days` 1 → 7. The gate is a human approval that may not + come the same day. + +### 6. `DDEV_IMAGE_TAG` not passed to the builds + +`push-tagged-image.yml` passes it; the new jobs didn't, so +`com.ddev.image-tag` was baked as `-amd64` instead of ``. That label +is what `imageVersionMismatch()` in `pkg/ddevapp/config_custom.go` compares +against, so pinned-image users would have seen spurious mismatch notes. + +### 7. Smaller items + +* `DOCKER_ORG` now falls back to `ddev` in both workflows (was empty on a repo + without the variable, producing `/ddev-webserver`). +* `validate-image-tag.sh`: the reserved-literal and `vX.Y.Z` checks were + unreachable — nothing that reaches them can match. They now test the part + before the hash, so `latest-0123456789` and `v1.2.3-0123456789` are rejected. + Also requires a leading character Docker accepts. +* New `containers/image-configs.sh` is the single source for the image list, + sourced by both `wait-for-images.sh` and `detect` (was duplicated, with + "keep in sync" comments). +* BSD `wc -l` padding broke 4 checks in `wait_for_images_test.sh` and 1 in + `autotag_test.sh` on macOS. Fixed in both. + +## Test status + +All 73 checks pass locally (macOS) and are wired into `container-tests.yml`: + +| Harness | Checks | +| --- | --- | +| `containers/autotag_test.sh` | 17 | +| `containers/required_image_tag_test.sh` | 7 (new) | +| `containers/registry_tag_exists_test.sh` | 4 | +| `containers/validate_image_tag_test.sh` | 15 | +| `containers/validate_image_repo_test.sh` | 16 (new) | +| `containers/wait_for_images_test.sh` | 14 | + +`shellcheck -x` clean on all new/changed scripts. `actionlint` reports no +untrusted-input findings; the remaining SC2086/SC2046 notes in +`test-reusable.yml` are pre-existing and untouched. + +## Verification Claude can do without credentials + +These run against the real registry (read-only, anonymous) and this checkout. +None of them push, and none need Docker running — +`docker buildx imagetools inspect` talks to the registry directly (verified +with `DOCKER_HOST` pointed at a dead socket). + +1. **Unit harnesses** — `for t in containers/*_test.sh; do $t; done`. +2. **The blocking regression** — `WAIT_FOR_IMAGES_ATTEMPTS=1 + containers/wait-for-images.sh` must find all five tags and exit 0. Before + the fix it failed on the first image. +3. **`detect` dry run** — source `containers/image-configs.sh`, loop + `required-image-tag.sh` + `registry-tag-exists.sh`, confirm `matrix=[]` on a + PR that changes no image content. +4. **Changed-image path** — append a line to `containers/ddev-xhgui/Dockerfile`, + re-run (3): only `ddev-xhgui` should say `BUILD ... (recomputed)`, and + `wait-for-images.sh` should skip it with "not waiting" while still finding + the other four. `git checkout` the file afterwards. +5. **Injection** — run (4) with `REQUIRED_IMAGE_TAG_BRANCH='evil"; id; #'`. + Expect the tag `evil-id--` and no command execution. (Done: passes.) +6. **Artifact round-trip against a local registry** — not yet done, and the + most valuable thing left that needs no secrets. Run `registry:2` in a + container, `docker save` a small image the way the `build` job does, write + `repos.txt`/`tag.txt`/`arch.txt`, then run `image-push.yml`'s load/validate/ + push loop against `localhost:5000`. Feed it a hostile `repos.txt` + (`ddev/ddev-webserver`, `attacker/evil`) and confirm + `validate-image-repo.sh` stops it before any push. Exercises the multi-arch + `imagetools create` grouping logic, which no unit test covers. +7. **`make` still builds** — `make` at the repo root, confirm `autotag-images` + no-ops and `versionconstants.go` is untouched. + +Items 1–5 have been run and pass. 6 and 7 have not. + +## Verification only a human can do + +Everything below needs `ddev-test/ddev` with the `image-push` environment and +`PUSH_SERVICE_ACCOUNT_TOKEN` configured. Do not run these against `ddev/ddev`. + +1. **Go-only PR.** A PR touching no `containers/` file. Every test job should + reach "Wait for pushed images", print five `found …` lines within seconds, + and continue. This is the fix for the blocking bug and nothing in CI has + ever exercised it — every commit on this branch carries `[skip ci]`, so the + four green Buildkite checks on #8707 either skipped or predate the change. +2. **No-op `containers/` PR.** Add a file under `containers/` that isn't in any + hash path. `detect` should report five `already exists (committed)` lines + and build nothing. +3. **Real image change, same-repo branch.** Edit + `containers/ddev-xhgui/Dockerfile`, run `make`, commit the + `versionconstants.go` change. Expect: `detect` lists only xhgui → + `build-and-push` runs both arches with no approval → `create-manifests` + comments → `imagetools inspect` shows both platforms and the per-arch tags + are gone. Then confirm the `com.ddev.image-tag` label reads ``, not + `-amd64` (item 6 above). +4. **Real image change, fork branch.** Same edit from a fork. Confirm the + `build` job shows no secret-loading step, that `check-artifacts` finds the + artifacts, that `image-push` requests approval once, and that the download + succeeds — this is the path where the missing `actions: read` would have + shown up as a false "nothing needed pushing". +5. **Fork PR touching `containers/` with no image change.** Confirm *no* + approval request appears (previously it always did). +6. **Adversarial artifact.** On a fork branch, add a step overwriting + `repos.txt` with `ddev/ddev-webserver` before upload. Approve, and confirm + the push job fails at `validate-image-repo.sh` rather than publishing. +7. **Hostile branch name.** Push a fork branch literally named + ``test`touch /tmp/pwned` `` and read the `detect` job log. The branch should + appear only as sanitized data. +8. **Expired artifact.** Trigger a fork build, wait past `retention-days`, then + approve. The run must fail loudly. + +## Still open + +* **The PR description is stale.** It still describes "an `approval` job gates + on a new `image-push` GitHub Environment before any expensive/untrusted build + work runs", which commits a7ec6b5c9 / 2bdced7ef removed. The in-repo docs are + correct. Needs a maintainer edit. +* **`actions: read` on `download-artifact@v8`** is added on the documented + requirement; it has not been observed failing or passing on a live run. + Item 4 above confirms it. +* **Registry pollution has no cleanup path.** Each image change adds a + multi-arch tag that is never removed. Fine for now; worth a follow-up issue + alongside the `TODO(#8609)` about the 18 unbuilt `ddev-dbserver` variants. +* **`registry-tag-exists.sh` cannot distinguish "missing" from "registry + unreachable."** A DockerHub blip costs a redundant rebuild in `detect`, or 20 + minutes and a red build in `wait-for-images.sh`. Acceptable, but it's the + most likely source of a confusing intermittent failure. +* **Commits.** None made — the fixes are uncommitted in the working tree, and + the branch still has one unpushed local commit (`77d90bd97`, empty). From f2c9ee76d960427b6e7847a3e54eef865adcf73b Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 09:40:23 -0600 Subject: [PATCH 20/36] fix(ci): build every db variant when ddev-dbserver changes, for #8609 GetDBImage() builds every variant's reference from one shared BaseDBTag, so a change under containers/ddev-dbserver moves the tag for all 20 at once. But `make` built only mariadb_11.8 and the automatic flow pushed only ddev-dbserver-mariadb-11.8, leaving the other 19 referenced at a tag that existed nowhere. TestDdevAllDatabases and every test pinning a non-default database (db_test.go, snapshot_test.go, config_test.go, debug-migrate-database_test.go) would fail after any dbserver change. Introduced by phase 1 (#8612): before that, BaseDBTag was hand-bumped after someone ran push-tagged-dbimage.yml for all 20, so the tag never moved ahead of the images. detect's matrix now covers every variant. It emits one entry per (image, arch) rather than a cross product, because the four oldest variants are amd64-only, and create-manifests takes its arch list from detect instead of assuming both. Artifact names key on repo_suffix rather than make_dir, which all 20 db variants share and would have collided on. wait-for-images.sh now fails fast, naming the command to run, when a non-locally-built image is out of date. Those 19 variants have no local fallback, and the tag `make` would invent depends on the runner's branch name (detached HEAD on a PR checkout), so it need not match what CI pushed. The variant list was duplicated four ways - this Makefile's three target lists, push-tagged-dbimage.yml's matrix and MULTI_ARCH_IMAGES, its multi-arch case statement, and image-configs.sh. It now lives in containers/ddev-dbserver/variants.txt, rendered per consumer by variants.sh. The generated lists are byte-identical to the ones they replace, verified for both host arches. validate-image-repo.sh drops its name pattern for the exact list, so a correctly-shaped repository that isn't published can't slip past. variants.txt sits inside the hashed dbserver directory deliberately: adding a database version has to change the content hash, or detect would find the tag already present and never build the new variant. That also means this commit moves BaseDBTag, so CI has to push all 20 variants before the non-default database tests can pass - the first live exercise of the fix. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/container-tests.yml | 2 + .github/workflows/image-build-push.yml | 116 ++++++++++------- .github/workflows/push-tagged-dbimage.yml | 74 ++++++----- Makefile | 11 +- containers/db_variants_test.sh | 110 ++++++++++++++++ containers/ddev-dbserver/Makefile | 16 +-- containers/ddev-dbserver/variants.sh | 122 ++++++++++++++++++ containers/ddev-dbserver/variants.txt | 36 ++++++ containers/image-configs.sh | 39 ++++-- containers/validate-image-repo.sh | 13 +- containers/validate_image_repo_test.sh | 7 + containers/wait-for-images.sh | 16 ++- containers/wait_for_images_test.sh | 75 ++++++++--- .../developers/building-contributing.md | 2 + docs/content/developers/release-management.md | 4 +- pkg/versionconstants/versionconstants.go | 2 +- 16 files changed, 514 insertions(+), 131 deletions(-) create mode 100755 containers/db_variants_test.sh create mode 100755 containers/ddev-dbserver/variants.sh create mode 100644 containers/ddev-dbserver/variants.txt diff --git a/.github/workflows/container-tests.yml b/.github/workflows/container-tests.yml index 5481bebacd3..88a78074717 100644 --- a/.github/workflows/container-tests.yml +++ b/.github/workflows/container-tests.yml @@ -47,6 +47,8 @@ jobs: - uses: actions/checkout@v7 - name: Run containers/autotag_test.sh run: containers/autotag_test.sh + - name: Run containers/db_variants_test.sh + run: containers/db_variants_test.sh - name: Run containers/required_image_tag_test.sh run: containers/required_image_tag_test.sh - name: Run containers/registry_tag_exists_test.sh diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index e71a0756f1c..5e5b1ad8fb3 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -42,7 +42,8 @@ jobs: name: Detect changed images runs-on: ubuntu-24.04 outputs: - matrix: ${{ steps.detect.outputs.matrix }} + build_matrix: ${{ steps.detect.outputs.build_matrix }} + manifest_matrix: ${{ steps.detect.outputs.manifest_matrix }} needs_build: ${{ steps.detect.outputs.needs_build }} is_fork: ${{ steps.fork.outputs.is_fork }} steps: @@ -58,9 +59,12 @@ jobs: set -eu -o pipefail source containers/image-configs.sh - MATRIX_JSON="[]" + # build_matrix is one entry per (image, arch) rather than a cross + # product, because the older db variants are amd64-only. + BUILD_JSON="[]" + MANIFEST_JSON="[]" for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do - IFS='|' read -r repo_suffix tag_var hash_paths make_dir make_target arch_suffixed extra_repo_suffixes <<< "$entry" + IFS='|' read -r repo_suffix tag_var hash_paths make_dir make_target arch_suffixed arches _ extra_repo_suffixes <<< "$entry" # shellcheck disable=SC2086 # hash_paths is a space-separated path list read -r state tag <<< "$(containers/required-image-tag.sh "$tag_var" $hash_paths)" repo="${DOCKER_ORG}/${repo_suffix}" @@ -68,19 +72,31 @@ jobs: echo "detect: ${repo}:${tag} already exists (${state}), nothing to build" continue fi - echo "detect: ${repo}:${tag} needs building (${state})" - MATRIX_JSON="$(echo "$MATRIX_JSON" | jq -c \ + echo "detect: ${repo}:${tag} needs building (${state}) for arches: ${arches}" + for arch in $arches; do + BUILD_JSON="$(echo "$BUILD_JSON" | jq -c \ + --arg repo_suffix "$repo_suffix" \ + --arg repo "$repo" \ + --arg tag "$tag" \ + --arg arch "$arch" \ + --arg make_dir "$make_dir" \ + --arg make_target "$make_target" \ + --arg arch_suffixed "$arch_suffixed" \ + --arg extra_repo_suffixes "$extra_repo_suffixes" \ + '. + [{"repo_suffix": $repo_suffix, "repo": $repo, "tag": $tag, "arch": $arch, "make_dir": $make_dir, "make_target": $make_target, "arch_suffixed": $arch_suffixed, "extra_repo_suffixes": $extra_repo_suffixes}]')" + done + MANIFEST_JSON="$(echo "$MANIFEST_JSON" | jq -c \ --arg repo "$repo" \ --arg tag "$tag" \ - --arg make_dir "$make_dir" \ - --arg make_target "$make_target" \ - --arg arch_suffixed "$arch_suffixed" \ + --arg arches "$arches" \ --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}]')" + '. + [{"repo": $repo, "tag": $tag, "arches": $arches, "extra_repo_suffixes": $extra_repo_suffixes}]')" done - echo "matrix=${MATRIX_JSON}" >> "$GITHUB_OUTPUT" - if [ "$(echo "$MATRIX_JSON" | jq 'length')" -gt 0 ]; then + echo "build_matrix=${BUILD_JSON}" >> "$GITHUB_OUTPUT" + echo "manifest_matrix=${MANIFEST_JSON}" >> "$GITHUB_OUTPUT" + echo "detect: $(echo "$MANIFEST_JSON" | jq 'length') image(s) to build, $(echo "$BUILD_JSON" | jq 'length') build job(s)" + if [ "$(echo "$BUILD_JSON" | jq 'length')" -gt 0 ]; then echo "needs_build=true" >> "$GITHUB_OUTPUT" else echo "needs_build=false" >> "$GITHUB_OUTPUT" @@ -108,34 +124,33 @@ jobs: # approval-gated push. --- build: - name: Build ${{ matrix.image.repo }} (${{ matrix.arch }}) + name: Build ${{ matrix.build.repo }} (${{ matrix.build.arch }}) needs: detect if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'true' strategy: fail-fast: false matrix: - image: ${{ fromJson(needs.detect.outputs.matrix) }} - arch: [amd64, arm64] - runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + build: ${{ fromJson(needs.detect.outputs.build_matrix) }} + runs-on: ${{ matrix.build.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} permissions: contents: read steps: - uses: actions/checkout@v7 - - name: Build ${{ matrix.image.repo }}:${{ matrix.image.tag }}-${{ matrix.arch }} + - name: Build ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }} 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 }}" + VERSION="${{ matrix.build.tag }}-${{ matrix.build.arch }}" + MAKE_TARGET="${{ matrix.build.make_target }}" + if [ "${{ matrix.build.arch_suffixed }}" = "true" ]; then + MAKE_TARGET="${MAKE_TARGET}_${{ matrix.build.arch }}" fi # DDEV_IMAGE_TAG is the tag people actually pull, recorded in the # com.ddev.image-tag label; VERSION is the throwaway per-arch tag. - make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" \ - VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.image.tag }}" + make -C "containers/${{ matrix.build.make_dir }}" "$MAKE_TARGET" \ + VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.build.tag }}" - REPOS="${{ matrix.image.repo }}" - for suffix in ${{ matrix.image.extra_repo_suffixes }}; do + REPOS="${{ matrix.build.repo }}" + for suffix in ${{ matrix.build.extra_repo_suffixes }}; do REPOS="${REPOS} ${DOCKER_ORG}/${suffix}" done @@ -146,11 +161,12 @@ jobs: echo "$repo" >> repos.txt done docker save "${REFS[@]}" -o image.tar - echo -n "${{ matrix.image.tag }}" > tag.txt - echo -n "${{ matrix.arch }}" > arch.txt + echo -n "${{ matrix.build.tag }}" > tag.txt + echo -n "${{ matrix.build.arch }}" > arch.txt - uses: actions/upload-artifact@v7 with: - name: image-${{ matrix.image.make_dir }}-${{ matrix.arch }} + # repo_suffix, not make_dir: all 20 db variants share one make_dir. + name: image-${{ matrix.build.repo_suffix }}-${{ matrix.build.arch }} path: | image.tar repos.txt @@ -165,15 +181,14 @@ jobs: # as main-build.yml, which already uses this same secret unguarded. --- build-and-push: - name: Build and push ${{ matrix.image.repo }} (${{ matrix.arch }}) + name: Build and push ${{ matrix.build.repo }} (${{ matrix.build.arch }}) needs: detect if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false' strategy: fail-fast: false matrix: - image: ${{ fromJson(needs.detect.outputs.matrix) }} - arch: [amd64, arm64] - runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + build: ${{ fromJson(needs.detect.outputs.build_matrix) }} + runs-on: ${{ matrix.build.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} steps: - uses: actions/checkout@v7 @@ -191,20 +206,20 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ env.DOCKERHUB_TOKEN }} - - name: Build and push ${{ matrix.image.repo }}:${{ matrix.image.tag }}-${{ matrix.arch }} + - name: Build and push ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }} 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 }}" + VERSION="${{ matrix.build.tag }}-${{ matrix.build.arch }}" + MAKE_TARGET="${{ matrix.build.make_target }}" + if [ "${{ matrix.build.arch_suffixed }}" = "true" ]; then + MAKE_TARGET="${MAKE_TARGET}_${{ matrix.build.arch }}" fi # See the DDEV_IMAGE_TAG note in the fork-side `build` job above. - make -C "containers/${{ matrix.image.make_dir }}" "$MAKE_TARGET" \ - VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.image.tag }}" + make -C "containers/${{ matrix.build.make_dir }}" "$MAKE_TARGET" \ + VERSION="$VERSION" DDEV_IMAGE_TAG="${{ matrix.build.tag }}" - REPOS="${{ matrix.image.repo }}" - for suffix in ${{ matrix.image.extra_repo_suffixes }}; do + REPOS="${{ matrix.build.repo }}" + for suffix in ${{ matrix.build.extra_repo_suffixes }}; do REPOS="${REPOS} ${DOCKER_ORG}/${suffix}" done for repo in $REPOS; do @@ -212,13 +227,13 @@ jobs: done create-manifests: - name: Create manifest for ${{ matrix.image.repo }} + name: Create manifest for ${{ matrix.manifest.repo }} needs: [detect, build-and-push] if: needs.detect.outputs.needs_build == 'true' && needs.detect.outputs.is_fork == 'false' strategy: fail-fast: false matrix: - image: ${{ fromJson(needs.detect.outputs.matrix) }} + manifest: ${{ fromJson(needs.detect.outputs.manifest_matrix) }} runs-on: ubuntu-24.04 permissions: contents: read @@ -244,22 +259,29 @@ jobs: id: manifest run: | set -eu -o pipefail - TAG="${{ matrix.image.tag }}" + TAG="${{ matrix.manifest.tag }}" + # The oldest db variants are amd64-only, so the arch list comes from + # detect rather than being assumed to be both. + ARCHES="${{ matrix.manifest.arches }}" 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="${{ matrix.manifest.repo }}" + for suffix in ${{ matrix.manifest.extra_repo_suffixes }}; do REPOS="${REPOS} ${DOCKER_ORG}/${suffix}" done PUSHED_SUMMARY="" for repo in $REPOS; do - docker buildx imagetools create -t "${repo}:${TAG}" "${repo}:${TAG}-amd64" "${repo}:${TAG}-arm64" + ARCH_TAGS=() + for arch in $ARCHES; do + ARCH_TAGS+=("${repo}:${TAG}-${arch}") + done + docker buildx imagetools create -t "${repo}:${TAG}" "${ARCH_TAGS[@]}" PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${TAG}\`"$'\n' - for arch in amd64 arm64; do + for arch in $ARCHES; do echo "Removing intermediary tag ${repo}:${TAG}-${arch}" curl -s -X DELETE -H "Authorization: JWT ${DOCKERHUB_JWT}" \ "https://hub.docker.com/v2/repositories/${repo}/tags/${TAG}-${arch}/" >/dev/null || true diff --git a/.github/workflows/push-tagged-dbimage.yml b/.github/workflows/push-tagged-dbimage.yml index ef0211c6ecf..0a302b86d0b 100644 --- a/.github/workflows/push-tagged-dbimage.yml +++ b/.github/workflows/push-tagged-dbimage.yml @@ -19,38 +19,50 @@ env: REGISTRY: docker.io DOCKER_ORG: "${{ vars.DOCKER_ORG }}" TAG: "${{ github.event.inputs.tag }}" - MULTI_ARCH_IMAGES: "mariadb_10.1 mariadb_10.2 mariadb_10.3 mariadb_10.4 mariadb_10.5 mariadb_10.6 mariadb_10.7 mariadb_10.8 mariadb_10.11 mariadb_11.4 mariadb_11.8 mariadb_12.3 mysql_5.7 mysql_8.0 mysql_8.4 mysql_9.7" permissions: contents: read jobs: + # The variant matrix and which of them are multi-arch both come from + # containers/ddev-dbserver/variants.txt, shared with that directory's + # Makefile and with image-configs.sh. + variants: + name: Read the db variant matrix + runs-on: ubuntu-24.04 + outputs: + matrix: ${{ steps.variants.outputs.matrix }} + multi_arch_images: ${{ steps.variants.outputs.multi_arch_images }} + steps: + - uses: actions/checkout@v7 + - id: variants + run: | + set -eu -o pipefail + V=containers/ddev-dbserver/variants.sh + echo "matrix=$($V json)" >> "$GITHUB_OUTPUT" + echo "multi_arch_images=$($V multi-arch-variants)" >> "$GITHUB_OUTPUT" + build-db-arch: - name: build ${{ matrix.arch }} ${{ matrix.dbtype }} + name: build ${{ matrix.build.arch }} ${{ matrix.build.dbtype }} + needs: variants strategy: fail-fast: false matrix: - arch: [amd64, arm64] - dbtype: [mariadb_5.5, mariadb_10.0, mariadb_10.1, mariadb_10.2, mariadb_10.3, mariadb_10.4, mariadb_10.5, mariadb_10.6, mariadb_10.7, mariadb_10.8, mariadb_10.11, mariadb_11.4, mariadb_11.8, mariadb_12.3, mysql_5.5, mysql_5.6, mysql_5.7, mysql_8.0, mysql_8.4, mysql_9.7] - # update 'meta' step below if you change this: - exclude: - - arch: arm64 - dbtype: mariadb_5.5 - - arch: arm64 - dbtype: mariadb_10.0 - - arch: arm64 - dbtype: mysql_5.5 - - arch: arm64 - dbtype: mysql_5.6 - runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} + build: ${{ fromJson(needs.variants.outputs.matrix) }} + runs-on: ${{ matrix.build.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }} steps: - name: Determine if multi-arch build id: meta + env: + MULTI_ARCH_IMAGES: ${{ needs.variants.outputs.multi_arch_images }} + DBTYPE: ${{ matrix.build.dbtype }} run: | - case "${{ matrix.dbtype }}" in - mariadb_5.5|mariadb_10.0|mysql_5.5|mysql_5.6) echo "multi_arch=false" >> $GITHUB_OUTPUT ;; - *) echo "multi_arch=true" >> $GITHUB_OUTPUT ;; - esac + set -eu -o pipefail + if [[ " ${MULTI_ARCH_IMAGES} " == *" ${DBTYPE} "* ]]; then + echo "multi_arch=true" >> $GITHUB_OUTPUT + else + echo "multi_arch=false" >> $GITHUB_OUTPUT + fi - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.owner.login == github.repository_owner }} @@ -78,33 +90,33 @@ jobs: - name: Clean up stale arch tag before push run: | TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username":"${{ vars.DOCKERHUB_USERNAME }}","password":"${{ env.DOCKERHUB_TOKEN }}"}' https://hub.docker.com/v2/users/login/ | jq -r .token) - ORG_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${{ matrix.dbtype }}" | tr '_' '-') - echo "Cleaning up stale tag for ${ORG_IMAGE}:${TAG}-${{ matrix.arch }}" - curl -s -X DELETE -H "Authorization: JWT $TOKEN" "https://hub.docker.com/v2/repositories/${ORG_IMAGE}/tags/${TAG}-${{ matrix.arch }}/" >/dev/null || true + ORG_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${{ matrix.build.dbtype }}" | tr '_' '-') + echo "Cleaning up stale tag for ${ORG_IMAGE}:${TAG}-${{ matrix.build.arch }}" + curl -s -X DELETE -H "Authorization: JWT $TOKEN" "https://hub.docker.com/v2/repositories/${ORG_IMAGE}/tags/${TAG}-${{ matrix.build.arch }}/" >/dev/null || true - name: Setup tmate session uses: mxschmitt/action-tmate@v3 with: limit-access-to-actor: true github-token: ${{ secrets.GITHUB_TOKEN }} if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} - - name: Build and push ${{ env.DOCKER_ORG }}/ddev-dbserver-${{ matrix.dbtype }}:${{ env.TAG }} ${{ matrix.arch }} image + - name: Build and push ${{ env.DOCKER_ORG }}/ddev-dbserver-${{ matrix.build.dbtype }}:${{ env.TAG }} ${{ matrix.build.arch }} image run: | set -eu cd containers/ddev-dbserver - target="${{ matrix.dbtype }}_${{ matrix.arch }}" - echo "Building target $target for arch ${{ matrix.arch }}" + target="${{ matrix.build.dbtype }}_${{ matrix.build.arch }}" + echo "Building target $target for arch ${{ matrix.build.arch }}" version="${TAG}" if [ "${{ steps.meta.outputs.multi_arch }}" = "true" ]; then - version="${version}-${{ matrix.arch }}" + version="${version}-${{ matrix.build.arch }}" fi make $target PUSH=true VERSION="${version}" DDEV_IMAGE_TAG="${TAG}" - name: Record image information id: image-info run: | set -eu - INSPECT_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${{ matrix.dbtype }}" | tr '_' '-'):${TAG} + INSPECT_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${{ matrix.build.dbtype }}" | tr '_' '-'):${TAG} if [ "${{ steps.meta.outputs.multi_arch }}" = "true" ]; then - INSPECT_IMAGE=${INSPECT_IMAGE}-${{ matrix.arch }} + INSPECT_IMAGE=${INSPECT_IMAGE}-${{ matrix.build.arch }} fi INSPECT_OUTPUT="" # Wait for image to be available with retry logic @@ -124,13 +136,15 @@ jobs: - name: Upload image info artifact uses: actions/upload-artifact@v7 with: - name: image-info-ddev-dbserver-${{ matrix.dbtype }}-${{ matrix.arch }} + name: image-info-ddev-dbserver-${{ matrix.build.dbtype }}-${{ matrix.build.arch }} path: image-info.txt retention-days: 1 create-manifests: name: create multi-arch db manifests - needs: build-db-arch + needs: [variants, build-db-arch] runs-on: ubuntu-24.04 + env: + MULTI_ARCH_IMAGES: ${{ needs.variants.outputs.multi_arch_images }} steps: - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 diff --git a/Makefile b/Makefile index b7f8fe0d77b..3a9e316d03e 100644 --- a/Makefile +++ b/Makefile @@ -68,12 +68,11 @@ 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 containers/image-configs.sh. 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. +# Only the default db variant (mariadb_11.8) is built here, to keep an +# unrelated rebuild cheap. Every ddev-dbserver variant shares BaseDBTag, so a +# dbserver change moves the tag for all of them at once; CI builds and pushes +# the whole matrix (containers/ddev-dbserver/variants.txt), and the rest are +# pulled from the registry. .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 diff --git a/containers/db_variants_test.sh b/containers/db_variants_test.sh new file mode 100755 index 00000000000..e75a89f4c92 --- /dev/null +++ b/containers/db_variants_test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# db_variants_test.sh - unit tests for ddev-dbserver/variants.sh. +# +# Pure text transforms, no Docker or network. The interesting property is that +# every consumer's view stays mutually consistent, since the whole point of +# variants.txt is that the four of them can't drift apart. +# Run with: +# containers/db_variants_test.sh + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VARIANTS="$SCRIPT_DIR/ddev-dbserver/variants.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 + +# --- A fixture matrix, so these assertions don't move every time a database +# version is added or dropped. +export VARIANTS_FILE="$WORKDIR/variants.txt" +cat > "$VARIANTS_FILE" <<'EOF' +# comment line, and a blank line, both ignored + +mysql_9.7 amd64 arm64 +mysql_5.6 amd64 +mariadb_11.8 amd64 arm64 +mariadb_5.5 amd64 +EOF + +assert_eq "mysql_9.7_both mysql_5.6_amd64 mariadb_11.8_both mariadb_5.5_amd64" \ + "$("$VARIANTS" build-targets amd64)" "build-targets on amd64 covers every variant" +assert_eq "mysql_9.7_both mariadb_11.8_both" \ + "$("$VARIANTS" build-targets arm64)" "build-targets on arm64 omits the amd64-only variants" +assert_eq "mysql_9.7_amd64 mysql_9.7_arm64 mariadb_11.8_amd64 mariadb_11.8_arm64" \ + "$("$VARIANTS" single-arch-targets)" "single-arch-targets covers only multi-arch variants, both ways" +assert_eq "mysql_9.7 mariadb_11.8" \ + "$("$VARIANTS" multi-arch-variants)" "multi-arch-variants is what gets a combined manifest" +assert_eq "mysql_9.7_test mysql_5.6_test mariadb_11.8_test mariadb_5.5_test" \ + "$("$VARIANTS" test-targets amd64)" "test-targets on amd64 covers every variant" +assert_eq "mysql_9.7_test mariadb_11.8_test" \ + "$("$VARIANTS" test-targets arm64)" "test-targets on arm64 omits the amd64-only variants" +assert_eq "ddev-dbserver-mysql-9.7 ddev-dbserver-mysql-5.6 ddev-dbserver-mariadb-11.8 ddev-dbserver-mariadb-5.5" \ + "$("$VARIANTS" repos | tr '\n' ' ' | sed 's/ $//')" "repos names the Docker Hub repositories" +assert_eq "6" "$("$VARIANTS" json | jq 'length')" "json emits one entry per (variant, arch)" +assert_eq "amd64" "$("$VARIANTS" json | jq -r '[.[] | select(.dbtype=="mysql_5.6")] | .[].arch')" \ + "json gives an amd64-only variant exactly one entry" + +if "$VARIANTS" bogus-view >/dev/null 2>&1; then + fail "should reject an unknown view" +else + pass "rejects an unknown view" +fi +if "$VARIANTS" build-targets >/dev/null 2>&1; then + fail "should require a host arch for build-targets" +else + pass "requires a host arch for build-targets" +fi + +# --- Against the real variants.txt: the views have to agree with each other, +# which is what stops the Makefile, image-configs.sh, and the push workflow +# from disagreeing about what exists. +unset VARIANTS_FILE + +real_build_amd64="$("$VARIANTS" build-targets amd64 | wc -w | tr -d '[:space:]')" +real_repos="$("$VARIANTS" repos | wc -l | tr -d '[:space:]')" +assert_eq "$real_build_amd64" "$real_repos" "every variant has a repo and an amd64 build target" + +real_json="$("$VARIANTS" json | jq 'length')" +multi="$("$VARIANTS" multi-arch-variants | wc -w | tr -d '[:space:]')" +single_arch="$(( real_repos - multi ))" +assert_eq "$(( multi * 2 + single_arch ))" "$real_json" "the json matrix is 2 jobs per multi-arch variant plus 1 each for the rest" + +assert_eq "$(( multi * 2 ))" "$("$VARIANTS" single-arch-targets | wc -w | tr -d '[:space:]')" \ + "single-arch-targets is two per multi-arch variant" + +# The default variant `make` builds locally must be in the matrix, or +# image-configs.sh would mark a nonexistent variant as locally built. +if "$VARIANTS" repos | grep -qx "ddev-dbserver-mariadb-11.8"; then + pass "the default variant image-configs.sh builds locally is in the matrix" +else + fail "mariadb_11.8 (image-configs.sh's DDEV_DBSERVER_DEFAULT_TARGET) is missing from variants.txt" +fi + +if [ "$FAILURES" -eq 0 ]; then + echo "All db_variants_test.sh checks passed." + exit 0 +else + echo "$FAILURES db_variants_test.sh check(s) failed." >&2 + exit 1 +fi diff --git a/containers/ddev-dbserver/Makefile b/containers/ddev-dbserver/Makefile index c648aac64f4..74ed2d4f6e8 100644 --- a/containers/ddev-dbserver/Makefile +++ b/containers/ddev-dbserver/Makefile @@ -19,17 +19,11 @@ CURRENT_ARCH=$(shell ../get_arch.sh) # So has to explicitly declare anything it might need from there (like SHELL) SHELL = /bin/bash -BUILD_TARGETS=$(shell if [ "$(CURRENT_ARCH)" = "amd64" ] ; then \ - echo "mysql_9.7_both mysql_8.4_both mysql_8.0_both mysql_5.7_both mysql_5.6_amd64 mysql_5.5_amd64 mariadb_12.3_both mariadb_11.8_both mariadb_11.4_both mariadb_10.11_both mariadb_10.8_both mariadb_10.7_both mariadb_10.6_both mariadb_10.5_both mariadb_10.4_both mariadb_10.3_both mariadb_10.2_both mariadb_10.1_both mariadb_10.0_amd64 mariadb_5.5_amd64"; \ -else \ - echo "mysql_9.7_both mysql_8.4_both mysql_8.0_both mysql_5.7_both mariadb_12.3_both mariadb_11.8_both mariadb_11.4_both mariadb_10.11_both mariadb_10.8_both mariadb_10.7_both mariadb_10.6_both mariadb_10.5_both mariadb_10.4_both mariadb_10.3_both mariadb_10.2_both mariadb_10.1_both"; \ -fi ) -SINGLE_ARCH_TARGETS=mysql_9.7_amd64 mysql_9.7_arm64 mysql_8.4_amd64 mysql_8.4_arm64 mysql_8.0_amd64 mysql_8.0_arm64 mysql_5.7_amd64 mysql_5.7_arm64 mariadb_12.3_amd64 mariadb_12.3_arm64 mariadb_11.8_amd64 mariadb_11.8_arm64 mariadb_11.4_amd64 mariadb_11.4_arm64 mariadb_10.11_amd64 mariadb_10.11_arm64 mariadb_10.8_amd64 mariadb_10.8_arm64 mariadb_10.7_amd64 mariadb_10.7_arm64 mariadb_10.6_amd64 mariadb_10.6_arm64 mariadb_10.5_amd64 mariadb_10.5_arm64 mariadb_10.4_amd64 mariadb_10.4_arm64 mariadb_10.3_amd64 mariadb_10.3_arm64 mariadb_10.2_amd64 mariadb_10.2_arm64 mariadb_10.1_amd64 mariadb_10.1_arm64 -TEST_TARGETS=$(shell if [ "$(CURRENT_ARCH)" = "amd64" ] ; then \ - echo "mysql_9.7_test mysql_8.4_test mysql_8.0_test mysql_5.7_test mysql_5.6_test mysql_5.5_test mariadb_12.3_test mariadb_11.8_test mariadb_11.4_test mariadb_10.11_test mariadb_10.8_test mariadb_10.7_test mariadb_10.6_test mariadb_10.5_test mariadb_10.4_test mariadb_10.3_test mariadb_10.2_test mariadb_10.1_test mariadb_10.0_test mariadb_5.5_test"; \ -else \ - echo "mysql_9.7_test mysql_8.4_test mysql_8.0_test mysql_5.7_test mariadb_12.3_test mariadb_11.8_test mariadb_11.4_test mariadb_10.11_test mariadb_10.8_test mariadb_10.7_test mariadb_10.6_test mariadb_10.5_test mariadb_10.4_test mariadb_10.3_test mariadb_10.2_test mariadb_10.1_test"; \ -fi ) +# The variant matrix lives in variants.txt - see variants.sh for the views. +VARIANTS_SH=./variants.sh +BUILD_TARGETS=$(shell $(VARIANTS_SH) build-targets $(CURRENT_ARCH)) +SINGLE_ARCH_TARGETS=$(shell $(VARIANTS_SH) single-arch-targets) +TEST_TARGETS=$(shell $(VARIANTS_SH) test-targets $(CURRENT_ARCH)) container: build diff --git a/containers/ddev-dbserver/variants.sh b/containers/ddev-dbserver/variants.sh new file mode 100755 index 00000000000..f32c27622b6 --- /dev/null +++ b/containers/ddev-dbserver/variants.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# variants.sh [] +# +# Renders the variant matrix in variants.txt for each of its consumers, so the +# list lives in exactly one place. Views: +# +# build-targets make targets for a `make build` on that host: +# _both, or _ for a +# variant that only builds on one. Variants the +# host can't build are omitted. +# single-arch-targets _amd64 _arm64 for every +# multi-arch variant, for one-arch-at-a-time +# builds (a multi-arch push builds each side +# separately, then combines them). +# multi-arch-variants the variants that get a combined manifest +# test-targets _test for what the host can build +# list | for shell consumers +# repos ddev-dbserver-- repository names +# json [{"dbtype":…,"arch":…}, …] for a GitHub matrix + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VARIANTS_FILE="${VARIANTS_FILE:-$SCRIPT_DIR/variants.txt}" + +if [ "$#" -lt 1 ]; then + echo "Usage: $0 [host-arch]" >&2 + exit 2 +fi + +VIEW="$1" +HOST_ARCH="${2:-}" + +VARIANTS=() +ARCHES=() +while read -r variant arches; do + [ -z "$variant" ] && continue + case "$variant" in \#*) continue ;; esac + VARIANTS+=("$variant") + ARCHES+=("$arches") +done < "$VARIANTS_FILE" + +if [ "${#VARIANTS[@]}" -eq 0 ]; then + echo "$0: no variants found in $VARIANTS_FILE" >&2 + exit 1 +fi + +host_can_build() { + local arches="$1" + [ -z "$HOST_ARCH" ] && return 0 + [[ " $arches " == *" $HOST_ARCH "* ]] +} + +require_host_arch() { + if [ -z "$HOST_ARCH" ]; then + echo "$0: $VIEW needs a host arch argument" >&2 + exit 2 + fi +} + +OUT=() +for i in "${!VARIANTS[@]}"; do + variant="${VARIANTS[$i]}" + arches="${ARCHES[$i]}" + # shellcheck disable=SC2086 # arches is a space-separated list + set -- $arches + arch_count="$#" + + case "$VIEW" in + build-targets) + require_host_arch + host_can_build "$arches" || continue + if [ "$arch_count" -gt 1 ]; then + OUT+=("${variant}_both") + else + OUT+=("${variant}_$1") + fi + ;; + single-arch-targets) + [ "$arch_count" -gt 1 ] || continue + for arch in $arches; do + OUT+=("${variant}_${arch}") + done + ;; + multi-arch-variants) + [ "$arch_count" -gt 1 ] || continue + OUT+=("$variant") + ;; + test-targets) + require_host_arch + host_can_build "$arches" || continue + OUT+=("${variant}_test") + ;; + list) + OUT+=("${variant}|${arches}") + ;; + repos) + OUT+=("ddev-dbserver-${variant/_/-}") + ;; + json) + for arch in $arches; do + OUT+=("$(printf '{"dbtype":"%s","arch":"%s"}' "$variant" "$arch")") + done + ;; + *) + echo "$0: unknown view '$VIEW'" >&2 + exit 2 + ;; + esac +done + +case "$VIEW" in + json) + printf '[%s]\n' "$(IFS=,; echo "${OUT[*]}")" + ;; + list|repos) + printf '%s\n' "${OUT[@]}" + ;; + *) + echo "${OUT[*]}" + ;; +esac diff --git a/containers/ddev-dbserver/variants.txt b/containers/ddev-dbserver/variants.txt new file mode 100644 index 00000000000..7e845692b32 --- /dev/null +++ b/containers/ddev-dbserver/variants.txt @@ -0,0 +1,36 @@ +# The ddev-dbserver variant matrix - one line per variant: +# +# _ [ ...] +# +# Single source of truth, read through variants.sh in this directory. Order is +# significant: it's the order build targets are emitted in. +# +# Consumers: +# containers/ddev-dbserver/Makefile BUILD/SINGLE_ARCH/TEST targets +# containers/image-configs.sh automatic build/push flow +# containers/validate-image-repo.sh push allowlist +# .github/workflows/push-tagged-dbimage.yml manual push matrix +# +# Adding or removing a line changes this directory's content hash, so every +# variant is rebuilt and re-pushed under a new tag - which is what has to +# happen, since they all share BaseDBTag. +mysql_9.7 amd64 arm64 +mysql_8.4 amd64 arm64 +mysql_8.0 amd64 arm64 +mysql_5.7 amd64 arm64 +mysql_5.6 amd64 +mysql_5.5 amd64 +mariadb_12.3 amd64 arm64 +mariadb_11.8 amd64 arm64 +mariadb_11.4 amd64 arm64 +mariadb_10.11 amd64 arm64 +mariadb_10.8 amd64 arm64 +mariadb_10.7 amd64 arm64 +mariadb_10.6 amd64 arm64 +mariadb_10.5 amd64 arm64 +mariadb_10.4 amd64 arm64 +mariadb_10.3 amd64 arm64 +mariadb_10.2 amd64 arm64 +mariadb_10.1 amd64 arm64 +mariadb_10.0 amd64 +mariadb_5.5 amd64 diff --git a/containers/image-configs.sh b/containers/image-configs.sh index 5b61b614cb4..d08b6d70ea7 100644 --- a/containers/image-configs.sh +++ b/containers/image-configs.sh @@ -12,16 +12,39 @@ # make_dir directory under containers/ to run make in # make_target make target that builds it # arch_suffixed "true" if the target name takes an _ suffix +# arches space-separated architectures to build +# built_by_make "true" if `make` at the repo root builds it locally; +# a "false" image can only come from the registry # extra_repo_suffixes further repositories the same target produces -# -# TODO(#8609): only the default db variant (mariadb_11.8) is listed - see the -# TODO on autotag-images in the top-level Makefile. # shellcheck disable=SC2034 # consumed by whatever sources this file DDEV_IMAGE_CONFIGS=( - 'ddev-webserver|WebTag|containers/ddev-webserver containers/containers_shared.mk|ddev-webserver|images|false|ddev-webserver-prod' - 'ddev-traefik-router|TraefikRouterTag|containers/ddev-traefik-router containers/containers_shared.mk|ddev-traefik-router|container|false|' - 'ddev-ssh-agent|SSHAuthTag|containers/ddev-ssh-agent containers/containers_shared.mk|ddev-ssh-agent|container|false|' - 'ddev-xhgui|XhguiTag|containers/ddev-xhgui containers/containers_shared.mk|ddev-xhgui|container|false|' - 'ddev-dbserver-mariadb-11.8|BaseDBTag|containers/ddev-dbserver containers/get_arch.sh|ddev-dbserver|mariadb_11.8|true|' + 'ddev-webserver|WebTag|containers/ddev-webserver containers/containers_shared.mk|ddev-webserver|images|false|amd64 arm64|true|ddev-webserver-prod' + 'ddev-traefik-router|TraefikRouterTag|containers/ddev-traefik-router containers/containers_shared.mk|ddev-traefik-router|container|false|amd64 arm64|true|' + 'ddev-ssh-agent|SSHAuthTag|containers/ddev-ssh-agent containers/containers_shared.mk|ddev-ssh-agent|container|false|amd64 arm64|true|' + 'ddev-xhgui|XhguiTag|containers/ddev-xhgui containers/containers_shared.mk|ddev-xhgui|container|false|amd64 arm64|true|' ) + +# Every ddev-dbserver variant shares one BaseDBTag (see GetDBImage() in +# pkg/docker/images.go), so a change under containers/ddev-dbserver moves the +# tag for all of them at once and every variant has to be rebuilt and pushed +# together - otherwise TestDdevAllDatabases and every test pinning a +# non-default database pulls a tag nobody published. `make` builds only the +# default variant locally, to keep an unrelated rebuild cheap; the rest exist +# only in the registry. +# +# The variant list comes from containers/ddev-dbserver/variants.txt via +# variants.sh, shared with that directory's Makefile and with +# push-tagged-dbimage.yml. +DDEV_DBSERVER_HASH_PATHS='containers/ddev-dbserver containers/get_arch.sh' +DDEV_DBSERVER_DEFAULT_TARGET='mariadb_11.8' + +while IFS='|' read -r _target _arches; do + [ -z "$_target" ] && continue + _built_by_make=false + [ "$_target" = "$DDEV_DBSERVER_DEFAULT_TARGET" ] && _built_by_make=true + DDEV_IMAGE_CONFIGS+=( + "ddev-dbserver-${_target/_/-}|BaseDBTag|${DDEV_DBSERVER_HASH_PATHS}|ddev-dbserver|${_target}|true|${_arches}|${_built_by_make}|" + ) +done < <("$(dirname "${BASH_SOURCE[0]}")/ddev-dbserver/variants.sh" list) +unset _target _arches _built_by_make diff --git a/containers/validate-image-repo.sh b/containers/validate-image-repo.sh index 61097e3a227..a17ab7c4a35 100755 --- a/containers/validate-image-repo.sh +++ b/containers/validate-image-repo.sh @@ -13,6 +13,8 @@ set -eu -o pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + ALLOWED_SUFFIXES=( ddev-webserver ddev-webserver-prod @@ -21,6 +23,13 @@ ALLOWED_SUFFIXES=( ddev-xhgui ) +# The db repositories are exactly the variants in +# containers/ddev-dbserver/variants.txt - no pattern match, so a repository +# this project doesn't publish can't slip through on shape alone. +while IFS= read -r _repo; do + [ -n "$_repo" ] && ALLOWED_SUFFIXES+=("$_repo") +done < <("$SCRIPT_DIR/ddev-dbserver/variants.sh" repos) + if [ "$#" -ne 1 ]; then echo "Usage: $0 " >&2 exit 2 @@ -42,9 +51,5 @@ for allowed in "${ALLOWED_SUFFIXES[@]}"; do fi done -if [[ "$SUFFIX" =~ ^ddev-dbserver-(mariadb|mysql)-[0-9]+\.[0-9]+$ ]]; then - exit 0 -fi - echo "validate-image-repo.sh: '${REPO}' is not one of the repositories this flow may push" >&2 exit 1 diff --git a/containers/validate_image_repo_test.sh b/containers/validate_image_repo_test.sh index 36a1e3f36e1..33e88a3b6b8 100755 --- a/containers/validate_image_repo_test.sh +++ b/containers/validate_image_repo_test.sh @@ -53,9 +53,16 @@ assert_invalid "someoneelse/ddev-webserver" "another organization" assert_invalid "ddev-webserver" "a bare name with no organization" assert_invalid "ddev/ddev-webserver-evil" "an unknown repository in the right org" assert_invalid "ddev/ddev-dbserver-postgres-16" "a db engine the flow doesn't build" +assert_invalid "ddev/ddev-dbserver-mariadb-99.9" "a correctly-shaped db variant that isn't in variants.txt" assert_invalid "ddev/../../etc/passwd" "a path-traversal attempt" assert_invalid "ddevhq/ddev-webserver" "an org that merely starts the same" +# The db repositories are the variants themselves, so the allowlist tracks +# containers/ddev-dbserver/variants.txt rather than a name pattern. +while IFS= read -r suffix; do + [ -n "$suffix" ] && assert_valid "ddev/${suffix}" +done < <("$SCRIPT_DIR/ddev-dbserver/variants.sh" repos) + # The org comes from a trusted workflow variable, so a differing DOCKER_ORG # must move the whole allowlist rather than widen it. if DOCKER_ORG=ddevhq "$VALIDATE" "ddevhq/ddev-webserver" >/dev/null 2>&1; then diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index 41d07aa90a6..ed9596e9b05 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -41,14 +41,24 @@ SLEEP_SECONDS="${WAIT_FOR_IMAGES_SLEEP:-30}" source "$SCRIPT_DIR/image-configs.sh" for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do - IFS='|' read -r repo_suffix tag_var hash_paths _ <<< "$entry" + IFS='|' read -r repo_suffix tag_var hash_paths _ _ _ _ built_by_make _ <<< "$entry" # shellcheck disable=SC2086 # hash_paths is a space-separated path list read -r state tag <<< "$("$REQUIRED_IMAGE_TAG" "$tag_var" $hash_paths)" image_repo="${DOCKER_ORG}/${repo_suffix}" if [ "$state" != "committed" ]; then - echo "wait-for-images.sh: ${image_repo} content differs from versionconstants.go; make builds ${tag} locally, not waiting" - continue + if [ "$built_by_make" = "true" ]; then + echo "wait-for-images.sh: ${image_repo} content differs from versionconstants.go; make builds ${tag} locally, not waiting" + continue + fi + # No local build to fall back on, and the tag `make` will invent here + # depends on this runner's branch name (detached HEAD on a PR checkout), + # so it may not match what image-build-push.yml pushed. Fail now with + # something actionable instead of timing out on a tag nobody pushed. + echo "wait-for-images.sh: ${image_repo} content differs from the tag committed in versionconstants.go," >&2 + echo "wait-for-images.sh: and make does not build this image locally." >&2 + echo "wait-for-images.sh: run 'make' and commit the ${tag_var} change in pkg/versionconstants/versionconstants.go." >&2 + exit 1 fi attempt=1 diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh index 448ee64b01e..c99c6eb5b55 100755 --- a/containers/wait_for_images_test.sh +++ b/containers/wait_for_images_test.sh @@ -101,35 +101,39 @@ export VERSIONCONSTANTS_FILE="$WORKDIR/versionconstants.go" COMMITTED_PREFIX="some_older_branch" REPOS=() -TAGS=() -HASHES=() TAG_VARS=() -: > "$VERSIONCONSTANTS_FILE" +declare -A HASH_BY_VAR +declare -A TAG_BY_VAR for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do IFS='|' read -r repo_suffix tag_var hash_paths _ <<< "$entry" - # shellcheck disable=SC2086 # hash_paths is a space-separated path list - hash="$("$HASH_PATHS" $hash_paths)" + if [ -z "${HASH_BY_VAR[$tag_var]:-}" ]; then + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + HASH_BY_VAR["$tag_var"]="$("$HASH_PATHS" $hash_paths)" + TAG_BY_VAR["$tag_var"]="${COMMITTED_PREFIX}-${HASH_BY_VAR[$tag_var]}" + fi REPOS+=("ddevhq/${repo_suffix}") - TAGS+=("${COMMITTED_PREFIX}-${hash}") - HASHES+=("$hash") TAG_VARS+=("$tag_var") - echo "var ${tag_var} = \"${COMMITTED_PREFIX}-${hash}\"" >> "$VERSIONCONSTANTS_FILE" done +IMAGE_COUNT="${#REPOS[@]}" +# All 20 ddev-dbserver variants share BaseDBTag, so the file has one line per +# distinct tag variable, not one per image. write_versionconstants() { : > "$VERSIONCONSTANTS_FILE" - for i in "${!TAG_VARS[@]}"; do - echo "var ${TAG_VARS[$i]} = \"${TAGS[$i]}\"" >> "$VERSIONCONSTANTS_FILE" + for var in "${!TAG_BY_VAR[@]}"; do + echo "var ${var} = \"${TAG_BY_VAR[$var]}\"" >> "$VERSIONCONSTANTS_FILE" done } mark_all_existing() { : > "$DOCKER_EXISTING_REF_FILE" for i in "${!REPOS[@]}"; do - echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" + echo "${REPOS[$i]}:${TAG_BY_VAR[${TAG_VARS[$i]}]}" >> "$DOCKER_EXISTING_REF_FILE" done } +write_versionconstants + # 1. Fast path: every committed tag already exists -> one docker call per # image, no sleep. This is the shape of every pull request that doesn't # change a container image. @@ -142,10 +146,10 @@ if [ "$RC" -eq 0 ]; then else fail "fast path should succeed when every committed tag already exists: $OUTPUT" fi -assert_eq "5" "$(count_lines "$DOCKER_CALL_LOG")" "fast path makes exactly one docker call per image" +assert_eq "$IMAGE_COUNT" "$(count_lines "$DOCKER_CALL_LOG")" "fast path makes exactly one docker call per image" assert_eq "0" "$(count_lines "$SLEEP_CALL_LOG")" "fast path never sleeps" case "$OUTPUT" in - *"found ${REPOS[0]}:${TAGS[0]}"*) pass "prints confirmation for each found tag" ;; + *"found ${REPOS[0]}:${TAG_BY_VAR[WebTag]}"*) pass "prints confirmation for each found tag" ;; *) fail "should print confirmation for each found tag: $OUTPUT" ;; esac @@ -154,13 +158,23 @@ esac # image, and must be waited for as-is rather than recomputed from the # current branch. case "$OUTPUT" in - *"${COMMITTED_PREFIX}-${HASHES[0]}"*) pass "waits for the committed tag's own branch prefix" ;; + *"${COMMITTED_PREFIX}-${HASH_BY_VAR[WebTag]}"*) pass "waits for the committed tag's own branch prefix" ;; *) fail "should wait for the committed prefix '${COMMITTED_PREFIX}', not the current branch: $OUTPUT" ;; esac +# 2b. Every ddev-dbserver variant is waited for, not just the default one that +# `make` builds locally - they all share BaseDBTag, so a dbserver change +# invalidates all of them at once. +for variant_repo in ddevhq/ddev-dbserver-mysql-8.0 ddevhq/ddev-dbserver-mariadb-10.11 ddevhq/ddev-dbserver-mariadb-5.5; do + case "$OUTPUT" in + *"found ${variant_repo}:"*) pass "waits for the non-default variant ${variant_repo}" ;; + *) fail "should wait for the non-default variant ${variant_repo}: $OUTPUT" ;; + esac +done + # 3. Content that no longer matches versionconstants.go is built locally by # make, so there is nothing to wait for and no registry call at all. -TAGS[0]="${COMMITTED_PREFIX}-0000000000" +TAG_BY_VAR[WebTag]="${COMMITTED_PREFIX}-0000000000" write_versionconstants mark_all_existing : > "$DOCKER_CALL_LOG" @@ -171,22 +185,43 @@ if [ "$RC" -eq 0 ]; then else fail "should succeed when content differs from versionconstants.go: $OUTPUT" fi -assert_eq "4" "$(count_lines "$DOCKER_CALL_LOG")" "skips the registry check for the locally-built image" +assert_eq "$(( IMAGE_COUNT - 1 ))" "$(count_lines "$DOCKER_CALL_LOG")" "skips the registry check for the locally-built image" assert_eq "0" "$(count_lines "$SLEEP_CALL_LOG")" "never sleeps for a locally-built image" case "$OUTPUT" in *"not waiting"*) pass "says why it isn't waiting for the changed image" ;; *) fail "should explain why it isn't waiting: $OUTPUT" ;; esac -TAGS[0]="${COMMITTED_PREFIX}-${HASHES[0]}" +TAG_BY_VAR[WebTag]="${COMMITTED_PREFIX}-${HASH_BY_VAR[WebTag]}" +write_versionconstants + +# 3b. A changed dbserver with a stale versionconstants.go is the case that has +# no local fallback: `make` builds only the default variant, so the other +# 19 could only come from a tag this runner can't predict. Fail fast rather +# than time out. +TAG_BY_VAR[BaseDBTag]="${COMMITTED_PREFIX}-0000000000" +write_versionconstants +mark_all_existing +OUTPUT="$("$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -ne 0 ]; then + pass "fails fast when a non-locally-built image is out of date" +else + fail "should fail when a non-locally-built image is out of date: $OUTPUT" +fi +case "$OUTPUT" in + *"run 'make' and commit the BaseDBTag change"*) pass "names the variable to regenerate" ;; + *) fail "should tell the contributor to run make and commit BaseDBTag: $OUTPUT" ;; +esac +TAG_BY_VAR[BaseDBTag]="${COMMITTED_PREFIX}-${HASH_BY_VAR[BaseDBTag]}" write_versionconstants # 4. A tag that's initially missing but becomes available on the 3rd check. +LAST=$(( IMAGE_COUNT - 1 )) : > "$DOCKER_EXISTING_REF_FILE" for i in "${!REPOS[@]}"; do - [ "$i" -eq 4 ] && continue - echo "${REPOS[$i]}:${TAGS[$i]}" >> "$DOCKER_EXISTING_REF_FILE" + [ "$i" -eq "$LAST" ] && continue + echo "${REPOS[$i]}:${TAG_BY_VAR[${TAG_VARS[$i]}]}" >> "$DOCKER_EXISTING_REF_FILE" done -echo "${REPOS[4]}:${TAGS[4]}" > "$DOCKER_DELAYED_REF_FILE" +echo "${REPOS[$LAST]}:${TAG_BY_VAR[${TAG_VARS[$LAST]}]}" > "$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 diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 356d911e4db..615bc6d78fe 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -318,6 +318,8 @@ Opening a pull request that touches `containers/` triggers the [Image build](htt The same resolution drives `containers/wait-for-images.sh`, which every test runner calls before pulling anything: it waits only for tags this commit will genuinely pull, and doesn't wait at all for an image whose content changed, since `make` builds that one locally on the runner. +Changing `containers/ddev-dbserver` is the one case where `make` alone isn't enough. Every database variant shares a single `BaseDBTag`, so a dbserver change moves the tag for all 20 of them at once, while `make` only builds the default `mariadb_11.8` locally — the other 19 can only come from the registry. CI builds and pushes the whole matrix (36 jobs, from `containers/ddev-dbserver/variants.txt`), so **run `make` and commit the `versionconstants.go` change**; `wait-for-images.sh` stops the test run with that instruction if you don't. + What happens next depends on whether the PR is from a fork: * **Fork PRs** (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. Both the tag and every repository name in that artifact are re-validated first (`containers/validate-image-tag.sh`, `containers/validate-image-repo.sh`), so an approval can only ever publish a hash-shaped tag under a known DDEV repository. A comment is posted on the PR once the push completes. If the build produced nothing to push, no approval is requested at all. diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index a06e610970a..53400873b92 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -78,7 +78,9 @@ The following “Repository secret” environment variables must be configured i 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. +The two workflows below (manual `workflow_dispatch`) remain for re-pushing a specific tag — for instance at release time, when every image is re-pushed under a `vX.Y.Z` tag. + +A `containers/ddev-dbserver` change builds and pushes all 20 database variants (36 jobs), because they all share a single `BaseDBTag`. That variant matrix lives in `containers/ddev-dbserver/variants.txt` and is read by `variants.sh`, which also generates that directory's make targets, the automatic flow's image list, and `push-tagged-dbimage.yml`'s matrix — add a database version there and every consumer picks it up. ### One-time setup: the `image-push` GitHub Environment diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index 69f3d6d4869..6609eb6d761 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -26,7 +26,7 @@ var WebTag = "20260721_rfay_content_addressed_image_tags-36bceca65e" // Note tha var DBImg = "ddev/ddev-dbserver" // BaseDBTag is the main tag, DBTag is constructed from it -var BaseDBTag = "20260721_rfay_content_addressed_image_tags-278125d33a" +var BaseDBTag = "20260814_rfay_docker_update_phase_2-1dc90407ef" // TraefikRouterImage is image for router var TraefikRouterImage = "ddev/ddev-traefik-router" From f621ccba9e8c3a704a2b6a111b1327cb767af02f Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 09:40:46 -0600 Subject: [PATCH 21/36] docs: record the db variant matrix as a must-test item in HANDOFF.md [skip ci] Adds the two things that have never run in CI and no harness covers: that a ddev-dbserver change builds and pushes all 20 variants (this PR is itself the first live test, since adding variants.txt moved BaseDBTag), and that push-tagged-dbimage.yml still works after its matrix was moved to variants.sh. Co-Authored-By: Claude Opus 5 (1M context) --- HANDOFF.md | 391 ++++++++++++++++++++++++++++------------------------- 1 file changed, 210 insertions(+), 181 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 825f5dca93c..2881f507bbf 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,203 +2,232 @@ Temporary file. Delete before merging. -Working tree on branch `20260814_rfay_docker_update_phase_2`, uncommitted. -Nothing has been pushed. +Branch `20260814_rfay_docker_update_phase_2`. Nothing has been pushed. -## What changed and why +## MUST TEST BEFORE MERGE -### 1. `wait-for-images.sh` waited for a tag nothing ever pushes — blocking +Two things in here have never run in CI and will not be exercised by any test +harness. Both are cheap to get wrong and expensive to discover after merge. -It computed `-`, but the tag `ddev` actually pulls is the -one committed in `versionconstants.go`. `autotag.sh` rewrites that line only -when the *hash* changes, and when it does it rewrites the whole tag including -the branch prefix — so the two agree only on a branch that changed the image. +### 1. A `containers/ddev-dbserver` change must build and push all 20 variants -Every test runner calls this script unconditionally, while -`image-build-push.yml` only triggers on `containers/**`. So any PR not touching -`containers/` would poll 20 minutes and fail every Buildkite and GitHub test -job. Reproduced on this branch before the fix: +This is the bug that motivated the second round of fixes, and this PR is +itself the first live test of the fix — adding `variants.txt` under +`containers/ddev-dbserver/` changed that directory's hash, so `BaseDBTag` is +now `20260814_rfay_docker_update_phase_2-1dc90407ef` and **CI must build and +push 36 jobs across 20 repositories before any test using a non-default +database can pass.** -```text -versionconstants.go WebTag = 20260721_rfay_content_addressed_image_tags-36bceca65e → EXISTS -wait-for-images.sh computed = 20260814_rfay_docker_update_phase_2-36bceca65e → MISSING +Watch for, on this PR's first real CI run: + +- `detect` reports 36 build jobs / 20 images, not 1. +- `create-manifests` (or `image-push`) produces a multi-arch manifest for + every one of the 20 `ddev/ddev-dbserver-*` repos at that tag, with the four + oldest (`mariadb-5.5`, `mariadb-10.0`, `mysql-5.5`, `mysql-5.6`) amd64-only. +- `TestDdevAllDatabases` passes, along with the tests in `db_test.go`, + `snapshot_test.go`, `config_test.go`, and `debug-migrate-database_test.go` + that pin a non-default database. + +Verify independently of CI's own reporting: + +```bash +TAG=$(grep -E '^var BaseDBTag' pkg/versionconstants/versionconstants.go | sed -E 's/.*"([^"]*)".*/\1/') +for r in $(containers/ddev-dbserver/variants.sh repos); do + printf '%-34s ' "$r"; containers/registry-tag-exists.sh "ddev/$r" "$TAG" && echo EXISTS || echo MISSING +done ``` -Fix: new `containers/required-image-tag.sh` resolves the tag the same way -`autotag.sh` does and reports which case applies: - -* `committed ` — hash still matches, so that exact tag (stale branch - prefix and all) is what gets pulled and what must exist in the registry. -* `recomputed ` — content changed, so `make` builds it locally on the - runner and there is nothing to wait for. - -`wait-for-images.sh` no longer needs a branch name at all, which removed the -`WAIT_FOR_IMAGES_BRANCH` plumbing from four callers. - -### 2. `detect` rebuilt and re-pushed unchanged images - -Same root cause: `detect` checked `-`, so any PR touching -`containers/` re-pushed all five images under a fresh branch-prefixed tag even -with no image change — and, on a fork, asked a maintainer to approve that -no-op. It now uses `required-image-tag.sh` too. Verified: `detect` on this PR -now yields `matrix=[]`. - -### 3. Script injection via `github.head_ref` — security - -`BRANCH="${{ github.head_ref || github.ref_name }}"` spliced attacker-controlled -text into a `run:` block (git ref names permit `"`, backtick, `$`, `;`). -`actionlint` flagged it independently. `detect` has no secrets itself, but it -emits `is_fork`; injected code could set `is_fork=false` and route fork content -into `build-and-push`, the job that loads `PUSH_SERVICE_ACCOUNT_TOKEN`. - -Fixed by passing it through `env:`. `is_fork` also moved into its own step, so -nothing the per-image loop does can reach the output that decides whether the -push secret loads. The `WAIT_FOR_IMAGES_BRANCH` removal in (1) also deleted a -PowerShell/bash splice of the same value in `test-wsl2-reusable.yml`. - -### 4. `image-push.yml` validated the tag but not the repository — security - -`repos.txt` comes straight out of the fork-produced artifact and was pushed to -verbatim, so an approved fork build could publish to any repo the credential -can write. New `containers/validate-image-repo.sh` enforces -`$DOCKER_ORG/` (with a pattern for `ddev-dbserver--`). - -### 5. Silent-failure paths in `image-push.yml` - -* Added `actions: read` — `download-artifact@v8` needs it to reach another - run's artifacts, and `continue-on-error: true` was masking that as - "nothing needed pushing". -* New ungated `check-artifacts` job lists artifacts via the API and gates the - environment job, so a fork PR with nothing to push no longer requests an - approval. Approving something that turns out to be a no-op trains people to - click without looking. -* Removed `continue-on-error` from the download; an empty push summary now - fails the job instead of commenting success. -* Artifact `retention-days` 1 → 7. The gate is a human approval that may not - come the same day. - -### 6. `DDEV_IMAGE_TAG` not passed to the builds - -`push-tagged-image.yml` passes it; the new jobs didn't, so -`com.ddev.image-tag` was baked as `-amd64` instead of ``. That label -is what `imageVersionMismatch()` in `pkg/ddevapp/config_custom.go` compares -against, so pinned-image users would have seen spurious mismatch notes. - -### 7. Smaller items - -* `DOCKER_ORG` now falls back to `ddev` in both workflows (was empty on a repo - without the variable, producing `/ddev-webserver`). -* `validate-image-tag.sh`: the reserved-literal and `vX.Y.Z` checks were - unreachable — nothing that reaches them can match. They now test the part - before the hash, so `latest-0123456789` and `v1.2.3-0123456789` are rejected. - Also requires a leading character Docker accepts. -* New `containers/image-configs.sh` is the single source for the image list, - sourced by both `wait-for-images.sh` and `detect` (was duplicated, with - "keep in sync" comments). -* BSD `wc -l` padding broke 4 checks in `wait_for_images_test.sh` and 1 in - `autotag_test.sh` on macOS. Fixed in both. +Every line must say EXISTS. Any MISSING means the matrix regressed and +non-default database tests will fail. + +### 2. `push-tagged-dbimage.yml` still works after the DRY refactor + +Its matrix, its `MULTI_ARCH_IMAGES` list, and its multi-arch/single-arch +decision were three separate hardcoded copies of the variant list; all three +now come from `variants.sh`. This is the release-time push path, so a mistake +here surfaces during a release. + +Run it manually on `ddev-test/ddev` with a throwaway tag and confirm: + +- The `variants` job runs first and its matrix expands to **36** `build-db-arch` + jobs — the same count as before (20 variants × 2 arches, minus 4 arm64 + exclusions). +- The four amd64-only variants get `multi_arch=false` in their `meta` step and + push an unsuffixed tag; the other 16 get `multi_arch=true` and push + `-amd64`/`-arm64`. +- `create-manifests` combines exactly the 16 multi-arch variants and deletes + the intermediary per-arch tags. +- All 20 repos carry the throwaway tag at the end. + +Locally I confirmed the generated lists are byte-identical to the ones they +replaced (`build-targets` for both host arches, `single-arch-targets`, +`test-targets` for both, and the `MULTI_ARCH_IMAGES` set), and that +`make -n` still resolves a target from each list including the amd64-only +ones under `CURRENT_ARCH=amd64`. That is static equivalence, not a live run. + +## Round 1 fixes — review findings + +### The tag-resolution bug (blocking) + +`wait-for-images.sh` computed `-`, but the tag ddev +pulls is the one committed in `versionconstants.go`, and `autotag.sh` rewrites +that line (branch prefix included) only when the hash changes. The two agree +only on a branch that changed the image, so any PR not touching `containers/` +would poll 20 minutes and fail every Buildkite and GitHub test job. `detect` +had the mirror-image bug: it re-pushed all images under a fresh branch prefix +on any `containers/` change, no-op or not. + +`containers/required-image-tag.sh` now resolves the tag once, for both +callers, reporting `committed` (hash matches — wait for that exact tag) or +`recomputed` (content changed — `make` builds it locally). This also removed +the `WAIT_FOR_IMAGES_BRANCH` plumbing from four callers, since the branch name +is no longer needed. + +### Security + +- `github.head_ref` reached a `run:` block spliced into the script. Git ref + names permit quotes and backticks, and `detect` emits `is_fork`, so injected + code could set `is_fork=false` and route fork content into `build-and-push`, + the job that loads `PUSH_SERVICE_ACCOUNT_TOKEN`. It now arrives via `env:`, + and `is_fork` moved to its own step. +- `image-push.yml` validated the tag but pushed to whatever repository names + the fork-produced artifact listed. `validate-image-repo.sh` constrains them + to `$DOCKER_ORG` plus an exact allowlist. + +### Silent failures + +- `image-push.yml` lacked `actions: read` for a cross-run artifact download, + and `continue-on-error` turned that into a "nothing needed pushing" comment. + A new ungated `check-artifacts` job gates the environment job instead, so a + fork build with nothing to push no longer asks for approval, and a failed + download is now fatal. +- Artifact retention 1 → 7 days; the gate is a human approval. + +### Smaller + +- `DDEV_IMAGE_TAG` was not passed, so `com.ddev.image-tag` recorded + `-` rather than the tag people pull, which + `imageVersionMismatch()` compares against. +- `DOCKER_ORG` falls back to `ddev` instead of producing `/ddev-webserver`. +- `validate-image-tag.sh`'s reserved-literal and `vX.Y.Z` checks were + unreachable behind the format check; they now test the part before the hash. +- BSD `wc -l` padding failed 5 checks on macOS. + +## Round 2 — the db variant matrix + +`GetDBImage()` in `pkg/docker/images.go` builds every variant's reference from +one shared `BaseDBTag`, so a dbserver change moves the tag for all 20 while +`make` built only `mariadb_11.8` and CI pushed only `ddev-dbserver-mariadb-11.8`. +The other 19 were referenced at a tag that existed nowhere. Introduced by +phase 1 (#8612): before that, `BaseDBTag` was hand-bumped after someone ran +`push-tagged-dbimage.yml` for all 20. + +Fixed by making `detect`'s matrix cover every variant. The build matrix is now +one entry per (image, arch) rather than a cross product, because the oldest +variants are amd64-only, and `create-manifests` takes its arch list from +`detect` instead of assuming both. Artifact names key on `repo_suffix`, not +`make_dir` — all 20 db variants share a `make_dir` and would have collided. + +`wait-for-images.sh` now also fails fast, with the command to run, when a +non-locally-built image is out of date. There is no local fallback for those +19 variants, and the tag `make` would invent depends on the runner's branch +name (detached HEAD on a PR checkout), so it may not match what CI pushed. + +### DRY + +The variant list was duplicated in four places. It now lives in +`containers/ddev-dbserver/variants.txt`, read through `variants.sh`, which +renders each consumer's view: + +| Consumer | View | +| --- | --- | +| `containers/ddev-dbserver/Makefile` | `build-targets`, `single-arch-targets`, `test-targets` | +| `containers/image-configs.sh` | `list` | +| `containers/validate-image-repo.sh` | `repos` | +| `.github/workflows/push-tagged-dbimage.yml` | `json`, `multi-arch-variants` | + +`variants.txt` sits inside the hashed dbserver directory on purpose: adding a +database version has to change the content hash, or `detect` would decide the +tag already exists and never build the new variant. ## Test status -All 73 checks pass locally (macOS) and are wired into `container-tests.yml`: +114 checks across seven harnesses, all passing locally (macOS), all wired into +`container-tests.yml`: | Harness | Checks | | --- | --- | | `containers/autotag_test.sh` | 17 | -| `containers/required_image_tag_test.sh` | 7 (new) | +| `containers/db_variants_test.sh` | 15 (new) | | `containers/registry_tag_exists_test.sh` | 4 | +| `containers/required_image_tag_test.sh` | 7 (new) | +| `containers/validate_image_repo_test.sh` | 37 (new) | | `containers/validate_image_tag_test.sh` | 15 | -| `containers/validate_image_repo_test.sh` | 16 (new) | -| `containers/wait_for_images_test.sh` | 14 | - -`shellcheck -x` clean on all new/changed scripts. `actionlint` reports no -untrusted-input findings; the remaining SC2086/SC2046 notes in -`test-reusable.yml` are pre-existing and untouched. - -## Verification Claude can do without credentials - -These run against the real registry (read-only, anonymous) and this checkout. -None of them push, and none need Docker running — -`docker buildx imagetools inspect` talks to the registry directly (verified -with `DOCKER_HOST` pointed at a dead socket). - -1. **Unit harnesses** — `for t in containers/*_test.sh; do $t; done`. -2. **The blocking regression** — `WAIT_FOR_IMAGES_ATTEMPTS=1 - containers/wait-for-images.sh` must find all five tags and exit 0. Before - the fix it failed on the first image. -3. **`detect` dry run** — source `containers/image-configs.sh`, loop - `required-image-tag.sh` + `registry-tag-exists.sh`, confirm `matrix=[]` on a - PR that changes no image content. -4. **Changed-image path** — append a line to `containers/ddev-xhgui/Dockerfile`, - re-run (3): only `ddev-xhgui` should say `BUILD ... (recomputed)`, and - `wait-for-images.sh` should skip it with "not waiting" while still finding - the other four. `git checkout` the file afterwards. -5. **Injection** — run (4) with `REQUIRED_IMAGE_TAG_BRANCH='evil"; id; #'`. - Expect the tag `evil-id--` and no command execution. (Done: passes.) -6. **Artifact round-trip against a local registry** — not yet done, and the - most valuable thing left that needs no secrets. Run `registry:2` in a - container, `docker save` a small image the way the `build` job does, write - `repos.txt`/`tag.txt`/`arch.txt`, then run `image-push.yml`'s load/validate/ - push loop against `localhost:5000`. Feed it a hostile `repos.txt` - (`ddev/ddev-webserver`, `attacker/evil`) and confirm - `validate-image-repo.sh` stops it before any push. Exercises the multi-arch - `imagetools create` grouping logic, which no unit test covers. -7. **`make` still builds** — `make` at the repo root, confirm `autotag-images` - no-ops and `versionconstants.go` is untouched. - -Items 1–5 have been run and pass. 6 and 7 have not. - -## Verification only a human can do - -Everything below needs `ddev-test/ddev` with the `image-push` environment and -`PUSH_SERVICE_ACCOUNT_TOKEN` configured. Do not run these against `ddev/ddev`. - -1. **Go-only PR.** A PR touching no `containers/` file. Every test job should - reach "Wait for pushed images", print five `found …` lines within seconds, - and continue. This is the fix for the blocking bug and nothing in CI has - ever exercised it — every commit on this branch carries `[skip ci]`, so the - four green Buildkite checks on #8707 either skipped or predate the change. -2. **No-op `containers/` PR.** Add a file under `containers/` that isn't in any - hash path. `detect` should report five `already exists (committed)` lines - and build nothing. -3. **Real image change, same-repo branch.** Edit - `containers/ddev-xhgui/Dockerfile`, run `make`, commit the - `versionconstants.go` change. Expect: `detect` lists only xhgui → - `build-and-push` runs both arches with no approval → `create-manifests` - comments → `imagetools inspect` shows both platforms and the per-arch tags - are gone. Then confirm the `com.ddev.image-tag` label reads ``, not - `-amd64` (item 6 above). -4. **Real image change, fork branch.** Same edit from a fork. Confirm the - `build` job shows no secret-loading step, that `check-artifacts` finds the - artifacts, that `image-push` requests approval once, and that the download - succeeds — this is the path where the missing `actions: read` would have - shown up as a false "nothing needed pushing". -5. **Fork PR touching `containers/` with no image change.** Confirm *no* - approval request appears (previously it always did). -6. **Adversarial artifact.** On a fork branch, add a step overwriting - `repos.txt` with `ddev/ddev-webserver` before upload. Approve, and confirm - the push job fails at `validate-image-repo.sh` rather than publishing. -7. **Hostile branch name.** Push a fork branch literally named - ``test`touch /tmp/pwned` `` and read the `detect` job log. The branch should - appear only as sanitized data. -8. **Expired artifact.** Trigger a fork build, wait past `retention-days`, then - approve. The run must fail loudly. +| `containers/wait_for_images_test.sh` | 19 | + +`shellcheck -x` clean on all new and changed scripts. `actionlint` clean on +all changed workflows apart from pre-existing SC2086/SC2046 notes in untouched +parts of `test-reusable.yml`. + +## Verification Claude ran + +Read-only against the real registry, plus this checkout. Nothing pushed. + +1. All seven harnesses. +2. `wait-for-images.sh` finds the four non-db images and correctly waits on the + db variants at the new `BaseDBTag`. +3. `detect` dry run: `matrix=[]` when nothing changed; 36 build jobs / 20 + manifests after a dbserver change; only xhgui after an xhgui change. +4. Hostile branch name `evil"; id; #` sanitizes to `evil-id-`, no execution. +5. Generated db lists byte-identical to the four hardcoded copies they replace; + `make -n` resolves a target from each list on both host arches. +6. `make autotag-images` built `mariadb_11.8` locally and rewrote `BaseDBTag`. +7. `docker buildx imagetools inspect` works with no Docker daemon running, + so the early placement of the wait step in the Buildkite scripts is fine. + +Not run: the artifact round-trip against a local `registry:2` (worth doing — +it needs no secrets and would exercise the multi-arch `imagetools create` +grouping that no unit test covers), and a full `make` build. + +## Other human verification + +On `ddev-test/ddev`, with the `image-push` environment configured. Confirm the +rule actually took: `gh api repos///environments/image-push` — an +environment referenced before it exists is auto-created with no protection. + +1. **A PR touching no `containers/` file.** Five `found …` lines within seconds, + then the tests run. This is the round-1 blocking fix and has never run in CI. +2. **A `containers/` file that isn't in any hash path.** `detect` reports + `already exists (committed)` for everything and builds nothing. +3. **An `ddev-xhgui` change on a same-repo branch.** Only xhgui builds; no + approval; `com.ddev.image-tag` on the result is the final tag, not + `-amd64`. +4. **The same from a fork.** No secret-loading step in `build`; exactly one + approval; the download succeeds rather than silently commenting + "nothing needed pushing". +5. **A fork PR touching `containers/` with no image change.** No approval + request at all. +6. **Adversarial artifact.** Overwrite `repos.txt` with `ddev/ddev-webserver` + before upload; the push must fail at `validate-image-repo.sh`. +7. **Hostile branch name** ``test`touch /tmp/pwned` ``; check the `detect` log. +8. **Expired artifact.** Approve after `retention-days`; must fail loudly. ## Still open -* **The PR description is stale.** It still describes "an `approval` job gates - on a new `image-push` GitHub Environment before any expensive/untrusted build - work runs", which commits a7ec6b5c9 / 2bdced7ef removed. The in-repo docs are - correct. Needs a maintainer edit. -* **`actions: read` on `download-artifact@v8`** is added on the documented - requirement; it has not been observed failing or passing on a live run. - Item 4 above confirms it. -* **Registry pollution has no cleanup path.** Each image change adds a - multi-arch tag that is never removed. Fine for now; worth a follow-up issue - alongside the `TODO(#8609)` about the 18 unbuilt `ddev-dbserver` variants. -* **`registry-tag-exists.sh` cannot distinguish "missing" from "registry - unreachable."** A DockerHub blip costs a redundant rebuild in `detect`, or 20 - minutes and a red build in `wait-for-images.sh`. Acceptable, but it's the - most likely source of a confusing intermittent failure. -* **Commits.** None made — the fixes are uncommitted in the working tree, and - the branch still has one unpushed local commit (`77d90bd97`, empty). +- **Fork artifact volume.** A fork changing `ddev-dbserver` now produces 36 + `docker save` tarballs. Local db images are 550–730MB, so that is roughly + 20GB of artifacts for one PR, compressed by `upload-artifact` but still + large, and now retained 7 days. This is the cost of building the full matrix + on the untrusted path; if it proves impractical, the fallback is to keep the + full matrix on the non-fork path and have forks use + `push-tagged-dbimage.yml`. Watch the first fork-side dbserver PR. +- **`actions: read` on `download-artifact@v8`** is added on the documented + requirement; not yet observed on a live run. +- **Registry pollution has no cleanup path.** Each image change adds tags that + are never removed — now 20 at a time for a dbserver change. +- **`registry-tag-exists.sh` cannot distinguish "missing" from "registry + unreachable."** A DockerHub blip costs a redundant rebuild in `detect` or 20 + minutes and a red build in `wait-for-images.sh`. It now makes 24 registry + calls per test run instead of 5, so the odds are higher. +- **The PR description** was rewritten for round 1 but does not yet mention the + db variant matrix. From 5363cbb8286ec25e8f4e6eb2dcdaaaed90fb6d03 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 10:11:55 -0600 Subject: [PATCH 22/36] fix(ci): derive the manual push workflows' tag instead of asking for it, for #8609 push-tagged-image.yml and push-tagged-dbimage.yml required a tag input whose description only mentioned release versions (v1.25.0). Since phase 1 they have a second use - publishing the content-addressed tag a branch needs - and that tag is derivable, so typing it by hand is both unnecessary and the way it ends up not matching versionconstants.go. That is exactly what happened pushing the db images for this branch: main-1dc90407ef was entered where the checkout needed 20260814_rfay_docker_update_phase_2-1dc90407ef, and the two don't interoperate. The input is now optional. Left empty, a resolve step derives the tag through the new containers/image-tag-for.sh, a lookup over image-configs.sh, so it matches versionconstants.go by construction. Supplied, it is used as-is, which is still what a release push wants. Either way the result goes through validate-image-tag.sh (or the release-tag shape) before anything is pushed. An image the automatic flow doesn't cover - test-ssh-server - has no hash to derive, so the resolver fails with that explanation rather than inventing one. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push-tagged-dbimage.yml | 26 ++++++++++++++-- .github/workflows/push-tagged-image.yml | 38 ++++++++++++++++++++--- containers/image-tag-for.sh | 38 +++++++++++++++++++++++ containers/required_image_tag_test.sh | 29 +++++++++++++++++ 4 files changed, 124 insertions(+), 7 deletions(-) create mode 100755 containers/image-tag-for.sh diff --git a/.github/workflows/push-tagged-dbimage.yml b/.github/workflows/push-tagged-dbimage.yml index 0a302b86d0b..728e8c8caf6 100644 --- a/.github/workflows/push-tagged-dbimage.yml +++ b/.github/workflows/push-tagged-dbimage.yml @@ -7,8 +7,8 @@ on: workflow_dispatch: inputs: tag: - description: Base tag for pushed dbimage (v1.25.0 for example)' - required: true + description: 'Tag to push (v1.25.0 for a release). Leave empty to use the tag this branch needs.' + required: false default: "" debug_enabled: description: 'Enable debug mode' @@ -18,7 +18,6 @@ on: env: REGISTRY: docker.io DOCKER_ORG: "${{ vars.DOCKER_ORG }}" - TAG: "${{ github.event.inputs.tag }}" permissions: contents: read @@ -33,6 +32,7 @@ jobs: outputs: matrix: ${{ steps.variants.outputs.matrix }} multi_arch_images: ${{ steps.variants.outputs.multi_arch_images }} + tag: ${{ steps.tag.outputs.tag }} steps: - uses: actions/checkout@v7 - id: variants @@ -41,10 +41,29 @@ jobs: V=containers/ddev-dbserver/variants.sh echo "matrix=$($V json)" >> "$GITHUB_OUTPUT" echo "multi_arch_images=$($V multi-arch-variants)" >> "$GITHUB_OUTPUT" + # An empty tag input means "publish what this checkout needs", which is + # derivable; retyping the content hash by hand is how it ends up not + # matching versionconstants.go. + - id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + set -eu -o pipefail + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + echo "Using the tag supplied on the workflow input: ${TAG}" + else + TAG="$(containers/image-tag-for.sh ddev-dbserver-mariadb-11.8)" + echo "No tag supplied; using the tag this checkout needs: ${TAG}" + fi + containers/validate-image-tag.sh "$TAG" || [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" build-db-arch: name: build ${{ matrix.build.arch }} ${{ matrix.build.dbtype }} needs: variants + env: + TAG: ${{ needs.variants.outputs.tag }} strategy: fail-fast: false matrix: @@ -145,6 +164,7 @@ jobs: runs-on: ubuntu-24.04 env: MULTI_ARCH_IMAGES: ${{ needs.variants.outputs.multi_arch_images }} + TAG: ${{ needs.variants.outputs.tag }} steps: - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 diff --git a/.github/workflows/push-tagged-image.yml b/.github/workflows/push-tagged-image.yml index b284511b23e..d1bd8533019 100644 --- a/.github/workflows/push-tagged-image.yml +++ b/.github/workflows/push-tagged-image.yml @@ -18,8 +18,8 @@ on: - ddev-xhgui - test-ssh-server tag: - description: Tag for pushed image (v1.25.0 for example) - required: true + description: 'Tag to push (v1.25.0 for a release). Leave empty to use the tag this branch needs.' + required: false default: "" debug_enabled: description: 'Enable debug mode' @@ -29,14 +29,42 @@ on: env: REGISTRY: docker.io DOCKER_ORG: "${{ vars.DOCKER_ORG }}" - TAG: "${{ github.event.inputs.tag }}" permissions: contents: read jobs: + # An empty tag input means "publish what this checkout needs", which is + # derivable; retyping the content hash by hand is how it ends up not + # matching versionconstants.go. + resolve-tag: + name: Resolve the tag to push + runs-on: ubuntu-24.04 + outputs: + tag: ${{ steps.tag.outputs.tag }} + steps: + - uses: actions/checkout@v7 + - id: tag + env: + INPUT_TAG: ${{ github.event.inputs.tag }} + IMAGE: ${{ github.event.inputs.image }} + run: | + set -eu -o pipefail + if [ -n "$INPUT_TAG" ]; then + TAG="$INPUT_TAG" + echo "Using the tag supplied on the workflow input: ${TAG}" + else + TAG="$(containers/image-tag-for.sh "$IMAGE")" + echo "No tag supplied; using the tag this checkout needs: ${TAG}" + fi + containers/validate-image-tag.sh "$TAG" || [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + build-and-push: name: build ${{ matrix.arch }} ${{ github.event.inputs.image }} + needs: resolve-tag + env: + TAG: ${{ needs.resolve-tag.outputs.tag }} strategy: matrix: arch: [amd64, arm64] @@ -131,9 +159,11 @@ jobs: retention-days: 1 create-manifests: name: create multi-arch manifest - needs: build-and-push + needs: [resolve-tag, build-and-push] runs-on: ubuntu-24.04 if: ${{ needs.build-and-push.outputs.multi_arch == 'true' }} + env: + TAG: ${{ needs.resolve-tag.outputs.tag }} steps: - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 diff --git a/containers/image-tag-for.sh b/containers/image-tag-for.sh new file mode 100755 index 00000000000..0c5abb60077 --- /dev/null +++ b/containers/image-tag-for.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# image-tag-for.sh +# +# Prints the tag this checkout needs for one image, by repository suffix +# (ddev-webserver, ddev-dbserver-mysql-8.0, ...). A thin lookup over +# image-configs.sh so the manual push workflows can default their tag input +# instead of asking a human to retype a content hash - the mismatch that +# causes is the whole reason wait-for-images.sh has to fail loudly. +# +# Exits non-zero for a repository the automatic flow doesn't cover +# (test-ssh-server, say), where there is no content hash to derive and the +# caller has to supply a tag. + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +WANTED="$1" + +# shellcheck source=containers/image-configs.sh +source "$SCRIPT_DIR/image-configs.sh" + +for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r repo_suffix tag_var hash_paths _ <<< "$entry" + [ "$repo_suffix" = "$WANTED" ] || continue + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + read -r _ tag <<< "$("$SCRIPT_DIR/required-image-tag.sh" "$tag_var" $hash_paths)" + echo "$tag" + exit 0 +done + +echo "image-tag-for.sh: '${WANTED}' is not one of the content-addressed images; pass a tag explicitly" >&2 +exit 1 diff --git a/containers/required_image_tag_test.sh b/containers/required_image_tag_test.sh index dddd67bf817..bccb7548097 100755 --- a/containers/required_image_tag_test.sh +++ b/containers/required_image_tag_test.sh @@ -83,6 +83,35 @@ else pass "rejects a missing hash path" fi +# --- image-tag-for.sh, the lookup the manual push workflows use to default +# their tag input. Against the real image-configs.sh and this checkout. +unset VERSIONCONSTANTS_FILE +IMAGE_TAG_FOR="$SCRIPT_DIR/image-tag-for.sh" + +web_tag="$("$IMAGE_TAG_FOR" ddev-webserver)" +web_hash="$("$HASH_PATHS" containers/ddev-webserver containers/containers_shared.mk)" +case "$web_tag" in + *"$web_hash") pass "image-tag-for.sh resolves ddev-webserver to a tag with the current hash" ;; + *) fail "ddev-webserver tag '$web_tag' should end in '$web_hash'" ;; +esac + +# Every db variant shares BaseDBTag, so the lookup has to agree across them. +assert_eq "$("$IMAGE_TAG_FOR" ddev-dbserver-mariadb-11.8)" "$("$IMAGE_TAG_FOR" ddev-dbserver-mysql-8.0)" \ + "image-tag-for.sh gives every db variant the same tag" + +# An image the automatic flow doesn't cover has no hash to derive, so the +# caller has to be told rather than handed a wrong tag. +if "$IMAGE_TAG_FOR" test-ssh-server >/dev/null 2>&1; then + fail "should refuse an image that isn't content-addressed" +else + pass "refuses an image that isn't content-addressed" +fi +if "$IMAGE_TAG_FOR" no-such-image >/dev/null 2>&1; then + fail "should refuse an unknown image" +else + pass "refuses an unknown image" +fi + if [ "$FAILURES" -eq 0 ]; then echo "All required_image_tag_test.sh checks passed." exit 0 From 9d74157dec197ebc181a38c1aee0cbfbf3c002c6 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 11:14:31 -0600 Subject: [PATCH 23/36] feat(images)!: make image tags bare content hashes, for #8609 The tag was -, but only the hash carries meaning: two tags with the same hash suffix are the same content by construction. Storing the whole string in versionconstants.go and matching it exactly turned a decorative prefix into a coordination requirement spanning the branch that built the image, the organization it was pushed to, and every consumer. That is not hypothetical. Pushing this branch's db images to the test org produced main-1dc90407ef while the checkout needed 20260814_rfay_docker_update_phase_2-1dc90407ef - identical content, two strings, no interoperability, and the same shape as workarounds hit before. Tags are now the bare hash. The same content resolves to the same tag on any branch, in any fork, in any organization, so autotag.sh, detect, and every test runner agree with no coordination at all. Readability is preserved three ways rather than in the tag: - autotag.sh writes the - alias as a trailing comment on the versionconstants.go line, so the file still says where the image came from. - A companion TagBranch variable carries the same thing as data, and `ddev version` gains an image-tag-branches row - collapsed to one branch name when every image came from the same one, expanded when they didn't. - Both push paths publish the - alias next to the hash tag off the same manifest, for anyone browsing the registry. The alias branch comes from a trusted context, is sanitized, and goes through validate-image-tag.sh, which now accepts both forms - so a fork branch named v1.25.0 cannot publish something that reads as a release. Comparison against versionconstants.go is now exact rather than a trailing hash match, so a line still in the old form is stale and `make` migrates it. wait-for-images.sh no longer has to fail when a non-locally-built image is out of date: the tag it would need is branch-independent, so it is the same string image-build-push.yml pushes, and waiting for it is well defined. Note for deployment: this invalidates every published content-hash tag, so the first run has to build and push all 24 images (44 jobs). Until it does, integration tests on this branch cannot pull. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/image-build-push.yml | 14 ++++- .github/workflows/image-push.yml | 15 +++++- containers/autotag.sh | 41 +++++++++----- containers/autotag_test.sh | 44 +++++++++------ containers/required-image-tag.sh | 41 +++++++------- containers/required_image_tag_test.sh | 52 ++++++++++-------- containers/validate-image-tag.sh | 18 +++++-- containers/validate_image_tag_test.sh | 9 ++++ containers/wait-for-images.sh | 20 +++---- containers/wait_for_images_test.sh | 54 +++++++++++-------- .../developers/building-contributing.md | 4 +- docs/content/developers/release-management.md | 2 +- pkg/version/version.go | 28 ++++++++++ pkg/versionconstants/versionconstants.go | 33 ++++++++++-- 14 files changed, 251 insertions(+), 124 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 5e5b1ad8fb3..8be92d70389 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -257,12 +257,22 @@ jobs: - name: Create manifest and clean up per-arch tags id: manifest + env: + # Trusted context, not the matrix: this is the readable alias + # published next to the hash tag, so it must not be fork-controlled + # beyond the branch name itself, which is sanitized below. + ALIAS_BRANCH: ${{ github.head_ref || github.ref_name }} run: | set -eu -o pipefail TAG="${{ matrix.manifest.tag }}" # The oldest db variants are amd64-only, so the arch list comes from # detect rather than being assumed to be both. ARCHES="${{ matrix.manifest.arches }}" + ALIAS="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')-${TAG}" + if ! containers/validate-image-tag.sh "$ALIAS"; then + echo "Alias '${ALIAS}' rejected; publishing only ${TAG}" >&2 + ALIAS="" + fi DOCKERHUB_JWT="$(curl -s -H "Content-Type: application/json" -X POST \ -d '{"username":"'"${{ vars.DOCKERHUB_USERNAME }}"'","password":"'"${DOCKERHUB_TOKEN}"'"}' \ @@ -279,7 +289,9 @@ jobs: for arch in $ARCHES; do ARCH_TAGS+=("${repo}:${TAG}-${arch}") done - docker buildx imagetools create -t "${repo}:${TAG}" "${ARCH_TAGS[@]}" + NAMES=(-t "${repo}:${TAG}") + [ -n "$ALIAS" ] && NAMES+=(-t "${repo}:${ALIAS}") + docker buildx imagetools create "${NAMES[@]}" "${ARCH_TAGS[@]}" PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${TAG}\`"$'\n' for arch in $ARCHES; do echo "Removing intermediary tag ${repo}:${TAG}-${arch}" diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index a64df075e06..538957ec765 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -93,8 +93,14 @@ jobs: - name: Validate, load, and push each built image id: push + env: + # From the workflow_run payload, not the artifact - the alias is + # published in the DDEV org, so its branch name has to come from a + # trusted context. Sanitized and validated below regardless. + ALIAS_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | set -eu -o pipefail + SANITIZED_BRANCH="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" declare -A TAG_BY_KEY declare -A REPOS_BY_KEY declare -A ARCHES_BY_KEY @@ -150,7 +156,14 @@ jobs: for arch in ${ARCHES_BY_KEY[$key]}; do arch_tags+=("${repo}:${tag}-${arch}") done - docker buildx imagetools create -t "${repo}:${tag}" "${arch_tags[@]}" + names=(-t "${repo}:${tag}") + alias_tag="${SANITIZED_BRANCH}-${tag}" + if containers/validate-image-tag.sh "$alias_tag"; then + names+=(-t "${repo}:${alias_tag}") + else + echo "Alias '${alias_tag}' rejected; publishing only ${tag}" >&2 + fi + docker buildx imagetools create "${names[@]}" "${arch_tags[@]}" PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${tag}\`"$'\n' for arch in ${ARCHES_BY_KEY[$key]}; do diff --git a/containers/autotag.sh b/containers/autotag.sh index 9d118202783..7efc80afe04 100755 --- a/containers/autotag.sh +++ b/containers/autotag.sh @@ -2,14 +2,20 @@ # autotag.sh [--print-only] [ ...] [-- ] # # Detects whether an image's content hash (see hash-paths.sh) differs from -# the hash embedded in the tag currently committed in -# pkg/versionconstants/versionconstants.go. If unchanged, does nothing (no -# Docker, no network). If changed, builds the image locally at the new -# - tag (unless already built) using the given build command, -# then rewrites just that tag's line in versionconstants.go in place. +# the tag currently committed in pkg/versionconstants/versionconstants.go. If +# unchanged, does nothing (no Docker, no network). If changed, builds the +# image locally at the new tag (unless already built) using the given build +# command, then rewrites just that tag's line in versionconstants.go in place. # -# --print-only prints the candidate - tag and exits, without -# touching Docker or versionconstants.go. +# The tag is the bare content hash, with no branch prefix: the hash is the +# content address, and a prefix would only make the same image resolve to +# different strings depending on which branch or repository published it. +# The branch is recorded in a trailing comment instead, so the line still says +# where the image came from, and pushes publish a - alias +# pointing at the same manifest for anyone browsing the registry. +# +# --print-only prints the candidate tag and exits, without touching Docker or +# versionconstants.go. # # Env: # HASH_LEN - hash length in hex chars (default 10, must match hash-paths.sh) @@ -60,7 +66,8 @@ CURRENT_HASH="$(HASH_LEN="$HASH_LEN" "$SCRIPT_DIR/hash-paths.sh" "${HASH_PATHS[@ BRANCH="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)" SANITIZED_BRANCH="$(echo "$BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" -CANDIDATE_TAG="${SANITIZED_BRANCH}-${CURRENT_HASH}" +CANDIDATE_TAG="${CURRENT_HASH}" +ALIAS_TAG="${SANITIZED_BRANCH}-${CURRENT_HASH}" if [ "$PRINT_ONLY" = true ]; then echo "$CANDIDATE_TAG" @@ -73,9 +80,9 @@ if [ -z "$EXISTING_TAG" ]; then exit 1 fi -EXISTING_HASH="${EXISTING_TAG: -${HASH_LEN}}" - -if [ "$EXISTING_HASH" = "$CURRENT_HASH" ]; then +# Exact, not a trailing-hash match, so a line still in the old - +# form is rewritten to the bare hash the first time make runs. +if [ "$EXISTING_TAG" = "$CURRENT_HASH" ]; then # Unchanged - nothing to do. exit 0 fi @@ -89,10 +96,16 @@ else echo "autotag.sh: no build command given, cannot build ${IMAGE_REPO}:${CANDIDATE_TAG}" >&2 exit 1 fi - "${BUILD_CMD[@]}" "VERSION=${CANDIDATE_TAG}" + "${BUILD_CMD[@]}" "VERSION=${CANDIDATE_TAG}" "DDEV_IMAGE_TAG=${CANDIDATE_TAG}" fi -sed -i.bak -E "s/^var ${TAG_VAR} = \"[^\"]*\"/var ${TAG_VAR} = \"${CANDIDATE_TAG}\"/" "$VERSIONCONSTANTS_FILE" +# The trailing comment is rewritten wholesale, not preserved: it exists to say +# which branch produced this hash, which is the readability a bare hash costs. +# Branch carries the same thing as data, for `ddev version`. +sed -i.bak -E \ + -e "s|^var ${TAG_VAR} = \"[^\"]*\".*|var ${TAG_VAR} = \"${CANDIDATE_TAG}\" // ${ALIAS_TAG}|" \ + -e "s|^var ${TAG_VAR}Branch = \"[^\"]*\"|var ${TAG_VAR}Branch = \"${SANITIZED_BRANCH}\"|" \ + "$VERSIONCONSTANTS_FILE" rm -f "${VERSIONCONSTANTS_FILE}.bak" -echo "autotag.sh: updated ${TAG_VAR} to ${CANDIDATE_TAG} in $VERSIONCONSTANTS_FILE" +echo "autotag.sh: updated ${TAG_VAR} to ${CANDIDATE_TAG} (${ALIAS_TAG}) in $VERSIONCONSTANTS_FILE" diff --git a/containers/autotag_test.sh b/containers/autotag_test.sh index f9fff49ee6a..96ed74fa401 100755 --- a/containers/autotag_test.sh +++ b/containers/autotag_test.sh @@ -84,7 +84,10 @@ cat > "$VERSIONCONSTANTS" <<'EOF' package versionconstants // WebTag defines the default web image tag -var WebTag = "v1.0.0" // Note that this can be overridden by make +var WebTag = "v1.0.0" // some-old-branch-v1.0.0 + +// WebTagBranch is the branch WebTag's content was built from. +var WebTagBranch = "some-old-branch" EOF export VERSIONCONSTANTS_FILE="$VERSIONCONSTANTS" @@ -136,10 +139,7 @@ candidate="$("$AUTOTAG" --print-only WebTag ddev/dummy-image imgdir)" after="$(cat "$VERSIONCONSTANTS")" assert_eq "$before" "$after" "--print-only does not modify the versionconstants file" current_hash="$("$HASH_PATHS" imgdir)" -case "$candidate" in - *-"$current_hash") pass "--print-only candidate tag ends with the current hash" ;; - *) fail "--print-only candidate tag '$candidate' doesn't end with hash '$current_hash'" ;; -esac +assert_eq "$current_hash" "$candidate" "--print-only candidate tag is the bare content hash" calls="$(count_lines "$DOCKER_CALL_LOG")" assert_eq "0" "$calls" "--print-only makes no docker calls" @@ -154,15 +154,12 @@ else fail "build command should have run when the tag changed and no local image existed" fi +current_branch="$(git rev-parse --abbrev-ref HEAD | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" new_tag_line="$(grep '^var WebTag = ' "$VERSIONCONSTANTS")" -case "$new_tag_line" in - *"-${current_hash}\""*) pass "versionconstants file rewritten with the new hash-suffixed tag" ;; - *) fail "versionconstants file not rewritten as expected: $new_tag_line" ;; -esac -case "$new_tag_line" in - *"// Note that this can be overridden by make"*) pass "trailing comment on the tag line is preserved" ;; - *) fail "trailing comment on the tag line was lost: $new_tag_line" ;; -esac +assert_eq "var WebTag = \"${current_hash}\" // ${current_branch}-${current_hash}" "$new_tag_line" \ + "the tag becomes the bare hash, with the branch alias as a trailing comment" +assert_eq "var WebTagBranch = \"${current_branch}\"" "$(grep '^var WebTagBranch = ' "$VERSIONCONSTANTS")" \ + "the companion Branch variable is updated for ddev version" # 7. Idempotency: re-run with no further changes -> no-op. No build, no file # rewrite, and (the key design property) no docker call at all. @@ -184,8 +181,7 @@ assert_eq "$calls_before" "$calls_after" "no docker calls at all on the unchange # tag -> build is skipped, but the file is still rewritten. echo "changed again" > imgdir/Dockerfile new_hash="$("$HASH_PATHS" imgdir)" -branch="$(git rev-parse --abbrev-ref HEAD | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" -echo "ddev/dummy-image:${branch}-${new_hash}" > "$DOCKER_EXISTING_REF_FILE" +echo "ddev/dummy-image:${new_hash}" > "$DOCKER_EXISTING_REF_FILE" rm -f "$BUILD_MARKER" "$AUTOTAG" WebTag ddev/dummy-image imgdir -- bash -c "touch '$BUILD_MARKER'" if [ -f "$BUILD_MARKER" ]; then @@ -193,12 +189,28 @@ if [ -f "$BUILD_MARKER" ]; then else pass "build skipped when a local image already exists at the computed tag" fi -if grep -q "\"${branch}-${new_hash}\"" "$VERSIONCONSTANTS"; then +if grep -q "^var WebTag = \"${new_hash}\"" "$VERSIONCONSTANTS"; then pass "versionconstants file rewritten even when the build was skipped" else fail "versionconstants file should still be rewritten when the build is skipped" fi +# 9. A line still carrying the old - form is migrated to the bare +# hash on the next run, even though its trailing hash already matches. +cat > "$VERSIONCONSTANTS" < versionconstants.go already names this tag, so it is +# what ddev pulls and it has to exist in the registry. # recomputed the content changed, so autotag.sh rewrites -# versionconstants.go to and builds it locally. -# -# The branch prefix is only meaningful in the recomputed state: autotag.sh -# rewrites the whole tag when the hash changes, so a committed tag whose hash -# still matches keeps whatever branch last changed that image. Recomputing the -# prefix in the committed state produces a tag nothing ever pushed. +# versionconstants.go to and, for an image `make` +# builds, produces it locally. # # Env: -# HASH_LEN - hash length in hex chars (default 10, must -# match hash-paths.sh) -# VERSIONCONSTANTS_FILE - path to versionconstants.go -# REQUIRED_IMAGE_TAG_BRANCH - branch for the recomputed prefix (default: the -# current git branch) +# HASH_LEN - hash length in hex chars (default 10, must match +# hash-paths.sh) +# VERSIONCONSTANTS_FILE - path to versionconstants.go set -eu -o pipefail @@ -44,11 +39,11 @@ if [ -z "$EXISTING_TAG" ]; then exit 1 fi -if [ "${EXISTING_TAG: -${HASH_LEN}}" = "$CURRENT_HASH" ]; then - echo "committed ${EXISTING_TAG}" - exit 0 +# Exact, not a trailing-hash match: a value still in the old - +# form is what ddev would pull, and it isn't this tag, so it counts as stale +# and `make` migrates the line. +if [ "$EXISTING_TAG" = "$CURRENT_HASH" ]; then + echo "committed ${CURRENT_HASH}" +else + echo "recomputed ${CURRENT_HASH}" fi - -BRANCH="${REQUIRED_IMAGE_TAG_BRANCH:-$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)}" -SANITIZED_BRANCH="$(echo "$BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" -echo "recomputed ${SANITIZED_BRANCH}-${CURRENT_HASH}" diff --git a/containers/required_image_tag_test.sh b/containers/required_image_tag_test.sh index bccb7548097..864f2a7b526 100755 --- a/containers/required_image_tag_test.sh +++ b/containers/required_image_tag_test.sh @@ -39,31 +39,37 @@ export VERSIONCONSTANTS_FILE="$WORKDIR/versionconstants.go" HASH_PATH_ARGS=(containers/ddev-xhgui containers/containers_shared.mk) CURRENT_HASH="$("$HASH_PATHS" "${HASH_PATH_ARGS[@]}")" -# 1. Committed tag still matches the content: returned as-is, keeping the -# branch prefix of whatever branch last changed the image. This is the case -# that makes wait-for-images.sh wait for a tag that actually exists. -echo "var XhguiTag = \"an_old_branch-${CURRENT_HASH}\" // trailing comment" > "$VERSIONCONSTANTS_FILE" -OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH=current_branch "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" -assert_eq "committed an_old_branch-${CURRENT_HASH}" "$OUTPUT" "keeps the committed tag when the hash still matches" - -# 2. Content no longer matches: the tag autotag.sh would rewrite it to, -# prefixed with the current branch. -echo "var XhguiTag = \"an_old_branch-0000000000\"" > "$VERSIONCONSTANTS_FILE" -OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH=current_branch "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" -assert_eq "recomputed current_branch-${CURRENT_HASH}" "$OUTPUT" "recomputes a branch-prefixed tag when the hash changed" - -# 3. Branch names are sanitized to the tag charset, the same way autotag.sh -# does it - a fork may name its branch anything git accepts. -# shellcheck disable=SC2016 # the un-expanded $(id) is the point -OUTPUT="$(REQUIRED_IMAGE_TAG_BRANCH='feature/oh no$(id)' "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" -assert_eq "recomputed feature-oh-no-id--${CURRENT_HASH}" "$OUTPUT" "sanitizes the branch name into the tag charset" -if "$SCRIPT_DIR/validate-image-tag.sh" "${OUTPUT#recomputed }" >/dev/null 2>&1; then - pass "a sanitized hostile branch name still yields a pushable tag" +# 1. Committed value already the current hash -> committed, and the tag is +# that hash. This is the case that makes wait-for-images.sh wait. +echo "var XhguiTag = \"${CURRENT_HASH}\" // some_branch-${CURRENT_HASH}" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "committed ${CURRENT_HASH}" "$OUTPUT" "reports committed when versionconstants.go already names this hash" + +# 2. Content no longer matches -> recomputed, same tag. The tag never depends +# on the branch, which is what lets detect, the runner, and `make` agree. +echo "var XhguiTag = \"0000000000\"" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "recomputed ${CURRENT_HASH}" "$OUTPUT" "reports recomputed, still with the bare content hash" + +# 3. A value still in the old - form is what ddev would pull and +# it isn't this tag, so it counts as stale and `make` migrates the line. +echo "var XhguiTag = \"an_old_branch-${CURRENT_HASH}\"" > "$VERSIONCONSTANTS_FILE" +OUTPUT="$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" +assert_eq "recomputed ${CURRENT_HASH}" "$OUTPUT" "treats a legacy branch-prefixed value as stale" + +# 4. The branch the caller happens to be on cannot change the answer. +echo "var XhguiTag = \"${CURRENT_HASH}\"" > "$VERSIONCONSTANTS_FILE" +assert_eq "$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" \ + "$(REQUIRED_IMAGE_TAG_BRANCH='some/other branch' "$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}")" \ + "the result is independent of the branch" + +if "$SCRIPT_DIR/validate-image-tag.sh" "$CURRENT_HASH" >/dev/null 2>&1; then + pass "the bare hash tag is one validate-image-tag.sh accepts" else - fail "sanitized branch name should still yield a tag validate-image-tag.sh accepts: $OUTPUT" + fail "validate-image-tag.sh should accept the bare hash '$CURRENT_HASH'" fi -# 4. A missing tag variable is a hard error, not an empty tag. +# 5. A missing tag variable is a hard error, not an empty tag. echo "var SomethingElse = \"whatever\"" > "$VERSIONCONSTANTS_FILE" OUTPUT="$("$REQUIRED_IMAGE_TAG" XhguiTag "${HASH_PATH_ARGS[@]}" 2>&1)" && RC=0 || RC=$? if [ "$RC" -ne 0 ]; then @@ -76,7 +82,7 @@ case "$OUTPUT" in *) fail "missing-variable message should name the variable: $OUTPUT" ;; esac -# 5. Usage error on too few arguments. +# 6. Usage error on too few arguments. if "$REQUIRED_IMAGE_TAG" XhguiTag >/dev/null 2>&1; then fail "should reject a missing hash path" else diff --git a/containers/validate-image-tag.sh b/containers/validate-image-tag.sh index 9e0cba2755f..40daac3cb40 100755 --- a/containers/validate-image-tag.sh +++ b/containers/validate-image-tag.sh @@ -6,14 +6,20 @@ # 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. # +# Two forms are accepted: +# the canonical tag, a bare content hash - what +# versionconstants.go holds and what ddev pulls +# - the human-readable alias published alongside it, whose +# prefix is a sanitized branch name +# # Requires: # - strict charset, matching the same sanitization autotag.sh applies, and # a leading character Docker actually accepts in a tag # - must end in exactly HASH_LEN lowercase hex characters (the part # tooling treats as authoritative) -# - neither the whole tag nor the part before the hash may be a reserved -# literal ("latest") or a release-tag shape (vX.Y.Z), so a forged tag can -# never be mistaken for a real one +# - neither the whole tag nor an alias prefix may be a reserved literal +# ("latest") or a release-tag shape (vX.Y.Z), so a fork that names its +# branch v1.25.0 can't publish something that reads as a release # # Env: # HASH_LEN - hash length in hex chars (default 10, must match hash-paths.sh) @@ -47,8 +53,12 @@ reject_reserved() { reject_reserved "$TAG" "is" +if [[ "$TAG" =~ ^[0-9a-f]{${HASH_LEN}}$ ]]; then + exit 0 +fi + if ! [[ "$TAG" =~ ^[A-Za-z0-9_][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 + echo "validate-image-tag.sh: '${TAG}' is neither a ${HASH_LEN}-hex-char hash nor -" >&2 exit 1 fi diff --git a/containers/validate_image_tag_test.sh b/containers/validate_image_tag_test.sh index 6a1501abbc8..6311ce1a35f 100755 --- a/containers/validate_image_tag_test.sh +++ b/containers/validate_image_tag_test.sh @@ -51,10 +51,19 @@ assert_rejected_because() { fi } +# The canonical form: a bare content hash, what versionconstants.go holds. +assert_valid "0123456789" +assert_valid "36bceca65e" + +# The readable alias published alongside it. assert_valid "20260721_rfay_content_addressed_image_tags-36bceca65e" assert_valid "main-0123456789" assert_valid "v1.2.3-rc1-0123456789" +assert_invalid "0123456789a" "an 11-hex-char bare tag" +assert_invalid "012345678" "a 9-hex-char bare tag" +assert_invalid "0123456789A" "a bare tag with an uppercase hex digit" + assert_invalid "latest" "the reserved literal 'latest'" assert_invalid "stable" "the reserved literal 'stable'" assert_invalid "v1.2.3" "a bare release tag" diff --git a/containers/wait-for-images.sh b/containers/wait-for-images.sh index ed9596e9b05..15bff87b4cc 100755 --- a/containers/wait-for-images.sh +++ b/containers/wait-for-images.sh @@ -46,19 +46,13 @@ for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do read -r state tag <<< "$("$REQUIRED_IMAGE_TAG" "$tag_var" $hash_paths)" image_repo="${DOCKER_ORG}/${repo_suffix}" - if [ "$state" != "committed" ]; then - if [ "$built_by_make" = "true" ]; then - echo "wait-for-images.sh: ${image_repo} content differs from versionconstants.go; make builds ${tag} locally, not waiting" - continue - fi - # No local build to fall back on, and the tag `make` will invent here - # depends on this runner's branch name (detached HEAD on a PR checkout), - # so it may not match what image-build-push.yml pushed. Fail now with - # something actionable instead of timing out on a tag nobody pushed. - echo "wait-for-images.sh: ${image_repo} content differs from the tag committed in versionconstants.go," >&2 - echo "wait-for-images.sh: and make does not build this image locally." >&2 - echo "wait-for-images.sh: run 'make' and commit the ${tag_var} change in pkg/versionconstants/versionconstants.go." >&2 - exit 1 + # A changed image that `make` builds here needs no registry round trip. One + # it doesn't build - every ddev-dbserver variant but the default - still + # does, and the tag is the bare content hash, so it is the same string + # image-build-push.yml pushed regardless of branch. Wait for it. + if [ "$state" != "committed" ] && [ "$built_by_make" = "true" ]; then + echo "wait-for-images.sh: ${image_repo} content differs from versionconstants.go; make builds ${tag} locally, not waiting" + continue fi attempt=1 diff --git a/containers/wait_for_images_test.sh b/containers/wait_for_images_test.sh index c99c6eb5b55..a313b6dd049 100755 --- a/containers/wait_for_images_test.sh +++ b/containers/wait_for_images_test.sh @@ -98,7 +98,6 @@ source "$SCRIPT_DIR/image-configs.sh" # --- A throwaway versionconstants.go, so the tags waited for are whatever this # test says they are rather than whatever the checkout happens to carry. export VERSIONCONSTANTS_FILE="$WORKDIR/versionconstants.go" -COMMITTED_PREFIX="some_older_branch" REPOS=() TAG_VARS=() @@ -109,7 +108,7 @@ for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do if [ -z "${HASH_BY_VAR[$tag_var]:-}" ]; then # shellcheck disable=SC2086 # hash_paths is a space-separated path list HASH_BY_VAR["$tag_var"]="$("$HASH_PATHS" $hash_paths)" - TAG_BY_VAR["$tag_var"]="${COMMITTED_PREFIX}-${HASH_BY_VAR[$tag_var]}" + TAG_BY_VAR["$tag_var"]="${HASH_BY_VAR[$tag_var]}" fi REPOS+=("ddevhq/${repo_suffix}") TAG_VARS+=("$tag_var") @@ -153,14 +152,15 @@ case "$OUTPUT" in *) fail "should print confirmation for each found tag: $OUTPUT" ;; esac -# 2. The regression that made every non-containers pull request hang: the -# committed tag's branch prefix belongs to whatever branch last changed the -# image, and must be waited for as-is rather than recomputed from the -# current branch. +# 2. The regression that made every non-containers pull request hang was a tag +# that depended on the current branch. The tag is now the bare content hash, +# so the same checkout resolves to the same tag on any branch, in any fork. case "$OUTPUT" in - *"${COMMITTED_PREFIX}-${HASH_BY_VAR[WebTag]}"*) pass "waits for the committed tag's own branch prefix" ;; - *) fail "should wait for the committed prefix '${COMMITTED_PREFIX}', not the current branch: $OUTPUT" ;; + *"found ${REPOS[0]}:${HASH_BY_VAR[WebTag]}"*) pass "waits for the bare content hash, with no branch prefix" ;; + *) fail "should wait for the bare hash '${HASH_BY_VAR[WebTag]}': $OUTPUT" ;; esac +OUTPUT_OTHER_BRANCH="$(REQUIRED_IMAGE_TAG_BRANCH='some/other branch' "$WAIT_FOR_IMAGES" 2>&1)" +assert_eq "$OUTPUT" "$OUTPUT_OTHER_BRANCH" "the tags waited for don't depend on the branch" # 2b. Every ddev-dbserver variant is waited for, not just the default one that # `make` builds locally - they all share BaseDBTag, so a dbserver change @@ -174,7 +174,7 @@ done # 3. Content that no longer matches versionconstants.go is built locally by # make, so there is nothing to wait for and no registry call at all. -TAG_BY_VAR[WebTag]="${COMMITTED_PREFIX}-0000000000" +TAG_BY_VAR[WebTag]="0000000000" write_versionconstants mark_all_existing : > "$DOCKER_CALL_LOG" @@ -191,27 +191,37 @@ case "$OUTPUT" in *"not waiting"*) pass "says why it isn't waiting for the changed image" ;; *) fail "should explain why it isn't waiting: $OUTPUT" ;; esac -TAG_BY_VAR[WebTag]="${COMMITTED_PREFIX}-${HASH_BY_VAR[WebTag]}" +TAG_BY_VAR[WebTag]="${HASH_BY_VAR[WebTag]}" write_versionconstants -# 3b. A changed dbserver with a stale versionconstants.go is the case that has -# no local fallback: `make` builds only the default variant, so the other -# 19 could only come from a tag this runner can't predict. Fail fast rather -# than time out. -TAG_BY_VAR[BaseDBTag]="${COMMITTED_PREFIX}-0000000000" +# 3b. A changed dbserver still has to wait for the 19 variants `make` doesn't +# build locally. The tag is the bare content hash, so it's the same string +# image-build-push.yml pushes and waiting for it is well-defined - only the +# default variant is skipped. +TAG_BY_VAR[BaseDBTag]="0000000000" write_versionconstants -mark_all_existing +: > "$DOCKER_EXISTING_REF_FILE" +for i in "${!REPOS[@]}"; do + tag="${TAG_BY_VAR[${TAG_VARS[$i]}]}" + [ "${TAG_VARS[$i]}" = "BaseDBTag" ] && tag="${HASH_BY_VAR[BaseDBTag]}" + echo "${REPOS[$i]}:${tag}" >> "$DOCKER_EXISTING_REF_FILE" +done +: > "$DOCKER_CALL_LOG" OUTPUT="$("$WAIT_FOR_IMAGES" 2>&1)" && RC=0 || RC=$? -if [ "$RC" -ne 0 ]; then - pass "fails fast when a non-locally-built image is out of date" +if [ "$RC" -eq 0 ]; then + pass "waits for the db variants make doesn't build, rather than failing" else - fail "should fail when a non-locally-built image is out of date: $OUTPUT" + fail "should wait for the non-locally-built db variants: $OUTPUT" fi case "$OUTPUT" in - *"run 'make' and commit the BaseDBTag change"*) pass "names the variable to regenerate" ;; - *) fail "should tell the contributor to run make and commit BaseDBTag: $OUTPUT" ;; + *"ddevhq/ddev-dbserver-mariadb-11.8 content differs"*) pass "skips only the default variant make builds locally" ;; + *) fail "should skip the locally-built default variant: $OUTPUT" ;; +esac +case "$OUTPUT" in + *"found ddevhq/ddev-dbserver-mysql-8.0:${HASH_BY_VAR[BaseDBTag]}"*) pass "waits for a non-default variant at the bare hash tag" ;; + *) fail "should wait for the non-default variants at the recomputed hash: $OUTPUT" ;; esac -TAG_BY_VAR[BaseDBTag]="${COMMITTED_PREFIX}-${HASH_BY_VAR[BaseDBTag]}" +TAG_BY_VAR[BaseDBTag]="${HASH_BY_VAR[BaseDBTag]}" write_versionconstants # 4. A tag that's initially missing but becomes available on the 3rd check. diff --git a/docs/content/developers/building-contributing.md b/docs/content/developers/building-contributing.md index 615bc6d78fe..a0c4be5d75a 100644 --- a/docs/content/developers/building-contributing.md +++ b/docs/content/developers/building-contributing.md @@ -314,7 +314,9 @@ When you change an image, running `make` from the repository root builds it loca ### 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. A `detect` job always runs first: it recomputes each image's content hash and resolves the tag this checkout actually needs, exactly the way `make` does — the tag committed in `versionconstants.go` if its hash still matches the content, otherwise a fresh `-` tag. If that tag is already in the registry there's nothing to build, so a pull request that touches `containers/` without changing an image costs one registry lookup per image and no build. +An image's tag is the bare content hash of the files it's built from — `ddev/ddev-webserver:36bceca65e`, with no branch prefix. The same content therefore resolves to the same tag no matter which branch, fork, or Docker Hub organization published it, which is what lets `make`, the CI detector, and every test runner agree without coordinating. `versionconstants.go` records the branch alongside it, both as a trailing comment and as a `WebTagBranch`-style variable that `ddev version` shows, and each push also publishes a readable `-` alias pointing at the same manifest. + +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 recomputes each image's content hash and checks whether that tag is already in the registry. If it is, there's nothing to build — so a pull request that touches `containers/` without changing an image costs one registry lookup per image and no build. The same resolution drives `containers/wait-for-images.sh`, which every test runner calls before pulling anything: it waits only for tags this commit will genuinely pull, and doesn't wait at all for an image whose content changed, since `make` builds that one locally on the runner. diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index 53400873b92..0ffcbc50884 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -78,7 +78,7 @@ The following “Repository secret” environment variables must be configured i 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 — for instance at release time, when every image is re-pushed under a `vX.Y.Z` tag. +The two workflows below (manual `workflow_dispatch`) remain for re-pushing a specific tag — for instance at release time, when every image is re-pushed under a `vX.Y.Z` tag. Their `tag` input is optional: leave it empty and the workflow uses the tag the checkout actually needs, which is safer than retyping a content hash. Supply it only for a release. A `containers/ddev-dbserver` change builds and pushes all 20 database variants (36 jobs), because they all share a single `BaseDBTag`. That variant matrix lives in `containers/ddev-dbserver/variants.txt` and is read by `variants.sh`, which also generates that directory's make targets, the automatic flow's image list, and `push-tagged-dbimage.yml`'s matrix — add a database version there and every consumer picks it up. diff --git a/pkg/version/version.go b/pkg/version/version.go index d174f563a29..2494cc0b1ff 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -66,10 +66,38 @@ func GetVersionInfo() (map[string]string, error) { } versionInfo["mutagen"] = versionconstants.RequiredMutagenVersion versionInfo["xhgui-image"] = docker.GetXhguiImage() + versionInfo["image-tag-branches"] = imageTagBranches() return versionInfo, retErr } +// imageTagBranches describes which branch each image tag was built from. The +// tags themselves are bare content hashes, so without this there is nothing in +// `ddev version` to say where an image came from. Collapses to a single branch +// name in the usual case where every image was built from the same one. +func imageTagBranches() string { + branches := []struct{ image, branch string }{ + {"web", versionconstants.WebTagBranch}, + {"db", versionconstants.BaseDBTagBranch}, + {"router", versionconstants.TraefikRouterTagBranch}, + {"ddev-ssh-agent", versionconstants.SSHAuthTagBranch}, + {"xhgui", versionconstants.XhguiTagBranch}, + } + + parts := make([]string, 0, len(branches)) + allSame := true + for _, b := range branches { + if b.branch != branches[0].branch { + allSame = false + } + parts = append(parts, b.image+"="+b.branch) + } + if allSame { + return branches[0].branch + } + return strings.Join(parts, " ") +} + // GetDockerPlatform gets the platform used for Docker engine func GetDockerPlatform() (string, error) { info, err := dockerutil.GetDockerClientInfo() diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index 6609eb6d761..e606a978c08 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -16,35 +16,58 @@ var DdevVersion = "" // Note that this is overridden by make // Compiled with link-time variables var AmplitudeAPIKey = "" +// Image tags are bare content hashes (see containers/hash-paths.sh), so the +// same image resolves to the same tag no matter which branch or repository +// published it. Each has a companion Branch naming the branch its +// content was built from - the readable hint a bare hash costs, shown by +// `ddev version` and republished as a - alias in the registry. +// All of these lines are maintained by containers/autotag.sh; running `make` +// updates them. + // WebImg defines the default web image used for applications. 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 = "36bceca65e" // 20260721_rfay_content_addressed_image_tags-36bceca65e + +// WebTagBranch is the branch WebTag's content was built from. +var WebTagBranch = "20260721_rfay_content_addressed_image_tags" // DBImg defines the default db image used for applications. var DBImg = "ddev/ddev-dbserver" // BaseDBTag is the main tag, DBTag is constructed from it -var BaseDBTag = "20260814_rfay_docker_update_phase_2-1dc90407ef" +var BaseDBTag = "1dc90407ef" // 20260814_rfay_docker_update_phase_2-1dc90407ef + +// BaseDBTagBranch is the branch BaseDBTag's content was built from. +var BaseDBTagBranch = "20260814_rfay_docker_update_phase_2" // TraefikRouterImage is image for router var TraefikRouterImage = "ddev/ddev-traefik-router" // TraefikRouterTag is traefik router tag -var TraefikRouterTag = "20260721_rfay_content_addressed_image_tags-c96123b524" +var TraefikRouterTag = "c96123b524" // 20260721_rfay_content_addressed_image_tags-c96123b524 + +// TraefikRouterTagBranch is the branch TraefikRouterTag's content was built from. +var TraefikRouterTagBranch = "20260721_rfay_content_addressed_image_tags" // SSHAuthImage is image for agent var SSHAuthImage = "ddev/ddev-ssh-agent" // SSHAuthTag is ssh-agent auth tag -var SSHAuthTag = "20260721_rfay_content_addressed_image_tags-8e8bf1217c" +var SSHAuthTag = "8e8bf1217c" // 20260721_rfay_content_addressed_image_tags-8e8bf1217c + +// SSHAuthTagBranch is the branch SSHAuthTag's content was built from. +var SSHAuthTagBranch = "20260721_rfay_content_addressed_image_tags" // XhguiImage is image for xhgui var XhguiImage = "ddev/ddev-xhgui" // XhguiTag is xhgui tag -var XhguiTag = "20260721_rfay_content_addressed_image_tags-f046b66382" +var XhguiTag = "f046b66382" // 20260721_rfay_content_addressed_image_tags-f046b66382 + +// XhguiTagBranch is the branch XhguiTag's content was built from. +var XhguiTagBranch = "20260721_rfay_content_addressed_image_tags" // UtilitiesImage is used in bash scripts var UtilitiesImage = "ddev/ddev-utilities:latest" From be860a5b8c4a0df3ec44b33ced4cc9e2e545451b Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 11:16:08 -0600 Subject: [PATCH 24/36] docs: record the hash-only tag migration in HANDOFF.md [skip ci] Adds the republish-everything step as must-test item 0: the scheme change invalidates every existing content-hash tag, so the first CI run has to push all 24 images before integration tests can pull. Notes that the manual db push now has two paths to test (derived tag and explicit release tag). Co-Authored-By: Claude Opus 5 (1M context) --- HANDOFF.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 2881f507bbf..cbd7ccf814a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,8 +6,22 @@ Branch `20260814_rfay_docker_update_phase_2`. Nothing has been pushed. ## MUST TEST BEFORE MERGE -Two things in here have never run in CI and will not be exercised by any test -harness. Both are cheap to get wrong and expensive to discover after merge. +### 0. Every image has to be republished at its bare-hash tag + +Tags are now bare content hashes (`ddev/ddev-webserver:36bceca65e`), which +invalidates every previously published content-hash tag. Nothing in either +organization carries the new tags yet — confirmed missing for webserver, +ssh-agent, and the db variants. **Integration tests cannot pull until the +first CI run publishes them**, which is 44 build jobs across 24 images. +Locally, `go test -run TestCmdVersion ./cmd/ddev/cmd/...` currently fails with +`manifest for ddev/ddev-ssh-agent:8e8bf1217c not found`, which is this and +nothing else. + +This is a one-time migration cost of the scheme change; after that first push +the tags stop depending on branch or organization, which is the whole point. + +Two further things have never run in CI and no test harness covers. Both are +cheap to get wrong and expensive to discover after merge. ### 1. A `containers/ddev-dbserver` change must build and push all 20 variants @@ -40,6 +54,10 @@ done Every line must say EXISTS. Any MISSING means the matrix regressed and non-default database tests will fail. +Note that item 1 below was written before the tag scheme changed: the tag to +check is now the bare `BaseDBTag` hash, not a branch-prefixed string, and the +`variants.sh repos` loop still works unchanged. + ### 2. `push-tagged-dbimage.yml` still works after the DRY refactor Its matrix, its `MULTI_ARCH_IMAGES` list, and its multi-arch/single-arch @@ -47,11 +65,18 @@ decision were three separate hardcoded copies of the variant list; all three now come from `variants.sh`. This is the release-time push path, so a mistake here surfaces during a release. -Run it manually on `ddev-test/ddev` with a throwaway tag and confirm: +Its `tag` input is now optional — left empty it derives the tag the checkout +needs, which is the fix for having to transcribe `main-1dc90407ef` by hand. +Test **both** paths: empty (derives the bare hash) and an explicit `vX.Y.Z` +(the release path). + +Run it manually on `ddev-test/ddev` and confirm: - The `variants` job runs first and its matrix expands to **36** `build-db-arch` jobs — the same count as before (20 variants × 2 arches, minus 4 arm64 exclusions). +- With an empty tag input, the resolve step logs the derived tag and it matches + `BaseDBTag` in `versionconstants.go`. - The four amd64-only variants get `multi_arch=false` in their `meta` step and push an unsuffixed tag; the other 16 get `multi_arch=true` and push `-amd64`/`-arm64`. @@ -152,18 +177,18 @@ tag already exists and never build the new variant. ## Test status -114 checks across seven harnesses, all passing locally (macOS), all wired into +127 checks across seven harnesses, all passing locally (macOS), all wired into `container-tests.yml`: | Harness | Checks | | --- | --- | -| `containers/autotag_test.sh` | 17 | +| `containers/autotag_test.sh` | 18 | | `containers/db_variants_test.sh` | 15 (new) | | `containers/registry_tag_exists_test.sh` | 4 | -| `containers/required_image_tag_test.sh` | 7 (new) | +| `containers/required_image_tag_test.sh` | 12 (new) | | `containers/validate_image_repo_test.sh` | 37 (new) | -| `containers/validate_image_tag_test.sh` | 15 | -| `containers/wait_for_images_test.sh` | 19 | +| `containers/validate_image_tag_test.sh` | 20 | +| `containers/wait_for_images_test.sh` | 21 | `shellcheck -x` clean on all new and changed scripts. `actionlint` clean on all changed workflows apart from pre-existing SC2086/SC2046 notes in untouched @@ -212,6 +237,26 @@ environment referenced before it exists is auto-created with no protection. 7. **Hostile branch name** ``test`touch /tmp/pwned` ``; check the `detect` log. 8. **Expired artifact.** Approve after `retention-days`; must fail loudly. +## Round 3 — hash-only tags + +The branch prefix in a tag carried no information but forced every consumer to +agree on a string. Tags are now the bare content hash; the branch survives as a +trailing comment in `versionconstants.go`, a `TagBranch` variable shown by +`ddev version` (`image-tag-branches`, collapsed when all images share a +branch), and a `-` alias published off the same manifest. + +`validate-image-tag.sh` accepts both forms, and the alias goes through it, so a +fork branch named `v1.25.0` can't publish something that reads as a release — +which finally makes the reserved/release checks load-bearing rather than dead. + +Comparison against `versionconstants.go` is exact now, so a line still in the +old form counts as stale and `make` migrates it. `wait-for-images.sh` lost its +fail-fast branch: the tag a non-locally-built image needs is branch-independent, +so waiting for it is well defined. + +The manual push workflows' `tag` input is optional; empty derives the tag via +`containers/image-tag-for.sh`. + ## Still open - **Fork artifact volume.** A fork changing `ddev-dbserver` now produces 36 From 7adf418c9023a20d0ddc2a07f88cd8549a555d40 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 11:55:46 -0600 Subject: [PATCH 25/36] fix(ci): publish the branch alias from the manual push workflows too, for #8609 image-build-push.yml and image-push.yml publish a - alias next to the bare hash tag, so the registry tag list still says which branch produced an image. push-tagged-image.yml and push-tagged-dbimage.yml did not, so a manual push - the path used to seed the tags for this branch - left a bare hash with nothing readable beside it. Both now derive the alias in the same step that resolves the tag, sanitize the branch, and run it through validate-image-tag.sh before use. Only a content hash gets one; a vX.Y.Z release tag is already readable. The four amd64-only db variants are pushed straight to the final tag and never reach the manifest loop, so they get the alias in a separate pass over the variant list rather than being silently skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/push-tagged-dbimage.yml | 47 +++++++++++++++++-- .github/workflows/push-tagged-image.yml | 26 +++++++++- docs/content/developers/release-management.md | 2 + 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/.github/workflows/push-tagged-dbimage.yml b/.github/workflows/push-tagged-dbimage.yml index 728e8c8caf6..e6fb9f849b4 100644 --- a/.github/workflows/push-tagged-dbimage.yml +++ b/.github/workflows/push-tagged-dbimage.yml @@ -32,23 +32,30 @@ jobs: outputs: matrix: ${{ steps.variants.outputs.matrix }} multi_arch_images: ${{ steps.variants.outputs.multi_arch_images }} + all_repos: ${{ steps.variants.outputs.all_repos }} tag: ${{ steps.tag.outputs.tag }} + alias: ${{ steps.tag.outputs.alias }} steps: - uses: actions/checkout@v7 - id: variants run: | set -eu -o pipefail V=containers/ddev-dbserver/variants.sh - echo "matrix=$($V json)" >> "$GITHUB_OUTPUT" - echo "multi_arch_images=$($V multi-arch-variants)" >> "$GITHUB_OUTPUT" + { + echo "matrix=$($V json)" + echo "multi_arch_images=$($V multi-arch-variants)" + echo "all_repos=$($V repos | tr '\n' ' ')" + } >> "$GITHUB_OUTPUT" # An empty tag input means "publish what this checkout needs", which is # derivable; retyping the content hash by hand is how it ends up not # matching versionconstants.go. - id: tag env: INPUT_TAG: ${{ github.event.inputs.tag }} + ALIAS_BRANCH: ${{ github.ref_name }} run: | set -eu -o pipefail + HASH_LEN="${HASH_LEN:-10}" if [ -n "$INPUT_TAG" ]; then TAG="$INPUT_TAG" echo "Using the tag supplied on the workflow input: ${TAG}" @@ -59,6 +66,21 @@ jobs: containers/validate-image-tag.sh "$TAG" || [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + # A readable neighbour for a bare hash, so the registry tag list + # still says which branch produced it. A release tag is already + # readable and gets none. + ALIAS="" + if [[ "$TAG" =~ ^[0-9a-f]{${HASH_LEN}}$ ]]; then + ALIAS="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')-${TAG}" + if containers/validate-image-tag.sh "$ALIAS"; then + echo "Publishing the alias ${ALIAS} alongside ${TAG}" + else + echo "Alias '${ALIAS}' rejected; publishing only ${TAG}" >&2 + ALIAS="" + fi + fi + echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT" + build-db-arch: name: build ${{ matrix.build.arch }} ${{ matrix.build.dbtype }} needs: variants @@ -164,7 +186,9 @@ jobs: runs-on: ubuntu-24.04 env: MULTI_ARCH_IMAGES: ${{ needs.variants.outputs.multi_arch_images }} + ALL_REPOS: ${{ needs.variants.outputs.all_repos }} TAG: ${{ needs.variants.outputs.tag }} + ALIAS: ${{ needs.variants.outputs.alias }} steps: - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 @@ -184,15 +208,30 @@ jobs: # Get Docker Hub token for cleanup TOKEN=$(curl -s -H "Content-Type: application/json" -X POST -d '{"username":"${{ vars.DOCKERHUB_USERNAME }}","password":"${{ env.DOCKERHUB_TOKEN }}"}' https://hub.docker.com/v2/users/login/ | jq -r .token) - # Create and push multi-arch manifests + # Create and push multi-arch manifests. ALIAS, when set, is a second + # name on the same manifest - see the variants job. for variant in ${MULTI_ARCH_IMAGES}; do ORG_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${variant}" | tr '_' '-') - docker buildx imagetools create -t ${ORG_IMAGE}:${TAG} ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 + NAMES=(-t "${ORG_IMAGE}:${TAG}") + [ -n "${ALIAS}" ] && NAMES+=(-t "${ORG_IMAGE}:${ALIAS}") + docker buildx imagetools create "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then docker buildx imagetools create -t ${ORG_IMAGE}:latest ${ORG_IMAGE}:${TAG} fi done + # The amd64-only variants are pushed straight to ${TAG} and never + # reach the loop above, so they need the alias adding separately. + if [ -n "${ALIAS}" ]; then + for repo in ${ALL_REPOS}; do + variant="$(echo "${repo#ddev-dbserver-}" | tr '-' '_')" + case " ${MULTI_ARCH_IMAGES} " in *" ${variant} "*) continue ;; esac + ORG_IMAGE=${DOCKER_ORG}/${repo} + echo "Aliasing ${ORG_IMAGE}:${TAG} as ${ALIAS}" + docker buildx imagetools create -t "${ORG_IMAGE}:${ALIAS}" "${ORG_IMAGE}:${TAG}" + done + fi + # Clean up intermediary single-arch tags from remote registry for variant in ${MULTI_ARCH_IMAGES}; do ORG_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${variant}" | tr '_' '-') diff --git a/.github/workflows/push-tagged-image.yml b/.github/workflows/push-tagged-image.yml index d1bd8533019..4cddce4afe7 100644 --- a/.github/workflows/push-tagged-image.yml +++ b/.github/workflows/push-tagged-image.yml @@ -42,14 +42,17 @@ jobs: runs-on: ubuntu-24.04 outputs: tag: ${{ steps.tag.outputs.tag }} + alias: ${{ steps.tag.outputs.alias }} steps: - uses: actions/checkout@v7 - id: tag env: INPUT_TAG: ${{ github.event.inputs.tag }} IMAGE: ${{ github.event.inputs.image }} + ALIAS_BRANCH: ${{ github.ref_name }} run: | set -eu -o pipefail + HASH_LEN="${HASH_LEN:-10}" if [ -n "$INPUT_TAG" ]; then TAG="$INPUT_TAG" echo "Using the tag supplied on the workflow input: ${TAG}" @@ -60,6 +63,21 @@ jobs: containers/validate-image-tag.sh "$TAG" || [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + # A readable neighbour for a bare hash, so the registry tag list + # still says which branch produced it. A release tag is already + # readable and gets none. + ALIAS="" + if [[ "$TAG" =~ ^[0-9a-f]{${HASH_LEN}}$ ]]; then + ALIAS="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')-${TAG}" + if containers/validate-image-tag.sh "$ALIAS"; then + echo "Publishing the alias ${ALIAS} alongside ${TAG}" + else + echo "Alias '${ALIAS}' rejected; publishing only ${TAG}" >&2 + ALIAS="" + fi + fi + echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT" + build-and-push: name: build ${{ matrix.arch }} ${{ github.event.inputs.image }} needs: resolve-tag @@ -164,6 +182,7 @@ jobs: if: ${{ needs.build-and-push.outputs.multi_arch == 'true' }} env: TAG: ${{ needs.resolve-tag.outputs.tag }} + ALIAS: ${{ needs.resolve-tag.outputs.alias }} steps: - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 @@ -189,9 +208,12 @@ jobs: MULTI_ARCH_IMAGES="${MULTI_ARCH_IMAGES} ${DOCKER_ORG}/ddev-webserver-prod" fi - # Create and push multi-arch manifests + # Create and push multi-arch manifests. ALIAS, when set, is a second + # name on the same manifest - see the resolve-tag job. for ORG_IMAGE in ${MULTI_ARCH_IMAGES}; do - docker buildx imagetools create -t ${ORG_IMAGE}:${TAG} ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 + NAMES=(-t "${ORG_IMAGE}:${TAG}") + [ -n "${ALIAS}" ] && NAMES+=(-t "${ORG_IMAGE}:${ALIAS}") + docker buildx imagetools create "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then docker buildx imagetools create -t ${ORG_IMAGE}:latest ${ORG_IMAGE}:${TAG} fi diff --git a/docs/content/developers/release-management.md b/docs/content/developers/release-management.md index 0ffcbc50884..4c96adcc0de 100644 --- a/docs/content/developers/release-management.md +++ b/docs/content/developers/release-management.md @@ -80,6 +80,8 @@ 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 — for instance at release time, when every image is re-pushed under a `vX.Y.Z` tag. Their `tag` input is optional: leave it empty and the workflow uses the tag the checkout actually needs, which is safer than retyping a content hash. Supply it only for a release. +When the tag is a content hash, these workflows also publish the `-` alias next to it — the same second name the automatic flow creates — so a manual push doesn't leave a bare hash with nothing readable beside it in the registry. A `vX.Y.Z` tag is already readable and gets no alias. + A `containers/ddev-dbserver` change builds and pushes all 20 database variants (36 jobs), because they all share a single `BaseDBTag`. That variant matrix lives in `containers/ddev-dbserver/variants.txt` and is read by `variants.sh`, which also generates that directory's make targets, the automatic flow's image list, and `push-tagged-dbimage.yml`'s matrix — add a database version there and every consumer picks it up. ### One-time setup: the `image-push` GitHub Environment From 07e00e910410198c7438d6e1cc9d5bfa5b507e09 Mon Sep 17 00:00:00 2001 From: Randy Fay Date: Sun, 16 Aug 2026 12:09:35 -0600 Subject: [PATCH 26/36] feat(images): add standard OCI metadata as labels and index annotations, for #8609 With tags reduced to bare content hashes, nothing on an image said where it came from. containers/image-metadata.sh is now the single definition of that metadata - source, url, documentation, vendor, licenses, revision, version, created, title - rendered in whichever form a caller needs. Labels and annotations both, because they are not interchangeable: - Labels live in the image config, travel with a docker pull, and show up in docker inspect offline. That is what a support report can rely on, and it is where the commit now appears. - Annotations live on the manifest index, which is what a tag points at, so they describe the tag as a whole rather than one platform and a registry can show them without pulling. A tag has no comment field; this is the closest standard equivalent. Labels are wired into containers_shared.mk (covering traefik, ssh-agent and xhgui), ddev-webserver's own build rule, and ddev-dbserver/build_image.sh, which deliberately does not include the shared makefile. Annotations are added at all four imagetools create sites. One caveat worth knowing: imagetools inherits the source media type, and a Docker manifest list has nowhere to store annotations. Images whose per-arch builds came from buildx are OCI indexes and keep them; images built with the classic builder are not and silently would not. Both automatic paths now check the result and log when annotations were dropped instead of leaving it to be discovered later. Existing published images keep whatever they were pushed with - image-metadata.sh is not in any image's hash paths, so this does not force a rebuild. New pushes pick the metadata up. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/image-build-push.yml | 11 +++- .github/workflows/image-push.yml | 11 +++- .github/workflows/push-tagged-dbimage.yml | 5 +- .github/workflows/push-tagged-image.yml | 5 +- containers/containers_shared.mk | 8 ++- containers/ddev-dbserver/build_image.sh | 9 ++- containers/ddev-webserver/Makefile | 2 +- containers/image-metadata.sh | 73 +++++++++++++++++++++++ 8 files changed, 115 insertions(+), 9 deletions(-) create mode 100755 containers/image-metadata.sh diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 8be92d70389..f8248013e81 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -273,6 +273,9 @@ jobs: echo "Alias '${ALIAS}' rejected; publishing only ${TAG}" >&2 ALIAS="" fi + # Descriptive metadata on the index the tag points at - the standard + # equivalent of a comment on a tag. See containers/image-metadata.sh. + read -r -a ANNOTATIONS <<< "$(containers/image-metadata.sh annotations "$TAG")" DOCKERHUB_JWT="$(curl -s -H "Content-Type: application/json" -X POST \ -d '{"username":"'"${{ vars.DOCKERHUB_USERNAME }}"'","password":"'"${DOCKERHUB_TOKEN}"'"}' \ @@ -291,7 +294,13 @@ jobs: done NAMES=(-t "${repo}:${TAG}") [ -n "$ALIAS" ] && NAMES+=(-t "${repo}:${ALIAS}") - docker buildx imagetools create "${NAMES[@]}" "${ARCH_TAGS[@]}" + docker buildx imagetools create "${ANNOTATIONS[@]}" "${NAMES[@]}" "${ARCH_TAGS[@]}" + # imagetools inherits the source media type and a Docker manifest + # list has nowhere to put annotations, so say when they were + # dropped rather than leaving it to be discovered later. + if ! docker buildx imagetools inspect --raw "${repo}:${TAG}" | jq -e '.annotations' >/dev/null 2>&1; then + echo "Note: ${repo}:${TAG} is not an OCI index, so index annotations were not stored." >&2 + fi PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${TAG}\`"$'\n' for arch in $ARCHES; do echo "Removing intermediary tag ${repo}:${TAG}-${arch}" diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 538957ec765..65594b749eb 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -101,6 +101,9 @@ jobs: run: | set -eu -o pipefail SANITIZED_BRANCH="$(echo "$ALIAS_BRANCH" | sed -E 's/[^A-Za-z0-9_.-]+/-/g')" + # The revision is this workflow's own checkout (the default branch), + # not the fork's, so record the commit the artifact was built from. + export DDEV_GIT_REVISION="${{ github.event.workflow_run.head_sha }}" declare -A TAG_BY_KEY declare -A REPOS_BY_KEY declare -A ARCHES_BY_KEY @@ -163,7 +166,13 @@ jobs: else echo "Alias '${alias_tag}' rejected; publishing only ${tag}" >&2 fi - docker buildx imagetools create "${names[@]}" "${arch_tags[@]}" + read -r -a annotations <<< "$(containers/image-metadata.sh annotations "$tag")" + docker buildx imagetools create "${annotations[@]}" "${names[@]}" "${arch_tags[@]}" + # See the same note in image-build-push.yml: a Docker manifest + # list has nowhere to store annotations. + if ! docker buildx imagetools inspect --raw "${repo}:${tag}" | jq -e '.annotations' >/dev/null 2>&1; then + echo "Note: ${repo}:${tag} is not an OCI index, so index annotations were not stored." >&2 + fi PUSHED_SUMMARY="${PUSHED_SUMMARY}- \`${repo}:${tag}\`"$'\n' for arch in ${ARCHES_BY_KEY[$key]}; do diff --git a/.github/workflows/push-tagged-dbimage.yml b/.github/workflows/push-tagged-dbimage.yml index e6fb9f849b4..2dfea5b2cc3 100644 --- a/.github/workflows/push-tagged-dbimage.yml +++ b/.github/workflows/push-tagged-dbimage.yml @@ -190,6 +190,8 @@ jobs: TAG: ${{ needs.variants.outputs.tag }} ALIAS: ${{ needs.variants.outputs.alias }} steps: + # Needed for containers/image-metadata.sh below. + - uses: actions/checkout@v7 - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 with: @@ -214,7 +216,8 @@ jobs: ORG_IMAGE=${DOCKER_ORG}/ddev-dbserver-$(echo "${variant}" | tr '_' '-') NAMES=(-t "${ORG_IMAGE}:${TAG}") [ -n "${ALIAS}" ] && NAMES+=(-t "${ORG_IMAGE}:${ALIAS}") - docker buildx imagetools create "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 + read -r -a ANNOTATIONS <<< "$(containers/image-metadata.sh annotations "${TAG}" "${ORG_IMAGE##*/}")" + docker buildx imagetools create "${ANNOTATIONS[@]}" "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then docker buildx imagetools create -t ${ORG_IMAGE}:latest ${ORG_IMAGE}:${TAG} fi diff --git a/.github/workflows/push-tagged-image.yml b/.github/workflows/push-tagged-image.yml index 4cddce4afe7..17977abb596 100644 --- a/.github/workflows/push-tagged-image.yml +++ b/.github/workflows/push-tagged-image.yml @@ -184,6 +184,8 @@ jobs: TAG: ${{ needs.resolve-tag.outputs.tag }} ALIAS: ${{ needs.resolve-tag.outputs.alias }} steps: + # Needed for containers/image-metadata.sh below. + - uses: actions/checkout@v7 - name: Load 1password secret(s) uses: 1password/load-secrets-action@v5 with: @@ -213,7 +215,8 @@ jobs: for ORG_IMAGE in ${MULTI_ARCH_IMAGES}; do NAMES=(-t "${ORG_IMAGE}:${TAG}") [ -n "${ALIAS}" ] && NAMES+=(-t "${ORG_IMAGE}:${ALIAS}") - docker buildx imagetools create "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 + read -r -a ANNOTATIONS <<< "$(containers/image-metadata.sh annotations "${TAG}" "${ORG_IMAGE##*/}")" + docker buildx imagetools create "${ANNOTATIONS[@]}" "${NAMES[@]}" ${ORG_IMAGE}:${TAG}-amd64 ${ORG_IMAGE}:${TAG}-arm64 if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then docker buildx imagetools create -t ${ORG_IMAGE}:latest ${ORG_IMAGE}:${TAG} fi diff --git a/containers/containers_shared.mk b/containers/containers_shared.mk index 4d5c3d4c070..030f7696d6e 100644 --- a/containers/containers_shared.mk +++ b/containers/containers_shared.mk @@ -13,10 +13,14 @@ DDEV_IMAGE_TAG ?= $(VERSION) DOTFILE_IMAGE = $(subst /,_,$(IMAGE))-$(VERSION) +# Standard OCI descriptive metadata (source, revision, created, ...) shared with +# every other image and with the push workflows - see containers/image-metadata.sh. +DDEV_IMAGE_LABELS = $(shell ../image-metadata.sh labels $(DDEV_IMAGE_TAG) $(notdir $(DOCKER_REPO))) + .PHONY: container push container: container-name - docker build -t $(DOCKER_REPO):$(VERSION) $(DOCKER_ARGS) --label "build-info=$(DOCKER_REPO):$(VERSION) commit=$(shell git describe --tags --always)" --label "com.ddev.image-tag=$(DDEV_IMAGE_TAG)" . + docker build -t $(DOCKER_REPO):$(VERSION) $(DOCKER_ARGS) --label "build-info=$(DOCKER_REPO):$(VERSION) commit=$(shell git describe --tags --always)" $(DDEV_IMAGE_LABELS) . container-name: @echo "container: $(DOCKER_REPO):$(VERSION)" @@ -35,6 +39,6 @@ push: $${tags} \ --label "build-info=$(DOCKER_ORG)/$${item}:$(VERSION) commit=$(shell git describe --tags --always) built $$(date) by $$(id -un) on $$(hostname)" \ --label "maintainer=DDEV " \ - --label "com.ddev.image-tag=$(DDEV_IMAGE_TAG)" \ + $$(../image-metadata.sh labels "$(DDEV_IMAGE_TAG)" "$${item}") \ $(DOCKER_ARGS) . ; \ done diff --git a/containers/ddev-dbserver/build_image.sh b/containers/ddev-dbserver/build_image.sh index 25e1486734d..9d86b436bd1 100755 --- a/containers/ddev-dbserver/build_image.sh +++ b/containers/ddev-dbserver/build_image.sh @@ -140,10 +140,15 @@ tag_directive="-t ${DOCKER_ORG}/ddev-dbserver-${DB_TYPE}-${DB_MAJOR_VERSION}:${I if [[ ${IMAGE_TAG} =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then tag_directive="$tag_directive -t ${DOCKER_ORG}/ddev-dbserver-${DB_TYPE}-${DB_MAJOR_VERSION}:latest" fi + +# Same OCI metadata every other image carries; this directory deliberately does +# not include containers_shared.mk, so it calls the generator itself. +label_directive="$("$(dirname "$0")/../image-metadata.sh" labels "${DDEV_IMAGE_TAG}" "ddev-dbserver-${DB_TYPE}-${DB_MAJOR_VERSION}")" + if [ ! -z ${PUSH:-} ]; then echo "building/pushing ddev/ddev-dbserver-${DB_TYPE}-${DB_MAJOR_VERSION}:${IMAGE_TAG}" set -x - docker buildx build --push --platform ${ARCHS} ${DOCKER_ARGS} --build-arg="BASE_IMAGE=${BASE_IMAGE}" --build-arg="DB_PINNED_VERSION=${DB_PINNED_VERSION}" --build-arg="DB_MAJOR_VERSION=${DB_MAJOR_VERSION}" --build-arg="DDEV_IMAGE_TAG=${DDEV_IMAGE_TAG}" ${tag_directive} . + docker buildx build --push --platform ${ARCHS} ${DOCKER_ARGS} --build-arg="BASE_IMAGE=${BASE_IMAGE}" --build-arg="DB_PINNED_VERSION=${DB_PINNED_VERSION}" --build-arg="DB_MAJOR_VERSION=${DB_MAJOR_VERSION}" --build-arg="DDEV_IMAGE_TAG=${DDEV_IMAGE_TAG}" ${label_directive} ${tag_directive} . set +x fi @@ -151,5 +156,5 @@ fi set -x if [ -z "${PUSH:-}" ]; then echo "Loading to local docker ddev/ddev-dbserver-${DB_TYPE}-${DB_MAJOR_VERSION}:${IMAGE_TAG}" - docker buildx build --load ${DOCKER_ARGS} --build-arg="DB_TYPE=${DB_TYPE}" --build-arg="DB_MAJOR_VERSION=${DB_MAJOR_VERSION}" --build-arg="BASE_IMAGE=${BASE_IMAGE}" --build-arg="DB_PINNED_VERSION=${DB_PINNED_VERSION}" --build-arg="DDEV_IMAGE_TAG=${DDEV_IMAGE_TAG}" ${tag_directive} . + docker buildx build --load ${DOCKER_ARGS} --build-arg="DB_TYPE=${DB_TYPE}" --build-arg="DB_MAJOR_VERSION=${DB_MAJOR_VERSION}" --build-arg="BASE_IMAGE=${BASE_IMAGE}" --build-arg="DB_PINNED_VERSION=${DB_PINNED_VERSION}" --build-arg="DDEV_IMAGE_TAG=${DDEV_IMAGE_TAG}" ${label_directive} ${tag_directive} . fi diff --git a/containers/ddev-webserver/Makefile b/containers/ddev-webserver/Makefile index 0fdd0e56fd8..59d32075812 100644 --- a/containers/ddev-webserver/Makefile +++ b/containers/ddev-webserver/Makefile @@ -16,7 +16,7 @@ DOCKER_REPO ?= $(DOCKER_ORG)/ddev-webserver images: $(DEFAULT_IMAGES) $(DEFAULT_IMAGES): - docker build --label com.ddev.buildhost=${shell hostname} --label com.ddev.image-tag=$(DDEV_IMAGE_TAG) --target=$@ -t $(DOCKER_ORG)/$@:$(VERSION) $(DOCKER_ARGS) . + docker build --label com.ddev.buildhost=${shell hostname} $(shell ../image-metadata.sh labels $(DDEV_IMAGE_TAG) $@) --target=$@ -t $(DOCKER_ORG)/$@:$(VERSION) $(DOCKER_ARGS) . test: images diff --git a/containers/image-metadata.sh b/containers/image-metadata.sh new file mode 100755 index 00000000000..cb8eac92b75 --- /dev/null +++ b/containers/image-metadata.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# image-metadata.sh [] +# +# One definition of the descriptive metadata every DDEV image carries, in +# whichever form the caller needs: +# +# pairs key=value lines +# labels --label "key=value" ... for a docker/buildx build +# annotations --annotation "index:key=value" ... for `imagetools create` +# +# Labels and annotations are not interchangeable, which is why both exist: +# +# - Labels live in the image config, so they travel with a `docker pull` and +# `docker inspect` shows them offline. That is what a support report needs. +# - Annotations live on the manifest index, which is what a tag points at, so +# they describe the tag as a whole rather than one platform, and a registry +# can show them without pulling. A tag has no comment field; an index +# annotation is the closest standard equivalent. +# +# Env: +# DDEV_GIT_REVISION - commit to record (default: current HEAD) +# SOURCE_DATE_EPOCH - build timestamp override, for reproducibility + +set -eu -o pipefail + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 <labels|annotations|pairs> <image-tag> [<title>]" >&2 + exit 2 +fi + +FORM="$1" +IMAGE_TAG="$2" +TITLE="${3:-}" + +REVISION="${DDEV_GIT_REVISION:-$(git rev-parse HEAD 2>/dev/null || echo unknown)}" +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + CREATED="$(date -u -r "$SOURCE_DATE_EPOCH" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d "@$SOURCE_DATE_EPOCH" +%Y-%m-%dT%H:%M:%SZ)" +else + CREATED="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fi + +PAIRS=( + "org.opencontainers.image.source=https://github.com/ddev/ddev" + "org.opencontainers.image.url=https://ddev.com" + "org.opencontainers.image.documentation=https://docs.ddev.com" + "org.opencontainers.image.vendor=DDEV" + "org.opencontainers.image.licenses=Apache-2.0" + "org.opencontainers.image.revision=${REVISION}" + "org.opencontainers.image.version=${IMAGE_TAG}" + "org.opencontainers.image.created=${CREATED}" + # The tag is a bare content hash, so this is the one field that says what + # the image is for without cross-referencing versionconstants.go. + "com.ddev.image-tag=${IMAGE_TAG}" +) +[ -n "$TITLE" ] && PAIRS+=("org.opencontainers.image.title=${TITLE}") + +case "$FORM" in + pairs) + printf '%s\n' "${PAIRS[@]}" + ;; + labels) + for p in "${PAIRS[@]}"; do printf -- '--label %q ' "$p"; done + echo + ;; + annotations) + for p in "${PAIRS[@]}"; do printf -- '--annotation %q ' "index:$p"; done + echo + ;; + *) + echo "$0: unknown form '$FORM'" >&2 + exit 2 + ;; +esac From 7fdc3578cac832079db34b273172f5f0d6f7bfad Mon Sep 17 00:00:00 2001 From: Randy Fay <randy@randyfay.com> Date: Sun, 16 Aug 2026 12:20:24 -0600 Subject: [PATCH 27/36] build(images): retag after the OCI metadata change, for #8609 Adding the label wiring touched containers_shared.mk, ddev-webserver/Makefile and ddev-dbserver/build_image.sh, all of which are inside their images' hash paths, so every tag moved. `make` rebuilt the changed images locally and rewrote versionconstants.go; without this the committed tags would name images CI is no longer building. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- pkg/versionconstants/versionconstants.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index e606a978c08..b08ff2907ef 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -28,16 +28,16 @@ var AmplitudeAPIKey = "" var WebImg = "ddev/ddev-webserver" // WebTag defines the default web image tag -var WebTag = "36bceca65e" // 20260721_rfay_content_addressed_image_tags-36bceca65e +var WebTag = "c202e92108" // 20260814_rfay_docker_update_phase_2-c202e92108 // WebTagBranch is the branch WebTag's content was built from. -var WebTagBranch = "20260721_rfay_content_addressed_image_tags" +var WebTagBranch = "20260814_rfay_docker_update_phase_2" // DBImg defines the default db image used for applications. var DBImg = "ddev/ddev-dbserver" // BaseDBTag is the main tag, DBTag is constructed from it -var BaseDBTag = "1dc90407ef" // 20260814_rfay_docker_update_phase_2-1dc90407ef +var BaseDBTag = "5a7c45ddbf" // 20260814_rfay_docker_update_phase_2-5a7c45ddbf // BaseDBTagBranch is the branch BaseDBTag's content was built from. var BaseDBTagBranch = "20260814_rfay_docker_update_phase_2" @@ -46,28 +46,28 @@ var BaseDBTagBranch = "20260814_rfay_docker_update_phase_2" var TraefikRouterImage = "ddev/ddev-traefik-router" // TraefikRouterTag is traefik router tag -var TraefikRouterTag = "c96123b524" // 20260721_rfay_content_addressed_image_tags-c96123b524 +var TraefikRouterTag = "bffcda31c5" // 20260814_rfay_docker_update_phase_2-bffcda31c5 // TraefikRouterTagBranch is the branch TraefikRouterTag's content was built from. -var TraefikRouterTagBranch = "20260721_rfay_content_addressed_image_tags" +var TraefikRouterTagBranch = "20260814_rfay_docker_update_phase_2" // SSHAuthImage is image for agent var SSHAuthImage = "ddev/ddev-ssh-agent" // SSHAuthTag is ssh-agent auth tag -var SSHAuthTag = "8e8bf1217c" // 20260721_rfay_content_addressed_image_tags-8e8bf1217c +var SSHAuthTag = "bb5e9f0003" // 20260814_rfay_docker_update_phase_2-bb5e9f0003 // SSHAuthTagBranch is the branch SSHAuthTag's content was built from. -var SSHAuthTagBranch = "20260721_rfay_content_addressed_image_tags" +var SSHAuthTagBranch = "20260814_rfay_docker_update_phase_2" // XhguiImage is image for xhgui var XhguiImage = "ddev/ddev-xhgui" // XhguiTag is xhgui tag -var XhguiTag = "f046b66382" // 20260721_rfay_content_addressed_image_tags-f046b66382 +var XhguiTag = "8757c1e92a" // 20260814_rfay_docker_update_phase_2-8757c1e92a // XhguiTagBranch is the branch XhguiTag's content was built from. -var XhguiTagBranch = "20260721_rfay_content_addressed_image_tags" +var XhguiTagBranch = "20260814_rfay_docker_update_phase_2" // UtilitiesImage is used in bash scripts var UtilitiesImage = "ddev/ddev-utilities:latest" From 0e6fdcc39746712aa42ec6d655e36ca648928da4 Mon Sep 17 00:00:00 2001 From: Randy Fay <randy@randyfay.com> Date: Sun, 16 Aug 2026 12:23:00 -0600 Subject: [PATCH 28/36] feat(build): fail staticrequired when versionconstants.go is stale, for #8609 Adding the OCI labels changed containers_shared.mk and two other files inside image hash paths, which moved every tag - and nothing caught that the commit went out without running `make`, leaving versionconstants.go naming images CI no longer builds. The pre-commit and pre-push hooks both run `make staticrequired`, so that is the place to notice. containers/check-image-tags.sh compares each tag against the current content hash and fails naming the stale variables and the fix. It is a check rather than a fix on purpose: correcting it means building the changed image, which is minutes of Docker work that has no business running inside a commit hook. No Docker and no network here - it costs about a second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- Makefile | 9 +++++- containers/check-image-tags.sh | 42 +++++++++++++++++++++++++++ containers/required_image_tag_test.sh | 41 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100755 containers/check-image-tags.sh diff --git a/Makefile b/Makefile index 3a9e316d03e..f3e8a3f71ad 100644 --- a/Makefile +++ b/Makefile @@ -230,7 +230,14 @@ setup: @mkdir -p $(TESTTMP) # Required static analysis targets for pre-push. -staticrequired: setup golangci-lint markdownlint zensical +staticrequired: setup golangci-lint markdownlint zensical check-image-tags + +# A changed image whose tag never made it into versionconstants.go ships a +# binary pulling tags nothing builds. Checked rather than fixed here, because +# fixing means a Docker build - run `make` for that. +.PHONY: check-image-tags +check-image-tags: + @containers/check-image-tags.sh # Fail rather than skip when a required tool is absent. These targets used to # print a note and exit 0, so `make staticrequired` reported success while diff --git a/containers/check-image-tags.sh b/containers/check-image-tags.sh new file mode 100755 index 00000000000..833adf06a13 --- /dev/null +++ b/containers/check-image-tags.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# check-image-tags.sh +# +# Fails when versionconstants.go names a tag that no longer matches the content +# under containers/, which happens whenever an image changes and `make` hasn't +# been run since. Left uncaught, that ships a binary pulling tags nothing +# builds, so this runs as part of `make staticrequired` - the one target both +# the pre-commit and pre-push hooks already insist on. +# +# Deliberately a check and not a fix: correcting it means building the changed +# image, which is minutes of Docker work and has no business happening inside a +# commit hook. This only compares hashes - no Docker, no network. + +set -eu -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=containers/image-configs.sh +source "$SCRIPT_DIR/image-configs.sh" + +STALE=() +SEEN="" +for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r _ tag_var hash_paths _ <<< "$entry" + # Every ddev-dbserver variant shares BaseDBTag; check each variable once. + case " $SEEN " in *" $tag_var "*) continue ;; esac + SEEN="$SEEN $tag_var" + + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + read -r state tag <<< "$("$SCRIPT_DIR/required-image-tag.sh" "$tag_var" $hash_paths)" + [ "$state" = "committed" ] || STALE+=("${tag_var} -> ${tag}") +done + +if [ "${#STALE[@]}" -eq 0 ]; then + echo "check-image-tags.sh: versionconstants.go is up to date with containers/" + exit 0 +fi + +echo "check-image-tags.sh: containers/ changed but versionconstants.go wasn't updated:" >&2 +printf ' %s\n' "${STALE[@]}" >&2 +echo "check-image-tags.sh: run 'make' to rebuild the changed image(s) and rewrite those tags, then commit the result." >&2 +exit 1 diff --git a/containers/required_image_tag_test.sh b/containers/required_image_tag_test.sh index 864f2a7b526..50de5d875ca 100755 --- a/containers/required_image_tag_test.sh +++ b/containers/required_image_tag_test.sh @@ -118,6 +118,47 @@ else pass "refuses an unknown image" fi +# --- check-image-tags.sh, the `make staticrequired` gate. Driven with a +# throwaway versionconstants.go so it can be failed on purpose. +CHECK_IMAGE_TAGS="$SCRIPT_DIR/check-image-tags.sh" + +# shellcheck source=containers/image-configs.sh +source "$SCRIPT_DIR/image-configs.sh" + +CURRENT_FILE="$WORKDIR/current_versionconstants.go" +: > "$CURRENT_FILE" +seen="" +for entry in "${DDEV_IMAGE_CONFIGS[@]}"; do + IFS='|' read -r _ tag_var hash_paths _ <<< "$entry" + case " $seen " in *" $tag_var "*) continue ;; esac + seen="$seen $tag_var" + # shellcheck disable=SC2086 # hash_paths is a space-separated path list + echo "var ${tag_var} = \"$("$HASH_PATHS" $hash_paths)\"" >> "$CURRENT_FILE" +done + +if VERSIONCONSTANTS_FILE="$CURRENT_FILE" "$CHECK_IMAGE_TAGS" >/dev/null 2>&1; then + pass "check-image-tags.sh passes when every tag matches the content" +else + fail "check-image-tags.sh should pass when every tag matches the content" +fi + +STALE_FILE="$WORKDIR/stale_versionconstants.go" +sed 's/= "[0-9a-f]*"/= "0000000000"/' "$CURRENT_FILE" > "$STALE_FILE" +OUTPUT="$(VERSIONCONSTANTS_FILE="$STALE_FILE" "$CHECK_IMAGE_TAGS" 2>&1)" && RC=0 || RC=$? +if [ "$RC" -ne 0 ]; then + pass "check-image-tags.sh fails when versionconstants.go is stale" +else + fail "check-image-tags.sh should fail when versionconstants.go is stale" +fi +case "$OUTPUT" in + *"run 'make'"*) pass "the failure says how to fix it" ;; + *) fail "the failure should tell the contributor to run make: $OUTPUT" ;; +esac +case "$OUTPUT" in + *WebTag*BaseDBTag*|*BaseDBTag*WebTag*) pass "the failure names which tags are stale" ;; + *) fail "the failure should name the stale tags: $OUTPUT" ;; +esac + if [ "$FAILURES" -eq 0 ]; then echo "All required_image_tag_test.sh checks passed." exit 0 From 841c18c1b2d29b18c832a879fa1fe855c42139fa Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Sun, 16 Aug 2026 22:37:44 +0000 Subject: [PATCH 29/36] fix(ci): resolve fork PRs by querying the fork's own repo for the comment step, for #8609 `listPullRequestsAssociatedWithCommit` only finds a commit within the repo it's queried against. The push-comment step always queried the base repo, so it silently skipped commenting on any fork PR - a fork-authored commit never lives in the base repo's own history - while still reporting success. Query `workflow_run.head_repository` (the fork, for a fork PR) instead, then filter the results to PRs whose base actually is this repo, in case the commit is associated with an unrelated PR elsewhere. Found while manually testing PR #8707's fork-PR path on ddev-test/ddev. --- .github/workflows/image-push.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 65594b749eb..620dbceb057 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -196,13 +196,24 @@ jobs: 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, + const headRepo = context.payload.workflow_run.head_repository; + if (!headRepo) { + console.log(`No head repository on the workflow_run event for ${headSha}, skipping comment.`); + return; + } + // Query the commit's own repository (the fork, for a fork PR) - + // listPullRequestsAssociatedWithCommit only finds a commit within + // the repo it's queried against, and a fork-authored commit never + // lives in the base repo's own history. + const { data: candidatePrs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: headRepo.owner.login, + repo: headRepo.name, commit_sha: headSha, }); + const prs = candidatePrs.filter((pr) => + pr.base.repo.owner.login === context.repo.owner && pr.base.repo.name === context.repo.repo); if (prs.length === 0) { - console.log(`No pull request associated with ${headSha}, skipping comment.`); + console.log(`No pull request into ${context.repo.owner}/${context.repo.repo} associated with ${headSha} (queried ${headRepo.full_name}), skipping comment.`); return; } // Read from env, not spliced into the script source, since the From c7866e555d06b254448044e33eaab257a7107ec6 Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Sun, 16 Aug 2026 23:15:01 +0000 Subject: [PATCH 30/36] fix(ci): read DOCKER_ORG from the public-variables branch for fork PRs, for #8609 vars.DOCKER_ORG isn't available to a fork-triggered pull_request run, so detect and build silently fell back to the hardcoded literal 'ddev' regardless of what a repo's real DOCKER_ORG is set to. That's dormant on ddev/ddev today (DOCKER_ORG there already is 'ddev'), but breaks any repo that configures a different org, and would break ddev/ddev itself if that variable ever changes. Reuse the existing public-variables mechanism (already used by test-reusable.yml and friends for exactly this class of problem: repo variables that need to be visible to fork PRs). Both jobs only fall back to it when vars.DOCKER_ORG is empty, so a same-repo run - where vars. DOCKER_ORG is reliably available - keeps using this repo's own value instead of the canonical one published on ddev/ddev's public-variables branch. Requires a maintainer to push DOCKER_ORG=ddev to ddev/ddev's public-variables branch (protected, no PR needed - see .github/public-variables/README.md) before this takes effect; until then the existing hardcoded 'ddev' fallback keeps working exactly as before. Found and verified while manually testing PR #8707's fork-PR path on ddev-test/ddev. --- .github/public-variables/README.md | 7 +++++-- .github/workflows/image-build-push.yml | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/public-variables/README.md b/.github/public-variables/README.md index 8576535ff40..71ae6218642 100644 --- a/.github/public-variables/README.md +++ b/.github/public-variables/README.md @@ -20,7 +20,9 @@ Current variables: - **Bats tests:** each pattern is matched as a case-sensitive substring against the bats filename (without `.bats`) or the `@test` description. E.g. `sveltekit` skips all tests in `sveltekit.bats`; `Symfony Composer` skips only the Composer-flavored test in `symfony.bats`. Go and bats patterns can be combined: `TestLagoonPull|sveltekit`. - `workflow_dispatch` runs skip loading the `public-variables` branch entirely, so maintainers can verify fixes without removing them from the embargo list first. - `DDEV_EMBARGO_PHP_VERSIONS` - comma-separated PHP versions to skip in `TestPHPConfig`, e.g. `7.0,7.1` -- `DOCKER_ORG` - Default `hub.docker.com` organization to use, nearly always `ddev`. +- `DOCKER_ORG` - Default `hub.docker.com` organization to use, nearly always `ddev`. `vars.DOCKER_ORG` + isn't available to a fork PR's untrusted `detect`/`build` jobs in `image-build-push.yml`, so those + jobs read it from here instead. ## Adding a new variable @@ -43,7 +45,8 @@ No workflow changes are needed - any file in this directory is picked up automat ## How it works Used in `.buildkite/test.sh`, `.github/workflows/test-reusable.yml`, -`.github/workflows/test-wsl2-reusable.yml`, and `.github/workflows/quickstart.yml`. +`.github/workflows/test-wsl2-reusable.yml`, `.github/workflows/quickstart.yml`, and +`.github/workflows/image-build-push.yml`. Each CI run does `git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp`, reads all files via `git ls-tree` + `git show`, then deletes the temporary ref. diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index f8248013e81..aafe46be503 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -48,6 +48,16 @@ jobs: is_fork: ${{ steps.fork.outputs.is_fork }} steps: - uses: actions/checkout@v7 + - name: Load DOCKER_ORG from public-variables branch + # Fork PRs can't see vars.DOCKER_ORG; fall back only then, so a + # same-repo run keeps this repo's own value. See + # .github/public-variables/README.md. + if: vars.DOCKER_ORG == '' + run: | + git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp + value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG)" + [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" + git update-ref -d refs/public-variables-tmp - name: Compute per-image build status id: detect # github.head_ref is attacker-controlled (a fork may name its branch @@ -136,6 +146,15 @@ jobs: contents: read steps: - uses: actions/checkout@v7 + - name: Load DOCKER_ORG from public-variables branch + # This job only runs for fork PRs, where vars.DOCKER_ORG is never + # available - see .github/public-variables/README.md. + if: vars.DOCKER_ORG == '' + run: | + git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp + value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG)" + [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" + git update-ref -d refs/public-variables-tmp - name: Build ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }} run: | set -eu -o pipefail From 0625d2b8e7c2571ffa48325a5f5c9619b1ec63dd Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Sun, 16 Aug 2026 23:20:17 +0000 Subject: [PATCH 31/36] fix(ci): tolerate a missing DOCKER_ORG file on the public-variables branch GitHub Actions bash steps default to `-e`, so git show's failure on a missing file aborted the whole step instead of falling through to the existing hardcoded fallback. Suppress and treat as empty instead. --- .github/workflows/image-build-push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index aafe46be503..da61cd2f0a6 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -55,7 +55,7 @@ jobs: if: vars.DOCKER_ORG == '' run: | git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp - value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG)" + value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG 2>/dev/null || true)" [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" git update-ref -d refs/public-variables-tmp - name: Compute per-image build status @@ -152,7 +152,7 @@ jobs: if: vars.DOCKER_ORG == '' run: | git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp - value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG)" + value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG 2>/dev/null || true)" [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" git update-ref -d refs/public-variables-tmp - name: Build ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }} From b0dc1a79d0a45fe379fa5910998ea68775146113 Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Mon, 17 Aug 2026 01:00:57 +0000 Subject: [PATCH 32/36] docs: remove HANDOFF.md now that its manual test plan is complete, for #8609 Every item in the plan has run on ddev-test/ddev: the two MUST-TEST items, all eight other-human-verification items (two accepted with a noted limitation), and the two bugs it flagged are fixed. Results are posted as a PR comment, so the file has no more information than the PR itself. --- HANDOFF.md | 278 ----------------------------------------------------- 1 file changed, 278 deletions(-) delete mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md deleted file mode 100644 index cbd7ccf814a..00000000000 --- a/HANDOFF.md +++ /dev/null @@ -1,278 +0,0 @@ -# HANDOFF — PR #8707 review fixes (#8609 phase 2) - -Temporary file. Delete before merging. - -Branch `20260814_rfay_docker_update_phase_2`. Nothing has been pushed. - -## MUST TEST BEFORE MERGE - -### 0. Every image has to be republished at its bare-hash tag - -Tags are now bare content hashes (`ddev/ddev-webserver:36bceca65e`), which -invalidates every previously published content-hash tag. Nothing in either -organization carries the new tags yet — confirmed missing for webserver, -ssh-agent, and the db variants. **Integration tests cannot pull until the -first CI run publishes them**, which is 44 build jobs across 24 images. -Locally, `go test -run TestCmdVersion ./cmd/ddev/cmd/...` currently fails with -`manifest for ddev/ddev-ssh-agent:8e8bf1217c not found`, which is this and -nothing else. - -This is a one-time migration cost of the scheme change; after that first push -the tags stop depending on branch or organization, which is the whole point. - -Two further things have never run in CI and no test harness covers. Both are -cheap to get wrong and expensive to discover after merge. - -### 1. A `containers/ddev-dbserver` change must build and push all 20 variants - -This is the bug that motivated the second round of fixes, and this PR is -itself the first live test of the fix — adding `variants.txt` under -`containers/ddev-dbserver/` changed that directory's hash, so `BaseDBTag` is -now `20260814_rfay_docker_update_phase_2-1dc90407ef` and **CI must build and -push 36 jobs across 20 repositories before any test using a non-default -database can pass.** - -Watch for, on this PR's first real CI run: - -- `detect` reports 36 build jobs / 20 images, not 1. -- `create-manifests` (or `image-push`) produces a multi-arch manifest for - every one of the 20 `ddev/ddev-dbserver-*` repos at that tag, with the four - oldest (`mariadb-5.5`, `mariadb-10.0`, `mysql-5.5`, `mysql-5.6`) amd64-only. -- `TestDdevAllDatabases` passes, along with the tests in `db_test.go`, - `snapshot_test.go`, `config_test.go`, and `debug-migrate-database_test.go` - that pin a non-default database. - -Verify independently of CI's own reporting: - -```bash -TAG=$(grep -E '^var BaseDBTag' pkg/versionconstants/versionconstants.go | sed -E 's/.*"([^"]*)".*/\1/') -for r in $(containers/ddev-dbserver/variants.sh repos); do - printf '%-34s ' "$r"; containers/registry-tag-exists.sh "ddev/$r" "$TAG" && echo EXISTS || echo MISSING -done -``` - -Every line must say EXISTS. Any MISSING means the matrix regressed and -non-default database tests will fail. - -Note that item 1 below was written before the tag scheme changed: the tag to -check is now the bare `BaseDBTag` hash, not a branch-prefixed string, and the -`variants.sh repos` loop still works unchanged. - -### 2. `push-tagged-dbimage.yml` still works after the DRY refactor - -Its matrix, its `MULTI_ARCH_IMAGES` list, and its multi-arch/single-arch -decision were three separate hardcoded copies of the variant list; all three -now come from `variants.sh`. This is the release-time push path, so a mistake -here surfaces during a release. - -Its `tag` input is now optional — left empty it derives the tag the checkout -needs, which is the fix for having to transcribe `main-1dc90407ef` by hand. -Test **both** paths: empty (derives the bare hash) and an explicit `vX.Y.Z` -(the release path). - -Run it manually on `ddev-test/ddev` and confirm: - -- The `variants` job runs first and its matrix expands to **36** `build-db-arch` - jobs — the same count as before (20 variants × 2 arches, minus 4 arm64 - exclusions). -- With an empty tag input, the resolve step logs the derived tag and it matches - `BaseDBTag` in `versionconstants.go`. -- The four amd64-only variants get `multi_arch=false` in their `meta` step and - push an unsuffixed tag; the other 16 get `multi_arch=true` and push - `-amd64`/`-arm64`. -- `create-manifests` combines exactly the 16 multi-arch variants and deletes - the intermediary per-arch tags. -- All 20 repos carry the throwaway tag at the end. - -Locally I confirmed the generated lists are byte-identical to the ones they -replaced (`build-targets` for both host arches, `single-arch-targets`, -`test-targets` for both, and the `MULTI_ARCH_IMAGES` set), and that -`make -n` still resolves a target from each list including the amd64-only -ones under `CURRENT_ARCH=amd64`. That is static equivalence, not a live run. - -## Round 1 fixes — review findings - -### The tag-resolution bug (blocking) - -`wait-for-images.sh` computed `<current-branch>-<hash>`, but the tag ddev -pulls is the one committed in `versionconstants.go`, and `autotag.sh` rewrites -that line (branch prefix included) only when the hash changes. The two agree -only on a branch that changed the image, so any PR not touching `containers/` -would poll 20 minutes and fail every Buildkite and GitHub test job. `detect` -had the mirror-image bug: it re-pushed all images under a fresh branch prefix -on any `containers/` change, no-op or not. - -`containers/required-image-tag.sh` now resolves the tag once, for both -callers, reporting `committed` (hash matches — wait for that exact tag) or -`recomputed` (content changed — `make` builds it locally). This also removed -the `WAIT_FOR_IMAGES_BRANCH` plumbing from four callers, since the branch name -is no longer needed. - -### Security - -- `github.head_ref` reached a `run:` block spliced into the script. Git ref - names permit quotes and backticks, and `detect` emits `is_fork`, so injected - code could set `is_fork=false` and route fork content into `build-and-push`, - the job that loads `PUSH_SERVICE_ACCOUNT_TOKEN`. It now arrives via `env:`, - and `is_fork` moved to its own step. -- `image-push.yml` validated the tag but pushed to whatever repository names - the fork-produced artifact listed. `validate-image-repo.sh` constrains them - to `$DOCKER_ORG` plus an exact allowlist. - -### Silent failures - -- `image-push.yml` lacked `actions: read` for a cross-run artifact download, - and `continue-on-error` turned that into a "nothing needed pushing" comment. - A new ungated `check-artifacts` job gates the environment job instead, so a - fork build with nothing to push no longer asks for approval, and a failed - download is now fatal. -- Artifact retention 1 → 7 days; the gate is a human approval. - -### Smaller - -- `DDEV_IMAGE_TAG` was not passed, so `com.ddev.image-tag` recorded - `<tag>-<arch>` rather than the tag people pull, which - `imageVersionMismatch()` compares against. -- `DOCKER_ORG` falls back to `ddev` instead of producing `/ddev-webserver`. -- `validate-image-tag.sh`'s reserved-literal and `vX.Y.Z` checks were - unreachable behind the format check; they now test the part before the hash. -- BSD `wc -l` padding failed 5 checks on macOS. - -## Round 2 — the db variant matrix - -`GetDBImage()` in `pkg/docker/images.go` builds every variant's reference from -one shared `BaseDBTag`, so a dbserver change moves the tag for all 20 while -`make` built only `mariadb_11.8` and CI pushed only `ddev-dbserver-mariadb-11.8`. -The other 19 were referenced at a tag that existed nowhere. Introduced by -phase 1 (#8612): before that, `BaseDBTag` was hand-bumped after someone ran -`push-tagged-dbimage.yml` for all 20. - -Fixed by making `detect`'s matrix cover every variant. The build matrix is now -one entry per (image, arch) rather than a cross product, because the oldest -variants are amd64-only, and `create-manifests` takes its arch list from -`detect` instead of assuming both. Artifact names key on `repo_suffix`, not -`make_dir` — all 20 db variants share a `make_dir` and would have collided. - -`wait-for-images.sh` now also fails fast, with the command to run, when a -non-locally-built image is out of date. There is no local fallback for those -19 variants, and the tag `make` would invent depends on the runner's branch -name (detached HEAD on a PR checkout), so it may not match what CI pushed. - -### DRY - -The variant list was duplicated in four places. It now lives in -`containers/ddev-dbserver/variants.txt`, read through `variants.sh`, which -renders each consumer's view: - -| Consumer | View | -| --- | --- | -| `containers/ddev-dbserver/Makefile` | `build-targets`, `single-arch-targets`, `test-targets` | -| `containers/image-configs.sh` | `list` | -| `containers/validate-image-repo.sh` | `repos` | -| `.github/workflows/push-tagged-dbimage.yml` | `json`, `multi-arch-variants` | - -`variants.txt` sits inside the hashed dbserver directory on purpose: adding a -database version has to change the content hash, or `detect` would decide the -tag already exists and never build the new variant. - -## Test status - -127 checks across seven harnesses, all passing locally (macOS), all wired into -`container-tests.yml`: - -| Harness | Checks | -| --- | --- | -| `containers/autotag_test.sh` | 18 | -| `containers/db_variants_test.sh` | 15 (new) | -| `containers/registry_tag_exists_test.sh` | 4 | -| `containers/required_image_tag_test.sh` | 12 (new) | -| `containers/validate_image_repo_test.sh` | 37 (new) | -| `containers/validate_image_tag_test.sh` | 20 | -| `containers/wait_for_images_test.sh` | 21 | - -`shellcheck -x` clean on all new and changed scripts. `actionlint` clean on -all changed workflows apart from pre-existing SC2086/SC2046 notes in untouched -parts of `test-reusable.yml`. - -## Verification Claude ran - -Read-only against the real registry, plus this checkout. Nothing pushed. - -1. All seven harnesses. -2. `wait-for-images.sh` finds the four non-db images and correctly waits on the - db variants at the new `BaseDBTag`. -3. `detect` dry run: `matrix=[]` when nothing changed; 36 build jobs / 20 - manifests after a dbserver change; only xhgui after an xhgui change. -4. Hostile branch name `evil"; id; #` sanitizes to `evil-id-`, no execution. -5. Generated db lists byte-identical to the four hardcoded copies they replace; - `make -n` resolves a target from each list on both host arches. -6. `make autotag-images` built `mariadb_11.8` locally and rewrote `BaseDBTag`. -7. `docker buildx imagetools inspect` works with no Docker daemon running, - so the early placement of the wait step in the Buildkite scripts is fine. - -Not run: the artifact round-trip against a local `registry:2` (worth doing — -it needs no secrets and would exercise the multi-arch `imagetools create` -grouping that no unit test covers), and a full `make` build. - -## Other human verification - -On `ddev-test/ddev`, with the `image-push` environment configured. Confirm the -rule actually took: `gh api repos/<owner>/<repo>/environments/image-push` — an -environment referenced before it exists is auto-created with no protection. - -1. **A PR touching no `containers/` file.** Five `found …` lines within seconds, - then the tests run. This is the round-1 blocking fix and has never run in CI. -2. **A `containers/` file that isn't in any hash path.** `detect` reports - `already exists (committed)` for everything and builds nothing. -3. **An `ddev-xhgui` change on a same-repo branch.** Only xhgui builds; no - approval; `com.ddev.image-tag` on the result is the final tag, not - `<tag>-amd64`. -4. **The same from a fork.** No secret-loading step in `build`; exactly one - approval; the download succeeds rather than silently commenting - "nothing needed pushing". -5. **A fork PR touching `containers/` with no image change.** No approval - request at all. -6. **Adversarial artifact.** Overwrite `repos.txt` with `ddev/ddev-webserver` - before upload; the push must fail at `validate-image-repo.sh`. -7. **Hostile branch name** ``test`touch /tmp/pwned` ``; check the `detect` log. -8. **Expired artifact.** Approve after `retention-days`; must fail loudly. - -## Round 3 — hash-only tags - -The branch prefix in a tag carried no information but forced every consumer to -agree on a string. Tags are now the bare content hash; the branch survives as a -trailing comment in `versionconstants.go`, a `<Name>TagBranch` variable shown by -`ddev version` (`image-tag-branches`, collapsed when all images share a -branch), and a `<branch>-<hash>` alias published off the same manifest. - -`validate-image-tag.sh` accepts both forms, and the alias goes through it, so a -fork branch named `v1.25.0` can't publish something that reads as a release — -which finally makes the reserved/release checks load-bearing rather than dead. - -Comparison against `versionconstants.go` is exact now, so a line still in the -old form counts as stale and `make` migrates it. `wait-for-images.sh` lost its -fail-fast branch: the tag a non-locally-built image needs is branch-independent, -so waiting for it is well defined. - -The manual push workflows' `tag` input is optional; empty derives the tag via -`containers/image-tag-for.sh`. - -## Still open - -- **Fork artifact volume.** A fork changing `ddev-dbserver` now produces 36 - `docker save` tarballs. Local db images are 550–730MB, so that is roughly - 20GB of artifacts for one PR, compressed by `upload-artifact` but still - large, and now retained 7 days. This is the cost of building the full matrix - on the untrusted path; if it proves impractical, the fallback is to keep the - full matrix on the non-fork path and have forks use - `push-tagged-dbimage.yml`. Watch the first fork-side dbserver PR. -- **`actions: read` on `download-artifact@v8`** is added on the documented - requirement; not yet observed on a live run. -- **Registry pollution has no cleanup path.** Each image change adds tags that - are never removed — now 20 at a time for a dbserver change. -- **`registry-tag-exists.sh` cannot distinguish "missing" from "registry - unreachable."** A DockerHub blip costs a redundant rebuild in `detect` or 20 - minutes and a red build in `wait-for-images.sh`. It now makes 24 registry - calls per test run instead of 5, so the odds are higher. -- **The PR description** was rewritten for round 1 but does not yet mention the - db variant matrix. From d4db3fa77e54e8a2a50a2f3c15701d5eb7cc0969 Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Mon, 17 Aug 2026 01:16:40 +0000 Subject: [PATCH 33/36] fix(ci): stop the public-variables loader from clobbering a set DOCKER_ORG, for #8609 Every generic public-variables loader (test-reusable.yml, test-wsl2-reusable.yml, .buildkite/test.sh) runs unconditionally and overwrites every variable it finds, including DOCKER_ORG now that #8711 added it. That's correct for variables like DDEV_EMBARGO_TESTS, which must apply everywhere unconditionally, but DOCKER_ORG is only meant as a fork-PR fallback: a same-repo run's own vars.DOCKER_ORG (e.g. ddev-test/ddev's ddevhq) was getting silently replaced by whatever the public-variables branch publishes for forks. Found while reviewing this PR - it would have broken the very Buildkite test being set up to validate it. Each loader now skips DOCKER_ORG when it's already non-empty, the same way it already skips README.md. Also fixes autotag.sh:77, a duplicate of a pattern this PR already fixed once in required-image-tag.sh: a missing `var <Tag> = ...` line made the grep|sed pipeline fail under `set -e`, killing the script before it reached its own "could not find" error message. Added the same `|| true` required-image-tag.sh uses, plus a test case (autotag_test.sh) that would have caught the regression. --- .buildkite/test.sh | 2 ++ .github/public-variables/README.md | 5 ++++- .github/workflows/test-reusable.yml | 2 ++ .github/workflows/test-wsl2-reusable.yml | 2 ++ containers/autotag.sh | 2 +- containers/autotag_test.sh | 20 ++++++++++++++++++++ 6 files changed, 31 insertions(+), 2 deletions(-) diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 3adbc442073..1cd0de70237 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -18,6 +18,8 @@ fi git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp while IFS= read -r varname; do [[ "$varname" == "README.md" ]] && continue + # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. + [[ "$varname" == "DOCKER_ORG" && -n "${DOCKER_ORG:-}" ]] && continue # MSYS_NO_PATHCONV prevents Git for Windows bash from mangling the ref:path syntax value=$(MSYS_NO_PATHCONV=1 git show "refs/public-variables-tmp:.github/public-variables/$varname") echo "$varname=${value}" diff --git a/.github/public-variables/README.md b/.github/public-variables/README.md index 71ae6218642..39c27c08c8e 100644 --- a/.github/public-variables/README.md +++ b/.github/public-variables/README.md @@ -22,7 +22,10 @@ Current variables: - `DDEV_EMBARGO_PHP_VERSIONS` - comma-separated PHP versions to skip in `TestPHPConfig`, e.g. `7.0,7.1` - `DOCKER_ORG` - Default `hub.docker.com` organization to use, nearly always `ddev`. `vars.DOCKER_ORG` isn't available to a fork PR's untrusted `detect`/`build` jobs in `image-build-push.yml`, so those - jobs read it from here instead. + jobs read it from here instead. Unlike the other variables below, this one is a fallback, not an + override: every loader skips it when the job's own `DOCKER_ORG` is already non-empty, so a + same-repo run (or one that sets its own value, e.g. `ddev-test/ddev`'s `ddevhq`) never gets + clobbered by the value published here for forks. ## Adding a new variable diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index e5820fb0532..98ddc04eb81 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -147,6 +147,8 @@ jobs: git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp while IFS= read -r varname; do [[ "$varname" == "README.md" ]] && continue + # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. + [[ "$varname" == "DOCKER_ORG" && -n "${DOCKER_ORG:-}" ]] && continue value=$(git show "refs/public-variables-tmp:.github/public-variables/$varname") echo "$varname=${value}" echo "$varname=$value" >> $GITHUB_ENV diff --git a/.github/workflows/test-wsl2-reusable.yml b/.github/workflows/test-wsl2-reusable.yml index 698a73560d3..952fee06a37 100644 --- a/.github/workflows/test-wsl2-reusable.yml +++ b/.github/workflows/test-wsl2-reusable.yml @@ -137,6 +137,8 @@ jobs: $files = git ls-tree --name-only refs/public-variables-tmp:.github/public-variables/ foreach ($varname in $files) { if ($varname -eq "README.md") { continue } + # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. + if ($varname -eq "DOCKER_ORG" -and $env:DOCKER_ORG) { continue } $value = git show "refs/public-variables-tmp:.github/public-variables/$varname" Write-Host "$varname=$value" "$varname=$value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/containers/autotag.sh b/containers/autotag.sh index 7efc80afe04..f32e5c0f501 100755 --- a/containers/autotag.sh +++ b/containers/autotag.sh @@ -74,7 +74,7 @@ if [ "$PRINT_ONLY" = true ]; then exit 0 fi -EXISTING_TAG="$(grep -E "^var ${TAG_VAR} = " "$VERSIONCONSTANTS_FILE" | sed -E "s/^var ${TAG_VAR} = \"([^\"]*)\".*/\\1/")" +EXISTING_TAG="$(grep -E "^var ${TAG_VAR} = " "$VERSIONCONSTANTS_FILE" 2>/dev/null | sed -E "s/^var ${TAG_VAR} = \"([^\"]*)\".*/\\1/" || true)" if [ -z "$EXISTING_TAG" ]; then echo "autotag.sh: could not find 'var ${TAG_VAR} = \"...\"' in $VERSIONCONSTANTS_FILE" >&2 exit 1 diff --git a/containers/autotag_test.sh b/containers/autotag_test.sh index 96ed74fa401..93d5e2e6fc3 100755 --- a/containers/autotag_test.sh +++ b/containers/autotag_test.sh @@ -211,6 +211,26 @@ assert_eq "var WebTag = \"${new_hash}\" // ${current_branch}-${new_hash}" \ "$(grep '^var WebTag = ' "$VERSIONCONSTANTS")" \ "an old <branch>-<hash> value is migrated to the bare hash" +# 10. A missing tag variable is a hard error, not an unrelated failure. +cat > "$VERSIONCONSTANTS" <<'EOF' +package versionconstants + +var XhguiTag = "v1.0.0" // some-old-branch-v1.0.0 +EOF +set +e +OUTPUT="$("$AUTOTAG" WebTag ddev/dummy-image imgdir 2>&1)" +STATUS=$? +set -e +if [ "$STATUS" -eq 0 ]; then + fail "should reject a versionconstants file missing the requested tag variable" +else + pass "rejects a versionconstants file missing the requested tag variable" +fi +case "$OUTPUT" in + *"could not find"*WebTag*) pass "missing-variable message names the variable" ;; + *) fail "missing-variable message should name the variable: $OUTPUT" ;; +esac + if [ "$FAILURES" -eq 0 ]; then echo "All autotag_test.sh checks passed." exit 0 From a63c5d223f2b07841da5ba77b144a3fd85f9b9ec Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Mon, 17 Aug 2026 01:17:37 +0000 Subject: [PATCH 34/36] docs(ci): stop repeating the same comment rationale at multiple call sites, for #8609 Found during review: the DOCKER_ORG fallback comment was near-duplicated between image-build-push.yml's detect and build jobs, and the alias-branch trust comment was near-duplicated between image-build-push.yml and image-push.yml. Each now states its rationale once and points the other call site at it, matching the pattern already used by the DDEV_IMAGE_TAG comment in build-and-push. --- .github/workflows/image-build-push.yml | 3 +-- .github/workflows/image-push.yml | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index da61cd2f0a6..8db619656a4 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -147,8 +147,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Load DOCKER_ORG from public-variables branch - # This job only runs for fork PRs, where vars.DOCKER_ORG is never - # available - see .github/public-variables/README.md. + # Same fallback as the detect job's identically-named step above. if: vars.DOCKER_ORG == '' run: | git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 620dbceb057..1b8eede555e 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -94,9 +94,8 @@ jobs: - name: Validate, load, and push each built image id: push env: - # From the workflow_run payload, not the artifact - the alias is - # published in the DDEV org, so its branch name has to come from a - # trusted context. Sanitized and validated below regardless. + # From the workflow_run payload, not the artifact - see the same + # variable's comment in image-build-push.yml's create-manifests job. ALIAS_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | set -eu -o pipefail From b0ff28dc3359c218afbecc6bdb1ca10f1e6fdaa5 Mon Sep 17 00:00:00 2001 From: Stanislav Zhuk <stasadev@gmail.com> Date: Mon, 17 Aug 2026 21:04:24 +0300 Subject: [PATCH 35/36] fix(ci): name ddev-test's DOCKER_ORG inline instead of via public-variables --- .buildkite/test.sh | 2 -- .github/public-variables/DOCKER_ORG | 0 .github/public-variables/README.md | 9 +-------- .github/workflows/image-build-push.yml | 20 +------------------- .github/workflows/image-push.yml | 2 +- .github/workflows/push-tagged-dbimage.yml | 2 +- .github/workflows/push-tagged-image.yml | 2 +- .github/workflows/test-reusable.yml | 4 +--- .github/workflows/test-wsl2-reusable.yml | 4 +--- 9 files changed, 7 insertions(+), 38 deletions(-) delete mode 100644 .github/public-variables/DOCKER_ORG diff --git a/.buildkite/test.sh b/.buildkite/test.sh index 1cd0de70237..3adbc442073 100755 --- a/.buildkite/test.sh +++ b/.buildkite/test.sh @@ -18,8 +18,6 @@ fi git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp while IFS= read -r varname; do [[ "$varname" == "README.md" ]] && continue - # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. - [[ "$varname" == "DOCKER_ORG" && -n "${DOCKER_ORG:-}" ]] && continue # MSYS_NO_PATHCONV prevents Git for Windows bash from mangling the ref:path syntax value=$(MSYS_NO_PATHCONV=1 git show "refs/public-variables-tmp:.github/public-variables/$varname") echo "$varname=${value}" diff --git a/.github/public-variables/DOCKER_ORG b/.github/public-variables/DOCKER_ORG deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/.github/public-variables/README.md b/.github/public-variables/README.md index 39c27c08c8e..a624df50e54 100644 --- a/.github/public-variables/README.md +++ b/.github/public-variables/README.md @@ -20,12 +20,6 @@ Current variables: - **Bats tests:** each pattern is matched as a case-sensitive substring against the bats filename (without `.bats`) or the `@test` description. E.g. `sveltekit` skips all tests in `sveltekit.bats`; `Symfony Composer` skips only the Composer-flavored test in `symfony.bats`. Go and bats patterns can be combined: `TestLagoonPull|sveltekit`. - `workflow_dispatch` runs skip loading the `public-variables` branch entirely, so maintainers can verify fixes without removing them from the embargo list first. - `DDEV_EMBARGO_PHP_VERSIONS` - comma-separated PHP versions to skip in `TestPHPConfig`, e.g. `7.0,7.1` -- `DOCKER_ORG` - Default `hub.docker.com` organization to use, nearly always `ddev`. `vars.DOCKER_ORG` - isn't available to a fork PR's untrusted `detect`/`build` jobs in `image-build-push.yml`, so those - jobs read it from here instead. Unlike the other variables below, this one is a fallback, not an - override: every loader skips it when the job's own `DOCKER_ORG` is already non-empty, so a - same-repo run (or one that sets its own value, e.g. `ddev-test/ddev`'s `ddevhq`) never gets - clobbered by the value published here for forks. ## Adding a new variable @@ -48,8 +42,7 @@ No workflow changes are needed - any file in this directory is picked up automat ## How it works Used in `.buildkite/test.sh`, `.github/workflows/test-reusable.yml`, -`.github/workflows/test-wsl2-reusable.yml`, `.github/workflows/quickstart.yml`, and -`.github/workflows/image-build-push.yml`. +`.github/workflows/test-wsl2-reusable.yml`, and `.github/workflows/quickstart.yml`. Each CI run does `git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp`, reads all files via `git ls-tree` + `git show`, then deletes the temporary ref. diff --git a/.github/workflows/image-build-push.yml b/.github/workflows/image-build-push.yml index 8db619656a4..3ae98674811 100644 --- a/.github/workflows/image-build-push.yml +++ b/.github/workflows/image-build-push.yml @@ -32,7 +32,7 @@ concurrency: cancel-in-progress: true env: - DOCKER_ORG: "${{ vars.DOCKER_ORG || 'ddev' }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" permissions: contents: read @@ -48,16 +48,6 @@ jobs: is_fork: ${{ steps.fork.outputs.is_fork }} steps: - uses: actions/checkout@v7 - - name: Load DOCKER_ORG from public-variables branch - # Fork PRs can't see vars.DOCKER_ORG; fall back only then, so a - # same-repo run keeps this repo's own value. See - # .github/public-variables/README.md. - if: vars.DOCKER_ORG == '' - run: | - git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp - value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG 2>/dev/null || true)" - [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" - git update-ref -d refs/public-variables-tmp - name: Compute per-image build status id: detect # github.head_ref is attacker-controlled (a fork may name its branch @@ -146,14 +136,6 @@ jobs: contents: read steps: - uses: actions/checkout@v7 - - name: Load DOCKER_ORG from public-variables branch - # Same fallback as the detect job's identically-named step above. - if: vars.DOCKER_ORG == '' - run: | - git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp - value="$(git show refs/public-variables-tmp:.github/public-variables/DOCKER_ORG 2>/dev/null || true)" - [ -n "$value" ] && echo "DOCKER_ORG=$value" >> "$GITHUB_ENV" - git update-ref -d refs/public-variables-tmp - name: Build ${{ matrix.build.repo }}:${{ matrix.build.tag }}-${{ matrix.build.arch }} run: | set -eu -o pipefail diff --git a/.github/workflows/image-push.yml b/.github/workflows/image-push.yml index 1b8eede555e..25eaa255391 100644 --- a/.github/workflows/image-push.yml +++ b/.github/workflows/image-push.yml @@ -15,7 +15,7 @@ on: types: [completed] env: - DOCKER_ORG: "${{ vars.DOCKER_ORG || 'ddev' }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" permissions: contents: read diff --git a/.github/workflows/push-tagged-dbimage.yml b/.github/workflows/push-tagged-dbimage.yml index 2dfea5b2cc3..367fc9bad96 100644 --- a/.github/workflows/push-tagged-dbimage.yml +++ b/.github/workflows/push-tagged-dbimage.yml @@ -17,7 +17,7 @@ on: default: false env: REGISTRY: docker.io - DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" permissions: contents: read diff --git a/.github/workflows/push-tagged-image.yml b/.github/workflows/push-tagged-image.yml index 17977abb596..49e3b235e8f 100644 --- a/.github/workflows/push-tagged-image.yml +++ b/.github/workflows/push-tagged-image.yml @@ -28,7 +28,7 @@ on: default: false env: REGISTRY: docker.io - DOCKER_ORG: "${{ vars.DOCKER_ORG }}" + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" permissions: contents: read diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 98ddc04eb81..fcb7a9cf4ed 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -116,7 +116,7 @@ jobs: runs-on: ${{ inputs.runner }} env: - DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" BUILDKIT_PROGRESS: plain DOCKER_CLI_EXPERIMENTAL: enabled DDEV_DEBUG: true @@ -147,8 +147,6 @@ jobs: git fetch --depth=1 --no-tags https://github.com/ddev/ddev public-variables:refs/public-variables-tmp while IFS= read -r varname; do [[ "$varname" == "README.md" ]] && continue - # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. - [[ "$varname" == "DOCKER_ORG" && -n "${DOCKER_ORG:-}" ]] && continue value=$(git show "refs/public-variables-tmp:.github/public-variables/$varname") echo "$varname=${value}" echo "$varname=$value" >> $GITHUB_ENV diff --git a/.github/workflows/test-wsl2-reusable.yml b/.github/workflows/test-wsl2-reusable.yml index 952fee06a37..162bfc2185a 100644 --- a/.github/workflows/test-wsl2-reusable.yml +++ b/.github/workflows/test-wsl2-reusable.yml @@ -67,7 +67,7 @@ jobs: name: WSL2 (${{ inputs.networking }}, ${{ inputs.make_target }}) env: - DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_ORG: "${{ vars.DOCKER_ORG || (github.repository_owner == 'ddev-test' && 'ddevhq' || 'ddev') }}" GOTEST_SHORT: ${{ inputs.gotest_short }} TESTARGS: ${{ inputs.testargs }} MAKE_TARGET: ${{ inputs.make_target }} @@ -137,8 +137,6 @@ jobs: $files = git ls-tree --name-only refs/public-variables-tmp:.github/public-variables/ foreach ($varname in $files) { if ($varname -eq "README.md") { continue } - # DOCKER_ORG is a fallback, not an override - see public-variables/README.md. - if ($varname -eq "DOCKER_ORG" -and $env:DOCKER_ORG) { continue } $value = git show "refs/public-variables-tmp:.github/public-variables/$varname" Write-Host "$varname=$value" "$varname=$value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append From a0f3919b1006a53881a93f6eefe18863dd608a2b Mon Sep 17 00:00:00 2001 From: rfay <randy@randyfay.com> Date: Mon, 17 Aug 2026 00:42:54 +0000 Subject: [PATCH 36/36] test: trivial fork PR change for #8609 phase 2 manual test 13 Fork PR test on ddev-test/ddev: confirm the DOCKER_ORG load step now successfully fetches the real value ('ddev') from ddev/ddev's public-variables branch, now that it's populated, instead of gracefully no-op'ing on a missing file. --- containers/ddev-ssh-agent/Dockerfile | 1 + pkg/versionconstants/versionconstants.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/containers/ddev-ssh-agent/Dockerfile b/containers/ddev-ssh-agent/Dockerfile index 81e3972dc8f..7d057d902ef 100644 --- a/containers/ddev-ssh-agent/Dockerfile +++ b/containers/ddev-ssh-agent/Dockerfile @@ -27,6 +27,7 @@ getent passwd "$(id -u)" >/dev/null 2>&1 || PS1='${debian_chroot:+($debian_chroo BASHRC EOF +# test: trivial fork PR change for #8609 phase 2 manual test 13 (real DOCKER_ORG value verification) HEALTHCHECK --interval=1s --retries=5 --timeout=120s CMD ["/healthcheck.sh"] VOLUME ${SOCKET_DIR} diff --git a/pkg/versionconstants/versionconstants.go b/pkg/versionconstants/versionconstants.go index b08ff2907ef..0c68019f760 100644 --- a/pkg/versionconstants/versionconstants.go +++ b/pkg/versionconstants/versionconstants.go @@ -55,10 +55,10 @@ var TraefikRouterTagBranch = "20260814_rfay_docker_update_phase_2" var SSHAuthImage = "ddev/ddev-ssh-agent" // SSHAuthTag is ssh-agent auth tag -var SSHAuthTag = "bb5e9f0003" // 20260814_rfay_docker_update_phase_2-bb5e9f0003 +var SSHAuthTag = "c98b5f7c0e" // 20260817_rfay_test13_dockerorg_realvalue-c98b5f7c0e // SSHAuthTagBranch is the branch SSHAuthTag's content was built from. -var SSHAuthTagBranch = "20260814_rfay_docker_update_phase_2" +var SSHAuthTagBranch = "20260817_rfay_test13_dockerorg_realvalue" // XhguiImage is image for xhgui var XhguiImage = "ddev/ddev-xhgui"