From 03715317e4729dbf8a70aadbcd5fccb9a9cb48af Mon Sep 17 00:00:00 2001 From: gdlol Date: Sun, 27 Sep 2026 01:41:50 +0000 Subject: [PATCH 1/4] dotnet feature --- .config/cspell/cspell.json | 9 +- .config/dotnet/Packages.props | 6 +- .config/dotnet/Project.props | 1 + .github/workflows/dotnet.yml | 200 +++++++++++++++ .github/workflows/publish.yml | 1 + .github/workflows/renovate.yml | 35 +++ Directory.Build.props | 1 + features/dotnet/Build/Build.csproj | 17 ++ features/dotnet/Build/BuildException.cs | 3 + features/dotnet/Build/Checksum.cs | 17 ++ features/dotnet/Build/Config.cs | 34 +++ features/dotnet/Build/Http.cs | 12 + features/dotnet/Build/Layout.cs | 62 +++++ features/dotnet/Build/Program.cs | 61 +++++ features/dotnet/Build/Resolution.cs | 72 ++++++ features/dotnet/Build/Staging.cs | 28 +++ features/dotnet/Build/Tarball.cs | 16 ++ features/dotnet/Dockerfile | 9 + features/dotnet/Install/Install.csproj | 18 ++ features/dotnet/Install/InstallException.cs | 3 + features/dotnet/Install/Merge.cs | 46 ++++ features/dotnet/Install/Program.cs | 24 ++ features/dotnet/Install/Registration.cs | 68 ++++++ features/dotnet/Install/Scan.cs | 74 ++++++ features/dotnet/config.json | 149 +++++++++++ package.json | 7 +- scripts/tasks/features/dotnet/buildContext.ts | 50 ++++ scripts/tasks/features/dotnet/buildImage.ts | 63 +++++ scripts/tasks/features/dotnet/fetchPayload.ts | 27 ++ .../tasks/features/dotnet/generateConfig.ts | 231 ++++++++++++++++++ .../tasks/features/dotnet/installBinary.ts | 27 ++ scripts/tasks/features/dotnet/invocation.ts | 38 +++ scripts/tasks/features/dotnet/packBinary.ts | 27 ++ scripts/tasks/features/dotnet/packInstall.ts | 38 +++ scripts/tasks/features/dotnet/paths.ts | 58 +++++ scripts/tasks/features/dotnet/payload.ts | 37 +++ scripts/tasks/features/dotnet/planPublish.ts | 75 ++++++ scripts/tasks/features/dotnet/publishImage.ts | 56 +++++ scripts/tasks/features/dotnet/registry.ts | 14 ++ scripts/tasks/features/dotnet/renovate.ts | 107 ++++++++ scripts/tasks/features/dotnet/tagPublished.ts | 55 +++++ scripts/tasks/features/dotnet/tags.ts | 65 +++++ scripts/verify.ts | 33 +++ scripts/verify/dotnet/assertions.ts | 34 +++ .../verify/dotnet/assets/console/Program.cs | 1 + .../dotnet/assets/console/console.csproj | 7 + scripts/verify/dotnet/assets/hello.cs | 2 + scripts/verify/dotnet/assets/tool/Program.cs | 1 + .../dotnet/assets/tool/hello-tool.csproj | 10 + scripts/verify/dotnet/assets/web/Program.cs | 5 + scripts/verify/dotnet/assets/web/web.csproj | 6 + scripts/verify/dotnet/lifecycle.ts | 197 +++++++++++++++ scripts/verify/dotnet/scenarios.ts | 204 ++++++++++++++++ scripts/verify/dotnet/selection.ts | 77 ++++++ scripts/verify/dotnet/serve.ts | 66 +++++ scripts/verify/dotnet/staging.ts | 38 +++ .../verify/dotnet/test-image-multi.Dockerfile | 13 + scripts/verify/dotnet/test-image.Dockerfile | 7 + scripts/verify/dotnet/verify.ts | 146 +++++++++++ 59 files changed, 2784 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/dotnet.yml create mode 100644 .github/workflows/renovate.yml create mode 100644 features/dotnet/Build/Build.csproj create mode 100644 features/dotnet/Build/BuildException.cs create mode 100644 features/dotnet/Build/Checksum.cs create mode 100644 features/dotnet/Build/Config.cs create mode 100644 features/dotnet/Build/Http.cs create mode 100644 features/dotnet/Build/Layout.cs create mode 100644 features/dotnet/Build/Program.cs create mode 100644 features/dotnet/Build/Resolution.cs create mode 100644 features/dotnet/Build/Staging.cs create mode 100644 features/dotnet/Build/Tarball.cs create mode 100644 features/dotnet/Dockerfile create mode 100644 features/dotnet/Install/Install.csproj create mode 100644 features/dotnet/Install/InstallException.cs create mode 100644 features/dotnet/Install/Merge.cs create mode 100644 features/dotnet/Install/Program.cs create mode 100644 features/dotnet/Install/Registration.cs create mode 100644 features/dotnet/Install/Scan.cs create mode 100644 features/dotnet/config.json create mode 100644 scripts/tasks/features/dotnet/buildContext.ts create mode 100644 scripts/tasks/features/dotnet/buildImage.ts create mode 100644 scripts/tasks/features/dotnet/fetchPayload.ts create mode 100644 scripts/tasks/features/dotnet/generateConfig.ts create mode 100644 scripts/tasks/features/dotnet/installBinary.ts create mode 100644 scripts/tasks/features/dotnet/invocation.ts create mode 100644 scripts/tasks/features/dotnet/packBinary.ts create mode 100644 scripts/tasks/features/dotnet/packInstall.ts create mode 100644 scripts/tasks/features/dotnet/paths.ts create mode 100644 scripts/tasks/features/dotnet/payload.ts create mode 100644 scripts/tasks/features/dotnet/planPublish.ts create mode 100644 scripts/tasks/features/dotnet/publishImage.ts create mode 100644 scripts/tasks/features/dotnet/registry.ts create mode 100644 scripts/tasks/features/dotnet/renovate.ts create mode 100644 scripts/tasks/features/dotnet/tagPublished.ts create mode 100644 scripts/tasks/features/dotnet/tags.ts create mode 100644 scripts/verify.ts create mode 100644 scripts/verify/dotnet/assertions.ts create mode 100644 scripts/verify/dotnet/assets/console/Program.cs create mode 100644 scripts/verify/dotnet/assets/console/console.csproj create mode 100644 scripts/verify/dotnet/assets/hello.cs create mode 100644 scripts/verify/dotnet/assets/tool/Program.cs create mode 100644 scripts/verify/dotnet/assets/tool/hello-tool.csproj create mode 100644 scripts/verify/dotnet/assets/web/Program.cs create mode 100644 scripts/verify/dotnet/assets/web/web.csproj create mode 100644 scripts/verify/dotnet/lifecycle.ts create mode 100644 scripts/verify/dotnet/scenarios.ts create mode 100644 scripts/verify/dotnet/selection.ts create mode 100644 scripts/verify/dotnet/serve.ts create mode 100644 scripts/verify/dotnet/staging.ts create mode 100644 scripts/verify/dotnet/test-image-multi.Dockerfile create mode 100644 scripts/verify/dotnet/test-image.Dockerfile create mode 100644 scripts/verify/dotnet/verify.ts 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/.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/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..d04eedd --- /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(["active", "preview", "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") { + 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..76d1c2e --- /dev/null +++ b/scripts/tasks/features/dotnet/renovate.ts @@ -0,0 +1,107 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +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 => { + 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 token = process.env.GH_TOKEN; + if (token === undefined || token === "") { + throw new Error("GH_TOKEN environment variable is not set"); + } + const headOid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); + const branch = await git.currentBranch({ fs, dir: projectRoot, fullname: false }); + if (!branch) { + throw new Error("Failed to determine the current branch"); + } + const body = versionPins(await headConfig(headOid), await readConfig()).join("\n"); + + 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(); + } +}; From cf30b18eafd1442e263eb76ac29dbdae2a2bfb1e Mon Sep 17 00:00:00 2001 From: gdlol Date: Sun, 27 Sep 2026 02:19:58 +0000 Subject: [PATCH 2/4] Renovate dry-run flag --- .../tasks/features/dotnet/generateConfig.ts | 4 ++-- scripts/tasks/features/dotnet/renovate.ts | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/scripts/tasks/features/dotnet/generateConfig.ts b/scripts/tasks/features/dotnet/generateConfig.ts index d04eedd..2ea2ea0 100644 --- a/scripts/tasks/features/dotnet/generateConfig.ts +++ b/scripts/tasks/features/dotnet/generateConfig.ts @@ -35,7 +35,7 @@ const channelSchema = z.object({ "latest-runtime": z.string(), "latest-sdk": z.string(), "release-type": z.string(), - "support-phase": z.enum(["active", "preview", "maintenance", "eol"]), + "support-phase": z.enum(["preview", "go-live", "active", "maintenance", "eol"]), "releases.json": z.string(), }); @@ -166,7 +166,7 @@ const resolveVersions = ( } const major = majorVersion(channel); let aliases: string[]; - if (channel["support-phase"] === "preview") { + if (channel["support-phase"] === "preview" || channel["support-phase"] === "go-live") { aliases = ["preview", `${major}-preview`, `${channel["channel-version"]}-preview`]; } else { aliases = [channel["channel-version"], major]; diff --git a/scripts/tasks/features/dotnet/renovate.ts b/scripts/tasks/features/dotnet/renovate.ts index 76d1c2e..ddded58 100644 --- a/scripts/tasks/features/dotnet/renovate.ts +++ b/scripts/tasks/features/dotnet/renovate.ts @@ -1,5 +1,6 @@ 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"; @@ -34,6 +35,12 @@ const headConfig = async (oid: string): Promise => { }; 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) { @@ -44,16 +51,23 @@ const main = async (): Promise => { 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 headOid = await git.resolveRef({ fs, dir: projectRoot, ref: "HEAD" }); const branch = await git.currentBranch({ fs, dir: projectRoot, fullname: false }); if (!branch) { throw new Error("Failed to determine the current branch"); } - const body = versionPins(await headConfig(headOid), await readConfig()).join("\n"); await git.branch({ fs, dir: projectRoot, ref: refreshBranch, object: headOid, force: true }); await git.add({ fs, dir: projectRoot, filepath: configFilepath }); From 0e6da7794d8a7ff1c4589c0e1e7139ff9202b906 Mon Sep 17 00:00:00 2001 From: gdlol Date: Sun, 27 Sep 2026 03:54:25 +0000 Subject: [PATCH 3/4] Fix pnpm config --- .config/workspaces/pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) 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: From ab74d5be92ce5acb6422e064f5e11dbb0092741c Mon Sep 17 00:00:00 2001 From: gdlol Date: Sun, 27 Sep 2026 07:01:09 +0000 Subject: [PATCH 4/4] fix test --- scripts/runner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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,