diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a61d2694..eeda16f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,13 @@ concurrency: # commit. ci-ok treats skipped jobs as passing. hosted-e2e is not scoped: # it always runs, and its step-level HOSTED_E2E_DISABLED switch is the only # bypass. +# MERGE-QUEUE REUSE: a push to main whose exact SHA already passed this +# workflow in the merge queue (scripts/ci-reuse-merge-group.py, run by +# clippy) only compiles, to refresh the main-only caches, and skips the +# test steps and test-only jobs the queue already ran. Jobs the queue never +# runs (test-release, e2e-full, yarn-berry-full, cargo-vex-matrix-full) and +# hosted-e2e still run. API errors, missing evidence and direct pushes run +# everything. jobs: # Required independently of ci-ok so the merge queue sees a compile/lint # failure immediately. Expensive jobs also depend on this preflight. @@ -71,12 +78,26 @@ jobs: if: github.event.pull_request.draft != true runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-16' }} timeout-minutes: 20 + permissions: + contents: read + actions: read + outputs: + reuse: ${{ steps.merge-queue.outputs.reuse }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + # Main remains the cache writer. Skip duplicate test execution only + # when this workflow passed in the merge queue on the identical SHA. + - name: Check merge-queue validation + id: merge-queue + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ github.token }} + run: python3 scripts/ci-reuse-merge-group.py + - name: Install Rust # rustup is pre-installed on GitHub-hosted runners. `toolchain # install` with no name reads rust-toolchain.toml in the repo root, @@ -141,6 +162,7 @@ jobs: run: node crates/socket-patch-node/npm/scripts/build-addon.mjs - name: Smoke-test addon + if: needs.clippy.outputs.reuse != 'true' run: node --test crates/socket-patch-node/npm/test/smoke.mjs # Check the standalone installer, release scripts, and native installer @@ -287,17 +309,22 @@ jobs: matrix: os: [macos-latest, windows-latest] # Two legs per OS (scripts/ci-test-shard.py): one leg linked ~240 - # test binaries and ran them serially for ~26 min, the merge queue's - # critical path. Shard 1 = unit tests + doctests + a third of the - # integration targets; shard 2 = the rest. + # test binaries and ran them serially for ~26 min. Windows balances + # measured runtime plus linking work (scripts/ci-test-durations.json); + # macOS keeps the count split. Shard 1 also owns unit tests and + # doctests. shard: [1, 2] exclude: # macOS legs run on main, the merge queue and nightly, not per PR push. - os: ${{ github.event_name == 'pull_request' && 'macos-latest' || '' }} + # One compile-only cache writer per OS is enough for an already + # tested SHA. + - shard: ${{ needs.clippy.outputs.reuse == 'true' && 2 || 0 }} runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} timeout-minutes: 50 env: VEXCTL_VERSION: v0.3.0 + CI_TEST_TIMINGS: ${{ matrix.os == 'windows-latest' && 'scripts/ci-test-durations.json' || '' }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -324,7 +351,12 @@ jobs: - name: Build run: cargo build --workspace + - name: Warm the test cache after merge-queue validation + if: needs.clippy.outputs.reuse == 'true' + run: cargo test --locked --workspace --no-run + - name: Install Go (for vexctl) + if: needs.clippy.outputs.reuse != 'true' id: go # The `vex` subcommand emits OpenVEX documents; tests/e2e_vex.rs # validates the output with vexctl when it's on PATH. vexctl is @@ -348,13 +380,14 @@ jobs: # version, the Go toolchain and the runner, so it is cached under # exactly those. Saved from main only, like the cargo cache. id: vexctl-cache - if: runner.os == 'macOS' + if: needs.clippy.outputs.reuse != 'true' && runner.os == 'macOS' uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: ${{ runner.temp }}/vexctl-bin key: vexctl-${{ env.VEXCTL_VERSION }}-go${{ steps.go.outputs.go-version }}-${{ runner.os }}-${{ runner.arch }} - name: Install vexctl + if: needs.clippy.outputs.reuse != 'true' # Linux / Windows: the v0.3.0 release binary, checked against the # sha256 pinned here (from the release's vexctl_checksums.txt). # Compiling it took ~80s on ubuntu and ~180s on windows, the @@ -418,7 +451,8 @@ jobs: - name: Save vexctl (macOS) if: >- - runner.os == 'macOS' + needs.clippy.outputs.reuse != 'true' + && runner.os == 'macOS' && github.ref == 'refs/heads/main' && steps.vexctl-cache.outputs.cache-hit != 'true' uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 @@ -427,6 +461,7 @@ jobs: key: ${{ steps.vexctl-cache.outputs.cache-primary-key }} - name: Run tests + if: needs.clippy.outputs.reuse != 'true' # Default features only: `--all-features` would also RUN the # docker-e2e suites, which soft-skip as "ok" here (no images, and # macOS/Windows have no Docker) — fake greens hiding a broken @@ -539,7 +574,15 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Warm the coverage cache after merge-queue validation + if: needs.clippy.outputs.reuse == 'true' + run: | + cargo llvm-cov show-env --sh > "$RUNNER_TEMP/coverage-env.sh" + source "$RUNNER_TEMP/coverage-env.sh" + cargo test --locked --workspace --no-run + - name: Install Go (for vexctl and the real-go suites) + if: needs.clippy.outputs.reuse != 'true' # This job is the Linux leg of `test` (see there): same Go pin. uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: @@ -547,6 +590,7 @@ jobs: cache: false - name: Install vexctl + if: needs.clippy.outputs.reuse != 'true' # The v0.3.0 Linux release binary, as in `test`'s vexctl step. env: VEXCTL_VERSION: v0.3.0 @@ -563,10 +607,12 @@ jobs: echo "$dir" >> "$GITHUB_PATH" - name: Run tests with coverage + if: needs.clippy.outputs.reuse != 'true' # Two-step pattern: `--no-report` runs instrumented tests and - # collects the raw profile data, then the two `report` calls - # emit lcov + summary from the same data. Avoids re-running - # tests twice. The output filename matches the `*.lcov` + # collects raw profiles, then one `report` exports LCOV. The + # summary is derived from that file (scripts/ci-lcov-summary.py) + # instead of a second `report` that merges the profiles and scans + # every instrumented object again. The output filename matches the `*.lcov` # gitignore pattern so a stray local run can't accidentally # commit a 600 KB report. # @@ -584,10 +630,11 @@ jobs: cargo llvm-cov --workspace --no-fail-fast \ --no-report cargo llvm-cov report --lcov --output-path coverage-host.lcov - cargo llvm-cov report --summary-only | tee coverage-summary.txt + python3 scripts/ci-lcov-summary.py coverage-host.lcov | tee coverage-summary.txt - name: Publish coverage summary to job summary - # Render the per-file table cargo-llvm-cov prints as a fenced + if: needs.clippy.outputs.reuse != 'true' + # Render the per-file LCOV line/function/branch totals as a fenced # block in the GitHub Actions job summary so reviewers don't # need to crack open the artifact for a quick look. run: | @@ -603,6 +650,7 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" - name: Upload host LCOV artifact + if: needs.clippy.outputs.reuse != 'true' uses: ./.github/actions/upload-artifact with: name: coverage-host @@ -613,7 +661,7 @@ jobs: # Dockerfile.base compiles the full-LTO release binary inside Docker, with # no cache. Build it once per run and hand the image to every docker leg. docker-base: - if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease') + if: (github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.head_ref == 'release/v5-prerelease') needs: clippy runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-22.04' || 'depot-ubuntu-22.04-4' }} timeout-minutes: 30 @@ -735,6 +783,12 @@ jobs: file: tests/docker/Dockerfile.${{ matrix.ecosystem }} tags: socket-patch-test-${{ matrix.ecosystem }}:latest load: true + # Warm only what the blocking agent_sbt_ slice below runs. The + # nightly e2e-docker job and sbt-compatibility.yml keep + # Dockerfile.sbt's full defaults. + build-args: | + ${{ matrix.ecosystem == 'sbt' && 'SBT_WARM_VERSIONS=1.2.8 1.13.0' || '' }} + ${{ matrix.ecosystem == 'sbt' && 'SBT_WARM_TOOLS=0' || '' }} - name: Configure docker-e2e coverage hooks # Mount the instrumented socket-patch that the test step's own @@ -899,6 +953,8 @@ jobs: e2e-build: if: github.event.pull_request.draft != true needs: clippy + outputs: + reuse: ${{ needs.clippy.outputs.reuse }} strategy: fail-fast: false matrix: @@ -931,12 +987,14 @@ jobs: cargo test --locked -p socket-patch-cli --all-features --tests --no-run --message-format=json-render-diagnostics > target-build.json - name: Bundle the binaries the legs run + if: needs.clippy.outputs.reuse != 'true' || matrix.os == 'ubuntu-latest' shell: bash env: BUNDLE_OS: ${{ matrix.os }} run: python3 scripts/ci-e2e-bundle.py --os "$BUNDLE_OS" --cargo-json target-build.json --dest target/e2e-bin - name: Compress the e2e binaries + if: needs.clippy.outputs.reuse != 'true' || matrix.os == 'ubuntu-latest' # Compress the bundle as one stream so identical Rust code across # test binaries shares a dictionary. Zstd is on every runner image. # The 128 MiB window cuts the Linux artifact from ~200 MB to ~94 MB; @@ -947,6 +1005,7 @@ jobs: tar -C target/e2e-bin -cf - . | zstd -q -f -10 --long=27 -o target/e2e-bin.tar.zst - uses: ./.github/actions/upload-artifact + if: needs.clippy.outputs.reuse != 'true' || matrix.os == 'ubuntu-latest' with: name: e2e-bin-${{ matrix.os }} path: target/e2e-bin.tar.zst @@ -957,6 +1016,8 @@ jobs: e2e-build-windows: if: (github.event.pull_request.draft != true) && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: clippy + outputs: + reuse: ${{ needs.clippy.outputs.reuse }} strategy: fail-fast: false matrix: @@ -969,6 +1030,8 @@ jobs: e2e-build-macos: if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: clippy + outputs: + reuse: ${{ needs.clippy.outputs.reuse }} strategy: fail-fast: false matrix: @@ -979,6 +1042,7 @@ jobs: steps: *e2e-build-steps e2e: + if: needs.e2e-build.outputs.reuse != 'true' # These jobs consume e2e-build's binaries and can run alongside unit tests. needs: [e2e-build] strategy: @@ -1684,7 +1748,7 @@ jobs: # repository variable CI_SCOPE is `full`, nightly, or on dispatch. See the # LEAN SCOPE note at the top of `jobs:`. e2e-extended: - if: (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: needs.e2e-build.outputs.reuse != 'true' && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: [e2e-build] strategy: fail-fast: false @@ -1799,7 +1863,7 @@ jobs: steps: *e2e-steps e2e-windows: - if: (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: needs.e2e-build-windows.outputs.reuse != 'true' && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: [e2e-build-windows] strategy: fail-fast: false @@ -1835,7 +1899,7 @@ jobs: # The macOS rows run on main, the merge queue and nightly, not on every # PR push, so PRs stop queueing on the small macOS runner pool. e2e-macos: - if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: (github.event_name != 'pull_request' && needs.e2e-build-macos.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') # These jobs consume e2e-build's binaries and can run alongside unit tests. needs: [e2e-build-macos] strategy: @@ -2045,7 +2109,7 @@ jobs: # Dropping that `needs` also dropped the draft skip it inherited, so # gate on draft here directly (push/merge_group/schedule still run). # Only wait for the clippy preflight. - if: github.event.pull_request.draft != true + if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true' needs: clippy runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS == 'true' && 'ubuntu-latest' || 'depot-ubuntu-24.04-4' }} timeout-minutes: 40 @@ -2089,7 +2153,7 @@ jobs: # Dropping that `needs` also dropped the draft skip it inherited, so # gate on draft here directly (push/merge_group/schedule still run). # Only wait for the clippy preflight. - if: github.event.pull_request.draft != true + if: github.event.pull_request.draft != true && needs.clippy.outputs.reuse != 'true' needs: clippy strategy: fail-fast: false @@ -2130,7 +2194,7 @@ jobs: # nightly, not on every PR push, so PRs stop queueing on the small # macOS runner pool. yarn-berry-e2e-macos: - if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: (github.event_name != 'pull_request' && needs.clippy.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') name: yarn-berry ${{ matrix.yarn }} (${{ matrix.os }}) needs: clippy strategy: @@ -2169,6 +2233,7 @@ jobs: # toolchain x lock cross off pull_request. Each leg also runs # e2e_safety_cargo_build (its headline test honours the knobs). cargo-vex-matrix: + if: needs.e2e-build.outputs.reuse != 'true' name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) # e2e-build only (its binaries; only the matching OS build is needed); # see yarn-classic-matrix on test/coverage. @@ -2248,7 +2313,7 @@ jobs: cargo-vex-matrix-windows: name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) - if: (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: needs.e2e-build-windows.outputs.reuse != 'true' && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') needs: [e2e-build-windows] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} timeout-minutes: 40 @@ -2263,7 +2328,7 @@ jobs: # The macOS rows run on main, the merge queue and nightly, not per PR push. cargo-vex-matrix-macos: - if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') + if: (github.event_name != 'pull_request' && needs.e2e-build-macos.outputs.reuse != 'true') && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') name: cargo ${{ matrix.toolchain }} lock-v${{ matrix.lock || 'own' }} (${{ matrix.os }}) needs: [e2e-build-macos] runs-on: ${{ vars.DISABLE_DEPOT_RUNNERS != 'true' && (matrix.os == 'ubuntu-latest' && 'depot-ubuntu-24.04-4' || matrix.os == 'ubuntu-22.04' && 'depot-ubuntu-22.04-4') || matrix.os }} @@ -2337,6 +2402,7 @@ jobs: - name: Install Rust run: scripts/rustup-retry.sh toolchain install - name: Pull the old cargos under test + if: needs.clippy.outputs.reuse != 'true' run: | docker pull rust:1.41-slim docker pull rust:1.56-slim @@ -2345,7 +2411,11 @@ jobs: with: shared-key: dev-ubuntu-latest save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Warm the shared Linux dev cache after merge-queue validation + if: needs.clippy.outputs.reuse == 'true' + run: cargo test --locked -p socket-patch-cli --test e2e_vendor_cargo_build --no-run - name: Vendored manifest [patch] on old cargo + if: needs.clippy.outputs.reuse != 'true' shell: bash env: SOCKET_PATCH_CARGO_E2E_REQUIRED: '1' diff --git a/scripts/ci-lcov-summary.py b/scripts/ci-lcov-summary.py new file mode 100644 index 000000000..6455af76c --- /dev/null +++ b/scripts/ci-lcov-summary.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Render the existing LCOV export without another llvm-profdata/llvm-cov pass. + +LCOV carries line, function and branch totals, not LLVM region totals. +The raw LCOV artifact remains the source of truth for the merged report. +""" + +import argparse +from pathlib import Path + +METRICS = (("Lines", "LH", "LF"), ("Functions", "FNH", "FNF"), ("Branches", "BRH", "BRF")) + + +def read_lcov(text): + files = {} + name, counts = None, {} + for line in text.splitlines(): + key, _, value = line.partition(":") + if key == "SF": + if name is not None: + raise ValueError("LCOV record missing end_of_record") + name, counts = value, {} + elif key in {key for _, hit, found in METRICS for key in (hit, found)}: + counts[key] = int(value) + elif line == "end_of_record": + if not name or name in files: + raise ValueError("LCOV source missing or duplicated") + if "LF" not in counts or "LH" not in counts: + raise ValueError(f"LCOV line totals missing: {name}") + for _, hit, found in METRICS: + if not 0 <= counts.get(hit, 0) <= counts.get(found, 0): + raise ValueError(f"Invalid LCOV totals: {name}") + files[name] = counts + name = None + if name is not None or not files: + raise ValueError("LCOV export empty or truncated") + return files + + +def summary(files, root): + def metric(counts, hit, found): + total, covered = counts.get(found, 0), counts.get(hit, 0) + return f"{covered}/{total} ({100 * covered / total:.2f}%)" if total else "0/0 (-)" + + def label(name): + try: + return str(Path(name).relative_to(root)) + except ValueError: + return name + + rows = [["File", *(name for name, _, _ in METRICS)]] + for name, counts in sorted(files.items()): + rows.append([label(name), *(metric(counts, hit, found) for _, hit, found in METRICS)]) + totals = {key: sum(counts.get(key, 0) for counts in files.values()) + for _, hit, found in METRICS for key in (hit, found)} + rows.append(["TOTAL", *(metric(totals, hit, found) for _, hit, found in METRICS)]) + widths = [max(len(row[i]) for row in rows) for i in range(4)] + return "\n".join(" ".join(value.ljust(widths[i]) if i == 0 else value.rjust(widths[i]) + for i, value in enumerate(row)).rstrip() for row in rows) + "\n" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lcov", type=Path) + parser.add_argument("--root", type=Path, default=Path.cwd()) + args = parser.parse_args() + print(summary(read_lcov(args.lcov.read_text(encoding="utf-8")), args.root), end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci-reuse-merge-group.py b/scripts/ci-reuse-merge-group.py new file mode 100644 index 000000000..317995f0e --- /dev/null +++ b/scripts/ci-reuse-merge-group.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Reuse CI only after the merge queue passed this exact main commit. + +One bounded, workflow-scoped API read; any missing or unreadable evidence +falls back to running CI. PRs, nightlies and manual runs never reuse results. +""" + +import json +import os +import subprocess +from pathlib import Path +from urllib.parse import urlencode + + +def successful_run(payload, repository, sha): + for run in payload["workflow_runs"]: + if (run.get("event") == "merge_group" + and run.get("head_sha") == sha + and run.get("status") == "completed" + and run.get("conclusion") == "success" + and run.get("path") == ".github/workflows/ci.yml" + and run.get("head_repository", {}).get("full_name") == repository + and run.get("head_branch", "").startswith("gh-readonly-queue/main/")): + return run["id"] + return None + + +def reusable_run(env): + if env.get("GITHUB_EVENT_NAME") != "push" or env.get("GITHUB_REF") != "refs/heads/main": + return None + try: + repository, sha = env["GITHUB_REPOSITORY"], env["GITHUB_SHA"] + query = urlencode({"event": "merge_group", "head_sha": sha, "per_page": 100}) + endpoint = f"repos/{repository}/actions/workflows/ci.yml/runs?{query}" + result = subprocess.run(["gh", "api", endpoint], check=True, capture_output=True, + text=True, timeout=15) + return successful_run(json.loads(result.stdout), repository, sha) + except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, AttributeError) as error: + # Do not print response bodies or stderr, which may contain credentials. + print(f"::notice::Could not verify merge-queue CI ({type(error).__name__}); running all checks.") + return None + + +def main(): + run = reusable_run(os.environ) + reuse = run is not None + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"reuse={'true' if reuse else 'false'}\n") + if reuse: + print(f"Merge-queue CI run {run} passed this SHA; retaining cache builds and the full tier.") + else: + print("No verified merge-queue CI result for this push; running all checks.") + + +if __name__ == "__main__": + main() diff --git a/scripts/ci-test-durations.json b/scripts/ci-test-durations.json new file mode 100644 index 000000000..61e0ab69c --- /dev/null +++ b/scripts/ci-test-durations.json @@ -0,0 +1,253 @@ +{ + "source_run": 37951515328, + "source_jobs": [ + 113892063480, + 113892063546 + ], + "unit_seconds": 185.45, + "compile_seconds": 2.0, + "default_seconds": 1.0, + "targets": { + "api_retry_e2e": 2.01, + "api_timeout_e2e": 4.29, + "apply": 10.48, + "binary_fetch_error_classification_e2e": 0.01, + "blob_fetcher_edges_e2e": 2.06, + "child_deadline_guard": 0.25, + "cli": 7.69, + "cli_apply_silent": 0.1, + "cli_argv_non_utf8": 0.0, + "cli_config_fallback": 0.18, + "cli_get_silent": 0.06, + "cli_global_args": 0.36, + "cli_parse_apply": 0.05, + "cli_parse_get": 0.1, + "cli_parse_list": 0.44, + "cli_parse_main": 0.12, + "cli_parse_remove": 0.07, + "cli_parse_repair": 0.06, + "cli_parse_rollback": 0.03, + "cli_parse_scan": 0.4, + "cli_parse_vendor": 0.06, + "cli_parse_vex": 1.06, + "cli_path_flags_validated": 0.52, + "cli_remove_silent": 0.28, + "cli_scan_silent": 0.56, + "cli_sigpipe": 0.0, + "command_module_layering": 0.11, + "contract_gradle_codes": 0.93, + "coverage_fix_apply_silent_mute_exit": 4.05, + "coverage_fix_rollback_ecosystem_scoped_hosted": 0.09, + "coverage_fix_scan_hosted_dryrun_vendored": 1.61, + "coverage_fix_vendor_silent_mute_exit": 7.4, + "covgap_api_blob_fetcher": 4.04, + "covgap_commands_get": 1.81, + "covgap_commands_rollback": 1.86, + "covgap_commands_scan_hosted": 1.46, + "covgap_commands_scan_mod": 1.81, + "covgap_commands_vendor": 3.75, + "covgap_commands_vex": 0.2, + "covgap_crawlers_composer_crawler": 0.01, + "covgap_crawlers_npm_crawler": 0.04, + "covgap_patch_apply": 0.06, + "covgap_update_state": 0.01, + "covgap_utils_socket_cli_config": 0.08, + "crawl_fd_limit_e2e": 0.0, + "crawler_cargo_e2e": 0.06, + "crawler_composer_e2e": 0.11, + "crawler_deno_e2e": 0.04, + "crawler_go_e2e": 0.04, + "crawler_gradle_e2e": 0.17, + "crawler_maven_e2e": 0.07, + "crawler_monorepo_gaps": 0.02, + "crawler_npm_e2e": 1.2, + "crawler_nuget_e2e": 0.07, + "crawler_python_e2e": 1.29, + "crawler_ruby_e2e": 0.85, + "crawlers_empty_paths_e2e": 0.03, + "diff_created_file_e2e": 0.15, + "diff_e2e": 0.01, + "docker_e2e_cargo": 0.0, + "docker_e2e_composer": 0.0, + "docker_e2e_deno": 0.0, + "docker_e2e_gem": 0.0, + "docker_e2e_golang": 0.0, + "docker_e2e_maven": 0.0, + "docker_e2e_npm": 0.0, + "docker_e2e_nuget": 0.0, + "docker_e2e_pypi": 0.0, + "docker_e2e_sbt": 0.0, + "docker_e2e_vendor_composer": 0.0, + "docker_e2e_vendor_gem": 0.0, + "docker_e2e_vendor_maven": 0.0, + "docker_e2e_vendor_nuget": 0.0, + "docker_e2e_vendor_pypi_pm": 0.0, + "e2e_bun_lockb": 1.02, + "e2e_cargo": 0.12, + "e2e_composer": 0.11, + "e2e_composer_version_identity": 0.25, + "e2e_embedded_vex": 0.92, + "e2e_gem": 0.09, + "e2e_golang": 0.12, + "e2e_golang_build": 0.0, + "e2e_golang_hosted_build": 0.0, + "e2e_golang_hosted_state": 0.0, + "e2e_golang_workspace_build": 0.0, + "e2e_gradle_agent_build": 0.36, + "e2e_gradle_discovery_build": 0.46, + "e2e_hosted_production": 0.07, + "e2e_maven": 0.12, + "e2e_npm": 0.01, + "e2e_nuget": 0.12, + "e2e_nuget_dotnet_build": 0.0, + "e2e_pypi": 0.01, + "e2e_pypi_multi_copy": 0.18, + "e2e_redirect_bun_build": 0.04, + "e2e_redirect_cargo_build": 2.95, + "e2e_redirect_cargo_shapes": 7.46, + "e2e_redirect_composer_build": 0.0, + "e2e_redirect_gem_stale_install": 0.01, + "e2e_redirect_gradle_build": 0.28, + "e2e_redirect_maven_build": 0.0, + "e2e_redirect_npm_build": 0.01, + "e2e_redirect_pnpm_build": 0.15, + "e2e_redirect_rush_sim": 0.01, + "e2e_redirect_uv_build": 0.0, + "e2e_redirect_vlt_build": 0.05, + "e2e_redirect_yarn_berry_build": 23.13, + "e2e_redirect_yarn_classic_build": 1.04, + "e2e_safety_cargo_build": 0.13, + "e2e_safety_cow": 0.0, + "e2e_safety_internals": 0.01, + "e2e_safety_lock": 1.08, + "e2e_safety_pnpm": 0.01, + "e2e_safety_vlt": 0.04, + "e2e_safety_yarn_pnp": 0.34, + "e2e_sbt": 1.17, + "e2e_sbt_build": 0.02, + "e2e_sbt_hosted": 0.4, + "e2e_sbt_vendor": 2.83, + "e2e_sbt_vendor_build": 0.03, + "e2e_scala_cli_vendor": 0.13, + "e2e_scan": 0.01, + "e2e_socket_yml_policy": 3.98, + "e2e_vendor_bun_build": 0.03, + "e2e_vendor_cargo_build": 10.85, + "e2e_vendor_composer_build": 0.06, + "e2e_vendor_composer_crlf": 0.31, + "e2e_vendor_gem_build": 0.03, + "e2e_vendor_golang_build": 0.0, + "e2e_vendor_gradle_build": 0.32, + "e2e_vendor_jvm_build": 0.3, + "e2e_vendor_maven_build": 0.04, + "e2e_vendor_npm_build": 15.21, + "e2e_vendor_pnpm_build": 0.5, + "e2e_vendor_pypi_build": 0.0, + "e2e_vendor_vlt_build": 0.05, + "e2e_vendor_yarn_berry_build": 14.67, + "e2e_vendor_yarn_classic_build": 0.03, + "e2e_vendor_yarn_classic_dev_flow": 0.03, + "e2e_vendored_production": 0.07, + "e2e_vex": 1.94, + "e2e_vex_build": 0.0, + "e2e_vex_lockfile": 118.65, + "e2e_vex_redirect": 3.28, + "e2e_vex_vendor": 2.72, + "e2e_vlt": 0.76, + "e2e_yarn4_pnpm_linker_build": 13.72, + "e2e_yarn4_workspaces_build": 9.39, + "e2e_yarn_legacy_cachekey_refusal_build": 3.67, + "ecosystem_dispatch_e2e": 0.48, + "fuzzy_match_e2e": 0.0, + "get": 8.01, + "global_probe_spawn_e2e": 0.24, + "global_scope_project_state": 0.88, + "gradle_agent_cli": 2.27, + "help_text_hygiene": 0.05, + "hosted_inventory": 0.06, + "hosted_memory_engine": 1.47, + "hosted_memory_parity": 1.77, + "hosted_memory_rollout": 0.93, + "hosted_superseding_pypi": 0.26, + "in_process_agent_reapply": 0.51, + "in_process_alternate_installers": 0.19, + "in_process_cargo_apply": 5.85, + "in_process_edge_cases": 0.18, + "in_process_gem_apply": 0.02, + "in_process_gem_multi_platform": 0.65, + "in_process_get": 3.45, + "in_process_get_corrupt_manifest": 0.01, + "in_process_get_hosted_ecosystems": 1.26, + "in_process_get_manifest_path": 0.21, + "in_process_get_modes": 1.29, + "in_process_get_update_count": 0.04, + "in_process_get_uuid_fallback": 0.03, + "in_process_pypi_apply": 25.35, + "in_process_pypi_multi_release": 32.83, + "in_process_python_envs": 1.03, + "in_process_redirect": 11.08, + "in_process_redirect_pdm": 2.46, + "in_process_redirect_pipenv": 2.45, + "in_process_redirect_pnpm": 4.63, + "in_process_redirect_poetry": 1.97, + "in_process_remote_ecosystems_apply": 9.9, + "in_process_remove_repair_lifecycle": 0.8, + "in_process_rollback_all_ecosystems": 0.3, + "in_process_rollback_hosted": 4.88, + "in_process_rollback_vendored": 1.02, + "in_process_scan": 2.94, + "in_process_target_ambiguity": 0.14, + "in_process_vendor": 25.4, + "in_process_vendor_bun_takeover": 3.72, + "in_process_vendor_npm_v1_takeover": 0.57, + "in_process_vendor_pnpm_parent_child": 0.67, + "in_process_vendor_pnpm_takeover": 1.62, + "in_process_vendor_pypi_takeover": 0.34, + "json_error_shape": 0.21, + "maven_sidecar_cli": 0.26, + "mode_migration_bun": 0.04, + "mode_migration_cargo": 3.9, + "mode_migration_npm": 0.03, + "mode_migration_pypi": 6.14, + "mode_migration_vlt": 0.04, + "output_helpers_e2e": 0.0, + "package_e2e": 0.02, + "pnpm_hosted": 0.09, + "poetry_hosted": 0.11, + "policy_pypi_names": 0.26, + "proxy_batch_e2e": 0.47, + "redirect_golden": 0.68, + "redirect_sbt_golden": 0.35, + "remedy_commands_parse": 0.56, + "remove": 2.05, + "remove_rollback_api_overrides": 0.07, + "repair": 10.03, + "rollback": 4.66, + "rollback_new_file_e2e": 0.01, + "scan": 9.08, + "scan_api_retry_e2e": 2.42, + "scan_pnpm_relocated_store_cwd_e2e": 0.07, + "scan_requirements_lock_only": 2.68, + "scan_rollout_e2e": 6.6, + "scan_vendor_e2e": 3.11, + "scan_vendor_requirements_unwired": 0.33, + "self_update_e2e": 7.68, + "self_update_failures_e2e": 8.18, + "spawn_env_hygiene": 0.39, + "telemetry_helpers_e2e": 0.01, + "update": 16.45, + "upstream_restore_golden": 8.1, + "uv_hosted": 0.01, + "vendor": 7.77, + "vendor_crash_safety_e2e": 0.67, + "vendor_eject": 0.35, + "vendor_eject_bun_lockb": 2.2, + "vendor_eject_fresh_checkout": 0.63, + "vendor_group_commit_e2e": 10.18, + "vendor_jvm_cli": 6.72, + "vendor_ledger_schema_e2e": 3.13, + "vendor_partial_staging_e2e": 0.56, + "vex_terminal_output": 0.18, + "vlt_locks": 16.39 + } +} diff --git a/scripts/ci-test-shard.py b/scripts/ci-test-shard.py index 05336ce9b..28b7e02f0 100644 --- a/scripts/ci-test-shard.py +++ b/scripts/ci-test-shard.py @@ -10,6 +10,10 @@ share to balance its unit tests. Every target lands in exactly one shard, so the union of the shards is the old single `cargo test --workspace` run. +Windows debug CI sets CI_TEST_TIMINGS to the checked-in runtime sample. +Those shards balance measured test time plus linking work, with shard 1's +unit tests reserved up front. Other runners retain the count-based split. + Extra arguments after `SHARD COUNT` go to every `cargo test` invocation. Each invocation runs with `--no-fail-fast`; the exit status is non-zero if any of them failed. @@ -19,6 +23,8 @@ """ import json +import os +from pathlib import Path import subprocess import sys @@ -34,11 +40,23 @@ def integration_targets(metadata): for t in p["targets"] if "test" in t["kind"]}) -def partition(names, count): +def partition(names, count, timings=None): """`count` lists covering `names` exactly once, in order, with the first list weighted by FIRST_SHARD_WEIGHT.""" if count < 1: raise ValueError("count must be >= 1") + if timings is not None: + # Longest processing time first, reserving shard 1's unit-test cost. + # Link overhead keeps a pile of fast tests from becoming a slow build. + def cost(name): + return timings["compile_seconds"] + timings["targets"].get(name, timings["default_seconds"]) + shards = [[] for _ in range(count)] + load = [timings["unit_seconds"]] + [0.0] * (count - 1) + for name in sorted(names, key=lambda n: (-cost(n), n)): + i = min(range(count), key=lambda k: (load[k], k)) + shards[i].append(name) + load[i] += cost(name) + return [sorted(shard) for shard in shards] weights = [FIRST_SHARD_WEIGHT if count > 1 else 1.0] + [1.0] * (count - 1) shards = [[] for _ in range(count)] load = [0.0] * count @@ -50,12 +68,12 @@ def partition(names, count): return shards -def invocations(shard, count, names, extra=()): +def invocations(shard, count, names, extra=(), timings=None): """The `cargo test` argument lists shard `shard` (1-based) runs.""" if not 1 <= shard <= count: raise ValueError(f"shard {shard} not in 1..{count}") base = ["cargo", "test", "--workspace", "--no-fail-fast", *extra] - mine = partition(names, count)[shard - 1] + mine = partition(names, count, timings)[shard - 1] runs = [] if shard == 1: runs.append(base + ["--lib", "--bins"] + [a for n in mine for a in ("--test", n)]) @@ -74,10 +92,12 @@ def main(argv): ["cargo", "metadata", "--no-deps", "--format-version", "1"], check=True, capture_output=True, text=True).stdout) names = integration_targets(metadata) + timing_path = os.environ.get("CI_TEST_TIMINGS") + timings = json.loads(Path(timing_path).read_text(encoding="utf-8")) if timing_path else None print(f"ci-test-shard: shard {shard}/{count}: " - f"{len(partition(names, count)[shard - 1])} of {len(names)} integration targets", flush=True) + f"{len(partition(names, count, timings)[shard - 1])} of {len(names)} integration targets", flush=True) status = 0 - for args in invocations(shard, count, names, argv[2:]): + for args in invocations(shard, count, names, argv[2:], timings): print("+ " + " ".join(args), flush=True) if subprocess.run(args).returncode != 0: status = 1 diff --git a/scripts/tests/test_ci_lcov_summary.py b/scripts/tests/test_ci_lcov_summary.py new file mode 100644 index 000000000..041885bbd --- /dev/null +++ b/scripts/tests/test_ci_lcov_summary.py @@ -0,0 +1,52 @@ +"""The cheap coverage summary counts the exported data, including empty metrics.""" + +import importlib.util +import unittest +from pathlib import Path + +ROOT = Path(__file__).parents[2] +spec = importlib.util.spec_from_file_location("lcov_summary", ROOT / "scripts/ci-lcov-summary.py") +lcov = importlib.util.module_from_spec(spec) +spec.loader.exec_module(lcov) + + +class Summary(unittest.TestCase): + sample = """TN: +SF:/work/src/one.rs +FN:2,one +FNDA:1,one +FNF:2 +FNH:1 +DA:2,1 +DA:3,0 +LF:2 +LH:1 +BRF:2 +BRH:1 +end_of_record +SF:/work/src/two.rs +LF:10 +LH:9 +FNF:1 +FNH:1 +end_of_record +""" + + def test_totals_are_weighted_by_counts_not_file_percentages(self): + text = lcov.summary(lcov.read_lcov(self.sample), Path("/work")) + self.assertIn("src/one.rs", text) + total = text.splitlines()[-1] + self.assertIn("10/12 (83.33%)", total) + self.assertIn("2/3 (66.67%)", total) + self.assertIn("1/2 (50.00%)", total) + self.assertIn("0/0 (-)", text) + + def test_truncated_duplicate_empty_and_invalid_exports_fail(self): + for text in ("", "SF:file.rs\nLF:1\nLH:1\n", self.sample + self.sample, + "SF:file.rs\nend_of_record\n", self.sample.replace("LH:1\n", "LH:3\n")): + with self.subTest(text=text), self.assertRaises(ValueError): + lcov.read_lcov(text) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ci_reuse_merge_group.py b/scripts/tests/test_ci_reuse_merge_group.py new file mode 100644 index 000000000..ffa058a77 --- /dev/null +++ b/scripts/tests/test_ci_reuse_merge_group.py @@ -0,0 +1,75 @@ +"""A reused CI verdict must belong to this workflow, repository and exact SHA.""" + +import importlib.util +import json +import subprocess +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).parents[2] +spec = importlib.util.spec_from_file_location("reuse", ROOT / "scripts/ci-reuse-merge-group.py") +reuse = importlib.util.module_from_spec(spec) +spec.loader.exec_module(reuse) + + +class Reuse(unittest.TestCase): + env = {"GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/heads/main", + "GITHUB_REPOSITORY": "SocketDev/socket-patch", "GITHUB_SHA": "a" * 40} + queue_run = {"id": 123, "event": "merge_group", "head_sha": "a" * 40, + "status": "completed", "conclusion": "success", "path": ".github/workflows/ci.yml", + "head_repository": {"full_name": "SocketDev/socket-patch"}, + "head_branch": "gh-readonly-queue/main/pr-1-abc"} + + def result(self, runs): + return subprocess.CompletedProcess([], 0, stdout=json.dumps({"workflow_runs": runs})) + + def test_only_the_same_successful_merge_queue_workflow_is_reused(self): + with patch.object(reuse.subprocess, "run", return_value=self.result([self.queue_run])) as call: + self.assertEqual(reuse.reusable_run(self.env), 123) + endpoint = call.call_args.args[0][-1] + self.assertIn("/actions/workflows/ci.yml/runs?", endpoint) + self.assertIn("event=merge_group", endpoint) + self.assertIn("head_sha=" + self.env["GITHUB_SHA"], endpoint) + self.assertLessEqual(call.call_args.kwargs["timeout"], 15) + + def test_unrelated_or_incomplete_results_cannot_skip_tests(self): + mismatches = [ + {"head_sha": "b" * 40}, {"event": "pull_request"}, + {"path": ".github/workflows/other.yml"}, {"status": "in_progress"}, + {"conclusion": "failure"}, {"conclusion": "cancelled"}, + {"conclusion": "skipped"}, {"conclusion": None}, + {"head_repository": {"full_name": "fork/socket-patch"}}, + {"head_branch": "gh-readonly-queue/release/pr-1-abc"}, + ] + for change in mismatches: + with self.subTest(change=change), patch.object( + reuse.subprocess, "run", return_value=self.result([dict(self.queue_run, **change)])): + self.assertIsNone(reuse.reusable_run(self.env)) + + def test_direct_push_with_no_queue_run_keeps_all_checks(self): + with patch.object(reuse.subprocess, "run", return_value=self.result([])): + self.assertIsNone(reuse.reusable_run(self.env)) + + def test_non_main_pushes_and_other_events_never_query_or_skip(self): + changes = [{"GITHUB_EVENT_NAME": e} for e in + ("pull_request", "merge_group", "schedule", "workflow_dispatch")] + changes.append({"GITHUB_REF": "refs/heads/feature"}) + for change in changes: + with self.subTest(change=change), patch.object(reuse.subprocess, "run") as call: + self.assertIsNone(reuse.reusable_run(dict(self.env, **change))) + call.assert_not_called() + + def test_api_failure_timeout_and_bad_payload_keep_all_checks(self): + for error in (FileNotFoundError(), subprocess.CalledProcessError(1, "gh"), + subprocess.TimeoutExpired("gh", 15)): + with self.subTest(error=error), patch.object(reuse.subprocess, "run", side_effect=error): + self.assertIsNone(reuse.reusable_run(self.env)) + for text in ("not json", "{}", '{"workflow_runs": null}', '{"workflow_runs": [null]}'): + with self.subTest(text=text), patch.object(reuse.subprocess, "run", return_value= + subprocess.CompletedProcess([], 0, stdout=text)): + self.assertIsNone(reuse.reusable_run(self.env)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ci_scheduling.py b/scripts/tests/test_ci_scheduling.py index cc5f5644b..67427b5b4 100644 --- a/scripts/tests/test_ci_scheduling.py +++ b/scripts/tests/test_ci_scheduling.py @@ -76,8 +76,43 @@ def test_os_builds_use_the_same_artifact_contract(self): self.assertIn("pattern: e2e-bin-${{ matrix.os }}*", "\n".join(JOBS[family])) for job in ("e2e-build-macos", "e2e-macos", "cargo-vex-matrix-macos", "yarn-berry-e2e-macos"): # Never on pull_request; lean scope also skips them (CI_SCOPE). - self.assertIn(" if: (github.event_name != 'pull_request') && (vars.CI_SCOPE == 'full'" - " || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')", JOBS[job]) + condition = next(line for line in JOBS[job] if line.startswith(" if:")) + self.assertTrue(condition.startswith(" if: (github.event_name != 'pull_request'"), condition) + self.assertTrue(condition.endswith(" && (vars.CI_SCOPE == 'full' || github.event_name == 'schedule'" + " || github.event_name == 'workflow_dispatch')"), condition) + + def test_reused_push_keeps_cache_writers_and_unqueued_jobs(self): + # A push whose SHA passed the merge queue still compiles (main is the + # only cache writer) and still runs what the queue never ran. + for job in ("clippy", "node-addon", "test", "test-release", "coverage", "e2e-build", + "e2e-build-windows", "e2e-build-macos", "cargo-old-toolchains", + "e2e-full", "cargo-vex-matrix-full", "yarn-berry-full", "hosted-e2e"): + with self.subTest(job=job): + condition = next((line for line in JOBS[job] if line.startswith(" if:")), "") + self.assertNotIn("outputs.reuse", condition) + for job in ("docker-base", "yarn-classic-matrix", "yarn-berry-e2e", "yarn-berry-e2e-macos"): + with self.subTest(job=job): + condition = next(line for line in JOBS[job] if line.startswith(" if:")) + self.assertIn("needs.clippy.outputs.reuse != 'true'", condition) + for family in ("e2e", "cargo-vex-matrix"): + for suffix in ("", "-windows", "-macos"): + build = "e2e-build" + suffix + with self.subTest(job=family + suffix): + condition = next(line for line in JOBS[family + suffix] if line.startswith(" if:")) + self.assertIn(f"needs.{build}.outputs.reuse != 'true'", condition) + self.assertIn(" reuse: ${{ needs.clippy.outputs.reuse }}", JOBS[build]) + self.assertIn("needs.e2e-build.outputs.reuse != 'true'", + next(line for line in JOBS["e2e-extended"] if line.startswith(" if:"))) + for job in ("test", "coverage", "cargo-old-toolchains"): + with self.subTest(job=job): + warm = [body for name, body in reader.steps(JOBS[job]) if name.startswith("Warm ")] + self.assertEqual(len(warm), 1) + self.assertIn("if: needs.clippy.outputs.reuse == 'true'", warm[0]) + self.assertIn("--no-run", warm[0]) + clippy = "\n".join(JOBS["clippy"]) + self.assertIn("actions: read", clippy) + self.assertIn("reuse: ${{ steps.merge-queue.outputs.reuse }}", clippy) + self.assertIn("python3 scripts/ci-reuse-merge-group.py", clippy) def test_row_reader_preserves_both_os_siblings(self): jobs = reader.jobs("""jobs: diff --git a/scripts/tests/test_ci_test_shard.py b/scripts/tests/test_ci_test_shard.py index bb2d446e0..7ca653e16 100644 --- a/scripts/tests/test_ci_test_shard.py +++ b/scripts/tests/test_ci_test_shard.py @@ -88,6 +88,20 @@ def test_integration_targets_reads_workspace_test_kinds(self): } self.assertEqual(shard.integration_targets(metadata), ["e2e_x", "zz"]) + def test_timed_shards_cover_new_targets_and_balance_the_recorded_work(self): + timings = json.loads((ROOT / "scripts/ci-test-durations.json").read_text()) + names = sorted(timings["targets"]) + ["a_new_test_target"] + partitions = shard.partition(names, 2, timings) + self.assertCountEqual([name for part in partitions for name in part], names) + loads = [timings["unit_seconds"], 0.0] + for i, part in enumerate(partitions): + loads[i] += sum(timings["compile_seconds"] + timings["targets"].get(n, timings["default_seconds"]) + for n in part) + self.assertLess(abs(loads[0] - loads[1]), 5) + actual = [args for k in (1, 2) for args in shard.invocations(k, 2, names, timings=timings)] + self.assertCountEqual(selected(actual), names) + self.assertEqual(sum("--doc" in args for args in actual), 1) + def test_the_checkout_has_integration_targets(self): try: out = subprocess.run(["cargo", "metadata", "--no-deps", "--format-version", "1"], diff --git a/tests/docker/Dockerfile.sbt b/tests/docker/Dockerfile.sbt index 247a5f16e..99509ea52 100644 --- a/tests/docker/Dockerfile.sbt +++ b/tests/docker/Dockerfile.sbt @@ -98,6 +98,8 @@ RUN set -eu \ # the image's Central cache from memory, which more lines would bloat past # its 2g container. ARG SBT_WARM_VERSIONS="0.13.18 1.2.8 1.13.0 2.0.9" +# CI's blocking slice uses only two sbt lines; nightly/compat keep all tools. +ARG SBT_WARM_TOOLS=1 RUN set -eu \ && for v in ${SBT_WARM_VERSIONS}; do \ d="/tmp/warm-$v"; mkdir -p "$d/project"; \ @@ -110,7 +112,8 @@ RUN set -eu \ sbt -batch -no-colors -Dsbt.server.autostart=false $extra update); \ rm -rf "$d"; \ done \ - && d=/tmp/warm-mill && mkdir -p "$d/foo/src" && cd "$d" \ + && if [ "$SBT_WARM_TOOLS" = 1 ]; then \ + d=/tmp/warm-mill && mkdir -p "$d/foo/src" && cd "$d" \ && printf '%s\n' '//| mill-version: '"${MILL_1_VERSION}" 'package build' 'import mill.*, scalalib.*' \ 'object foo extends ScalaModule {' ' def scalaVersion = "2.13.16"' \ ' def mvnDeps = Seq(mvn"org.apache.commons:commons-lang3:3.11")' '}' > build.mill \ @@ -120,4 +123,5 @@ RUN set -eu \ && printf '%s\n' '//> using scala 3.3.6' '//> using dep org.apache.commons:commons-lang3:3.11' > project.scala \ && echo '@main def m() = println("warm")' > Main.scala \ && scala-cli compile . --server=false > /dev/null \ - && cd / && rm -rf "$d" + && cd / && rm -rf "$d"; \ + fi