diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json
index 1dd5227..abf8471 100644
--- a/.config/cspell/cspell.json
+++ b/.config/cspell/cspell.json
@@ -5,6 +5,9 @@
"gitignoreRoot": ".",
"ignorePaths": ["**/node_modules/**", "LICENSE"],
"words": [
+ "aspnet",
+ "aspnetcore",
+ "buildx",
"csharpierignore",
"csharpierrc",
"devcontainer",
@@ -16,8 +19,12 @@
"hasha",
"mktemp",
"msbuild",
+ "NOLOGO",
"packagejson",
"syncpack",
- "unrs"
+ "unrs",
+ "muxer",
+ "tarball",
+ "optout"
]
}
diff --git a/.config/dotnet/Packages.props b/.config/dotnet/Packages.props
index e3d80b5..96c9240 100644
--- a/.config/dotnet/Packages.props
+++ b/.config/dotnet/Packages.props
@@ -2,5 +2,9 @@
true
-
+
+
+
+
+
diff --git a/.config/dotnet/Project.props b/.config/dotnet/Project.props
index f9260ca..4934aea 100644
--- a/.config/dotnet/Project.props
+++ b/.config/dotnet/Project.props
@@ -2,6 +2,7 @@
enable
enable
+ true
diff --git a/.config/workspaces/pnpm-workspace.yaml b/.config/workspaces/pnpm-workspace.yaml
index 0994f66..f75b7e2 100644
--- a/.config/workspaces/pnpm-workspace.yaml
+++ b/.config/workspaces/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
packages:
- "features/.devcontainer/features/src/*"
- "features/.devcontainer/features/test/*"
+ - "features"
lockfile: false
confirmModulesPurge: false
update:
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
new file mode 100644
index 0000000..a45582a
--- /dev/null
+++ b/.github/workflows/dotnet.yml
@@ -0,0 +1,200 @@
+name: "Dotnet"
+
+on:
+ pull_request:
+ paths:
+ - "features/dotnet/config.json"
+ push:
+ branches:
+ - main
+ paths:
+ - "features/dotnet/config.json"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+concurrency:
+ group: "dotnet-${{ github.ref }}"
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ plan:
+ runs-on: ubuntu-latest
+ outputs:
+ matrix: ${{ steps.plan.outputs.matrix }}
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Plan the publishes
+ id: plan
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: never
+ runCmd: pnpm tsx scripts/tasks/features/dotnet/planPublish.ts --channel "$CHANNEL"
+ env: |
+ CHANNEL=${{ github.head_ref || github.ref_name }}
+ GH_TOKEN=${{ github.token }}
+
+ install-binaries:
+ needs: plan
+ if: needs.plan.outputs.matrix != ''
+ runs-on: ${{ matrix.runner }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - rid: linux-x64
+ runner: ubuntu-latest
+ arm64: false
+ - rid: linux-arm64
+ runner: ubuntu-24.04-arm
+ arm64: true
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Pack the install binary
+ uses: devcontainers/ci@v0.3
+ with:
+ imageName: ${{ matrix.arm64 && format('ghcr.io/{0}/devcontainer', github.repository) || '' }}
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: ${{ matrix.arm64 && 'filter' || 'never' }}
+ refFilterForPush: ${{ matrix.arm64 && 'refs/heads/main' || '' }}
+ useNativeRunner: ${{ matrix.arm64 }}
+ platform: ${{ matrix.arm64 && 'linux/arm64' || '' }}
+ runCmd: pnpm tsx scripts/tasks/features/dotnet/packInstall.ts --rid "$RID" --out .ci/install
+ env: |
+ RID=${{ matrix.rid }}
+
+ - name: Upload the install binary
+ uses: actions/upload-artifact@v7
+ with:
+ name: install-${{ matrix.rid }}
+ path: .ci/install/${{ matrix.rid }}/
+
+ image:
+ needs:
+ - plan
+ - install-binaries
+ if: needs.plan.outputs.matrix != ''
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Download the x64 install binary
+ uses: actions/download-artifact@v7
+ with:
+ name: install-linux-x64
+ path: .ci/install/linux-x64
+
+ - name: Download the arm64 install binary
+ uses: actions/download-artifact@v7
+ with:
+ name: install-linux-arm64
+ path: .ci/install/linux-arm64
+
+ - name: Publish the image
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: never
+ runCmd: pnpm tsx scripts/tasks/features/dotnet/publishImage.ts --component ${{ matrix.component }} --version ${{ matrix.version }} --channel "$CHANNEL" --install-dir .ci/install
+ env: |
+ CHANNEL=${{ github.head_ref || github.ref_name }}
+ GH_TOKEN=${{ github.token }}
+
+ commit-tags:
+ needs:
+ - plan
+ - image
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.plan.result == 'success' && needs.image.result == 'success' && needs.plan.outputs.matrix != ''
+ permissions:
+ contents: write
+ packages: read
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Tag the published pair
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: never
+ runCmd: pnpm tsx scripts/tasks/features/dotnet/tagPublished.ts --component ${{ matrix.component }} --version ${{ matrix.version }}
+ env: |
+ GH_TOKEN=${{ github.token }}
+
+ verify:
+ needs:
+ - plan
+ - image
+ if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') }}
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ scenario:
+ - sdk
+ - runtime
+ - aspnet
+ - multi
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Verify the images
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: never
+ runCmd: pnpm verify dotnet --scenario ${{ matrix.scenario }} --images pull --channel "$CHANNEL"
+ env: |
+ CHANNEL=${{ github.head_ref || github.ref_name }}
+ GH_TOKEN=${{ github.token }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index fb51f1b..45a3af1 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -4,6 +4,7 @@ on:
push:
tags:
- "*_[0-9]+.[0-9]+.[0-9]+"
+ - "!features-*"
workflow_dispatch:
permissions:
diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml
new file mode 100644
index 0000000..6a79687
--- /dev/null
+++ b/.github/workflows/renovate.yml
@@ -0,0 +1,35 @@
+name: "Renovate"
+
+on:
+ schedule:
+ - cron: "0 0 7,21 * *"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: read
+
+jobs:
+ renovate:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout (GitHub)
+ uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Refresh the dotnet config
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ github.repository }}/devcontainer
+ push: never
+ runCmd: pnpm tsx scripts/tasks/features/dotnet/renovate.ts
+ env: |
+ GH_TOKEN=${{ secrets.RENOVATE_TOKEN }}
diff --git a/Directory.Build.props b/Directory.Build.props
index f32f53c..788e299 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -5,6 +5,7 @@
$(MSBuildThisFileDirectory)
$([MSBuild]::NormalizeDirectory($(ProjectRoot), ".config", "dotnet"))
$(ConfigDirectory)Packages.props
+ net10.0
diff --git a/features/dotnet/Build/Build.csproj b/features/dotnet/Build/Build.csproj
new file mode 100644
index 0000000..4b13639
--- /dev/null
+++ b/features/dotnet/Build/Build.csproj
@@ -0,0 +1,17 @@
+
+
+ $(DefaultTargetFramework)
+ build
+ Exe
+ DevcontainerConfig.Dotnet.Build
+
+
+
+
+
+
+
+ true
+ true
+
+
diff --git a/features/dotnet/Build/BuildException.cs b/features/dotnet/Build/BuildException.cs
new file mode 100644
index 0000000..e0f3954
--- /dev/null
+++ b/features/dotnet/Build/BuildException.cs
@@ -0,0 +1,3 @@
+namespace DevcontainerConfig.Dotnet.Build;
+
+sealed class BuildException(string message) : Exception(message);
diff --git a/features/dotnet/Build/Checksum.cs b/features/dotnet/Build/Checksum.cs
new file mode 100644
index 0000000..71d67ae
--- /dev/null
+++ b/features/dotnet/Build/Checksum.cs
@@ -0,0 +1,17 @@
+using System.Security.Cryptography;
+
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Checksum
+{
+ internal static async Task VerifyAsync(string tempPath, string sha512, CancellationToken cancellationToken)
+ {
+ await using FileStream stream = File.OpenRead(tempPath);
+ string actual = Convert.ToHexStringLower(await SHA512.HashDataAsync(stream, cancellationToken));
+
+ if (!string.Equals(actual, sha512, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new BuildException($"sha512 mismatch: expected {sha512}, actual {actual}.");
+ }
+ }
+}
diff --git a/features/dotnet/Build/Config.cs b/features/dotnet/Build/Config.cs
new file mode 100644
index 0000000..c2d2c48
--- /dev/null
+++ b/features/dotnet/Build/Config.cs
@@ -0,0 +1,34 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DevcontainerConfig.Dotnet.Build;
+
+sealed class Artifact
+{
+ [JsonPropertyName("url")]
+ public required string Url { get; init; }
+
+ [JsonPropertyName("sha512")]
+ public required string Sha512 { get; init; }
+}
+
+sealed class ComponentVersion
+{
+ [JsonPropertyName("aliases")]
+ public List Aliases { get; init; } = [];
+
+ [JsonExtensionData]
+ public Dictionary Rids { get; init; } = [];
+}
+
+sealed class ComponentConfig
+{
+ [JsonPropertyName("versions")]
+ public required Dictionary Versions { get; init; }
+}
+
+sealed class FeatureConfig
+{
+ [JsonPropertyName("components")]
+ public required Dictionary Components { get; init; }
+}
diff --git a/features/dotnet/Build/Http.cs b/features/dotnet/Build/Http.cs
new file mode 100644
index 0000000..033e89b
--- /dev/null
+++ b/features/dotnet/Build/Http.cs
@@ -0,0 +1,12 @@
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Http
+{
+ internal static async Task DownloadAsync(string tempPath, string url, CancellationToken cancellationToken)
+ {
+ using HttpClient client = new();
+ await using Stream httpStream = await client.GetStreamAsync(url, cancellationToken);
+ await using FileStream fileStream = File.Create(tempPath);
+ await httpStream.CopyToAsync(fileStream, cancellationToken);
+ }
+}
diff --git a/features/dotnet/Build/Layout.cs b/features/dotnet/Build/Layout.cs
new file mode 100644
index 0000000..a825bd2
--- /dev/null
+++ b/features/dotnet/Build/Layout.cs
@@ -0,0 +1,62 @@
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Layout
+{
+ internal static void Assert(string outPath, string component, string version)
+ {
+ AssertMuxer(Path.Join(outPath, "dotnet"));
+
+ string fxrPath = Path.Join(outPath, "host", "fxr");
+ if (!Directory.Exists(fxrPath))
+ {
+ throw new BuildException($"{fxrPath} is missing.");
+ }
+
+ if (!Directory.EnumerateDirectories(fxrPath).Any())
+ {
+ throw new BuildException($"{fxrPath} has no child directory.");
+ }
+
+ string payloadPath = component switch
+ {
+ "sdk" => Path.Join(outPath, "sdk", version),
+ "runtime" => Path.Join(outPath, "shared", "Microsoft.NETCore.App", version),
+ "aspnet" => Path.Join(outPath, "shared", "Microsoft.AspNetCore.App", version),
+ _ => throw new BuildException($"unknown component '{component}'."),
+ };
+
+ if (!Directory.Exists(payloadPath))
+ {
+ throw new BuildException($"{payloadPath} is missing.");
+ }
+ }
+
+ static void AssertMuxer(string muxerPath)
+ {
+ FileAttributes attributes;
+ try
+ {
+ attributes = File.GetAttributes(muxerPath);
+ }
+ catch (FileNotFoundException)
+ {
+ throw new BuildException($"{muxerPath} is missing.");
+ }
+
+ if (attributes.HasFlag(FileAttributes.Directory))
+ {
+ throw new BuildException($"{muxerPath} is not a regular file.");
+ }
+
+ UnixFileMode mode = File.GetUnixFileMode(muxerPath);
+
+ if (
+ !mode.HasFlag(UnixFileMode.UserExecute)
+ && !mode.HasFlag(UnixFileMode.GroupExecute)
+ && !mode.HasFlag(UnixFileMode.OtherExecute)
+ )
+ {
+ throw new BuildException($"{muxerPath} has no execute permission.");
+ }
+ }
+}
diff --git a/features/dotnet/Build/Program.cs b/features/dotnet/Build/Program.cs
new file mode 100644
index 0000000..dafbf59
--- /dev/null
+++ b/features/dotnet/Build/Program.cs
@@ -0,0 +1,61 @@
+using System.CommandLine;
+using System.Runtime.Versioning;
+using DevcontainerConfig.Dotnet.Build;
+
+[assembly: SupportedOSPlatform("linux")]
+
+Option configOption = new("--config") { Required = true, Description = "Path to the feature config.json." };
+Option componentOption = new("--component")
+{
+ Required = true,
+ Description = "Component to stage: sdk, runtime or aspnet.",
+};
+Option versionOption = new("--version")
+{
+ Required = true,
+ Description = "Pinned component version in config.json.",
+};
+Option archOption = new("--arch")
+{
+ Required = true,
+ Description = "Docker target architecture: amd64 or arm64.",
+};
+Option outOption = new("--out")
+{
+ Required = true,
+ Description = "Directory to extract the artifact into.",
+};
+
+RootCommand root = new("Download, verify and extract a pinned .NET payload artifact.")
+{
+ configOption,
+ componentOption,
+ versionOption,
+ archOption,
+ outOption,
+};
+root.Options.Remove(root.Options.OfType().Single());
+root.SetAction(
+ async (parseResult, cancellationToken) =>
+ {
+ try
+ {
+ await Staging.RunAsync(
+ parseResult.GetRequiredValue(configOption),
+ parseResult.GetRequiredValue(componentOption),
+ parseResult.GetRequiredValue(versionOption),
+ parseResult.GetRequiredValue(archOption),
+ parseResult.GetRequiredValue(outOption),
+ cancellationToken
+ );
+ return 0;
+ }
+ catch (Exception e)
+ {
+ Console.Error.WriteLine($"build: {e.Message.ReplaceLineEndings(" ")}");
+ return 1;
+ }
+ }
+);
+
+return await root.Parse(args).InvokeAsync();
diff --git a/features/dotnet/Build/Resolution.cs b/features/dotnet/Build/Resolution.cs
new file mode 100644
index 0000000..eb4b22f
--- /dev/null
+++ b/features/dotnet/Build/Resolution.cs
@@ -0,0 +1,72 @@
+using System.Text.Json;
+
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Resolution
+{
+ internal static async Task ResolveAsync(
+ FileInfo config,
+ string component,
+ string version,
+ string arch,
+ CancellationToken cancellationToken
+ )
+ {
+ string rid = $"linux-{MapArch(arch)}";
+ FeatureConfig feature = await LoadAsync(config, cancellationToken);
+
+ if (!feature.Components.TryGetValue(component, out ComponentConfig? componentConfig))
+ {
+ throw new BuildException(
+ $"component '{component}' not found in {config.FullName}; available components: "
+ + $"{string.Join(", ", feature.Components.Keys)}."
+ );
+ }
+
+ if (!componentConfig.Versions.TryGetValue(version, out ComponentVersion? componentVersion))
+ {
+ throw new BuildException(
+ $"version '{version}' not found for component '{component}' in {config.FullName}; "
+ + $"available versions: {string.Join(", ", componentConfig.Versions.Keys)}."
+ );
+ }
+
+ if (!componentVersion.Rids.TryGetValue(rid, out JsonElement ridEntry))
+ {
+ throw new BuildException(
+ $"rid '{rid}' not found for component '{component}' version '{version}' in "
+ + $"{config.FullName}; available rids: {string.Join(", ", componentVersion.Rids.Keys)}."
+ );
+ }
+
+ Artifact artifact =
+ ridEntry.Deserialize()
+ ?? throw new BuildException($"artifact for rid '{rid}' is null in {config.FullName}.");
+
+ if (artifact.Sha512.Length != 128 || !artifact.Sha512.All(char.IsAsciiHexDigit))
+ {
+ throw new BuildException(
+ $"sha512 for component '{component}' version '{version}' rid '{rid}' is not 128 hex " + "characters."
+ );
+ }
+
+ return artifact;
+ }
+
+ static string MapArch(string arch)
+ {
+ return arch switch
+ {
+ "amd64" => "x64",
+ "arm64" => "arm64",
+ _ => throw new BuildException($"arch '{arch}' is not one of: amd64, arm64."),
+ };
+ }
+
+ static async Task LoadAsync(FileInfo config, CancellationToken cancellationToken)
+ {
+ await using FileStream stream = config.OpenRead();
+ return await JsonSerializer.DeserializeAsync(stream, cancellationToken: cancellationToken)
+ ?? throw new BuildException($"{config.FullName} deserialized to null.");
+ }
+}
diff --git a/features/dotnet/Build/Staging.cs b/features/dotnet/Build/Staging.cs
new file mode 100644
index 0000000..71a0bef
--- /dev/null
+++ b/features/dotnet/Build/Staging.cs
@@ -0,0 +1,28 @@
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Staging
+{
+ internal static async Task RunAsync(
+ FileInfo config,
+ string component,
+ string version,
+ string arch,
+ DirectoryInfo outDir,
+ CancellationToken cancellationToken
+ )
+ {
+ Artifact artifact = await Resolution.ResolveAsync(config, component, version, arch, cancellationToken);
+ string tempPath = Path.Join(Path.GetTempPath(), $"dotnet-build-{Guid.NewGuid():N}");
+ try
+ {
+ await Http.DownloadAsync(tempPath, artifact.Url, cancellationToken);
+ await Checksum.VerifyAsync(tempPath, artifact.Sha512, cancellationToken);
+ await Tarball.ExtractAsync(tempPath, outDir, cancellationToken);
+ Layout.Assert(outDir.FullName, component, version);
+ }
+ finally
+ {
+ File.Delete(tempPath);
+ }
+ }
+}
diff --git a/features/dotnet/Build/Tarball.cs b/features/dotnet/Build/Tarball.cs
new file mode 100644
index 0000000..0515622
--- /dev/null
+++ b/features/dotnet/Build/Tarball.cs
@@ -0,0 +1,16 @@
+using System.Formats.Tar;
+using System.IO.Compression;
+
+namespace DevcontainerConfig.Dotnet.Build;
+
+internal static class Tarball
+{
+ internal static async Task ExtractAsync(string tempPath, DirectoryInfo outDir, CancellationToken cancellationToken)
+ {
+ outDir.Create();
+
+ await using FileStream fileStream = File.OpenRead(tempPath);
+ await using GZipStream gzip = new(fileStream, CompressionMode.Decompress);
+ await TarFile.ExtractToDirectoryAsync(gzip, outDir.FullName, overwriteFiles: false, cancellationToken);
+ }
+}
diff --git a/features/dotnet/Dockerfile b/features/dotnet/Dockerfile
new file mode 100644
index 0000000..032cd3e
--- /dev/null
+++ b/features/dotnet/Dockerfile
@@ -0,0 +1,9 @@
+FROM scratch AS linux-amd64-base
+FROM scratch AS linux-arm64-base
+
+ARG TARGETOS
+ARG TARGETARCH
+FROM ${TARGETOS}-${TARGETARCH}-base
+ARG TARGETOS
+ARG TARGETARCH
+COPY ${TARGETOS}/${TARGETARCH}/features/dotnet/ /features/dotnet/
diff --git a/features/dotnet/Install/Install.csproj b/features/dotnet/Install/Install.csproj
new file mode 100644
index 0000000..a988d9b
--- /dev/null
+++ b/features/dotnet/Install/Install.csproj
@@ -0,0 +1,18 @@
+
+
+ $(DefaultTargetFramework)
+ install
+ Exe
+ DevcontainerConfig.Dotnet.Install
+
+
+
+
+
+
+
+ true
+ true
+ lld
+
+
diff --git a/features/dotnet/Install/InstallException.cs b/features/dotnet/Install/InstallException.cs
new file mode 100644
index 0000000..287058d
--- /dev/null
+++ b/features/dotnet/Install/InstallException.cs
@@ -0,0 +1,3 @@
+namespace DevcontainerConfig.Dotnet.Install;
+
+sealed class InstallException(string message) : Exception(message);
diff --git a/features/dotnet/Install/Merge.cs b/features/dotnet/Install/Merge.cs
new file mode 100644
index 0000000..6b3ec8a
--- /dev/null
+++ b/features/dotnet/Install/Merge.cs
@@ -0,0 +1,46 @@
+using NuGet.Versioning;
+
+namespace DevcontainerConfig.Dotnet.Install;
+
+internal static class Merge
+{
+ internal static void Into(Source source, string root)
+ {
+ Directory.CreateDirectory(root);
+ MergeDir(source.Path, root, depth: 1);
+ Console.WriteLine($"install: merged {source.Component} {source.Name} into {root}");
+ }
+
+ // Entries of the source root are depth 1: the unversioned root files, which are first-wins so they come from
+ // the first processed source. Deeper files overwrite; a version-named directory at any depth is skip-if-present,
+ // whole subtree.
+ private static void MergeDir(string sourceDir, string targetDir, int depth)
+ {
+ foreach (string sourcePath in Directory.EnumerateFileSystemEntries(sourceDir))
+ {
+ string name = Path.GetFileName(sourcePath);
+ string targetPath = Path.Join(targetDir, name);
+
+ if (Directory.Exists(sourcePath))
+ {
+ if (NuGetVersion.TryParse(name, out _) && Directory.Exists(targetPath))
+ {
+ continue;
+ }
+ Directory.CreateDirectory(targetPath);
+ MergeDir(sourcePath, targetPath, depth + 1);
+ }
+ else if (depth == 1)
+ {
+ if (!File.Exists(targetPath))
+ {
+ File.Copy(sourcePath, targetPath, overwrite: false);
+ }
+ }
+ else
+ {
+ File.Copy(sourcePath, targetPath, overwrite: true);
+ }
+ }
+ }
+}
diff --git a/features/dotnet/Install/Program.cs b/features/dotnet/Install/Program.cs
new file mode 100644
index 0000000..896b85b
--- /dev/null
+++ b/features/dotnet/Install/Program.cs
@@ -0,0 +1,24 @@
+using System.Runtime.Versioning;
+using DevcontainerConfig.Dotnet.Install;
+
+[assembly: SupportedOSPlatform("linux")]
+
+const string root = "/opt/dotnet";
+string stagedDir = Environment.GetEnvironmentVariable("FEATURES_DIR") is { Length: > 0 } dir
+ ? dir
+ : Directory.GetCurrentDirectory();
+
+try
+{
+ foreach (Source source in Scan.Sources(stagedDir))
+ {
+ Merge.Into(source, root);
+ }
+ Registration.Register(root);
+ return 0;
+}
+catch (Exception e)
+{
+ Console.Error.WriteLine($"install: {e.Message.ReplaceLineEndings(" ")}");
+ return 1;
+}
diff --git a/features/dotnet/Install/Registration.cs b/features/dotnet/Install/Registration.cs
new file mode 100644
index 0000000..add032b
--- /dev/null
+++ b/features/dotnet/Install/Registration.cs
@@ -0,0 +1,68 @@
+namespace DevcontainerConfig.Dotnet.Install;
+
+internal static class Registration
+{
+ private const string LocationDir = "/etc/dotnet";
+ private const string LinkPath = "/usr/local/bin/dotnet";
+ private const string ProfileDir = "/etc/profile.d";
+
+ private const UnixFileMode Mode0644 =
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead;
+
+ internal static void Register(string root)
+ {
+ WriteInstallLocation(root);
+ CreateSymlink(root);
+ WriteProfileD(root);
+ }
+
+ private static void WriteInstallLocation(string root)
+ {
+ Directory.CreateDirectory(LocationDir);
+ string path = Path.Join(LocationDir, "install_location");
+ File.WriteAllText(path, $"{root}\n");
+ File.SetUnixFileMode(path, Mode0644);
+ Console.WriteLine($"install: registered {root} in {path}");
+ }
+
+ private static void CreateSymlink(string root)
+ {
+ string target = Path.Join(root, "dotnet");
+ if (Directory.Exists(LinkPath))
+ {
+ throw new InstallException($"{LinkPath} is a directory.");
+ }
+
+ // rename(2) replaces whatever else the path holds (absent, symlink or regular file) and never resolves the
+ // destination, so the link lands in one step.
+ string temp = Path.Join(Path.GetDirectoryName(LinkPath), $".dotnet-{Guid.NewGuid():N}");
+ File.CreateSymbolicLink(temp, target);
+ try
+ {
+ File.Move(temp, LinkPath, overwrite: true);
+ }
+ finally
+ {
+ File.Delete(temp);
+ }
+ Console.WriteLine($"install: linked {LinkPath} to {target}");
+ }
+
+ private static void WriteProfileD(string root)
+ {
+ Directory.CreateDirectory(ProfileDir);
+ string path = Path.Join(ProfileDir, "dotnet.sh");
+ File.WriteAllText(
+ path,
+ $$"""
+ export DOTNET_ROOT="{{root}}"
+ export DOTNET_CLI_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/dotnet/cli"
+ export NUGET_PACKAGES="${XDG_DATA_HOME:-$HOME/.local/share}/NuGet/global-packages"
+ export DOTNET_CLI_TELEMETRY_OPTOUT=true
+ export PATH="$PATH:$DOTNET_CLI_HOME/.dotnet/tools"
+ """
+ );
+ File.SetUnixFileMode(path, Mode0644);
+ Console.WriteLine($"install: wrote {path}");
+ }
+}
diff --git a/features/dotnet/Install/Scan.cs b/features/dotnet/Install/Scan.cs
new file mode 100644
index 0000000..020ca12
--- /dev/null
+++ b/features/dotnet/Install/Scan.cs
@@ -0,0 +1,74 @@
+using NuGet.Versioning;
+
+namespace DevcontainerConfig.Dotnet.Install;
+
+sealed record Source(string Component, string Name, NuGetVersion Version, string Path);
+
+internal static class Scan
+{
+ private static readonly string[] Components = ["sdk", "runtime", "aspnet"];
+
+ internal static List Sources(string stagedDir)
+ {
+ if (!Directory.Exists(stagedDir))
+ {
+ throw new InstallException($"{stagedDir} is not a directory.");
+ }
+
+ Dictionary> versionDirs = [];
+ foreach (string component in Components)
+ {
+ string componentDir = Path.Join(stagedDir, component);
+ if (!Directory.Exists(componentDir))
+ {
+ continue;
+ }
+
+ List dirs = [.. Directory.EnumerateDirectories(componentDir)];
+ if (dirs.Count == 0)
+ {
+ throw new InstallException($"{componentDir} has no version directory.");
+ }
+ versionDirs.Add(component, dirs);
+ }
+
+ if (versionDirs.Count == 0)
+ {
+ throw new InstallException($"{stagedDir} has no component directory.");
+ }
+
+ List sources = [];
+ foreach (string component in Components)
+ {
+ if (!versionDirs.TryGetValue(component, out List? dirs))
+ {
+ continue;
+ }
+
+ foreach (string dir in dirs)
+ {
+ string name = Path.GetFileName(dir);
+ if (!NuGetVersion.TryParse(name, out NuGetVersion? version))
+ {
+ throw new InstallException($"{dir} is not a version.");
+ }
+
+ string muxer = Path.Join(dir, "dotnet");
+ if (!File.Exists(muxer))
+ {
+ throw new InstallException($"{muxer} is missing.");
+ }
+
+ sources.Add(new Source(component, name, version, dir));
+ }
+ }
+
+ return
+ [
+ .. sources
+ .OrderBy(source => Array.IndexOf(Components, source.Component))
+ .ThenByDescending(source => source.Version, VersionComparer.Default)
+ .ThenBy(source => source.Name, StringComparer.Ordinal),
+ ];
+ }
+}
diff --git a/features/dotnet/config.json b/features/dotnet/config.json
new file mode 100644
index 0000000..b8d07f3
--- /dev/null
+++ b/features/dotnet/config.json
@@ -0,0 +1,149 @@
+{
+ "revision": 1,
+ "components": {
+ "sdk": {
+ "versions": {
+ "11.0.100-preview.7.26381.103": {
+ "aliases": ["preview", "11-preview", "11.0-preview"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/11.0.100-preview.7.26381.103/dotnet-sdk-11.0.100-preview.7.26381.103-linux-x64.tar.gz",
+ "sha512": "527f9dc8104a86214e37e81c7cea2c7d7fba31f6158a23e71458b85a0a2fba53fb2c606a2a3e53a2775fdae3e3d27cd644637b43039e573395ad1dee4b3968b1"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/11.0.100-preview.7.26381.103/dotnet-sdk-11.0.100-preview.7.26381.103-linux-arm64.tar.gz",
+ "sha512": "213b5a48455402dbebb9bd74b576d9a7ec37f30d4c6ce1099de5f5a153f6013069757d4c659784ca07e45ac86529b95e9c2fef26cdce9578f77806b8757213a2"
+ }
+ },
+ "10.0.400": {
+ "aliases": ["10.0", "10", "lts", "latest"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.400/dotnet-sdk-10.0.400-linux-x64.tar.gz",
+ "sha512": "1033977dd837150e0814cf0c5d5b17ceb63925fda7ba2158b47258a4bd7c048cf82eac3bc1166f3146f53124a3f5fba09db1de1260d2ce96399860303b404b48"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.400/dotnet-sdk-10.0.400-linux-arm64.tar.gz",
+ "sha512": "a1b45da58e5591fff909a6126ac6bfc1ef9c12bc72c0625f7815e83a82be1a902317ee96926cbbf81324a45c6abf2ed8102a216d0507879cc166159af78d1b77"
+ }
+ },
+ "9.0.317": {
+ "aliases": ["9.0", "9"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.317/dotnet-sdk-9.0.317-linux-x64.tar.gz",
+ "sha512": "145bf69dcb88c4b905feb531cfdd7894a75fc875d2a030e958a13d1fb1131521c8cebd8a8a6e0fbd1a433ebae9cde86356b6adad07b1ad81efb92b36ff8a3333"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.317/dotnet-sdk-9.0.317-linux-arm64.tar.gz",
+ "sha512": "fdf30fe705c91304d890115e955f738055f8c0885ea9891e7df1153321120fa2c38b6ae4dd132f871cb8facc0d1fabbd2b25ddd53d0a5b4293aa85d296e3b98d"
+ }
+ },
+ "8.0.424": {
+ "aliases": ["8.0", "8"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.424/dotnet-sdk-8.0.424-linux-x64.tar.gz",
+ "sha512": "6503fd9f464d5e3a4f43a881d2b74afc6a2c46ceda74d027f1565b7239f4b3ec884857c03c0dcd49eb52f384d5ae1fa5aaf135f0a6aabc5518103aceed643c74"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.424/dotnet-sdk-8.0.424-linux-arm64.tar.gz",
+ "sha512": "bb19b6779ad93d146055583d644ef269bb42501f6c7fdef51e14026cde9d5fd726d370de098a8d8504867fb24bfcb5ab88cc22bec812461aede334de1aacf7b6"
+ }
+ }
+ }
+ },
+ "runtime": {
+ "versions": {
+ "11.0.0-preview.7.26381.103": {
+ "aliases": ["preview", "11-preview", "11.0-preview"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/11.0.0-preview.7.26381.103/dotnet-runtime-11.0.0-preview.7.26381.103-linux-x64.tar.gz",
+ "sha512": "cfba2c21d63149c3181d98f4fa9b1e1a795cdafb43edcc07bec720149faeb555cd45f95e956a1e03307c8937142c734346bcb165ba0a04f34540519da2c8cbf9"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/11.0.0-preview.7.26381.103/dotnet-runtime-11.0.0-preview.7.26381.103-linux-arm64.tar.gz",
+ "sha512": "174d0949a427273239e5d82ca5d1152fe7a9c94228bbec323893424e98c3ceb7a569f159a53f76400e8bc86a7cb4b7b45cd5fb6ffe7c6e49d25a8af20e4a8a1d"
+ }
+ },
+ "10.0.11": {
+ "aliases": ["10.0", "10", "lts", "latest"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.11/dotnet-runtime-10.0.11-linux-x64.tar.gz",
+ "sha512": "64c77a5f98d6dfc63393ed5d2fed47c2855c7cdd4322008b3b13e1d0b710aefc1fba7e56612f25f7e8a2b1b6cef5cf77cb3a834ff3685e723ad286703c3396d8"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.11/dotnet-runtime-10.0.11-linux-arm64.tar.gz",
+ "sha512": "2a729a4eff6a55e271b3ae7d4f2be98aef16df22e3b9316827558e204f7881bbf0b7b9f8ebcfb1a1419c87fbcb8a00f5be72b6474e02c695c30b2c52bafb65ed"
+ }
+ },
+ "9.0.19": {
+ "aliases": ["9.0", "9"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.19/dotnet-runtime-9.0.19-linux-x64.tar.gz",
+ "sha512": "e7fca9c5a7efa2e7dad6ba601509c684ef46727e3ab1f0a51a375d0dff26fa06f001eec882033dd34a6a10497af30f00ff00066c165e00b1e13d7da2dcf5d441"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.19/dotnet-runtime-9.0.19-linux-arm64.tar.gz",
+ "sha512": "e07510c649fedf3fb13dd21026f34c8bcd17f54a68e4c071a1287d26e107e09d9ccd13ef0f63c630d309b8b797310a8dc42aed6a551eb145d9fad86a5819a4ee"
+ }
+ },
+ "8.0.30": {
+ "aliases": ["8.0", "8"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/8.0.30/dotnet-runtime-8.0.30-linux-x64.tar.gz",
+ "sha512": "64d921a7a79c32c5a3f45f5473e8b56d41180c4ea41204bf0efd1f1b9249c2e509ee219341617138fa34ff79a7f6ca8523c4a73a29a5b6616ddea2e43204d62c"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/8.0.30/dotnet-runtime-8.0.30-linux-arm64.tar.gz",
+ "sha512": "b900fb5822e44affc110d06b9c5ed372ed01485f307b1900bcea0bd1a9bc166a109a35ab741327668d83a2fe9d8ac0a94a12a94f4188bd2f8e1cadf404697aa1"
+ }
+ }
+ }
+ },
+ "aspnet": {
+ "versions": {
+ "11.0.0-preview.7.26381.103": {
+ "aliases": ["preview", "11-preview", "11.0-preview"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/11.0.0-preview.7.26381.103/aspnetcore-runtime-11.0.0-preview.7.26381.103-linux-x64.tar.gz",
+ "sha512": "a5d8b79f3f9f9ad915ca4a3b5099823ca03511d1c900a8117c1ed6b8ccc1fe843e430d4f58d5c05b971ddacb7e88faf6ed258ed704ec9f414a81f6c05322c65b"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/11.0.0-preview.7.26381.103/aspnetcore-runtime-11.0.0-preview.7.26381.103-linux-arm64.tar.gz",
+ "sha512": "cadec63c9223d5789c17fff57c4b26348b40b772b0b461972cf62b2743a517819b7dd8f1e52ba5da780a6d3109aa3ca0718e8a980bdfab3c73e33e8899e54e07"
+ }
+ },
+ "10.0.11": {
+ "aliases": ["10.0", "10", "lts", "latest"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.11/aspnetcore-runtime-10.0.11-linux-x64.tar.gz",
+ "sha512": "4c6be0623330074e699dab8084be15a1baebb7a518c0dd8ce99f93cf79777cd46f3a38ef9d25edc152ed606f084b63736bd9e4082eb32d188fc357bf6ac4d1d6"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.11/aspnetcore-runtime-10.0.11-linux-arm64.tar.gz",
+ "sha512": "9549f7a59d5d6f7dd3e965bf88631698b23974aff4e34d589037d6ae9a3f4433902881b4d23f7e18602ab954823f5be35015054a6eccb57041b0f20d92873ed7"
+ }
+ },
+ "9.0.19": {
+ "aliases": ["9.0", "9"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/9.0.19/aspnetcore-runtime-9.0.19-linux-x64.tar.gz",
+ "sha512": "579f37c2af8dbe8f7e3ef294c02fcf6ce2649fc34aba8f8ead087a9bd794403a8852079fc0f1c5cbe5baba337e6b999478667fed0f05fbe0fb8fba70661f7608"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/9.0.19/aspnetcore-runtime-9.0.19-linux-arm64.tar.gz",
+ "sha512": "3c716a748de08c44b475d8a7e2ef8973d8d330fea31d076688263c209c649318c396f8e4779b5bc1ae2176f7b623985a0da177bb50265da722c73b6aebf7faeb"
+ }
+ },
+ "8.0.30": {
+ "aliases": ["8.0", "8"],
+ "linux-x64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/8.0.30/aspnetcore-runtime-8.0.30-linux-x64.tar.gz",
+ "sha512": "415f79420e9fc465467ccab237f18b609710a715ebe43cd3c05c69af975d474fddcbe37eb831164472de60739874216c451ebbfb5f1fb040b41e90e41dc77206"
+ },
+ "linux-arm64": {
+ "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/8.0.30/aspnetcore-runtime-8.0.30-linux-arm64.tar.gz",
+ "sha512": "279d8ab84b6102c29fc96b5980805e80c679a234abdbe8047aa9f5954b260ddb70b4ae0793e2c379335d20dfaa6c86bdf7c8bd7d442c8ebe3c02d75b10bc8a4f"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 998383e..1f959b8 100644
--- a/package.json
+++ b/package.json
@@ -11,11 +11,13 @@
"pushTags": "tsx scripts/pushTags.ts",
"restore": "tsx scripts/restore.ts",
"test": "tsx scripts/test.ts",
- "update": "tsx scripts/update.ts"
+ "update": "tsx scripts/update.ts",
+ "verify": "tsx scripts/verify.ts"
},
"dependencies": {
"@devcontainers/cli": "^0.88.0",
"@eslint/js": "^10.0.1",
+ "@octokit/rest": "^22.0.1",
"@prettier/plugin-xml": "^3.4.2",
"@tsconfig/node24": "^24.0.5",
"@typescript-eslint/utils": "^8.68.0",
@@ -41,7 +43,8 @@
"tsx": "^4.23.13",
"typescript": "^6.0.3",
"typescript-eslint": "^8.68.0",
- "vitest": "^4.1.11"
+ "vitest": "^4.1.11",
+ "zod": "^4.5.4"
},
"devDependencies": {
"@types/node": "^24.13.3"
diff --git a/scripts/runner.ts b/scripts/runner.ts
index edf1f4c..1eebca5 100644
--- a/scripts/runner.ts
+++ b/scripts/runner.ts
@@ -18,7 +18,12 @@ export const createDevContainerRunner = async (env: Record = {})
const workspacePath = path.resolve(tempPath.path, path.basename(projectRoot));
await mkdir(workspacePath, { recursive: true });
const composeProject = path.basename(tempPath.path).toLowerCase();
- const $$ = $({ ...shellOptions, cwd: workspacePath, env: { COMPOSE_PROJECT_NAME: composeProject } });
+ const $$ = $({
+ ...shellOptions,
+ cwd: workspacePath,
+ env: { COMPOSE_PROJECT_NAME: composeProject },
+ localDir: projectRoot,
+ });
await cp(projectRoot, workspacePath, {
recursive: true,
diff --git a/scripts/tasks/features/dotnet/buildContext.ts b/scripts/tasks/features/dotnet/buildContext.ts
new file mode 100644
index 0000000..60fa1db
--- /dev/null
+++ b/scripts/tasks/features/dotnet/buildContext.ts
@@ -0,0 +1,50 @@
+import { chmod, copyFile, link, mkdir, readdir, readlink, rm, symlink } from "node:fs/promises";
+import path from "node:path";
+
+import { projectRoot } from "@/scripts/project.js";
+
+import type { Component } from "./generateConfig.js";
+import type { DockerArch } from "./paths.js";
+import { paths } from "./paths.js";
+
+const linkTree = async (source: string, target: string): Promise => {
+ for (const entry of await readdir(source, { withFileTypes: true })) {
+ const sourceEntry = path.join(source, entry.name);
+ const targetEntry = path.join(target, entry.name);
+ if (entry.isDirectory()) {
+ await mkdir(targetEntry);
+ await linkTree(sourceEntry, targetEntry);
+ } else if (entry.isFile()) {
+ await link(sourceEntry, targetEntry);
+ } else if (entry.isSymbolicLink()) {
+ await symlink(await readlink(sourceEntry), targetEntry);
+ } else {
+ throw new Error(`${sourceEntry}: unsupported payload entry`);
+ }
+ }
+};
+
+export const assembleBuildContext = async (
+ component: Component,
+ version: string,
+ installBinaries: Partial>,
+): Promise => {
+ const context = paths.contextPath(component, version);
+ await rm(context, { recursive: true, force: true });
+ await mkdir(path.join(context, "docker"), { recursive: true });
+ await copyFile(path.join(projectRoot, "features/dotnet/Dockerfile"), path.join(context, "docker", "Dockerfile"));
+ for (const arch of paths.dockerArchOptions) {
+ const installBinary = installBinaries[arch];
+ if (installBinary === undefined) {
+ continue;
+ }
+ const stagedRoot = path.join(context, "linux", arch, "features", "dotnet");
+ const staged = path.join(stagedRoot, component, version);
+ await mkdir(staged, { recursive: true });
+ await linkTree(paths.payloadPath(component, version, arch), staged);
+ const install = path.join(stagedRoot, "install");
+ await copyFile(installBinary, install);
+ await chmod(install, 0o755);
+ }
+ return context;
+};
diff --git a/scripts/tasks/features/dotnet/buildImage.ts b/scripts/tasks/features/dotnet/buildImage.ts
new file mode 100644
index 0000000..bfcec55
--- /dev/null
+++ b/scripts/tasks/features/dotnet/buildImage.ts
@@ -0,0 +1,63 @@
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import { $$ } from "@/scripts/shell.js";
+
+import { assembleBuildContext } from "./buildContext.js";
+import { readConfig } from "./generateConfig.js";
+import { parseInvocation } from "./invocation.js";
+import { ensurePackBinary } from "./packBinary.js";
+import { paths } from "./paths.js";
+import { defaultRefPrefix, imageRefs, mainChannel, tagNames } from "./tags.js";
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ component: { type: "string" },
+ version: { type: "string" },
+ arch: { type: "string" },
+ tag: { type: "string", multiple: true },
+ channel: { type: "string", default: mainChannel },
+ prefix: { type: "string" },
+ force: { type: "boolean", default: false },
+ },
+ });
+
+ const { component, version, arch } = parseInvocation(values);
+ const channel = values.channel;
+ const prefix = values.prefix ?? (await defaultRefPrefix());
+ if (arch !== paths.hostArch()) {
+ throw new Error(
+ `--arch ${arch} does not match the host architecture ${paths.hostArch()}; local image builds build the current platform only`,
+ );
+ }
+
+ const install = await ensurePackBinary("install", paths.ridFor(arch), values.force);
+ const payload = paths.payloadPath(component, version, arch);
+ if (!(await paths.isFile(path.join(payload, "dotnet")))) {
+ throw new Error(
+ `No payload at ${payload}; run scripts/tasks/features/dotnet/fetchPayload.ts --component ${component} --version ${version} --arch ${arch} first`,
+ );
+ }
+
+ const context = await assembleBuildContext(component, version, { [arch]: install });
+ const config = await readConfig();
+ const refs = [...imageRefs(prefix, component, tagNames(config, component, version, channel)), ...(values.tag ?? [])];
+ await $$`docker buildx build ${[
+ ...["--platform", `linux/${arch}`],
+ "--load",
+ ...["--file", path.join(context, "docker", "Dockerfile")],
+ ...refs.flatMap((ref) => ["--tag", ref]),
+ context,
+ ]}`;
+ console.log(refs.join("\n"));
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/fetchPayload.ts b/scripts/tasks/features/dotnet/fetchPayload.ts
new file mode 100644
index 0000000..e891c8e
--- /dev/null
+++ b/scripts/tasks/features/dotnet/fetchPayload.ts
@@ -0,0 +1,27 @@
+import { parseArgs } from "node:util";
+
+import { parseInvocation } from "./invocation.js";
+import { ensurePayload } from "./payload.js";
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ component: { type: "string" },
+ version: { type: "string" },
+ arch: { type: "string" },
+ force: { type: "boolean", default: false },
+ },
+ });
+
+ const { component, version, arch } = parseInvocation(values);
+ await ensurePayload(component, version, arch, values.force);
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/generateConfig.ts b/scripts/tasks/features/dotnet/generateConfig.ts
new file mode 100644
index 0000000..2ea2ea0
--- /dev/null
+++ b/scripts/tasks/features/dotnet/generateConfig.ts
@@ -0,0 +1,231 @@
+import { readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+import * as prettier from "prettier";
+import { z } from "zod";
+
+import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" };
+import { projectRoot } from "@/scripts/project.js";
+
+const releasesIndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json";
+export const configPath = path.resolve(projectRoot, "features/dotnet/config.json");
+
+export const components = ["sdk", "runtime", "aspnet"] as const;
+export type Component = (typeof components)[number];
+
+export const rids = ["linux-x64", "linux-arm64"] as const;
+export type Rid = (typeof rids)[number];
+
+type FeedKey = "sdk" | "runtime" | "aspnetcore-runtime";
+
+const componentFeedKeys: Record = {
+ sdk: "sdk",
+ runtime: "runtime",
+ aspnet: "aspnetcore-runtime",
+};
+
+const componentFilePrefixes: Record = {
+ sdk: "dotnet-sdk-",
+ runtime: "dotnet-runtime-",
+ aspnet: "aspnetcore-runtime-",
+};
+
+const channelSchema = z.object({
+ "channel-version": z.string(),
+ "latest-runtime": z.string(),
+ "latest-sdk": z.string(),
+ "release-type": z.string(),
+ "support-phase": z.enum(["preview", "go-live", "active", "maintenance", "eol"]),
+ "releases.json": z.string(),
+});
+
+const releasesIndexSchema = z.object({ "releases-index": z.array(channelSchema) });
+
+const feedFileSchema = z.object({
+ name: z.string(),
+ rid: z.string(),
+ url: z.string(),
+ hash: z.string(),
+});
+
+const feedComponentSchema = z.object({
+ version: z.string(),
+ files: z.array(feedFileSchema),
+});
+
+const feedReleaseSchema = z.object({
+ sdk: feedComponentSchema.optional(),
+ runtime: feedComponentSchema.optional(),
+ "aspnetcore-runtime": feedComponentSchema.optional(),
+});
+
+const feedReleasesSchema = z.object({ releases: z.array(feedReleaseSchema) });
+
+type FeedChannel = z.infer;
+type FeedIndex = z.infer;
+type FeedFile = z.infer;
+type FeedComponent = z.infer;
+type FeedReleases = z.infer;
+
+const artifactSchema = z.object({ url: z.string(), sha512: z.string() });
+
+const componentVersionSchema = z.object({
+ aliases: z.array(z.string()),
+ "linux-x64": artifactSchema,
+ "linux-arm64": artifactSchema,
+});
+
+const componentConfigSchema = z.object({ versions: z.record(z.string(), componentVersionSchema) });
+
+export const featureConfigSchema = z.object({
+ revision: z.number(),
+ components: z.record(z.enum(components), componentConfigSchema),
+});
+
+export type Artifact = z.infer;
+export type ComponentVersion = z.infer;
+export type ComponentConfig = z.infer;
+export type FeatureConfig = z.infer;
+
+interface ChannelData {
+ channel: FeedChannel;
+ releases: FeedReleases;
+}
+
+const fetchJson = async (url: string, schema: S): Promise> => {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`GET ${url} → HTTP ${response.status}`);
+ }
+ const result = schema.safeParse(await response.json());
+ if (!result.success) {
+ throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`);
+ }
+ return result.data;
+};
+
+const selectChannels = (index: FeedIndex): FeedChannel[] =>
+ index["releases-index"].filter((channel) => channel["support-phase"] !== "eol");
+
+const fetchChannelData = async (channels: readonly FeedChannel[]): Promise =>
+ Promise.all(
+ channels.map(async (channel) => ({
+ channel,
+ releases: await fetchJson(channel["releases.json"], feedReleasesSchema),
+ })),
+ );
+
+const resolveComponent = (releases: FeedReleases, feedKey: FeedKey, expectedVersion: string): FeedComponent => {
+ const component = releases.releases.find((release) => release[feedKey]?.version === expectedVersion)?.[feedKey];
+ if (!component) {
+ throw new Error(`No release entry with ${feedKey}.version === ${expectedVersion}`);
+ }
+ return component;
+};
+
+const resolveArtifacts = (component: Component, version: string, files: readonly FeedFile[]): Record =>
+ Object.fromEntries(
+ rids.map((rid) => {
+ const name = `${componentFilePrefixes[component]}${rid}.tar.gz`;
+ const matches = files.filter((candidate) => candidate.rid === rid && candidate.name === name);
+ if (matches.length !== 1) {
+ throw new Error(
+ `Component ${component} version ${version}: expected one file ${name} for rid ${rid}, found ${matches.length}`,
+ );
+ }
+ const [file] = matches;
+ return [rid, { url: file.url, sha512: file.hash }] as const;
+ }),
+ ) as Record;
+
+const majorVersion = (channel: FeedChannel): string => {
+ const major = /^(\d+)\./.exec(channel["channel-version"])?.[1];
+ if (major === undefined) {
+ throw new Error(`Channel ${channel["channel-version"]}: cannot derive major version`);
+ }
+ return major;
+};
+
+const resolveVersions = (
+ component: Component,
+ channelData: readonly ChannelData[],
+): Record => {
+ const feedKey = componentFeedKeys[component];
+ const latestChannel = channelData.find((entry) => entry.channel["support-phase"] === "active");
+ const ltsChannel = channelData.find(
+ (entry) => entry.channel["release-type"] === "lts" && entry.channel["support-phase"] === "active",
+ );
+ const versions: Record = {};
+ for (const entry of channelData) {
+ const { channel } = entry;
+ const expected = component === "sdk" ? channel["latest-sdk"] : channel["latest-runtime"];
+ const feedComponent = resolveComponent(entry.releases, feedKey, expected);
+ const version = feedComponent.version;
+ if (version in versions) {
+ throw new Error(`Component ${component}: version ${version} resolved from more than one channel`);
+ }
+ const major = majorVersion(channel);
+ let aliases: string[];
+ if (channel["support-phase"] === "preview" || channel["support-phase"] === "go-live") {
+ aliases = ["preview", `${major}-preview`, `${channel["channel-version"]}-preview`];
+ } else {
+ aliases = [channel["channel-version"], major];
+ if (ltsChannel === entry) {
+ aliases.push("lts");
+ }
+ if (latestChannel === entry) {
+ aliases.push("latest");
+ }
+ }
+ versions[version] = { aliases, ...resolveArtifacts(component, version, feedComponent.files) };
+ }
+ return versions;
+};
+
+export const parseConfig = (text: string, source: string): FeatureConfig => {
+ const result = featureConfigSchema.safeParse(JSON.parse(text));
+ if (!result.success) {
+ throw new Error(`${source}: ${z.prettifyError(result.error)}`);
+ }
+ return result.data;
+};
+
+export const readConfig = async (): Promise =>
+ parseConfig(await readFile(configPath, "utf-8"), configPath);
+
+const readRevision = async (): Promise => {
+ try {
+ return (await readConfig()).revision;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return 1;
+ }
+ throw error;
+ }
+};
+
+export const generateConfig = async (): Promise => {
+ const index = await fetchJson(releasesIndexUrl, releasesIndexSchema);
+ const channelData = await fetchChannelData(selectChannels(index));
+ const config: FeatureConfig = {
+ revision: await readRevision(),
+ components: {
+ sdk: { versions: resolveVersions("sdk", channelData) },
+ runtime: { versions: resolveVersions("runtime", channelData) },
+ aspnet: { versions: resolveVersions("aspnet", channelData) },
+ },
+ };
+ await writeFile(
+ configPath,
+ await prettier.format(JSON.stringify(config), { ...prettierOptions, filepath: configPath }),
+ );
+};
+
+if (import.meta.main) {
+ try {
+ await generateConfig();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/installBinary.ts b/scripts/tasks/features/dotnet/installBinary.ts
new file mode 100644
index 0000000..5d567b8
--- /dev/null
+++ b/scripts/tasks/features/dotnet/installBinary.ts
@@ -0,0 +1,27 @@
+import path from "node:path";
+
+import { ensurePackBinary } from "./packBinary.js";
+import type { DockerArch } from "./paths.js";
+import { paths } from "./paths.js";
+
+const localBinary = async (arch: DockerArch): Promise => {
+ if (arch !== paths.hostArch()) {
+ throw new Error(
+ `--install-dir is required for the ${paths.ridFor(arch)} install binary: a local pack builds the host architecture only`,
+ );
+ }
+ return ensurePackBinary("install", paths.ridFor(arch), false);
+};
+
+export const resolveInstallBinaries = async (installDir?: string): Promise> => {
+ const binaries = {} as Record;
+ for (const arch of paths.dockerArchOptions) {
+ const binaryPath =
+ installDir === undefined ? await localBinary(arch) : path.join(installDir, paths.ridFor(arch), "install");
+ if (!(await paths.isFile(binaryPath))) {
+ throw new Error(`No ${paths.ridFor(arch)} install binary at ${binaryPath}`);
+ }
+ binaries[arch] = binaryPath;
+ }
+ return binaries;
+};
diff --git a/scripts/tasks/features/dotnet/invocation.ts b/scripts/tasks/features/dotnet/invocation.ts
new file mode 100644
index 0000000..1e22ccb
--- /dev/null
+++ b/scripts/tasks/features/dotnet/invocation.ts
@@ -0,0 +1,38 @@
+import type { Component } from "./generateConfig.js";
+import { components } from "./generateConfig.js";
+import type { DockerArch } from "./paths.js";
+import { paths } from "./paths.js";
+
+export interface Invocation {
+ component: Component;
+ version: string;
+ arch: DockerArch;
+}
+
+export const parseComponent = (value: string | undefined): Component => {
+ const component = components.find((candidate) => candidate === value);
+ if (component === undefined) {
+ throw new Error(`--component must be one of: ${components.join(", ")}`);
+ }
+ return component;
+};
+
+export const parseVersion = (value: string | undefined): string => {
+ if (value === undefined || value === "") {
+ throw new Error("--version is required");
+ }
+ return value;
+};
+
+export const parseInvocation = (values: { component?: string; version?: string; arch?: string }): Invocation => {
+ const component = parseComponent(values.component);
+ const version = parseVersion(values.version);
+ const arch =
+ values.arch === undefined
+ ? paths.hostArch()
+ : paths.dockerArchOptions.find((candidate) => candidate === values.arch);
+ if (arch === undefined) {
+ throw new Error(`--arch must be one of: ${paths.dockerArchOptions.join(", ")}`);
+ }
+ return { component, version, arch };
+};
diff --git a/scripts/tasks/features/dotnet/packBinary.ts b/scripts/tasks/features/dotnet/packBinary.ts
new file mode 100644
index 0000000..38ad360
--- /dev/null
+++ b/scripts/tasks/features/dotnet/packBinary.ts
@@ -0,0 +1,27 @@
+import path from "node:path";
+
+import { project$$ } from "@/scripts/shell.js";
+
+import type { Rid } from "./generateConfig.js";
+import type { Assembly } from "./paths.js";
+import { paths } from "./paths.js";
+
+const projects: Record = { build: "Build", install: "Install" };
+
+export const ensurePackBinary = async (assembly: Assembly, rid: Rid, force: boolean): Promise => {
+ const binaryPath = path.join(paths.packPath(assembly, rid), assembly);
+ if (!force && (await paths.isFile(binaryPath))) {
+ return binaryPath;
+ }
+ const project = projects[assembly];
+ await project$$`dotnet publish ${[
+ ...["--configuration", "Release"],
+ ...["--runtime", rid],
+ `features/dotnet/${project}/${project}.csproj`,
+ `-p:PublishDir=${paths.packPath(assembly, rid)}/`,
+ ]}`;
+ if (!(await paths.isFile(binaryPath))) {
+ throw new Error(`dotnet publish did not produce ${binaryPath}`);
+ }
+ return binaryPath;
+};
diff --git a/scripts/tasks/features/dotnet/packInstall.ts b/scripts/tasks/features/dotnet/packInstall.ts
new file mode 100644
index 0000000..5b8e8a7
--- /dev/null
+++ b/scripts/tasks/features/dotnet/packInstall.ts
@@ -0,0 +1,38 @@
+import { copyFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import { rids } from "./generateConfig.js";
+import { ensurePackBinary } from "./packBinary.js";
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ rid: { type: "string" },
+ out: { type: "string" },
+ force: { type: "boolean", default: false },
+ },
+ });
+
+ const rid = rids.find((candidate) => candidate === values.rid);
+ if (rid === undefined) {
+ throw new Error(`--rid must be one of: ${rids.join(", ")}`);
+ }
+ if (values.out === undefined || values.out === "") {
+ throw new Error("--out is required");
+ }
+ const binary = await ensurePackBinary("install", rid, values.force);
+ const target = path.join(path.resolve(values.out), rid, "install");
+ await mkdir(path.dirname(target), { recursive: true });
+ await copyFile(binary, target);
+ console.log(`Install binary written to ${target}`);
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/paths.ts b/scripts/tasks/features/dotnet/paths.ts
new file mode 100644
index 0000000..62c5711
--- /dev/null
+++ b/scripts/tasks/features/dotnet/paths.ts
@@ -0,0 +1,58 @@
+import { stat } from "node:fs/promises";
+import path from "node:path";
+
+import { workspaces } from "@/scripts/project.js";
+
+import type { Component, Rid } from "./generateConfig.js";
+
+const dockerArchOptions = ["amd64", "arm64"] as const;
+export type DockerArch = (typeof dockerArchOptions)[number];
+
+export type Assembly = "build" | "install";
+
+const hostArch = (): DockerArch => {
+ switch (process.arch) {
+ case "x64":
+ return "amd64";
+ case "arm64":
+ return "arm64";
+ default:
+ throw new Error(`Unsupported host architecture: ${process.arch}`);
+ }
+};
+
+const rids: Record = { amd64: "linux-x64", arm64: "linux-arm64" };
+
+const ridFor = (arch: DockerArch): Rid => rids[arch];
+
+const payloadRoot = path.resolve(workspaces, "payload/dotnet");
+
+const payloadPath = (component: Component, version: string, arch: DockerArch): string =>
+ path.join(payloadRoot, component, version, arch);
+
+const contextPath = (component: Component, version: string): string =>
+ path.join(payloadRoot, "ctx", component, version);
+
+const packPath = (assembly: Assembly, rid: Rid): string => path.join(workspaces, "bin/dotnet", assembly, rid);
+
+const isFile = async (candidate: string): Promise => {
+ try {
+ return (await stat(candidate)).isFile();
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+};
+
+export const paths = {
+ dockerArchOptions,
+ hostArch,
+ ridFor,
+ payloadRoot,
+ payloadPath,
+ contextPath,
+ packPath,
+ isFile,
+} as const;
diff --git a/scripts/tasks/features/dotnet/payload.ts b/scripts/tasks/features/dotnet/payload.ts
new file mode 100644
index 0000000..6cb99a7
--- /dev/null
+++ b/scripts/tasks/features/dotnet/payload.ts
@@ -0,0 +1,37 @@
+import { rename, rm } from "node:fs/promises";
+import path from "node:path";
+
+import { $$ } from "@/scripts/shell.js";
+
+import type { Component } from "./generateConfig.js";
+import { configPath } from "./generateConfig.js";
+import { ensurePackBinary } from "./packBinary.js";
+import type { DockerArch } from "./paths.js";
+import { paths } from "./paths.js";
+
+export const ensurePayload = async (
+ component: Component,
+ version: string,
+ arch: DockerArch,
+ force: boolean,
+): Promise => {
+ const outPath = paths.payloadPath(component, version, arch);
+ if (!force && (await paths.isFile(path.join(outPath, "dotnet")))) {
+ console.log(`Payload already present at ${outPath}; skipping fetch`);
+ return;
+ }
+ const buildBinary = await ensurePackBinary("build", paths.ridFor(paths.hostArch()), force);
+ const tempPath = `${outPath}.tmp`;
+ await rm(tempPath, { recursive: true, force: true });
+ await $$`${[
+ buildBinary,
+ ...["--config", configPath],
+ ...["--component", component],
+ ...["--version", version],
+ ...["--arch", arch],
+ ...["--out", tempPath],
+ ]}`;
+ await rm(outPath, { recursive: true, force: true });
+ await rename(tempPath, outPath);
+ console.log(`Payload written to ${outPath}`);
+};
diff --git a/scripts/tasks/features/dotnet/planPublish.ts b/scripts/tasks/features/dotnet/planPublish.ts
new file mode 100644
index 0000000..5874e04
--- /dev/null
+++ b/scripts/tasks/features/dotnet/planPublish.ts
@@ -0,0 +1,75 @@
+import { appendFile, mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import { $ } from "execa";
+
+import type { Component } from "./generateConfig.js";
+import { components, readConfig } from "./generateConfig.js";
+import { login } from "./registry.js";
+import { canonicalTag, defaultRefPrefix, imageRef, mainChannel, tagNames } from "./tags.js";
+
+interface PlanEntry {
+ component: Component;
+ version: string;
+}
+
+const $$inspect = $({ reject: false, stdio: "ignore" });
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ channel: { type: "string", default: mainChannel },
+ out: { type: "string", default: ".ci/plan.json" },
+ prefix: { type: "string" },
+ },
+ });
+
+ const channel = values.channel;
+ const prefix = values.prefix ?? (await defaultRefPrefix());
+ const config = await readConfig();
+ const pairs = components.flatMap((component) =>
+ Object.keys(config.components[component].versions).map((version) => ({ component, version })),
+ );
+ const derived = pairs.map(({ component, version }) => ({
+ component,
+ version,
+ canonical: imageRef(prefix, component, canonicalTag(config, component, version, channel)),
+ tags: tagNames(config, component, version, channel),
+ }));
+
+ if (channel === mainChannel) {
+ await login(prefix);
+ }
+ const listed: PlanEntry[] = [];
+ for (const pair of derived) {
+ if (channel === mainChannel) {
+ const inspect = await $$inspect`docker buildx imagetools inspect ${pair.canonical}`;
+ if (inspect.exitCode === 0) {
+ console.log(`${pair.component} ${pair.version}: already published (${pair.canonical})`);
+ continue;
+ }
+ }
+ console.log(`${pair.component} ${pair.version}: to publish (${pair.tags.join(", ")})`);
+ listed.push({ component: pair.component, version: pair.version });
+ }
+
+ const plan = { include: listed };
+ const outPath = path.resolve(values.out);
+ await mkdir(path.dirname(outPath), { recursive: true });
+ await writeFile(outPath, `${JSON.stringify(plan, null, 2)}\n`);
+ const githubOutput = process.env.GITHUB_OUTPUT;
+ if (githubOutput !== undefined && githubOutput !== "") {
+ await appendFile(githubOutput, `matrix=${listed.length === 0 ? "" : JSON.stringify(plan)}\n`);
+ }
+ console.log(`${listed.length} of ${pairs.length} pairs to publish (${outPath})`);
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/publishImage.ts b/scripts/tasks/features/dotnet/publishImage.ts
new file mode 100644
index 0000000..4306cf7
--- /dev/null
+++ b/scripts/tasks/features/dotnet/publishImage.ts
@@ -0,0 +1,56 @@
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import { $$ } from "@/scripts/shell.js";
+
+import { assembleBuildContext } from "./buildContext.js";
+import { readConfig } from "./generateConfig.js";
+import { resolveInstallBinaries } from "./installBinary.js";
+import { parseComponent, parseVersion } from "./invocation.js";
+import { paths } from "./paths.js";
+import { ensurePayload } from "./payload.js";
+import { login } from "./registry.js";
+import { defaultRefPrefix, imageRefs, mainChannel, tagNames } from "./tags.js";
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ component: { type: "string" },
+ version: { type: "string" },
+ channel: { type: "string", default: mainChannel },
+ "install-dir": { type: "string" },
+ prefix: { type: "string" },
+ },
+ });
+
+ const component = parseComponent(values.component);
+ const version = parseVersion(values.version);
+ const channel = values.channel;
+ const prefix = values.prefix ?? (await defaultRefPrefix());
+ const config = await readConfig();
+ const tags = tagNames(config, component, version, channel);
+ const installBinaries = await resolveInstallBinaries(values["install-dir"]);
+ for (const arch of paths.dockerArchOptions) {
+ await ensurePayload(component, version, arch, false);
+ }
+ const context = await assembleBuildContext(component, version, installBinaries);
+ await login(prefix);
+ const refs = imageRefs(prefix, component, tags);
+ await $$`docker buildx build ${[
+ ...["--platform", paths.dockerArchOptions.map((arch) => `linux/${arch}`).join(",")],
+ "--push",
+ ...["--file", path.join(context, "docker", "Dockerfile")],
+ ...refs.flatMap((ref) => ["--tag", ref]),
+ context,
+ ]}`;
+ console.log(refs.join("\n"));
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/registry.ts b/scripts/tasks/features/dotnet/registry.ts
new file mode 100644
index 0000000..174a4cf
--- /dev/null
+++ b/scripts/tasks/features/dotnet/registry.ts
@@ -0,0 +1,14 @@
+import { $ } from "execa";
+
+import { getRemoteInfo } from "@/scripts/tasks/build.js";
+
+const ghcrHost = "ghcr.io";
+
+export const login = async (prefix: string): Promise => {
+ const token = process.env.GH_TOKEN;
+ if (token === undefined || token === "" || prefix.split("/")[0] !== ghcrHost) {
+ return;
+ }
+ const { owner } = await getRemoteInfo();
+ await $({ input: token, verbose: "full" })`docker login ${ghcrHost} --username ${owner} --password-stdin`;
+};
diff --git a/scripts/tasks/features/dotnet/renovate.ts b/scripts/tasks/features/dotnet/renovate.ts
new file mode 100644
index 0000000..ddded58
--- /dev/null
+++ b/scripts/tasks/features/dotnet/renovate.ts
@@ -0,0 +1,121 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import { Octokit } from "@octokit/rest";
+import git from "isomorphic-git";
+import http from "isomorphic-git/http/node";
+
+import { projectRoot } from "@/scripts/project.js";
+import { getRemoteInfo } from "@/scripts/tasks/build.js";
+
+import type { Component, FeatureConfig } from "./generateConfig.js";
+import { components, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js";
+
+const refreshBranch = "renovate";
+const title = "Update dotnet version pins";
+const configFilepath = path.relative(projectRoot, configPath);
+const textDecoder = new TextDecoder();
+
+const versionPins = (previous: FeatureConfig, next: FeatureConfig): string[] =>
+ components.flatMap((component: Component) => {
+ const before = Object.keys(previous.components[component].versions);
+ const after = Object.keys(next.components[component].versions);
+ const removed = before.filter((version) => !after.includes(version));
+ const added = after.filter((version) => !before.includes(version));
+ if (removed.length === 0 && added.length === 0) {
+ return [];
+ }
+ return [`${component}: ${removed.join(", ")} → ${added.join(", ")}`];
+ });
+
+const headConfig = async (oid: string): Promise => {
+ const { blob } = await git.readBlob({ fs, dir: projectRoot, oid, filepath: configFilepath });
+ return parseConfig(textDecoder.decode(blob), `${configFilepath} at HEAD`);
+};
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ "dry-run": { type: "boolean", default: false },
+ },
+ });
+
+ await generateConfig();
+ const [row] = await git.statusMatrix({ fs, dir: projectRoot, filepaths: [configFilepath] });
+ if (row === undefined) {
+ throw new Error(`git status returned no row for ${configFilepath}`);
+ }
+ if (row[1] === row[2]) {
+ console.log(`${configFilepath} is unchanged; nothing to do`);
+ return;
+ }
+
+ const headOid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" });
+ const body = versionPins(await headConfig(headOid), await readConfig()).join("\n");
+ if (values["dry-run"]) {
+ console.log(`Dry run: the regenerated ${configFilepath} differs from HEAD; the refresh commit would be:`);
+ console.log(`${title}\n\n${body}`);
+ console.log("Dry run: no branch update, no push, no pull request");
+ return;
+ }
+
+ const token = process.env.GH_TOKEN;
+ if (token === undefined || token === "") {
+ throw new Error("GH_TOKEN environment variable is not set");
+ }
+ const branch = await git.currentBranch({ fs, dir: projectRoot, fullname: false });
+ if (!branch) {
+ throw new Error("Failed to determine the current branch");
+ }
+
+ await git.branch({ fs, dir: projectRoot, ref: refreshBranch, object: headOid, force: true });
+ await git.add({ fs, dir: projectRoot, filepath: configFilepath });
+ await git.commit({
+ fs,
+ dir: projectRoot,
+ ref: refreshBranch,
+ message: `${title}\n\n${body}`,
+ author: { name: "Renovate", email: "" },
+ });
+ const remote = (await git.listRemotes({ fs, dir: projectRoot })).at(0);
+ if (remote === undefined) {
+ throw new Error("Git remote not found");
+ }
+ await git.push({
+ fs,
+ http,
+ dir: projectRoot,
+ remote: remote.remote,
+ ref: refreshBranch,
+ force: true,
+ onAuth: () => ({ username: "git", password: token }),
+ });
+
+ const { owner, repo } = await getRemoteInfo();
+ const octokit = new Octokit({ auth: token });
+ const open = await octokit.rest.pulls.list({ owner, repo, state: "open", head: `${owner}:${refreshBranch}` });
+ if (open.data.length > 0) {
+ console.log(`Pushed ${refreshBranch}; the open pull request keeps its diff`);
+ return;
+ }
+ const created = await octokit.rest.pulls.create({
+ owner,
+ repo,
+ head: refreshBranch,
+ base: branch,
+ draft: true,
+ title,
+ body,
+ });
+ console.log(`Opened ${created.data.html_url}`);
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/tagPublished.ts b/scripts/tasks/features/dotnet/tagPublished.ts
new file mode 100644
index 0000000..31d28ad
--- /dev/null
+++ b/scripts/tasks/features/dotnet/tagPublished.ts
@@ -0,0 +1,55 @@
+import fs from "node:fs/promises";
+import { parseArgs } from "node:util";
+
+import { Octokit } from "@octokit/rest";
+import git from "isomorphic-git";
+
+import { projectRoot } from "@/scripts/project.js";
+import { getRemoteInfo } from "@/scripts/tasks/build.js";
+
+import { readConfig } from "./generateConfig.js";
+import { parseComponent, parseVersion } from "./invocation.js";
+import { canonicalTag, mainChannel } from "./tags.js";
+
+const isNotFound = (error: unknown): boolean =>
+ typeof error === "object" && error !== null && "status" in error && error.status === 404;
+
+const main = async (): Promise => {
+ const { values } = parseArgs({
+ options: {
+ component: { type: "string" },
+ version: { type: "string" },
+ },
+ });
+
+ const component = parseComponent(values.component);
+ const version = parseVersion(values.version);
+ const token = process.env.GH_TOKEN;
+ if (token === undefined || token === "") {
+ throw new Error("GH_TOKEN environment variable is not set");
+ }
+ const config = await readConfig();
+ const name = `features-dotnet-${component}-${canonicalTag(config, component, version, mainChannel)}`;
+ const oid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" });
+ const { owner, repo } = await getRemoteInfo();
+ const octokit = new Octokit({ auth: token });
+ try {
+ await octokit.rest.git.getRef({ owner, repo, ref: `tags/${name}` });
+ console.log(`Tag ${name} already exists`);
+ } catch (error) {
+ if (!isNotFound(error)) {
+ throw error;
+ }
+ await octokit.rest.git.createRef({ owner, repo, ref: `refs/tags/${name}`, sha: oid });
+ console.log(`Tagged ${name} at ${oid}`);
+ }
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/tasks/features/dotnet/tags.ts b/scripts/tasks/features/dotnet/tags.ts
new file mode 100644
index 0000000..20c2000
--- /dev/null
+++ b/scripts/tasks/features/dotnet/tags.ts
@@ -0,0 +1,65 @@
+import { getRemoteInfo } from "@/scripts/tasks/build.js";
+
+import type { Component, FeatureConfig } from "./generateConfig.js";
+
+export const mainChannel = "main";
+
+const tagPattern = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/;
+
+const channelSuffix = (channel: string): string => {
+ const suffix = channel.replaceAll(/[^a-zA-Z0-9._-]/gu, "-").replaceAll(/^[-.]+|[-.]+$/gu, "");
+ if (suffix === "") {
+ throw new Error(`Channel ${channel} has an empty tag suffix`);
+ }
+ return suffix;
+};
+
+const mainTagNames = (config: FeatureConfig, component: Component, version: string): readonly string[] => {
+ const entry = config.components[component].versions[version];
+ if (entry === undefined) {
+ const available = Object.keys(config.components[component].versions).join(", ");
+ throw new Error(`Component ${component} has no version ${version} (available: ${available})`);
+ }
+ return [`${version}-r${config.revision}`, version, ...entry.aliases];
+};
+
+const channelTagNames = (names: readonly string[], channel: string): readonly string[] => {
+ if (channel === mainChannel) {
+ return names;
+ }
+ const suffix = channelSuffix(channel);
+ const mainTags = new Set(names);
+ return names.map((name) => {
+ const tag = `${name}-${suffix}`;
+ if (mainTags.has(tag)) {
+ throw new Error(`Channel ${channel}: derived tag ${tag} is one of the pair's main channel tags`);
+ }
+ return tag;
+ });
+};
+
+export const tagNames = (config: FeatureConfig, component: Component, version: string, channel: string): string[] => {
+ const names = channelTagNames(mainTagNames(config, component, version), channel);
+ return names.map((tag) => {
+ if (!tagPattern.test(tag)) {
+ throw new Error(`Channel ${channel}: derived tag ${tag} is not a valid registry tag`);
+ }
+ return tag;
+ });
+};
+
+export const canonicalTag = (config: FeatureConfig, component: Component, version: string, channel: string): string => {
+ const [canonical] = tagNames(config, component, version, channel);
+ return canonical;
+};
+
+export const defaultRefPrefix = async (): Promise => {
+ const { owner, repo } = await getRemoteInfo();
+ return `ghcr.io/${owner}/${repo}`;
+};
+
+export const imageRef = (prefix: string, component: Component, tag: string): string =>
+ `${prefix}/dotnet/${component}:${tag}`;
+
+export const imageRefs = (prefix: string, component: Component, tags: readonly string[]): string[] =>
+ tags.map((tag) => imageRef(prefix, component, tag));
diff --git a/scripts/verify.ts b/scripts/verify.ts
new file mode 100644
index 0000000..8496354
--- /dev/null
+++ b/scripts/verify.ts
@@ -0,0 +1,33 @@
+import { readdir } from "node:fs/promises";
+import path from "node:path";
+
+import { projectRoot } from "@/scripts/project.js";
+
+const main = async (): Promise => {
+ const [name, ...rest] = process.argv.slice(2);
+ const verifyPath = path.join(projectRoot, "scripts", "verify");
+ const names = (await readdir(verifyPath, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort();
+ if (name === undefined || !names.includes(name)) {
+ throw new Error(`First argument must be a feature name (one of: ${names.join(", ")})`);
+ }
+
+ const { verify } = (await import(`@/scripts/verify/${name}/verify.js`)) as {
+ verify?: (argv: string[]) => Promise;
+ };
+ if (verify === undefined) {
+ throw new Error(`scripts/verify/${name}/verify.ts does not export verify(argv: string[])`);
+ }
+ await verify(rest);
+};
+
+if (import.meta.main) {
+ try {
+ await main();
+ } catch (error) {
+ console.error(error);
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/verify/dotnet/assertions.ts b/scripts/verify/dotnet/assertions.ts
new file mode 100644
index 0000000..001f4a7
--- /dev/null
+++ b/scripts/verify/dotnet/assertions.ts
@@ -0,0 +1,34 @@
+import type { CommandResult } from "./lifecycle.js";
+
+export interface Expectation {
+ stdout: readonly string[];
+ sorted?: boolean;
+}
+
+const linesOf = (text: string): string[] => (text.trim() === "" ? [] : text.trimEnd().split("\n"));
+
+const sameLines = (actual: readonly string[], expected: readonly string[]): boolean =>
+ actual.length === expected.length && actual.every((line, index) => line === expected[index]);
+
+const assertExec = (result: CommandResult, expectation: Expectation): void => {
+ const stdout = linesOf(result.stdout);
+ const sorted = expectation.sorted === true;
+ const order = (lines: readonly string[]): string[] => (sorted ? [...lines].sort() : [...lines]);
+
+ const problems: string[] = [];
+ if (result.exitCode !== 0) {
+ problems.push(`exit code ${result.exitCode ?? "none"}`);
+ }
+ if (!sameLines(order(stdout), order(expectation.stdout))) {
+ problems.push(`stdout ${JSON.stringify(stdout)} — expected ${JSON.stringify(expectation.stdout)}`);
+ }
+ if (result.stderr.trim() !== "") {
+ problems.push(`stderr ${JSON.stringify(result.stderr)}`);
+ }
+ if (problems.length === 0) {
+ return;
+ }
+ throw new Error(`devcontainer exec ${result.command.join(" ")}:\n${problems.join("\n")}`);
+};
+
+export const assertions = { exec: assertExec } as const;
diff --git a/scripts/verify/dotnet/assets/console/Program.cs b/scripts/verify/dotnet/assets/console/Program.cs
new file mode 100644
index 0000000..b76afd4
--- /dev/null
+++ b/scripts/verify/dotnet/assets/console/Program.cs
@@ -0,0 +1 @@
+Console.WriteLine("console-ok");
diff --git a/scripts/verify/dotnet/assets/console/console.csproj b/scripts/verify/dotnet/assets/console/console.csproj
new file mode 100644
index 0000000..a87271f
--- /dev/null
+++ b/scripts/verify/dotnet/assets/console/console.csproj
@@ -0,0 +1,7 @@
+
+
+ Exe
+ $(DefaultTargetFramework)
+ enable
+
+
diff --git a/scripts/verify/dotnet/assets/hello.cs b/scripts/verify/dotnet/assets/hello.cs
new file mode 100644
index 0000000..87b4294
--- /dev/null
+++ b/scripts/verify/dotnet/assets/hello.cs
@@ -0,0 +1,2 @@
+#:property PublishAot=false
+Console.WriteLine("file-based-ok");
diff --git a/scripts/verify/dotnet/assets/tool/Program.cs b/scripts/verify/dotnet/assets/tool/Program.cs
new file mode 100644
index 0000000..5b3ee40
--- /dev/null
+++ b/scripts/verify/dotnet/assets/tool/Program.cs
@@ -0,0 +1 @@
+Console.WriteLine("hello-tool-ok");
diff --git a/scripts/verify/dotnet/assets/tool/hello-tool.csproj b/scripts/verify/dotnet/assets/tool/hello-tool.csproj
new file mode 100644
index 0000000..60b4ace
--- /dev/null
+++ b/scripts/verify/dotnet/assets/tool/hello-tool.csproj
@@ -0,0 +1,10 @@
+
+
+ Exe
+ $(DefaultTargetFramework)
+ enable
+ true
+ hello-tool
+ 1.0.0
+
+
diff --git a/scripts/verify/dotnet/assets/web/Program.cs b/scripts/verify/dotnet/assets/web/Program.cs
new file mode 100644
index 0000000..1a69d7e
--- /dev/null
+++ b/scripts/verify/dotnet/assets/web/Program.cs
@@ -0,0 +1,5 @@
+var builder = WebApplication.CreateBuilder(args);
+builder.Logging.ClearProviders();
+var app = builder.Build();
+app.MapGet("/", () => "web-ok");
+app.Run();
diff --git a/scripts/verify/dotnet/assets/web/web.csproj b/scripts/verify/dotnet/assets/web/web.csproj
new file mode 100644
index 0000000..24cc562
--- /dev/null
+++ b/scripts/verify/dotnet/assets/web/web.csproj
@@ -0,0 +1,6 @@
+
+
+ $(DefaultTargetFramework)
+ enable
+
+
diff --git a/scripts/verify/dotnet/lifecycle.ts b/scripts/verify/dotnet/lifecycle.ts
new file mode 100644
index 0000000..e827e46
--- /dev/null
+++ b/scripts/verify/dotnet/lifecycle.ts
@@ -0,0 +1,197 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path, { posix } from "node:path";
+
+import { $ } from "execa";
+
+import { projectRoot } from "@/scripts/project.js";
+import { project$$ } from "@/scripts/shell.js";
+import { login } from "@/scripts/tasks/features/dotnet/registry.js";
+
+import type { ComponentImage } from "./selection.js";
+
+const baseImage = "mcr.microsoft.com/devcontainers/base:debian";
+const remoteUser = "verify";
+
+const $$docker = $({ reject: false, stdin: "ignore", stderr: "ignore" });
+const $$exec = $({ reject: false, stdio: ["ignore", "pipe", "pipe"], verbose: "full", cwd: projectRoot });
+
+export interface Workspace {
+ path: string;
+ containerPath: string;
+}
+
+export interface Command {
+ command: readonly string[];
+ env?: Readonly>;
+}
+
+export interface CommandResult {
+ command: readonly string[];
+ exitCode: number | undefined;
+ stdout: string;
+ stderr: string;
+}
+
+export interface StartedCommand {
+ command: readonly string[];
+ hasExited(): boolean;
+ output(): string;
+ kill(): Promise;
+}
+
+export interface TestImage {
+ tag: string;
+ components: readonly ComponentImage[];
+}
+
+export interface WorkspaceConfig {
+ image: string;
+ remoteEnv?: Readonly>;
+}
+
+const workspaceAt = (root: string, name: string): Workspace => {
+ const directory = `workspace-${name}`;
+ return { path: path.join(root, directory), containerPath: posix.join("/workspaces", directory) };
+};
+
+export const imagesModes = ["build", "pull"] as const;
+export type ImagesMode = (typeof imagesModes)[number];
+
+export interface ProvisionOptions {
+ images: ImagesMode;
+ channel: string;
+ prefix: string;
+ force: boolean;
+}
+
+const provisionComponent = async (
+ { component, version, ref }: ComponentImage,
+ options: ProvisionOptions,
+): Promise => {
+ if (options.images === "pull") {
+ await project$$`docker pull ${ref}`;
+ return;
+ }
+ const args = [...["--component", component], ...["--version", version], ...(options.force ? ["--force"] : [])];
+ await project$$`tsx scripts/tasks/features/dotnet/fetchPayload.ts ${args}`;
+ await project$$`tsx scripts/tasks/features/dotnet/buildImage.ts ${[
+ ...args,
+ ...["--channel", options.channel],
+ ...["--prefix", options.prefix],
+ ]}`;
+};
+
+const provision = async (components: readonly ComponentImage[], options: ProvisionOptions): Promise => {
+ if (options.images === "pull") {
+ await login(options.prefix);
+ }
+ for (const component of components) {
+ await provisionComponent(component, options);
+ }
+ const inspect = await $$docker`docker image inspect ${baseImage}`;
+ if (inspect.exitCode !== 0) {
+ await project$$`docker pull ${baseImage}`;
+ }
+};
+
+const testImageBuild = (image: TestImage): { dockerfile: string; args: string[] } => {
+ const [only] = image.components;
+ if (image.components.length === 1) {
+ return { dockerfile: "test-image.Dockerfile", args: [`DOTNET_IMAGE_REF=${only.ref}`] };
+ }
+ return {
+ dockerfile: "test-image-multi.Dockerfile",
+ args: image.components.map(({ component, ref }) => `DOTNET_${component.toUpperCase()}_IMAGE_REF=${ref}`),
+ };
+};
+
+const buildTestImage = async (ctxPath: string, image: TestImage): Promise => {
+ const { dockerfile, args } = testImageBuild(image);
+ await mkdir(ctxPath, { recursive: true });
+ await project$$`docker buildx build ${[
+ "--network=none",
+ "--load",
+ ...["--tag", image.tag],
+ ...["--file", path.join(import.meta.dirname, dockerfile)],
+ ...args.flatMap((arg) => ["--build-arg", arg]),
+ ctxPath,
+ ]}`;
+};
+
+const createWorkspace = async (workspace: Workspace, config: WorkspaceConfig): Promise => {
+ const devcontainerPath = path.join(workspace.path, ".devcontainer");
+ await mkdir(devcontainerPath, { recursive: true });
+ const data = {
+ image: config.image,
+ features: { "./features": {}, "./user-init": {} },
+ remoteUser,
+ containerUser: remoteUser,
+ runArgs: ["--network=none"],
+ remoteEnv: {
+ DOTNET_NOLOGO: "1",
+ DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK: "1",
+ ...config.remoteEnv,
+ },
+ };
+ await writeFile(path.join(devcontainerPath, "devcontainer.json"), `${JSON.stringify(data, null, 2)}\n`);
+};
+
+const buildContainer = async (workspace: Workspace): Promise => {
+ await project$$`devcontainer build ${["--workspace-folder", workspace.path]}`;
+};
+
+const startContainer = async (workspace: Workspace): Promise => {
+ await project$$`devcontainer up ${["--remove-existing-container", ...["--workspace-folder", workspace.path]]}`;
+};
+
+const execInContainer = async (workspace: Workspace, command: Command): Promise => {
+ const env = Object.entries(command.env ?? {}).flatMap(([name, value]) => ["--remote-env", `${name}=${value}`]);
+ const result = await $$exec`devcontainer exec ${["--workspace-folder", workspace.path]} ${env} ${command.command}`;
+ return { command: command.command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr };
+};
+
+const startInContainer = (workspace: Workspace, command: readonly string[]): StartedCommand => {
+ const child = $$exec`devcontainer exec ${["--workspace-folder", workspace.path]} ${command}`;
+ const output: string[] = [];
+ child.stdout?.on("data", (chunk: Buffer) => output.push(chunk.toString()));
+ child.stderr?.on("data", (chunk: Buffer) => output.push(chunk.toString()));
+ return {
+ command,
+ hasExited: () => child.nodeChildProcess.exitCode !== null || child.nodeChildProcess.signalCode !== null,
+ output: () => output.join(""),
+ kill: async () => {
+ child.kill();
+ await child;
+ },
+ };
+};
+
+const removeContainer = async (workspace: Workspace): Promise => {
+ const filter = `label=devcontainer.config_file=${path.join(workspace.path, ".devcontainer/devcontainer.json")}`;
+ const { stdout } = await $$docker`docker ps -a ${[...["--filter", filter], ...["--format", "{{.ID}}"]]}`;
+ const ids = [...new Set(stdout.split("\n").filter(Boolean))];
+ if (ids.length > 0) {
+ await $$docker`docker rm --force ${ids}`;
+ }
+};
+
+const removeImage = async (tag: string): Promise => {
+ const inspect = await $$docker`docker image inspect ${tag}`;
+ if (inspect.exitCode !== 0) {
+ return;
+ }
+ await project$$`docker image rm ${tag}`;
+};
+
+export const lifecycle = {
+ provision,
+ buildTestImage,
+ createWorkspace,
+ workspaceAt,
+ buildContainer,
+ startContainer,
+ execInContainer,
+ startInContainer,
+ removeContainer,
+ removeImage,
+} as const;
diff --git a/scripts/verify/dotnet/scenarios.ts b/scripts/verify/dotnet/scenarios.ts
new file mode 100644
index 0000000..fff945c
--- /dev/null
+++ b/scripts/verify/dotnet/scenarios.ts
@@ -0,0 +1,204 @@
+import path from "node:path";
+
+import type { Component } from "@/scripts/tasks/features/dotnet/generateConfig.js";
+
+import type { Expectation } from "./assertions.js";
+import type { TestImage, Workspace } from "./lifecycle.js";
+import type { Selection } from "./selection.js";
+import type { Serve } from "./serve.js";
+
+const names = ["sdk", "runtime", "aspnet", "multi"] as const;
+export type ScenarioName = (typeof names)[number];
+
+const appNames = ["console", "web"] as const;
+export type AppName = (typeof appNames)[number];
+
+export interface Exec {
+ command: readonly string[];
+ env?: Readonly>;
+ expect: Expectation;
+}
+
+export interface ScenarioContext {
+ selection: Selection;
+ workspace: Workspace;
+}
+
+interface ScenarioDefinition {
+ components: readonly Component[];
+ assets: readonly string[];
+ directories: readonly string[];
+ app?: AppName;
+}
+
+export interface Scenario extends ScenarioDefinition {
+ name: ScenarioName;
+ workspace: Workspace;
+ testImage: TestImage;
+ execs: readonly Exec[];
+ remoteEnv?: Readonly>;
+ serve?: Serve;
+}
+
+export interface BuildStage {
+ testImage: TestImage;
+ publishes: readonly Exec[];
+}
+
+interface ScenarioBehavior {
+ execs: readonly Exec[];
+ remoteEnv?: Readonly>;
+ serve?: Serve;
+}
+
+const testImagePrefix = "features-verify-dotnet";
+
+const definitions: Record = {
+ sdk: { components: ["sdk"], assets: ["hello.cs", "tool"], directories: ["pushed"] },
+ runtime: { components: ["runtime"], assets: [], directories: [], app: "console" },
+ aspnet: { components: ["aspnet"], assets: [], directories: [], app: "web" },
+ multi: { components: ["sdk", "runtime", "aspnet"], assets: [], directories: [] },
+};
+
+const sdkBehavior = ({ selection, workspace }: ScenarioContext): ScenarioBehavior => ({
+ execs: [
+ { command: ["dotnet", "--version"], expect: { stdout: [selection.components.sdk.version] } },
+ { command: ["dotnet", "run", "--file", "hello.cs"], expect: { stdout: ["file-based-ok"] } },
+ {
+ command: [
+ ...["dotnet", "pack", "tool/hello-tool.csproj"],
+ ...["--configuration", "Release"],
+ ...["--output", "nuget"],
+ ...["--verbosity", "quiet"],
+ ],
+ env: { DefaultTargetFramework: selection.tfm },
+ expect: { stdout: [] },
+ },
+ {
+ command: [...["dotnet", "tool", "install"], ...["--global", "hello-tool"], ...["--source", "nuget"]],
+ expect: {
+ stdout: [
+ "You can invoke the tool using the following command: hello-tool",
+ "Tool 'hello-tool' (version '1.0.0') was successfully installed.",
+ ],
+ },
+ },
+ { command: ["hello-tool"], expect: { stdout: ["hello-tool-ok"] } },
+ {
+ command: [...["dotnet", "nuget", "locals", "global-packages"], "--list"],
+ expect: { stdout: [`global-packages: ${selection.globalPackagesPath}`] },
+ },
+ {
+ command: [
+ ...["dotnet", "nuget", "push", "nuget/hello-tool.1.0.0.nupkg"],
+ ...["--source", `${workspace.containerPath}/pushed`],
+ ],
+ expect: {
+ stdout: [
+ `Pushing hello-tool.1.0.0.nupkg to '${workspace.containerPath}/pushed'...`,
+ "Your package was pushed.",
+ ],
+ },
+ },
+ ],
+});
+
+const runtimeBehavior = (): ScenarioBehavior => ({
+ execs: [{ command: ["dotnet", "app/console.dll"], expect: { stdout: ["console-ok"] } }],
+});
+
+const aspnetBehavior = ({ workspace }: ScenarioContext): ScenarioBehavior => ({
+ execs: [],
+ remoteEnv: { ASPNETCORE_URLS: `http://unix:${workspace.containerPath}/app.sock` },
+ serve: {
+ command: ["dotnet", "app/web.dll"],
+ socketPath: path.join(workspace.path, "app.sock"),
+ expectedBody: "web-ok",
+ },
+});
+
+const multiBehavior = ({ selection }: ScenarioContext): ScenarioBehavior => ({
+ execs: [
+ {
+ command: ["dotnet", "--list-sdks"],
+ expect: { stdout: [`${selection.components.sdk.version} [/opt/dotnet/sdk]`] },
+ },
+ {
+ command: ["dotnet", "--list-runtimes"],
+ expect: {
+ stdout: [
+ `Microsoft.AspNetCore.App ${selection.components.aspnet.version} [/opt/dotnet/shared/Microsoft.AspNetCore.App]`,
+ `Microsoft.NETCore.App ${selection.components.runtime.version} [/opt/dotnet/shared/Microsoft.NETCore.App]`,
+ ],
+ sorted: true,
+ },
+ },
+ ],
+});
+
+const behaviors: Record ScenarioBehavior> = {
+ sdk: sdkBehavior,
+ runtime: runtimeBehavior,
+ aspnet: aspnetBehavior,
+ multi: multiBehavior,
+};
+
+const appOutput = (app: AppName): string => `out/${app}`;
+
+const testImageOf = (name: ScenarioName, selection: Selection): TestImage => {
+ const components = definitions[name].components.map((component) => selection.components[component]);
+ const [label] = components;
+ if (label === undefined) {
+ throw new Error(`Scenario ${name} declares no components`);
+ }
+ return { tag: `${testImagePrefix}:${name}-${label.version}`, components };
+};
+
+const createScenario = (name: ScenarioName, context: ScenarioContext): Scenario => {
+ const behavior = behaviors[name](context);
+ return {
+ ...definitions[name],
+ name,
+ workspace: context.workspace,
+ testImage: testImageOf(name, context.selection),
+ execs: behavior.execs,
+ remoteEnv: behavior.remoteEnv,
+ serve: behavior.serve,
+ };
+};
+
+const createBuildStage = (apps: readonly AppName[], selection: Selection): BuildStage => ({
+ testImage: testImageOf("sdk", selection),
+ publishes: apps.map((app) => ({
+ command: [
+ ...["dotnet", "publish", `${app}/${app}.csproj`],
+ ...["--configuration", "Release"],
+ ...["--output", appOutput(app)],
+ ...["--verbosity", "quiet"],
+ ],
+ env: { DefaultTargetFramework: selection.tfm },
+ expect: { stdout: [] },
+ })),
+});
+
+const select = (values: readonly string[]): ScenarioName[] => {
+ if (values.length === 0) {
+ return [...names];
+ }
+ const selected = values.map((value) => {
+ const name = names.find((candidate) => candidate === value);
+ if (name === undefined) {
+ throw new Error(`--scenario must be one of: ${names.join(", ")}`);
+ }
+ return name;
+ });
+ return names.filter((name) => selected.includes(name));
+};
+
+export const scenarios = {
+ apps: appNames,
+ appOutput,
+ create: createScenario,
+ createBuildStage,
+ select,
+} as const;
diff --git a/scripts/verify/dotnet/selection.ts b/scripts/verify/dotnet/selection.ts
new file mode 100644
index 0000000..f40aacb
--- /dev/null
+++ b/scripts/verify/dotnet/selection.ts
@@ -0,0 +1,77 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+import { z } from "zod";
+
+import { projectRoot } from "@/scripts/project.js";
+import type { Component, FeatureConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js";
+import { configPath, readConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js";
+import { canonicalTag, imageRef } from "@/scripts/tasks/features/dotnet/tags.js";
+
+const userInitFeaturePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json");
+
+const userInitFeatureSchema = z.object({ containerEnv: z.object({ XDG_DATA_HOME: z.string() }) });
+
+export interface ComponentImage {
+ component: Component;
+ version: string;
+ ref: string;
+}
+
+export interface Selection {
+ components: Record;
+ tfm: string;
+ globalPackagesPath: string;
+}
+
+const ltsVersion = (config: FeatureConfig, component: Component): string => {
+ const versions = config.components[component].versions;
+ const matches = Object.keys(versions).filter((version) => versions[version].aliases.includes("lts"));
+ if (matches.length !== 1) {
+ throw new Error(`${configPath}: component ${component} must have exactly one lts version, found ${matches.length}`);
+ }
+ return matches[0];
+};
+
+const componentImage = (
+ config: FeatureConfig,
+ component: Component,
+ channel: string,
+ prefix: string,
+): ComponentImage => {
+ const version = ltsVersion(config, component);
+ return { component, version, ref: imageRef(prefix, component, canonicalTag(config, component, version, channel)) };
+};
+
+const tfmOf = (sdkVersion: string): string => {
+ const match = /^(\d+)\.(\d+)\./.exec(sdkVersion);
+ if (match === null) {
+ throw new Error(`Cannot derive the target framework from the sdk version ${sdkVersion}`);
+ }
+ return `net${match[1]}.${match[2]}`;
+};
+
+const readGlobalPackagesPath = async (): Promise => {
+ const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8"));
+ const result = userInitFeatureSchema.safeParse(data);
+ if (!result.success) {
+ throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`);
+ }
+ return `${result.data.containerEnv.XDG_DATA_HOME}/NuGet/global-packages`;
+};
+
+const derive = async (channel: string, prefix: string): Promise => {
+ const config = await readConfig();
+ const sdk = componentImage(config, "sdk", channel, prefix);
+ return {
+ components: {
+ sdk,
+ runtime: componentImage(config, "runtime", channel, prefix),
+ aspnet: componentImage(config, "aspnet", channel, prefix),
+ },
+ tfm: tfmOf(sdk.version),
+ globalPackagesPath: await readGlobalPackagesPath(),
+ };
+};
+
+export const selection = { derive } as const;
diff --git a/scripts/verify/dotnet/serve.ts b/scripts/verify/dotnet/serve.ts
new file mode 100644
index 0000000..f3d54c2
--- /dev/null
+++ b/scripts/verify/dotnet/serve.ts
@@ -0,0 +1,66 @@
+import { request } from "node:http";
+import { setTimeout as delay } from "node:timers/promises";
+
+import type { StartedCommand } from "./lifecycle.js";
+
+const timeoutMs = 30_000;
+const intervalMs = 100;
+
+export interface Serve {
+ command: readonly string[];
+ socketPath: string;
+ expectedBody: string;
+}
+
+interface Response {
+ statusCode: number | undefined;
+ body: string;
+}
+
+const attempt = (socketPath: string): Promise =>
+ new Promise((resolve, reject) => {
+ const call = request({ socketPath, path: "/", method: "GET" }, (response) => {
+ const chunks: Buffer[] = [];
+ response.on("data", (chunk: Buffer) => chunks.push(chunk));
+ response.on("end", () => {
+ resolve({ statusCode: response.statusCode, body: Buffer.concat(chunks).toString() });
+ });
+ });
+ call.on("error", (error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT" || error.code === "ECONNREFUSED") {
+ resolve(undefined);
+ return;
+ }
+ reject(error);
+ });
+ call.end();
+ });
+
+const detail = (text: string): string => (text.trim() === "" ? "" : `\n${text.trimEnd()}`);
+
+const assertResponse = async (server: StartedCommand, { socketPath, expectedBody }: Serve): Promise => {
+ const deadline = performance.now() + timeoutMs;
+ while (true) {
+ if (server.hasExited()) {
+ throw new Error(`${server.command.join(" ")} exited before it served ${socketPath}${detail(server.output())}`);
+ }
+ const response = await attempt(socketPath);
+ if (response !== undefined) {
+ if (response.statusCode !== 200) {
+ throw new Error(`GET ${socketPath} returned HTTP ${String(response.statusCode)} — expected 200`);
+ }
+ if (response.body !== expectedBody) {
+ throw new Error(
+ `GET ${socketPath} returned ${JSON.stringify(response.body)} — expected ${JSON.stringify(expectedBody)}`,
+ );
+ }
+ return;
+ }
+ if (performance.now() >= deadline) {
+ throw new Error(`GET ${socketPath} did not answer within ${timeoutMs} ms${detail(server.output())}`);
+ }
+ await delay(intervalMs);
+ }
+};
+
+export const serve = { assertResponse } as const;
diff --git a/scripts/verify/dotnet/staging.ts b/scripts/verify/dotnet/staging.ts
new file mode 100644
index 0000000..3914b4f
--- /dev/null
+++ b/scripts/verify/dotnet/staging.ts
@@ -0,0 +1,38 @@
+import { cp, mkdir } from "node:fs/promises";
+import path from "node:path";
+
+import { projectRoot } from "@/scripts/project.js";
+
+import type { Workspace } from "./lifecycle.js";
+
+const assetsPath = path.join(import.meta.dirname, "assets");
+const featureSourcePath = path.join(projectRoot, ".devcontainer/features/src");
+
+const stageFeatures = async (workspace: Workspace): Promise => {
+ const devcontainerPath = path.join(workspace.path, ".devcontainer");
+ await cp(path.join(featureSourcePath, "features"), path.join(devcontainerPath, "features"), { recursive: true });
+ await cp(path.join(featureSourcePath, "user-init"), path.join(devcontainerPath, "user-init"), { recursive: true });
+};
+
+const stageAssets = async (
+ workspace: Workspace,
+ assets: readonly string[],
+ directories: readonly string[],
+): Promise => {
+ for (const asset of assets) {
+ await cp(path.join(assetsPath, asset), path.join(workspace.path, asset), { recursive: true });
+ }
+ for (const directory of directories) {
+ await mkdir(path.join(workspace.path, directory), { recursive: true });
+ }
+};
+
+const stageApp = async (workspace: Workspace, sourcePath: string): Promise => {
+ await cp(sourcePath, path.join(workspace.path, "app"), { recursive: true });
+};
+
+export const staging = {
+ features: stageFeatures,
+ assets: stageAssets,
+ app: stageApp,
+} as const;
diff --git a/scripts/verify/dotnet/test-image-multi.Dockerfile b/scripts/verify/dotnet/test-image-multi.Dockerfile
new file mode 100644
index 0000000..a597282
--- /dev/null
+++ b/scripts/verify/dotnet/test-image-multi.Dockerfile
@@ -0,0 +1,13 @@
+# check=skip=InvalidDefaultArgInFrom
+ARG DOTNET_SDK_IMAGE_REF
+ARG DOTNET_RUNTIME_IMAGE_REF
+ARG DOTNET_ASPNET_IMAGE_REF
+
+FROM ${DOTNET_SDK_IMAGE_REF} AS sdk
+FROM ${DOTNET_RUNTIME_IMAGE_REF} AS runtime
+FROM ${DOTNET_ASPNET_IMAGE_REF} AS aspnet
+
+FROM mcr.microsoft.com/devcontainers/base:debian
+COPY --from=sdk /features/dotnet/ /opt/devcontainer-config/features/dotnet/
+COPY --from=runtime /features/dotnet/ /opt/devcontainer-config/features/dotnet/
+COPY --from=aspnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/
diff --git a/scripts/verify/dotnet/test-image.Dockerfile b/scripts/verify/dotnet/test-image.Dockerfile
new file mode 100644
index 0000000..e09232d
--- /dev/null
+++ b/scripts/verify/dotnet/test-image.Dockerfile
@@ -0,0 +1,7 @@
+# check=skip=InvalidDefaultArgInFrom
+ARG DOTNET_IMAGE_REF
+
+FROM ${DOTNET_IMAGE_REF} AS dotnet
+
+FROM mcr.microsoft.com/devcontainers/base:debian
+COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/
diff --git a/scripts/verify/dotnet/verify.ts b/scripts/verify/dotnet/verify.ts
new file mode 100644
index 0000000..b425794
--- /dev/null
+++ b/scripts/verify/dotnet/verify.ts
@@ -0,0 +1,146 @@
+import { mkdtempDisposable } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { parseArgs } from "node:util";
+
+import type { Component } from "@/scripts/tasks/features/dotnet/generateConfig.js";
+import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/dotnet/tags.js";
+
+import { assertions } from "./assertions.js";
+import type { TestImage, Workspace } from "./lifecycle.js";
+import { imagesModes, lifecycle } from "./lifecycle.js";
+import { scenarios } from "./scenarios.js";
+import type { ComponentImage } from "./selection.js";
+import { selection } from "./selection.js";
+import { serve } from "./serve.js";
+import { staging } from "./staging.js";
+
+const componentImages = (images: readonly TestImage[]): ComponentImage[] => {
+ const byComponent = new Map();
+ for (const image of images) {
+ for (const component of image.components) {
+ byComponent.set(component.component, component);
+ }
+ }
+ return [...byComponent.values()];
+};
+
+export const verify = async (argv: string[]): Promise => {
+ const { values } = parseArgs({
+ args: argv,
+ options: {
+ force: { type: "boolean", default: false },
+ rmi: { type: "boolean", default: false },
+ scenario: { type: "string", multiple: true },
+ images: { type: "string", default: "build" },
+ channel: { type: "string", default: mainChannel },
+ prefix: { type: "string" },
+ },
+ });
+
+ const images = imagesModes.find((candidate) => candidate === values.images);
+ if (images === undefined) {
+ throw new Error(`--images must be one of: ${imagesModes.join(", ")}`);
+ }
+ const channel = values.channel;
+ const prefix = values.prefix ?? (await defaultRefPrefix());
+ const selected = scenarios.select(values.scenario ?? []);
+ const derived = await selection.derive(channel, prefix);
+ console.log(
+ `>>> verify dotnet: selection sdk=${derived.components.sdk.version} runtime=${derived.components.runtime.version} aspnet=${derived.components.aspnet.version} tfm=${derived.tfm}`,
+ );
+
+ const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-dotnet-"));
+ const ctxPath = path.join(tempPath.path, "ctx");
+ const runs = selected.map((name) =>
+ scenarios.create(name, { selection: derived, workspace: lifecycle.workspaceAt(tempPath.path, name) }),
+ );
+ const apps = [...new Set(runs.flatMap((run) => (run.app === undefined ? [] : [run.app])))];
+ const build =
+ apps.length === 0
+ ? undefined
+ : {
+ workspace: lifecycle.workspaceAt(tempPath.path, "build"),
+ ...scenarios.createBuildStage(apps, derived),
+ };
+ const testImages = new Map();
+ for (const run of runs) {
+ testImages.set(run.testImage.tag, run.testImage);
+ }
+ if (build !== undefined) {
+ testImages.set(build.testImage.tag, build.testImage);
+ }
+ const workspaces: Workspace[] = [
+ ...runs.map((run) => run.workspace),
+ ...(build === undefined ? [] : [build.workspace]),
+ ];
+
+ try {
+ console.log(">>> verify dotnet: provision");
+ await lifecycle.provision(componentImages([...testImages.values()]), {
+ images,
+ channel,
+ prefix,
+ force: values.force,
+ });
+
+ console.log(">>> verify dotnet: test images");
+ for (const image of testImages.values()) {
+ await lifecycle.buildTestImage(ctxPath, image);
+ }
+
+ console.log(">>> verify dotnet: workspaces");
+ for (const run of runs) {
+ await lifecycle.createWorkspace(run.workspace, { image: run.testImage.tag, remoteEnv: run.remoteEnv });
+ await staging.features(run.workspace);
+ await staging.assets(run.workspace, run.assets, run.directories);
+ }
+ if (build !== undefined) {
+ await lifecycle.createWorkspace(build.workspace, { image: build.testImage.tag });
+ await staging.features(build.workspace);
+ await staging.assets(build.workspace, scenarios.apps, []);
+ }
+
+ if (build !== undefined) {
+ console.log(">>> verify dotnet: build stage");
+ await lifecycle.buildContainer(build.workspace);
+ await lifecycle.startContainer(build.workspace);
+ for (const publish of build.publishes) {
+ assertions.exec(await lifecycle.execInContainer(build.workspace, publish), publish.expect);
+ }
+ for (const run of runs) {
+ if (run.app !== undefined) {
+ await staging.app(run.workspace, path.join(build.workspace.path, scenarios.appOutput(run.app)));
+ }
+ }
+ }
+
+ for (const run of runs) {
+ console.log(`>>> verify dotnet: scenario ${run.name}`);
+ await lifecycle.buildContainer(run.workspace);
+ await lifecycle.startContainer(run.workspace);
+ for (const exec of run.execs) {
+ assertions.exec(await lifecycle.execInContainer(run.workspace, exec), exec.expect);
+ }
+ if (run.serve !== undefined) {
+ const server = lifecycle.startInContainer(run.workspace, run.serve.command);
+ try {
+ await serve.assertResponse(server, run.serve);
+ } finally {
+ await server.kill();
+ }
+ }
+ }
+ } finally {
+ console.log(">>> verify dotnet: cleanup");
+ for (const workspace of workspaces) {
+ await lifecycle.removeContainer(workspace);
+ }
+ if (values.rmi) {
+ for (const image of testImages.values()) {
+ await lifecycle.removeImage(image.tag);
+ }
+ }
+ await tempPath.remove();
+ }
+};