diff --git a/.boringcache.toml b/.boringcache.toml index 5e82704..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,11 +8,14 @@ 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] +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 d030d7f..3f6bd1f 100644 --- a/.github/actions/discourse-image-factory/action.yml +++ b/.github/actions/discourse-image-factory/action.yml @@ -6,6 +6,12 @@ inputs: phase: {required: true} 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: ""} cli_version: {required: false, default: ""} buildkit_image: {required: false, default: ""} runs: @@ -18,10 +24,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,36 +77,28 @@ 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: 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 + - name: Set up Buildx for the GitHub Actions comparison 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 -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" - install_binary linux "$(detect_arch)" "$CLI_VERSION" - else - bash "$installer" - fi - echo "${HOME}/.local/bin" >> "$GITHUB_PATH" - - name: Select the cache cohort id: scope shell: bash env: BENCHMARK_ID: ${{ inputs.benchmark_id }} CACHE_LANE: ${{ inputs.cache_lane }} + CACHE_PROFILE: ${{ inputs.cache_profile }} ARCH: ${{ inputs.arch }} run: | set -euo pipefail @@ -70,52 +107,125 @@ 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 + # 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 "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 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" + 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" + + # 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-upstream/image + 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 - run: >- - python3 ./scripts/run-actions-cache-plan.py - "${{ inputs.cache_lane }}-${{ inputs.arch }}" - --cache-scope "${{ steps.scope.outputs.cache_scope }}" + 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 + 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) fi - boringcache docker "${args[@]}" + if [[ "$CACHE_PROFILE" == "bundler" || "$CACHE_PROFILE" == "bundler-ccache" ]]; then + args+=(--mount-cache) + fi + if [[ "$CACHE_PROFILE" == "ccache" || "$CACHE_PROFILE" == "bundler-ccache" ]]; then + args+=(--tool-cache-ccache) + fi + python3 ./scripts/run-boringcache-plan.py "$PLAN" "${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: Smoke test the runtime-deps image + if: inputs.plan_variant == 'runtime-deps' + shell: bash + run: docker run --rm "${BASE_IMAGE}:runtime-deps" /bin/true - name: Show managed BuildKit errors if: ${{ inputs.strategy == 'boringcache' && failure() }} @@ -130,30 +240,36 @@ runs: fi - 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 }}" - - - name: Capture image-factory timing - id: timing + if: inputs.run_specs == 'true' + id: image_specs + continue-on-error: true shell: bash env: - BUILD_STARTED_AT: ${{ steps.build_timer.outputs.started_at }} - run: echo "build_seconds=$(( $(date +%s) - BUILD_STARTED_AT ))" >> "$GITHUB_OUTPUT" + DISCOURSE_SOURCE_SHA: ${{ steps.scope.outputs.discourse_source_sha }} + 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 \ + -e SKIP_LINT=1 \ + -e DISCOURSE_TURBO_RSPEC_RETRY_AND_LOG_FLAKY_TESTS=1 \ + -e COMMIT_HASH="$DISCOURSE_SOURCE_SHA" \ + "${TEST_IMAGE}:release" - name: Write benchmark evidence 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 \ @@ -166,13 +282,20 @@ 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 \ - --source-sha "$SOURCE_SHA" + --workspace discourse/benchmark-discourse \ + --source-repository boringcache/discourse_docker \ + --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 + + - name: Enforce the upstream image specs + 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 e282870..3732118 100644 --- a/.github/workflows/discourse-image-factory.yml +++ b/.github/workflows/discourse-image-factory.yml @@ -4,9 +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: "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} + 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: ""} + 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: "f0152c829ccb9eb1e9cab6f06c5658d3d6912fbf"} push: branches: [main] paths: [".boringcache.toml", ".gitmodules", "plans/**", "scripts/**", ".github/actions/discourse-image-factory/**", "upstream", "docker-upstream"] @@ -24,6 +30,7 @@ env: jobs: commit: name: ${{ matrix.provider }} ${{ matrix.arch }} commit + if: ${{ github.event_name != 'workflow_dispatch' || (!inputs.bundler_cache_experiment && !inputs.ccache_experiment) }} runs-on: ${{ matrix.runner }} timeout-minutes: 180 env: @@ -45,6 +52,128 @@ 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 }} + + 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, 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: publish + 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 }} + + ccache: + name: ${{ matrix.provider }} amd64 runtime-deps 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 }} + plan_variant: runtime-deps + run_specs: "false" + 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 }} @@ -55,7 +184,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 +210,53 @@ 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 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-cache-stack${{ inputs.benchmark_id_suffix }}-* + path: phase-evidence + - 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-cache-stack${{ inputs.benchmark_id_suffix }} + path: benchmark-results/ + if-no-files-found: error + + ccache-report: + name: Discourse runtime-deps ccache 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 runtime-deps ccache 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/.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..f0152c8 160000 --- a/docker-upstream +++ b/docker-upstream @@ -1 +1 @@ -Subproject commit 9c35fe8f6f4eb66d399f756e3bae773292e34db2 +Subproject commit f0152c829ccb9eb1e9cab6f06c5658d3d6912fbf diff --git a/plans/fresh-amd64/.boringcache.toml b/plans/fresh-amd64/.boringcache.toml index 12b6d02..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,11 +8,14 @@ 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] +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..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,11 +8,14 @@ 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] +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-runtime-deps/.boringcache.toml b/plans/rolling-amd64-runtime-deps/.boringcache.toml new file mode 100644 index 0000000..c515084 --- /dev/null +++ b/plans/rolling-amd64-runtime-deps/.boringcache.toml @@ -0,0 +1,21 @@ +workspace = "discourse/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/plans/rolling-amd64/.boringcache.toml b/plans/rolling-amd64/.boringcache.toml index 5e82704..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,11 +8,14 @@ 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] +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..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,11 +8,14 @@ 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] +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 0f2339f..c1b59b1 100755 --- a/scripts/benchmark-report.py +++ b/scripts/benchmark-report.py @@ -20,6 +20,9 @@ PROVIDER_LABELS = { "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", "boringcache-toolcache": "BoringCache toolcache", @@ -71,6 +74,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 +186,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 +341,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 +372,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..8d9fd4f --- /dev/null +++ b/scripts/prepare_discourse_cache_profile.py @@ -0,0 +1,119 @@ +#!/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", "ccache", "bundler-ccache") + + +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 verify_bundler_cache(source: str) -> str: + fragments = { + "installed bundle cache mount": ( + "--mount=type=cache,id=discourse-bundle-${DISCOURSE_BRANCH}," + "target=/home/discourse/.cache/bundle,sharing=locked,uid=1000,gid=1000" + ), + "pnpm home ownership": ( + "install -dm 0755 -o discourse -g discourse /home/discourse/.local/share/pnpm" + ), + "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) + if matches != 1: + raise ProfileMismatch(f"expected one {description}, found {matches}") + return source + + +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" + "# 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 CCACHE_VERSION=4.13.6\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", + "builder stage boundary", + ) + + +def render(source: str, profile: str) -> str: + if profile == "baseline": + return source + if profile == "bundler": + return verify_bundler_cache(source) + if profile == "ccache": + return add_ccache(source) + if profile == "bundler-ccache": + return add_ccache(verify_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/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/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/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 0f09e51..ffd8bb3 100644 --- a/scripts/verify-upstream-recipe.py +++ b/scripts/verify-upstream-recipe.py @@ -7,17 +7,18 @@ 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"), "rolling-amd64": ("rolling", "amd64"), "rolling-arm64": ("rolling", "arm64"), } - - class RecipeMismatch(RuntimeError): pass @@ -27,17 +28,29 @@ 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-main", + "base-slim-stable", + "base-web-only-main", + "base-web-only-stable", + "base-release-main", + "base-release-stable", + "test-release", + ] + + +def expected_runtime_deps_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", + "--set=base-runtime-deps.no-cache=true", "--load", ] @@ -47,74 +60,330 @@ 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}") 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"] - require(adapter["command"] == expected_command(arch), f"{name} command drifted") + adapters = tomllib.load(config_file)["adapters"] + adapter = adapters["docker"] + ccache = adapters["ccache"] + 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") + 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") + + 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_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_adapter["command"] == expected_command(), "root default plan drifted", ) require( 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() 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") + require( + "--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 the complete installed bundle", + ) + require( + "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,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-bundle-test-main,target=/home/discourse/.cache/bundle" + in test_dockerfile, + "test image must use a separate complete installed-bundle cache", + ) + require( + "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 cache pnpm's complete writable home", + ) + require( + "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") + require("ARG CCACHE_VERSION=4.13.6" in ccache_profile, "ccache profile must use the CLI-tested version") + 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, + "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( + 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( + "cp -a /home/discourse/.cache/bundle/. /var/www/discourse/vendor/bundle/" + in combined_profile, + "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( + '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", ) + 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", + ) + require( + '--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( + "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", + ) + 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( + "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( - 'select-boringcache-plan.py "$PLAN" --cache-tag "$CACHE_SCOPE"' in action, - "composite action does not materialize the selected cache cohort", + "working-directory: docker-upstream/image" in action, + "the Action setup must resolve the materialized per-lane Bake plan", ) - require("docker run --rm -e RUBY_ONLY=1" in action, "upstream test invocation is missing") - 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") + 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( + '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", + ) + 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( + "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("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") 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( - '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} > 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", ) + 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, "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", + ) + 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(), "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") + 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, @@ -132,11 +401,18 @@ 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") + 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, 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