diff --git a/docs/build_cache_requirements.md b/docs/build_cache_requirements.md
new file mode 100644
index 000000000..3efac3690
--- /dev/null
+++ b/docs/build_cache_requirements.md
@@ -0,0 +1,95 @@
+# 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` (initially; `aspnetcore` in the future)
+- `branch` = e.g., `main`, `release/10.0`
+- `configKey` = e.g., `coreclr_x64_linux`, `coreclr_arm64_windows`
+- `artifactFile` = e.g., `BuildArtifacts_linux_x64_Release_coreclr.tar.gz`
+
+---
+
+## 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)
+```
+
+Where `{rid}` = `linux-x64`, `linux-arm64`, `win-x64`, etc.
+
+This layout was confirmed by inspecting `BuildArtifacts_linux_arm64_Release_coreclr.tar.gz`. **If this 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..70e21181c 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,52 @@ 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 the .NET runtime from the Build Cache Service (BCS), which caches pre-built runtime binaries for individual commits. This is useful for performance regression bisection where VMR feed gaps make it hard to pinpoint which commit caused a regression.
+
+### Basic usage (latest cached build on main)
+
+```
+> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache
+```
+
+### 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 |
+|----------|---------|-------------|
+| `buildCacheCommitSha` | (empty) | Specific runtime commit SHA. 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`). Auto-detected from agent platform. |
+
+### 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..c01e89267
--- /dev/null
+++ b/src/Microsoft.Crank.Agent/BuildCacheClient.cs
@@ -0,0 +1,637 @@
+// 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.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 overlays
+ /// them onto the agent's installed shared framework and/or the published app output so
+ /// benchmarks run 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();
+
+ ///
+ /// Maps the agent's platform (RID) to the BCS configuration key and artifact filename.
+ ///
+ internal static readonly IReadOnlyDictionary PlatformToBcsConfig =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["linux-x64"] = ("coreclr_x64_linux", "BuildArtifacts_linux_x64_Release_coreclr.tar.gz"),
+ ["linux-arm64"] = ("coreclr_arm64_linux", "BuildArtifacts_linux_arm64_Release_coreclr.tar.gz"),
+ ["linux-musl-x64"] = ("coreclr_muslx64_linux", "BuildArtifacts_linux_musl_x64_Release_coreclr.tar.gz"),
+ ["win-x64"] = ("coreclr_x64_windows", "BuildArtifacts_windows_x64_Release_coreclr.zip"),
+ ["win-arm64"] = ("coreclr_arm64_windows", "BuildArtifacts_windows_arm64_Release_coreclr.zip"),
+ ["win-x86"] = ("coreclr_x86_windows", "BuildArtifacts_windows_x86_Release_coreclr.zip"),
+ };
+
+ ///
+ /// 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)
+ {
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+
+ 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-job temp directory. The caller
+ /// is responsible for invoking the overlay methods on the returned directory.
+ ///
+ public static async Task DownloadAndExtractAsync(
+ string baseUrl,
+ string repoName,
+ string commitSha,
+ string buildCacheConfig,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrEmpty(commitSha))
+ {
+ throw new ArgumentException("commitSha must be provided.", nameof(commitSha));
+ }
+
+ buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig);
+ 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 directory.
+ 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}");
+ }
+
+ 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();
+ }
+ }
+
+ ///
+ /// Overlays BCS runtime binaries (managed + native + host binaries) into a published
+ /// output directory. Used for self-contained publishes where the runtime is bundled in
+ /// the publish output.
+ ///
+ /// Number of files overlaid.
+ public static int OverlayPublishedOutput(string extractDir, string outputFolder)
+ {
+ var rid = GetPlatformMoniker();
+ int filesCopied = 0;
+
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ if (nugetPackageDir != null)
+ {
+ var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid);
+ if (Directory.Exists(runtimesDir))
+ {
+ filesCopied += CopyManaged(runtimesDir, outputFolder);
+ filesCopied += CopyNative(runtimesDir, outputFolder);
+ }
+ }
+
+ var corehostDir = FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ // For self-contained, all three host binaries live alongside the app.
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetNativeLibName("hostpolicy"));
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetNativeLibName("hostfxr"));
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetDotnetExecutableName());
+ }
+
+ return filesCopied;
+ }
+
+ ///
+ /// Overlays BCS runtime binaries into the agent's installed dotnet home so framework-
+ /// dependent apps that load the runtime from shared/Microsoft.NETCore.App/{version}/
+ /// get the BCS bits at runtime. Also rewrites the .version file so reporting code
+ /// that reads it (e.g., GetDependencies) picks up the BCS commit instead of the feed commit.
+ ///
+ /// Number of files overlaid.
+ public static int OverlayDotnetHome(string extractDir, string dotnetHome, string runtimeVersion, string commitSha = null)
+ {
+ if (string.IsNullOrEmpty(runtimeVersion))
+ {
+ throw new ArgumentException("runtimeVersion must be provided.", nameof(runtimeVersion));
+ }
+
+ var rid = GetPlatformMoniker();
+ int filesCopied = 0;
+
+ var sharedFrameworkDir = Path.Combine(dotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ if (!Directory.Exists(sharedFrameworkDir))
+ {
+ throw new InvalidOperationException(
+ $"Build Cache: Expected shared framework directory does not exist: '{sharedFrameworkDir}'. " +
+ "The feed-resolved runtime must be installed before overlaying.");
+ }
+
+ var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}");
+ if (nugetPackageDir != null)
+ {
+ var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid);
+ if (Directory.Exists(runtimesDir))
+ {
+ filesCopied += CopyManaged(runtimesDir, sharedFrameworkDir);
+ filesCopied += CopyNative(runtimesDir, sharedFrameworkDir);
+ }
+ }
+
+ var corehostDir = FindCorehostDirectory(extractDir, rid);
+ if (corehostDir != null)
+ {
+ // hostpolicy lives in the shared framework dir.
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, sharedFrameworkDir, GetNativeLibName("hostpolicy"));
+
+ // hostfxr lives at host/fxr/{version}/.
+ var hostFxrDir = Path.Combine(dotnetHome, "host", "fxr", runtimeVersion);
+ if (Directory.Exists(hostFxrDir))
+ {
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, hostFxrDir, GetNativeLibName("hostfxr"));
+ }
+
+ // The dotnet host lives at the dotnetHome root.
+ filesCopied += CopyHostBinaryIfPresent(corehostDir, dotnetHome, GetDotnetExecutableName());
+ }
+
+ // Rewrite the .version file so anything that reads it (notably the agent's own
+ // GetDependencies / BenchmarksNetCoreAppVersion measurement) reports the BCS commit
+ // instead of the feed-installed commit. Format: "\n\n".
+ if (!string.IsNullOrEmpty(commitSha))
+ {
+ var versionFile = Path.Combine(sharedFrameworkDir, ".version");
+ File.WriteAllText(versionFile, $"{commitSha}\n{runtimeVersion}\n");
+ }
+
+ 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;
+ await ProcessUtil.RetryOnExceptionAsync(DownloadRetryCount, async () =>
+ {
+ using var response = await _httpClient.GetAsync(url, cancellationToken);
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ throw new InvalidOperationException(
+ $"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 ProcessUtil.RetryOnExceptionAsync(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)
+ {
+ // Don't retry 404s; they aren't transient.
+ throw new InvalidOperationException(
+ $"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);
+ });
+ }
+
+ ///
+ /// 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))
+ {
+ 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 ----------------------------------------------------------
+
+ private static int CopyManaged(string runtimesDir, string destinationDir)
+ {
+ int copied = 0;
+ var libDir = Path.Combine(runtimesDir, "lib");
+ if (!Directory.Exists(libDir))
+ {
+ return 0;
+ }
+
+ // Pick the highest-versioned net{X}.0 directory (the archive should only ship one).
+ var managedDir = Directory.GetDirectories(libDir)
+ .OrderByDescending(d => Path.GetFileName(d), StringComparer.OrdinalIgnoreCase)
+ .FirstOrDefault();
+
+ 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);
+ copied++;
+ }
+
+ return copied;
+ }
+
+ private static int CopyHostBinaryIfPresent(string sourceDir, string destDir, string fileName)
+ {
+ var src = Path.Combine(sourceDir, fileName);
+ if (!File.Exists(src))
+ {
+ return 0;
+ }
+
+ Directory.CreateDirectory(destDir);
+ File.Copy(src, Path.Combine(destDir, fileName), overwrite: true);
+ return 1;
+ }
+
+ 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)
+ {
+ if (!string.IsNullOrEmpty(buildCacheConfig))
+ {
+ return buildCacheConfig;
+ }
+
+ var rid = GetPlatformMoniker();
+ if (PlatformToBcsConfig.TryGetValue(rid, out var mapped))
+ {
+ return mapped.configKey;
+ }
+
+ throw new InvalidOperationException(
+ $"No Build Cache configuration mapping for platform '{rid}'. Specify buildCacheConfig explicitly.");
+ }
+
+ private static string GetArtifactFile(string buildCacheConfig)
+ {
+ var match = PlatformToBcsConfig.Values.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;
+ }
+
+ 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/Startup.cs b/src/Microsoft.Crank.Agent/Startup.cs
index 8168a2e88..ac904b1e5 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");
@@ -2933,23 +2957,83 @@ private static async Task CloneRestoreAndBuild(string path, Job job, str
runtimeVersion = channel;
}
+ // For buildcache channel, SDK/ASP.NET/Desktop use "latest" since BCS only has runtime
+ var nonRuntimeChannel = String.Equals(channel, "buildcache", StringComparison.OrdinalIgnoreCase) ? "latest" : channel;
+
if (String.IsNullOrEmpty(desktopVersion))
{
- desktopVersion = channel;
+ desktopVersion = nonRuntimeChannel;
}
if (String.IsNullOrEmpty(aspNetCoreVersion))
{
- aspNetCoreVersion = channel;
+ aspNetCoreVersion = nonRuntimeChannel;
}
if (String.IsNullOrEmpty(sdkVersion))
{
- sdkVersion = channel;
+ sdkVersion = nonRuntimeChannel;
}
runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, runtimeVersion);
+ // Build Cache Service: if the runtime version is "BuildCache", prepare BCS artifacts
+ // and resolve a real "Latest" runtime version for the NuGet build.
+ var useBuildCache = String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase);
+ 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
+ var resolved = await BuildCacheClient.ResolveCommitAsync(
+ _buildCacheBaseUrl, _buildCacheRepoName, 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, _buildCacheRepoName, buildCacheCommitSha, buildCacheConfigResolved,
+ cancellationToken);
+
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+ Log.Info($"Build Cache: Artifacts for commit {shortSha} ready for post-build overlay");
+
+ // 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})");
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: {ex.Message}";
+ return null;
+ }
+ }
+
sdkVersion = await ResolveSdkVersion(sdkVersion, targetFramework);
aspNetCoreVersion = await ResolveAspNetCoreVersion(aspNetCoreVersion, targetFramework);
@@ -3206,6 +3290,32 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () =>
var dotnetDir = dotnetHome;
+ // Build Cache: overlay BCS bits into the freshly-installed shared framework BEFORE any
+ // metadata capture below reads the .version file. Doing it here means the Microsoft.NETCore.App
+ // metadata measurement records the BCS commit and the agent's GetDependencies pass picks up
+ // the BCS-built assemblies' AssemblyInformationalVersion. The published-output overlay still
+ // happens after publish (only that folder exists by then).
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ try
+ {
+ var dotnetHomeOverlay = BuildCacheClient.OverlayDotnetHome(
+ buildCacheExtractDir, dotnetDir, runtimeVersion, buildCacheCommitSha);
+ Log.Info($"Build Cache: Overlaid {dotnetHomeOverlay} files into dotnet home (commit {BuildCacheClient.ShortSha(buildCacheCommitSha)})");
+
+ if (dotnetHomeOverlay == 0)
+ {
+ job.Error = $"Build Cache: dotnet-home overlay copied 0 files for commit {buildCacheCommitSha}.";
+ return null;
+ }
+ }
+ catch (Exception ex)
+ {
+ job.Error = $"Build Cache: dotnet-home overlay failed: {ex.Message}";
+ return null;
+ }
+ }
+
// Updating Job to reflect actual versions used
job.AspNetCoreVersion = aspNetCoreVersion;
job.RuntimeVersion = runtimeVersion;
@@ -3434,6 +3544,42 @@ 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 agent's
+ // installed shared framework was already overlaid earlier (right after install) so the
+ // .NET runtime metadata and FDD execution see BCS bits; 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 dir.
+ if (useBuildCache && buildCacheExtractDir != null)
+ {
+ var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha);
+
+ int publishedOverlay;
+ try
+ {
+ publishedOverlay = BuildCacheClient.OverlayPublishedOutput(buildCacheExtractDir, outputFolder);
+ 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.
+ // For framework-dependent publishes 0 is acceptable here because the dotnet-home
+ // overlay above already placed the BCS bits in the shared framework directory.
+ 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 runtime version for reporting. We append rather than
+ // replace so PatchRuntimeConfig still sees a valid feed-resolved version below.
+ job.RuntimeVersion = $"{runtimeVersion}+buildcache.{shortSha}";
+ }
+
PatchRuntimeConfig(job, outputFolder, aspNetCoreVersion, runtimeVersion);
}
@@ -4582,6 +4728,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..299387380 100644
--- a/src/Microsoft.Crank.Models/Job.cs
+++ b/src/Microsoft.Crank.Models/Job.cs
@@ -71,6 +71,11 @@ 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; } = "";
+
// Delay from the process started to the console receiving "Application started"
public TimeSpan StartupMainMethod { get; set; }
public TimeSpan BuildTime { get; set; }
diff --git a/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
new file mode 100644
index 000000000..e5baac882
--- /dev/null
+++ b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs
@@ -0,0 +1,448 @@
+// 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.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.True(result.Entries.ContainsKey("coreclr_x64_linux"));
+ Assert.Equal("abc123def456", result.Entries["coreclr_x64_linux"].CommitSha);
+ Assert.Equal("2025-01-01T00:00:00Z", result.Entries["coreclr_x64_linux"].CommitTime);
+ }
+
+ [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.True(result.Entries.ContainsKey("coreclr_arm64_linux"));
+ Assert.Equal("deadbeef", result.Entries["coreclr_arm64_linux"].CommitSha);
+ Assert.Equal("2025-02-02T00:00:00Z", result.Entries["coreclr_arm64_linux"].CommitTime);
+ }
+
+ [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_MissingFields_ReturnsNullsWithoutThrowing()
+ {
+ const string json = """
+ {
+ "branch_name": "main",
+ "coreclr_x64_linux": { "CommitSha": "abc" },
+ "empty_config": {}
+ }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.Equal("abc", result.Entries["coreclr_x64_linux"].CommitSha);
+ Assert.Null(result.Entries["coreclr_x64_linux"].CommitTime);
+ Assert.Null(result.Entries["empty_config"].CommitSha);
+ Assert.Null(result.Entries["empty_config"].CommitTime);
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_EntriesLookupIsCaseInsensitive()
+ {
+ const string json = """
+ { "branch_name": "main", "CoreCLR_X64_Linux": { "CommitSha": "abc" } }
+ """;
+
+ var result = BuildCacheClient.ParseLatestBuilds(json);
+
+ Assert.True(result.Entries.ContainsKey("coreclr_x64_linux"));
+ Assert.True(result.Entries.ContainsKey("CORECLR_X64_LINUX"));
+ }
+
+ [Fact]
+ public void ParseLatestBuilds_NonObjectValues_AreSkipped()
+ {
+ // Real-world payloads sometimes carry non-object metadata that must be ignored.
+ 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"));
+ }
+
+ // -------------------------------------------------------------------
+ // GetPlatformMoniker
+ // -------------------------------------------------------------------
+
+ [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);
+ }
+
+ [Fact]
+ public void PlatformToBcsConfig_ContainsAllSupportedRids()
+ {
+ // Sanity: agents typically run on these RIDs; ensure the table covers them.
+ Assert.True(BuildCacheClient.PlatformToBcsConfig.ContainsKey("linux-x64"));
+ Assert.True(BuildCacheClient.PlatformToBcsConfig.ContainsKey("linux-arm64"));
+ Assert.True(BuildCacheClient.PlatformToBcsConfig.ContainsKey("win-x64"));
+ }
+
+ // -------------------------------------------------------------------
+ // 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(""));
+ }
+
+ // -------------------------------------------------------------------
+ // GetNativeLibName
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void GetNativeLibName_MatchesHostPlatform()
+ {
+ var name = BuildCacheClient.GetNativeLibName("hostpolicy");
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Equal("hostpolicy.dll", name);
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ Assert.Equal("libhostpolicy.dylib", name);
+ }
+ else
+ {
+ Assert.Equal("libhostpolicy.so", name);
+ }
+ }
+
+ // -------------------------------------------------------------------
+ // OverlayPublishedOutput / OverlayDotnetHome
+ // -------------------------------------------------------------------
+
+ [Fact]
+ public void OverlayPublishedOutput_CopiesAllRuntimeFilesUnconditionally()
+ {
+ // Build a fake BCS extract layout for the host RID.
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, _, expectedManagedNames, expectedNativeNames) = BuildFakeBcsArchive(rid, includeHost: true);
+
+ var outputFolder = Path.Combine(_testDir, "published");
+ Directory.CreateDirectory(outputFolder);
+
+ // Note: outputFolder is intentionally EMPTY — the overlay must still copy
+ // managed/native runtime files (regression: earlier behavior skipped missing dest).
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder);
+
+ Assert.True(copied >= expectedManagedNames.Count + expectedNativeNames.Count,
+ $"Expected at least {expectedManagedNames.Count + expectedNativeNames.Count} files; got {copied}");
+
+ foreach (var dll in expectedManagedNames)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, dll)), $"Missing managed file {dll}");
+ }
+ foreach (var native in expectedNativeNames)
+ {
+ Assert.True(File.Exists(Path.Combine(outputFolder, native)), $"Missing native file {native}");
+ }
+
+ // hostpolicy must have been copied alongside the app for self-contained.
+ Assert.True(File.Exists(Path.Combine(outputFolder, BuildCacheClient.GetNativeLibName("hostpolicy"))));
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_NoMatchingPlatformLayout_ReturnsZero()
+ {
+ // Empty extract directory ⇒ overlay finds nothing ⇒ returns 0 (caller fails the job).
+ var extractDir = Path.Combine(_testDir, "empty-extract");
+ Directory.CreateDirectory(extractDir);
+
+ var outputFolder = Path.Combine(_testDir, "published");
+ Directory.CreateDirectory(outputFolder);
+
+ var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder);
+
+ Assert.Equal(0, copied);
+ }
+
+ [Fact]
+ public void OverlayPublishedOutput_SkipsPdbAndDbgFiles()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, runtimesDir, _, _) = BuildFakeBcsArchive(rid, includeHost: false);
+
+ // Add a .pdb and .dbg in the native dir.
+ 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");
+ Directory.CreateDirectory(outputFolder);
+
+ BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder);
+
+ Assert.False(File.Exists(Path.Combine(outputFolder, "coreclr.pdb")));
+ Assert.False(File.Exists(Path.Combine(outputFolder, "libcoreclr.dbg")));
+ }
+
+ [Fact]
+ public void OverlayDotnetHome_RequiresExistingSharedFrameworkDir()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true);
+
+ var dotnetHome = Path.Combine(_testDir, "dotnetHome-missing");
+ Directory.CreateDirectory(dotnetHome);
+
+ // shared/Microsoft.NETCore.App/{version} does NOT exist ⇒ should throw with a clear message.
+ var ex = Assert.Throws(
+ () => BuildCacheClient.OverlayDotnetHome(extractDir, dotnetHome, "10.0.0-preview.1"));
+
+ Assert.Contains("shared framework", ex.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void OverlayDotnetHome_OverlaysSharedFrameworkAndHostFxr()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, _, expectedManagedNames, expectedNativeNames) = BuildFakeBcsArchive(rid, includeHost: true);
+
+ const string runtimeVersion = "10.0.0-preview.1";
+ var dotnetHome = Path.Combine(_testDir, "dotnetHome");
+ var sharedFw = Path.Combine(dotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ var hostFxr = Path.Combine(dotnetHome, "host", "fxr", runtimeVersion);
+ Directory.CreateDirectory(sharedFw);
+ Directory.CreateDirectory(hostFxr);
+
+ var copied = BuildCacheClient.OverlayDotnetHome(extractDir, dotnetHome, runtimeVersion);
+
+ Assert.True(copied > 0);
+ foreach (var dll in expectedManagedNames)
+ {
+ Assert.True(File.Exists(Path.Combine(sharedFw, dll)), $"Missing managed in shared FW: {dll}");
+ }
+ foreach (var native in expectedNativeNames)
+ {
+ Assert.True(File.Exists(Path.Combine(sharedFw, native)), $"Missing native in shared FW: {native}");
+ }
+
+ Assert.True(File.Exists(Path.Combine(hostFxr, BuildCacheClient.GetNativeLibName("hostfxr"))));
+ }
+
+ [Fact]
+ public void OverlayDotnetHome_WithCommitSha_RewritesVersionFile()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true);
+
+ const string runtimeVersion = "11.0.0-preview.5.26256.117";
+ const string commitSha = "603403d9cb49d3d1c35b56bcff024ce99a8c5c3a";
+ var dotnetHome = Path.Combine(_testDir, "dotnetHome-version");
+ var sharedFw = Path.Combine(dotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ Directory.CreateDirectory(sharedFw);
+
+ // Simulate dotnet-install having already written a .version file with the FEED commit.
+ File.WriteAllText(Path.Combine(sharedFw, ".version"), "feedfeedfeed\n" + runtimeVersion + "\n");
+
+ BuildCacheClient.OverlayDotnetHome(extractDir, dotnetHome, runtimeVersion, commitSha);
+
+ var versionFileContents = File.ReadAllText(Path.Combine(sharedFw, ".version"));
+ var lines = versionFileContents.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(commitSha, lines[0]);
+ Assert.Equal(runtimeVersion, lines[1]);
+ }
+
+ [Fact]
+ public void OverlayDotnetHome_WithoutCommitSha_LeavesVersionFileUntouched()
+ {
+ var rid = BuildCacheClient.GetPlatformMoniker();
+ var (extractDir, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true);
+
+ const string runtimeVersion = "11.0.0-preview.5.26256.117";
+ var dotnetHome = Path.Combine(_testDir, "dotnetHome-noversion");
+ var sharedFw = Path.Combine(dotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion);
+ Directory.CreateDirectory(sharedFw);
+
+ const string original = "feedfeedfeed\n" + "11.0.0-preview.5.26256.117\n";
+ File.WriteAllText(Path.Combine(sharedFw, ".version"), original);
+
+ BuildCacheClient.OverlayDotnetHome(extractDir, dotnetHome, runtimeVersion);
+
+ Assert.Equal(original, File.ReadAllText(Path.Combine(sharedFw, ".version")));
+ }
+
+ // -------------------------------------------------------------------
+ // Fake BCS archive helpers
+ // -------------------------------------------------------------------
+
+ ///
+ /// Builds an on-disk fake of an extracted BCS archive matching the layout the agent expects:
+ /// microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/lib/net10.0/*.dll,
+ /// microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/native/*, and
+ /// optionally {rid}.Release/corehost/*.
+ ///
+ private (string extractDir, string runtimesDir, System.Collections.Generic.List managed, System.Collections.Generic.List native)
+ BuildFakeBcsArchive(string rid, bool includeHost)
+ {
+ 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", "net10.0");
+ var nativeDir = Path.Combine(runtimesDir, "native");
+ Directory.CreateDirectory(libDir);
+ Directory.CreateDirectory(nativeDir);
+
+ var managed = new System.Collections.Generic.List
+ {
+ "System.Private.CoreLib.dll",
+ "System.Runtime.dll",
+ "System.Console.dll",
+ };
+ foreach (var dll in managed)
+ {
+ File.WriteAllText(Path.Combine(libDir, dll), "fake managed " + dll);
+ }
+
+ var native = new System.Collections.Generic.List();
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ native.AddRange(new[] { "coreclr.dll", "clrjit.dll" });
+ }
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ native.AddRange(new[] { "libcoreclr.dylib", "libclrjit.dylib" });
+ }
+ else
+ {
+ native.AddRange(new[] { "libcoreclr.so", "libclrjit.so" });
+ }
+ foreach (var n in native)
+ {
+ File.WriteAllText(Path.Combine(nativeDir, n), "fake native " + n);
+ }
+
+ if (includeHost)
+ {
+ var hostDir = Path.Combine(extractDir, $"{rid}.Release", "corehost");
+ Directory.CreateDirectory(hostDir);
+ File.WriteAllText(Path.Combine(hostDir, BuildCacheClient.GetNativeLibName("hostpolicy")), "hostpolicy");
+ File.WriteAllText(Path.Combine(hostDir, BuildCacheClient.GetNativeLibName("hostfxr")), "hostfxr");
+ File.WriteAllText(Path.Combine(hostDir, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"), "dotnet");
+ }
+
+ return (extractDir, runtimesDir, managed, native);
+ }
+ }
+}