From 6633001f4feadf88fa1b8221c79d1ecdbd2a7940 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 10:45:59 +0100 Subject: [PATCH 01/17] Benchmark Discourse Bundler mount cache --- .../discourse-image-factory/action.yml | 68 +++++++++++++++-- .github/workflows/discourse-image-factory.yml | 66 +++++++++++++++- scripts/benchmark-report.py | 13 +++- scripts/prepare_discourse_cache_profile.py | 75 +++++++++++++++++++ scripts/verify-upstream-recipe.py | 38 +++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 scripts/prepare_discourse_cache_profile.py diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index d030d7f..89c9214 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -6,6 +6,10 @@ inputs: phase: {required: true} arch: {required: true} benchmark_id: {required: true} + cache_profile: {required: false, default: ""} + report_strategy: {required: false, default: ""} + discourse_ref: {required: false, default: ""} + docker_ref: {required: false, default: ""} cli_version: {required: false, default: ""} buildkit_image: {required: false, default: ""} runs: @@ -18,10 +22,49 @@ runs: - name: Checkout pinned Discourse sources shell: bash + env: + DISCOURSE_REF: ${{ inputs.discourse_ref }} + DOCKER_REF: ${{ inputs.docker_ref }} run: | + set -euo pipefail git submodule sync --recursive git -c protocol.version=2 submodule update --init --depth 1 --recommend-shallow --jobs 4 upstream docker-upstream + checkout_immutable_ref() { + local repository="$1" + local requested_ref="$2" + local label="$3" + local depth="$4" + if [[ -z "$requested_ref" ]]; then + return + fi + if [[ ! "$requested_ref" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "$label ref must be a full immutable commit SHA: $requested_ref" >&2 + exit 1 + fi + git -C "$repository" fetch --depth "$depth" origin "$requested_ref" + git -C "$repository" checkout --detach FETCH_HEAD + } + + checkout_immutable_ref upstream "$DISCOURSE_REF" "Discourse" 1 + checkout_immutable_ref docker-upstream "$DOCKER_REF" "discourse_docker" 128 + + if [[ -n "$DOCKER_REF" ]]; then + mozilla_key_fix="a68d4b8707fd653697e8b6b27b336d093dbed5e4" + if ! git -C docker-upstream cat-file -e "${mozilla_key_fix}^{commit}" 2>/dev/null; then + git -C docker-upstream fetch --depth 1 origin "$mozilla_key_fix" + fi + if ! git -C docker-upstream merge-base --is-ancestor "$mozilla_key_fix" HEAD; then + if [[ "$(git -C docker-upstream rev-parse --is-shallow-repository)" == "true" ]]; then + git -C docker-upstream fetch --unshallow origin + fi + fi + if ! git -C docker-upstream merge-base --is-ancestor "$mozilla_key_fix" HEAD; then + echo "discourse_docker ref must contain the Mozilla signing-key fix $mozilla_key_fix" >&2 + exit 1 + fi + fi + - name: Verify the committed image-factory plans shell: bash run: python3 ./scripts/verify-upstream-recipe.py @@ -32,6 +75,12 @@ runs: PREPARE_PHASE: ${{ inputs.phase == 'warm' && 'warm1' || 'base' }} run: ./scripts/prepare-source.sh "$PREPARE_PHASE" + - name: Prepare the benchmark cache profile + shell: bash + env: + CACHE_PROFILE: ${{ inputs.cache_profile }} + run: python3 ./scripts/prepare_discourse_cache_profile.py "${CACHE_PROFILE:-baseline}" + - name: Set up Buildx for the GitHub Actions comparison if: inputs.strategy == 'actions-cache' uses: docker/setup-buildx-action@v4 @@ -62,6 +111,7 @@ runs: env: BENCHMARK_ID: ${{ inputs.benchmark_id }} CACHE_LANE: ${{ inputs.cache_lane }} + CACHE_PROFILE: ${{ inputs.cache_profile }} ARCH: ${{ inputs.arch }} run: | set -euo pipefail @@ -70,10 +120,11 @@ runs: ref_slug="${ref_slug#-}" ref_slug="${ref_slug%-}" ref_slug="${ref_slug:-main}" + profile_slug="${CACHE_PROFILE:+-${CACHE_PROFILE}}" if [[ "$CACHE_LANE" == "rolling" ]]; then - cache_scope="${BENCHMARK_ID}-rolling-${ref_slug}-${ARCH}" + cache_scope="${BENCHMARK_ID}${profile_slug}-rolling-${ref_slug}-${ARCH}" else - cache_scope="${BENCHMARK_ID}-run-r${GITHUB_RUN_ID}-a${GITHUB_RUN_ATTEMPT}-${ARCH}" + cache_scope="${BENCHMARK_ID}${profile_slug}-run-r${GITHUB_RUN_ID}-a${GITHUB_RUN_ATTEMPT}-${ARCH}" fi echo "cache_scope=${cache_scope}" >> "$GITHUB_OUTPUT" echo "discourse_source_sha=${tests_passed_sha}" >> "$GITHUB_OUTPUT" @@ -108,6 +159,7 @@ runs: BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }} BORINGCACHE_MANAGED_BUILDKIT_LOG_PATH: ${{ runner.temp }}/boringcache-managed-buildkit-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }}.log READ_ONLY: ${{ inputs.phase == 'warm' && 'true' || 'false' }} + CACHE_PROFILE: ${{ inputs.cache_profile }} RUST_LOG: warn run: | set -euo pipefail @@ -115,6 +167,9 @@ runs: if [[ "$READ_ONLY" == "true" ]]; then args+=(--read-only) fi + if [[ "$CACHE_PROFILE" == "bundler" ]]; then + args+=(--mount-cache) + fi boringcache docker "${args[@]}" - name: Show managed BuildKit errors @@ -147,13 +202,14 @@ runs: shell: bash env: BENCHMARK_ID: ${{ inputs.benchmark_id }} - STRATEGY: ${{ inputs.strategy }} + STRATEGY: ${{ inputs.report_strategy || inputs.strategy }} CACHE_LANE: ${{ inputs.cache_lane }} REPORT_PHASE: ${{ inputs.cache_lane == 'rolling' && 'commit' || inputs.phase == 'warm' && 'warm' || 'cold' }} WORKFLOW_STARTED_AT: ${{ steps.workflow_timer.outputs.started_at }} BUILD_SECONDS: ${{ steps.timing.outputs.build_seconds }} CACHE_SCOPE: ${{ steps.scope.outputs.cache_scope }} SOURCE_SHA: ${{ steps.scope.outputs.source_sha }} + DISCOURSE_SOURCE_SHA: ${{ steps.scope.outputs.discourse_source_sha }} ARCH: ${{ inputs.arch }} run: | python3 ./scripts/benchmark-report.py phase \ @@ -168,11 +224,13 @@ runs: --cache-tag "$CACHE_SCOPE" \ --workspace boringcache/benchmark-discourse \ --source-repository discourse/discourse_docker \ - --source-sha "$SOURCE_SHA" + --source-sha "$SOURCE_SHA" \ + --workload-repository discourse/discourse \ + --workload-sha "$DISCOURSE_SOURCE_SHA" - name: Retain benchmark evidence uses: actions/upload-artifact@v6 with: - name: phase-${{ inputs.benchmark_id }}-${{ inputs.strategy }}-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }} + name: phase-${{ inputs.benchmark_id }}-${{ inputs.report_strategy || inputs.strategy }}-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }} path: benchmark-results/ if-no-files-found: error diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index e282870..9b45a88 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -7,6 +7,9 @@ on: cli_version: {description: "Optional exact BoringCache CLI release or prerelease tag.", required: false, type: string, default: ""} buildkit_image: {description: "Optional managed BuildKit image canary.", required: false, type: string, default: ""} benchmark_id_suffix: {description: "Optional suffix for an isolated comparison.", required: false, type: string, default: ""} + bundler_cache_experiment: {description: "Run the isolated Bundler mount-cache lane on amd64.", required: false, type: boolean, default: false} + discourse_ref: {description: "Optional full Discourse commit SHA for a historical run.", required: false, type: string, default: ""} + docker_ref: {description: "Optional full discourse_docker commit SHA at or after the Mozilla-key fix.", required: false, type: string, default: ""} push: branches: [main] paths: [".boringcache.toml", ".gitmodules", "plans/**", "scripts/**", ".github/actions/discourse-image-factory/**", "upstream", "docker-upstream"] @@ -24,6 +27,7 @@ env: jobs: commit: name: ${{ matrix.provider }} ${{ matrix.arch }} commit + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.bundler_cache_experiment }} runs-on: ${{ matrix.runner }} timeout-minutes: 180 env: @@ -45,6 +49,36 @@ jobs: phase: publish arch: ${{ matrix.arch }} benchmark_id: ${{ format('discourse-image-factory{0}', inputs.benchmark_id_suffix) }} + discourse_ref: ${{ inputs.discourse_ref }} + docker_ref: ${{ inputs.docker_ref }} + cli_version: ${{ inputs.cli_version }} + buildkit_image: ${{ inputs.buildkit_image }} + + bundler: + name: ${{ matrix.provider }} amd64 commit + if: ${{ github.event_name == 'workflow_dispatch' && inputs.bundler_cache_experiment }} + runs-on: ubuntu-24.04 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - {provider: GitHub Actions control, strategy: actions-cache, report_strategy: actions-cache, cache_profile: ""} + - {provider: BoringCache control, strategy: boringcache, report_strategy: boringcache, cache_profile: ""} + - {provider: BoringCache + Bundler mount, strategy: boringcache, report_strategy: boringcache-bundler, cache_profile: bundler} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/discourse-image-factory + with: + strategy: ${{ matrix.strategy }} + report_strategy: ${{ matrix.report_strategy }} + cache_profile: ${{ matrix.cache_profile }} + cache_lane: rolling + phase: publish + arch: amd64 + benchmark_id: ${{ format('discourse-bundler-cache{0}', inputs.benchmark_id_suffix) }} + discourse_ref: ${{ inputs.discourse_ref }} + docker_ref: ${{ inputs.docker_ref }} cli_version: ${{ inputs.cli_version }} buildkit_image: ${{ inputs.buildkit_image }} @@ -55,7 +89,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 - with: {pattern: "phase-*", path: phase-evidence} + with: {pattern: "phase-discourse-image-factory${{ inputs.benchmark_id_suffix }}-*", path: phase-evidence} - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse upstream image-factory rolling benchmark" --input-dir phase-evidence --output-dir benchmark-results - name: Retain the BoringCache amd64 rolling result uses: actions/upload-artifact@v6 @@ -81,3 +115,33 @@ jobs: name: benchmark-discourse-image-factory${{ inputs.benchmark_id_suffix }}-actions-cache-arm64-rolling path: benchmark-results/discourse-image-factory${{ inputs.benchmark_id_suffix }}-actions-cache-arm64-rolling.json if-no-files-found: error + + bundler-report: + name: Discourse Bundler mount-cache experiment report + needs: bundler + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v6 + with: + pattern: phase-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-* + path: phase-evidence + - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse Bundler mount-cache benchmark" --input-dir phase-evidence --output-dir benchmark-results + - name: Retain the Bundler mount result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling + path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling.json + if-no-files-found: error + - name: Retain the BoringCache control result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling + path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling.json + if-no-files-found: error + - name: Retain the GitHub Actions control result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling + path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling.json + if-no-files-found: error diff --git a/scripts/benchmark-report.py b/scripts/benchmark-report.py index 0f2339f..fa02f0c 100755 --- a/scripts/benchmark-report.py +++ b/scripts/benchmark-report.py @@ -20,6 +20,7 @@ PROVIDER_LABELS = { "actions-cache": "GitHub Actions", "boringcache": "BoringCache", + "boringcache-bundler": "BoringCache + Bundler mount", "boringcache-mountcache": "BoringCache mountcache", "boringcache-native": "BoringCache native", "boringcache-toolcache": "BoringCache toolcache", @@ -71,6 +72,8 @@ def parse_args() -> argparse.Namespace: phase.add_argument("--workspace", default="") phase.add_argument("--source-repository", default="") phase.add_argument("--source-sha", default="") + phase.add_argument("--workload-repository", default="") + phase.add_argument("--workload-sha", default="") phase.add_argument("--evidence") phase.add_argument("--output-dir", default="benchmark-results") @@ -181,6 +184,8 @@ def write_phase(args: argparse.Namespace) -> int: "source": { "repository": args.source_repository or None, "sha": args.source_sha or None, + "workload_repository": args.workload_repository or None, + "workload_sha": args.workload_sha or None, }, "product_refs": evidence_product_refs(evidence), "action": evidence_action_versions(evidence), @@ -334,6 +339,9 @@ def render_markdown(title: str, lanes: dict[tuple[str, str, str, str], dict[str, if source and source.get("repository"): lines.append(f"Source: `{source['repository']}@{source['sha'][:7]}`") lines.append("") + if source.get("workload_repository") and source.get("workload_sha"): + lines.append(f"Workload: `{source['workload_repository']}@{source['workload_sha'][:7]}`") + lines.append("") return "\n".join(lines) @@ -362,7 +370,10 @@ def render_benchmark( for lane in lane_names: baseline = lanes.get((BASELINE_STRATEGY, "", lane)) candidate = lanes.get((CANDIDATE_STRATEGY, "", lane)) - reference = candidate or baseline + reference = candidate or baseline or next( + (value for (strategy, variant, item), value in lanes.items() if item == lane), + None, + ) if reference is None: continue diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py new file mode 100644 index 0000000..05c9dc0 --- /dev/null +++ b/scripts/prepare_discourse_cache_profile.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Apply one benchmark-only cache profile to Discourse's pinned Dockerfile.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +DOCKERFILE = ROOT / "docker-upstream/image/base/Dockerfile" +PROFILES = ("baseline", "bundler") + + +class ProfileMismatch(RuntimeError): + pass + + +def replace_once(source: str, before: str, after: str, description: str) -> str: + matches = source.count(before) + if matches != 1: + raise ProfileMismatch(f"expected one {description}, found {matches}") + return source.replace(before, after, 1) + + +def add_bundler_cache(source: str) -> str: + return replace_once( + source, + "RUN cd /var/www/discourse &&\\\n" + " sudo -u discourse bundle config --local deployment true &&\\\n" + " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" + " sudo -u discourse bundle config --local without test development &&\\\n" + " sudo -u discourse bundle install --jobs $(nproc --ignore=1) &&\\\n", + "RUN --mount=type=cache,id=discourse-bundler,target=/home/discourse/.bundle/cache,uid=1000,gid=1000 \\\n" + " cd /var/www/discourse &&\\\n" + " sudo -u discourse bundle config --local deployment true &&\\\n" + " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" + " sudo -u discourse bundle config --local without test development &&\\\n" + " sudo -u discourse env BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install --jobs $(nproc --ignore=1) &&\\\n" + " sudo -u discourse du -sh /home/discourse/.bundle/cache &&\\\n", + "Bundler install command", + ) + + +def render(source: str, profile: str) -> str: + if profile == "baseline": + return source + if profile == "bundler": + return add_bundler_cache(source) + raise ProfileMismatch(f"unknown cache profile: {profile}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("profile", choices=PROFILES) + parser.add_argument("--dockerfile", type=Path, default=DOCKERFILE) + parser.add_argument("--check", action="store_true") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + source = args.dockerfile.read_text() + try: + rendered = render(source, args.profile) + except ProfileMismatch as error: + raise SystemExit(f"Discourse cache profile mismatch: {error}") from error + if not args.check: + args.dockerfile.write_text(rendered) + print(f"Prepared Discourse cache profile: {args.profile}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 0f09e51..709d998 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -7,9 +7,12 @@ import tomllib from pathlib import Path +from prepare_discourse_cache_profile import ProfileMismatch, render + ROOT = Path(__file__).resolve().parents[1] UPSTREAM_WORKFLOW = ROOT / "docker-upstream/.github/workflows/build.yml" +UPSTREAM_DOCKERFILE = ROOT / "docker-upstream/image/base/Dockerfile" PLANS = { "fresh-amd64": ("fresh", "amd64"), "fresh-arm64": ("fresh", "arm64"), @@ -79,6 +82,20 @@ def main() -> int: rolling = (ROOT / ".github/workflows/discourse-image-factory.yml").read_text() fresh = (ROOT / ".github/workflows/discourse-image-factory-fresh.yml").read_text() require("discourse-dev.Dockerfile" not in action + rolling + fresh, "custom Dockerfile returned") + dockerfile = UPSTREAM_DOCKERFILE.read_text() + require(render(dockerfile, "baseline") == dockerfile, "baseline cache profile must leave upstream unchanged") + bundler_profile = render(dockerfile, "bundler") + require( + "--mount=type=cache,id=discourse-bundler,target=/home/discourse/.bundle/cache,uid=1000,gid=1000" + in bundler_profile, + "Bundler cache profile does not mount Bundler's user cache", + ) + require( + "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + in bundler_profile, + "Bundler cache profile does not enable the global gem cache", + ) + require("discourse-ccache" not in bundler_profile, "paused ccache experiment returned") require( 'PLAN: ${{ format(\'{0}-{1}\', inputs.cache_lane, inputs.arch) }}' in action, "composite action does not select the committed lane and architecture plan", @@ -97,18 +114,35 @@ def main() -> int: ) require("working-directory: docker-upstream/image" in action, "CLI must run the upstream Bake plan") require('args+=(--read-only)' in action, "warm builds must restore without publishing") + require('args+=(--mount-cache)' in action, "Bundler experiment must enable mount-cache offload") + require('[[ "$CACHE_PROFILE" == "bundler" ]]' in action, "mount-cache offload must stay scoped to Bundler") + require("--tool-cache ccache" not in action + rolling, "unsupported Docker ccache composition returned") require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") require("publish_images" not in action + rolling + fresh, "benchmark image publication returned") require("git -C upstream rev-parse HEAD" in action, "rolling cache does not use the pinned Discourse source") require("git ls-remote" not in action, "benchmark execution must not race a moving upstream branch") require( - 'cache_scope="${BENCHMARK_ID}-rolling-${ref_slug}-${ARCH}"' in action, + "a68d4b8707fd653697e8b6b27b336d093dbed5e4" in action, + "historical runs must enforce the Mozilla signing-key boundary", + ) + require( + "must be a full immutable commit SHA" in action, + "historical source overrides must reject moving refs", + ) + require( + 'cache_scope="${BENCHMARK_ID}${profile_slug}-rolling-${ref_slug}-${ARCH}"' in action, "rolling cache scope must stay stable across upstream commits", ) require( 'cache_scope="${BENCHMARK_ID}-rolling-${ref_slug}-${ARCH}-${tests_passed_sha}"' not in action, "rolling cache scope must not turn every upstream commit into a cold cohort", ) + require( + action.index("Prepare clean Discourse sources") < action.index("Prepare the benchmark cache profile"), + "source cleanup would erase the benchmark cache profile", + ) + require("bundler_cache_experiment" in rolling, "Bundler workflow-dispatch lane is missing") + require("cache_profile: bundler" in rolling, "Bundler lane does not select the Bundler profile") require( "origin/tests-passed" in (ROOT / "upstream/script/docker_test.rb").read_text(), "upstream image specs no longer select the tests-passed branch", @@ -136,7 +170,7 @@ def main() -> int: require('destination = UPSTREAM_IMAGE / ".boringcache.toml"' in selector, "selected plan must reach the Action working directory") require("destination.write_text(updated)" in selector, "selected plan must be materialized before the Action runs") require('"boringcache"' not in selector, "plan selection must not invoke the BoringCache product") - except (KeyError, OSError, RecipeMismatch, tomllib.TOMLDecodeError) as error: + except (KeyError, OSError, ProfileMismatch, RecipeMismatch, tomllib.TOMLDecodeError) as error: print(f"Discourse recipe mismatch: {error}", file=sys.stderr) return 1 From 04081fc504a446fb1952c333ebeb30d877ff7379 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 10:48:47 +0100 Subject: [PATCH 02/17] Bound Discourse cache cohort names --- .github/actions/discourse-image-factory/action.yml | 5 +++++ scripts/verify-upstream-recipe.py | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 89c9214..e5bd8e3 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -126,6 +126,11 @@ runs: else cache_scope="${BENCHMARK_ID}${profile_slug}-run-r${GITHUB_RUN_ID}-a${GITHUB_RUN_ATTEMPT}-${ARCH}" fi + # Managed BuildKit appends target names; leave room under OCI's 128-character tag limit. + if (( ${#cache_scope} > 80 )); then + scope_hash="$(printf '%s' "$cache_scope" | sha256sum | cut -c1-8)" + cache_scope="${cache_scope:0:71}-${scope_hash}" + fi echo "cache_scope=${cache_scope}" >> "$GITHUB_OUTPUT" echo "discourse_source_sha=${tests_passed_sha}" >> "$GITHUB_OUTPUT" echo "source_sha=$(git -C docker-upstream rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 709d998..78bfdaa 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -133,6 +133,7 @@ def main() -> int: 'cache_scope="${BENCHMARK_ID}${profile_slug}-rolling-${ref_slug}-${ARCH}"' in action, "rolling cache scope must stay stable across upstream commits", ) + require("${#cache_scope} > 80" in action, "long cache scopes must leave room for Bake target names") require( 'cache_scope="${BENCHMARK_ID}-rolling-${ref_slug}-${ARCH}-${tests_passed_sha}"' not in action, "rolling cache scope must not turn every upstream commit into a cold cohort", From d094ecb98135c738b5231859174a806df0fe279b Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 11:00:30 +0100 Subject: [PATCH 03/17] Isolate concurrent Bundler cache writers --- scripts/prepare_discourse_cache_profile.py | 2 +- scripts/verify-upstream-recipe.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index 05c9dc0..7839b8f 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -31,7 +31,7 @@ def add_bundler_cache(source: str) -> str: " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" " sudo -u discourse bundle config --local without test development &&\\\n" " sudo -u discourse bundle install --jobs $(nproc --ignore=1) &&\\\n", - "RUN --mount=type=cache,id=discourse-bundler,target=/home/discourse/.bundle/cache,uid=1000,gid=1000 \\\n" + "RUN --mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH},target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000 \\\n" " cd /var/www/discourse &&\\\n" " sudo -u discourse bundle config --local deployment true &&\\\n" " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 78bfdaa..aac2518 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -86,7 +86,7 @@ def main() -> int: require(render(dockerfile, "baseline") == dockerfile, "baseline cache profile must leave upstream unchanged") bundler_profile = render(dockerfile, "bundler") require( - "--mount=type=cache,id=discourse-bundler,target=/home/discourse/.bundle/cache,uid=1000,gid=1000" + "--mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH},target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000" in bundler_profile, "Bundler cache profile does not mount Bundler's user cache", ) From a079099678ef25cb28077ae16c0034de28439bd8 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 13:35:20 +0100 Subject: [PATCH 04/17] Pin Discourse benchmark execution --- .../discourse-image-factory/action.yml | 19 +++++++++++++++---- scripts/verify-upstream-recipe.py | 14 +++++++++++++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index e5bd8e3..c6fae5f 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -151,6 +151,8 @@ runs: - name: Build with the GitHub Actions cache if: inputs.strategy == 'actions-cache' shell: bash + env: + ARCH: ${{ inputs.arch }} run: >- python3 ./scripts/run-actions-cache-plan.py "${{ inputs.cache_lane }}-${{ inputs.arch }}" @@ -161,6 +163,7 @@ runs: shell: bash working-directory: docker-upstream/image env: + ARCH: ${{ inputs.arch }} BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }} BORINGCACHE_MANAGED_BUILDKIT_LOG_PATH: ${{ runner.temp }}/boringcache-managed-buildkit-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }}.log READ_ONLY: ${{ inputs.phase == 'warm' && 'true' || 'false' }} @@ -191,10 +194,18 @@ runs: - name: Run upstream's image specs shell: bash - run: >- - docker run --rm -e RUBY_ONLY=1 -e USE_TURBO=1 -e SKIP_PLUGINS=1 - -e SKIP_LINT=1 -e DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS=1 - "${TEST_IMAGE}:release-${{ inputs.arch }}" + env: + DISCOURSE_SOURCE_SHA: ${{ steps.scope.outputs.discourse_source_sha }} + run: | + timeout --foreground --signal=TERM --kill-after=1m 45m \ + docker run --rm \ + -e RUBY_ONLY=1 \ + -e USE_TURBO=1 \ + -e SKIP_PLUGINS=1 \ + -e SKIP_LINT=1 \ + -e DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS=1 \ + -e COMMIT_HASH="$DISCOURSE_SOURCE_SHA" \ + "${TEST_IMAGE}:release-${{ inputs.arch }}" - name: Capture image-factory timing id: timing diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index aac2518..de62852 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -100,11 +100,23 @@ def main() -> int: 'PLAN: ${{ format(\'{0}-{1}\', inputs.cache_lane, inputs.arch) }}' in action, "composite action does not select the committed lane and architecture plan", ) + require( + action.count('ARCH: ${{ inputs.arch }}') == 4, + "composite action must pin architecture-sensitive scope, build, and report steps", + ) require( 'select-boringcache-plan.py "$PLAN" --cache-tag "$CACHE_SCOPE"' in action, "composite action does not materialize the selected cache cohort", ) - require("docker run --rm -e RUBY_ONLY=1" in action, "upstream test invocation is missing") + require("docker run --rm" in action, "upstream test invocation is missing") + require( + "timeout --foreground --signal=TERM --kill-after=1m 45m" in action, + "upstream image specs must not hang a benchmark lane indefinitely", + ) + require( + '-e COMMIT_HASH="$DISCOURSE_SOURCE_SHA"' in action, + "upstream image specs must test the reported Discourse source", + ) require(action.count("boringcache docker") == 1, "BoringCache path must use one CLI-owned Docker lifecycle") require("https://install.boringcache.com/install.sh" in action, "BoringCache path must use the public installer") require("CLI_VERSION: ${{ inputs.cli_version }}" in action, "CLI canary input is not forwarded") From 89f99066524cc28861aa8b95f54a8564bf1b76b0 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 17:47:01 +0100 Subject: [PATCH 05/17] Benchmark Discourse ccache tool cache --- .boringcache.toml | 7 +++ .../discourse-image-factory/action.yml | 30 ++++++--- .github/workflows/discourse-image-factory.yml | 62 ++++++++++++++++++- plans/fresh-amd64/.boringcache.toml | 7 +++ plans/fresh-arm64/.boringcache.toml | 7 +++ plans/rolling-amd64/.boringcache.toml | 7 +++ plans/rolling-arm64/.boringcache.toml | 7 +++ scripts/benchmark-report.py | 1 + scripts/prepare_discourse_cache_profile.py | 25 +++++++- scripts/select-boringcache-plan.py | 51 ++++++++++----- scripts/verify-upstream-recipe.py | 40 ++++++++++-- 11 files changed, 214 insertions(+), 30 deletions(-) diff --git a/.boringcache.toml b/.boringcache.toml index 5e82704..64b90ed 100644 --- a/.boringcache.toml +++ b/.boringcache.toml @@ -16,3 +16,10 @@ command = [ "--set=base-runtime-deps-*.no-cache=true", "--load", ] + +[adapters.ccache] +tag = "discourse-ccache-rolling-amd64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=amd64", "tool=ccache"] diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index c6fae5f..26ca41b 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -104,6 +104,7 @@ runs: bash "$installer" fi echo "${HOME}/.local/bin" >> "$GITHUB_PATH" + "${HOME}/.local/bin/boringcache" --version - name: Select the cache cohort id: scope @@ -146,7 +147,10 @@ runs: env: PLAN: ${{ format('{0}-{1}', inputs.cache_lane, inputs.arch) }} CACHE_SCOPE: ${{ steps.scope.outputs.cache_scope }} - run: python3 ./scripts/select-boringcache-plan.py "$PLAN" --cache-tag "$CACHE_SCOPE" + run: >- + python3 ./scripts/select-boringcache-plan.py "$PLAN" + --cache-tag "$CACHE_SCOPE" + --ccache-tag "${CACHE_SCOPE}-compiler" - name: Build with the GitHub Actions cache if: inputs.strategy == 'actions-cache' @@ -178,8 +182,18 @@ runs: if [[ "$CACHE_PROFILE" == "bundler" ]]; then args+=(--mount-cache) fi + if [[ "$CACHE_PROFILE" == "ccache" ]]; then + args+=(--tool-cache ccache) + fi boringcache docker "${args[@]}" + - name: Capture image-factory timing + id: timing + shell: bash + env: + BUILD_STARTED_AT: ${{ steps.build_timer.outputs.started_at }} + run: echo "build_seconds=$(( $(date +%s) - BUILD_STARTED_AT ))" >> "$GITHUB_OUTPUT" + - name: Show managed BuildKit errors if: ${{ inputs.strategy == 'boringcache' && failure() }} shell: bash @@ -193,6 +207,8 @@ runs: fi - name: Run upstream's image specs + id: image_specs + continue-on-error: true shell: bash env: DISCOURSE_SOURCE_SHA: ${{ steps.scope.outputs.discourse_source_sha }} @@ -207,13 +223,6 @@ runs: -e COMMIT_HASH="$DISCOURSE_SOURCE_SHA" \ "${TEST_IMAGE}:release-${{ inputs.arch }}" - - name: Capture image-factory timing - id: timing - shell: bash - env: - BUILD_STARTED_AT: ${{ steps.build_timer.outputs.started_at }} - run: echo "build_seconds=$(( $(date +%s) - BUILD_STARTED_AT ))" >> "$GITHUB_OUTPUT" - - name: Write benchmark evidence shell: bash env: @@ -250,3 +259,8 @@ runs: name: phase-${{ inputs.benchmark_id }}-${{ inputs.report_strategy || inputs.strategy }}-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }} path: benchmark-results/ if-no-files-found: error + + - name: Enforce the upstream image specs + if: steps.image_specs.outcome != 'success' + shell: bash + run: exit 1 diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index 9b45a88..2f26485 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -8,6 +8,7 @@ on: buildkit_image: {description: "Optional managed BuildKit image canary.", required: false, type: string, default: ""} benchmark_id_suffix: {description: "Optional suffix for an isolated comparison.", required: false, type: string, default: ""} bundler_cache_experiment: {description: "Run the isolated Bundler mount-cache lane on amd64.", required: false, type: boolean, default: false} + ccache_experiment: {description: "Run the isolated Docker ccache tool-cache lane on amd64.", required: false, type: boolean, default: false} discourse_ref: {description: "Optional full Discourse commit SHA for a historical run.", required: false, type: string, default: ""} docker_ref: {description: "Optional full discourse_docker commit SHA at or after the Mozilla-key fix.", required: false, type: string, default: ""} push: @@ -27,7 +28,7 @@ env: jobs: commit: name: ${{ matrix.provider }} ${{ matrix.arch }} commit - if: ${{ github.event_name != 'workflow_dispatch' || !inputs.bundler_cache_experiment }} + if: ${{ github.event_name != 'workflow_dispatch' || (!inputs.bundler_cache_experiment && !inputs.ccache_experiment) }} runs-on: ${{ matrix.runner }} timeout-minutes: 180 env: @@ -82,6 +83,34 @@ jobs: cli_version: ${{ inputs.cli_version }} buildkit_image: ${{ inputs.buildkit_image }} + ccache: + name: ${{ matrix.provider }} amd64 commit + if: ${{ github.event_name == 'workflow_dispatch' && inputs.ccache_experiment }} + runs-on: ubuntu-24.04 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + include: + - {provider: GitHub Actions control, strategy: actions-cache, report_strategy: actions-cache, cache_profile: ""} + - {provider: BoringCache control, strategy: boringcache, report_strategy: boringcache, cache_profile: ""} + - {provider: BoringCache + ccache, strategy: boringcache, report_strategy: boringcache-ccache, cache_profile: ccache} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/discourse-image-factory + with: + strategy: ${{ matrix.strategy }} + report_strategy: ${{ matrix.report_strategy }} + cache_profile: ${{ matrix.cache_profile }} + cache_lane: rolling + phase: publish + arch: amd64 + benchmark_id: ${{ format('discourse-ccache{0}', inputs.benchmark_id_suffix) }} + discourse_ref: ${{ inputs.discourse_ref }} + docker_ref: ${{ inputs.docker_ref }} + cli_version: ${{ inputs.cli_version }} + buildkit_image: ${{ inputs.buildkit_image }} + report: name: Discourse image-factory rolling report needs: commit @@ -145,3 +174,34 @@ jobs: name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling.json if-no-files-found: error + + ccache-report: + name: Discourse ccache tool-cache experiment report + needs: ccache + if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.ccache_experiment }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v6 + with: + pattern: phase-discourse-ccache${{ inputs.benchmark_id_suffix }}-* + path: phase-evidence + - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse ccache tool-cache benchmark" --input-dir phase-evidence --output-dir benchmark-results + - name: Retain the ccache result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-ccache${{ inputs.benchmark_id_suffix }}-boringcache-ccache-amd64-rolling + path: benchmark-results/discourse-ccache${{ inputs.benchmark_id_suffix }}-boringcache-ccache-amd64-rolling.json + if-no-files-found: error + - name: Retain the BoringCache control result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-ccache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling + path: benchmark-results/discourse-ccache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling.json + if-no-files-found: error + - name: Retain the GitHub Actions control result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-ccache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling + path: benchmark-results/discourse-ccache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling.json + if-no-files-found: error diff --git a/plans/fresh-amd64/.boringcache.toml b/plans/fresh-amd64/.boringcache.toml index 12b6d02..dcd9d3a 100644 --- a/plans/fresh-amd64/.boringcache.toml +++ b/plans/fresh-amd64/.boringcache.toml @@ -16,3 +16,10 @@ command = [ "--set=base-runtime-deps-*.no-cache=true", "--load", ] + +[adapters.ccache] +tag = "discourse-ccache-fresh-amd64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=fresh", "arch=amd64", "tool=ccache"] diff --git a/plans/fresh-arm64/.boringcache.toml b/plans/fresh-arm64/.boringcache.toml index 216fad8..ffff98f 100644 --- a/plans/fresh-arm64/.boringcache.toml +++ b/plans/fresh-arm64/.boringcache.toml @@ -16,3 +16,10 @@ command = [ "--set=base-runtime-deps-*.no-cache=true", "--load", ] + +[adapters.ccache] +tag = "discourse-ccache-fresh-arm64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=fresh", "arch=arm64", "tool=ccache"] diff --git a/plans/rolling-amd64/.boringcache.toml b/plans/rolling-amd64/.boringcache.toml index 5e82704..64b90ed 100644 --- a/plans/rolling-amd64/.boringcache.toml +++ b/plans/rolling-amd64/.boringcache.toml @@ -16,3 +16,10 @@ command = [ "--set=base-runtime-deps-*.no-cache=true", "--load", ] + +[adapters.ccache] +tag = "discourse-ccache-rolling-amd64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=amd64", "tool=ccache"] diff --git a/plans/rolling-arm64/.boringcache.toml b/plans/rolling-arm64/.boringcache.toml index b11518f..af239ab 100644 --- a/plans/rolling-arm64/.boringcache.toml +++ b/plans/rolling-arm64/.boringcache.toml @@ -16,3 +16,10 @@ command = [ "--set=base-runtime-deps-*.no-cache=true", "--load", ] + +[adapters.ccache] +tag = "discourse-ccache-rolling-arm64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=arm64", "tool=ccache"] diff --git a/scripts/benchmark-report.py b/scripts/benchmark-report.py index fa02f0c..6f3e3fe 100755 --- a/scripts/benchmark-report.py +++ b/scripts/benchmark-report.py @@ -21,6 +21,7 @@ "actions-cache": "GitHub Actions", "boringcache": "BoringCache", "boringcache-bundler": "BoringCache + Bundler mount", + "boringcache-ccache": "BoringCache + ccache", "boringcache-mountcache": "BoringCache mountcache", "boringcache-native": "BoringCache native", "boringcache-toolcache": "BoringCache toolcache", diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index 7839b8f..d74dfe3 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] DOCKERFILE = ROOT / "docker-upstream/image/base/Dockerfile" -PROFILES = ("baseline", "bundler") +PROFILES = ("baseline", "bundler", "ccache") class ProfileMismatch(RuntimeError): @@ -42,11 +42,34 @@ def add_bundler_cache(source: str) -> str: ) +def add_ccache(source: str) -> str: + source = replace_once( + source, + " git \\\n" + " cmake \\\n", + " git \\\n" + " ccache \\\n" + " cmake \\\n", + "builder package list", + ) + return replace_once( + source, + " libbrotli-dev\n\n" + "FROM builder AS libheif-builder\n", + " libbrotli-dev\n\n" + 'ENV PATH="/usr/lib/ccache:${PATH}"\n\n' + "FROM builder AS libheif-builder\n", + "builder stage boundary", + ) + + def render(source: str, profile: str) -> str: if profile == "baseline": return source if profile == "bundler": return add_bundler_cache(source) + if profile == "ccache": + return add_ccache(source) raise ProfileMismatch(f"unknown cache profile: {profile}") diff --git a/scripts/select-boringcache-plan.py b/scripts/select-boringcache-plan.py index c8a8029..8c80f4a 100644 --- a/scripts/select-boringcache-plan.py +++ b/scripts/select-boringcache-plan.py @@ -18,13 +18,38 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("plan") parser.add_argument("--cache-tag", required=True) + parser.add_argument("--ccache-tag", required=True) return parser.parse_args() +def replace_adapter_tag(text: str, adapter: str, tag: str, source: Path) -> str: + lines = text.splitlines(keepends=True) + section = f"[adapters.{adapter}]" + in_section = False + replacements = 0 + + for index, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + in_section = stripped == section + continue + if in_section and stripped.startswith("tag = "): + ending = "\n" if line.endswith("\n") else "" + lines[index] = f'tag = "{tag}"{ending}' + replacements += 1 + + if replacements != 1: + raise SystemExit(f"Plan must declare exactly one {adapter} tag: {source}") + return "".join(lines) + + def main() -> int: args = parse_args() - if CACHE_TAG.fullmatch(args.cache_tag) is None: - raise SystemExit(f"Invalid cache tag: {args.cache_tag}") + for label, tag in (("cache", args.cache_tag), ("ccache", args.ccache_tag)): + if CACHE_TAG.fullmatch(tag) is None: + raise SystemExit(f"Invalid {label} tag: {tag}") + if args.cache_tag == args.ccache_tag: + raise SystemExit("Docker and ccache tags must be distinct") plan_dir = (ROOT / "plans" / args.plan).resolve() if plan_dir.parent != (ROOT / "plans").resolve(): @@ -32,15 +57,8 @@ def main() -> int: source = plan_dir / ".boringcache.toml" text = source.read_text() - updated, replacements = re.subn( - r'^tag = "[^"]+"$', - f'tag = "{args.cache_tag}"', - text, - count=1, - flags=re.MULTILINE, - ) - if replacements != 1: - raise SystemExit(f"Plan must declare exactly one Docker tag: {source}") + updated = replace_adapter_tag(text, "docker", args.cache_tag, source) + updated = replace_adapter_tag(updated, "ccache", args.ccache_tag, source) destination = UPSTREAM_IMAGE / ".boringcache.toml" if destination.exists(): @@ -48,9 +66,14 @@ def main() -> int: destination.write_text(updated) with destination.open("rb") as config_file: - materialized_tag = tomllib.load(config_file)["adapters"]["docker"]["tag"] - if materialized_tag != args.cache_tag: - raise SystemExit(f"Materialized the wrong cache tag: {materialized_tag}") + adapters = tomllib.load(config_file)["adapters"] + materialized_tags = { + "docker": adapters["docker"]["tag"], + "ccache": adapters["ccache"]["tag"], + } + expected_tags = {"docker": args.cache_tag, "ccache": args.ccache_tag} + if materialized_tags != expected_tags: + raise SystemExit(f"Materialized the wrong cache tags: {materialized_tags}") return 0 diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index de62852..a154cd5 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -61,14 +61,21 @@ def main() -> int: for name, (lane, arch) in PLANS.items(): path = ROOT / "plans" / name / ".boringcache.toml" with path.open("rb") as config_file: - adapter = tomllib.load(config_file)["adapters"]["docker"] + adapters = tomllib.load(config_file)["adapters"] + adapter = adapters["docker"] + ccache = adapters["ccache"] require(adapter["command"] == expected_command(arch), f"{name} command drifted") require(adapter["tag"] == f"discourse-image-factory-{lane}-{arch}", f"{name} tag drifted") require(adapter["no-platform"] is True, f"{name} must keep one explicit cache cohort") require(adapter["no-git"] is True, f"{name} must use its declared lane and architecture") + require(ccache["tag"] == f"discourse-ccache-{lane}-{arch}", f"{name} ccache tag drifted") + require(ccache["no-platform"] is True, f"{name} ccache must use the Docker platform cohort") + require(ccache["no-git"] is True, f"{name} ccache must use its declared lane") + require(ccache["fail-on-cache-error"] is True, f"{name} ccache must fail closed") with (ROOT / ".boringcache.toml").open("rb") as config_file: - root_adapter = tomllib.load(config_file)["adapters"]["docker"] + root_adapters = tomllib.load(config_file)["adapters"] + root_adapter = root_adapters["docker"] require( root_adapter["command"] == expected_command("amd64"), "root default plan drifted", @@ -77,6 +84,10 @@ def main() -> int: root_adapter["tag"] == "discourse-image-factory-rolling-amd64", "root default tag drifted", ) + require( + root_adapters["ccache"]["tag"] == "discourse-ccache-rolling-amd64", + "root ccache tag drifted", + ) action = (ROOT / ".github/actions/discourse-image-factory/action.yml").read_text() rolling = (ROOT / ".github/workflows/discourse-image-factory.yml").read_text() @@ -95,7 +106,14 @@ def main() -> int: in bundler_profile, "Bundler cache profile does not enable the global gem cache", ) - require("discourse-ccache" not in bundler_profile, "paused ccache experiment returned") + ccache_profile = render(dockerfile, "ccache") + require(" ccache \\\n" in ccache_profile, "ccache profile does not install ccache") + require( + 'ENV PATH="/usr/lib/ccache:${PATH}"' in ccache_profile, + "ccache profile does not select Debian's compiler wrappers", + ) + require("CCACHE_REMOTE_STORAGE" not in ccache_profile, "the Dockerfile must not own BoringCache's ccache endpoint") + require("BUNDLE_GLOBAL_GEM_CACHE" not in ccache_profile, "ccache profile must stay isolated from Bundler") require( 'PLAN: ${{ format(\'{0}-{1}\', inputs.cache_lane, inputs.arch) }}' in action, "composite action does not select the committed lane and architecture plan", @@ -105,8 +123,8 @@ def main() -> int: "composite action must pin architecture-sensitive scope, build, and report steps", ) require( - 'select-boringcache-plan.py "$PLAN" --cache-tag "$CACHE_SCOPE"' in action, - "composite action does not materialize the selected cache cohort", + '--ccache-tag "${CACHE_SCOPE}-compiler"' in action, + "composite action does not materialize an isolated ccache cohort", ) require("docker run --rm" in action, "upstream test invocation is missing") require( @@ -128,7 +146,13 @@ def main() -> int: require('args+=(--read-only)' in action, "warm builds must restore without publishing") require('args+=(--mount-cache)' in action, "Bundler experiment must enable mount-cache offload") require('[[ "$CACHE_PROFILE" == "bundler" ]]' in action, "mount-cache offload must stay scoped to Bundler") - require("--tool-cache ccache" not in action + rolling, "unsupported Docker ccache composition returned") + require('args+=(--tool-cache ccache)' in action, "ccache experiment must use the released Docker tool-cache surface") + require('[[ "$CACHE_PROFILE" == "ccache" ]]' in action, "Docker ccache must stay scoped to its experiment") + require( + action.index("Capture image-factory timing") < action.index("Run upstream's image specs"), + "image-factory timing must not include the upstream specs", + ) + require("continue-on-error: true" in action, "benchmark evidence must survive an upstream spec timeout") require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") require("publish_images" not in action + rolling + fresh, "benchmark image publication returned") require("git -C upstream rev-parse HEAD" in action, "rolling cache does not use the pinned Discourse source") @@ -156,6 +180,9 @@ def main() -> int: ) require("bundler_cache_experiment" in rolling, "Bundler workflow-dispatch lane is missing") require("cache_profile: bundler" in rolling, "Bundler lane does not select the Bundler profile") + require("ccache_experiment" in rolling, "ccache workflow-dispatch lane is missing") + require("cache_profile: ccache" in rolling, "ccache lane does not select the ccache profile") + require("report_strategy: boringcache-ccache" in rolling, "ccache lane does not retain its own result") require( "origin/tests-passed" in (ROOT / "upstream/script/docker_test.rb").read_text(), "upstream image specs no longer select the tests-passed branch", @@ -182,6 +209,7 @@ def main() -> int: selector = (ROOT / "scripts/select-boringcache-plan.py").read_text() require('destination = UPSTREAM_IMAGE / ".boringcache.toml"' in selector, "selected plan must reach the Action working directory") require("destination.write_text(updated)" in selector, "selected plan must be materialized before the Action runs") + require('replace_adapter_tag(updated, "ccache", args.ccache_tag, source)' in selector, "selected plan must isolate ccache tags") require('"boringcache"' not in selector, "plan selection must not invoke the BoringCache product") except (KeyError, OSError, ProfileMismatch, RecipeMismatch, tomllib.TOMLDecodeError) as error: print(f"Discourse recipe mismatch: {error}", file=sys.stderr) From ba89b8d938c051c7c43215fe799feb4a5e03e655 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 17:51:40 +0100 Subject: [PATCH 06/17] Verify the installed CLI on PATH --- .github/actions/discourse-image-factory/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 26ca41b..154b19c 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -104,7 +104,7 @@ runs: bash "$installer" fi echo "${HOME}/.local/bin" >> "$GITHUB_PATH" - "${HOME}/.local/bin/boringcache" --version + boringcache --version - name: Select the cache cohort id: scope From 894d2c679307820cb67c1609171040228bb6cc86 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 17:59:44 +0100 Subject: [PATCH 07/17] Focus the Discourse ccache benchmark --- .../discourse-image-factory/action.yml | 34 ++++++++++++++----- .github/workflows/discourse-image-factory.yml | 8 +++-- .../.boringcache.toml | 21 ++++++++++++ scripts/verify-upstream-recipe.py | 32 +++++++++++++++++ 4 files changed, 83 insertions(+), 12 deletions(-) create mode 100644 plans/rolling-amd64-runtime-deps/.boringcache.toml diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 154b19c..71655d3 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -7,6 +7,8 @@ inputs: arch: {required: true} benchmark_id: {required: true} cache_profile: {required: false, default: ""} + plan_variant: {required: false, default: ""} + run_specs: {required: false, default: "true"} report_strategy: {required: false, default: ""} discourse_ref: {required: false, default: ""} docker_ref: {required: false, default: ""} @@ -146,21 +148,29 @@ runs: shell: bash env: PLAN: ${{ format('{0}-{1}', inputs.cache_lane, inputs.arch) }} + PLAN_VARIANT: ${{ inputs.plan_variant }} CACHE_SCOPE: ${{ steps.scope.outputs.cache_scope }} - run: >- - python3 ./scripts/select-boringcache-plan.py "$PLAN" - --cache-tag "$CACHE_SCOPE" - --ccache-tag "${CACHE_SCOPE}-compiler" + run: | + if [[ -n "$PLAN_VARIANT" ]]; then + PLAN="${PLAN}-${PLAN_VARIANT}" + fi + python3 ./scripts/select-boringcache-plan.py "$PLAN" \ + --cache-tag "$CACHE_SCOPE" \ + --ccache-tag "${CACHE_SCOPE}-compiler" - name: Build with the GitHub Actions cache if: inputs.strategy == 'actions-cache' shell: bash env: ARCH: ${{ inputs.arch }} - run: >- - python3 ./scripts/run-actions-cache-plan.py - "${{ inputs.cache_lane }}-${{ inputs.arch }}" - --cache-scope "${{ steps.scope.outputs.cache_scope }}" + PLAN: ${{ format('{0}-{1}', inputs.cache_lane, inputs.arch) }} + PLAN_VARIANT: ${{ inputs.plan_variant }} + run: | + if [[ -n "$PLAN_VARIANT" ]]; then + PLAN="${PLAN}-${PLAN_VARIANT}" + fi + python3 ./scripts/run-actions-cache-plan.py "$PLAN" \ + --cache-scope "${{ steps.scope.outputs.cache_scope }}" - name: Build with BoringCache if: inputs.strategy == 'boringcache' @@ -194,6 +204,11 @@ runs: BUILD_STARTED_AT: ${{ steps.build_timer.outputs.started_at }} run: echo "build_seconds=$(( $(date +%s) - BUILD_STARTED_AT ))" >> "$GITHUB_OUTPUT" + - name: Smoke test the runtime-deps image + if: inputs.plan_variant == 'runtime-deps' + shell: bash + run: docker run --rm "${BASE_IMAGE}:runtime-deps-${{ inputs.arch }}" /bin/true + - name: Show managed BuildKit errors if: ${{ inputs.strategy == 'boringcache' && failure() }} shell: bash @@ -207,6 +222,7 @@ runs: fi - name: Run upstream's image specs + if: inputs.run_specs == 'true' id: image_specs continue-on-error: true shell: bash @@ -261,6 +277,6 @@ runs: if-no-files-found: error - name: Enforce the upstream image specs - if: steps.image_specs.outcome != 'success' + if: inputs.run_specs == 'true' && steps.image_specs.outcome != 'success' shell: bash run: exit 1 diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index 2f26485..320c41a 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -84,7 +84,7 @@ jobs: buildkit_image: ${{ inputs.buildkit_image }} ccache: - name: ${{ matrix.provider }} amd64 commit + name: ${{ matrix.provider }} amd64 runtime-deps commit if: ${{ github.event_name == 'workflow_dispatch' && inputs.ccache_experiment }} runs-on: ubuntu-24.04 timeout-minutes: 180 @@ -102,6 +102,8 @@ jobs: strategy: ${{ matrix.strategy }} report_strategy: ${{ matrix.report_strategy }} cache_profile: ${{ matrix.cache_profile }} + plan_variant: runtime-deps + run_specs: "false" cache_lane: rolling phase: publish arch: amd64 @@ -176,7 +178,7 @@ jobs: if-no-files-found: error ccache-report: - name: Discourse ccache tool-cache experiment report + name: Discourse runtime-deps ccache experiment report needs: ccache if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.ccache_experiment }} runs-on: ubuntu-latest @@ -186,7 +188,7 @@ jobs: with: pattern: phase-discourse-ccache${{ inputs.benchmark_id_suffix }}-* path: phase-evidence - - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse ccache tool-cache benchmark" --input-dir phase-evidence --output-dir benchmark-results + - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse runtime-deps ccache benchmark" --input-dir phase-evidence --output-dir benchmark-results - name: Retain the ccache result uses: actions/upload-artifact@v6 with: diff --git a/plans/rolling-amd64-runtime-deps/.boringcache.toml b/plans/rolling-amd64-runtime-deps/.boringcache.toml new file mode 100644 index 0000000..6a031c4 --- /dev/null +++ b/plans/rolling-amd64-runtime-deps/.boringcache.toml @@ -0,0 +1,21 @@ +workspace = "boringcache/benchmark-discourse" + +[adapters.docker] +tag = "discourse-runtime-deps-rolling-amd64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base-runtime-deps", "lane=rolling", "arch=amd64"] +command = [ + "docker", "buildx", "bake", + "base-runtime-deps", + "--set=base-runtime-deps-*.no-cache=true", + "--load", +] + +[adapters.ccache] +tag = "discourse-ccache-runtime-deps-rolling-amd64" +no-platform = true +no-git = true +fail-on-cache-error = true +metadata-hints = ["benchmark=discourse", "upstream-job=base-runtime-deps", "lane=rolling", "arch=amd64", "tool=ccache"] diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index a154cd5..f60e864 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -45,6 +45,17 @@ def expected_command(arch: str) -> list[str]: ] +def expected_runtime_deps_command() -> list[str]: + return [ + "docker", + "buildx", + "bake", + "base-runtime-deps", + "--set=base-runtime-deps-*.no-cache=true", + "--load", + ] + + def main() -> int: try: workflow = UPSTREAM_WORKFLOW.read_text() @@ -73,6 +84,22 @@ def main() -> int: require(ccache["no-git"] is True, f"{name} ccache must use its declared lane") require(ccache["fail-on-cache-error"] is True, f"{name} ccache must fail closed") + runtime_deps_path = ROOT / "plans" / "rolling-amd64-runtime-deps" / ".boringcache.toml" + with runtime_deps_path.open("rb") as config_file: + runtime_deps_adapters = tomllib.load(config_file)["adapters"] + require( + runtime_deps_adapters["docker"]["command"] == expected_runtime_deps_command(), + "ccache experiment must isolate the forced-no-cache runtime-deps target", + ) + require( + runtime_deps_adapters["docker"]["tag"] == "discourse-runtime-deps-rolling-amd64", + "runtime-deps Docker tag drifted", + ) + require( + runtime_deps_adapters["ccache"]["tag"] == "discourse-ccache-runtime-deps-rolling-amd64", + "runtime-deps ccache tag drifted", + ) + with (ROOT / ".boringcache.toml").open("rb") as config_file: root_adapters = tomllib.load(config_file)["adapters"] root_adapter = root_adapters["docker"] @@ -118,6 +145,7 @@ def main() -> int: 'PLAN: ${{ format(\'{0}-{1}\', inputs.cache_lane, inputs.arch) }}' in action, "composite action does not select the committed lane and architecture plan", ) + require('PLAN_VARIANT: ${{ inputs.plan_variant }}' in action, "composite action does not select focused plans") require( action.count('ARCH: ${{ inputs.arch }}') == 4, "composite action must pin architecture-sensitive scope, build, and report steps", @@ -153,6 +181,8 @@ def main() -> int: "image-factory timing must not include the upstream specs", ) require("continue-on-error: true" in action, "benchmark evidence must survive an upstream spec timeout") + require("Smoke test the runtime-deps image" in action, "focused ccache builds must smoke test their output") + require("if: inputs.run_specs == 'true'" in action, "focused builds must be able to skip unrelated image specs") require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") require("publish_images" not in action + rolling + fresh, "benchmark image publication returned") require("git -C upstream rev-parse HEAD" in action, "rolling cache does not use the pinned Discourse source") @@ -182,6 +212,8 @@ def main() -> int: require("cache_profile: bundler" in rolling, "Bundler lane does not select the Bundler profile") require("ccache_experiment" in rolling, "ccache workflow-dispatch lane is missing") require("cache_profile: ccache" in rolling, "ccache lane does not select the ccache profile") + require("plan_variant: runtime-deps" in rolling, "ccache lane must isolate runtime-deps") + require('run_specs: "false"' in rolling, "runtime-deps lane must not invoke the absent test image") require("report_strategy: boringcache-ccache" in rolling, "ccache lane does not retain its own result") require( "origin/tests-passed" in (ROOT / "upstream/script/docker_test.rb").read_text(), From 624a9eef7a9d9c131f940a480fb58af47c7c6b65 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 18:09:30 +0100 Subject: [PATCH 08/17] Pin the Docker ccache compatibility version --- scripts/prepare_discourse_cache_profile.py | 20 ++++++++++++++++++++ scripts/verify-upstream-recipe.py | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index d74dfe3..fd5a733 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -57,6 +57,26 @@ def add_ccache(source: str) -> str: " libbrotli-dev\n\n" "FROM builder AS libheif-builder\n", " libbrotli-dev\n\n" + "# BoringCache v1.19.1 targets ccache 4.13.6's @-attribute syntax.\n" + "# Keep Debian's compiler wrappers, but replace its older ccache binary.\n" + "ARG TARGETARCH\n" + "ARG CCACHE_VERSION=4.13.6\n" + "RUN case \"${TARGETARCH}\" in \\\n" + " amd64) ccache_arch=x86_64; ccache_sha=567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13 ;; \\\n" + " arm64) ccache_arch=aarch64; ccache_sha=fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98 ;; \\\n" + " *) echo \"Unsupported ccache architecture: ${TARGETARCH}\" >&2; exit 2 ;; \\\n" + " esac &&\\\n" + " ccache_archive=\"ccache-${CCACHE_VERSION}-linux-${ccache_arch}-glibc.tar.gz\" &&\\\n" + " wget --tries=5 --timeout=30 --waitretry=2 \\\n" + " \"https://github.com/ccache/ccache/releases/download/v${CCACHE_VERSION}/${ccache_archive}\" \\\n" + " -O \"/tmp/${ccache_archive}\" &&\\\n" + " echo \"${ccache_sha} /tmp/${ccache_archive}\" | sha256sum --check &&\\\n" + " tar xzf \"/tmp/${ccache_archive}\" -C /tmp &&\\\n" + " install -m 0755 \\\n" + " \"/tmp/ccache-${CCACHE_VERSION}-linux-${ccache_arch}-glibc/ccache\" \\\n" + " /usr/bin/ccache &&\\\n" + " rm -rf /tmp/ccache-* &&\\\n" + " ccache --version | grep -F \"ccache version ${CCACHE_VERSION}\"\n\n" 'ENV PATH="/usr/lib/ccache:${PATH}"\n\n' "FROM builder AS libheif-builder\n", "builder stage boundary", diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index f60e864..8e31533 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -135,6 +135,16 @@ def main() -> int: ) ccache_profile = render(dockerfile, "ccache") require(" ccache \\\n" in ccache_profile, "ccache profile does not install ccache") + require("ARG CCACHE_VERSION=4.13.6" in ccache_profile, "ccache profile must use the CLI-tested version") + require( + "567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13" in ccache_profile, + "amd64 ccache release must be checksum verified", + ) + require( + "fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98" in ccache_profile, + "arm64 ccache release must be checksum verified", + ) + require("/usr/bin/ccache" in ccache_profile, "released ccache must replace Debian's older binary") require( 'ENV PATH="/usr/lib/ccache:${PATH}"' in ccache_profile, "ccache profile does not select Debian's compiler wrappers", From 2cd533b1bda0727c58ce9cf534c76749e6aa3c84 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 18:13:11 +0100 Subject: [PATCH 09/17] Stage ccache before the Docker benchmark --- .../discourse-image-factory/action.yml | 5 +++ scripts/prepare_discourse_cache_profile.py | 18 +-------- scripts/stage-ccache-binary.sh | 39 +++++++++++++++++++ scripts/verify-upstream-recipe.py | 28 +++++++++---- 4 files changed, 66 insertions(+), 24 deletions(-) create mode 100755 scripts/stage-ccache-binary.sh diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 71655d3..64415d4 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -83,6 +83,11 @@ runs: CACHE_PROFILE: ${{ inputs.cache_profile }} run: python3 ./scripts/prepare_discourse_cache_profile.py "${CACHE_PROFILE:-baseline}" + - name: Stage the CLI-compatible ccache binary + if: inputs.cache_profile == 'ccache' + shell: bash + run: ./scripts/stage-ccache-binary.sh "${{ inputs.arch }}" docker-upstream/image/base/ccache + - name: Set up Buildx for the GitHub Actions comparison if: inputs.strategy == 'actions-cache' uses: docker/setup-buildx-action@v4 diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index fd5a733..634cc3e 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -59,23 +59,9 @@ def add_ccache(source: str) -> str: " libbrotli-dev\n\n" "# BoringCache v1.19.1 targets ccache 4.13.6's @-attribute syntax.\n" "# Keep Debian's compiler wrappers, but replace its older ccache binary.\n" - "ARG TARGETARCH\n" "ARG CCACHE_VERSION=4.13.6\n" - "RUN case \"${TARGETARCH}\" in \\\n" - " amd64) ccache_arch=x86_64; ccache_sha=567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13 ;; \\\n" - " arm64) ccache_arch=aarch64; ccache_sha=fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98 ;; \\\n" - " *) echo \"Unsupported ccache architecture: ${TARGETARCH}\" >&2; exit 2 ;; \\\n" - " esac &&\\\n" - " ccache_archive=\"ccache-${CCACHE_VERSION}-linux-${ccache_arch}-glibc.tar.gz\" &&\\\n" - " wget --tries=5 --timeout=30 --waitretry=2 \\\n" - " \"https://github.com/ccache/ccache/releases/download/v${CCACHE_VERSION}/${ccache_archive}\" \\\n" - " -O \"/tmp/${ccache_archive}\" &&\\\n" - " echo \"${ccache_sha} /tmp/${ccache_archive}\" | sha256sum --check &&\\\n" - " tar xzf \"/tmp/${ccache_archive}\" -C /tmp &&\\\n" - " install -m 0755 \\\n" - " \"/tmp/ccache-${CCACHE_VERSION}-linux-${ccache_arch}-glibc/ccache\" \\\n" - " /usr/bin/ccache &&\\\n" - " rm -rf /tmp/ccache-* &&\\\n" + "COPY ccache /usr/bin/ccache\n" + "RUN chmod 0755 /usr/bin/ccache &&\\\n" " ccache --version | grep -F \"ccache version ${CCACHE_VERSION}\"\n\n" 'ENV PATH="/usr/lib/ccache:${PATH}"\n\n' "FROM builder AS libheif-builder\n", diff --git a/scripts/stage-ccache-binary.sh b/scripts/stage-ccache-binary.sh new file mode 100755 index 0000000..828b10f --- /dev/null +++ b/scripts/stage-ccache-binary.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +arch="${1:?usage: stage-ccache-binary.sh ARCH DESTINATION}" +destination="${2:?usage: stage-ccache-binary.sh ARCH DESTINATION}" +version="4.13.6" + +case "$arch" in + amd64) + release_arch="x86_64" + checksum="567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13" + ;; + arm64) + release_arch="aarch64" + checksum="fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98" + ;; + *) + echo "Unsupported ccache architecture: $arch" >&2 + exit 2 + ;; +esac + +archive="ccache-${version}-linux-${release_arch}-glibc.tar.gz" +temporary_dir="$(mktemp -d)" +trap 'rm -rf "$temporary_dir"' EXIT + +curl --fail --location --show-error --silent \ + --retry 8 --retry-all-errors --connect-timeout 30 --max-time 180 \ + "https://github.com/ccache/ccache/releases/download/v${version}/${archive}" \ + --output "${temporary_dir}/${archive}" +actual_checksum="$(sha256sum "${temporary_dir}/${archive}" | awk '{print $1}')" +if [[ "$actual_checksum" != "$checksum" ]]; then + echo "Checksum mismatch for $archive" >&2 + exit 1 +fi +tar xzf "${temporary_dir}/${archive}" -C "$temporary_dir" +install -m 0755 \ + "${temporary_dir}/ccache-${version}-linux-${release_arch}-glibc/ccache" \ + "$destination" diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 8e31533..35b8fc8 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -136,14 +136,7 @@ def main() -> int: ccache_profile = render(dockerfile, "ccache") require(" ccache \\\n" in ccache_profile, "ccache profile does not install ccache") require("ARG CCACHE_VERSION=4.13.6" in ccache_profile, "ccache profile must use the CLI-tested version") - require( - "567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13" in ccache_profile, - "amd64 ccache release must be checksum verified", - ) - require( - "fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98" in ccache_profile, - "arm64 ccache release must be checksum verified", - ) + require("COPY ccache /usr/bin/ccache" in ccache_profile, "ccache profile must use the staged release") require("/usr/bin/ccache" in ccache_profile, "released ccache must replace Debian's older binary") require( 'ENV PATH="/usr/lib/ccache:${PATH}"' in ccache_profile, @@ -191,6 +184,14 @@ def main() -> int: "image-factory timing must not include the upstream specs", ) require("continue-on-error: true" in action, "benchmark evidence must survive an upstream spec timeout") + require( + "Stage the CLI-compatible ccache binary" in action, + "ccache profile must stage its compatible binary on the native runner", + ) + require( + action.index("Stage the CLI-compatible ccache binary") < action.index("Start image-factory timing"), + "ccache tool setup must stay outside build timing", + ) require("Smoke test the runtime-deps image" in action, "focused ccache builds must smoke test their output") require("if: inputs.run_specs == 'true'" in action, "focused builds must be able to skip unrelated image specs") require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") @@ -229,6 +230,17 @@ def main() -> int: "origin/tests-passed" in (ROOT / "upstream/script/docker_test.rb").read_text(), "upstream image specs no longer select the tests-passed branch", ) + + ccache_stage = (ROOT / "scripts" / "stage-ccache-binary.sh").read_text() + require('version="4.13.6"' in ccache_stage, "staged ccache version drifted") + require( + "567b1b648411819590f918f045218c92da14418bdec3b30db94a3b4f5d77cf13" in ccache_stage, + "amd64 ccache release must be checksum verified", + ) + require( + "fae67fb810e1f0d390409af6603355483572229e19183e68574cd0f851a6fb98" in ccache_stage, + "arm64 ccache release must be checksum verified", + ) gitmodules = (ROOT / ".gitmodules").read_text() require("branch = tests-passed" in gitmodules, "source sync must follow Discourse tests-passed") sync = (ROOT / ".github/workflows/sync.yml").read_text() From 4f3338dbcec93a496dee28bb4e88838578b70c80 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 18:26:56 +0100 Subject: [PATCH 10/17] Retry benchmark CLI release downloads --- .github/actions/discourse-image-factory/action.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 64415d4..a72c8d5 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -101,11 +101,18 @@ runs: set -euo pipefail installer="$(mktemp)" trap 'rm -f "$installer"' EXIT - curl -fsSL -H "Cache-Control: no-cache" -H "Pragma: no-cache" \ + curl -fsSL --retry 8 --retry-all-errors --connect-timeout 30 --max-time 180 \ + -H "Cache-Control: no-cache" -H "Pragma: no-cache" \ https://install.boringcache.com/install.sh -o "$installer" if [[ -n "$CLI_VERSION" ]]; then export BORINGCACHE_INSTALLER_SOURCE_ONLY=1 source "$installer" + download_file() { + local url="$1" + local output="$2" + curl -fsSL --retry 8 --retry-all-errors --connect-timeout 30 --max-time 180 \ + "$url" -o "$output" + } install_binary linux "$(detect_arch)" "$CLI_VERSION" else bash "$installer" From 1e89a0fdc94866e962280fc237afd87ae8876f70 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 12 Aug 2026 19:39:02 +0100 Subject: [PATCH 11/17] Benchmark full build with Bundler cache and ccache --- .../discourse-image-factory/action.yml | 6 ++--- .github/workflows/discourse-image-factory.yml | 8 ++++++ scripts/prepare_discourse_cache_profile.py | 4 ++- scripts/verify-upstream-recipe.py | 26 +++++++++++++++++-- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index a72c8d5..83284aa 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -84,7 +84,7 @@ runs: run: python3 ./scripts/prepare_discourse_cache_profile.py "${CACHE_PROFILE:-baseline}" - name: Stage the CLI-compatible ccache binary - if: inputs.cache_profile == 'ccache' + if: inputs.cache_profile == 'ccache' || inputs.cache_profile == 'bundler-ccache' shell: bash run: ./scripts/stage-ccache-binary.sh "${{ inputs.arch }}" docker-upstream/image/base/ccache @@ -201,10 +201,10 @@ runs: if [[ "$READ_ONLY" == "true" ]]; then args+=(--read-only) fi - if [[ "$CACHE_PROFILE" == "bundler" ]]; then + if [[ "$CACHE_PROFILE" == "bundler" || "$CACHE_PROFILE" == "bundler-ccache" ]]; then args+=(--mount-cache) fi - if [[ "$CACHE_PROFILE" == "ccache" ]]; then + if [[ "$CACHE_PROFILE" == "ccache" || "$CACHE_PROFILE" == "bundler-ccache" ]]; then args+=(--tool-cache ccache) fi boringcache docker "${args[@]}" diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index 320c41a..d4bde46 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -67,6 +67,7 @@ jobs: - {provider: GitHub Actions control, strategy: actions-cache, report_strategy: actions-cache, cache_profile: ""} - {provider: BoringCache control, strategy: boringcache, report_strategy: boringcache, cache_profile: ""} - {provider: BoringCache + Bundler mount, strategy: boringcache, report_strategy: boringcache-bundler, cache_profile: bundler} + - {provider: BoringCache + Bundler mount + ccache, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache} steps: - uses: actions/checkout@v6 - uses: ./.github/actions/discourse-image-factory @@ -74,6 +75,7 @@ jobs: strategy: ${{ matrix.strategy }} report_strategy: ${{ matrix.report_strategy }} cache_profile: ${{ matrix.cache_profile }} + run_specs: "false" cache_lane: rolling phase: publish arch: amd64 @@ -164,6 +166,12 @@ jobs: name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling.json if-no-files-found: error + - name: Retain the Bundler mount and ccache result + uses: actions/upload-artifact@v6 + with: + name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-ccache-amd64-rolling + path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-ccache-amd64-rolling.json + if-no-files-found: error - name: Retain the BoringCache control result uses: actions/upload-artifact@v6 with: diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index 634cc3e..7be87e2 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -9,7 +9,7 @@ ROOT = Path(__file__).resolve().parents[1] DOCKERFILE = ROOT / "docker-upstream/image/base/Dockerfile" -PROFILES = ("baseline", "bundler", "ccache") +PROFILES = ("baseline", "bundler", "ccache", "bundler-ccache") class ProfileMismatch(RuntimeError): @@ -76,6 +76,8 @@ def render(source: str, profile: str) -> str: return add_bundler_cache(source) if profile == "ccache": return add_ccache(source) + if profile == "bundler-ccache": + return add_ccache(add_bundler_cache(source)) raise ProfileMismatch(f"unknown cache profile: {profile}") diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 35b8fc8..91fa5e3 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -144,6 +144,17 @@ def main() -> int: ) require("CCACHE_REMOTE_STORAGE" not in ccache_profile, "the Dockerfile must not own BoringCache's ccache endpoint") require("BUNDLE_GLOBAL_GEM_CACHE" not in ccache_profile, "ccache profile must stay isolated from Bundler") + combined_profile = render(dockerfile, "bundler-ccache") + require( + "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + in combined_profile, + "combined profile does not enable the Bundler mount cache", + ) + require("COPY ccache /usr/bin/ccache" in combined_profile, "combined profile does not stage ccache") + require( + 'ENV PATH="/usr/lib/ccache:${PATH}"' in combined_profile, + "combined profile does not select ccache's compiler wrappers", + ) require( 'PLAN: ${{ format(\'{0}-{1}\', inputs.cache_lane, inputs.arch) }}' in action, "composite action does not select the committed lane and architecture plan", @@ -176,9 +187,15 @@ def main() -> int: require("working-directory: docker-upstream/image" in action, "CLI must run the upstream Bake plan") require('args+=(--read-only)' in action, "warm builds must restore without publishing") require('args+=(--mount-cache)' in action, "Bundler experiment must enable mount-cache offload") - require('[[ "$CACHE_PROFILE" == "bundler" ]]' in action, "mount-cache offload must stay scoped to Bundler") + require( + '[[ "$CACHE_PROFILE" == "bundler" || "$CACHE_PROFILE" == "bundler-ccache" ]]' in action, + "mount-cache offload must stay scoped to Bundler profiles", + ) require('args+=(--tool-cache ccache)' in action, "ccache experiment must use the released Docker tool-cache surface") - require('[[ "$CACHE_PROFILE" == "ccache" ]]' in action, "Docker ccache must stay scoped to its experiment") + require( + '[[ "$CACHE_PROFILE" == "ccache" || "$CACHE_PROFILE" == "bundler-ccache" ]]' in action, + "Docker ccache must stay scoped to ccache profiles", + ) require( action.index("Capture image-factory timing") < action.index("Run upstream's image specs"), "image-factory timing must not include the upstream specs", @@ -221,6 +238,11 @@ def main() -> int: ) require("bundler_cache_experiment" in rolling, "Bundler workflow-dispatch lane is missing") require("cache_profile: bundler" in rolling, "Bundler lane does not select the Bundler profile") + require("cache_profile: bundler-ccache" in rolling, "combined lane does not select both cache profiles") + require( + "report_strategy: boringcache-bundler-ccache" in rolling, + "combined lane does not retain its own result", + ) require("ccache_experiment" in rolling, "ccache workflow-dispatch lane is missing") require("cache_profile: ccache" in rolling, "ccache lane does not select the ccache profile") require("plan_variant: runtime-deps" in rolling, "ccache lane must isolate runtime-deps") From 50aee448e8243bf34579d03ff1360bf6713db1e6 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 10:35:39 +0100 Subject: [PATCH 12/17] Benchmark Discourse's full cache stack --- .boringcache.toml | 12 +- .../discourse-image-factory/action.yml | 31 ++-- .github/workflows/discourse-image-factory.yml | 123 +++++++++++----- .gitmodules | 3 +- docker-upstream | 2 +- plans/fresh-amd64/.boringcache.toml | 12 +- plans/fresh-arm64/.boringcache.toml | 12 +- .../.boringcache.toml | 4 +- plans/rolling-amd64/.boringcache.toml | 12 +- plans/rolling-arm64/.boringcache.toml | 12 +- scripts/benchmark-report.py | 1 + scripts/prepare_discourse_cache_profile.py | 40 ++--- scripts/run-actions-cache-plan.py | 44 ++++-- scripts/run-boringcache-plan.py | 74 ++++++++++ scripts/verify-upstream-recipe.py | 139 +++++++++++++++--- 15 files changed, 373 insertions(+), 148 deletions(-) create mode 100644 scripts/run-boringcache-plan.py diff --git a/.boringcache.toml b/.boringcache.toml index 64b90ed..e365d47 100644 --- a/.boringcache.toml +++ b/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-image-factory-rolling-amd64" @@ -8,13 +8,9 @@ fail-on-cache-error = true metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=amd64"] command = [ "docker", "buildx", "bake", - "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - "test-release-amd64", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-runtime-deps", "base-slim-main", "base-slim-stable", + "base-web-only-main", "base-web-only-stable", + "base-release-main", "base-release-stable", "test-release", ] [adapters.ccache] diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 83284aa..e767303 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -148,6 +148,7 @@ runs: fi echo "cache_scope=${cache_scope}" >> "$GITHUB_OUTPUT" echo "discourse_source_sha=${tests_passed_sha}" >> "$GITHUB_OUTPUT" + echo "discourse_source_datestamp=$(git -C upstream show -s --format=%cd --date=format:%Y%m%d HEAD)" >> "$GITHUB_OUTPUT" echo "source_sha=$(git -C docker-upstream rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Start image-factory timing @@ -175,28 +176,40 @@ runs: shell: bash env: ARCH: ${{ inputs.arch }} + DATESTAMP: ${{ steps.scope.outputs.discourse_source_datestamp }} + DISCOURSE_REF: ${{ inputs.discourse_ref }} PLAN: ${{ format('{0}-{1}', inputs.cache_lane, inputs.arch) }} PLAN_VARIANT: ${{ inputs.plan_variant }} + READ_ONLY: ${{ inputs.phase == 'warm' && 'true' || 'false' }} run: | if [[ -n "$PLAN_VARIANT" ]]; then PLAN="${PLAN}-${PLAN_VARIANT}" fi - python3 ./scripts/run-actions-cache-plan.py "$PLAN" \ - --cache-scope "${{ steps.scope.outputs.cache_scope }}" + args=(--cache-scope "${{ steps.scope.outputs.cache_scope }}") + if [[ "$READ_ONLY" == "true" ]]; then + args+=(--read-only) + fi + python3 ./scripts/run-actions-cache-plan.py "$PLAN" "${args[@]}" - name: Build with BoringCache if: inputs.strategy == 'boringcache' shell: bash - working-directory: docker-upstream/image env: ARCH: ${{ inputs.arch }} + DATESTAMP: ${{ steps.scope.outputs.discourse_source_datestamp }} + DISCOURSE_REF: ${{ inputs.discourse_ref }} BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }} BORINGCACHE_MANAGED_BUILDKIT_LOG_PATH: ${{ runner.temp }}/boringcache-managed-buildkit-${{ inputs.arch }}-${{ inputs.cache_lane }}-${{ inputs.phase }}.log READ_ONLY: ${{ inputs.phase == 'warm' && 'true' || 'false' }} CACHE_PROFILE: ${{ inputs.cache_profile }} + PLAN: ${{ format('{0}-{1}', inputs.cache_lane, inputs.arch) }} + PLAN_VARIANT: ${{ inputs.plan_variant }} RUST_LOG: warn run: | set -euo pipefail + if [[ -n "$PLAN_VARIANT" ]]; then + PLAN="${PLAN}-${PLAN_VARIANT}" + fi args=() if [[ "$READ_ONLY" == "true" ]]; then args+=(--read-only) @@ -205,9 +218,9 @@ runs: args+=(--mount-cache) fi if [[ "$CACHE_PROFILE" == "ccache" || "$CACHE_PROFILE" == "bundler-ccache" ]]; then - args+=(--tool-cache ccache) + args+=(--tool-cache-ccache) fi - boringcache docker "${args[@]}" + python3 ./scripts/run-boringcache-plan.py "$PLAN" "${args[@]}" - name: Capture image-factory timing id: timing @@ -219,7 +232,7 @@ runs: - name: Smoke test the runtime-deps image if: inputs.plan_variant == 'runtime-deps' shell: bash - run: docker run --rm "${BASE_IMAGE}:runtime-deps-${{ inputs.arch }}" /bin/true + run: docker run --rm "${BASE_IMAGE}:runtime-deps" /bin/true - name: Show managed BuildKit errors if: ${{ inputs.strategy == 'boringcache' && failure() }} @@ -249,7 +262,7 @@ runs: -e SKIP_LINT=1 \ -e DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS=1 \ -e COMMIT_HASH="$DISCOURSE_SOURCE_SHA" \ - "${TEST_IMAGE}:release-${{ inputs.arch }}" + "${TEST_IMAGE}:release" - name: Write benchmark evidence shell: bash @@ -275,8 +288,8 @@ runs: --workflow-seconds "$(( $(date +%s) - WORKFLOW_STARTED_AT ))" \ --build-seconds "$BUILD_SECONDS" \ --cache-tag "$CACHE_SCOPE" \ - --workspace boringcache/benchmark-discourse \ - --source-repository discourse/discourse_docker \ + --workspace discourse/benchmark-discourse \ + --source-repository boringcache/discourse_docker \ --source-sha "$SOURCE_SHA" \ --workload-repository discourse/discourse \ --workload-sha "$DISCOURSE_SOURCE_SHA" diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index d4bde46..467139f 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -4,13 +4,15 @@ run-name: ${{ format('Discourse image factory | {0}', github.event_name == 'push on: workflow_dispatch: inputs: - cli_version: {description: "Optional exact BoringCache CLI release or prerelease tag.", required: false, type: string, default: ""} + cli_version: {description: "Exact BoringCache CLI release.", required: true, type: string, default: "1.19.1"} buildkit_image: {description: "Optional managed BuildKit image canary.", required: false, type: string, default: ""} benchmark_id_suffix: {description: "Optional suffix for an isolated comparison.", required: false, type: string, default: ""} - bundler_cache_experiment: {description: "Run the isolated Bundler mount-cache lane on amd64.", required: false, type: boolean, default: false} + bundler_cache_experiment: {description: "Seed and roll the full upstream graph across GHA, BoringCache, and BoringCache with mount/tool caches.", required: false, type: boolean, default: false} ccache_experiment: {description: "Run the isolated Docker ccache tool-cache lane on amd64.", required: false, type: boolean, default: false} discourse_ref: {description: "Optional full Discourse commit SHA for a historical run.", required: false, type: string, default: ""} - docker_ref: {description: "Optional full discourse_docker commit SHA at or after the Mozilla-key fix.", required: false, type: string, default: ""} + seed_ref: {description: "Older Discourse commit used to seed the cache experiment.", required: true, type: string, default: "eedf0ac2344c37d66a2c9ab05dc8a83bf3efd9bb"} + rolling_ref: {description: "Newer Discourse commit with unchanged dependency locks.", required: true, type: string, default: "763655f6faf47b088afee1a59e2d97cec5886c97"} + docker_ref: {description: "Full BoringCache discourse_docker cache-wiring commit SHA.", required: true, type: string, default: "5cc4f095b4efdb7bd105577e021390f6c2dfa147"} push: branches: [main] paths: [".boringcache.toml", ".gitmodules", "plans/**", "scripts/**", ".github/actions/discourse-image-factory/**", "upstream", "docker-upstream"] @@ -55,19 +57,47 @@ jobs: cli_version: ${{ inputs.cli_version }} buildkit_image: ${{ inputs.buildkit_image }} - bundler: - name: ${{ matrix.provider }} amd64 commit + cache-preflight: + name: Verify the seed and rolling refs if: ${{ github.event_name == 'workflow_dispatch' && inputs.bundler_cache_experiment }} runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Verify the controlled dependency window + env: + SEED_REF: ${{ inputs.seed_ref }} + ROLLING_REF: ${{ inputs.rolling_ref }} + run: | + set -euo pipefail + sha_pattern='^[0-9a-fA-F]{40}$' + [[ "$SEED_REF" =~ $sha_pattern ]] || { echo "seed_ref must be a full commit SHA" >&2; exit 1; } + [[ "$ROLLING_REF" =~ $sha_pattern ]] || { echo "rolling_ref must be a full commit SHA" >&2; exit 1; } + + git submodule update --init --depth 1 upstream + git -C upstream fetch --depth 128 origin "$ROLLING_REF" + git -C upstream cat-file -e "${SEED_REF}^{commit}" + git -C upstream merge-base --is-ancestor "$SEED_REF" "$ROLLING_REF" + git -C upstream diff --quiet "$SEED_REF" "$ROLLING_REF" -- Gemfile Gemfile.lock package.json pnpm-lock.yaml + + echo "Seed: $(git -C upstream show -s --format='%H %s' "$SEED_REF")" + echo "Roll: $(git -C upstream show -s --format='%H %s' "$ROLLING_REF")" + echo "Headroom: $(git -C upstream rev-list --count "${SEED_REF}..${ROLLING_REF}") commits" + + cache-seed: + name: ${{ matrix.provider }} ${{ matrix.arch }} seed + needs: cache-preflight + runs-on: ${{ matrix.runner }} timeout-minutes: 180 strategy: fail-fast: false matrix: include: - - {provider: GitHub Actions control, strategy: actions-cache, report_strategy: actions-cache, cache_profile: ""} - - {provider: BoringCache control, strategy: boringcache, report_strategy: boringcache, cache_profile: ""} - - {provider: BoringCache + Bundler mount, strategy: boringcache, report_strategy: boringcache-bundler, cache_profile: bundler} - - {provider: BoringCache + Bundler mount + ccache, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache} + - {provider: GitHub Actions, strategy: actions-cache, report_strategy: actions-cache, cache_profile: baseline, arch: amd64, runner: ubuntu-24.04} + - {provider: GitHub Actions, strategy: actions-cache, report_strategy: actions-cache, cache_profile: baseline, arch: arm64, runner: ubuntu-24.04-arm} + - {provider: BoringCache layer, strategy: boringcache, report_strategy: boringcache, cache_profile: baseline, arch: amd64, runner: ubuntu-24.04} + - {provider: BoringCache layer, strategy: boringcache, report_strategy: boringcache, cache_profile: baseline, arch: arm64, runner: ubuntu-24.04-arm} + - {provider: BoringCache all caches, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache, arch: amd64, runner: ubuntu-24.04} + - {provider: BoringCache all caches, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache, arch: arm64, runner: ubuntu-24.04-arm} steps: - uses: actions/checkout@v6 - uses: ./.github/actions/discourse-image-factory @@ -75,12 +105,44 @@ jobs: strategy: ${{ matrix.strategy }} report_strategy: ${{ matrix.report_strategy }} cache_profile: ${{ matrix.cache_profile }} - run_specs: "false" - cache_lane: rolling + cache_lane: fresh phase: publish - arch: amd64 - benchmark_id: ${{ format('discourse-bundler-cache{0}', inputs.benchmark_id_suffix) }} - discourse_ref: ${{ inputs.discourse_ref }} + arch: ${{ matrix.arch }} + benchmark_id: ${{ format('discourse-cache-stack{0}', inputs.benchmark_id_suffix) }} + discourse_ref: ${{ inputs.seed_ref }} + docker_ref: ${{ inputs.docker_ref }} + cli_version: ${{ inputs.cli_version }} + buildkit_image: ${{ inputs.buildkit_image }} + + cache-roll: + name: ${{ matrix.provider }} ${{ matrix.arch }} roll + needs: cache-seed + runs-on: ${{ matrix.runner }} + timeout-minutes: 180 + env: + BORINGCACHE_SAVE_TOKEN: "" + strategy: + fail-fast: false + matrix: + include: + - {provider: GitHub Actions, strategy: actions-cache, report_strategy: actions-cache, cache_profile: baseline, arch: amd64, runner: ubuntu-24.04} + - {provider: GitHub Actions, strategy: actions-cache, report_strategy: actions-cache, cache_profile: baseline, arch: arm64, runner: ubuntu-24.04-arm} + - {provider: BoringCache layer, strategy: boringcache, report_strategy: boringcache, cache_profile: baseline, arch: amd64, runner: ubuntu-24.04} + - {provider: BoringCache layer, strategy: boringcache, report_strategy: boringcache, cache_profile: baseline, arch: arm64, runner: ubuntu-24.04-arm} + - {provider: BoringCache all caches, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache, arch: amd64, runner: ubuntu-24.04} + - {provider: BoringCache all caches, strategy: boringcache, report_strategy: boringcache-bundler-ccache, cache_profile: bundler-ccache, arch: arm64, runner: ubuntu-24.04-arm} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/discourse-image-factory + with: + strategy: ${{ matrix.strategy }} + report_strategy: ${{ matrix.report_strategy }} + cache_profile: ${{ matrix.cache_profile }} + cache_lane: fresh + phase: warm + arch: ${{ matrix.arch }} + benchmark_id: ${{ format('discourse-cache-stack{0}', inputs.benchmark_id_suffix) }} + discourse_ref: ${{ inputs.rolling_ref }} docker_ref: ${{ inputs.docker_ref }} cli_version: ${{ inputs.cli_version }} buildkit_image: ${{ inputs.buildkit_image }} @@ -150,39 +212,22 @@ jobs: if-no-files-found: error bundler-report: - name: Discourse Bundler mount-cache experiment report - needs: bundler + name: Discourse seeded cache-stack report + if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.bundler_cache_experiment }} + needs: cache-roll runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: - pattern: phase-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-* + pattern: phase-discourse-cache-stack${{ inputs.benchmark_id_suffix }}-* path: phase-evidence - - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse Bundler mount-cache benchmark" --input-dir phase-evidence --output-dir benchmark-results - - name: Retain the Bundler mount result - uses: actions/upload-artifact@v6 - with: - name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling - path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-amd64-rolling.json - if-no-files-found: error - - name: Retain the Bundler mount and ccache result - uses: actions/upload-artifact@v6 - with: - name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-ccache-amd64-rolling - path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-bundler-ccache-amd64-rolling.json - if-no-files-found: error - - name: Retain the BoringCache control result - uses: actions/upload-artifact@v6 - with: - name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling - path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-boringcache-amd64-rolling.json - if-no-files-found: error - - name: Retain the GitHub Actions control result + - run: python3 ./scripts/benchmark-report.py summarize --title "Discourse seeded full cache-stack benchmark" --input-dir phase-evidence --output-dir benchmark-results + - name: Retain the cache-stack comparison uses: actions/upload-artifact@v6 with: - name: benchmark-discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling - path: benchmark-results/discourse-bundler-cache${{ inputs.benchmark_id_suffix }}-actions-cache-amd64-rolling.json + name: benchmark-discourse-cache-stack${{ inputs.benchmark_id_suffix }} + path: benchmark-results/ if-no-files-found: error ccache-report: diff --git a/.gitmodules b/.gitmodules index 5a142af..ffa41b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,5 +5,6 @@ shallow = true [submodule "docker-upstream"] path = docker-upstream - url = https://github.com/discourse/discourse_docker.git + url = https://github.com/boringcache/discourse_docker.git + branch = agent/benchmark-cache-controls shallow = true diff --git a/docker-upstream b/docker-upstream index 9c35fe8..5cc4f09 160000 --- a/docker-upstream +++ b/docker-upstream @@ -1 +1 @@ -Subproject commit 9c35fe8f6f4eb66d399f756e3bae773292e34db2 +Subproject commit 5cc4f095b4efdb7bd105577e021390f6c2dfa147 diff --git a/plans/fresh-amd64/.boringcache.toml b/plans/fresh-amd64/.boringcache.toml index dcd9d3a..d9254ed 100644 --- a/plans/fresh-amd64/.boringcache.toml +++ b/plans/fresh-amd64/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-image-factory-fresh-amd64" @@ -8,13 +8,9 @@ fail-on-cache-error = true metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=fresh", "arch=amd64"] command = [ "docker", "buildx", "bake", - "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - "test-release-amd64", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-runtime-deps", "base-slim-main", "base-slim-stable", + "base-web-only-main", "base-web-only-stable", + "base-release-main", "base-release-stable", "test-release", ] [adapters.ccache] diff --git a/plans/fresh-arm64/.boringcache.toml b/plans/fresh-arm64/.boringcache.toml index ffff98f..d165e8c 100644 --- a/plans/fresh-arm64/.boringcache.toml +++ b/plans/fresh-arm64/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-image-factory-fresh-arm64" @@ -8,13 +8,9 @@ fail-on-cache-error = true metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=fresh", "arch=arm64"] command = [ "docker", "buildx", "bake", - "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - "test-release-arm64", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-runtime-deps", "base-slim-main", "base-slim-stable", + "base-web-only-main", "base-web-only-stable", + "base-release-main", "base-release-stable", "test-release", ] [adapters.ccache] diff --git a/plans/rolling-amd64-runtime-deps/.boringcache.toml b/plans/rolling-amd64-runtime-deps/.boringcache.toml index 6a031c4..c515084 100644 --- a/plans/rolling-amd64-runtime-deps/.boringcache.toml +++ b/plans/rolling-amd64-runtime-deps/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-runtime-deps-rolling-amd64" @@ -9,7 +9,7 @@ metadata-hints = ["benchmark=discourse", "upstream-job=base-runtime-deps", "lane command = [ "docker", "buildx", "bake", "base-runtime-deps", - "--set=base-runtime-deps-*.no-cache=true", + "--set=base-runtime-deps.no-cache=true", "--load", ] diff --git a/plans/rolling-amd64/.boringcache.toml b/plans/rolling-amd64/.boringcache.toml index 64b90ed..e365d47 100644 --- a/plans/rolling-amd64/.boringcache.toml +++ b/plans/rolling-amd64/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-image-factory-rolling-amd64" @@ -8,13 +8,9 @@ fail-on-cache-error = true metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=amd64"] command = [ "docker", "buildx", "bake", - "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - "test-release-amd64", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-runtime-deps", "base-slim-main", "base-slim-stable", + "base-web-only-main", "base-web-only-stable", + "base-release-main", "base-release-stable", "test-release", ] [adapters.ccache] diff --git a/plans/rolling-arm64/.boringcache.toml b/plans/rolling-arm64/.boringcache.toml index af239ab..62e2b43 100644 --- a/plans/rolling-arm64/.boringcache.toml +++ b/plans/rolling-arm64/.boringcache.toml @@ -1,4 +1,4 @@ -workspace = "boringcache/benchmark-discourse" +workspace = "discourse/benchmark-discourse" [adapters.docker] tag = "discourse-image-factory-rolling-arm64" @@ -8,13 +8,9 @@ fail-on-cache-error = true metadata-hints = ["benchmark=discourse", "upstream-job=base", "lane=rolling", "arch=arm64"] command = [ "docker", "buildx", "bake", - "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - "test-release-arm64", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-runtime-deps", "base-slim-main", "base-slim-stable", + "base-web-only-main", "base-web-only-stable", + "base-release-main", "base-release-stable", "test-release", ] [adapters.ccache] diff --git a/scripts/benchmark-report.py b/scripts/benchmark-report.py index 6f3e3fe..c1b59b1 100755 --- a/scripts/benchmark-report.py +++ b/scripts/benchmark-report.py @@ -21,6 +21,7 @@ "actions-cache": "GitHub Actions", "boringcache": "BoringCache", "boringcache-bundler": "BoringCache + Bundler mount", + "boringcache-bundler-ccache": "BoringCache + Bundler mount + ccache", "boringcache-ccache": "BoringCache + ccache", "boringcache-mountcache": "BoringCache mountcache", "boringcache-native": "BoringCache native", diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index 7be87e2..ab53903 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -23,23 +23,25 @@ def replace_once(source: str, before: str, after: str, description: str) -> str: return source.replace(before, after, 1) -def add_bundler_cache(source: str) -> str: - return replace_once( - source, - "RUN cd /var/www/discourse &&\\\n" - " sudo -u discourse bundle config --local deployment true &&\\\n" - " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" - " sudo -u discourse bundle config --local without test development &&\\\n" - " sudo -u discourse bundle install --jobs $(nproc --ignore=1) &&\\\n", - "RUN --mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH},target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000 \\\n" - " cd /var/www/discourse &&\\\n" - " sudo -u discourse bundle config --local deployment true &&\\\n" - " sudo -u discourse bundle config --local path ./vendor/bundle &&\\\n" - " sudo -u discourse bundle config --local without test development &&\\\n" - " sudo -u discourse env BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install --jobs $(nproc --ignore=1) &&\\\n" - " sudo -u discourse du -sh /home/discourse/.bundle/cache &&\\\n", - "Bundler install command", - ) +def verify_bundler_cache(source: str) -> str: + fragments = { + "Bundler cache mount": ( + "--mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH}," + "target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000" + ), + "pnpm cache mount": "target=/home/discourse/.local/share/pnpm/store,sharing=locked,uid=1000,gid=1000", + "Bundler global cache": ( + "BUNDLE_GLOBAL_GEM_CACHE=true " + "BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + ), + "Bundler cache before-install diagnostic": "Bundler cache before install:", + "Bundler cache after-install diagnostic": "Bundler cache after install:", + } + for description, fragment in fragments.items(): + matches = source.count(fragment) + if matches != 1: + raise ProfileMismatch(f"expected one {description}, found {matches}") + return source def add_ccache(source: str) -> str: @@ -73,11 +75,11 @@ def render(source: str, profile: str) -> str: if profile == "baseline": return source if profile == "bundler": - return add_bundler_cache(source) + return verify_bundler_cache(source) if profile == "ccache": return add_ccache(source) if profile == "bundler-ccache": - return add_ccache(add_bundler_cache(source)) + return add_ccache(verify_bundler_cache(source)) raise ProfileMismatch(f"unknown cache profile: {profile}") diff --git a/scripts/run-actions-cache-plan.py b/scripts/run-actions-cache-plan.py index c9d2156..7949c84 100644 --- a/scripts/run-actions-cache-plan.py +++ b/scripts/run-actions-cache-plan.py @@ -17,16 +17,21 @@ def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("plan") parser.add_argument("--cache-scope", required=True) + parser.add_argument("--read-only", action="store_true") parser.add_argument("--dry-run", action="store_true") return parser.parse_args() -def load_command(plan_dir: Path) -> list[str]: +def load_plan(plan_dir: Path) -> tuple[list[str], list[str]]: with (plan_dir / ".boringcache.toml").open("rb") as config_file: command = tomllib.load(config_file)["adapters"]["docker"]["command"] if command[:3] != ["docker", "buildx", "bake"]: raise SystemExit(f"{plan_dir} is not a Docker Bake plan") - return command + targets = [argument for argument in command[3:] if not argument.startswith("-")] + if not targets: + raise SystemExit(f"{plan_dir} does not declare any Bake targets") + options = [argument for argument in command[3:] if argument.startswith("-")] + return targets, options def main() -> int: @@ -34,17 +39,30 @@ def main() -> int: plan_dir = (ROOT / "plans" / args.plan).resolve() if plan_dir.parent != (ROOT / "plans").resolve(): raise SystemExit(f"Unknown plan: {args.plan}") - command = load_command(plan_dir) - - command.extend( - ( - f"--set=*.cache-from=type=gha,scope={args.cache_scope}", - f"--set=*.cache-to=type=gha,scope={args.cache_scope},mode=max", - ) - ) - if args.dry_run: - command.append("--print") - return subprocess.run(command, cwd=UPSTREAM_IMAGE, check=False).returncode + targets, plan_options = load_plan(plan_dir) + for target in targets: + target_scope = f"{args.cache_scope}-{target}" + command = [ + "docker", + "buildx", + "bake", + target, + *(plan_options or ["--load"]), + f"--set={target}.cache-from=type=gha,scope={target_scope}", + ] + if target == "test-release": + command.append( + "--set=base-slim-main.cache-from=" + f"type=gha,scope={args.cache_scope}-base-slim-main" + ) + if not args.read_only: + command.append(f"--set={target}.cache-to=type=gha,scope={target_scope},mode=max") + if args.dry_run: + command.append("--print") + result = subprocess.run(command, cwd=UPSTREAM_IMAGE, check=False) + if result.returncode != 0: + return result.returncode + return 0 if __name__ == "__main__": diff --git a/scripts/run-boringcache-plan.py b/scripts/run-boringcache-plan.py new file mode 100644 index 0000000..3513e35 --- /dev/null +++ b/scripts/run-boringcache-plan.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Execute Discourse's upstream Bake targets individually through BoringCache.""" + +from __future__ import annotations + +import argparse +import subprocess +import tomllib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +UPSTREAM_IMAGE = ROOT / "docker-upstream/image" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("plan") + parser.add_argument("--read-only", action="store_true") + parser.add_argument("--mount-cache", action="store_true") + parser.add_argument("--tool-cache-ccache", action="store_true") + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args() + + +def load_plan(plan_dir: Path) -> tuple[list[str], list[str]]: + with (plan_dir / ".boringcache.toml").open("rb") as config_file: + command = tomllib.load(config_file)["adapters"]["docker"]["command"] + if command[:3] != ["docker", "buildx", "bake"]: + raise SystemExit(f"{plan_dir} is not a Docker Bake plan") + targets = [argument for argument in command[3:] if not argument.startswith("-")] + if not targets: + raise SystemExit(f"{plan_dir} does not declare any Bake targets") + options = [argument for argument in command[3:] if argument.startswith("-")] + return targets, options + + +def main() -> int: + args = parse_args() + plan_dir = (ROOT / "plans" / args.plan).resolve() + if plan_dir.parent != (ROOT / "plans").resolve(): + raise SystemExit(f"Unknown plan: {args.plan}") + + boringcache_args: list[str] = [] + if args.read_only: + boringcache_args.append("--read-only") + if args.mount_cache: + boringcache_args.append("--mount-cache") + if args.tool_cache_ccache: + boringcache_args.extend(("--tool-cache", "ccache")) + if args.dry_run: + boringcache_args.append("--dry-run") + + targets, plan_options = load_plan(plan_dir) + for target in targets: + command = [ + "boringcache", + "docker", + *boringcache_args, + "--", + "docker", + "buildx", + "bake", + target, + *(plan_options or ["--load"]), + ] + result = subprocess.run(command, cwd=UPSTREAM_IMAGE, check=False) + if result.returncode != 0: + return result.returncode + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 91fa5e3..5ab4e11 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -19,8 +19,6 @@ "rolling-amd64": ("rolling", "amd64"), "rolling-arm64": ("rolling", "arm64"), } - - class RecipeMismatch(RuntimeError): pass @@ -30,18 +28,19 @@ def require(condition: bool, message: str) -> None: raise RecipeMismatch(message) -def expected_command(arch: str) -> list[str]: +def expected_command() -> list[str]: return [ "docker", "buildx", "bake", "base-runtime-deps", - "base-slim", - "base-web-only", - "base-release", - f"test-release-{arch}", - "--set=base-runtime-deps-*.no-cache=true", - "--load", + "base-slim-main", + "base-slim-stable", + "base-web-only-main", + "base-web-only-stable", + "base-release-main", + "base-release-stable", + "test-release", ] @@ -51,7 +50,7 @@ def expected_runtime_deps_command() -> list[str]: "buildx", "bake", "base-runtime-deps", - "--set=base-runtime-deps-*.no-cache=true", + "--set=base-runtime-deps.no-cache=true", "--load", ] @@ -61,11 +60,11 @@ def main() -> int: workflow = UPSTREAM_WORKFLOW.read_text() for fragment in ( "arch: [amd64, arm64]", - "docker buildx bake base-runtime-deps --no-cache --load", - "docker buildx bake base-slim --load", - "docker buildx bake base-web-only --load", - "docker buildx bake base-release --load", - "docker buildx bake test-release-${{ matrix.arch }} --load", + "docker buildx bake base-runtime-deps -f docker-bake.hcl -f docker-bake.cache.hcl --load", + "docker buildx bake base-slim-main -f docker-bake.hcl -f docker-bake.cache.hcl", + "docker buildx bake base-web-only-main -f docker-bake.hcl -f docker-bake.cache.hcl --load", + "docker buildx bake base-release-main -f docker-bake.hcl -f docker-bake.cache.hcl --load", + "docker buildx bake test-release -f docker-bake.hcl -f docker-bake.cache.hcl --load", ): require(fragment in workflow, f"upstream base job changed: {fragment}") @@ -75,7 +74,7 @@ def main() -> int: adapters = tomllib.load(config_file)["adapters"] adapter = adapters["docker"] ccache = adapters["ccache"] - require(adapter["command"] == expected_command(arch), f"{name} command drifted") + require(adapter["command"] == expected_command(), f"{name} command drifted") require(adapter["tag"] == f"discourse-image-factory-{lane}-{arch}", f"{name} tag drifted") require(adapter["no-platform"] is True, f"{name} must keep one explicit cache cohort") require(adapter["no-git"] is True, f"{name} must use its declared lane and architecture") @@ -104,7 +103,7 @@ def main() -> int: root_adapters = tomllib.load(config_file)["adapters"] root_adapter = root_adapters["docker"] require( - root_adapter["command"] == expected_command("amd64"), + root_adapter["command"] == expected_command(), "root default plan drifted", ) require( @@ -123,6 +122,7 @@ def main() -> int: dockerfile = UPSTREAM_DOCKERFILE.read_text() require(render(dockerfile, "baseline") == dockerfile, "baseline cache profile must leave upstream unchanged") bundler_profile = render(dockerfile, "bundler") + require(bundler_profile == dockerfile, "Bundler profile must use the fork's committed cache mount") require( "--mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH},target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000" in bundler_profile, @@ -133,6 +133,25 @@ def main() -> int: in bundler_profile, "Bundler cache profile does not enable the global gem cache", ) + require( + bundler_profile.count("target=/home/discourse/.local/share/pnpm/store") == 1, + "base image must cache the upstream pnpm install", + ) + test_dockerfile = (ROOT / "docker-upstream/image/discourse_test/Dockerfile").read_text() + require( + "--mount=type=cache,id=discourse-bundler-main,target=/home/discourse/.bundle/cache" + in test_dockerfile, + "test image must share the main Bundler cache mount", + ) + require( + "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + in test_dockerfile, + "test image must use Bundler's mounted global cache", + ) + require( + "target=/home/discourse/.local/share/pnpm/store" in test_dockerfile, + "test image must cache the upstream pnpm install", + ) ccache_profile = render(dockerfile, "ccache") require(" ccache \\\n" in ccache_profile, "ccache profile does not install ccache") require("ARG CCACHE_VERSION=4.13.6" in ccache_profile, "ccache profile must use the CLI-tested version") @@ -143,7 +162,10 @@ def main() -> int: "ccache profile does not select Debian's compiler wrappers", ) require("CCACHE_REMOTE_STORAGE" not in ccache_profile, "the Dockerfile must not own BoringCache's ccache endpoint") - require("BUNDLE_GLOBAL_GEM_CACHE" not in ccache_profile, "ccache profile must stay isolated from Bundler") + require( + ccache_profile.count("BUNDLE_GLOBAL_GEM_CACHE=true") == 1, + "ccache control must retain the fork's native Bundler mount without enabling remote offload", + ) combined_profile = render(dockerfile, "bundler-ccache") require( "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" @@ -177,21 +199,28 @@ def main() -> int: '-e COMMIT_HASH="$DISCOURSE_SOURCE_SHA"' in action, "upstream image specs must test the reported Discourse source", ) - require(action.count("boringcache docker") == 1, "BoringCache path must use one CLI-owned Docker lifecycle") + boringcache_runner = (ROOT / "scripts/run-boringcache-plan.py").read_text() + require( + boringcache_runner.count('"boringcache",') == 1, + "BoringCache target runner must use one CLI-owned Docker lifecycle per target", + ) require("https://install.boringcache.com/install.sh" in action, "BoringCache path must use the public installer") require("CLI_VERSION: ${{ inputs.cli_version }}" in action, "CLI canary input is not forwarded") require( "BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }}" in action, "BuildKit canary input is not forwarded", ) - require("working-directory: docker-upstream/image" in action, "CLI must run the upstream Bake plan") + require("run-boringcache-plan.py" in action, "CLI must run the upstream Bake targets") require('args+=(--read-only)' in action, "warm builds must restore without publishing") require('args+=(--mount-cache)' in action, "Bundler experiment must enable mount-cache offload") require( '[[ "$CACHE_PROFILE" == "bundler" || "$CACHE_PROFILE" == "bundler-ccache" ]]' in action, "mount-cache offload must stay scoped to Bundler profiles", ) - require('args+=(--tool-cache ccache)' in action, "ccache experiment must use the released Docker tool-cache surface") + require( + 'boringcache_args.extend(("--tool-cache", "ccache"))' in boringcache_runner, + "ccache experiment must use the released Docker tool-cache surface", + ) require( '[[ "$CACHE_PROFILE" == "ccache" || "$CACHE_PROFILE" == "bundler-ccache" ]]' in action, "Docker ccache must stay scoped to ccache profiles", @@ -214,6 +243,10 @@ def main() -> int: require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") require("publish_images" not in action + rolling + fresh, "benchmark image publication returned") require("git -C upstream rev-parse HEAD" in action, "rolling cache does not use the pinned Discourse source") + require( + action.count('DISCOURSE_REF: ${{ inputs.discourse_ref }}') == 3, + "checkout and both cache providers must receive the exact Discourse source", + ) require("git ls-remote" not in action, "benchmark execution must not race a moving upstream branch") require( "a68d4b8707fd653697e8b6b27b336d093dbed5e4" in action, @@ -236,9 +269,11 @@ def main() -> int: action.index("Prepare clean Discourse sources") < action.index("Prepare the benchmark cache profile"), "source cleanup would erase the benchmark cache profile", ) - require("bundler_cache_experiment" in rolling, "Bundler workflow-dispatch lane is missing") - require("cache_profile: bundler" in rolling, "Bundler lane does not select the Bundler profile") - require("cache_profile: bundler-ccache" in rolling, "combined lane does not select both cache profiles") + require("bundler_cache_experiment" in rolling, "full cache-stack workflow-dispatch lane is missing") + require( + rolling.count("cache_profile: bundler-ccache") == 4, + "seed and roll must both select mount cache plus ccache on amd64 and arm64", + ) require( "report_strategy: boringcache-bundler-ccache" in rolling, "combined lane does not retain its own result", @@ -265,6 +300,56 @@ def main() -> int: ) gitmodules = (ROOT / ".gitmodules").read_text() require("branch = tests-passed" in gitmodules, "source sync must follow Discourse tests-passed") + require( + "url = https://github.com/boringcache/discourse_docker.git" in gitmodules, + "Docker recipes must come from the controlled BoringCache fork", + ) + require( + "branch = agent/benchmark-cache-controls" in gitmodules, + "Docker recipe sync must follow the benchmark controls branch", + ) + require("ARG DISCOURSE_REF" in dockerfile, "forked Dockerfile must accept an immutable Discourse ref") + require( + "git -C /var/www/discourse checkout --detach FETCH_HEAD" in dockerfile, + "forked Dockerfile must build the requested Discourse commit", + ) + bake = (ROOT / "docker-upstream/image/docker-bake.hcl").read_text() + require('variable "DISCOURSE_REF"' in bake, "Bake must expose the immutable Discourse ref") + require('variable "DATESTAMP"' in bake, "Bake must retain PR #1088's daily native-cache boundary") + require("ARG DATESTAMP" in dockerfile, "Dockerfile must retain PR #1088's daily cache boundary") + require( + (ROOT / "docker-upstream/image/docker-bake.cache.hcl").exists(), + "fork must include PR #1088's per-target cache-read composition", + ) + require( + (ROOT / "docker-upstream/image/docker-bake.cache-write.hcl").exists(), + "fork must include PR #1088's per-target cache-write composition", + ) + require( + '"DISCOURSE_REF" = branch == "main" ? DISCOURSE_REF : ""' in bake, + "Bake must pin main targets without changing stable targets", + ) + + seeded = rolling + require("eedf0ac2344c37d66a2c9ab05dc8a83bf3efd9bb" in seeded, "seeded workflow lost its older ref") + require("763655f6faf47b088afee1a59e2d97cec5886c97" in seeded, "seeded workflow lost its rolling ref") + require( + 'git -C upstream diff --quiet "$SEED_REF" "$ROLLING_REF" -- Gemfile Gemfile.lock package.json pnpm-lock.yaml' + in seeded, + "seeded workflow must reject dependency-changing roll-forward refs", + ) + require("cache_lane: fresh" in seeded, "seed and roll must share one run-scoped cache cohort") + require("phase: warm" in seeded, "roll-forward jobs must restore read-only on fresh runners") + require("BORINGCACHE_SAVE_TOKEN: \"\"" in seeded, "roll-forward jobs must not publish into the seed") + require("ubuntu-24.04-arm" in seeded, "cache stack must cover upstream's native arm64 runner") + require( + seeded.count("strategy: actions-cache") >= 2, + "seed and roll must retain the GitHub Actions comparison", + ) + require( + seeded.count("report_strategy: boringcache-bundler-ccache") == 4, + "seed and roll must retain the full BoringCache cache stack on amd64 and arm64", + ) sync = (ROOT / ".github/workflows/sync.yml").read_text() require( "git -C upstream fetch --depth=1 origin refs/heads/tests-passed" in sync, @@ -282,6 +367,12 @@ def main() -> int: require('cwd=UPSTREAM_IMAGE' in runner, "plans must run from upstream's image directory") require('"boringcache"' not in runner, "comparison helper must not own the BoringCache lifecycle") require("type=gha" in runner, "comparison helper must retain GitHub Actions Cache") + require("for target in targets" in runner, "GHA must build PR #1088 targets individually") + require("if not args.read_only" in runner, "GHA roll-forward must not republish cache") + require( + "for target in targets" in boringcache_runner, + "BoringCache must build PR #1088 targets individually", + ) selector = (ROOT / "scripts/select-boringcache-plan.py").read_text() require('destination = UPSTREAM_IMAGE / ".boringcache.toml"' in selector, "selected plan must reach the Action working directory") require("destination.write_text(updated)" in selector, "selected plan must be materialized before the Action runs") From afa9394f1cf6307d697587f6672f11034c9c8768 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 10:41:02 +0100 Subject: [PATCH 13/17] Pin the corrected Discourse cache fork --- .github/workflows/discourse-image-factory.yml | 2 +- docker-upstream | 2 +- scripts/verify-upstream-recipe.py | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index 467139f..688f2ab 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -12,7 +12,7 @@ on: discourse_ref: {description: "Optional full Discourse commit SHA for a historical run.", required: false, type: string, default: ""} seed_ref: {description: "Older Discourse commit used to seed the cache experiment.", required: true, type: string, default: "eedf0ac2344c37d66a2c9ab05dc8a83bf3efd9bb"} rolling_ref: {description: "Newer Discourse commit with unchanged dependency locks.", required: true, type: string, default: "763655f6faf47b088afee1a59e2d97cec5886c97"} - docker_ref: {description: "Full BoringCache discourse_docker cache-wiring commit SHA.", required: true, type: string, default: "5cc4f095b4efdb7bd105577e021390f6c2dfa147"} + docker_ref: {description: "Full BoringCache discourse_docker cache-wiring commit SHA.", required: true, type: string, default: "f551d332451ed9794ce27a112dbbca3025a6eafc"} push: branches: [main] paths: [".boringcache.toml", ".gitmodules", "plans/**", "scripts/**", ".github/actions/discourse-image-factory/**", "upstream", "docker-upstream"] diff --git a/docker-upstream b/docker-upstream index 5cc4f09..f551d33 160000 --- a/docker-upstream +++ b/docker-upstream @@ -1 +1 @@ -Subproject commit 5cc4f095b4efdb7bd105577e021390f6c2dfa147 +Subproject commit f551d332451ed9794ce27a112dbbca3025a6eafc diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 5ab4e11..5c48eb2 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -120,6 +120,11 @@ def main() -> int: fresh = (ROOT / ".github/workflows/discourse-image-factory-fresh.yml").read_text() require("discourse-dev.Dockerfile" not in action + rolling + fresh, "custom Dockerfile returned") dockerfile = UPSTREAM_DOCKERFILE.read_text() + require("COPY install-redis" not in dockerfile, "obsolete PR #1088 Redis installer copy returned") + require( + "apt-get -y update && DEBIAN_FRONTEND=noninteractive apt-get -y install redis" in dockerfile, + "current upstream Redis package installation is missing", + ) require(render(dockerfile, "baseline") == dockerfile, "baseline cache profile must leave upstream unchanged") bundler_profile = render(dockerfile, "bundler") require(bundler_profile == dockerfile, "Bundler profile must use the fork's committed cache mount") From fb194f4eb94f97bcfb787b3929ce886f22630ac0 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 10:52:37 +0100 Subject: [PATCH 14/17] Use the reviewed BoringCache Action --- .../discourse-image-factory/action.yml | 61 ++++++++----------- .github/workflows/discourse-image-factory.yml | 2 +- scripts/verify-upstream-recipe.py | 20 ++++-- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index e767303..6a9150f 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -83,7 +83,7 @@ runs: CACHE_PROFILE: ${{ inputs.cache_profile }} run: python3 ./scripts/prepare_discourse_cache_profile.py "${CACHE_PROFILE:-baseline}" - - name: Stage the CLI-compatible ccache binary + - name: Prepare ccache 4.13.6 for the Docker build context if: inputs.cache_profile == 'ccache' || inputs.cache_profile == 'bundler-ccache' shell: bash run: ./scripts/stage-ccache-binary.sh "${{ inputs.arch }}" docker-upstream/image/base/ccache @@ -92,34 +92,6 @@ runs: if: inputs.strategy == 'actions-cache' uses: docker/setup-buildx-action@v4 - - name: Install the released BoringCache CLI - if: inputs.strategy == 'boringcache' - shell: bash - env: - CLI_VERSION: ${{ inputs.cli_version }} - run: | - set -euo pipefail - installer="$(mktemp)" - trap 'rm -f "$installer"' EXIT - curl -fsSL --retry 8 --retry-all-errors --connect-timeout 30 --max-time 180 \ - -H "Cache-Control: no-cache" -H "Pragma: no-cache" \ - https://install.boringcache.com/install.sh -o "$installer" - if [[ -n "$CLI_VERSION" ]]; then - export BORINGCACHE_INSTALLER_SOURCE_ONLY=1 - source "$installer" - download_file() { - local url="$1" - local output="$2" - curl -fsSL --retry 8 --retry-all-errors --connect-timeout 30 --max-time 180 \ - "$url" -o "$output" - } - install_binary linux "$(detect_arch)" "$CLI_VERSION" - else - bash "$installer" - fi - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" - boringcache --version - - name: Select the cache cohort id: scope shell: bash @@ -151,11 +123,6 @@ runs: echo "discourse_source_datestamp=$(git -C upstream show -s --format=%cd --date=format:%Y%m%d HEAD)" >> "$GITHUB_OUTPUT" echo "source_sha=$(git -C docker-upstream rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Start image-factory timing - id: build_timer - shell: bash - run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" - - name: Select the BoringCache image-factory plan if: inputs.strategy == 'boringcache' shell: bash @@ -171,6 +138,32 @@ runs: --cache-tag "$CACHE_SCOPE" \ --ccache-tag "${CACHE_SCOPE}-compiler" + # boringcache/one owns the reviewed CLI installation. Discourse still runs + # its upstream target-by-target Bake commands through the shared CLI plan. + - name: Set up BoringCache for the upstream Bake graph + if: inputs.strategy == 'boringcache' + uses: boringcache/one@e24257b122813ad11d53b9ed024b474ca4946ad2 # v1.19.1 + with: + cli-version: ${{ inputs.cli_version }} + trust-policy: restore + setup: none + mode: docker + working-directory: . + docker-command: setup + managed-buildkit-image: ${{ inputs.buildkit_image }} + proxy-port: "22244" + diagnostics: summary + fail-on-cache-error: true + metadata-hints: benchmark=discourse,purpose=bake-cli-setup + env: + BORINGCACHE_RESTORE_TOKEN: ${{ env.BORINGCACHE_RESTORE_TOKEN }} + BORINGCACHE_SAVE_TOKEN: "" + + - name: Start image-factory timing + id: build_timer + shell: bash + run: echo "started_at=$(date +%s)" >> "$GITHUB_OUTPUT" + - name: Build with the GitHub Actions cache if: inputs.strategy == 'actions-cache' shell: bash diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index 688f2ab..f5e6424 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -4,7 +4,7 @@ run-name: ${{ format('Discourse image factory | {0}', github.event_name == 'push on: workflow_dispatch: inputs: - cli_version: {description: "Exact BoringCache CLI release.", required: true, type: string, default: "1.19.1"} + cli_version: {description: "Exact BoringCache CLI release.", required: true, type: string, default: "v1.19.1"} buildkit_image: {description: "Optional managed BuildKit image canary.", required: false, type: string, default: ""} benchmark_id_suffix: {description: "Optional suffix for an isolated comparison.", required: false, type: string, default: ""} bundler_cache_experiment: {description: "Seed and roll the full upstream graph across GHA, BoringCache, and BoringCache with mount/tool caches.", required: false, type: boolean, default: false} diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 5c48eb2..fb50f8e 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -209,8 +209,14 @@ def main() -> int: boringcache_runner.count('"boringcache",') == 1, "BoringCache target runner must use one CLI-owned Docker lifecycle per target", ) - require("https://install.boringcache.com/install.sh" in action, "BoringCache path must use the public installer") - require("CLI_VERSION: ${{ inputs.cli_version }}" in action, "CLI canary input is not forwarded") + require( + "uses: boringcache/one@e24257b122813ad11d53b9ed024b474ca4946ad2 # v1.19.1" in action, + "BoringCache setup must use the reviewed v1.19.1 Action distribution", + ) + require("https://install.boringcache.com/install.sh" not in action, "hand-written CLI installer returned") + require("docker-command: setup" in action, "the Action must install the CLI without replacing upstream Bake") + require("trust-policy: restore" in action, "the setup-only Action invocation must not publish an empty cache") + require("cli-version: ${{ inputs.cli_version }}" in action, "CLI canary input is not forwarded") require( "BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }}" in action, "BuildKit canary input is not forwarded", @@ -236,13 +242,19 @@ def main() -> int: ) require("continue-on-error: true" in action, "benchmark evidence must survive an upstream spec timeout") require( - "Stage the CLI-compatible ccache binary" in action, + "Prepare ccache 4.13.6 for the Docker build context" in action, "ccache profile must stage its compatible binary on the native runner", ) require( - action.index("Stage the CLI-compatible ccache binary") < action.index("Start image-factory timing"), + action.index("Prepare ccache 4.13.6 for the Docker build context") + < action.index("Start image-factory timing"), "ccache tool setup must stay outside build timing", ) + require( + action.index("Set up BoringCache for the upstream Bake graph") + < action.index("Start image-factory timing"), + "BoringCache Action setup must stay outside build timing", + ) require("Smoke test the runtime-deps image" in action, "focused ccache builds must smoke test their output") require("if: inputs.run_specs == 'true'" in action, "focused builds must be able to skip unrelated image specs") require("run-actions-cache-plan.py" in action, "GitHub Actions comparison path is missing") From a4dec9b58d04e0486d17a96227c79e10a198bdcd Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 11:00:56 +0100 Subject: [PATCH 15/17] Scope Action setup to the selected Bake plan --- .github/actions/discourse-image-factory/action.yml | 2 +- scripts/verify-upstream-recipe.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 6a9150f..7862dbc 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -148,7 +148,7 @@ runs: trust-policy: restore setup: none mode: docker - working-directory: . + working-directory: docker-upstream/image docker-command: setup managed-buildkit-image: ${{ inputs.buildkit_image }} proxy-port: "22244" diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index fb50f8e..472e60a 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -216,6 +216,10 @@ def main() -> int: require("https://install.boringcache.com/install.sh" not in action, "hand-written CLI installer returned") require("docker-command: setup" in action, "the Action must install the CLI without replacing upstream Bake") require("trust-policy: restore" in action, "the setup-only Action invocation must not publish an empty cache") + require( + "working-directory: docker-upstream/image" in action, + "the Action setup must resolve the materialized per-lane Bake plan", + ) require("cli-version: ${{ inputs.cli_version }}" in action, "CLI canary input is not forwarded") require( "BORINGCACHE_MANAGED_BUILDKIT_IMAGE: ${{ inputs.buildkit_image }}" in action, From 0c693b899b928de93a29bd301e6f115bff4234dc Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 11:20:38 +0100 Subject: [PATCH 16/17] Cache complete Discourse package state --- .github/workflows/discourse-image-factory.yml | 2 +- docker-upstream | 2 +- scripts/prepare_discourse_cache_profile.py | 29 ++++++++---- scripts/verify-upstream-recipe.py | 47 ++++++++++++------- 4 files changed, 52 insertions(+), 28 deletions(-) diff --git a/.github/workflows/discourse-image-factory.yml b/.github/workflows/discourse-image-factory.yml index f5e6424..3732118 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -12,7 +12,7 @@ on: discourse_ref: {description: "Optional full Discourse commit SHA for a historical run.", required: false, type: string, default: ""} seed_ref: {description: "Older Discourse commit used to seed the cache experiment.", required: true, type: string, default: "eedf0ac2344c37d66a2c9ab05dc8a83bf3efd9bb"} rolling_ref: {description: "Newer Discourse commit with unchanged dependency locks.", required: true, type: string, default: "763655f6faf47b088afee1a59e2d97cec5886c97"} - docker_ref: {description: "Full BoringCache discourse_docker cache-wiring commit SHA.", required: true, type: string, default: "f551d332451ed9794ce27a112dbbca3025a6eafc"} + docker_ref: {description: "Full BoringCache discourse_docker cache-wiring commit SHA.", required: true, type: string, default: "f0152c829ccb9eb1e9cab6f06c5658d3d6912fbf"} push: branches: [main] paths: [".boringcache.toml", ".gitmodules", "plans/**", "scripts/**", ".github/actions/discourse-image-factory/**", "upstream", "docker-upstream"] diff --git a/docker-upstream b/docker-upstream index f551d33..f0152c8 160000 --- a/docker-upstream +++ b/docker-upstream @@ -1 +1 @@ -Subproject commit f551d332451ed9794ce27a112dbbca3025a6eafc +Subproject commit f0152c829ccb9eb1e9cab6f06c5658d3d6912fbf diff --git a/scripts/prepare_discourse_cache_profile.py b/scripts/prepare_discourse_cache_profile.py index ab53903..8d9fd4f 100644 --- a/scripts/prepare_discourse_cache_profile.py +++ b/scripts/prepare_discourse_cache_profile.py @@ -25,17 +25,28 @@ def replace_once(source: str, before: str, after: str, description: str) -> str: def verify_bundler_cache(source: str) -> str: fragments = { - "Bundler cache mount": ( - "--mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH}," - "target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000" + "installed bundle cache mount": ( + "--mount=type=cache,id=discourse-bundle-${DISCOURSE_BRANCH}," + "target=/home/discourse/.cache/bundle,sharing=locked,uid=1000,gid=1000" ), - "pnpm cache mount": "target=/home/discourse/.local/share/pnpm/store,sharing=locked,uid=1000,gid=1000", - "Bundler global cache": ( - "BUNDLE_GLOBAL_GEM_CACHE=true " - "BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + "pnpm home ownership": ( + "install -dm 0755 -o discourse -g discourse /home/discourse/.local/share/pnpm" ), - "Bundler cache before-install diagnostic": "Bundler cache before install:", - "Bundler cache after-install diagnostic": "Bundler cache after install:", + "pnpm home cache mount": ( + "target=/home/discourse/.local/share/pnpm,sharing=locked,uid=1000,gid=1000" + ), + "installed bundle path": "bundle config --local path /home/discourse/.cache/bundle", + "installed bundle materialization": ( + "cp -a /home/discourse/.cache/bundle/. /var/www/discourse/vendor/bundle/" + ), + "final image bundle path": "bundle config --local path ./vendor/bundle", + "final image bundle repair install": ( + "sudo -u discourse bundle install --jobs $(nproc --ignore=1) &&\\\n" + " sudo -u discourse bundle check" + ), + "final image bundle check": "sudo -u discourse bundle check", + "bundle cache before-install diagnostic": "Installed bundle cache before install:", + "bundle cache after-install diagnostic": "Installed bundle cache after install:", } for description, fragment in fragments.items(): matches = source.count(fragment) diff --git a/scripts/verify-upstream-recipe.py b/scripts/verify-upstream-recipe.py index 472e60a..ffd8bb3 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -129,33 +129,46 @@ def main() -> int: bundler_profile = render(dockerfile, "bundler") require(bundler_profile == dockerfile, "Bundler profile must use the fork's committed cache mount") require( - "--mount=type=cache,id=discourse-bundler-${DISCOURSE_BRANCH},target=/home/discourse/.bundle/cache,sharing=locked,uid=1000,gid=1000" + "--mount=type=cache,id=discourse-bundle-${DISCOURSE_BRANCH},target=/home/discourse/.cache/bundle,sharing=locked,uid=1000,gid=1000" in bundler_profile, - "Bundler cache profile does not mount Bundler's user cache", + "Bundler cache profile does not mount the complete installed bundle", ) require( - "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" - in bundler_profile, - "Bundler cache profile does not enable the global gem cache", + "cp -a /home/discourse/.cache/bundle/. /var/www/discourse/vendor/bundle/" in bundler_profile, + "Bundler cache profile does not materialize the installed bundle into the image", + ) + require( + "sudo -u discourse bundle config --local path ./vendor/bundle" in bundler_profile + and "sudo -u discourse bundle install --jobs $(nproc --ignore=1)" in bundler_profile + and "sudo -u discourse bundle check" in bundler_profile, + "base image must repair and validate the materialized vendor bundle", ) require( - bundler_profile.count("target=/home/discourse/.local/share/pnpm/store") == 1, - "base image must cache the upstream pnpm install", + bundler_profile.count("target=/home/discourse/.local/share/pnpm,sharing=locked,uid=1000,gid=1000") + == 1, + "base image must cache pnpm's complete writable home", ) test_dockerfile = (ROOT / "docker-upstream/image/discourse_test/Dockerfile").read_text() require( - "--mount=type=cache,id=discourse-bundler-main,target=/home/discourse/.bundle/cache" + "--mount=type=cache,id=discourse-bundle-test-main,target=/home/discourse/.cache/bundle" in test_dockerfile, - "test image must share the main Bundler cache mount", + "test image must use a separate complete installed-bundle cache", ) require( - "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + "cp -a /home/discourse/.cache/bundle/. /var/www/discourse/vendor/bundle/" in test_dockerfile + and "sudo -u discourse bundle install --jobs $(nproc --ignore=1)" in test_dockerfile + and "sudo -u discourse bundle check" in test_dockerfile, + "test image must materialize, repair, and validate its cached installed bundle", + ) + require( + "target=/home/discourse/.local/share/pnpm,sharing=locked,uid=1000,gid=1000" in test_dockerfile, - "test image must use Bundler's mounted global cache", + "test image must cache pnpm's complete writable home", ) require( - "target=/home/discourse/.local/share/pnpm/store" in test_dockerfile, - "test image must cache the upstream pnpm install", + "install -dm 0755 -o discourse -g discourse /home/discourse/.local/share/pnpm" + in test_dockerfile, + "test image must make pnpm's writable parent owned by discourse", ) ccache_profile = render(dockerfile, "ccache") require(" ccache \\\n" in ccache_profile, "ccache profile does not install ccache") @@ -168,14 +181,14 @@ def main() -> int: ) require("CCACHE_REMOTE_STORAGE" not in ccache_profile, "the Dockerfile must not own BoringCache's ccache endpoint") require( - ccache_profile.count("BUNDLE_GLOBAL_GEM_CACHE=true") == 1, - "ccache control must retain the fork's native Bundler mount without enabling remote offload", + ccache_profile.count("target=/home/discourse/.cache/bundle") == 1, + "ccache control must retain the fork's installed-bundle mount without enabling remote offload", ) combined_profile = render(dockerfile, "bundler-ccache") require( - "BUNDLE_GLOBAL_GEM_CACHE=true BUNDLE_USER_CACHE=/home/discourse/.bundle/cache bundle install" + "cp -a /home/discourse/.cache/bundle/. /var/www/discourse/vendor/bundle/" in combined_profile, - "combined profile does not enable the Bundler mount cache", + "combined profile does not materialize the cached installed bundle", ) require("COPY ccache /usr/bin/ccache" in combined_profile, "combined profile does not stage ccache") require( From ac7e837c5020d2d56157f40fa3412e87e307f095 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Thu, 13 Aug 2026 11:46:26 +0100 Subject: [PATCH 17/17] Run Discourse image specs in CI mode --- .github/actions/discourse-image-factory/action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/discourse-image-factory/action.yml b/.github/actions/discourse-image-factory/action.yml index 7862dbc..3f6bd1f 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -249,6 +249,7 @@ runs: run: | timeout --foreground --signal=TERM --kill-after=1m 45m \ docker run --rm \ + -e CI=true \ -e RUBY_ONLY=1 \ -e USE_TURBO=1 \ -e SKIP_PLUGINS=1 \