diff --git a/.github/actions/core-cicd/build-cache-remote/action.yml b/.github/actions/core-cicd/build-cache-remote/action.yml new file mode 100644 index 000000000000..3e5aca5270d4 --- /dev/null +++ b/.github/actions/core-cicd/build-cache-remote/action.yml @@ -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..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})." diff --git a/.github/actions/core-cicd/maven-job/action.yml b/.github/actions/core-cicd/maven-job/action.yml index 1a8cbf80bd5c..61452b19e960 100644 --- a/.github/actions/core-cicd/maven-job/action.yml +++ b/.github/actions/core-cicd/maven-job/action.yml @@ -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 == '' }} @@ -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" diff --git a/.github/test-matrix.yml b/.github/test-matrix.yml index d9fe80aa8168..07f815deee5e 100644 --- a/.github/test-matrix.yml +++ b/.github/test-matrix.yml @@ -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 === diff --git a/.github/workflows/cicd_1-pr.yml b/.github/workflows/cicd_1-pr.yml index 3a4a57bc264a..8ba392e770a5 100644 --- a/.github/workflows/cicd_1-pr.yml +++ b/.github/workflows/cicd_1-pr.yml @@ -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 diff --git a/.github/workflows/cicd_2-merge-queue.yml b/.github/workflows/cicd_2-merge-queue.yml index 0af9b08b50c1..4cb5de844bde 100644 --- a/.github/workflows/cicd_2-merge-queue.yml +++ b/.github/workflows/cicd_2-merge-queue.yml @@ -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 diff --git a/.github/workflows/cicd_3-trunk.yml b/.github/workflows/cicd_3-trunk.yml index 70c4c8668c84..92b2e6be6186 100644 --- a/.github/workflows/cicd_3-trunk.yml +++ b/.github/workflows/cicd_3-trunk.yml @@ -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 diff --git a/.github/workflows/cicd_comp_build-phase.yml b/.github/workflows/cicd_comp_build-phase.yml index 978df5121b78..ef922019f7bf 100644 --- a/.github/workflows/cicd_comp_build-phase.yml +++ b/.github/workflows/cicd_comp_build-phase.yml @@ -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 @@ -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 }} @@ -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 </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 diff --git a/.github/workflows/cicd_comp_test-phase.yml b/.github/workflows/cicd_comp_test-phase.yml index 68c1dfd4bcac..932d48665d1b 100644 --- a/.github/workflows/cicd_comp_test-phase.yml +++ b/.github/workflows/cicd_comp_test-phase.yml @@ -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: diff --git a/.mvn/maven-build-cache-config.xml b/.mvn/maven-build-cache-config.xml index f500f6dadf68..b74108c0037d 100644 --- a/.mvn/maven-build-cache-config.xml +++ b/.mvn/maven-build-cache-config.xml @@ -34,6 +34,13 @@ https://maven.apache.org/extensions/maven-build-cache-extension/maven-build-cach 3 + + diff --git a/core-web/pom.xml b/core-web/pom.xml index 452184a4ec03..f1dbf19dc0a0 100644 --- a/core-web/pom.xml +++ b/core-web/pom.xml @@ -177,6 +177,11 @@ exec nx format:check + + --base=${git.origin.branch} @@ -387,22 +392,6 @@ - - is_pr - - - - github.event.name - pull_request - - - - origin/main - --base=${git.origin.branch} --head=HEAD - --branch=${git.origin.branch} - false - - format