From cd48ba03f4621f37fc062db2d6473ca3c3e0f411 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 12:43:54 -0400 Subject: [PATCH 01/10] feat(ci): build per-PR Docker test image on "PR: docker image" label (#36331) Adds cicd_manual_build-pr-docker-image.yml: when a PR carries the "PR: docker image" label, builds and pushes to dotcms/dotcms-test tagged pr-- (plus immutable _). Fires on label apply and on pushes to an already-labeled PR. Reuses the modern build phase + deploy-docker composite action. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cicd_manual_build-pr-docker-image.yml | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 .github/workflows/cicd_manual_build-pr-docker-image.yml diff --git a/.github/workflows/cicd_manual_build-pr-docker-image.yml b/.github/workflows/cicd_manual_build-pr-docker-image.yml new file mode 100644 index 000000000000..51c4c4a307da --- /dev/null +++ b/.github/workflows/cicd_manual_build-pr-docker-image.yml @@ -0,0 +1,108 @@ +# Build a test Docker image for any PR labeled "PR: docker image". +# +# Triggers a build whenever the label is applied OR a new commit is pushed to a +# labeled PR. The image is pushed to dotcms/dotcms-test with a tag derived from +# the PR number and a normalized branch name, e.g.: +# +# branch feat/bedrock-agentic-harness on PR #1234 +# -> dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness +# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness_ (immutable) +# +# Reuses the standard build phase (cicd_comp_build-phase.yml) to produce the +# docker build context, then pushes via the deploy-docker composite action with +# a test image name. Fork PRs cannot push (no secrets) — internal branches only. + +name: 'PR Docker Test Image' +run-name: "PR Docker [#${{ github.event.pull_request.number }} ${{ github.event.pull_request.head.ref }}]" + +on: + pull_request: + types: [labeled, synchronize, opened, reopened] + +# One build per PR; newer pushes cancel in-flight builds. +concurrency: + group: pr-docker-image-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + prep: + name: Resolve Tag + # Only run for PRs carrying the "PR: docker image" label. For `labeled` + # events this includes the just-added label; for `synchronize` it checks the + # current label set, so pushes to an already-labeled PR rebuild. + if: "contains(github.event.pull_request.labels.*.name, 'PR: docker image')" + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + outputs: + tag: ${{ steps.tag.outputs.tag }} + ref: ${{ steps.tag.outputs.ref }} + steps: + - name: Compute normalized image tag + id: tag + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + # Lowercase, replace any char outside [a-z0-9._-] with '-', collapse and + # trim dashes, cap length so the result is a valid Docker tag. + norm=$(printf '%s' "${BRANCH}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9._-' '-' \ + | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ + | cut -c1-100) + tag="pr-${PR_NUMBER}-${norm}" + echo "Resolved tag: ${tag}" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "ref=${BRANCH}" >> "$GITHUB_OUTPUT" + + build: + name: Build + needs: [prep] + uses: ./.github/workflows/cicd_comp_build-phase.yml + with: + core-build: true + run-pr-checks: false + ref: ${{ needs.prep.outputs.ref }} + generate-docker: true + permissions: + contents: read + packages: write + + push: + name: Push Test Image + needs: [prep, build] + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + permissions: + contents: read + packages: write + steps: + - name: Checkout core + uses: actions/checkout@v4 + with: + ref: ${{ needs.prep.outputs.ref }} + + - name: Get SDKMAN Java version + id: sdkman + run: | + if [ -f .sdkmanrc ]; then + v=$(awk -F '=' '/^java=/ {print $2}' .sdkmanrc) + echo "SDKMAN_JAVA_VERSION=${v}" >> "$GITHUB_OUTPUT" + else + echo "No .sdkmanrc file found"; exit 1 + fi + + - name: Build/Push Test Image + uses: ./.github/actions/core-cicd/deployment/deploy-docker + with: + image_name: dotcms/dotcms-test + docker_platforms: linux/amd64 + build_run_id: ${{ github.run_id }} + commit_id: ${{ github.event.pull_request.head.sha }} + docker-use-ref: false + version: ${{ needs.prep.outputs.tag }} + do_deploy: true + docker_io_username: ${{ secrets.DOCKER_USERNAME }} + docker_io_token: ${{ secrets.DOCKER_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + pull: true + build_args: | + SDKMAN_JAVA_VERSION=${{ steps.sdkman.outputs.SDKMAN_JAVA_VERSION }} From 9f7436d8cc0d40c3df6eb4a3d97be80a46584523 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 12:56:35 -0400 Subject: [PATCH 02/10] feat(ci): post sticky PR comment with pushed image tags (#36331) After the image is pushed, upsert a single sticky comment (keyed on a hidden marker) listing docker pull commands for the latest tags, so each new push updates in place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cicd_manual_build-pr-docker-image.yml | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/cicd_manual_build-pr-docker-image.yml b/.github/workflows/cicd_manual_build-pr-docker-image.yml index 51c4c4a307da..380dcf27d101 100644 --- a/.github/workflows/cicd_manual_build-pr-docker-image.yml +++ b/.github/workflows/cicd_manual_build-pr-docker-image.yml @@ -74,6 +74,7 @@ jobs: permissions: contents: read packages: write + pull-requests: write steps: - name: Checkout core uses: actions/checkout@v4 @@ -91,6 +92,7 @@ jobs: fi - name: Build/Push Test Image + id: deploy uses: ./.github/actions/core-cicd/deployment/deploy-docker with: image_name: dotcms/dotcms-test @@ -106,3 +108,37 @@ jobs: pull: true build_args: | SDKMAN_JAVA_VERSION=${{ steps.sdkman.outputs.SDKMAN_JAVA_VERSION }} + + # Upsert a single sticky comment on the PR with the latest pushed tags. + # Keyed on a hidden marker so each new push updates in place instead of + # appending a new comment. + - name: Sticky comment with image tags + if: success() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + TAGS: ${{ steps.deploy.outputs.tags }} + run: | + MARKER='' + { + echo "${MARKER}" + echo "### 🐳 PR Docker test image" + echo "" + echo "Latest build for commit \`${SHA:0:7}\` pushed to \`dotcms/dotcms-test\`:" + echo "" + echo '```bash' + for t in ${TAGS}; do echo "docker pull ${t}"; done + echo '```' + } > body.md + + id=$(gh api "repos/${REPO}/issues/${PR}/comments" -f per_page=100 \ + --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty") + if [ -n "${id}" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null + echo "Updated sticky comment ${id}" + else + gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null + echo "Created sticky comment" + fi From 0b4961a2192f03381918568579d4fed6206208a1 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 12:58:04 -0400 Subject: [PATCH 03/10] feat(ci): reuse existing build-context artifact for the PR's SHA (#36331) Adds a discover job that looks for a docker-build-context artifact already produced for the head SHA by another run (e.g. the PR's normal CI). When found, the build job is skipped and deploy-docker pulls that run's artifact via build_run_id, avoiding a redundant Maven compile. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cicd_manual_build-pr-docker-image.yml | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cicd_manual_build-pr-docker-image.yml b/.github/workflows/cicd_manual_build-pr-docker-image.yml index 380dcf27d101..aeec73a787a1 100644 --- a/.github/workflows/cicd_manual_build-pr-docker-image.yml +++ b/.github/workflows/cicd_manual_build-pr-docker-image.yml @@ -54,9 +54,48 @@ jobs: echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "ref=${BRANCH}" >> "$GITHUB_OUTPUT" + # Look for a docker-build-context artifact already produced for this exact + # commit by another run (e.g. the PR's normal CI). If found, we skip our own + # build and feed that run's artifact straight to deploy-docker. + discover: + name: Find Existing Build + needs: [prep] + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + permissions: + actions: read + outputs: + run_id: ${{ steps.find.outputs.run_id }} + steps: + - name: Find docker-build-context for this SHA + id: find + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha }} + THIS_RUN: ${{ github.run_id }} + run: | + run_id="" + # Runs are returned newest-first; take the most recent one that still + # has a non-expired docker-build-context artifact. + for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${SHA}&per_page=50" \ + --jq '.workflow_runs[].id'); do + [ "${rid}" = "${THIS_RUN}" ] && continue + has=$(gh api "repos/${REPO}/actions/runs/${rid}/artifacts" \ + --jq '.artifacts[] | select(.name=="docker-build-context" and .expired==false) | .id' \ + | head -1) + if [ -n "${has}" ]; then run_id="${rid}"; break; fi + done + if [ -n "${run_id}" ]; then + echo "Reusing docker-build-context from run ${run_id}" + else + echo "No reusable artifact for ${SHA}; will build." + fi + echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" + build: name: Build - needs: [prep] + needs: [prep, discover] + if: needs.discover.outputs.run_id == '' uses: ./.github/workflows/cicd_comp_build-phase.yml with: core-build: true @@ -69,12 +108,15 @@ jobs: push: name: Push Test Image - needs: [prep, build] + needs: [prep, discover, build] + # build is skipped when we reuse an existing artifact — proceed unless it failed. + if: always() && needs.build.result != 'failure' && needs.build.result != 'cancelled' runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} permissions: contents: read packages: write pull-requests: write + actions: read steps: - name: Checkout core uses: actions/checkout@v4 @@ -97,7 +139,8 @@ jobs: with: image_name: dotcms/dotcms-test docker_platforms: linux/amd64 - build_run_id: ${{ github.run_id }} + # Reuse the discovered run's artifact when present, else our own build. + build_run_id: ${{ needs.discover.outputs.run_id || github.run_id }} commit_id: ${{ github.event.pull_request.head.sha }} docker-use-ref: false version: ${{ needs.prep.outputs.tag }} From be08f18d6e6682f78b5414ca9525c2c2e3e93f98 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 13:22:11 -0400 Subject: [PATCH 04/10] refactor(ci): promote prebuilt image instead of rebuilding (#36331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR pipeline already builds dotcms/dotcms-test once and uploads it as the docker-image artifact, but PR checks cannot use registry secrets. Move publishing to a workflow_run-triggered job that runs from main (trusted) and promotes that artifact to Docker Hub — load + retag + push, no second build. A pull_request:labeled trigger covers applying the label to an existing PR by reusing the latest docker-image artifact for the SHA (building only if none exists). Drops the synchronize self-build that duplicated the PR pipeline's image build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cicd_manual_build-pr-docker-image.yml | 280 +++++++++++------- 1 file changed, 166 insertions(+), 114 deletions(-) diff --git a/.github/workflows/cicd_manual_build-pr-docker-image.yml b/.github/workflows/cicd_manual_build-pr-docker-image.yml index aeec73a787a1..399b436920ce 100644 --- a/.github/workflows/cicd_manual_build-pr-docker-image.yml +++ b/.github/workflows/cicd_manual_build-pr-docker-image.yml @@ -1,168 +1,220 @@ -# Build a test Docker image for any PR labeled "PR: docker image". +# Publish a per-PR test Docker image for PRs labeled "PR: docker image". # -# Triggers a build whenever the label is applied OR a new commit is pushed to a -# labeled PR. The image is pushed to dotcms/dotcms-test with a tag derived from -# the PR number and a normalized branch name, e.g.: +# The image is NOT built here — the normal PR pipeline ("-1 PR Check") already +# builds dotcms/dotcms-test: once and uploads it as the `docker-image` +# artifact (see core-cicd/maven-job). This workflow *promotes* that artifact to +# Docker Hub, retagged for the PR. It runs from main (trusted) so it may use the +# registry secrets the PR pipeline is forbidden from touching. # -# branch feat/bedrock-agentic-harness on PR #1234 -# -> dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness -# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness_ (immutable) +# Tag scheme — branch feat/bedrock-agentic-harness on PR #1234 -> +# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness (mutable) +# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness_ (immutable) # -# Reuses the standard build phase (cicd_comp_build-phase.yml) to produce the -# docker build context, then pushes via the deploy-docker composite action with -# a test image name. Fork PRs cannot push (no secrets) — internal branches only. +# Triggers: +# workflow_run (after "-1 PR Check") — covers pushes; reuses that run's image, +# so there is exactly one build per commit (no double build). +# pull_request labeled — covers the label being applied to an existing PR; +# reuses the most recent docker-image artifact for the head SHA, building +# only if none exists. +# +# Internal branches only: fork PRs have no access to DOCKER_* secrets. name: 'PR Docker Test Image' -run-name: "PR Docker [#${{ github.event.pull_request.number }} ${{ github.event.pull_request.head.ref }}]" +run-name: "PR Docker [${{ github.event.pull_request.number && format('#{0} {1}', github.event.pull_request.number, github.event.pull_request.head.ref) || github.event.workflow_run.head_branch }}]" on: + workflow_run: + workflows: ['-1 PR Check'] + types: [completed] pull_request: - types: [labeled, synchronize, opened, reopened] + types: [labeled] -# One build per PR; newer pushes cancel in-flight builds. concurrency: - group: pr-docker-image-${{ github.event.pull_request.number }} + group: pr-docker-image-${{ github.event.pull_request.number || github.event.workflow_run.head_branch }} cancel-in-progress: true +permissions: + contents: read + jobs: + # Normalize the two trigger shapes into a single context and decide whether to + # proceed. Also locates a reusable docker-image artifact for the head SHA. prep: - name: Resolve Tag - # Only run for PRs carrying the "PR: docker image" label. For `labeled` - # events this includes the just-added label; for `synchronize` it checks the - # current label set, so pushes to an already-labeled PR rebuild. - if: "contains(github.event.pull_request.labels.*.name, 'PR: docker image')" - runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} - outputs: - tag: ${{ steps.tag.outputs.tag }} - ref: ${{ steps.tag.outputs.ref }} - steps: - - name: Compute normalized image tag - id: tag - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - # Lowercase, replace any char outside [a-z0-9._-] with '-', collapse and - # trim dashes, cap length so the result is a valid Docker tag. - norm=$(printf '%s' "${BRANCH}" \ - | tr '[:upper:]' '[:lower:]' \ - | tr -c 'a-z0-9._-' '-' \ - | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ - | cut -c1-100) - tag="pr-${PR_NUMBER}-${norm}" - echo "Resolved tag: ${tag}" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" - echo "ref=${BRANCH}" >> "$GITHUB_OUTPUT" - - # Look for a docker-build-context artifact already produced for this exact - # commit by another run (e.g. the PR's normal CI). If found, we skip our own - # build and feed that run's artifact straight to deploy-docker. - discover: - name: Find Existing Build - needs: [prep] + name: Resolve Context runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} permissions: actions: read + pull-requests: read outputs: - run_id: ${{ steps.find.outputs.run_id }} + proceed: ${{ steps.ctx.outputs.proceed }} + pr: ${{ steps.ctx.outputs.pr }} + sha: ${{ steps.ctx.outputs.sha }} + tag: ${{ steps.ctx.outputs.tag }} + source_run_id: ${{ steps.ctx.outputs.source_run_id }} steps: - - name: Find docker-build-context for this SHA - id: find + - name: Resolve PR context + id: ctx env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - SHA: ${{ github.event.pull_request.head.sha }} - THIS_RUN: ${{ github.run_id }} + EVENT: ${{ github.event_name }} + # workflow_run shape + WR_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + WR_SHA: ${{ github.event.workflow_run.head_sha }} + WR_BRANCH: ${{ github.event.workflow_run.head_branch }} + WR_RUN_ID: ${{ github.event.workflow_run.id }} + WR_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + # pull_request (labeled) shape + PR_LABEL: ${{ github.event.label.name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BRANCH: ${{ github.event.pull_request.head.ref }} + PR_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} run: | - run_id="" - # Runs are returned newest-first; take the most recent one that still - # has a non-expired docker-build-context artifact. - for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${SHA}&per_page=50" \ - --jq '.workflow_runs[].id'); do - [ "${rid}" = "${THIS_RUN}" ] && continue - has=$(gh api "repos/${REPO}/actions/runs/${rid}/artifacts" \ - --jq '.artifacts[] | select(.name=="docker-build-context" and .expired==false) | .id' \ - | head -1) - if [ -n "${has}" ]; then run_id="${rid}"; break; fi - done - if [ -n "${run_id}" ]; then - echo "Reusing docker-build-context from run ${run_id}" - else - echo "No reusable artifact for ${SHA}; will build." + set -euo pipefail + LABEL='PR: docker image' + proceed=false; pr=""; branch=""; sha=""; run_id=""; tag="" + + if [ "${EVENT}" = "workflow_run" ]; then + if [ "${WR_CONCLUSION}" != "success" ]; then + echo "PR check concluded '${WR_CONCLUSION}', nothing to promote." + elif [ "${WR_HEAD_REPO}" != "${REPO}" ]; then + echo "Fork PR (${WR_HEAD_REPO}) — no secrets, skipping." + else + sha="${WR_SHA}"; branch="${WR_BRANCH}" + pr=$(gh pr list --repo "${REPO}" --head "${branch}" --state open \ + --json number --jq '.[0].number // empty') + if [ -z "${pr}" ]; then + echo "No open PR for branch '${branch}', skipping." + elif gh pr view "${pr}" --repo "${REPO}" --json labels \ + --jq '.labels[].name' | grep -qxF "${LABEL}"; then + proceed=true + # Prefer the artifact from the run that just finished. + if gh api "repos/${REPO}/actions/runs/${WR_RUN_ID}/artifacts" \ + --jq '.artifacts[].name' | grep -qx 'docker-image'; then + run_id="${WR_RUN_ID}" + fi + else + echo "PR #${pr} is not labeled '${LABEL}', skipping." + fi + fi + + elif [ "${EVENT}" = "pull_request" ]; then + if [ "${PR_LABEL}" != "${LABEL}" ]; then + echo "Label '${PR_LABEL}' is not '${LABEL}', skipping." + elif [ "${PR_HEAD_REPO}" != "${REPO}" ]; then + echo "Fork PR (${PR_HEAD_REPO}) — no secrets, skipping." + else + proceed=true; pr="${PR_NUMBER}"; branch="${PR_BRANCH}"; sha="${PR_SHA}" + fi + fi + + # If we have no run id yet, look for the newest run with a non-expired + # docker-image artifact for this exact commit. + if [ "${proceed}" = "true" ] && [ -z "${run_id}" ]; then + for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${sha}&per_page=50" \ + --jq '.workflow_runs[].id'); do + [ "${rid}" = "${GITHUB_RUN_ID}" ] && continue + if gh api "repos/${REPO}/actions/runs/${rid}/artifacts" \ + --jq '.artifacts[] | select(.expired==false) | .name' \ + | grep -qx 'docker-image'; then + run_id="${rid}"; break + fi + done fi - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" + if [ "${proceed}" = "true" ]; then + norm=$(printf '%s' "${branch}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9._-' '-' \ + | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ + | cut -c1-100) + tag="pr-${pr}-${norm}" + echo "PR #${pr} sha=${sha} tag=${tag} source_run_id=${run_id:-}" + fi + + { + echo "proceed=${proceed}" + echo "pr=${pr}" + echo "sha=${sha}" + echo "tag=${tag}" + echo "source_run_id=${run_id}" + } >> "$GITHUB_OUTPUT" + + # Fallback: only when no reusable docker-image artifact exists for the SHA + # (e.g. label applied to a PR whose build was skipped, or artifact expired). build: name: Build - needs: [prep, discover] - if: needs.discover.outputs.run_id == '' + needs: [prep] + if: needs.prep.outputs.proceed == 'true' && needs.prep.outputs.source_run_id == '' uses: ./.github/workflows/cicd_comp_build-phase.yml with: core-build: true run-pr-checks: false - ref: ${{ needs.prep.outputs.ref }} + ref: ${{ needs.prep.outputs.sha }} generate-docker: true permissions: contents: read packages: write - push: - name: Push Test Image - needs: [prep, discover, build] - # build is skipped when we reuse an existing artifact — proceed unless it failed. - if: always() && needs.build.result != 'failure' && needs.build.result != 'cancelled' + promote: + name: Promote to Docker Hub + needs: [prep, build] + # build is skipped when reusing an existing artifact — proceed unless it failed. + if: always() && needs.prep.outputs.proceed == 'true' && needs.build.result != 'failure' && needs.build.result != 'cancelled' runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} permissions: contents: read - packages: write - pull-requests: write actions: read + pull-requests: write steps: - - name: Checkout core - uses: actions/checkout@v4 + - name: Download prebuilt image + uses: actions/download-artifact@v4 with: - ref: ${{ needs.prep.outputs.ref }} + name: docker-image + path: /tmp/prebuilt + # Reused run when found, otherwise this run's own build job. + run-id: ${{ needs.prep.outputs.source_run_id || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Get SDKMAN Java version - id: sdkman + - name: Load, retag and push + id: push + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + TAG: ${{ needs.prep.outputs.tag }} + SHA: ${{ needs.prep.outputs.sha }} run: | - if [ -f .sdkmanrc ]; then - v=$(awk -F '=' '/^java=/ {print $2}' .sdkmanrc) - echo "SDKMAN_JAVA_VERSION=${v}" >> "$GITHUB_OUTPUT" - else - echo "No .sdkmanrc file found"; exit 1 + set -euo pipefail + DEST='dotcms/dotcms-test' + SHA7="${SHA:0:7}" + + loaded=$(docker load -i /tmp/prebuilt/image.tar | sed -n 's/.*Loaded image: //p' | head -1) + if [ -z "${loaded}" ]; then + echo "::error::No image found in artifact"; exit 1 fi + echo "Loaded ${loaded}" - - name: Build/Push Test Image - id: deploy - uses: ./.github/actions/core-cicd/deployment/deploy-docker - with: - image_name: dotcms/dotcms-test - docker_platforms: linux/amd64 - # Reuse the discovered run's artifact when present, else our own build. - build_run_id: ${{ needs.discover.outputs.run_id || github.run_id }} - commit_id: ${{ github.event.pull_request.head.sha }} - docker-use-ref: false - version: ${{ needs.prep.outputs.tag }} - do_deploy: true - docker_io_username: ${{ secrets.DOCKER_USERNAME }} - docker_io_token: ${{ secrets.DOCKER_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - pull: true - build_args: | - SDKMAN_JAVA_VERSION=${{ steps.sdkman.outputs.SDKMAN_JAVA_VERSION }} - - # Upsert a single sticky comment on the PR with the latest pushed tags. - # Keyed on a hidden marker so each new push updates in place instead of - # appending a new comment. + echo "${DOCKER_TOKEN}" | docker login -u "${DOCKER_USERNAME}" --password-stdin + + pushed="" + for t in "${TAG}" "${TAG}_${SHA7}"; do + docker tag "${loaded}" "${DEST}:${t}" + docker push "${DEST}:${t}" + pushed="${pushed} ${DEST}:${t}" + done + echo "tags=$(echo ${pushed} | xargs)" >> "$GITHUB_OUTPUT" + + # Upsert a single sticky comment with the latest pushed tags, keyed on a + # hidden marker so each new push updates it in place. - name: Sticky comment with image tags if: success() env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - SHA: ${{ github.event.pull_request.head.sha }} - TAGS: ${{ steps.deploy.outputs.tags }} + PR: ${{ needs.prep.outputs.pr }} + SHA: ${{ needs.prep.outputs.sha }} + TAGS: ${{ steps.push.outputs.tags }} run: | MARKER='' { From 154525b0b1713b74dbd6ac7fc928c0e4a24df22c Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 13:40:03 -0400 Subject: [PATCH 05/10] feat(ci): publish PR test image right after build via dispatch (#36331) Publish the per-PR test image as soon as the build artifact exists, in parallel with tests, without putting DOCKER_* secrets in the PR pipeline: - cicd_1-pr.yml: add a label-gated, internal-branch-only job that fires a repository_dispatch (pr-docker-image) right after build succeeds, using a minimal PR_DOCKER_DISPATCH_TOKEN (no registry creds). - cicd_publish-pr-test-image.yml (renamed): trusted publisher running from main via repository_dispatch + pull_request_target(labeled). Downloads the docker-image artifact from the dispatched run, retags pr-N-branch (+_sha), pushes to dotcms/dotcms-test, and upserts a sticky comment. Re-validates the label and head SHA as defense in depth. Requires a new repo secret PR_DOCKER_DISPATCH_TOKEN (fine-grained PAT/App, contents:write only) to trigger the publisher. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cicd_1-pr.yml | 30 ++++ ...age.yml => cicd_publish-pr-test-image.yml} | 151 +++++++----------- 2 files changed, 91 insertions(+), 90 deletions(-) rename .github/workflows/{cicd_manual_build-pr-docker-image.yml => cicd_publish-pr-test-image.yml} (50%) diff --git a/.github/workflows/cicd_1-pr.yml b/.github/workflows/cicd_1-pr.yml index ff6a18401831..891e03c330be 100644 --- a/.github/workflows/cicd_1-pr.yml +++ b/.github/workflows/cicd_1-pr.yml @@ -70,6 +70,36 @@ jobs: contents: read packages: write + # Trigger Test Image Publish - for PRs labeled "PR: docker image", fire a + # repository_dispatch as soon as the build artifact exists (in parallel with + # tests) so the trusted publisher (cicd_publish-pr-test-image.yml) promotes the + # docker-image artifact to dotcms/dotcms-test. Uses a minimal dispatch token, + # NOT the Docker registry secrets, which must never enter this PR-modifiable + # pipeline. Internal branches only (the token is absent for fork PRs). + trigger-test-image: + name: Trigger Test Image Publish + needs: [ initialize, build ] + if: >- + always() && needs.build.result == 'success' && + github.event.pull_request.head.repo.full_name == github.repository && + contains(github.event.pull_request.labels.*.name, 'PR: docker image') + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + continue-on-error: true # never fail the PR check over a test-image dispatch + steps: + - name: Dispatch publish event + env: + GH_TOKEN: ${{ secrets.PR_DOCKER_DISPATCH_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + SHA: ${{ github.event.pull_request.head.sha }} + BRANCH: ${{ github.event.pull_request.head.ref }} + RUN_ID: ${{ github.run_id }} + run: | + jq -nc \ + --arg pr "${PR}" --arg sha "${SHA}" --arg branch "${BRANCH}" --arg run "${RUN_ID}" \ + '{event_type:"pr-docker-image", client_payload:{pr:$pr,sha:$sha,branch:$branch,run_id:$run}}' \ + | gh api "repos/${REPO}/dispatches" -X POST --input - + # Test job - runs various tests based on initialization outputs test: name: PR Test diff --git a/.github/workflows/cicd_manual_build-pr-docker-image.yml b/.github/workflows/cicd_publish-pr-test-image.yml similarity index 50% rename from .github/workflows/cicd_manual_build-pr-docker-image.yml rename to .github/workflows/cicd_publish-pr-test-image.yml index 399b436920ce..adab5864aec2 100644 --- a/.github/workflows/cicd_manual_build-pr-docker-image.yml +++ b/.github/workflows/cicd_publish-pr-test-image.yml @@ -1,44 +1,40 @@ -# Publish a per-PR test Docker image for PRs labeled "PR: docker image". +# Trusted publisher for per-PR test images (dotcms/dotcms-test). # -# The image is NOT built here — the normal PR pipeline ("-1 PR Check") already -# builds dotcms/dotcms-test: once and uploads it as the `docker-image` -# artifact (see core-cicd/maven-job). This workflow *promotes* that artifact to -# Docker Hub, retagged for the PR. It runs from main (trusted) so it may use the -# registry secrets the PR pipeline is forbidden from touching. +# Runs from main only — via repository_dispatch and pull_request_target — so the +# DOCKER_* secrets it uses never enter the PR-modifiable `-1 PR Check` pipeline. +# It does NOT build: it promotes the `docker-image` artifact that the PR build +# already produced (core-cicd/maven-job) to Docker Hub, retagged for the PR. +# +# Triggers: +# repository_dispatch (pr-docker-image) — fired by cicd_1-pr.yml right after a +# labeled PR's build succeeds, while tests are still running. Payload carries +# {pr, sha, branch, run_id}; the artifact is pulled from that run. +# pull_request_target (labeled) — the label was applied to an existing PR with +# no new commit; reuse the newest docker-image artifact for the head SHA. # # Tag scheme — branch feat/bedrock-agentic-harness on PR #1234 -> # dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness (mutable) # dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness_ (immutable) # -# Triggers: -# workflow_run (after "-1 PR Check") — covers pushes; reuses that run's image, -# so there is exactly one build per commit (no double build). -# pull_request labeled — covers the label being applied to an existing PR; -# reuses the most recent docker-image artifact for the head SHA, building -# only if none exists. -# # Internal branches only: fork PRs have no access to DOCKER_* secrets. -name: 'PR Docker Test Image' -run-name: "PR Docker [${{ github.event.pull_request.number && format('#{0} {1}', github.event.pull_request.number, github.event.pull_request.head.ref) || github.event.workflow_run.head_branch }}]" +name: 'Publish PR Test Image' +run-name: "Publish PR Docker [#${{ github.event.client_payload.pr || github.event.pull_request.number }}]" on: - workflow_run: - workflows: ['-1 PR Check'] - types: [completed] - pull_request: + repository_dispatch: + types: [pr-docker-image] + pull_request_target: types: [labeled] concurrency: - group: pr-docker-image-${{ github.event.pull_request.number || github.event.workflow_run.head_branch }} + group: publish-pr-test-image-${{ github.event.client_payload.pr || github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read jobs: - # Normalize the two trigger shapes into a single context and decide whether to - # proceed. Also locates a reusable docker-image artifact for the head SHA. prep: name: Resolve Context runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} @@ -50,7 +46,7 @@ jobs: pr: ${{ steps.ctx.outputs.pr }} sha: ${{ steps.ctx.outputs.sha }} tag: ${{ steps.ctx.outputs.tag }} - source_run_id: ${{ steps.ctx.outputs.source_run_id }} + run_id: ${{ steps.ctx.outputs.run_id }} steps: - name: Resolve PR context id: ctx @@ -58,59 +54,49 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} EVENT: ${{ github.event_name }} - # workflow_run shape - WR_CONCLUSION: ${{ github.event.workflow_run.conclusion }} - WR_SHA: ${{ github.event.workflow_run.head_sha }} - WR_BRANCH: ${{ github.event.workflow_run.head_branch }} - WR_RUN_ID: ${{ github.event.workflow_run.id }} - WR_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} - # pull_request (labeled) shape - PR_LABEL: ${{ github.event.label.name }} - PR_NUMBER: ${{ github.event.pull_request.number }} - PR_BRANCH: ${{ github.event.pull_request.head.ref }} - PR_SHA: ${{ github.event.pull_request.head.sha }} - PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + # repository_dispatch payload + CP_PR: ${{ github.event.client_payload.pr }} + CP_SHA: ${{ github.event.client_payload.sha }} + CP_BRANCH: ${{ github.event.client_payload.branch }} + CP_RUN_ID: ${{ github.event.client_payload.run_id }} + # pull_request_target (labeled) shape + PT_LABEL: ${{ github.event.label.name }} + PT_PR: ${{ github.event.pull_request.number }} + PT_BRANCH: ${{ github.event.pull_request.head.ref }} + PT_SHA: ${{ github.event.pull_request.head.sha }} + PT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} run: | set -euo pipefail LABEL='PR: docker image' proceed=false; pr=""; branch=""; sha=""; run_id=""; tag="" - if [ "${EVENT}" = "workflow_run" ]; then - if [ "${WR_CONCLUSION}" != "success" ]; then - echo "PR check concluded '${WR_CONCLUSION}', nothing to promote." - elif [ "${WR_HEAD_REPO}" != "${REPO}" ]; then - echo "Fork PR (${WR_HEAD_REPO}) — no secrets, skipping." + if [ "${EVENT}" = "repository_dispatch" ]; then + pr="${CP_PR}"; sha="${CP_SHA}"; branch="${CP_BRANCH}"; run_id="${CP_RUN_ID}" + # Defense in depth: the dispatch came from the PR-modifiable pipeline, + # so re-validate the label and that the SHA is still the PR head. + read -r has_label head <<<"$(gh pr view "${pr}" --repo "${REPO}" \ + --json labels,headRefOid \ + --jq '"\(.labels | map(.name) | index("'"${LABEL}"'") != null) \(.headRefOid)"')" + if [ "${has_label}" != "true" ]; then + echo "PR #${pr} not labeled '${LABEL}', skipping." + elif [ "${head}" != "${sha}" ]; then + echo "PR head ${head} moved past dispatched ${sha}; a newer run will publish." else - sha="${WR_SHA}"; branch="${WR_BRANCH}" - pr=$(gh pr list --repo "${REPO}" --head "${branch}" --state open \ - --json number --jq '.[0].number // empty') - if [ -z "${pr}" ]; then - echo "No open PR for branch '${branch}', skipping." - elif gh pr view "${pr}" --repo "${REPO}" --json labels \ - --jq '.labels[].name' | grep -qxF "${LABEL}"; then - proceed=true - # Prefer the artifact from the run that just finished. - if gh api "repos/${REPO}/actions/runs/${WR_RUN_ID}/artifacts" \ - --jq '.artifacts[].name' | grep -qx 'docker-image'; then - run_id="${WR_RUN_ID}" - fi - else - echo "PR #${pr} is not labeled '${LABEL}', skipping." - fi + proceed=true fi - elif [ "${EVENT}" = "pull_request" ]; then - if [ "${PR_LABEL}" != "${LABEL}" ]; then - echo "Label '${PR_LABEL}' is not '${LABEL}', skipping." - elif [ "${PR_HEAD_REPO}" != "${REPO}" ]; then - echo "Fork PR (${PR_HEAD_REPO}) — no secrets, skipping." + elif [ "${EVENT}" = "pull_request_target" ]; then + if [ "${PT_LABEL}" != "${LABEL}" ]; then + echo "Label '${PT_LABEL}' is not '${LABEL}', skipping." + elif [ "${PT_HEAD_REPO}" != "${REPO}" ]; then + echo "Fork PR (${PT_HEAD_REPO}) — no secrets, skipping." else - proceed=true; pr="${PR_NUMBER}"; branch="${PR_BRANCH}"; sha="${PR_SHA}" + proceed=true; pr="${PT_PR}"; branch="${PT_BRANCH}"; sha="${PT_SHA}" fi fi - # If we have no run id yet, look for the newest run with a non-expired - # docker-image artifact for this exact commit. + # Label path has no run id — find the newest run with a non-expired + # docker-image artifact for this commit. if [ "${proceed}" = "true" ] && [ -z "${run_id}" ]; then for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${sha}&per_page=50" \ --jq '.workflow_runs[].id'); do @@ -121,6 +107,10 @@ jobs: run_id="${rid}"; break fi done + if [ -z "${run_id}" ]; then + echo "No docker-image artifact found for ${sha}; push a commit to build one." + proceed=false + fi fi if [ "${proceed}" = "true" ]; then @@ -130,7 +120,7 @@ jobs: | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ | cut -c1-100) tag="pr-${pr}-${norm}" - echo "PR #${pr} sha=${sha} tag=${tag} source_run_id=${run_id:-}" + echo "PR #${pr} sha=${sha} tag=${tag} run_id=${run_id}" fi { @@ -138,30 +128,13 @@ jobs: echo "pr=${pr}" echo "sha=${sha}" echo "tag=${tag}" - echo "source_run_id=${run_id}" + echo "run_id=${run_id}" } >> "$GITHUB_OUTPUT" - # Fallback: only when no reusable docker-image artifact exists for the SHA - # (e.g. label applied to a PR whose build was skipped, or artifact expired). - build: - name: Build - needs: [prep] - if: needs.prep.outputs.proceed == 'true' && needs.prep.outputs.source_run_id == '' - uses: ./.github/workflows/cicd_comp_build-phase.yml - with: - core-build: true - run-pr-checks: false - ref: ${{ needs.prep.outputs.sha }} - generate-docker: true - permissions: - contents: read - packages: write - - promote: + publish: name: Promote to Docker Hub - needs: [prep, build] - # build is skipped when reusing an existing artifact — proceed unless it failed. - if: always() && needs.prep.outputs.proceed == 'true' && needs.build.result != 'failure' && needs.build.result != 'cancelled' + needs: [prep] + if: needs.prep.outputs.proceed == 'true' runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} permissions: contents: read @@ -173,8 +146,7 @@ jobs: with: name: docker-image path: /tmp/prebuilt - # Reused run when found, otherwise this run's own build job. - run-id: ${{ needs.prep.outputs.source_run_id || github.run_id }} + run-id: ${{ needs.prep.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Load, retag and push @@ -205,8 +177,7 @@ jobs: done echo "tags=$(echo ${pushed} | xargs)" >> "$GITHUB_OUTPUT" - # Upsert a single sticky comment with the latest pushed tags, keyed on a - # hidden marker so each new push updates it in place. + # Upsert a single sticky comment with the latest pushed tags. - name: Sticky comment with image tags if: success() env: From ee28235aba659d64bacd09dce272d85fd97b207f Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 14:03:11 -0400 Subject: [PATCH 06/10] fix(ci): skip test-image dispatch cleanly when token unset (#36331) Without PR_DOCKER_DISPATCH_TOKEN the dispatch ran unauthenticated and failed, showing a red check on every labeled PR. Guard on the token and exit 0 when it's absent so the job is green until maintainers provision it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cicd_1-pr.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/cicd_1-pr.yml b/.github/workflows/cicd_1-pr.yml index 891e03c330be..3203c619b1d9 100644 --- a/.github/workflows/cicd_1-pr.yml +++ b/.github/workflows/cicd_1-pr.yml @@ -95,6 +95,10 @@ jobs: BRANCH: ${{ github.event.pull_request.head.ref }} RUN_ID: ${{ github.run_id }} run: | + if [ -z "${GH_TOKEN}" ]; then + echo "PR_DOCKER_DISPATCH_TOKEN is not configured — skipping test-image dispatch." + exit 0 + fi jq -nc \ --arg pr "${PR}" --arg sha "${SHA}" --arg branch "${BRANCH}" --arg run "${RUN_ID}" \ '{event_type:"pr-docker-image", client_payload:{pr:$pr,sha:$sha,branch:$branch,run_id:$run}}' \ From 83317640e6ea0670ce536b504ca5c42ea152d39c Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 14:07:46 -0400 Subject: [PATCH 07/10] refactor(ci): publish test image with existing DOCKER_* secrets, drop dispatch token (#36331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the repository_dispatch indirection (which needed a new PAT) with a direct publish using the DOCKER_USERNAME/DOCKER_TOKEN secrets already in the repo: - cicd_comp_publish-pr-test-image.yml (new, reusable): loads the docker-image artifact, retags pr-N-branch (+_sha), pushes to dotcms/dotcms-test, sticky comment. Discovers the artifact by SHA when no run id is passed. - cicd_1-pr.yml: publish-test-image job (needs build, label+internal gated) calls it right after build with artifact_run_id=github.run_id — publishes in parallel with tests, no dispatch token. - cicd_publish-pr-test-image.yml: now a thin pull_request_target(labeled) entry for the label-applied case, calling the same reusable workflow. Note: DOCKER_TOKEN can push to dotcms/dotcms, so the build-path job exposes it to the PR-modifiable pipeline (internal branches only) — same risk class as the existing CI_MACHINE_TOKEN/SEMGREP_APP_TOKEN usage there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cicd_1-pr.yml | 47 ++-- .../cicd_comp_publish-pr-test-image.yml | 162 +++++++++++++ .../workflows/cicd_publish-pr-test-image.yml | 212 ++---------------- 3 files changed, 203 insertions(+), 218 deletions(-) create mode 100644 .github/workflows/cicd_comp_publish-pr-test-image.yml diff --git a/.github/workflows/cicd_1-pr.yml b/.github/workflows/cicd_1-pr.yml index 3203c619b1d9..c14dd889476a 100644 --- a/.github/workflows/cicd_1-pr.yml +++ b/.github/workflows/cicd_1-pr.yml @@ -70,39 +70,30 @@ jobs: contents: read packages: write - # Trigger Test Image Publish - for PRs labeled "PR: docker image", fire a - # repository_dispatch as soon as the build artifact exists (in parallel with - # tests) so the trusted publisher (cicd_publish-pr-test-image.yml) promotes the - # docker-image artifact to dotcms/dotcms-test. Uses a minimal dispatch token, - # NOT the Docker registry secrets, which must never enter this PR-modifiable - # pipeline. Internal branches only (the token is absent for fork PRs). - trigger-test-image: - name: Trigger Test Image Publish + # Publish Test Image - for PRs labeled "PR: docker image", promote the + # docker-image artifact this build just produced to dotcms/dotcms-test, in + # parallel with the tests below. Label-gated and internal-branch only (fork PRs + # have no DOCKER_* secrets). Skipped automatically when build did not run. + publish-test-image: + name: Publish Test Image needs: [ initialize, build ] if: >- always() && needs.build.result == 'success' && github.event.pull_request.head.repo.full_name == github.repository && contains(github.event.pull_request.labels.*.name, 'PR: docker image') - runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} - continue-on-error: true # never fail the PR check over a test-image dispatch - steps: - - name: Dispatch publish event - env: - GH_TOKEN: ${{ secrets.PR_DOCKER_DISPATCH_TOKEN }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - SHA: ${{ github.event.pull_request.head.sha }} - BRANCH: ${{ github.event.pull_request.head.ref }} - RUN_ID: ${{ github.run_id }} - run: | - if [ -z "${GH_TOKEN}" ]; then - echo "PR_DOCKER_DISPATCH_TOKEN is not configured — skipping test-image dispatch." - exit 0 - fi - jq -nc \ - --arg pr "${PR}" --arg sha "${SHA}" --arg branch "${BRANCH}" --arg run "${RUN_ID}" \ - '{event_type:"pr-docker-image", client_payload:{pr:$pr,sha:$sha,branch:$branch,run_id:$run}}' \ - | gh api "repos/${REPO}/dispatches" -X POST --input - + uses: ./.github/workflows/cicd_comp_publish-pr-test-image.yml + with: + pr: ${{ github.event.pull_request.number }} + sha: ${{ github.event.pull_request.head.sha }} + branch: ${{ github.event.pull_request.head.ref }} + artifact_run_id: ${{ github.run_id }} + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + permissions: + contents: read + actions: read + pull-requests: write # Test job - runs various tests based on initialization outputs test: diff --git a/.github/workflows/cicd_comp_publish-pr-test-image.yml b/.github/workflows/cicd_comp_publish-pr-test-image.yml new file mode 100644 index 000000000000..264baba65310 --- /dev/null +++ b/.github/workflows/cicd_comp_publish-pr-test-image.yml @@ -0,0 +1,162 @@ +# Reusable: promote an already-built docker-image artifact to dotcms/dotcms-test. +# +# Does NOT build. The PR build (core-cicd/maven-job) already builds +# dotcms/dotcms-test: and uploads it as the `docker-image` artifact; +# this loads that artifact, retags it for the PR, and pushes it to Docker Hub. +# +# Callers: +# cicd_1-pr.yml — needs: build, passes artifact_run_id = github.run_id +# cicd_publish-pr-test-image.yml (pull_request_target labeled) — omits +# artifact_run_id, so this workflow discovers the newest docker-image +# artifact for the head SHA. +# +# Tag scheme — branch feat/x on PR #1234 -> +# dotcms/dotcms-test:pr-1234-feat-x (mutable) +# dotcms/dotcms-test:pr-1234-feat-x_ (immutable) + +name: 'Publish PR Test Image (reusable)' + +on: + workflow_call: + inputs: + pr: + required: true + type: string + sha: + required: true + type: string + branch: + required: true + type: string + artifact_run_id: + description: 'Run id holding the docker-image artifact; empty = discover by SHA' + required: false + type: string + default: '' + secrets: + DOCKER_USERNAME: + required: true + DOCKER_TOKEN: + required: true + +permissions: + contents: read + +jobs: + publish: + name: Promote to Docker Hub + runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + permissions: + contents: read + actions: read + pull-requests: write + steps: + - name: Resolve artifact run and tag + id: resolve + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + SHA: ${{ inputs.sha }} + BRANCH: ${{ inputs.branch }} + PR: ${{ inputs.pr }} + RUN_ID_IN: ${{ inputs.artifact_run_id }} + run: | + set -euo pipefail + run_id="${RUN_ID_IN}" + # Label path has no run id — find the newest run with a non-expired + # docker-image artifact for this exact commit. + if [ -z "${run_id}" ]; then + for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${SHA}&per_page=50" \ + --jq '.workflow_runs[].id'); do + [ "${rid}" = "${GITHUB_RUN_ID}" ] && continue + if gh api "repos/${REPO}/actions/runs/${rid}/artifacts" \ + --jq '.artifacts[] | select(.expired==false) | .name' \ + | grep -qx 'docker-image'; then + run_id="${rid}"; break + fi + done + fi + if [ -z "${run_id}" ]; then + echo "No docker-image artifact for ${SHA}; nothing to publish yet." + echo "found=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + norm=$(printf '%s' "${BRANCH}" \ + | tr '[:upper:]' '[:lower:]' \ + | tr -c 'a-z0-9._-' '-' \ + | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ + | cut -c1-100) + { + echo "found=true" + echo "run_id=${run_id}" + echo "tag=pr-${PR}-${norm}" + } >> "$GITHUB_OUTPUT" + echo "Publishing pr-${PR}-${norm} from run ${run_id}" + + - name: Download prebuilt image + if: steps.resolve.outputs.found == 'true' + uses: actions/download-artifact@v4 + with: + name: docker-image + path: /tmp/prebuilt + run-id: ${{ steps.resolve.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Load, retag and push + id: push + if: steps.resolve.outputs.found == 'true' + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + TAG: ${{ steps.resolve.outputs.tag }} + SHA: ${{ inputs.sha }} + run: | + set -euo pipefail + DEST='dotcms/dotcms-test' + SHA7="${SHA:0:7}" + + loaded=$(docker load -i /tmp/prebuilt/image.tar | sed -n 's/.*Loaded image: //p' | head -1) + if [ -z "${loaded}" ]; then + echo "::error::No image found in artifact"; exit 1 + fi + echo "Loaded ${loaded}" + + echo "${DOCKER_TOKEN}" | docker login -u "${DOCKER_USERNAME}" --password-stdin + + pushed="" + for t in "${TAG}" "${TAG}_${SHA7}"; do + docker tag "${loaded}" "${DEST}:${t}" + docker push "${DEST}:${t}" + pushed="${pushed} ${DEST}:${t}" + done + echo "tags=$(echo ${pushed} | xargs)" >> "$GITHUB_OUTPUT" + + - name: Sticky comment with image tags + if: steps.resolve.outputs.found == 'true' && steps.push.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR: ${{ inputs.pr }} + SHA: ${{ inputs.sha }} + TAGS: ${{ steps.push.outputs.tags }} + run: | + MARKER='' + { + echo "${MARKER}" + echo "### 🐳 PR Docker test image" + echo "" + echo "Latest build for commit \`${SHA:0:7}\` pushed to \`dotcms/dotcms-test\`:" + echo "" + echo '```bash' + for t in ${TAGS}; do echo "docker pull ${t}"; done + echo '```' + } > body.md + + id=$(gh api "repos/${REPO}/issues/${PR}/comments" -f per_page=100 \ + --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty") + if [ -n "${id}" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null + echo "Updated sticky comment ${id}" + else + gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null + echo "Created sticky comment" + fi diff --git a/.github/workflows/cicd_publish-pr-test-image.yml b/.github/workflows/cicd_publish-pr-test-image.yml index adab5864aec2..997e0396700f 100644 --- a/.github/workflows/cicd_publish-pr-test-image.yml +++ b/.github/workflows/cicd_publish-pr-test-image.yml @@ -1,210 +1,42 @@ -# Trusted publisher for per-PR test images (dotcms/dotcms-test). +# Publish a per-PR test image when the "PR: docker image" label is applied to an +# existing PR (no new commit). The push-on-commit path lives in cicd_1-pr.yml, +# which publishes right after build. This entry covers the label-applied case by +# reusing the newest docker-image artifact already built for the head SHA. # -# Runs from main only — via repository_dispatch and pull_request_target — so the -# DOCKER_* secrets it uses never enter the PR-modifiable `-1 PR Check` pipeline. -# It does NOT build: it promotes the `docker-image` artifact that the PR build -# already produced (core-cicd/maven-job) to Docker Hub, retagged for the PR. -# -# Triggers: -# repository_dispatch (pr-docker-image) — fired by cicd_1-pr.yml right after a -# labeled PR's build succeeds, while tests are still running. Payload carries -# {pr, sha, branch, run_id}; the artifact is pulled from that run. -# pull_request_target (labeled) — the label was applied to an existing PR with -# no new commit; reuse the newest docker-image artifact for the head SHA. -# -# Tag scheme — branch feat/bedrock-agentic-harness on PR #1234 -> -# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness (mutable) -# dotcms/dotcms-test:pr-1234-feat-bedrock-agentic-harness_ (immutable) -# -# Internal branches only: fork PRs have no access to DOCKER_* secrets. +# Uses pull_request_target so it runs from main (DOCKER_* secrets never run in +# PR-modifiable code on this path) and so the GITHUB_TOKEN can comment. +# Internal branches only: fork PRs have no secrets. name: 'Publish PR Test Image' -run-name: "Publish PR Docker [#${{ github.event.client_payload.pr || github.event.pull_request.number }}]" +run-name: "Publish PR Docker [#${{ github.event.pull_request.number }} ${{ github.event.pull_request.head.ref }}]" on: - repository_dispatch: - types: [pr-docker-image] pull_request_target: types: [labeled] concurrency: - group: publish-pr-test-image-${{ github.event.client_payload.pr || github.event.pull_request.number }} + group: publish-pr-test-image-${{ github.event.pull_request.number }} cancel-in-progress: true permissions: contents: read jobs: - prep: - name: Resolve Context - runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} - permissions: - actions: read - pull-requests: read - outputs: - proceed: ${{ steps.ctx.outputs.proceed }} - pr: ${{ steps.ctx.outputs.pr }} - sha: ${{ steps.ctx.outputs.sha }} - tag: ${{ steps.ctx.outputs.tag }} - run_id: ${{ steps.ctx.outputs.run_id }} - steps: - - name: Resolve PR context - id: ctx - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - EVENT: ${{ github.event_name }} - # repository_dispatch payload - CP_PR: ${{ github.event.client_payload.pr }} - CP_SHA: ${{ github.event.client_payload.sha }} - CP_BRANCH: ${{ github.event.client_payload.branch }} - CP_RUN_ID: ${{ github.event.client_payload.run_id }} - # pull_request_target (labeled) shape - PT_LABEL: ${{ github.event.label.name }} - PT_PR: ${{ github.event.pull_request.number }} - PT_BRANCH: ${{ github.event.pull_request.head.ref }} - PT_SHA: ${{ github.event.pull_request.head.sha }} - PT_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - run: | - set -euo pipefail - LABEL='PR: docker image' - proceed=false; pr=""; branch=""; sha=""; run_id=""; tag="" - - if [ "${EVENT}" = "repository_dispatch" ]; then - pr="${CP_PR}"; sha="${CP_SHA}"; branch="${CP_BRANCH}"; run_id="${CP_RUN_ID}" - # Defense in depth: the dispatch came from the PR-modifiable pipeline, - # so re-validate the label and that the SHA is still the PR head. - read -r has_label head <<<"$(gh pr view "${pr}" --repo "${REPO}" \ - --json labels,headRefOid \ - --jq '"\(.labels | map(.name) | index("'"${LABEL}"'") != null) \(.headRefOid)"')" - if [ "${has_label}" != "true" ]; then - echo "PR #${pr} not labeled '${LABEL}', skipping." - elif [ "${head}" != "${sha}" ]; then - echo "PR head ${head} moved past dispatched ${sha}; a newer run will publish." - else - proceed=true - fi - - elif [ "${EVENT}" = "pull_request_target" ]; then - if [ "${PT_LABEL}" != "${LABEL}" ]; then - echo "Label '${PT_LABEL}' is not '${LABEL}', skipping." - elif [ "${PT_HEAD_REPO}" != "${REPO}" ]; then - echo "Fork PR (${PT_HEAD_REPO}) — no secrets, skipping." - else - proceed=true; pr="${PT_PR}"; branch="${PT_BRANCH}"; sha="${PT_SHA}" - fi - fi - - # Label path has no run id — find the newest run with a non-expired - # docker-image artifact for this commit. - if [ "${proceed}" = "true" ] && [ -z "${run_id}" ]; then - for rid in $(gh api "repos/${REPO}/actions/runs?head_sha=${sha}&per_page=50" \ - --jq '.workflow_runs[].id'); do - [ "${rid}" = "${GITHUB_RUN_ID}" ] && continue - if gh api "repos/${REPO}/actions/runs/${rid}/artifacts" \ - --jq '.artifacts[] | select(.expired==false) | .name' \ - | grep -qx 'docker-image'; then - run_id="${rid}"; break - fi - done - if [ -z "${run_id}" ]; then - echo "No docker-image artifact found for ${sha}; push a commit to build one." - proceed=false - fi - fi - - if [ "${proceed}" = "true" ]; then - norm=$(printf '%s' "${branch}" \ - | tr '[:upper:]' '[:lower:]' \ - | tr -c 'a-z0-9._-' '-' \ - | sed -E 's/-+/-/g; s/^[-.]+//; s/[-.]+$//' \ - | cut -c1-100) - tag="pr-${pr}-${norm}" - echo "PR #${pr} sha=${sha} tag=${tag} run_id=${run_id}" - fi - - { - echo "proceed=${proceed}" - echo "pr=${pr}" - echo "sha=${sha}" - echo "tag=${tag}" - echo "run_id=${run_id}" - } >> "$GITHUB_OUTPUT" - publish: - name: Promote to Docker Hub - needs: [prep] - if: needs.prep.outputs.proceed == 'true' - runs-on: ubuntu-${{ vars.UBUNTU_RUNNER_VERSION || '24.04' }} + name: Publish + if: >- + github.event.label.name == 'PR: docker image' && + github.event.pull_request.head.repo.full_name == github.repository + uses: ./.github/workflows/cicd_comp_publish-pr-test-image.yml + with: + pr: ${{ github.event.pull_request.number }} + sha: ${{ github.event.pull_request.head.sha }} + branch: ${{ github.event.pull_request.head.ref }} + # artifact_run_id omitted -> reusable workflow discovers it by SHA + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} permissions: contents: read actions: read pull-requests: write - steps: - - name: Download prebuilt image - uses: actions/download-artifact@v4 - with: - name: docker-image - path: /tmp/prebuilt - run-id: ${{ needs.prep.outputs.run_id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Load, retag and push - id: push - env: - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} - TAG: ${{ needs.prep.outputs.tag }} - SHA: ${{ needs.prep.outputs.sha }} - run: | - set -euo pipefail - DEST='dotcms/dotcms-test' - SHA7="${SHA:0:7}" - - loaded=$(docker load -i /tmp/prebuilt/image.tar | sed -n 's/.*Loaded image: //p' | head -1) - if [ -z "${loaded}" ]; then - echo "::error::No image found in artifact"; exit 1 - fi - echo "Loaded ${loaded}" - - echo "${DOCKER_TOKEN}" | docker login -u "${DOCKER_USERNAME}" --password-stdin - - pushed="" - for t in "${TAG}" "${TAG}_${SHA7}"; do - docker tag "${loaded}" "${DEST}:${t}" - docker push "${DEST}:${t}" - pushed="${pushed} ${DEST}:${t}" - done - echo "tags=$(echo ${pushed} | xargs)" >> "$GITHUB_OUTPUT" - - # Upsert a single sticky comment with the latest pushed tags. - - name: Sticky comment with image tags - if: success() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - PR: ${{ needs.prep.outputs.pr }} - SHA: ${{ needs.prep.outputs.sha }} - TAGS: ${{ steps.push.outputs.tags }} - run: | - MARKER='' - { - echo "${MARKER}" - echo "### 🐳 PR Docker test image" - echo "" - echo "Latest build for commit \`${SHA:0:7}\` pushed to \`dotcms/dotcms-test\`:" - echo "" - echo '```bash' - for t in ${TAGS}; do echo "docker pull ${t}"; done - echo '```' - } > body.md - - id=$(gh api "repos/${REPO}/issues/${PR}/comments" -f per_page=100 \ - --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty") - if [ -n "${id}" ]; then - gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null - echo "Updated sticky comment ${id}" - else - gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null - echo "Created sticky comment" - fi From 5f80b333b10476e5be20b681ed773f9fb2fde467 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 14:28:25 -0400 Subject: [PATCH 08/10] chore: re-trigger PR test-image publish (#36331) Co-Authored-By: Claude Opus 4.8 (1M context) From beff692f408faa6c5ddb20e9a26ec91bfb52b091 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 14:48:59 -0400 Subject: [PATCH 09/10] chore: re-trigger PR test-image publish after dockerhub perms fix (#36331) Co-Authored-By: Claude Opus 4.8 (1M context) From 2bb76b27f345a3a850270488f39c4de2c06e89f7 Mon Sep 17 00:00:00 2001 From: Will Ezell Date: Fri, 26 Jun 2026 15:07:32 -0400 Subject: [PATCH 10/10] fix(ci): build sticky comment body with jq --rawfile (#36331) gh api has no @file syntax for field values, so -F body=@body.md sent an empty body (HTTP 422 "body wasn't supplied"). Build the JSON with jq --rawfile and POST/PATCH via --input -. The image push itself already worked; this only fixes the trailing sticky-comment step. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/cicd_comp_publish-pr-test-image.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd_comp_publish-pr-test-image.yml b/.github/workflows/cicd_comp_publish-pr-test-image.yml index 264baba65310..c04bdad536fd 100644 --- a/.github/workflows/cicd_comp_publish-pr-test-image.yml +++ b/.github/workflows/cicd_comp_publish-pr-test-image.yml @@ -153,10 +153,12 @@ jobs: id=$(gh api "repos/${REPO}/issues/${PR}/comments" -f per_page=100 \ --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty") + # gh api has no @file syntax for fields — build the JSON body with jq. + payload=$(jq -nc --rawfile b body.md '{body: $b}') if [ -n "${id}" ]; then - gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null + printf '%s' "${payload}" | gh api -X PATCH "repos/${REPO}/issues/comments/${id}" --input - >/dev/null echo "Updated sticky comment ${id}" else - gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null + printf '%s' "${payload}" | gh api -X POST "repos/${REPO}/issues/${PR}/comments" --input - >/dev/null echo "Created sticky comment" fi