diff --git a/.github/workflows/cicd_1-pr.yml b/.github/workflows/cicd_1-pr.yml index ff6a18401831..c14dd889476a 100644 --- a/.github/workflows/cicd_1-pr.yml +++ b/.github/workflows/cicd_1-pr.yml @@ -70,6 +70,31 @@ jobs: contents: read packages: write + # 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') + 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: name: PR 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..c04bdad536fd --- /dev/null +++ b/.github/workflows/cicd_comp_publish-pr-test-image.yml @@ -0,0 +1,164 @@ +# 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") + # 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 + printf '%s' "${payload}" | gh api -X PATCH "repos/${REPO}/issues/comments/${id}" --input - >/dev/null + echo "Updated sticky comment ${id}" + else + printf '%s' "${payload}" | gh api -X POST "repos/${REPO}/issues/${PR}/comments" --input - >/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 new file mode 100644 index 000000000000..997e0396700f --- /dev/null +++ b/.github/workflows/cicd_publish-pr-test-image.yml @@ -0,0 +1,42 @@ +# 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. +# +# 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.pull_request.number }} ${{ github.event.pull_request.head.ref }}]" + +on: + pull_request_target: + types: [labeled] + +concurrency: + group: publish-pr-test-image-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + publish: + 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