Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions .github/actions/core-cicd/build-cache-remote/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
name: 'Remote Build Cache'
description: |
Points the Apache Maven Build Cache Extension at the S3 build-cache bucket.

The extension speaks plain HTTP PUT/GET/HEAD, and S3 needs SigV4, so an
aws-sigv4-proxy container signs on the way out. Maven talks to 127.0.0.1.

Exports BUILD_CACHE_ARGS for the Maven job to append. If the bucket secrets
are absent (forks, or the feature is off) it exports an empty string and the
build runs exactly as it does today.

inputs:
enabled:
description: |
Set false to build without the cache. The caller wires this to a global
kill switch (BUILD_CACHE_DISABLED) and a per-PR label, so a suspected bad
entry can be bypassed without editing a workflow.
required: false
default: 'true'
bucket:
description: 'S3 bucket name for the build cache'
required: false
default: ''
endpoint:
description: 'S3 endpoint URL, e.g. https://s3.gra.io.cloud.ovh.net'
required: false
default: ''
access-key:
description: 'S3 access key. Use the READ-ONLY key on untrusted refs.'
required: false
default: ''
secret-key:
description: 'S3 secret key'
required: false
default: ''
region:
description: 'Signing region. Derived from the endpoint host when empty.'
required: false
default: ''
save:
description: |
Whether this job may push to the shared cache. Only trusted refs
(trunk, merge queue) should ever set this true — a writable cache
reached from untrusted code is a supply-chain vector (CVE-2025-36852).
Defence in depth only: the real control is handing PR jobs a
GetObject-only key.
required: false
default: 'false'
prefix:
description: 'Key prefix inside the bucket'
required: false
default: 'maven-build-cache'
port:
description: 'Localhost port for the signing proxy'
required: false
default: '8079'

outputs:
enabled:
description: 'true when the remote cache was wired up'
value: ${{ steps.setup.outputs.enabled }}

runs:
using: 'composite'
steps:
- id: setup
name: Start build-cache signing proxy
shell: bash
env:
ENABLED: ${{ inputs.enabled }}
BUCKET: ${{ inputs.bucket }}
ENDPOINT: ${{ inputs.endpoint }}
AWS_ACCESS_KEY_ID: ${{ inputs.access-key }}
AWS_SECRET_ACCESS_KEY: ${{ inputs.secret-key }}
REGION_IN: ${{ inputs.region }}
SAVE: ${{ inputs.save }}
PREFIX: ${{ inputs.prefix }}
PORT: ${{ inputs.port }}
run: |
set -euo pipefail
echo "BUILD_CACHE_ARGS=" >> "$GITHUB_ENV"
echo "enabled=false" >> "$GITHUB_OUTPUT"

if [[ "$ENABLED" != "true" ]]; then
echo "Remote build cache turned off (BUILD_CACHE_DISABLED variable or PR label)."
exit 0
fi
if [[ -z "$BUCKET" || -z "$ENDPOINT" || -z "$AWS_ACCESS_KEY_ID" || -z "$AWS_SECRET_ACCESS_KEY" ]]; then
echo "Build-cache secrets not available; remote cache disabled."
exit 0
fi
if [[ "${RUNNER_OS}" != "Linux" ]]; then
echo "Remote cache only wired for Linux runners; skipping on ${RUNNER_OS}."
exit 0
fi

HOST="${ENDPOINT#*://}"; HOST="${HOST%%/*}"
SCHEME="${ENDPOINT%%://*}"; [[ "$SCHEME" == "$ENDPOINT" ]] && SCHEME=https

# OVH endpoints look like s3.<region>.io.cloud.ovh.net
REGION="$REGION_IN"
if [[ -z "$REGION" ]]; then
REGION=$(echo "$HOST" | cut -d. -f2)
echo "Derived signing region '${REGION}' from ${HOST}"
fi
if [[ -z "$REGION" ]]; then
echo "::warning::Could not derive a signing region from '${HOST}'; remote cache disabled."
exit 0
fi

# Pinned by digest, not :latest. This container is handed the bucket
# credentials and proxies every cache read and write, so a repointed tag
# would be a credential-stealing position. The digest below is the one
# validated end-to-end against the OVH bucket; it is an OCI index
# covering linux/amd64 and linux/arm64. Bump deliberately.
docker run -d --name build-cache-sigv4 \
-p "127.0.0.1:${PORT}:8080" \
-e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \
public.ecr.aws/aws-observability/aws-sigv4-proxy@sha256:6cd48ff272e30b6c3c01c02eac42dc3376bc3efa6ed3377bccd484e1c1b389df \
--name s3 --region "$REGION" \
--host "$HOST" --sign-host "$HOST" \
--upstream-url-scheme "$SCHEME" \
--log-failed-requests

# A HEAD on a key that does not exist must answer 404, not 403 — the
# extension reads 403 as a hard error rather than a cache miss.
for i in $(seq 1 20); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' -I \
"http://127.0.0.1:${PORT}/${BUCKET}/${PREFIX}/.probe" || echo 000)
case "$CODE" in
404|200) echo "Signing proxy ready (probe HTTP ${CODE})"; break ;;
403) echo "::warning::Bucket rejected the probe (403) — bad key or region. Remote cache disabled."
docker rm -f build-cache-sigv4 >/dev/null 2>&1 || true
exit 0 ;;
esac
sleep 1
done
if [[ "${CODE:-000}" != "404" && "${CODE:-000}" != "200" ]]; then
echo "::warning::Signing proxy did not become ready (last HTTP ${CODE:-000}). Remote cache disabled."
docker logs build-cache-sigv4 2>&1 | tail -20 || true
docker rm -f build-cache-sigv4 >/dev/null 2>&1 || true
exit 0
fi

# This job is not supposed to be able to write. That is the whole security
# boundary — client-side flags are not, since a job holding a writable key
# can bypass Maven entirely. Prove the key is really read-only instead of
# assuming it, because a silently-writable "read-only" key looks identical
# to a correct setup until someone abuses it.
if [[ "$SAVE" != "true" ]]; then
WCODE=$(curl -s -o /dev/null -w '%{http_code}' -X PUT --data-binary '' \
"http://127.0.0.1:${PORT}/${BUCKET}/${PREFIX}/_probe/readonly-check" || echo 000)
if [[ "$WCODE" == "403" ]]; then
echo "Verified: this job's credentials cannot write to the cache."
elif [[ "$WCODE" == "200" || "$WCODE" == "204" ]]; then
echo "::warning title=Build cache key is not read-only::A job that should only read \
from the build cache was able to WRITE to it (HTTP ${WCODE}). Untrusted code can poison \
entries that trusted builds later replay (CVE-2025-36852). Re-issue this key with \
GetObject-only permissions."
else
echo "Read-only write-probe returned HTTP ${WCODE}; could not confirm either way."
fi
fi

# alwaysRunPlugins is NOT optional. On a cache hit the extension skips
# every cached plugin execution, including install:install — measured:
# the module's jar then never lands in ~/.m2/repository, and this job
# exists to publish that repository as the `maven-repo` artifact ~25 test
# jobs consume. Same reasoning for docker-maven-plugin: a hit on
# dotcms-core would otherwise skip the execution that writes
# dotCMS/target/docker-build.tar, which the next step uploads.
# (Named goals, not globs — FINAL_ARGS is expanded unquoted in maven-job.)
ALWAYS_RUN="maven-install-plugin:install,docker-maven-plugin:build"

# remote.save.final: never let a later build overwrite an existing entry.
ARGS="-Dmaven.build.cache.remote.enabled=true"
ARGS="$ARGS -Dmaven.build.cache.remote.url=http://127.0.0.1:${PORT}/${BUCKET}/${PREFIX}"
ARGS="$ARGS -Dmaven.build.cache.remote.save.enabled=${SAVE}"
ARGS="$ARGS -Dmaven.build.cache.remote.save.final=true"
ARGS="$ARGS -Dmaven.build.cache.lazyRestore=true"
ARGS="$ARGS -Dmaven.build.cache.alwaysRunPlugins=${ALWAYS_RUN}"
echo "BUILD_CACHE_ARGS=$ARGS" >> "$GITHUB_ENV"
echo "BUILD_CACHE_SAVE=${SAVE}" >> "$GITHUB_ENV"
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "Remote build cache enabled (save=${SAVE})."
24 changes: 23 additions & 1 deletion .github/actions/core-cicd/maven-job/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,27 @@ runs:
echo "${DOTCMS_LICENSE_KEY}" > ${DOTCMS_LICENSE_PATH}/license.dat
echo "DOTCMS_LICENSE_FILE=${DOTCMS_LICENSE_PATH}/license.dat" >> "$GITHUB_ENV"

# The Maven distribution itself, which mvnw downloads from Maven Central on a
# cold runner. Without this every job in a run fetches the same ~40MB zip --
# around 26 downloads per run -- and Maven Central eventually answers 429,
# killing the job before any dotCMS code runs (and, under fail-fast, taking
# the rest of the matrix with it).
#
# Unlike the caches below this uses the combined action rather than the
# restore/save split: the content is an immutable versioned download, so
# there is no risk of persisting a polluted cache, and letting every job save
# means the first one to run on a cold key repairs it for the rest.
- id: cache-maven-wrapper
name: Cache Maven Wrapper Distribution
# Pinned to a commit SHA, not the mutable v4 tag: a tag can be repointed by
# the action owner, which is how the trivy-action and kics-github-action
# compromises worked. Bump deliberately when updating.
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.m2/wrapper
# Changes only when the Maven version does, so this key is ~always a hit.
key: ${{ runner.os }}-maven-wrapper-${{ hashFiles('.mvn/wrapper/maven-wrapper.properties') }}

- id: restore-cache-maven
name: Restore Maven Repository Cache
if: ${{ inputs.artifacts-from == '' }}
Expand Down Expand Up @@ -307,7 +328,8 @@ runs:
echo "Defaulting dotcms.core.compiler.release=${JAVA_MAJOR} to match java-version"
fi

FINAL_ARGS=$(echo "$DEFAULT_ARGS $COMPILER_ARGS $MAVEN_ARGS" | tr ' ' '\n' | awk '!seen[$0]++' | tr '\n' ' ')
# Set by the build-cache-remote action when the S3 cache is wired up; empty otherwise.
FINAL_ARGS=$(echo "$DEFAULT_ARGS $COMPILER_ARGS ${BUILD_CACHE_ARGS:-} $MAVEN_ARGS" | tr ' ' '\n' | awk '!seen[$0]++' | tr '\n' ' ')

if [[ "${{ runner.os }}" == "Windows" && "${{ inputs.native }}" == "true" ]]; then
echo "Building Maven with args $FINAL_ARGS"
Expand Down
5 changes: 4 additions & 1 deletion .github/test-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ test_types:
needs_node: true
suites:
- name: "Frontend Unit Tests"
maven_args: "test -pl :dotcms-core-web"
# -Pvalidate runs eslint + prettier (generate-resources). They used to run inside the
# Initial Artifact Build on PRs only, which kept core-web out of the build cache.
# This job already builds the whole workspace, so they cost almost nothing here.
maven_args: "test -pl :dotcms-core-web -Pvalidate"
stage_name: "Frontend Tests"

# === MULTI-SUITE TESTS ===
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cicd_1-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ jobs:
with:
core-build: true
run-pr-checks: true
secrets:
# READ-ONLY key: PR code is untrusted, and a build cache an attacker can
# write is replayed as a build output on trunk (CVE-2025-36852). Fork PRs
# receive no secrets at all and fall back to a normal uncached build.
build-cache-access-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ACCESS_KEY_RO }}
build-cache-secret-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_SECRET_KEY_RO }}
build-cache-endpoint: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ENDPOINT }}
build-cache-bucket: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_NAME }}
permissions:
contents: read
packages: write
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cicd_2-merge-queue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ jobs:
needs: [ initialize ]
if: needs.initialize.outputs.found_artifacts == 'false'
uses: ./.github/workflows/cicd_comp_build-phase.yml
secrets:
# Read-write: the merge queue is a trusted ref and is the primary cache
# populator — its builds are PR-shaped, so what it writes is what the next
# merge-queue build can reuse.
build-cache-access-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ACCESS_KEY }}
build-cache-secret-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_SECRET_KEY }}
build-cache-endpoint: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ENDPOINT }}
build-cache-bucket: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_NAME }}
permissions:
contents: read
packages: write
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/cicd_3-trunk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ jobs:
java-version: ${{ github.event.inputs.java-version || '' }}
maven-compiler-release: ${{ github.event.inputs.maven-compiler-release || '' }}
artifact-suffix: ${{ github.event.inputs.artifact-suffix || '' }}
secrets:
# Read-write: trunk is trusted.
build-cache-access-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ACCESS_KEY }}
build-cache-secret-key: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_SECRET_KEY }}
build-cache-endpoint: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_ENDPOINT }}
build-cache-bucket: ${{ secrets.OVH_S3_BUILD_CACHE_BUCKET_NAME }}
permissions:
contents: read
packages: write
Expand Down
75 changes: 74 additions & 1 deletion .github/workflows/cicd_comp_build-phase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ on:
required: false
type: string
default: ''
secrets:
# Remote build cache (S3). Absent secrets simply disable the cache, so any
# caller that does not pass these -- every release workflow -- builds from
# scratch exactly as it does today.
# Untrusted refs must be given the READ-ONLY key pair; see the
# build-cache-remote action for why.
build-cache-access-key:
required: false
build-cache-secret-key:
required: false
build-cache-endpoint:
required: false
build-cache-bucket:
required: false

jobs:
# Initial JDK Build
Expand Down Expand Up @@ -105,11 +119,30 @@ jobs:
echo "VALIDATE_PROFILE=" >> "$GITHUB_ENV"
fi

# Wire the Maven build cache to the shared S3 bucket. Only the merge queue
# and trunk may write; every other ref reads. Exports BUILD_CACHE_ARGS.
#
# Two ways to turn it off without editing this file:
# - repo/org variable BUILD_CACHE_DISABLED=true (global kill switch)
# - PR label "CI : No Build Cache" (one pull request)
- name: Set up remote build cache
id: build-cache
uses: ./.github/actions/core-cicd/build-cache-remote
with:
enabled: "${{ vars.BUILD_CACHE_DISABLED != 'true' && !contains(github.event.pull_request.labels.*.name, 'CI : No Build Cache') }}"
bucket: ${{ secrets.build-cache-bucket }}
endpoint: ${{ secrets.build-cache-endpoint }}
access-key: ${{ secrets.build-cache-access-key }}
secret-key: ${{ secrets.build-cache-secret-key }}
save: ${{ github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}

# Run the Maven build job
- uses: ./.github/actions/core-cicd/maven-job
with:
stage-name: "Initial Artifact Build"
maven-args: "clean install ${{ env.VALIDATE_PROFILE }} -Dprod=true -DskipTests=true -Dgithub.event.name=${{ github.event_name }}"
# No -Dgithub.event.name: its only effect was activating core-web's is_pr profile,
# which made the PR effective pom differ from trunk's for that module.
maven-args: "clean install ${{ env.VALIDATE_PROFILE }} -Dprod=true -DskipTests=true"
generate-artifacts: true
require-main: ${{ inputs.version == '1.0.0-SNAPSHOT' }}
github-token: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -120,6 +153,46 @@ jobs:
maven-compiler-release: ${{ inputs.maven-compiler-release }}
artifact-suffix: ${{ inputs.artifact-suffix }}

# Record who produced each cache entry. Nothing in an S3 bucket says which
# ref wrote a given hash, which is half of what makes a shared build cache
# a supply-chain risk (CVE-2025-36852). Written first-writer-wins, so an
# entry keeps the identity of the build that actually created it.
- name: Record build-cache provenance
if: env.BUILD_CACHE_SAVE == 'true' && success()
continue-on-error: true
env:
AWS_ACCESS_KEY_ID: ${{ secrets.build-cache-access-key }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.build-cache-secret-key }}
ENDPOINT: ${{ secrets.build-cache-endpoint }}
BUCKET: ${{ secrets.build-cache-bucket }}
run: |
set -uo pipefail
root="$HOME/.m2/build-cache"
[[ -d "$root" ]] || exit 0
cat > /tmp/provenance.json <<EOF
{
"repository": "${{ github.repository }}",
"ref": "${{ github.ref }}",
"sha": "${{ github.sha }}",
"event": "${{ github.event_name }}",
"workflow_ref": "${{ github.workflow_ref }}",
"run_id": "${{ github.run_id }}",
"run_attempt": "${{ github.run_attempt }}",
"actor": "${{ github.actor }}"
}
EOF
count=0
# A '<hash>/local' directory means this run built that module itself.
while IFS= read -r dir; do
rel="${dir#"$root"/}"
key="maven-build-cache/${rel%/local}/provenance.json"
aws --endpoint-url "$ENDPOINT" s3api head-object \
--bucket "$BUCKET" --key "$key" >/dev/null 2>&1 && continue
aws --endpoint-url "$ENDPOINT" s3 cp /tmp/provenance.json \
"s3://$BUCKET/$key" --only-show-errors && count=$((count+1))
done < <(find "$root" -mindepth 5 -maxdepth 5 -type d -name local)
echo "Recorded provenance for $count cache entries."

# Check for unauthorized changes to the working directory (only for PR checks)
- name: Check for changes to source during build
if: inputs.run-pr-checks
Expand Down
12 changes: 11 additions & 1 deletion .github/workflows/cicd_comp_test-phase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,17 @@ jobs:
# a comma-list subset like '1,2', or 'all') run every suite/phase to completion so
# failures are attributable per phase. Derived from "phase off" rather than an
# exact-match list so comma-list subsets are covered too.
fail-fast: ${{ inputs.opensearch_phase == '' || inputs.opensearch_phase == 'none' || inputs.opensearch_phase == '0' }}
#
# The label escape hatch exists because fail-fast destroys evidence: one failing
# suite cancels the other ~24, and a cancelled job is indistinguishable from a
# failed one at a glance. When you are chasing a flake, or want to know whether a
# change broke one suite or twenty, label the PR "CI : No Fail Fast" and every
# suite runs to completion. Costs runner time, so it is opt-in per PR.
# On merge_group there is no pull_request in the event, contains() is false, and
# fast-fail stays on -- the queue should still bail early.
fail-fast: >-
${{ (inputs.opensearch_phase == '' || inputs.opensearch_phase == 'none' || inputs.opensearch_phase == '0')
&& !contains(github.event.pull_request.labels.*.name, 'CI : No Fail Fast') }}
matrix: ${{ fromJSON(needs.setup-matrix.outputs.matrix) }}

steps:
Expand Down
Loading
Loading