diff --git a/docs/build_cache_requirements.md b/docs/build_cache_requirements.md
new file mode 100644
index 000000000..45e53a587
--- /dev/null
+++ b/docs/build_cache_requirements.md
@@ -0,0 +1,129 @@
+# Build Cache Service — Requirements for Crank Integration
+
+## Context
+
+Crank (the .NET benchmarking tool) has been updated to support a new `buildcache` channel that downloads pre-built runtime binaries from the Build Cache Service (BCS) instead of resolving versions from VMR/NuGet feeds. This gives per-commit granularity for performance testing and regression bisection.
+
+The crank-side changes are complete. This document describes what's needed on the BCS/dotnet-performance-infra side to make the integration work end-to-end.
+
+---
+
+## Requirement 1: Public Blob Access
+
+**Status:** Already in progress (per prior discussion).
+
+Crank's BCS client uses unauthenticated HTTP GET requests to download artifacts. The blobs in the `pvscmdupload` storage account's `$web` container need to be publicly readable.
+
+**URLs crank will hit:**
+
+```
+GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/latest/{branch}/latestBuilds.json
+GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/{artifactFile}
+```
+
+Where:
+- `repoName` = `runtime` or `aspnetcore` (selected per-job via the `buildCacheRepo` property; defaults to the agent's `--build-cache-repo-name`)
+- `branch` = e.g., `main`, `release/10.0`
+- `configKey` = e.g., `coreclr_x64_linux`, `coreclr_arm64_windows` (runtime); `aspnetcore_x64_linux`, `aspnetcore_arm64_windows` (aspnetcore)
+- `artifactFile` = e.g., `BuildArtifacts_linux_x64_Release_coreclr.tar.gz` (runtime); `BuildArtifacts_linux_x64_Release_aspnetcore.nupkg` (aspnetcore — the verbatim runtime-pack nupkg)
+
+The aspnetcore `latestBuilds.json` lives at `builds/aspnetcore/latest/{branch}/latestBuilds.json` and contains only the 5 `aspnetcore_*` config keys plus an `all` entry and `branch_name`; it does **not** carry the runtime `coreclr_*` keys. Crank's parser enumerates keys dynamically, so it tolerates either repo's file.
+
+---
+
+## Requirement 2: Commit Index File (Not Required)
+
+~~Originally proposed as a per-branch `commitIndex.json` mapping commits to timestamps.~~
+
+**Decision:** Not needed. For the default case, `latestBuilds.json` provides the latest commit. For specific-commit runs (e.g., bisection), users will already know the SHAs — either from git history, GitHub, or a local tool that queries the GitHub API for the commit list. A separate index in BCS would be redundant.
+
+If automated bisection tooling is built in the future, it can query GitHub directly for ordered commit SHAs and then check BCS blob existence per-commit.
+
+---
+
+## Requirement 3: latestBuilds.json Compatibility
+
+**Resolved.** The actual `latestBuilds.json` uses PascalCase (`CommitSha`, `CommitTime`), not snake_case. Crank's parser has been updated to accept both casings for forward compatibility.
+
+---
+
+## Requirement 4: Artifact Layout Stability
+
+Crank extracts **runtime** artifacts using this path convention inside the archive:
+
+```
+microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/lib/net{X}.0/ → managed DLLs
+microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/native/ → native libs
+{rid}.Release/corehost/ → host binaries (dotnet, libhostfxr, libhostpolicy)
+```
+
+For **aspnetcore** artifacts the stored blob is the **verbatim runtime-pack nupkg** (a zip,
+extension `.nupkg`), so the layout is the nupkg's own — `runtimes/{rid}` sits at the archive root,
+with no `microsoft.aspnetcore.app.runtime.{rid}/Release` wrapper. Crucially, the verbatim nupkg
+carries the host-resolvable framework metadata next to the managed assemblies:
+
+```
+runtimes/{rid}/lib/net{X}.0/Microsoft.AspNetCore.*.dll → managed assemblies
+runtimes/{rid}/lib/net{X}.0/Microsoft.AspNetCore.App.deps.json → host-resolvable metadata (REQUIRED)
+runtimes/{rid}/lib/net{X}.0/Microsoft.AspNetCore.App.runtimeconfig.json → host-resolvable metadata (REQUIRED)
+runtimes/{rid}/native/ → native libs (optional)
+```
+
+Because the nupkg is a complete framework, crank **places `Microsoft.AspNetCore.App` directly** from
+the pack into the per-job dotnet home (the whole managed set incl `deps.json`/`runtimeconfig.json`,
+no feed contribution) and **fails the job** (`BuildCacheIncompleteException`) if the pack is missing
+managed assemblies, `deps.json`, or `runtimeconfig.json` — for perf runs, erroring is preferable to
+silently running mixed/feed bits. The base runtime + host stay feed-resolved (the aspnetcore pack
+ships neither). Self-contained (SCD) publishes are the exception: the framework is co-mingled with
+the app under the app's own `.deps.json`, so for SCD crank overlays only the managed `*.dll`
+(+ native), not the framework metadata.
+
+This differs from **runtime**, whose archive is raw build output (no shared-framework
+`deps.json`/`runtimeconfig.json`), so the runtime flavour overlays BCS binaries onto a feed-installed
+runtime (reusing the feed's metadata) rather than placing directly.
+
+Where `{rid}` = `linux-x64`, `linux-arm64`, `win-x64`, `win-arm64`, `win-x86`. (aspnetcore v1 has no
+musl/osx/arm32 configs.)
+
+The runtime layout was confirmed by inspecting `BuildArtifacts_linux_x64_Release_coreclr.tar.gz` (no
+framework metadata in the pack lib). The aspnetcore contract — a verbatim runtime-pack nupkg carrying
+managed + `deps.json` + `runtimeconfig.json`, uploaded as `BuildArtifacts_{os}_{arch}_Release_aspnetcore.nupkg`
+— was confirmed against a locally-built `Microsoft.AspNetCore.App.Runtime.win-x64.nupkg` and
+dotnet/performance#5243's `stage-bcs-nupkg-aspnetcore.ps1`.
+**If either layout changes in future builds, the crank extraction will break.** Consider treating it
+as a stable contract or documenting it.
+
+---
+
+## Nice-to-Have: Artifact Manifest
+
+A `manifest.json` per commit+config that describes the archive contents would make extraction more robust:
+
+```
+builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/manifest.json
+```
+
+```json
+{
+ "runtimeVersion": "10.0.0-preview.4.26120.3",
+ "commitSha": "abc123...",
+ "rid": "linux-arm64",
+ "managedPath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/lib/net10.0",
+ "nativePath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/native",
+ "corehostPath": "linux-arm64.Release/corehost"
+}
+```
+
+This isn't blocking — crank currently discovers paths by convention — but it would decouple crank from the internal archive layout and make future changes safe.
+
+---
+
+## Summary
+
+| # | Requirement | Priority | Blocking? |
+|---|-------------|----------|-----------|
+| 1 | Public blob access | High | Yes — crank can't download without it |
+| 2 | ~~Commit index~~ | N/A | Dropped — users provide SHAs directly or use GitHub |
+| 3 | `latestBuilds.json` field names | N/A | Resolved — crank parser updated to handle PascalCase |
+| 4 | Artifact layout stability | Medium | Not now, but breaking changes would break crank |
+| 5 | Artifact manifest.json | Low | Nice-to-have for robustness |
diff --git a/docs/dotnet_versions.md b/docs/dotnet_versions.md
index 9c48d5305..12dfdee75 100644
--- a/docs/dotnet_versions.md
+++ b/docs/dotnet_versions.md
@@ -55,9 +55,12 @@ When a TFM is configured, the agent will download the corresponding .NET SDK ver
- `current`: only latest public versions, this is the default
- `latest`: latest versions used by ASP.NET
- `edge`: latest nightly builds available
+- `buildcache`: runtime from the Build Cache Service (per-commit builds)
The difference between `latest` and `edge` is that `latest` will pick runtimes and SDKs that are deemed compatible together. For instance a very recent .NET core runtime might be compatible with a less recent ASP.NET runtime. The `edge` is used to pick the absolute latest build for the select TFM.
+The `buildcache` channel uses the Build Cache Service (BCS) from `dotnet-performance-infra` to resolve runtime versions by individual commit SHA rather than from VMR feeds. This provides much finer-grained control — every cached runtime commit is available, whereas VMR feeds may have multi-day gaps between ingested commits. SDK and ASP.NET Core versions are resolved from `latest` when using `buildcache`.
+
In order to benchmark and ASP.NET application using very recent runtimes of .NET 5, the `latest` channel is recommended:
```
@@ -115,4 +118,75 @@ The following command uses the `edge` channel but ASP.NET is fixed so it doesn't
```
> crank --config /crank/samples/hello/hello.benchmarks.yml --scenario hello --profile local --application.framework netcoreapp5.0 --application.channel edge --application.aspnetCoreVersion 5.0.0-preview.6.20279.12
-```
\ No newline at end of file
+```
+
+## Using the Build Cache channel
+
+The `buildcache` channel resolves pre-built binaries for individual commits from the Build Cache Service (BCS). This is useful for performance regression bisection where VMR feed gaps make it hard to pinpoint which commit caused a regression.
+
+The channel can resolve from one of two repositories, selected per-job with the `buildCacheRepo` property:
+
+- `runtime` (default) — **overlays** the base .NET runtime (`Microsoft.NETCore.App`) with BCS bits built from a [dotnet/runtime](https://github.com/dotnet/runtime) commit. The runtime archive is raw build output (no shared-framework metadata), so BCS binaries are overlaid onto a feed-installed runtime.
+- `aspnetcore` — **places** the ASP.NET Core shared framework (`Microsoft.AspNetCore.App`) directly from a [dotnet/aspnetcore](https://github.com/dotnet/aspnetcore) commit's BCS build. The aspnetcore archive is the runtime-pack nupkg stored verbatim (carrying `deps.json` + `runtimeconfig.json`), so the framework folder is built entirely from BCS and the job **fails** if the pack is incomplete. The base runtime stays at the feed-resolved version.
+
+When no `buildCacheRepo` is supplied the job falls back to the agent-level `--build-cache-repo-name` (which itself defaults to `runtime`), so existing runtime usage is unchanged.
+
+### Basic usage (latest cached build on main)
+
+```
+# runtime (default)
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache
+```
+
+### Bisecting ASP.NET Core
+
+Add `--application.buildCacheRepo aspnetcore` to resolve an ASP.NET Core commit instead. The `buildCacheCommitSha` / `buildCacheBranch` values are then interpreted as **dotnet/aspnetcore** commit/branch:
+
+```
+# latest cached aspnetcore build on main
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheRepo aspnetcore
+
+# specific aspnetcore commit
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheRepo aspnetcore --application.buildCacheCommitSha a1b2c3d4e5f6...
+```
+
+> **One BCS component per job (v1).** A single job overrides EITHER the base runtime OR the ASP.NET Core shared framework from BCS, not both at once. The `buildCacheRepo` selector decides which one; the other framework comes from the normal feed. If a job somehow requests both, the selected flavour wins and the other is skipped with a log warning.
+
+### Specific commit SHA
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheCommitSha a1b2c3d4e5f6...
+```
+
+If the commit is not found in the cache, crank will fail with an error rather than falling back.
+
+### Different branch
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheBranch release/10.0
+```
+
+### Mixed channels (BCS runtime + pinned ASP.NET)
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.aspNetCoreVersion 10.0.0-preview.3.26115.7
+```
+
+### Build Cache properties
+
+| Property | Default | Description |
+|----------|---------|-------------|
+| `buildCacheRepo` | (agent `--build-cache-repo-name`, i.e. `runtime`) | Which BCS repository to resolve from: `runtime` (overlays `Microsoft.NETCore.App` from a feed install) or `aspnetcore` (places `Microsoft.AspNetCore.App` directly from the verbatim runtime-pack nupkg; fails loud if incomplete). |
+| `buildCacheCommitSha` | (empty) | Specific commit SHA in the selected repo (runtime or aspnetcore). If empty, uses the latest cached build for the branch. |
+| `buildCacheBranch` | `main` | Branch to query for the latest build. |
+| `buildCacheConfig` | (auto-detected) | BCS configuration key (e.g., `coreclr_x64_linux` for runtime, `aspnetcore_x64_linux` for aspnetcore). Auto-detected from agent platform and the selected repo. |
+
+### Agent configuration
+
+The agent supports these command-line options for BCS:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `--build-cache-base-url` | `https://pvscmdupload.z22.web.core.windows.net` | Base URL for BCS blob storage. |
+| `--build-cache-repo-name` | `runtime` | Repository name in BCS. |
+| `--build-cache-disabled` | (not set) | Disables BCS integration on this agent. |
diff --git a/src/Microsoft.Crank.Agent/BuildCacheClient.cs b/src/Microsoft.Crank.Agent/BuildCacheClient.cs
new file mode 100644
index 000000000..74bd87bd5
--- /dev/null
+++ b/src/Microsoft.Crank.Agent/BuildCacheClient.cs
@@ -0,0 +1,1149 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Formats.Tar;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.InteropServices;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Crank.Agent
+{
+ ///
+ /// Lightweight client for the Build Caching Service (BCS) in dotnet-performance-infra.
+ /// Downloads pre-built runtime artifacts from public Azure Blob Storage and assembles a
+ /// per-job dotnet home (or overlays a self-contained published app) so the benchmark runs
+ /// against the BCS runtime instead of the feed-installed one.
+ ///
+ internal static class BuildCacheClient
+ {
+ private const int DownloadRetryCount = 3;
+ private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(10);
+ private static readonly TimeSpan _latestBuildsCacheDuration = TimeSpan.FromHours(1);
+
+ private static readonly HttpClient _httpClient = new HttpClient { Timeout = _httpTimeout };
+
+ // Cache latestBuilds.json responses to avoid repeated downloads (keyed by baseUrl|repo|branch).
+ private static readonly ConcurrentDictionary _latestBuildsCache = new();
+
+ // Per-(commit,config) async locks so concurrent jobs serialize their downloads/extracts.
+ private static readonly ConcurrentDictionary _extractLocks = new();
+
+ // Hex SHA-1 (full or short), 8-40 chars. BCS commits are runtime repo commits.
+ private static readonly Regex _shaRegex = new("^[0-9a-fA-F]{8,40}$", RegexOptions.Compiled);
+
+ ///
+ /// Maps the agent's platform (RID) to the BCS configuration key and artifact filename.
+ /// The reverse direction (config → RID) is used to decide which RID-shaped subtree to
+ /// look for inside the archive when the user supplies an explicit config override.
+ ///
+ internal static readonly IReadOnlyDictionary PlatformToBcsConfig =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["linux-x64"] = ("coreclr_x64_linux", "BuildArtifacts_linux_x64_Release_coreclr.tar.gz", "linux-x64"),
+ ["linux-arm64"] = ("coreclr_arm64_linux", "BuildArtifacts_linux_arm64_Release_coreclr.tar.gz", "linux-arm64"),
+ ["linux-musl-x64"] = ("coreclr_muslx64_linux", "BuildArtifacts_linux_musl_x64_Release_coreclr.tar.gz", "linux-musl-x64"),
+ ["win-x64"] = ("coreclr_x64_windows", "BuildArtifacts_windows_x64_Release_coreclr.zip", "win-x64"),
+ ["win-arm64"] = ("coreclr_arm64_windows", "BuildArtifacts_windows_arm64_Release_coreclr.zip", "win-arm64"),
+ ["win-x86"] = ("coreclr_x86_windows", "BuildArtifacts_windows_x86_Release_coreclr.zip", "win-x86"),
+ };
+
+ ///
+ /// ASP.NET Core (dotnet/aspnetcore) variant of . Maps the
+ /// agent's platform (RID) to the aspnetcore BCS configuration key and artifact filename.
+ /// The stored artifact is the verbatim runtime-pack nupkg, so the archive's internal layout
+ /// is the nupkg's own (runtimes/{rid}/lib/net{X}.0/... at the root). This and the
+ /// configKey / artifact tokens are a load-bearing contract produced by dotnet/performance's
+ /// stage-bcs-nupkg-aspnetcore.ps1.
+ /// v1 has no musl/osx/arm32 entries.
+ ///
+ internal static readonly IReadOnlyDictionary PlatformToBcsConfigAspNetCore =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["linux-x64"] = ("aspnetcore_x64_linux", "BuildArtifacts_linux_x64_Release_aspnetcore.nupkg", "linux-x64"),
+ ["linux-arm64"] = ("aspnetcore_arm64_linux", "BuildArtifacts_linux_arm64_Release_aspnetcore.nupkg", "linux-arm64"),
+ ["win-x64"] = ("aspnetcore_x64_windows", "BuildArtifacts_windows_x64_Release_aspnetcore.nupkg", "win-x64"),
+ ["win-arm64"] = ("aspnetcore_arm64_windows", "BuildArtifacts_windows_arm64_Release_aspnetcore.nupkg", "win-arm64"),
+ ["win-x86"] = ("aspnetcore_x86_windows", "BuildArtifacts_windows_x86_Release_aspnetcore.nupkg", "win-x86"),
+ };
+
+ ///
+ /// Which BCS repository the buildcache channel resolves from. Selects the platform→config
+ /// map, the latestBuilds.json / artifact path RepoName, and the overlay target inside the
+ /// dotnet home (Runtime → Microsoft.NETCore.App; AspNetCore → Microsoft.AspNetCore.App).
+ ///
+ internal enum BuildCacheFlavor
+ {
+ Runtime,
+ AspNetCore,
+ }
+
+ /// BCS RepoName for each flavour (segment in BCS blob paths).
+ internal const string RepoNameRuntime = "runtime";
+ internal const string RepoNameAspNetCore = "aspnetcore";
+
+ ///
+ /// Maps a BCS RepoName (the per-job buildCacheRepo selector) to a flavour. Anything
+ /// other than "aspnetcore" (including empty / "runtime") resolves to ,
+ /// preserving the proven runtime default.
+ ///
+ internal static BuildCacheFlavor ParseFlavor(string repoName)
+ {
+ return string.Equals(repoName, RepoNameAspNetCore, StringComparison.OrdinalIgnoreCase)
+ ? BuildCacheFlavor.AspNetCore
+ : BuildCacheFlavor.Runtime;
+ }
+
+ /// Selects the platform→config map for a flavour.
+ private static IReadOnlyDictionary GetConfigMap(BuildCacheFlavor flavor)
+ {
+ return flavor == BuildCacheFlavor.AspNetCore ? PlatformToBcsConfigAspNetCore : PlatformToBcsConfig;
+ }
+
+ ///
+ /// All config entries across every flavour. configKeys are globally unique (coreclr_* vs
+ /// aspnetcore_*), so config→artifact and config→RID lookups can search this union without
+ /// the caller needing to know the flavour.
+ ///
+ private static IEnumerable<(string configKey, string artifactFile, string rid)> AllConfigs()
+ {
+ return PlatformToBcsConfig.Values.Concat(PlatformToBcsConfigAspNetCore.Values);
+ }
+
+ ///
+ /// Sentinel thrown for HTTP responses that are definitively not retryable (e.g. 404).
+ /// Distinguishes "the build doesn't exist" from "transient network blip".
+ ///
+ public class BuildCacheNotFoundException : InvalidOperationException
+ {
+ public BuildCacheNotFoundException(string message) : base(message) { }
+ }
+
+ ///
+ /// Thrown when a BCS archive is present but does not contain a complete framework
+ /// (missing managed assemblies, deps.json/runtimeconfig.json). Used by the aspnetcore
+ /// direct-placement path, which deliberately fails loudly rather than producing a partial
+ /// framework — for perf runs, silently running an incomplete framework is worse than erroring.
+ ///
+ public class BuildCacheIncompleteException : InvalidOperationException
+ {
+ public BuildCacheIncompleteException(string message) : base(message) { }
+ }
+
+ ///
+ /// Validates a user-supplied commit SHA. Accepts 8-40 lowercase/uppercase hex chars.
+ ///
+ internal static void ValidateCommitSha(string commitSha)
+ {
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ return;
+ }
+
+ if (!_shaRegex.IsMatch(commitSha))
+ {
+ throw new ArgumentException(
+ $"'{commitSha}' is not a valid commit SHA. Expected 8-40 hex characters.",
+ nameof(commitSha));
+ }
+ }
+
+ ///
+ /// Resolves the commit SHA to use from BCS. If a specific commit is provided, returns it
+ /// (after platform-config inference). Otherwise queries latestBuilds.json for the latest
+ /// commit on the branch.
+ ///
+ public static async Task<(string commitSha, string buildCacheConfig)> ResolveCommitAsync(
+ string baseUrl,
+ string repoName,
+ string branch,
+ string commitSha,
+ string buildCacheConfig,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateCommitSha(commitSha);
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig, ParseFlavor(repoName));
+
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ var latestBuilds = await GetLatestBuildsAsync(baseUrl, repoName, branch, cancellationToken);
+
+ if (latestBuilds.Entries.TryGetValue(buildCacheConfig, out var configEntry) && !string.IsNullOrEmpty(configEntry.CommitSha))
+ {
+ commitSha = configEntry.CommitSha;
+ Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for config '{buildCacheConfig}' on branch '{branch}' (committed {configEntry.CommitTime})");
+ }
+ else if (latestBuilds.Entries.TryGetValue("all", out var allEntry) && !string.IsNullOrEmpty(allEntry.CommitSha))
+ {
+ commitSha = allEntry.CommitSha;
+ Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for all configs on branch '{branch}' (committed {allEntry.CommitTime})");
+ }
+ else
+ {
+ throw new InvalidOperationException(
+ $"Build Cache: No latest build found for branch '{branch}' (config '{buildCacheConfig}'). Check that BCS has builds for this branch.");
+ }
+ }
+ else
+ {
+ Log.Info($"Build Cache: Using specified commit {ShortSha(commitSha)}");
+ }
+
+ return (commitSha, buildCacheConfig);
+ }
+
+ ///
+ /// Downloads and extracts BCS runtime artifacts to a per-call temp directory. The caller
+ /// owns the returned directory and is responsible for deleting it when done.
+ ///
+ public static async Task DownloadAndExtractAsync(
+ string baseUrl,
+ string repoName,
+ string commitSha,
+ string buildCacheConfig,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateCommitSha(commitSha);
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ throw new ArgumentException("commitSha must be provided.", nameof(commitSha));
+ }
+
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig, ParseFlavor(repoName));
+ var artifactFile = GetArtifactFile(buildCacheConfig);
+ var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
+
+ var artifactUrl =
+ $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/buildArtifacts/" +
+ $"{Uri.EscapeDataString(commitSha)}/{Uri.EscapeDataString(buildCacheConfig)}/{Uri.EscapeDataString(artifactFile)}";
+
+ var rootCacheDir = Path.Combine(Path.GetTempPath(), "crank-buildcache");
+ Directory.CreateDirectory(rootCacheDir);
+
+ var safeConfig = SanitizeForPath(buildCacheConfig);
+
+ // Per-(commit,config) lock so two concurrent jobs don't race on the same archive download.
+ var lockKey = $"{commitSha}|{safeConfig}";
+ var gate = _extractLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken);
+ try
+ {
+ var commitDir = Path.Combine(rootCacheDir, commitSha);
+ Directory.CreateDirectory(commitDir);
+
+ var archivePath = Path.Combine(commitDir, $"{safeConfig}-{artifactFile}");
+
+ if (!File.Exists(archivePath))
+ {
+ Log.Info($"Build Cache: Downloading {artifactFile} from {artifactUrl}");
+ await DownloadWithRetryAsync(artifactUrl, archivePath, cancellationToken);
+ Log.Info($"Build Cache: Downloaded {new FileInfo(archivePath).Length / (1024 * 1024)} MB");
+ }
+ else
+ {
+ Log.Info($"Build Cache: Using cached archive at {archivePath}");
+ }
+
+ // Per-call unique extract dir so two concurrent jobs for the same (commit,config)
+ // never delete each other's working tree.
+ var extractDir = Path.Combine(commitDir, $"extracted-{safeConfig}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(extractDir);
+
+ Log.Info($"Build Cache: Extracting archive to {extractDir} ...");
+ await ExtractArchiveAsync(archivePath, extractDir, cancellationToken);
+
+ return extractDir;
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Deletes a previously-extracted directory. Safe to call multiple times. Archives in the
+ /// parent commit dir are intentionally NOT deleted so subsequent jobs for the same commit
+ /// can reuse the download.
+ ///
+ public static void CleanupExtractDir(string extractDir)
+ {
+ if (string.IsNullOrEmpty(extractDir))
+ {
+ return;
+ }
+
+ try
+ {
+ if (Directory.Exists(extractDir))
+ {
+ Directory.Delete(extractDir, recursive: true);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Info($"Build Cache: Failed to clean up extracted dir '{extractDir}': {ex.Message}");
+ }
+ }
+
+ ///
+ /// Builds a per-job dotnet home. The global dotnet home is NOT modified, so concurrent jobs
+ /// and subsequent non-buildcache jobs are unaffected.
+ ///
+ /// For the home mirrors the feed runtime/asp.net/host
+ /// subtrees and OVERLAYS BCS bits onto the base runtime (Microsoft.NETCore.App) + host. The
+ /// runtime archive is raw build output and does not carry the framework's deps.json/
+ /// runtimeconfig.json, so overlay-onto-feed is required (it reuses the feed metadata).
+ ///
+ /// For the ASP.NET Core shared framework
+ /// (Microsoft.AspNetCore.App) is PLACED DIRECTLY from the BCS pack — the aspnetcore archive is
+ /// the runtime-pack nupkg stored verbatim, which carries the managed assemblies AND the
+ /// host-resolvable deps.json/runtimeconfig.json, so the framework folder is built entirely from
+ /// BCS (no feed contribution) and fails loud if incomplete. The base runtime + host stay
+ /// feed-cloned (the aspnetcore pack ships neither).
+ ///
+ /// Absolute path to the per-job dotnet home root. Caller owns it.
+ public static string CreateBuildCacheDotnetHome(
+ string globalDotnetHome,
+ string extractDir,
+ string runtimeVersion,
+ string aspNetCoreVersion,
+ string commitSha,
+ string buildCacheConfig,
+ BuildCacheFlavor flavor = BuildCacheFlavor.Runtime)
+ {
+ if (string.IsNullOrEmpty(runtimeVersion))
+ {
+ throw new ArgumentException("runtimeVersion must be provided.", nameof(runtimeVersion));
+ }
+
+ if (flavor == BuildCacheFlavor.AspNetCore && string.IsNullOrEmpty(aspNetCoreVersion))
+ {
+ throw new ArgumentException(
+ "aspNetCoreVersion must be provided for the aspnetcore build cache flavour (it is the overlay target).",
+ nameof(aspNetCoreVersion));
+ }
+
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig, flavor);
+ var rid = GetRidForConfig(buildCacheConfig);
+
+ // Per-job, never reused across jobs to avoid pollution.
+ var bcsHomeRoot = Path.Combine(
+ Path.GetTempPath(),
+ "crank-buildcache",
+ $"home-{ShortSha(commitSha)}-{SanitizeForPath(buildCacheConfig)}-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(bcsHomeRoot);
+
+ // 1. Copy the dotnet host binary.
+ var dotnetExeName = GetDotnetExecutableName();
+ var srcDotnet = Path.Combine(globalDotnetHome, dotnetExeName);
+ if (File.Exists(srcDotnet))
+ {
+ var dstDotnet = Path.Combine(bcsHomeRoot, dotnetExeName);
+ File.Copy(srcDotnet, dstDotnet, overwrite: true);
+ EnsureExecutable(dstDotnet);
+ }
+
+ // 2. Mirror host/fxr/{runtimeVersion} (small dir, ~1 file).
+ var srcHostFxr = Path.Combine(globalDotnetHome, "host", "fxr", runtimeVersion);
+ var dstHostFxr = Path.Combine(bcsHomeRoot, "host", "fxr", runtimeVersion);
+ if (Directory.Exists(srcHostFxr))
+ {
+ CopyDirectory(srcHostFxr, dstHostFxr);
+ }
+
+ // 3. Mirror shared/Microsoft.NETCore.App/{runtimeVersion}.
+ var srcNetCoreApp = Path.Combine(globalDotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ var dstNetCoreApp = Path.Combine(bcsHomeRoot, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ if (Directory.Exists(srcNetCoreApp))
+ {
+ CopyDirectory(srcNetCoreApp, dstNetCoreApp);
+ }
+
+ // 4. Mirror shared/Microsoft.AspNetCore.App/{aspNetCoreVersion}.
+ // For the RUNTIME flavour this is the non-overridden framework, cloned from the feed.
+ // For the ASPNETCORE flavour we do NOT clone the feed copy — the framework is placed
+ // directly from the BCS pack below, so no feed assembly can leak into the framework
+ // that actually runs.
+ var dstAspNet = string.IsNullOrEmpty(aspNetCoreVersion)
+ ? null
+ : Path.Combine(bcsHomeRoot, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ if (flavor == BuildCacheFlavor.Runtime && !string.IsNullOrEmpty(aspNetCoreVersion))
+ {
+ var srcAspNet = Path.Combine(globalDotnetHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ if (Directory.Exists(srcAspNet))
+ {
+ CopyDirectory(srcAspNet, dstAspNet);
+ }
+ }
+
+ if (flavor == BuildCacheFlavor.AspNetCore)
+ {
+ // 5a. Place the ASP.NET Core shared framework DIRECTLY from the BCS pack. The aspnetcore
+ // artifact is the runtime-pack nupkg stored verbatim, so its bare runtimes/{rid}/lib/
+ // net{X}.0/ already carries the managed assemblies AND the host-resolvable
+ // Microsoft.AspNetCore.App.deps.json + runtimeconfig.json. We build the framework
+ // folder entirely from that (no feed clone) and synthesize .version; the base runtime
+ // + host stay feed-cloned (steps 1-3) since the aspnetcore pack ships neither. Fails
+ // loud if the placed framework is incomplete.
+ try
+ {
+ PlaceAspNetFrameworkFromPack(extractDir, dstAspNet, rid, aspNetCoreVersion, commitSha);
+ }
+ catch
+ {
+ try { Directory.Delete(bcsHomeRoot, recursive: true); } catch { }
+ throw;
+ }
+
+ Log.Info($"Build Cache: Per-job dotnet home built at {bcsHomeRoot} (Microsoft.AspNetCore.App placed directly from BCS commit {ShortSha(commitSha)})");
+ return bcsHomeRoot;
+ }
+
+ // ===== Runtime flavour: overlay BCS bits onto the feed-cloned base runtime. =====
+ int filesOverlaid = 0;
+
+ {
+ // 5. Overlay BCS managed + native into the per-job NETCore.App.
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ if (nugetPackageDir != null)
+ {
+ var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid);
+ if (Directory.Exists(runtimesDir))
+ {
+ filesOverlaid += CopyManaged(runtimesDir, dstNetCoreApp);
+ filesOverlaid += CopyNative(runtimesDir, dstNetCoreApp);
+ }
+ }
+
+ // 6. Overlay BCS host binaries.
+ var corehostDir = FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstNetCoreApp, GetNativeLibName("hostpolicy"));
+
+ if (Directory.Exists(dstHostFxr))
+ {
+ filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstHostFxr, GetNativeLibName("hostfxr"));
+ }
+
+ var dstDotnetHost = Path.Combine(bcsHomeRoot, dotnetExeName);
+ var copied = CopyHostBinaryIfPresent(corehostDir, bcsHomeRoot, dotnetExeName);
+ if (copied > 0)
+ {
+ EnsureExecutable(dstDotnetHost);
+ }
+ filesOverlaid += copied;
+ }
+ }
+
+ if (filesOverlaid == 0)
+ {
+ // The per-job home would be just a copy of the feed runtime with no BCS bits.
+ // Tear it down and let the caller fail the job loudly.
+ try { Directory.Delete(bcsHomeRoot, recursive: true); } catch { }
+ throw new InvalidOperationException(
+ $"Build Cache: overlay copied 0 files for commit {ShortSha(commitSha)} (config '{buildCacheConfig}', rid '{rid}', repo '{flavor}'). " +
+ "The archive layout may have changed or the platform is not supported.");
+ }
+
+ // 7. Rewrite the overlaid runtime's .version so any consumer (the agent's own version
+ // measurement, GetDependencies, etc.) reports the BCS commit.
+ File.WriteAllText(
+ Path.Combine(dstNetCoreApp, ".version"),
+ $"{commitSha}\n{runtimeVersion}\n");
+
+ Log.Info($"Build Cache: Per-job dotnet home built at {bcsHomeRoot} ({filesOverlaid} BCS files overlaid)");
+ return bcsHomeRoot;
+ }
+
+ ///
+ /// Resolves the runtimes/{rid} directory inside an extracted aspnetcore archive. The
+ /// aspnetcore archive is the runtime-pack nupkg stored verbatim, so the directory lives at the
+ /// archive root (runtimes/{rid}). Falls back to the wrapped build-output layout
+ /// (microsoft.aspnetcore.app.runtime.{rid}/Release/runtimes/{rid}) for resilience.
+ ///
+ private static string ResolveAspNetRuntimesDir(string extractDir, string rid)
+ {
+ var bare = Path.Combine(extractDir, "runtimes", rid);
+ if (Directory.Exists(bare))
+ {
+ return bare;
+ }
+
+ var pkg = FindDirectory(extractDir, $"microsoft.aspnetcore.app.runtime.{rid}");
+ if (pkg != null)
+ {
+ var wrapped = Path.Combine(pkg, "Release", "runtimes", rid);
+ if (Directory.Exists(wrapped))
+ {
+ return wrapped;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Builds the ASP.NET Core shared-framework folder directly from the verbatim runtime-pack
+ /// nupkg's runtimes/{rid}/lib/net{X}.0/ + runtimes/{rid}/native/. Copies the
+ /// WHOLE managed set (assemblies + Microsoft.AspNetCore.App.deps.json + runtimeconfig.json,
+ /// skipping only debug symbols), then synthesizes .version carrying the BCS commit.
+ /// Throws if the result is not a complete,
+ /// host-resolvable framework — no feed fallback.
+ ///
+ private static void PlaceAspNetFrameworkFromPack(
+ string extractDir, string destFrameworkDir, string rid, string version, string commitSha)
+ {
+ var runtimesDir = ResolveAspNetRuntimesDir(extractDir, rid);
+ if (runtimesDir == null)
+ {
+ throw new BuildCacheIncompleteException(
+ $"Build Cache: aspnetcore runtime pack (runtimes/{rid}) not found in archive for commit {ShortSha(commitSha)}. " +
+ "The archive may not be the expected verbatim Microsoft.AspNetCore.App.Runtime.{rid} nupkg.");
+ }
+
+ var managedDir = SelectHighestManagedDir(Path.Combine(runtimesDir, "lib"));
+ if (managedDir == null)
+ {
+ throw new BuildCacheIncompleteException(
+ $"Build Cache: no managed lib/net*.0 directory in aspnetcore pack for commit {ShortSha(commitSha)} (rid '{rid}').");
+ }
+
+ Directory.CreateDirectory(destFrameworkDir);
+
+ // Copy the ENTIRE managed set (assemblies + deps.json + runtimeconfig.json). This is the
+ // load-bearing difference from an overlay: the verbatim nupkg carries the host-resolvable
+ // metadata, so the placed folder is a complete shared framework with no feed contribution.
+ foreach (var file in Directory.GetFiles(managedDir))
+ {
+ var name = Path.GetFileName(file);
+ if (name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) ||
+ name.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var dest = Path.Combine(destFrameworkDir, name);
+ File.Copy(file, dest, overwrite: true);
+ EnsureExecutable(dest);
+ }
+
+ // Native (e.g. aspnetcorev2_inprocess on Windows) — optional; 0 if absent.
+ CopyNative(runtimesDir, destFrameworkDir);
+
+ // Synthesize the host-resolvable .version carrying the BCS aspnetcore commit.
+ File.WriteAllText(Path.Combine(destFrameworkDir, ".version"), $"{commitSha}\n{version}\n");
+
+ // Fail-loud: a complete framework needs managed assemblies AND the host-resolvable metadata.
+ void Require(bool condition, string what)
+ {
+ if (!condition)
+ {
+ throw new BuildCacheIncompleteException(
+ $"Build Cache: directly-placed Microsoft.AspNetCore.App for commit {ShortSha(commitSha)} is incomplete — missing {what}. " +
+ "The BCS archive must be the complete runtime-pack nupkg (managed assemblies + deps.json + runtimeconfig.json). " +
+ "Failing the job rather than running an incomplete framework.");
+ }
+ }
+
+ Require(Directory.GetFiles(destFrameworkDir, "*.dll").Length > 0, "managed assemblies (*.dll)");
+ Require(File.Exists(Path.Combine(destFrameworkDir, "Microsoft.AspNetCore.App.deps.json")), "Microsoft.AspNetCore.App.deps.json");
+ Require(File.Exists(Path.Combine(destFrameworkDir, "Microsoft.AspNetCore.App.runtimeconfig.json")), "Microsoft.AspNetCore.App.runtimeconfig.json");
+ }
+
+ ///
+ /// Overlays BCS runtime binaries into a self-contained published output directory. For SCD
+ /// the runtime ships next to the app, so this is the only way to make the benchmark
+ /// actually run BCS bits.
+ ///
+ /// For the base runtime managed + native binaries
+ /// and host (hostpolicy) are overlaid. For only
+ /// the managed (+ optional native) Microsoft.AspNetCore.*.dll set is overlaid — the
+ /// aspnetcore pack ships no host binaries and the base runtime stays the published one.
+ /// The SDK-bound apphost is never replaced (see note below).
+ ///
+ /// Number of files overlaid.
+ public static int OverlayPublishedOutput(
+ string extractDir,
+ string outputFolder,
+ string buildCacheConfig,
+ string assemblyName,
+ BuildCacheFlavor flavor = BuildCacheFlavor.Runtime)
+ {
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig, flavor);
+ var rid = GetRidForConfig(buildCacheConfig);
+ int filesCopied = 0;
+
+ // The aspnetcore artifact is the raw runtime-pack nupkg (runtimes/{rid} at the archive
+ // root); the runtime artifact wraps it in microsoft.netcore.app.runtime.{rid}/Release.
+ string runtimesDir;
+ if (flavor == BuildCacheFlavor.AspNetCore)
+ {
+ runtimesDir = ResolveAspNetRuntimesDir(extractDir, rid);
+ }
+ else
+ {
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ runtimesDir = nugetPackageDir != null ? Path.Combine(nugetPackageDir, "Release", "runtimes", rid) : null;
+ }
+
+ if (runtimesDir != null && Directory.Exists(runtimesDir))
+ {
+ // SCD co-mingles the framework with the app in one folder governed by the app's own
+ // .deps.json, so overlay only the managed *.dll (+ native) — CopyManaged copies *.dll
+ // only, so the framework's deps.json/runtimeconfig.json are intentionally NOT copied.
+ filesCopied += CopyManaged(runtimesDir, outputFolder);
+ filesCopied += CopyNative(runtimesDir, outputFolder);
+ }
+
+ // The aspnetcore pack ships no host binaries, so host overlay only applies to runtime.
+ var corehostDir = flavor == BuildCacheFlavor.AspNetCore ? null : FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetNativeLibName("hostpolicy"));
+
+ // Intentionally NOT replacing the SDK-bound apphost. The BCS archive ships the raw,
+ // unbound apphost (the binary has a placeholder SHA-256 hash where the managed DLL
+ // path is encoded). The SDK's publish step normally invokes HostWriter.CreateAppHost
+ // to bake the managed entry-point path into that binary. Overlaying the raw BCS
+ // apphost on top of the SDK-bound one leaves the executable unable to locate its
+ // managed DLL and the app fails to start with:
+ //
+ // "This executable is not bound to a managed DLL to execute. The binding value
+ // is: ''"
+ //
+ // The perf-relevant runtime code (CoreCLR JIT, GC, managed BCL, hostfxr, hostpolicy)
+ // is still overlaid above. To overlay apphost as well, BCS would need to ship a
+ // pre-bound apphost per project, or the agent would need to invoke the apphost
+ // binder against the BCS apphost using the published app's binding metadata.
+ }
+
+ return filesCopied;
+ }
+
+ // --- HTTP / latestBuilds.json -------------------------------------------------
+
+ private static async Task GetLatestBuildsAsync(
+ string baseUrl, string repoName, string branch, CancellationToken cancellationToken)
+ {
+ var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/');
+ var cacheKey = $"{normalizedBaseUrl}|{repoName}|{branch}";
+
+ if (_latestBuildsCache.TryGetValue(cacheKey, out var cached) &&
+ DateTimeOffset.UtcNow - cached.fetchedAt < _latestBuildsCacheDuration)
+ {
+ return cached.data;
+ }
+
+ // Branch may contain slashes (e.g., "release/10.0"). Escape each segment but keep
+ // the slash semantics so the URL still resolves correctly on the server.
+ var escapedBranch = string.Join("/", branch.Split('/').Select(Uri.EscapeDataString));
+ var url = $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/latest/{escapedBranch}/latestBuilds.json";
+
+ Log.Info($"Build Cache: Fetching latest builds from {url}");
+
+ string json = null;
+
+ // 404s are not transient; pre-check before entering the retry loop.
+ await RetryTransientAsync(DownloadRetryCount, async () =>
+ {
+ using var response = await _httpClient.GetAsync(url, cancellationToken);
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ throw new BuildCacheNotFoundException(
+ $"Build Cache: No latest builds found for branch '{branch}' in repo '{repoName}'. URL: {url}");
+ }
+
+ response.EnsureSuccessStatusCode();
+ json = await response.Content.ReadAsStringAsync(cancellationToken);
+ });
+
+ var latestBuilds = ParseLatestBuilds(json);
+ _latestBuildsCache[cacheKey] = (DateTimeOffset.UtcNow, latestBuilds);
+ return latestBuilds;
+ }
+
+ private static async Task DownloadWithRetryAsync(string url, string destination, CancellationToken cancellationToken)
+ {
+ var partial = destination + ".partial";
+
+ await RetryTransientAsync(DownloadRetryCount, async () =>
+ {
+ if (File.Exists(partial))
+ {
+ File.Delete(partial);
+ }
+
+ using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
+ {
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Definitively not transient; do not retry.
+ throw new BuildCacheNotFoundException(
+ $"Build Cache: Artifact not found at {url}. The build may not exist in the cache.");
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ var expectedLength = response.Content.Headers.ContentLength;
+
+ using (var fileStream = File.Create(partial))
+ {
+ await response.Content.CopyToAsync(fileStream, cancellationToken);
+ }
+
+ if (expectedLength.HasValue)
+ {
+ var actual = new FileInfo(partial).Length;
+ if (actual != expectedLength.Value)
+ {
+ throw new InvalidOperationException(
+ $"Build Cache: Download size mismatch (expected {expectedLength.Value}, got {actual}). URL: {url}");
+ }
+ }
+ }
+
+ if (File.Exists(destination))
+ {
+ File.Delete(destination);
+ }
+
+ File.Move(partial, destination);
+ });
+ }
+
+ ///
+ /// Like
+ /// but rethrows immediately without retrying.
+ ///
+ private static async Task RetryTransientAsync(int retries, Func operation)
+ {
+ var attempts = 0;
+ while (true)
+ {
+ try
+ {
+ attempts++;
+ await operation();
+ return;
+ }
+ catch (BuildCacheNotFoundException)
+ {
+ // Non-retryable: fail fast.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ if (attempts > retries)
+ {
+ throw;
+ }
+
+ Log.Info($"Build Cache: Attempt {attempts} failed: {ex.Message}");
+ }
+ }
+ }
+
+ ///
+ /// Parses the latestBuilds.json format from BCS. The JSON has dynamic keys for each
+ /// build configuration plus a "branch_name" / "BranchName" string property.
+ ///
+ internal static LatestBuildsResponse ParseLatestBuilds(string json)
+ {
+ var result = new LatestBuildsResponse();
+
+ using var doc = JsonDocument.Parse(json);
+
+ foreach (var property in doc.RootElement.EnumerateObject())
+ {
+ if (property.Name.Equals("branch_name", StringComparison.OrdinalIgnoreCase) ||
+ property.Name.Equals("BranchName", StringComparison.Ordinal))
+ {
+ if (property.Value.ValueKind == JsonValueKind.String)
+ {
+ result.BranchName = property.Value.GetString();
+ }
+ continue;
+ }
+
+ if (property.Value.ValueKind == JsonValueKind.Object)
+ {
+ var entry = new LatestBuildEntry
+ {
+ CommitSha = TryGetStringPropertyAnyCase(property.Value, "CommitSha", "commit_sha"),
+ CommitTime = TryGetStringPropertyAnyCase(property.Value, "CommitTime", "commit_time"),
+ };
+
+ result.Entries[property.Name] = entry;
+ }
+ }
+
+ return result;
+ }
+
+ private static string TryGetStringPropertyAnyCase(JsonElement element, params string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String)
+ {
+ return value.GetString();
+ }
+ }
+
+ return null;
+ }
+
+ // --- Extraction ---------------------------------------------------------------
+
+ private static Task ExtractArchiveAsync(string archivePath, string outputDir, CancellationToken cancellationToken)
+ {
+ Directory.CreateDirectory(outputDir);
+
+ if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase))
+ {
+ return ExtractTarGzAsync(archivePath, outputDir, cancellationToken);
+ }
+
+ if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase) ||
+ archivePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.Run(() => ZipFile.ExtractToDirectory(archivePath, outputDir, overwriteFiles: true), cancellationToken);
+ }
+
+ throw new InvalidOperationException($"Unsupported archive format: {archivePath}");
+ }
+
+ private static async Task ExtractTarGzAsync(string archivePath, string outputDir, CancellationToken cancellationToken)
+ {
+ await using var fs = File.OpenRead(archivePath);
+ await using var gz = new GZipStream(fs, CompressionMode.Decompress);
+ await TarFile.ExtractToDirectoryAsync(gz, outputDir, overwriteFiles: true, cancellationToken: cancellationToken);
+ }
+
+ // --- Overlay helpers ----------------------------------------------------------
+
+ ///
+ /// Selects the highest net{X}.0 directory under lib/ using a numeric comparison.
+ /// Lexicographic ordering puts "net9.0" above "net10.0", which would silently overlay
+ /// the wrong managed assemblies if BCS ever ships multiple TFMs.
+ ///
+ internal static string SelectHighestManagedDir(string libDir)
+ {
+ if (!Directory.Exists(libDir))
+ {
+ return null;
+ }
+
+ (int major, int minor, string path) Parse(string dir)
+ {
+ var name = Path.GetFileName(dir);
+ if (name.StartsWith("net", StringComparison.OrdinalIgnoreCase))
+ {
+ var rest = name.Substring(3);
+ var dot = rest.IndexOf('.');
+ if (dot > 0 &&
+ int.TryParse(rest.Substring(0, dot), out var maj) &&
+ int.TryParse(rest.Substring(dot + 1), out var min))
+ {
+ return (maj, min, dir);
+ }
+ }
+
+ return (-1, -1, dir);
+ }
+
+ return Directory.GetDirectories(libDir)
+ .Select(Parse)
+ .OrderByDescending(t => t.major)
+ .ThenByDescending(t => t.minor)
+ .Select(t => t.path)
+ .FirstOrDefault();
+ }
+
+ private static int CopyManaged(string runtimesDir, string destinationDir)
+ {
+ int copied = 0;
+ var libDir = Path.Combine(runtimesDir, "lib");
+ var managedDir = SelectHighestManagedDir(libDir);
+
+ if (managedDir == null)
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destinationDir);
+
+ foreach (var file in Directory.GetFiles(managedDir, "*.dll"))
+ {
+ var dest = Path.Combine(destinationDir, Path.GetFileName(file));
+ File.Copy(file, dest, overwrite: true);
+ copied++;
+ }
+
+ return copied;
+ }
+
+ private static int CopyNative(string runtimesDir, string destinationDir)
+ {
+ int copied = 0;
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ if (!Directory.Exists(nativeDir))
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destinationDir);
+
+ foreach (var file in Directory.GetFiles(nativeDir))
+ {
+ var fileName = Path.GetFileName(file);
+ if (fileName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) ||
+ fileName.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var dest = Path.Combine(destinationDir, fileName);
+ File.Copy(file, dest, overwrite: true);
+ EnsureExecutable(dest);
+ copied++;
+ }
+
+ return copied;
+ }
+
+ private static int CopyHostBinaryIfPresent(string sourceDir, string destDir, string fileName)
+ {
+ var sourcePath = Path.Combine(sourceDir, fileName);
+ if (!File.Exists(sourcePath))
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destDir);
+ var destPath = Path.Combine(destDir, fileName);
+ File.Copy(sourcePath, destPath, overwrite: true);
+ EnsureExecutable(destPath);
+ return 1;
+ }
+
+ ///
+ /// Recursively copies a directory tree. Used to materialize the per-job dotnet home
+ /// from the global feed-installed one. Native files and the dotnet host are chmod'd
+ /// executable on Unix-like systems.
+ ///
+ internal static void CopyDirectory(string sourceDir, string destDir)
+ {
+ Directory.CreateDirectory(destDir);
+
+ foreach (var file in Directory.EnumerateFiles(sourceDir))
+ {
+ var destFile = Path.Combine(destDir, Path.GetFileName(file));
+ File.Copy(file, destFile, overwrite: true);
+ EnsureExecutable(destFile);
+ }
+
+ foreach (var subDir in Directory.EnumerateDirectories(sourceDir))
+ {
+ CopyDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir)));
+ }
+ }
+
+ ///
+ /// On Unix-like systems, ensures the destination file has the user-execute bit set.
+ /// Native libs don't strictly require +x, but the dotnet host and apphost do, and the
+ /// File.Copy + overwrite path can drop the bit if the destination didn't have it.
+ ///
+ private static void EnsureExecutable(string path)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ try
+ {
+ var current = File.GetUnixFileMode(path);
+ var withExec = current
+ | UnixFileMode.UserExecute
+ | UnixFileMode.GroupExecute
+ | UnixFileMode.OtherExecute;
+ if (current != withExec)
+ {
+ File.SetUnixFileMode(path, withExec);
+ }
+ }
+ catch
+ {
+ // Best-effort; some filesystems (FAT, network shares) don't support mode bits.
+ }
+ }
+
+ private static string FindDirectory(string root, string directoryName)
+ {
+ if (!Directory.Exists(root))
+ {
+ return null;
+ }
+
+ foreach (var dir in Directory.GetDirectories(root))
+ {
+ if (Path.GetFileName(dir).Equals(directoryName, StringComparison.OrdinalIgnoreCase))
+ {
+ return dir;
+ }
+ }
+
+ return null;
+ }
+
+ private static string FindCorehostDirectory(string extractDir, string rid)
+ {
+ var primary = Path.Combine(extractDir, $"{rid}.Release", "corehost");
+ if (Directory.Exists(primary))
+ {
+ return primary;
+ }
+
+ var alternate = Path.Combine(extractDir, "corehost");
+ if (Directory.Exists(alternate))
+ {
+ return alternate;
+ }
+
+ return null;
+ }
+
+ // --- Platform / RID mapping ---------------------------------------------------
+
+ private static string ResolveBuildCacheConfig(string buildCacheConfig, BuildCacheFlavor flavor = BuildCacheFlavor.Runtime)
+ {
+ if (!string.IsNullOrEmpty(buildCacheConfig))
+ {
+ return buildCacheConfig;
+ }
+
+ var rid = GetPlatformMoniker();
+ if (GetConfigMap(flavor).TryGetValue(rid, out var mapped))
+ {
+ return mapped.configKey;
+ }
+
+ throw new InvalidOperationException(
+ $"No Build Cache configuration mapping for platform '{rid}' (repo '{flavor}'). Specify buildCacheConfig explicitly.");
+ }
+
+ private static string GetArtifactFile(string buildCacheConfig)
+ {
+ var match = AllConfigs().FirstOrDefault(v =>
+ string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase));
+
+ if (match.artifactFile == null)
+ {
+ throw new InvalidOperationException(
+ $"Unknown Build Cache configuration key: '{buildCacheConfig}'.");
+ }
+
+ return match.artifactFile;
+ }
+
+ ///
+ /// Maps a BCS config key back to its RID. Use this for overlay path discovery so an
+ /// explicit musl/cross-arch override actually finds the right runtime pack inside the
+ /// archive instead of falling back to the host's detected RID. Searches every flavour's
+ /// map since config keys are globally unique.
+ ///
+ internal static string GetRidForConfig(string buildCacheConfig)
+ {
+ var match = AllConfigs().FirstOrDefault(v =>
+ string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase));
+
+ if (match.rid == null)
+ {
+ throw new InvalidOperationException(
+ $"Unknown Build Cache configuration key: '{buildCacheConfig}'.");
+ }
+
+ return match.rid;
+ }
+
+ internal static string GetNativeLibName(string baseName)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return $"{baseName}.dll";
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return $"lib{baseName}.dylib";
+ }
+
+ return $"lib{baseName}.so";
+ }
+
+ private static string GetDotnetExecutableName()
+ => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+
+ internal static string GetPlatformMoniker()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.Arm64 => "win-arm64",
+ Architecture.X86 => "win-x86",
+ _ => "win-x64",
+ };
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64";
+ }
+
+ return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64";
+ }
+
+ private static string SanitizeForPath(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return "default";
+ }
+
+ var invalid = Path.GetInvalidFileNameChars();
+ return string.Concat(value.Select(c => invalid.Contains(c) ? '_' : c));
+ }
+
+ internal static string ShortSha(string commitSha)
+ => string.IsNullOrEmpty(commitSha)
+ ? string.Empty
+ : commitSha.Substring(0, Math.Min(8, commitSha.Length));
+
+ // --- DTOs ---------------------------------------------------------------------
+
+ internal class LatestBuildsResponse
+ {
+ public string BranchName { get; set; }
+ public Dictionary Entries { get; set; } = new(StringComparer.OrdinalIgnoreCase);
+ }
+
+ internal class LatestBuildEntry
+ {
+ public string CommitSha { get; set; }
+ public string CommitTime { get; set; }
+ }
+ }
+}
diff --git a/src/Microsoft.Crank.Agent/JobContext.cs b/src/Microsoft.Crank.Agent/JobContext.cs
index a358b0e42..6959636bb 100644
--- a/src/Microsoft.Crank.Agent/JobContext.cs
+++ b/src/Microsoft.Crank.Agent/JobContext.cs
@@ -32,6 +32,21 @@ public class JobContext
public string DockerImage { get; set; }
public string DockerContainerId { get; set; }
+ ///
+ /// Per-job isolated dotnet home used for buildcache runs. When non-null, the agent should
+ /// run the application using this root (so the BCS-overlaid runtime is loaded), and the
+ /// global dotnet home is left untouched for concurrent jobs. The directory is owned by
+ /// the job and deleted when the job completes.
+ ///
+ public string BuildCacheDotnetHome { get; set; }
+
+ ///
+ /// Temporary directory holding the extracted BCS archive for this job. Deleted at the
+ /// end of the job; the underlying archive in the parent commit directory is kept so
+ /// subsequent jobs for the same commit can reuse it without re-downloading.
+ ///
+ public string BuildCacheExtractDir { get; set; }
+
public ulong EventPipeSessionId { get; set; }
public Task EventPipeTask { get; set; }
public bool EventPipeTerminated { get; set; }
diff --git a/src/Microsoft.Crank.Agent/Startup.cs b/src/Microsoft.Crank.Agent/Startup.cs
index 8168a2e88..839ee1623 100644
--- a/src/Microsoft.Crank.Agent/Startup.cs
+++ b/src/Microsoft.Crank.Agent/Startup.cs
@@ -109,6 +109,11 @@ public class Startup
"https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2"
];
+ // Build Cache Service configuration
+ private static string _buildCacheBaseUrl = "https://pvscmdupload.z22.web.core.windows.net";
+ private static string _buildCacheRepoName = "runtime";
+ private static bool _buildCacheEnabled = true;
+
// Cached lists of SDKs and runtimes already installed
private static readonly HashSet _installedAspNetRuntimes = new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet _installedDotnetRuntimes = new(StringComparer.OrdinalIgnoreCase);
@@ -270,6 +275,10 @@ public static int Main(string[] args)
_certSniAuth = app.Option("--cert-sni", "Enable subject name / issuer based authentication (SNI).", CommandOptionType.NoValue);
_managedIdentityClientId = app.Option("--mi-client-id", "Client ID of the user-assigned managed identity to use for authentication.", CommandOptionType.SingleValue);
+ var buildCacheBaseUrlOption = app.Option("--build-cache-base-url", $"Base URL for Build Cache Service blob storage. Default is '{_buildCacheBaseUrl}'.", CommandOptionType.SingleValue);
+ var buildCacheRepoNameOption = app.Option("--build-cache-repo-name", $"Repository name for Build Cache Service. Default is '{_buildCacheRepoName}'.", CommandOptionType.SingleValue);
+ var buildCacheDisabledOption = app.Option("--build-cache-disabled", "Disable Build Cache Service integration.", CommandOptionType.NoValue);
+
app.OnExecute(() =>
{
var logConf = new LoggerConfiguration()
@@ -278,6 +287,21 @@ public static int Main(string[] args)
.Enrich.FromLogContext()
.WriteTo.Console(theme: AnsiConsoleTheme.Code);
+ if (buildCacheBaseUrlOption.HasValue())
+ {
+ _buildCacheBaseUrl = buildCacheBaseUrlOption.Value();
+ }
+
+ if (buildCacheRepoNameOption.HasValue())
+ {
+ _buildCacheRepoName = buildCacheRepoNameOption.Value();
+ }
+
+ if (buildCacheDisabledOption.HasValue())
+ {
+ _buildCacheEnabled = false;
+ }
+
if (_runAsService.HasValue() && OperatingSystem != OperatingSystem.Windows)
{
throw new PlatformNotSupportedException($"--service is only available on Windows");
@@ -1015,7 +1039,7 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca
{
buildAndRunTask = Task.Run(async () =>
{
- benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, cts.Token);
+ benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, context, cts.Token);
if (benchmarksDir == null)
{
@@ -1027,7 +1051,10 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca
{
try
{
- process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, _dotnethome, context);
+ // For buildcache jobs the per-job isolated dotnet home holds the
+ // BCS-overlaid runtime; run against it instead of the global home.
+ var runtimeDotnetHome = context.BuildCacheDotnetHome ?? _dotnethome;
+ process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, runtimeDotnetHome, context);
Log.Info($"Process started: {job.ProcessId}");
@@ -1910,6 +1937,22 @@ async Task DeleteJobAsync()
await TryDeleteDirAsync(tempDir);
}
+ // Build Cache: clean up per-job extracted artifacts and the isolated
+ // dotnet home so concurrent / future jobs do not see stale state and
+ // /tmp does not accumulate multi-GB extracts.
+ if (_cleanup && !job.NoClean)
+ {
+ BuildCacheClient.CleanupExtractDir(context.BuildCacheExtractDir);
+
+ if (!string.IsNullOrEmpty(context.BuildCacheDotnetHome))
+ {
+ await TryDeleteDirAsync(context.BuildCacheDotnetHome);
+ }
+
+ context.BuildCacheExtractDir = null;
+ context.BuildCacheDotnetHome = null;
+ }
+
// Delete temporary attachment files
// NB: Attachments are already deleted once they are copied, unless the job fails
// to reach that point.
@@ -2832,7 +2875,7 @@ private static async Task DockerCleanUpAsync(string containerId, string imageNam
}
}
- private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, CancellationToken cancellationToken = default)
+ private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, JobContext jobContext = null, CancellationToken cancellationToken = default)
{
var reuseFolder = await RetrieveSourcesAsync(job, path);
@@ -2928,28 +2971,148 @@ private static async Task CloneRestoreAndBuild(string path, Job job, str
}
}
+ // Build Cache Service flavour selection. The per-job buildCacheRepo selector (falling
+ // back to the agent-level --build-cache-repo-name default) decides which BCS repository
+ // the buildcache channel resolves from: "runtime" overrides Microsoft.NETCore.App,
+ // "aspnetcore" overrides Microsoft.AspNetCore.App.
+ var buildCacheRepo = !String.IsNullOrEmpty(job.BuildCacheRepo) ? job.BuildCacheRepo : _buildCacheRepoName;
+ var buildCacheFlavor = BuildCacheClient.ParseFlavor(buildCacheRepo);
+ var isBuildCacheChannel = String.Equals(channel, "buildcache", StringComparison.OrdinalIgnoreCase);
+ var isAspNetCoreBuildCache = isBuildCacheChannel && buildCacheFlavor == BuildCacheClient.BuildCacheFlavor.AspNetCore;
+
if (String.IsNullOrEmpty(runtimeVersion))
{
- runtimeVersion = channel;
+ // For the aspnetcore flavour the base runtime resolves to a real feed version; the
+ // BCS sentinel rides on aspNetCoreVersion instead of runtimeVersion.
+ runtimeVersion = isAspNetCoreBuildCache ? "latest" : channel;
}
+ // For buildcache channel, the components NOT overridden by BCS use "latest" from feeds.
+ var nonRuntimeChannel = isBuildCacheChannel ? "latest" : channel;
+
if (String.IsNullOrEmpty(desktopVersion))
{
- desktopVersion = channel;
+ desktopVersion = nonRuntimeChannel;
}
if (String.IsNullOrEmpty(aspNetCoreVersion))
{
- aspNetCoreVersion = channel;
+ // aspnetcore flavour: the buildcache sentinel rides on aspNetCoreVersion (mirrors how
+ // the runtime flavour puts the sentinel on runtimeVersion via the channel).
+ aspNetCoreVersion = isAspNetCoreBuildCache ? "buildcache" : nonRuntimeChannel;
}
if (String.IsNullOrEmpty(sdkVersion))
{
- sdkVersion = channel;
+ sdkVersion = nonRuntimeChannel;
}
runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, runtimeVersion);
+ // Build Cache Service: if either the runtime version is "BuildCache" (runtime flavour) or
+ // the asp.net version is "buildcache" (aspnetcore flavour), prepare BCS artifacts and
+ // resolve a real "Latest" version for the overridden component's NuGet build.
+ var useBuildCacheRuntime = String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase);
+ var useBuildCacheAspNet = String.Equals(aspNetCoreVersion, "buildcache", StringComparison.OrdinalIgnoreCase);
+ var useBuildCache = useBuildCacheRuntime || useBuildCacheAspNet;
+
+ // The selector is authoritative for which component BCS overrides. In the rare case both
+ // sentinels are explicitly requested (Q2: v1 overrides only one component per job), honour
+ // the selector's flavour and skip the other below.
+ if (useBuildCacheAspNet && !useBuildCacheRuntime)
+ {
+ buildCacheFlavor = BuildCacheClient.BuildCacheFlavor.AspNetCore;
+ }
+ else if (useBuildCacheRuntime && !useBuildCacheAspNet)
+ {
+ buildCacheFlavor = BuildCacheClient.BuildCacheFlavor.Runtime;
+ }
+ string buildCacheCommitSha = null;
+ string buildCacheExtractDir = null;
+
+ string buildCacheConfigResolved = null;
+ if (useBuildCache)
+ {
+ if (!_buildCacheEnabled)
+ {
+ job.Error = "Build Cache channel was requested but Build Cache Service is disabled on this agent (--build-cache-disabled).";
+ return null;
+ }
+
+ // Validate user-supplied commit SHA early so we can fail with a clear message instead
+ // of throwing later from a Substring call.
+ if (!string.IsNullOrEmpty(job.BuildCacheCommitSha) && job.BuildCacheCommitSha.Length < 8)
+ {
+ job.Error = $"Build Cache: 'buildCacheCommitSha' must be at least 8 characters long (got '{job.BuildCacheCommitSha}').";
+ return null;
+ }
+
+ try
+ {
+ var branch = !string.IsNullOrEmpty(job.BuildCacheBranch) ? job.BuildCacheBranch : "main";
+ var commitSha = job.BuildCacheCommitSha;
+ var buildCacheConfig = job.BuildCacheConfig;
+
+ // Resolve which commit and config to use. ResolveCommitAsync/DownloadAndExtractAsync
+ // derive the flavour (and therefore the config map + RepoName path segment) from the
+ // repoName we pass, so "runtime" and "aspnetcore" route to their own BCS blobs.
+ var resolved = await BuildCacheClient.ResolveCommitAsync(
+ _buildCacheBaseUrl, buildCacheRepo, branch, commitSha, buildCacheConfig, cancellationToken);
+
+ buildCacheCommitSha = resolved.commitSha;
+ buildCacheConfigResolved = resolved.buildCacheConfig;
+
+ // Download and extract the BCS artifacts to a per-job temp directory
+ buildCacheExtractDir = await BuildCacheClient.DownloadAndExtractAsync(
+ _buildCacheBaseUrl, buildCacheRepo, buildCacheCommitSha, buildCacheConfigResolved,
+ cancellationToken);
+
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+ Log.Info($"Build Cache: Artifacts for commit {shortSha} (repo '{buildCacheRepo}') ready for post-build overlay");
+
+ if (buildCacheFlavor == BuildCacheClient.BuildCacheFlavor.AspNetCore)
+ {
+ // The ASP.NET Core shared-framework VERSION (folder name) is feed-resolved
+ // (Latest) so PatchRuntimeConfig + the app's framework reference resolve to a real
+ // shared/Microsoft.AspNetCore.App/{ver} dir; the framework CONTENTS are then placed
+ // DIRECTLY from the BCS pack into that folder (the feed copy is not used). Set
+ // "Latest" here so ResolveAspNetCoreVersion below turns it into a real version.
+ // runtimeVersion already holds a real feed runtime version.
+ aspNetCoreVersion = "Latest";
+ Log.Info($"ASP.NET Core for build: version resolved from feeds, framework placed directly from BCS commit {shortSha}");
+
+ // Q2: v1 overrides only one BCS component per job. If a runtime sentinel was also
+ // requested, honour the selector (aspnetcore) and let the base runtime resolve normally.
+ if (useBuildCacheRuntime)
+ {
+ Log.Info("Build Cache: both runtime and aspnetcore overrides were requested; honouring buildCacheRepo='aspnetcore' and resolving the base runtime from feeds.");
+ runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, "Latest");
+ }
+ }
+ else
+ {
+ // Resolve a REAL runtime version from feeds for the NuGet build. We deliberately keep
+ // runtimeVersion pointing at this feed-resolved version so PatchRuntimeConfig and the
+ // dotnet-install steps agree; the BCS bits are overlaid on top of that exact version.
+ runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, "Latest");
+ Log.Info($"Runtime for build: {runtimeVersion} (Latest from feeds, will be overlaid with BCS commit {shortSha})");
+
+ // Q2: if an aspnetcore sentinel was also requested, honour the selector (runtime)
+ // and let asp.net resolve normally from feeds.
+ if (useBuildCacheAspNet)
+ {
+ Log.Info("Build Cache: both runtime and aspnetcore overrides were requested; honouring buildCacheRepo='runtime' and resolving asp.net from feeds.");
+ aspNetCoreVersion = nonRuntimeChannel;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: {ex.Message}";
+ return null;
+ }
+ }
+
sdkVersion = await ResolveSdkVersion(sdkVersion, targetFramework);
aspNetCoreVersion = await ResolveAspNetCoreVersion(aspNetCoreVersion, targetFramework);
@@ -3206,6 +3369,45 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
var dotnetDir = dotnetHome;
+ // Build Cache: build a per-job dotnet home that contains BCS-overlaid runtime + asp.net
+ // + host. We DO NOT mutate the global dotnet home — concurrent jobs and subsequent
+ // non-buildcache jobs must remain unaffected. The publish step continues to use the
+ // global dotnetDir (it has the SDK); only the runtime-resolution paths (metadata
+ // reading, crossgen/symbols emit, StartProcess) point at the per-job home.
+ var runtimeHomeDir = dotnetDir;
+
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ try
+ {
+ var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome(
+ dotnetDir,
+ buildCacheExtractDir,
+ runtimeVersion,
+ aspNetCoreVersion,
+ buildCacheCommitSha,
+ job.BuildCacheConfig,
+ buildCacheFlavor);
+
+ runtimeHomeDir = bcsHome;
+
+ // Stash on the JobContext so StartProcess uses this isolated home (FDD) and
+ // so the cleanup pass at job end can delete it.
+ if (jobContext != null)
+ {
+ jobContext.BuildCacheDotnetHome = bcsHome;
+ jobContext.BuildCacheExtractDir = buildCacheExtractDir;
+ }
+
+ Log.Info($"Build Cache: Isolated dotnet home: {bcsHome}");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: failed to build isolated dotnet home: {ex.Message}";
+ return null;
+ }
+ }
+
// Updating Job to reflect actual versions used
job.AspNetCoreVersion = aspNetCoreVersion;
job.RuntimeVersion = runtimeVersion;
@@ -3239,7 +3441,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
try
{
- var aspNetCoreVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version");
+ var aspNetCoreVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version");
(_, var aspnetCoreCommitHash) = await ParseLatestVersionFile(aspNetCoreVersionFileName);
job.Metadata.Enqueue(new MeasurementMetadata
@@ -3272,7 +3474,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
try
{
- var netCoreAppVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version");
+ var netCoreAppVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version");
(_, var netCoreAppCommitHash) = await ParseLatestVersionFile(netCoreAppVersionFileName);
job.Metadata.Enqueue(new MeasurementMetadata
@@ -3434,6 +3636,60 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
Log.Info($"Application published successfully in {job.BuildTime.TotalMilliseconds} ms");
+ // Build Cache: overlay BCS runtime binaries onto the just-published app. The
+ // per-job dotnet home was built earlier (used for FDD execution + metadata);
+ // here we cover the SCD case where the runtime ships in the publish output.
+ // PatchRuntimeConfig still runs with the feed-resolved runtimeVersion so
+ // runtimeconfig.json points to a real installed shared-framework dir.
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+
+ int publishedOverlay;
+ try
+ {
+ var publishProjectFileName = Path.Combine(benchmarkedApp, FormatPathSeparators(job.Project));
+ var assemblyName = GetAssemblyName(job, publishProjectFileName);
+
+ publishedOverlay = BuildCacheClient.OverlayPublishedOutput(
+ buildCacheExtractDir,
+ outputFolder,
+ job.BuildCacheConfig,
+ assemblyName,
+ buildCacheFlavor);
+
+ Log.Info($"Build Cache: Overlaid {publishedOverlay} files into published output (commit {shortSha})");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: published-output overlay failed: {ex.Message}";
+ return null;
+ }
+
+ // For self-contained publishes the published output must contain runtime binaries
+ // (managed + native + apphost). For framework-dependent publishes 0 is acceptable
+ // here because the per-job dotnet home already provides BCS bits at runtime.
+ if (job.SelfContained && publishedOverlay == 0)
+ {
+ job.Error = $"Build Cache: published-output overlay copied 0 files for self-contained " +
+ $"commit {shortSha}. The archive layout may have changed or the platform is not supported.";
+ return null;
+ }
+
+ // Record the BCS commit alongside the overridden component's version for reporting.
+ // We append rather than replace so PatchRuntimeConfig still sees a valid feed-resolved
+ // version below. For the aspnetcore flavour annotate the asp.net version field (and
+ // leave the runtime version as the feed runtime); for runtime annotate the runtime field.
+ if (buildCacheFlavor == BuildCacheClient.BuildCacheFlavor.AspNetCore)
+ {
+ job.AspNetCoreVersion = $"{aspNetCoreVersion}+buildcache.{shortSha}";
+ }
+ else
+ {
+ job.RuntimeVersion = $"{runtimeVersion}+buildcache.{shortSha}";
+ }
+ }
+
PatchRuntimeConfig(job, outputFolder, aspNetCoreVersion, runtimeVersion);
}
@@ -3532,7 +3788,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
{
var crossgenFolder = job.SelfContained
? outputFolder
- : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
+ : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
;
var crossgenFilename = Path.Combine(crossgenFolder, "crossgen");
@@ -3560,7 +3816,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
var symbolsFolder = job.SelfContained
? outputFolder
- : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
+ : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion)
;
// dotnet symbol --symbols --output mySymbols /usr/share/dotnet/shared/Microsoft.NETCore.App/2.1.0/lib*.so
@@ -3958,6 +4214,14 @@ private static async Task ResolveAspNetCoreVersion(string aspNetCoreVers
switch (aspNetCoreVersion.ToLowerInvariant())
{
+ case "buildcache":
+ // Defensive: the aspnetcore buildcache flavour rewrites aspNetCoreVersion to "Latest"
+ // in the BCS prep block before this point, so this normally isn't hit. Treat the raw
+ // sentinel as Latest so the shared-framework version still resolves to a real feed
+ // version (the folder the BCS bits overlay onto).
+ aspNetCoreVersion = await ResolveAspNetCoreVersion("Latest", targetFramework);
+ Log.Info($"ASP.NET: {aspNetCoreVersion} (BuildCache → Latest)");
+ break;
case "current":
aspNetCoreVersion = string.IsNullOrEmpty(currentAspNetCoreVersion)
? await ResolveAspNetCoreVersion("Latest", targetFramework)
@@ -4582,6 +4846,13 @@ private static async Task ResolveRuntimeVersion(string buildToolsPath, s
break;
}
}
+ else if (String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase))
+ {
+ // BuildCache channel: version resolution is deferred to InstallRuntimeFromBuildCacheAsync
+ // because it needs to download artifacts. We return a placeholder here.
+ runtimeVersion = "BuildCache";
+ Log.Info($"Runtime: will be resolved from Build Cache Service");
+ }
else
{
// Custom version
diff --git a/src/Microsoft.Crank.Models/Job.cs b/src/Microsoft.Crank.Models/Job.cs
index 1128cd0f0..70fca9a6d 100644
--- a/src/Microsoft.Crank.Models/Job.cs
+++ b/src/Microsoft.Crank.Models/Job.cs
@@ -71,6 +71,16 @@ public class Job
public string UseMonoRuntime { get; set; } = "";
public bool NoGlobalJson { get; set; }
+ // Build Cache Service properties for per-commit runtime resolution
+ public string BuildCacheCommitSha { get; set; } = "";
+ public string BuildCacheBranch { get; set; } = "";
+ public string BuildCacheConfig { get; set; } = "";
+
+ // Selects which BCS repository the buildcache channel resolves from: "runtime" (default,
+ // overrides Microsoft.NETCore.App) or "aspnetcore" (overrides Microsoft.AspNetCore.App).
+ // Empty falls back to the agent-level --build-cache-repo-name default.
+ public string BuildCacheRepo { get; set; } = "";
+
// Delay from the process started to the console receiving "Application started"
public TimeSpan StartupMainMethod { get; set; }
public TimeSpan BuildTime { get; set; }
@@ -399,7 +409,11 @@ public BuildKeyData GetBuildKeyData()
DockerPull = DockerPull,
DockerFile = DockerFile,
DockerImageName = DockerImageName,
- DockerContextDirectory = DockerContextDirectory
+ DockerContextDirectory = DockerContextDirectory,
+ BuildCacheCommitSha = BuildCacheCommitSha,
+ BuildCacheBranch = BuildCacheBranch,
+ BuildCacheConfig = BuildCacheConfig,
+ BuildCacheRepo = BuildCacheRepo
};
}
@@ -508,5 +522,9 @@ public class BuildKeyData
public string DockerFile { get; set; }
public string DockerImageName { get; set; }
public string DockerContextDirectory { get; set; }
+ public string BuildCacheCommitSha { get; set; }
+ public string BuildCacheBranch { get; set; }
+ public string BuildCacheConfig { get; set; }
+ public string BuildCacheRepo { get; set; }
}
}
diff --git a/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
new file mode 100644
index 000000000..50a94e6c4
--- /dev/null
+++ b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
@@ -0,0 +1,814 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using Microsoft.Crank.Agent;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Microsoft.Crank.UnitTests
+{
+ public class BuildCacheClientTests : IDisposable
+ {
+ private readonly ITestOutputHelper _output;
+ private readonly string _testDir;
+
+ public BuildCacheClientTests(ITestOutputHelper output)
+ {
+ _output = output;
+ _testDir = Path.Combine(Path.GetTempPath(), "crank_buildcache_tests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_testDir);
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_testDir))
+ {
+ Directory.Delete(_testDir, true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // ParseLatestBuilds
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void ParseLatestBuilds_PascalCase_ParsesCommitShaAndTime()
+ {
+ const string json = """
+ {
+ "BranchName": "main",
+ "coreclr_x64_linux": {
+ "CommitSha": "abc123def456",
+ "CommitTime": "2025-01-01T00:00:00Z"
+ }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal("main", result.BranchName);
+ Assert.Equal("abc123def456", result.Entries["coreclr_x64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_SnakeCase_ParsesCommitShaAndTime()
+ {
+ const string json = """
+ {
+ "branch_name": "release/10.0",
+ "coreclr_arm64_linux": {
+ "commit_sha": "deadbeef",
+ "commit_time": "2025-02-02T00:00:00Z"
+ }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal("release/10.0", result.BranchName);
+ Assert.Equal("deadbeef", result.Entries["coreclr_arm64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_MixedCasing_ParsesAllConfigs()
+ {
+ const string json = """
+ {
+ "branch_name": "main",
+ "coreclr_x64_windows": { "CommitSha": "win123", "CommitTime": "2025-03-03" },
+ "coreclr_x64_linux": { "commit_sha": "lnx456", "commit_time": "2025-04-04" }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal(2, result.Entries.Count);
+ Assert.Equal("win123", result.Entries["coreclr_x64_windows"].CommitSha);
+ Assert.Equal("lnx456", result.Entries["coreclr_x64_linux"].CommitSha);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_NonObjectValues_AreSkipped()
+ {
+ const string json = """
+ {
+ "branch_name": "main",
+ "schemaVersion": 2,
+ "lastUpdated": "2025-01-01",
+ "coreclr_x64_linux": { "CommitSha": "abc" }
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Single(result.Entries);
+ Assert.True(result.Entries.ContainsKey("coreclr_x64_linux"));
+ }
+
+ // -------------------------------------------------------------------
+ // ValidateCommitSha
+ // -------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("abcdef12")] // min length
+ [InlineData("ABCDEF12")] // upper hex
+ [InlineData("603403d9cb49d3d1c35b56bcff024ce99a8c5c3a")] // full 40
+ public void ValidateCommitSha_AcceptsValid(string sha)
+ {
+ BuildCacheClient.ValidateCommitSha(sha);
+ }
+
+ [Theory]
+ [InlineData("abc")] // too short
+ [InlineData("ghijklmn")] // non-hex
+ [InlineData("abcd 1234")] // contains space
+ [InlineData("../../../etc/passwd")] // path traversal attempt
+ [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] // 41 chars, too long
+ public void ValidateCommitSha_RejectsInvalid(string sha)
+ {
+ Assert.Throws(() => BuildCacheClient.ValidateCommitSha(sha));
+ }
+
+ // -------------------------------------------------------------------
+ // ShortSha
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void ShortSha_LongInput_ReturnsFirstEight()
+ {
+ Assert.Equal("abcdef12", BuildCacheClient.ShortSha("abcdef1234567890"));
+ }
+
+ [Fact]
+ public void ShortSha_ShortInput_ReturnsAsIs()
+ {
+ Assert.Equal("abc", BuildCacheClient.ShortSha("abc"));
+ }
+
+ [Fact]
+ public void ShortSha_NullOrEmpty_ReturnsEmpty()
+ {
+ Assert.Equal(string.Empty, BuildCacheClient.ShortSha(null));
+ Assert.Equal(string.Empty, BuildCacheClient.ShortSha(""));
+ }
+
+ // -------------------------------------------------------------------
+ // Platform / RID mapping
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void GetPlatformMoniker_ReturnsKnownRid()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+
+ var validRids = new[]
+ {
+ "linux-x64", "linux-arm64",
+ "win-x64", "win-arm64", "win-x86",
+ "osx-x64", "osx-arm64",
+ };
+
+ Assert.Contains(rid, validRids);
+ }
+
+ [Theory]
+ [InlineData("coreclr_x64_linux", "linux-x64")]
+ [InlineData("coreclr_arm64_linux", "linux-arm64")]
+ [InlineData("coreclr_muslx64_linux", "linux-musl-x64")]
+ [InlineData("coreclr_x64_windows", "win-x64")]
+ [InlineData("coreclr_arm64_windows", "win-arm64")]
+ [InlineData("coreclr_x86_windows", "win-x86")]
+ public void GetRidForConfig_ReturnsMatchingRid(string configKey, string expectedRid)
+ {
+ Assert.Equal(expectedRid, BuildCacheClient.GetRidForConfig(configKey));
+ }
+
+ [Fact]
+ public void GetRidForConfig_UnknownConfig_Throws()
+ {
+ Assert.Throws(() => BuildCacheClient.GetRidForConfig("totally_unknown"));
+ }
+
+ // -------------------------------------------------------------------
+ // ASP.NET Core config map (locked external contract — see
+ // dotnet/performance stage-bcs-nupkg-aspnetcore.ps1). Pins configKey / RID /
+ // artifact filename so an accidental token change is caught.
+ // -------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("linux-x64", "aspnetcore_x64_linux", "BuildArtifacts_linux_x64_Release_aspnetcore.nupkg")]
+ [InlineData("linux-arm64", "aspnetcore_arm64_linux", "BuildArtifacts_linux_arm64_Release_aspnetcore.nupkg")]
+ [InlineData("win-x64", "aspnetcore_x64_windows", "BuildArtifacts_windows_x64_Release_aspnetcore.nupkg")]
+ [InlineData("win-arm64", "aspnetcore_arm64_windows", "BuildArtifacts_windows_arm64_Release_aspnetcore.nupkg")]
+ [InlineData("win-x86", "aspnetcore_x86_windows", "BuildArtifacts_windows_x86_Release_aspnetcore.nupkg")]
+ public void PlatformToBcsConfigAspNetCore_MatchesLockedContract(string rid, string expectedConfigKey, string expectedArtifact)
+ {
+ Assert.True(BuildCacheClient.PlatformToBcsConfigAspNetCore.TryGetValue(rid, out var entry), $"Missing aspnetcore entry for '{rid}'.");
+ Assert.Equal(expectedConfigKey, entry.configKey);
+ Assert.Equal(expectedArtifact, entry.artifactFile);
+ Assert.Equal(rid, entry.rid);
+ }
+
+ [Fact]
+ public void PlatformToBcsConfigAspNetCore_HasExactlyTheFiveV1Platforms()
+ {
+ var rids = BuildCacheClient.PlatformToBcsConfigAspNetCore.Keys.OrderBy(k => k, StringComparer.Ordinal).ToArray();
+ Assert.Equal(
+ new[] { "linux-arm64", "linux-x64", "win-arm64", "win-x64", "win-x86" },
+ rids);
+
+ // v1 explicitly excludes musl / osx / arm32.
+ Assert.DoesNotContain(BuildCacheClient.PlatformToBcsConfigAspNetCore.Keys, k => k.Contains("musl", StringComparison.OrdinalIgnoreCase));
+ Assert.DoesNotContain(BuildCacheClient.PlatformToBcsConfigAspNetCore.Keys, k => k.StartsWith("osx", StringComparison.OrdinalIgnoreCase));
+ }
+
+ // -------------------------------------------------------------------
+ // SelectHighestManagedDir (numeric-aware)
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void SelectHighestManagedDir_NumericOrderNotLexicographic()
+ {
+ var libDir = Path.Combine(_testDir, "lib");
+ Directory.CreateDirectory(Path.Combine(libDir, "net8.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net9.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net10.0"));
+ Directory.CreateDirectory(Path.Combine(libDir, "net11.0"));
+
+ // Lexicographic: net9.0 > net8.0 > net11.0 > net10.0 (wrong).
+ // Numeric: net11.0 > net10.0 > net9.0 > net8.0 (correct).
+ var selected = BuildCacheClient.SelectHighestManagedDir(libDir);
+
+ Assert.Equal("net11.0", Path.GetFileName(selected));
+ }
+
+ [Fact]
+ public void SelectHighestManagedDir_NoDirs_ReturnsNull()
+ {
+ var libDir = Path.Combine(_testDir, "empty-lib");
+ Directory.CreateDirectory(libDir);
+
+ Assert.Null(BuildCacheClient.SelectHighestManagedDir(libDir));
+ }
+
+ [Fact]
+ public void SelectHighestManagedDir_MissingDir_ReturnsNull()
+ {
+ Assert.Null(BuildCacheClient.SelectHighestManagedDir(Path.Combine(_testDir, "does-not-exist")));
+ }
+
+ // -------------------------------------------------------------------
+ // OverlayPublishedOutput
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void OverlayPublishedOutput_CopiesRuntimeFilesAndHostpolicyButNotApphost()
+ {
+ // The BCS archive ships an unbound apphost (the SDK normally binds the published
+ // managed DLL path into the executable during publish). Overlaying the raw BCS apphost
+ // on top of the SDK-bound one breaks the published app, so we deliberately skip it.
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: true);
+
+ var outputFolder = Path.Combine(_testDir, "published");
+ Directory.CreateDirectory(outputFolder);
+
+ // Pre-existing SDK-bound apphost that must NOT be overwritten.
+ var apphostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MyApp.exe" : "MyApp";
+ File.WriteAllText(Path.Combine(outputFolder, apphostName), "SDK_BOUND_APPHOST");
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+
+ // managed + native + hostpolicy (no apphost contribution)
+ Assert.True(copied >= managed.Count + native.Count + 1);
+
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, dll)), $"Missing managed file {dll}");
+ }
+ foreach (var n in native)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, n)), $"Missing native file {n}");
+ }
+
+ Assert.True(File.Exists(Path.Combine(outputFolder, BuildCacheClient.GetNativeLibName("hostpolicy"))));
+
+ // SDK-bound apphost preserved.
+ Assert.Equal("SDK_BOUND_APPHOST", File.ReadAllText(Path.Combine(outputFolder, apphostName)));
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_EmptyExtract_ReturnsZero()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+
+ var extractDir = Path.Combine(_testDir, "empty");
+ Directory.CreateDirectory(extractDir);
+
+ var outputFolder = Path.Combine(_testDir, "output");
+ Directory.CreateDirectory(outputFolder);
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+ Assert.Equal(0, copied);
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_SkipsPdbAndDbg()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, runtimesDir, _, _) = BuildFakeBcsArchive(rid, includeHost: false, includeApphost: false);
+
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ File.WriteAllText(Path.Combine(nativeDir, "coreclr.pdb"), "pdb");
+ File.WriteAllText(Path.Combine(nativeDir, "libcoreclr.dbg"), "dbg");
+
+ var outputFolder = Path.Combine(_testDir, "published-pdb");
+ Directory.CreateDirectory(outputFolder);
+
+ BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp");
+
+ Assert.False(File.Exists(Path.Combine(outputFolder, "coreclr.pdb")));
+ Assert.False(File.Exists(Path.Combine(outputFolder, "libcoreclr.dbg")));
+ }
+
+ // -------------------------------------------------------------------
+ // CreateBuildCacheDotnetHome — the heart of round 3
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_MirrorsGlobalAndOverlaysBcs()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5.26256.117";
+ const string aspNetCoreVersion = "11.0.0-preview.5.26256.117";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion);
+ var commitSha = "603403d9cb49d3d1c35b56bcff024ce99a8c5c3a";
+
+ var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion, commitSha, configKey);
+
+ try
+ {
+ // 1. Global dotnet home must NOT be touched (no cross-job pollution).
+ var globalVersion = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ Assert.Contains("FEED_COMMIT", globalVersion);
+ Assert.DoesNotContain(commitSha, globalVersion);
+
+ // 2. Per-job home exists with BCS overlay applied.
+ Assert.True(Directory.Exists(bcsHome));
+ var bcsNetCoreApp = Path.Combine(bcsHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, dll)), $"Missing BCS managed {dll}");
+ }
+ foreach (var n in native)
+ {
+ Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, n)), $"Missing BCS native {n}");
+ }
+
+ // 3. .version was rewritten with BCS commit.
+ var bcsVersion = File.ReadAllText(Path.Combine(bcsNetCoreApp, ".version"));
+ Assert.Contains(commitSha, bcsVersion);
+
+ // 4. ASP.NET Core dir was mirrored (from global, not overlaid).
+ Assert.True(Directory.Exists(Path.Combine(bcsHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion)));
+
+ // 5. dotnet host binary is present.
+ var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+ Assert.True(File.Exists(Path.Combine(bcsHome, dotnetExeName)));
+
+ // 6. host/fxr was mirrored AND overlaid.
+ var hostFxrFile = Path.Combine(bcsHome, "host", "fxr", runtimeVersion, BuildCacheClient.GetNativeLibName("hostfxr"));
+ Assert.True(File.Exists(hostFxrFile));
+ }
+ finally
+ {
+ try { Directory.Delete(bcsHome, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_NoBcsBitsForPlatform_Throws()
+ {
+ // Build a BCS archive layout for an RID that doesn't match the host RID, so the
+ // overlay finds nothing.
+ var hostRid = BuildCacheClient.GetPlatformMoniker();
+ var wrongRid = hostRid == "linux-x64" ? "win-x64" : "linux-x64";
+ var (extractDir, _, _, _) = BuildFakeBcsArchive(wrongRid, includeHost: false, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5";
+ const string aspNetCoreVersion = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion);
+
+ // Will resolve config from host RID and search for hostRid-shaped subtree → 0 files.
+ var ex = Assert.Throws(() =>
+ BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion,
+ "abcdef0123456789", buildCacheConfig: null));
+
+ Assert.Contains("0 files", ex.Message);
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_TwoConcurrentJobs_AreIsolated()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = ConfigKeyForRid(rid);
+ var (extractDir1, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+ var (extractDir2, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false);
+
+ const string runtimeVersion = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, runtimeVersion);
+ var sha1 = "1111aaaa2222bbbb3333cccc4444dddd55556666";
+ var sha2 = "6666eeee7777ffff8888aaaa9999bbbbccccdddd";
+
+ var home1 = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir1, runtimeVersion, runtimeVersion, sha1, configKey);
+ var home2 = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir2, runtimeVersion, runtimeVersion, sha2, configKey);
+
+ try
+ {
+ Assert.NotEqual(home1, home2);
+
+ var v1 = File.ReadAllText(Path.Combine(home1, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ var v2 = File.ReadAllText(Path.Combine(home2, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+
+ Assert.Contains(sha1, v1);
+ Assert.DoesNotContain(sha2, v1);
+ Assert.Contains(sha2, v2);
+ Assert.DoesNotContain(sha1, v2);
+
+ // Global home untouched.
+ var globalV = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ Assert.DoesNotContain(sha1, globalV);
+ Assert.DoesNotContain(sha2, globalV);
+ }
+ finally
+ {
+ try { Directory.Delete(home1, recursive: true); } catch { }
+ try { Directory.Delete(home2, recursive: true); } catch { }
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // ASP.NET Core flavour: ParseFlavor + config→RID + overlay targeting
+ // -------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("aspnetcore", true)]
+ [InlineData("ASPNETCORE", true)]
+ [InlineData("runtime", false)]
+ [InlineData("RUNTIME", false)]
+ [InlineData("", false)]
+ [InlineData(null, false)]
+ [InlineData("something-else", false)]
+ public void ParseFlavor_MapsRepoNameToFlavor(string repoName, bool expectedAspNetCore)
+ {
+ var expected = expectedAspNetCore
+ ? BuildCacheClient.BuildCacheFlavor.AspNetCore
+ : BuildCacheClient.BuildCacheFlavor.Runtime;
+ Assert.Equal(expected, BuildCacheClient.ParseFlavor(repoName));
+ }
+
+ [Theory]
+ [InlineData("aspnetcore_x64_linux", "linux-x64")]
+ [InlineData("aspnetcore_arm64_linux", "linux-arm64")]
+ [InlineData("aspnetcore_x64_windows", "win-x64")]
+ [InlineData("aspnetcore_arm64_windows", "win-arm64")]
+ [InlineData("aspnetcore_x86_windows", "win-x86")]
+ public void GetRidForConfig_AspNetCoreConfig_ReturnsMatchingRid(string configKey, string expectedRid)
+ {
+ // GetRidForConfig searches the union of both flavour maps; aspnetcore keys must resolve.
+ Assert.Equal(expectedRid, BuildCacheClient.GetRidForConfig(configKey));
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_AspNetCore_CopiesManagedAspNetDllsAndNoHost()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = AspNetConfigKeyForRid(rid);
+ var (extractDir, managed) = BuildFakeAspNetCoreBcsArchive(rid);
+
+ var outputFolder = Path.Combine(_testDir, "published-aspnet");
+ Directory.CreateDirectory(outputFolder);
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(
+ extractDir, outputFolder, configKey, "MyApp", BuildCacheClient.BuildCacheFlavor.AspNetCore);
+
+ // SCD overlay copies managed *.dll only (the app's own .deps.json governs, so the
+ // framework's deps.json/runtimeconfig.json are NOT copied); no host binaries.
+ Assert.Equal(managed.Count, copied);
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, dll)), $"Missing aspnetcore managed file {dll}");
+ }
+ Assert.False(File.Exists(Path.Combine(outputFolder, "Microsoft.AspNetCore.App.deps.json")));
+ Assert.False(File.Exists(Path.Combine(outputFolder, "Microsoft.AspNetCore.App.runtimeconfig.json")));
+ Assert.False(File.Exists(Path.Combine(outputFolder, BuildCacheClient.GetNativeLibName("hostpolicy"))));
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_AspNetCore_PlacesAspNetCoreAppFromPackNotRuntime()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = AspNetConfigKeyForRid(rid);
+ var (extractDir, managed) = BuildFakeAspNetCoreBcsArchive(rid);
+
+ const string runtimeVersion = "11.0.0-preview.5.26256.117";
+ const string aspNetCoreVersion = "11.0.0-preview.5.26256.117";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion);
+ var commitSha = "aaaabbbbccccddddeeeeffff0000111122223333";
+
+ var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion, commitSha, configKey,
+ BuildCacheClient.BuildCacheFlavor.AspNetCore);
+
+ try
+ {
+ var bcsAspNet = Path.Combine(bcsHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+
+ // 1. BCS managed Microsoft.AspNetCore.*.dll placed into the per-job AspNetCore.App.
+ foreach (var dll in managed)
+ {
+ Assert.True(File.Exists(Path.Combine(bcsAspNet, dll)), $"Missing BCS aspnetcore managed {dll}");
+ }
+
+ // 1b. Pristine direct-placement: the pack's host-resolvable metadata is present and the
+ // feed-only marker did NOT leak (the folder is built purely from BCS).
+ Assert.True(File.Exists(Path.Combine(bcsAspNet, "Microsoft.AspNetCore.App.deps.json")));
+ Assert.True(File.Exists(Path.Combine(bcsAspNet, "Microsoft.AspNetCore.App.runtimeconfig.json")));
+ Assert.False(File.Exists(Path.Combine(bcsAspNet, "FeedOnlyAspNet.dll")));
+
+ // 2. AspNetCore.App/.version carries the BCS (aspnetcore) commit.
+ var aspNetVersion = File.ReadAllText(Path.Combine(bcsAspNet, ".version"));
+ Assert.Contains(commitSha, aspNetVersion);
+ Assert.Contains(aspNetCoreVersion, aspNetVersion);
+
+ // 3. The base runtime (NETCore.App) is cloned from the feed and its .version is NOT
+ // rewritten — the aspnetcore flavour overrides only the asp.net framework.
+ var netCoreVersion = File.ReadAllText(Path.Combine(bcsHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"));
+ Assert.Contains("FEED_COMMIT", netCoreVersion);
+ Assert.DoesNotContain(commitSha, netCoreVersion);
+
+ // 4. Global home untouched.
+ var globalAspNet = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version"));
+ Assert.DoesNotContain(commitSha, globalAspNet);
+ }
+ finally
+ {
+ try { Directory.Delete(bcsHome, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_AspNetCore_MissingDepsJson_Throws()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = AspNetConfigKeyForRid(rid);
+ var (extractDir, _) = BuildFakeAspNetCoreBcsArchive(rid, includeDeps: false);
+
+ const string version = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(version, version);
+
+ var ex = Assert.Throws(() =>
+ BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, version, version, "abcdef0123456789", configKey,
+ BuildCacheClient.BuildCacheFlavor.AspNetCore));
+ Assert.Contains("deps.json", ex.Message);
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_AspNetCore_MissingRuntimeConfig_Throws()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = AspNetConfigKeyForRid(rid);
+ var (extractDir, _) = BuildFakeAspNetCoreBcsArchive(rid, includeRuntimeConfig: false);
+
+ const string version = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(version, version);
+
+ var ex = Assert.Throws(() =>
+ BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, version, version, "abcdef0123456789", configKey,
+ BuildCacheClient.BuildCacheFlavor.AspNetCore));
+ Assert.Contains("runtimeconfig.json", ex.Message);
+ }
+
+ [Fact]
+ public void CreateBuildCacheDotnetHome_AspNetCore_MissingAspNetVersion_Throws()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var configKey = AspNetConfigKeyForRid(rid);
+ var (extractDir, _) = BuildFakeAspNetCoreBcsArchive(rid);
+
+ const string runtimeVersion = "11.0.0-preview.5";
+ var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, runtimeVersion);
+
+ Assert.Throws(() =>
+ BuildCacheClient.CreateBuildCacheDotnetHome(
+ globalHome, extractDir, runtimeVersion, aspNetCoreVersion: "",
+ "abcdef0123456789", configKey, BuildCacheClient.BuildCacheFlavor.AspNetCore));
+ }
+
+ // -------------------------------------------------------------------
+ // CleanupExtractDir
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void CleanupExtractDir_DeletesDirectory()
+ {
+ var dir = Path.Combine(_testDir, "cleanup-target");
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(Path.Combine(dir, "x.txt"), "hi");
+
+ BuildCacheClient.CleanupExtractDir(dir);
+
+ Assert.False(Directory.Exists(dir));
+ }
+
+ [Fact]
+ public void CleanupExtractDir_MissingDir_DoesNotThrow()
+ {
+ BuildCacheClient.CleanupExtractDir(Path.Combine(_testDir, "never-existed"));
+ BuildCacheClient.CleanupExtractDir(null);
+ BuildCacheClient.CleanupExtractDir("");
+ }
+
+ // -------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------
+
+ private static string ConfigKeyForRid(string rid)
+ => BuildCacheClient.PlatformToBcsConfig.TryGetValue(rid, out var v) ? v.configKey : null;
+
+ ///
+ /// Builds a fake "global" dotnet home with .version files containing a FEED commit so
+ /// tests can detect whether the .version was overwritten with the BCS commit.
+ ///
+ private string BuildFakeGlobalDotnetHome(string runtimeVersion, string aspNetCoreVersion)
+ {
+ var home = Path.Combine(_testDir, "global-home-" + Guid.NewGuid().ToString("N"));
+ var netCoreApp = Path.Combine(home, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ var aspNetCoreApp = Path.Combine(home, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion);
+ var hostFxr = Path.Combine(home, "host", "fxr", runtimeVersion);
+
+ Directory.CreateDirectory(netCoreApp);
+ Directory.CreateDirectory(aspNetCoreApp);
+ Directory.CreateDirectory(hostFxr);
+
+ File.WriteAllText(Path.Combine(netCoreApp, ".version"), "FEED_COMMIT_DO_NOT_TOUCH\n" + runtimeVersion + "\n");
+ File.WriteAllText(Path.Combine(netCoreApp, "System.Private.CoreLib.dll"), "feed managed");
+ File.WriteAllText(Path.Combine(netCoreApp, BuildCacheClient.GetNativeLibName("hostpolicy")), "feed hostpolicy");
+
+ File.WriteAllText(Path.Combine(aspNetCoreApp, ".version"), "FEED_ASPNET\n" + aspNetCoreVersion + "\n");
+ File.WriteAllText(Path.Combine(aspNetCoreApp, "Microsoft.AspNetCore.dll"), "feed aspnet");
+ // A feed-only asp.net assembly the BCS pack does NOT ship; when the aspnetcore framework is
+ // placed directly from BCS this must NOT appear in the per-job home.
+ File.WriteAllText(Path.Combine(aspNetCoreApp, "FeedOnlyAspNet.dll"), "feed only aspnet");
+
+ File.WriteAllText(Path.Combine(hostFxr, BuildCacheClient.GetNativeLibName("hostfxr")), "feed hostfxr");
+
+ var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
+ File.WriteAllText(Path.Combine(home, dotnetExeName), "feed dotnet host");
+
+ return home;
+ }
+
+ ///
+ /// Builds a fake BCS extraction at microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/
+ /// + corehost layout. adds the renamed-by-SDK apphost binary.
+ ///
+ private (string extractDir, string runtimesDir, List managed, List native)
+ BuildFakeBcsArchive(string rid, bool includeHost, bool includeApphost)
+ {
+ var extractDir = Path.Combine(_testDir, "extracted-" + Guid.NewGuid().ToString("N"));
+ var nugetPkg = Path.Combine(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ var runtimesDir = Path.Combine(nugetPkg, "Release", "runtimes", rid);
+ var libDir = Path.Combine(runtimesDir, "lib", "net11.0");
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ Directory.CreateDirectory(libDir);
+ Directory.CreateDirectory(nativeDir);
+
+ var managed = new List
+ {
+ "System.Private.CoreLib.dll",
+ "System.Runtime.dll",
+ "System.Console.dll",
+ };
+ foreach (var dll in managed)
+ {
+ File.WriteAllText(Path.Combine(libDir, dll), "BCS managed " + dll);
+ }
+
+ List native;
+ if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase))
+ {
+ native = new List { "coreclr.dll", "clrjit.dll" };
+ }
+ else if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase))
+ {
+ native = new List { "libcoreclr.dylib", "libclrjit.dylib" };
+ }
+ else
+ {
+ native = new List { "libcoreclr.so", "libclrjit.so" };
+ }
+ foreach (var n in native)
+ {
+ File.WriteAllText(Path.Combine(nativeDir, n), "BCS native " + n);
+ }
+
+ if (includeHost)
+ {
+ var hostDir = Path.Combine(extractDir, $"{rid}.Release", "corehost");
+ Directory.CreateDirectory(hostDir);
+ File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostpolicy")), "BCS hostpolicy");
+ File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostfxr")), "BCS hostfxr");
+ File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "dotnet.exe" : "dotnet"), "BCS dotnet host");
+
+ if (includeApphost)
+ {
+ File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "apphost.exe" : "apphost"), "BCS apphost");
+ }
+ }
+
+ return (extractDir, runtimesDir, managed, native);
+ }
+
+ private static string NativeLibForRid(string rid, string baseName)
+ {
+ if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"{baseName}.dll";
+ }
+ if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase))
+ {
+ return $"lib{baseName}.dylib";
+ }
+ return $"lib{baseName}.so";
+ }
+
+ private static string AspNetConfigKeyForRid(string rid)
+ => BuildCacheClient.PlatformToBcsConfigAspNetCore.TryGetValue(rid, out var v) ? v.configKey : null;
+
+ ///
+ /// Builds a fake aspnetcore BCS extraction at runtimes/{rid}/lib/net11.0/ (the verbatim
+ /// runtime-pack nupkg layout) with managed Microsoft.AspNetCore.*.dll files PLUS the
+ /// host-resolvable Microsoft.AspNetCore.App.deps.json + runtimeconfig.json (which the real
+ /// nupkg carries next to the assemblies). Negative-case flags omit the metadata to exercise the
+ /// direct-placement fail-loud path.
+ ///
+ private (string extractDir, List managed) BuildFakeAspNetCoreBcsArchive(
+ string rid, bool includeDeps = true, bool includeRuntimeConfig = true)
+ {
+ var extractDir = Path.Combine(_testDir, "extracted-aspnet-" + Guid.NewGuid().ToString("N"));
+ var libDir = Path.Combine(extractDir, "runtimes", rid, "lib", "net11.0");
+ Directory.CreateDirectory(libDir);
+
+ var managed = new List
+ {
+ "Microsoft.AspNetCore.dll",
+ "Microsoft.AspNetCore.Mvc.Core.dll",
+ "Microsoft.AspNetCore.Routing.dll",
+ };
+ foreach (var dll in managed)
+ {
+ File.WriteAllText(Path.Combine(libDir, dll), "BCS aspnetcore managed " + dll);
+ }
+
+ if (includeDeps)
+ {
+ File.WriteAllText(Path.Combine(libDir, "Microsoft.AspNetCore.App.deps.json"), "{}");
+ }
+ if (includeRuntimeConfig)
+ {
+ File.WriteAllText(Path.Combine(libDir, "Microsoft.AspNetCore.App.runtimeconfig.json"), "{}");
+ }
+
+ return (extractDir, managed);
+ }
+ }
+}