diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 4391cfb..4c832d6 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -127,23 +127,21 @@ jobs: 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/feature + runCmd: pnpm tsx scripts/tasks/features/dotnet/publishImage.ts --component ${{ matrix.component }} --version ${{ matrix.key }} --channel "$CHANNEL" --install-dir .ci/feature env: | CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} - commit-tags: + release-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 != '' + - verify + if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') && needs.verify.result == 'success' }} permissions: contents: write - packages: read + packages: write runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Checkout (GitHub) uses: actions/checkout@v7 @@ -155,13 +153,14 @@ jobs: username: ${{ github.repository_owner }} password: ${{ github.token }} - - name: Tag the published pair + - name: Apply the release tags 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 }} + runCmd: pnpm tsx scripts/tasks/features/dotnet/releaseTags.ts --channel "$CHANNEL" ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '--git-tags' || '' }} env: | + CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} verify: @@ -169,10 +168,13 @@ jobs: - plan - image if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm scenario: - sdk - runtime diff --git a/.github/workflows/ripgrep.yml b/.github/workflows/ripgrep.yml index bc695ba..42b7d13 100644 --- a/.github/workflows/ripgrep.yml +++ b/.github/workflows/ripgrep.yml @@ -69,23 +69,21 @@ jobs: with: cacheFrom: ghcr.io/${{ github.repository }}/devcontainer push: never - runCmd: pnpm tsx scripts/tasks/features/ripgrep/publishImage.ts --version ${{ matrix.version }} --channel "$CHANNEL" + runCmd: pnpm tsx scripts/tasks/features/ripgrep/publishImage.ts --version ${{ matrix.key }} --channel "$CHANNEL" env: | CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} - commit-tags: + release-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 != '' + - verify + if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') && needs.verify.result == 'success' }} permissions: contents: write - packages: read + packages: write runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Checkout (GitHub) uses: actions/checkout@v7 @@ -97,13 +95,14 @@ jobs: username: ${{ github.repository_owner }} password: ${{ github.token }} - - name: Tag the published version + - name: Apply the release tags uses: devcontainers/ci@v0.3 with: cacheFrom: ghcr.io/${{ github.repository }}/devcontainer push: never - runCmd: pnpm tsx scripts/tasks/features/ripgrep/tagPublished.ts --version ${{ matrix.version }} + runCmd: pnpm tsx scripts/tasks/features/ripgrep/releaseTags.ts --channel "$CHANNEL" ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '--git-tags' || '' }} env: | + CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} verify: @@ -111,7 +110,13 @@ jobs: - plan - image if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm steps: - name: Checkout (GitHub) uses: actions/checkout@v7 diff --git a/.github/workflows/uv.yml b/.github/workflows/uv.yml index 88764ed..5a6f4eb 100644 --- a/.github/workflows/uv.yml +++ b/.github/workflows/uv.yml @@ -1,4 +1,4 @@ -name: "Uv" +name: "uv" on: pull_request: @@ -127,23 +127,21 @@ jobs: with: cacheFrom: ghcr.io/${{ github.repository }}/devcontainer push: never - runCmd: pnpm tsx scripts/tasks/features/uv/publishImage.ts --component ${{ matrix.component }} --version ${{ matrix.version }} --channel "$CHANNEL" --install-dir .ci/feature + runCmd: pnpm tsx scripts/tasks/features/uv/publishImage.ts --component ${{ matrix.component }} --version ${{ matrix.key }} --channel "$CHANNEL" --install-dir .ci/feature env: | CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} - commit-tags: + release-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 != '' + - verify + if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') && needs.verify.result == 'success' }} permissions: contents: write - packages: read + packages: write runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} steps: - name: Checkout (GitHub) uses: actions/checkout@v7 @@ -155,13 +153,14 @@ jobs: username: ${{ github.repository_owner }} password: ${{ github.token }} - - name: Tag the published pair + - name: Apply the release tags uses: devcontainers/ci@v0.3 with: cacheFrom: ghcr.io/${{ github.repository }}/devcontainer push: never - runCmd: pnpm tsx scripts/tasks/features/uv/tagPublished.ts --component ${{ matrix.component }} --version ${{ matrix.version }} + runCmd: pnpm tsx scripts/tasks/features/uv/releaseTags.ts --channel "$CHANNEL" ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && '--git-tags' || '' }} env: | + CHANNEL=${{ github.head_ref || github.ref_name }} GH_TOKEN=${{ github.token }} verify: @@ -169,10 +168,13 @@ jobs: - plan - image if: ${{ !cancelled() && needs.plan.result == 'success' && (needs.image.result == 'success' || needs.image.result == 'skipped') }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: + runner: + - ubuntu-latest + - ubuntu-24.04-arm scenario: - binaries - python diff --git a/features/dotnet/Build/Config.cs b/features/dotnet/Build/Config.cs index c2d2c48..4f10e72 100644 --- a/features/dotnet/Build/Config.cs +++ b/features/dotnet/Build/Config.cs @@ -14,9 +14,6 @@ sealed class Artifact sealed class ComponentVersion { - [JsonPropertyName("aliases")] - public List Aliases { get; init; } = []; - [JsonExtensionData] public Dictionary Rids { get; init; } = []; } @@ -32,3 +29,35 @@ sealed class FeatureConfig [JsonPropertyName("components")] public required Dictionary Components { get; init; } } + +sealed record PayloadSection(string Source, string Component, string Key, ComponentVersion Entry); + +internal static class Config +{ + internal static FeatureConfig Parse(string json, string source) + { + return JsonSerializer.Deserialize(json) + ?? throw new BuildException($"{source} deserialized to null."); + } + + internal static PayloadSection Section(FeatureConfig config, string source, string component, string key) + { + if (!config.Components.TryGetValue(component, out ComponentConfig? componentConfig)) + { + throw new BuildException( + $"component '{component}' not found in {source}; available components: " + + $"{string.Join(", ", config.Components.Keys)}." + ); + } + + if (!componentConfig.Versions.TryGetValue(key, out ComponentVersion? entry)) + { + throw new BuildException( + $"version '{key}' not found for component '{component}' in {source}; " + + $"available versions: {string.Join(", ", componentConfig.Versions.Keys)}." + ); + } + + return new PayloadSection(source, component, key, entry); + } +} diff --git a/features/dotnet/Build/Program.cs b/features/dotnet/Build/Program.cs index dafbf59..469dd21 100644 --- a/features/dotnet/Build/Program.cs +++ b/features/dotnet/Build/Program.cs @@ -4,7 +4,7 @@ [assembly: SupportedOSPlatform("linux")] -Option configOption = new("--config") { Required = true, Description = "Path to the feature config.json." }; +Option configOption = new("--config") { Required = true, Description = "Path to the feature config.json." }; Option componentOption = new("--component") { Required = true, diff --git a/features/dotnet/Build/Resolution.cs b/features/dotnet/Build/Resolution.cs index eb4b22f..c6f616a 100644 --- a/features/dotnet/Build/Resolution.cs +++ b/features/dotnet/Build/Resolution.cs @@ -4,49 +4,26 @@ namespace DevcontainerConfig.Dotnet.Build; internal static class Resolution { - internal static async Task ResolveAsync( - FileInfo config, - string component, - string version, - string arch, - CancellationToken cancellationToken - ) + internal static Artifact Resolve(PayloadSection section, string arch) { 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)) + if (!section.Entry.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)}." + $"rid '{rid}' not found for component '{section.Component}' version '{section.Key}' in " + + $"{section.Source}; available rids: {string.Join(", ", section.Entry.Rids.Keys)}." ); } Artifact artifact = ridEntry.Deserialize() - ?? throw new BuildException($"artifact for rid '{rid}' is null in {config.FullName}."); + ?? throw new BuildException($"artifact for rid '{rid}' is null in {section.Source}."); 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." + $"sha512 for component '{section.Component}' version '{section.Key}' rid '{rid}' is not 128 hex " + + "characters." ); } @@ -62,11 +39,4 @@ static string MapArch(string arch) _ => 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 index 71a0bef..6686f05 100644 --- a/features/dotnet/Build/Staging.cs +++ b/features/dotnet/Build/Staging.cs @@ -3,22 +3,25 @@ namespace DevcontainerConfig.Dotnet.Build; internal static class Staging { internal static async Task RunAsync( - FileInfo config, + string config, string component, - string version, + string key, string arch, DirectoryInfo outDir, CancellationToken cancellationToken ) { - Artifact artifact = await Resolution.ResolveAsync(config, component, version, arch, cancellationToken); + string json = await File.ReadAllTextAsync(config, cancellationToken); + FeatureConfig feature = Config.Parse(json, config); + PayloadSection section = Config.Section(feature, config, component, key); + Artifact artifact = Resolution.Resolve(section, arch); 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); + Layout.Assert(outDir.FullName, section.Component, section.Key); } finally { diff --git a/features/dotnet/config.json b/features/dotnet/config.json index 9f03c11..dd84623 100644 --- a/features/dotnet/config.json +++ b/features/dotnet/config.json @@ -4,7 +4,6 @@ "sdk": { "versions": { "11.0.100-rc.3.26522.148": { - "aliases": ["preview", "11-preview", "11.0-preview"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/11.0.100-rc.3.26522.148/dotnet-sdk-11.0.100-rc.3.26522.148-linux-x64.tar.gz", "sha512": "77ef603622f0632ce59fc631871c9d0ec0c7402399837c659ead5b6fc7ea898f7f64a2632ecdc8d3330e608307416a36bff90a4d10179210489676167b9b80da" @@ -15,7 +14,6 @@ } }, "10.0.403": { - "aliases": ["10.0", "10", "lts", "latest"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/10.0.403/dotnet-sdk-10.0.403-linux-x64.tar.gz", "sha512": "b7b3d8461cf98b07d2552ce9199702fa3ad010ac2671a5f8cd5136f7534e50f6855ba7bbb02b11494563f1d04156276a31cda1103b900e5eecd0d9473dc7b9c5" @@ -26,7 +24,6 @@ } }, "9.0.320": { - "aliases": ["9.0", "9"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/9.0.320/dotnet-sdk-9.0.320-linux-x64.tar.gz", "sha512": "eb371100b5c40641a812b3683bfda1772f0b69e5232091293c715d49eb837f52174b9b9a93375d692bddbfd1d57005f1a9aa774b4b30276665b0c0b1a9b66427" @@ -37,7 +34,6 @@ } }, "8.0.427": { - "aliases": ["8.0", "8"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Sdk/8.0.427/dotnet-sdk-8.0.427-linux-x64.tar.gz", "sha512": "85725692f465f5024b9b42a159a29d9f73e8c57ad9e8bfe1e7028a505d18031d68931e8aa3f9de44145b08170dde897c8a78e1aa18d4219698b6891af7095d39" @@ -47,12 +43,17 @@ "sha512": "2b851de08e94922b382d1d2754df112cd167f6e9b6f3d15d2cfe143fded1d5b05d6b196b545941d22c97db62bb426d71a4fe6e131a92e482c6ea1a7337ffedab" } } + }, + "aliases": { + "11.0.100-rc.3.26522.148": ["preview", "11-preview", "11.0-preview"], + "10.0.403": ["10.0", "10", "lts", "latest"], + "9.0.320": ["9.0", "9"], + "8.0.427": ["8.0", "8"] } }, "runtime": { "versions": { "11.0.0-rc.3.26522.148": { - "aliases": ["preview", "11-preview", "11.0-preview"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/11.0.0-rc.3.26522.148/dotnet-runtime-11.0.0-rc.3.26522.148-linux-x64.tar.gz", "sha512": "3b059283f689d4338a2dee33e1b433f2218f9d754e25b96821ff68281ea5d0f354d4f284bba00d8b594cf2c166531593b538e3f8308a349ca6e844ee74c03ebb" @@ -63,7 +64,6 @@ } }, "10.0.14": { - "aliases": ["10.0", "10", "lts", "latest"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/10.0.14/dotnet-runtime-10.0.14-linux-x64.tar.gz", "sha512": "634326028645f38731c8c4e71a68356e4ca0d6b6607139a234ab760687f742063edaf33a1f20dda615742d2dc8300ca2ff857619d51fbb38090383b71334af89" @@ -74,7 +74,6 @@ } }, "9.0.22": { - "aliases": ["9.0", "9"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/9.0.22/dotnet-runtime-9.0.22-linux-x64.tar.gz", "sha512": "c6f039209e4b5824ac57837ff41e95f6ab3c79b1a118f4a8539707a8381df46d3b2ed98bbd893cc850c680feb18c64f2b2308c81f5f60e50927a76b295e5c402" @@ -85,7 +84,6 @@ } }, "8.0.33": { - "aliases": ["8.0", "8"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/Runtime/8.0.33/dotnet-runtime-8.0.33-linux-x64.tar.gz", "sha512": "b314039d337a8c5c4be6a6f99712a2618cbeb5e1ab7cac7a5c1cbf1c97bd355f5d116b9932a8cfc604ac7849f74638896eba0534892c6518311908aba29b4f7b" @@ -95,12 +93,17 @@ "sha512": "a9ba25eae72f98e25233ae202d6e4fb6663b2c8da23bcd9d797c89e8b1d01f51de90f1994dc7101a5fef56db401c05af184c5e251a9bce570adf044c4e3ae5a9" } } + }, + "aliases": { + "11.0.0-rc.3.26522.148": ["preview", "11-preview", "11.0-preview"], + "10.0.14": ["10.0", "10", "lts", "latest"], + "9.0.22": ["9.0", "9"], + "8.0.33": ["8.0", "8"] } }, "aspnet": { "versions": { "11.0.0-rc.3.26522.148": { - "aliases": ["preview", "11-preview", "11.0-preview"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/11.0.0-rc.3.26522.148/aspnetcore-runtime-11.0.0-rc.3.26522.148-linux-x64.tar.gz", "sha512": "4c9319dcad8dd5b5eae734dae36331003a784e42401395cf5e24b19394a9955494a1d7b17ff4e9321d93cd1d960ca8c66cf9a849e90d1db7c35a50ba54f8260f" @@ -111,7 +114,6 @@ } }, "10.0.14": { - "aliases": ["10.0", "10", "lts", "latest"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/10.0.14/aspnetcore-runtime-10.0.14-linux-x64.tar.gz", "sha512": "dd024fd2616926a555b75cbf7a836f6dce998199f0596d9d75676dca2b9d59c8ee8673c6ac23473c0f1a4bdd59eb5cd02fe9ab097eeaf2dd8c8bd117c85e7031" @@ -122,7 +124,6 @@ } }, "9.0.22": { - "aliases": ["9.0", "9"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/9.0.22/aspnetcore-runtime-9.0.22-linux-x64.tar.gz", "sha512": "eafa93b084b09ee587739bbfadda0e4b3c734afa5308095100c7a310cd269caef2ec734ae27dabae563150e30b909e5b351f917bb44fb6d608ed8a4caeb590c2" @@ -133,7 +134,6 @@ } }, "8.0.33": { - "aliases": ["8.0", "8"], "linux-x64": { "url": "https://builds.dotnet.microsoft.com/dotnet/aspnetcore/Runtime/8.0.33/aspnetcore-runtime-8.0.33-linux-x64.tar.gz", "sha512": "08e37c6cd09edde60170f3f65531f1b688610f692f9de62125e2f7217bffa2ac130c9d6b49803266ac2d4916a91a277e095c226b83e8ea565d7de4014422aa6d" @@ -143,6 +143,12 @@ "sha512": "d39d1b692f79d1a9acdfa21383826ee9d1f606f24693a4ffe495a10996bcbd4f337d092544da48de5a54eae6e5ee475bd12ab36e04516e91a5d6409212a8925b" } } + }, + "aliases": { + "11.0.0-rc.3.26522.148": ["preview", "11-preview", "11.0-preview"], + "10.0.14": ["10.0", "10", "lts", "latest"], + "9.0.22": ["9.0", "9"], + "8.0.33": ["8.0", "8"] } } } diff --git a/features/ripgrep/config.json b/features/ripgrep/config.json index a621e2b..bc8f3eb 100644 --- a/features/ripgrep/config.json +++ b/features/ripgrep/config.json @@ -2,7 +2,6 @@ "revision": 2, "versions": { "15.2.0": { - "aliases": ["15.2", "15", "latest"], "x86_64-unknown-linux-musl": { "url": "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz", "sha256": "33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c" @@ -12,5 +11,8 @@ "sha256": "800b1e7206afe799dfb5a6901f23147cfaabe0e52210538100f61e86e1740915" } } + }, + "aliases": { + "15.2.0": ["15.2", "15", "latest"] } } diff --git a/features/uv/README.md b/features/uv/README.md index a5b62aa..e82821b 100644 --- a/features/uv/README.md +++ b/features/uv/README.md @@ -1,4 +1,4 @@ -# Uv (uv) +# uv Installs the uv Python package manager and pinned Python versions. diff --git a/features/uv/config.json b/features/uv/config.json index 0c985eb..7fb1fc1 100644 --- a/features/uv/config.json +++ b/features/uv/config.json @@ -4,7 +4,6 @@ "uv": { "versions": { "0.13.1": { - "aliases": ["0.13", "latest"], "linux-x64": { "url": "https://github.com/astral-sh/uv/releases/download/0.13.1/uv-x86_64-unknown-linux-musl.tar.gz", "sha256": "6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0" @@ -14,12 +13,15 @@ "sha256": "345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16" } } + }, + "aliases": { + "0.13.1": ["0.13", "latest"] } }, "python": { "versions": { - "3.11.17": { - "aliases": ["3.11"], + "3.11.17-20261013": { + "pythonVersion": "3.11.17", "build": "20261013", "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.11.17%2B20261013-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", @@ -30,8 +32,8 @@ "sha256": "3d09fa150bebc6c4f888f8d601328b46fd17f1be5c62cd390a353b7ccff6ed51" } }, - "3.12.15": { - "aliases": ["3.12"], + "3.12.15-20261013": { + "pythonVersion": "3.12.15", "build": "20261013", "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.12.15%2B20261013-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", @@ -42,8 +44,8 @@ "sha256": "9467a99bacc7d90b64b02de7fa9ecfc99b72905e64e74f7ff6b460ad95cfed5a" } }, - "3.13.16": { - "aliases": ["3.13"], + "3.13.16-20261013": { + "pythonVersion": "3.13.16", "build": "20261013", "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.13.16%2B20261013-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", @@ -54,8 +56,8 @@ "sha256": "69a30ed26198178732c37b8de0b6b5191479fbacfe526954da776befe38f18e7" } }, - "3.14.8": { - "aliases": ["3.14"], + "3.14.8-20261013": { + "pythonVersion": "3.14.8", "build": "20261013", "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.14.8%2B20261013-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", @@ -66,8 +68,8 @@ "sha256": "d8ae14f303d3971431184cf423ae5467202de2bc19bf6491a490250b87647d4b" } }, - "3.15.0": { - "aliases": ["3.15", "latest"], + "3.15.0-20261013": { + "pythonVersion": "3.15.0", "build": "20261013", "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20261013/cpython-3.15.0%2B20261013-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz", @@ -78,6 +80,13 @@ "sha256": "064778ca701efc4c8366d0b235a2a0f5dbe14d921aeb2ef4f5c7b93e5a75cbd0" } } + }, + "aliases": { + "3.11.17-20261013": ["3.11", "3.11.17"], + "3.12.15-20261013": ["3.12", "3.12.15"], + "3.13.16-20261013": ["3.13", "3.13.16"], + "3.14.8-20261013": ["3.14", "3.14.8"], + "3.15.0-20261013": ["3.15", "3.15.0", "latest"] } } } diff --git a/features/uv/feature.json b/features/uv/feature.json index 67303e2..2f3ed20 100644 --- a/features/uv/feature.json +++ b/features/uv/feature.json @@ -1,6 +1,6 @@ { "id": "uv", - "name": "Uv", + "name": "uv", "description": "Installs the uv Python package manager and pinned Python versions.", "image": "ghcr.io/devcontainer-config/features/uv", "options": { diff --git a/scripts/tasks/features/config.ts b/scripts/tasks/features/config.ts new file mode 100644 index 0000000..8339ccb --- /dev/null +++ b/scripts/tasks/features/config.ts @@ -0,0 +1,70 @@ +export const tagPattern = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/u; + +export type AliasTable = Readonly>; + +export interface TagNamespace { + source: string; + revision: number; + versions: Readonly>; + aliases: AliasTable; + requiredClaims: readonly string[]; +} + +// The single inversion of an alias table: alias → version key. Rejects a name claimed twice. +export const aliasClaims = (aliases: AliasTable, source: string): Map => { + const claims = new Map(); + for (const [key, names] of Object.entries(aliases)) { + for (const name of names) { + const owner = claims.get(name); + if (owner === key) { + throw new Error(`${source}: alias ${name} is claimed twice by ${key}`); + } + if (owner !== undefined) { + throw new Error(`${source}: alias ${name} is claimed by ${owner} and ${key}`); + } + claims.set(name, key); + } + } + return claims; +}; + +export const claimedKey = (aliases: AliasTable, alias: string, source: string): string => { + const key = aliasClaims(aliases, source).get(alias); + if (key === undefined) { + throw new Error(`${source}: no version claims the ${alias} alias`); + } + return key; +}; + +export const checkTagNamespace = (namespace: TagNamespace): void => { + const { source, revision, versions, aliases, requiredClaims } = namespace; + for (const key of Object.keys(versions)) { + if (!Object.hasOwn(aliases, key)) { + throw new Error(`${source}: version ${key} has no aliases row`); + } + } + for (const key of Object.keys(aliases)) { + if (!Object.hasOwn(versions, key)) { + throw new Error(`${source}: aliases row ${key} has no version`); + } + } + const claims = aliasClaims(aliases, source); + for (const claim of requiredClaims) { + if (!claims.has(claim)) { + throw new Error(`${source}: no version claims the ${claim} alias`); + } + } + const owners = new Map(); + for (const key of Object.keys(versions)) { + for (const name of [`${key}-r${revision}`, key, ...aliases[key]]) { + if (!tagPattern.test(name)) { + throw new Error(`${source}: tag ${name} derived by ${key} is not a valid registry tag name`); + } + const owner = owners.get(name); + if (owner !== undefined && owner !== key) { + throw new Error(`${source}: tag ${name} is derived by ${owner} and ${key}`); + } + owners.set(name, key); + } + } +}; diff --git a/scripts/tasks/features/dotnet/buildContext.ts b/scripts/tasks/features/dotnet/buildContext.ts index eba8902..4ebfbdf 100644 --- a/scripts/tasks/features/dotnet/buildContext.ts +++ b/scripts/tasks/features/dotnet/buildContext.ts @@ -10,10 +10,10 @@ import { paths } from "./paths.js"; export const assembleBuildContext = async ( component: Component, - version: string, + key: string, featureBinaries: Partial>, ): Promise => { - const context = paths.contextPath(component, version); + const context = paths.contextPath(component, key); 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")); @@ -23,9 +23,9 @@ export const assembleBuildContext = async ( continue; } const stagedRoot = path.join(context, "linux", arch, "features", "dotnet"); - const staged = path.join(stagedRoot, component, version); + const staged = path.join(stagedRoot, component, key); await mkdir(staged, { recursive: true }); - await linkTree(paths.payloadPath(component, version, arch), staged); + await linkTree(paths.payloadPath(component, key, arch), staged); await copyFile(path.join(projectRoot, "features/dotnet/install"), path.join(stagedRoot, "install")); await copyFile(featureBinary, path.join(stagedRoot, "Feature")); } diff --git a/scripts/tasks/features/dotnet/buildImage.ts b/scripts/tasks/features/dotnet/buildImage.ts index 2b60293..5129e95 100644 --- a/scripts/tasks/features/dotnet/buildImage.ts +++ b/scripts/tasks/features/dotnet/buildImage.ts @@ -24,7 +24,7 @@ const main = async (): Promise => { }, }); - const { component, version, arch } = parseInvocation(values); + const { component, key, arch } = parseInvocation(values); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); if (arch !== paths.hostArch()) { @@ -34,16 +34,16 @@ const main = async (): Promise => { } const feature = await ensureFeatureBinary(paths.ridFor(arch), values.force); - const payload = paths.payloadPath(component, version, arch); + const payload = paths.payloadPath(component, key, 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`, + `No payload at ${payload}; run scripts/tasks/features/dotnet/fetchPayload.ts --component ${component} --version ${key} --arch ${arch} first`, ); } - const context = await assembleBuildContext(component, version, { [arch]: feature }); + const context = await assembleBuildContext(component, key, { [arch]: feature }); const config = await readConfig(); - const refs = [...imageRefs(prefix, component, tagNames(config, component, version, channel)), ...(values.tag ?? [])]; + const refs = [...imageRefs(prefix, component, tagNames(config, component, key, channel).all), ...(values.tag ?? [])]; await $$`docker buildx build ${[ ...["--platform", `linux/${arch}`], "--load", diff --git a/scripts/tasks/features/dotnet/fetchPayload.ts b/scripts/tasks/features/dotnet/fetchPayload.ts index e891c8e..07cc2d9 100644 --- a/scripts/tasks/features/dotnet/fetchPayload.ts +++ b/scripts/tasks/features/dotnet/fetchPayload.ts @@ -13,8 +13,8 @@ const main = async (): Promise => { }, }); - const { component, version, arch } = parseInvocation(values); - await ensurePayload(component, version, arch, values.force); + const { component, key, arch } = parseInvocation(values); + await ensurePayload(component, key, arch, values.force); }; if (import.meta.main) { diff --git a/scripts/tasks/features/dotnet/generateConfig.ts b/scripts/tasks/features/dotnet/generateConfig.ts index 2ea2ea0..0aca696 100644 --- a/scripts/tasks/features/dotnet/generateConfig.ts +++ b/scripts/tasks/features/dotnet/generateConfig.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; import { projectRoot } from "@/scripts/project.js"; +import { checkTagNamespace } from "@/scripts/tasks/features/config.js"; const releasesIndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json"; export const configPath = path.resolve(projectRoot, "features/dotnet/config.json"); @@ -69,17 +70,25 @@ type FeedReleases = z.infer; const artifactSchema = z.object({ url: z.string(), sha512: z.string() }); -const componentVersionSchema = z.object({ - aliases: z.array(z.string()), +const componentVersionSchema = z.strictObject({ "linux-x64": artifactSchema, "linux-arm64": artifactSchema, }); -const componentConfigSchema = z.object({ versions: z.record(z.string(), componentVersionSchema) }); +const aliasTableSchema = z.record(z.string(), z.array(z.string())); -export const featureConfigSchema = z.object({ +const componentConfigSchema = z.strictObject({ + versions: z.record(z.string(), componentVersionSchema), + aliases: aliasTableSchema, +}); + +export const featureConfigSchema = z.strictObject({ revision: z.number(), - components: z.record(z.enum(components), componentConfigSchema), + components: z.strictObject({ + sdk: componentConfigSchema, + runtime: componentConfigSchema, + aspnet: componentConfigSchema, + }), }); export type Artifact = z.infer; @@ -146,40 +155,57 @@ const majorVersion = (channel: FeedChannel): string => { return major; }; -const resolveVersions = ( - component: Component, - channelData: readonly ChannelData[], -): Record => { +interface ComponentData { + versions: Record; + aliases: Record; +} + +const resolveComponentData = (component: Component, channelData: readonly ChannelData[]): ComponentData => { 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 = {}; + const aliases: 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) { + if (Object.hasOwn(versions, version)) { throw new Error(`Component ${component}: version ${version} resolved from more than one channel`); } const major = majorVersion(channel); - let aliases: string[]; + const claims: string[] = []; if (channel["support-phase"] === "preview" || channel["support-phase"] === "go-live") { - aliases = ["preview", `${major}-preview`, `${channel["channel-version"]}-preview`]; + claims.push("preview", `${major}-preview`, `${channel["channel-version"]}-preview`); } else { - aliases = [channel["channel-version"], major]; + claims.push(channel["channel-version"], major); if (ltsChannel === entry) { - aliases.push("lts"); + claims.push("lts"); } if (latestChannel === entry) { - aliases.push("latest"); + claims.push("latest"); } } - versions[version] = { aliases, ...resolveArtifacts(component, version, feedComponent.files) }; + versions[version] = resolveArtifacts(component, version, feedComponent.files); + aliases[version] = claims; + } + return { versions, aliases }; +}; + +const validateConfig = (config: FeatureConfig, source: string): void => { + for (const component of components) { + const { versions, aliases } = config.components[component]; + checkTagNamespace({ + source: `${source} component ${component}`, + revision: config.revision, + versions, + aliases, + requiredClaims: ["latest", "lts"], + }); } - return versions; }; export const parseConfig = (text: string, source: string): FeatureConfig => { @@ -187,6 +213,7 @@ export const parseConfig = (text: string, source: string): FeatureConfig => { if (!result.success) { throw new Error(`${source}: ${z.prettifyError(result.error)}`); } + validateConfig(result.data, source); return result.data; }; @@ -210,11 +237,12 @@ export const generateConfig = async (): Promise => { const config: FeatureConfig = { revision: await readRevision(), components: { - sdk: { versions: resolveVersions("sdk", channelData) }, - runtime: { versions: resolveVersions("runtime", channelData) }, - aspnet: { versions: resolveVersions("aspnet", channelData) }, + sdk: resolveComponentData("sdk", channelData), + runtime: resolveComponentData("runtime", channelData), + aspnet: resolveComponentData("aspnet", channelData), }, }; + validateConfig(config, configPath); await writeFile( configPath, await prettier.format(JSON.stringify(config), { ...prettierOptions, filepath: configPath }), diff --git a/scripts/tasks/features/dotnet/invocation.ts b/scripts/tasks/features/dotnet/invocation.ts index 1e22ccb..4766be0 100644 --- a/scripts/tasks/features/dotnet/invocation.ts +++ b/scripts/tasks/features/dotnet/invocation.ts @@ -5,7 +5,7 @@ import { paths } from "./paths.js"; export interface Invocation { component: Component; - version: string; + key: string; arch: DockerArch; } @@ -17,7 +17,7 @@ export const parseComponent = (value: string | undefined): Component => { return component; }; -export const parseVersion = (value: string | undefined): string => { +export const parseKey = (value: string | undefined): string => { if (value === undefined || value === "") { throw new Error("--version is required"); } @@ -26,7 +26,7 @@ export const parseVersion = (value: string | undefined): string => { export const parseInvocation = (values: { component?: string; version?: string; arch?: string }): Invocation => { const component = parseComponent(values.component); - const version = parseVersion(values.version); + const key = parseKey(values.version); const arch = values.arch === undefined ? paths.hostArch() @@ -34,5 +34,5 @@ export const parseInvocation = (values: { component?: string; version?: string; if (arch === undefined) { throw new Error(`--arch must be one of: ${paths.dockerArchOptions.join(", ")}`); } - return { component, version, arch }; + return { component, key, arch }; }; diff --git a/scripts/tasks/features/dotnet/paths.ts b/scripts/tasks/features/dotnet/paths.ts index 60bc28b..800474f 100644 --- a/scripts/tasks/features/dotnet/paths.ts +++ b/scripts/tasks/features/dotnet/paths.ts @@ -27,11 +27,10 @@ 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 payloadPath = (component: Component, key: string, arch: DockerArch): string => + path.join(payloadRoot, component, key, arch); -const contextPath = (component: Component, version: string): string => - path.join(payloadRoot, "ctx", component, version); +const contextPath = (component: Component, key: string): string => path.join(payloadRoot, "ctx", component, key); const packPath = (assembly: Assembly, rid: Rid): string => path.join(workspaces, "bin/dotnet", assembly, rid); diff --git a/scripts/tasks/features/dotnet/payload.ts b/scripts/tasks/features/dotnet/payload.ts index 055f96b..8b16b89 100644 --- a/scripts/tasks/features/dotnet/payload.ts +++ b/scripts/tasks/features/dotnet/payload.ts @@ -11,11 +11,11 @@ import { paths } from "./paths.js"; export const ensurePayload = async ( component: Component, - version: string, + key: string, arch: DockerArch, force: boolean, ): Promise => { - const outPath = paths.payloadPath(component, version, arch); + const outPath = paths.payloadPath(component, key, arch); if (!force && (await paths.isFile(path.join(outPath, "dotnet")))) { console.log(`Payload already present at ${outPath}; skipping fetch`); return; @@ -27,7 +27,7 @@ export const ensurePayload = async ( buildBinary, ...["--config", configPath], ...["--component", component], - ...["--version", version], + ...["--version", key], ...["--arch", arch], ...["--out", tempPath], ]}`; diff --git a/scripts/tasks/features/dotnet/planPublish.ts b/scripts/tasks/features/dotnet/planPublish.ts index 19119f9..c0d292f 100644 --- a/scripts/tasks/features/dotnet/planPublish.ts +++ b/scripts/tasks/features/dotnet/planPublish.ts @@ -4,7 +4,7 @@ import { planPublish } from "@/scripts/tasks/features/planPublish.js"; import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { components, readConfig } from "./generateConfig.js"; -import { canonicalTag, imageRef, tagNames } from "./tags.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -19,15 +19,18 @@ const main = async (): Promise => { 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 })), + Object.keys(config.components[component].versions).map((key) => ({ component, key })), ); await planPublish( - pairs.map(({ component, version }) => ({ - label: `${component} ${version}`, - canonical: imageRef(prefix, component, canonicalTag(config, component, version, channel)), - tags: tagNames(config, component, version, channel), - entry: { component, version }, - })), + pairs.map(({ component, key }) => { + const tags = tagNames(config, component, key, channel); + return { + label: `${component} ${key}`, + canonical: imageRef(prefix, component, tags.canonical), + tags: tags.all, + entry: { component, key }, + }; + }), { channel, prefix, out: values.out }, ); }; diff --git a/scripts/tasks/features/dotnet/publishImage.ts b/scripts/tasks/features/dotnet/publishImage.ts index 6eca9e2..d50ead1 100644 --- a/scripts/tasks/features/dotnet/publishImage.ts +++ b/scripts/tasks/features/dotnet/publishImage.ts @@ -7,10 +7,10 @@ import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js" import { assembleBuildContext } from "./buildContext.js"; import { resolveFeatureBinaries } from "./featureBinary.js"; import { readConfig } from "./generateConfig.js"; -import { parseComponent, parseVersion } from "./invocation.js"; +import { parseComponent, parseKey } from "./invocation.js"; import { paths } from "./paths.js"; import { ensurePayload } from "./payload.js"; -import { imageRefs, tagNames } from "./tags.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -24,18 +24,18 @@ const main = async (): Promise => { }); const component = parseComponent(values.component); - const version = parseVersion(values.version); + const key = parseKey(values.version); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); const config = await readConfig(); - const tags = tagNames(config, component, version, channel); + const canonical = tagNames(config, component, key, channel).canonical; const featureBinaries = await resolveFeatureBinaries(values["install-dir"]); for (const arch of paths.dockerArchOptions) { - await ensurePayload(component, version, arch, false); + await ensurePayload(component, key, arch, false); } - const context = await assembleBuildContext(component, version, featureBinaries); + const context = await assembleBuildContext(component, key, featureBinaries); await login(prefix); - const refs = imageRefs(prefix, component, tags); + const refs = [imageRef(prefix, component, canonical)]; await pushImage(context, refs); console.log(refs.join("\n")); }; diff --git a/scripts/tasks/features/dotnet/releaseTags.ts b/scripts/tasks/features/dotnet/releaseTags.ts new file mode 100644 index 0000000..4781331 --- /dev/null +++ b/scripts/tasks/features/dotnet/releaseTags.ts @@ -0,0 +1,53 @@ +import { parseArgs } from "node:util"; + +import type { ReleaseEntry } from "@/scripts/tasks/features/release.js"; +import { releaseTags } from "@/scripts/tasks/features/release.js"; +import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { components, readConfig } from "./generateConfig.js"; +import { imageRef, imageRefs, tagNames } from "./tags.js"; + +interface FeatureReleaseEntry extends ReleaseEntry { + gitTag: string; +} + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + "git-tags": { type: "boolean", default: false }, + }, + }); + + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const config = await readConfig(); + const entries: FeatureReleaseEntry[] = components.flatMap((component) => + Object.keys(config.components[component].versions).map((key) => { + const tags = tagNames(config, component, key, channel); + return { + label: `${component} ${key}`, + canonical: imageRef(prefix, component, tags.canonical), + tags: imageRefs(prefix, component, tags.claims), + gitTag: `features-dotnet-${component}-${tagNames(config, component, key, mainChannel).canonical}`, + }; + }), + ); + await releaseTags(entries, prefix); + if (values["git-tags"]) { + for (const { gitTag } of entries) { + await tagPublished(gitTag); + } + } +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/dotnet/renovate.ts b/scripts/tasks/features/dotnet/renovate.ts index 41e96fd..0125371 100644 --- a/scripts/tasks/features/dotnet/renovate.ts +++ b/scripts/tasks/features/dotnet/renovate.ts @@ -2,37 +2,20 @@ import path from "node:path"; import { parseArgs } from "node:util"; import { projectRoot } from "@/scripts/project.js"; -import { headFile, refresh } from "@/scripts/tasks/features/renovate.js"; +import type { ConfigDocument } from "@/scripts/tasks/features/renovate.js"; +import { refreshFeatureConfig } from "@/scripts/tasks/features/renovate.js"; -import type { Component, FeatureConfig } from "./generateConfig.js"; +import type { FeatureConfig } from "./generateConfig.js"; import { components, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; -const title = "Update dotnet version pins"; -const configFilepath = path.relative(projectRoot, configPath); - -const versionPins = (previous: FeatureConfig | undefined, next: FeatureConfig): string[] => { - if (previous === undefined) { - return components.flatMap((component) => { - const versions = Object.keys(next.components[component].versions); - return versions.length === 0 ? [] : [`${component}: added ${versions.join(", ")}`]; - }); - } - return 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 text = await headFile(oid, configFilepath); - return text === undefined ? undefined : parseConfig(text, `${configFilepath} at HEAD`); -}; +const document = (config: FeatureConfig): ConfigDocument => ({ + revision: config.revision, + namespaces: components.map((component) => ({ + name: component, + versions: config.components[component].versions, + aliases: config.components[component].aliases, + })), +}); const main = async (): Promise => { const { values } = parseArgs({ @@ -41,13 +24,15 @@ const main = async (): Promise => { }, }); - await generateConfig(); - await refresh( + await refreshFeatureConfig( { - branch: "renovate", - title, - filepath: configFilepath, - body: async (headOid) => versionPins(await headConfig(headOid), await readConfig()).join("\n"), + branch: "renovate-dotnet", + title: "Update dotnet version pins", + filepath: path.relative(projectRoot, configPath), + generate: generateConfig, + read: readConfig, + parse: parseConfig, + document, }, values["dry-run"], ); diff --git a/scripts/tasks/features/dotnet/tagPublished.ts b/scripts/tasks/features/dotnet/tagPublished.ts deleted file mode 100644 index 88fd50b..0000000 --- a/scripts/tasks/features/dotnet/tagPublished.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { parseArgs } from "node:util"; - -import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; -import { mainChannel } from "@/scripts/tasks/features/tags.js"; - -import { readConfig } from "./generateConfig.js"; -import { parseComponent, parseVersion } from "./invocation.js"; -import { canonicalTag } from "./tags.js"; - -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 config = await readConfig(); - await tagPublished(`features-dotnet-${component}-${canonicalTag(config, component, version, mainChannel)}`); -}; - -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 index 577e09b..7e496a6 100644 --- a/scripts/tasks/features/dotnet/tags.ts +++ b/scripts/tasks/features/dotnet/tags.ts @@ -1,22 +1,19 @@ -import type { TagSource } from "@/scripts/tasks/features/tags.js"; -import { canonicalTag as baseCanonicalTag, tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; +import type { TagSet, TagSource } from "@/scripts/tasks/features/tags.js"; +import { tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; -const sourceOf = (config: FeatureConfig, component: Component, version: string): TagSource => { - const entry = config.components[component].versions[version]; +const sourceOf = (config: FeatureConfig, component: Component, key: string): TagSource => { + const entry = config.components[component].versions[key]; if (entry === undefined) { const available = Object.keys(config.components[component].versions).join(", "); - throw new Error(`Component ${component} has no version ${version} (available: ${available})`); + throw new Error(`Component ${component} has no version ${key} (available: ${available})`); } - return { version, revision: config.revision, aliases: entry.aliases }; + return { key, revision: config.revision, aliases: config.components[component].aliases[key] }; }; -export const tagNames = (config: FeatureConfig, component: Component, version: string, channel: string): string[] => - baseTagNames(sourceOf(config, component, version), channel); - -export const canonicalTag = (config: FeatureConfig, component: Component, version: string, channel: string): string => - baseCanonicalTag(sourceOf(config, component, version), channel); +export const tagNames = (config: FeatureConfig, component: Component, key: string, channel: string): TagSet => + baseTagNames(sourceOf(config, component, key), channel); export const imageRef = (prefix: string, component: Component, tag: string): string => `${prefix}/dotnet/${component}:${tag}`; diff --git a/scripts/tasks/features/release.ts b/scripts/tasks/features/release.ts new file mode 100644 index 0000000..3c57646 --- /dev/null +++ b/scripts/tasks/features/release.ts @@ -0,0 +1,25 @@ +import { $ } from "execa"; + +import { login } from "./registry.js"; + +const $$inspect = $({ reject: false, stdio: "ignore" }); + +export interface ReleaseEntry { + label: string; + canonical: string; + tags: readonly string[]; +} + +export const releaseTags = async (entries: readonly ReleaseEntry[], prefix: string): Promise => { + await login(prefix); + for (const entry of entries) { + const inspect = await $$inspect`docker buildx imagetools inspect ${entry.canonical}`; + if (inspect.exitCode !== 0) { + throw new Error(`${entry.label}: canonical tag ${entry.canonical} is missing`); + } + } + for (const entry of entries) { + await $`docker buildx imagetools create ${entry.tags.flatMap((tag) => ["--tag", tag])} ${entry.canonical}`; + console.log(`${entry.label}: applied ${entry.tags.join(", ")} to ${entry.canonical}`); + } +}; diff --git a/scripts/tasks/features/renovate.ts b/scripts/tasks/features/renovate.ts index 86bde3f..1e3a603 100644 --- a/scripts/tasks/features/renovate.ts +++ b/scripts/tasks/features/renovate.ts @@ -25,6 +25,146 @@ export const headFile = async (oid: string, filepath: string): Promise>; + aliases: Readonly>; +} + +export interface ConfigDocument { + revision: number; + namespaces: readonly ConfigNamespace[]; +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const diffPaths = (before: unknown, after: unknown, prefix: string): string[] => { + if (isObject(before) && isObject(after)) { + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + return [...keys].flatMap((key) => diffPaths(before[key], after[key], prefix === "" ? key : `${prefix}.${key}`)); + } + if (Array.isArray(before) && Array.isArray(after)) { + if (before.length !== after.length) { + return [prefix]; + } + return before.flatMap((value, index) => diffPaths(value, after[index], `${prefix}[${index}]`)); + } + return JSON.stringify(before) === JSON.stringify(after) ? [] : [prefix]; +}; + +const aliasOwners = (aliases: Readonly>): Map => { + const owners = new Map(); + for (const [key, names] of Object.entries(aliases)) { + for (const name of names) { + owners.set(name, key); + } + } + return owners; +}; + +// One shared comparison of a regenerated config against HEAD: added, removed and changed payload entries and alias moves. +// A payload entry that survives a revision must be identical; a revision change turns the failure into a listed change. +export const configChanges = (previous: ConfigDocument, next: ConfigDocument, source: string): string[] => { + const revisionChanged = previous.revision !== next.revision; + const changes: string[] = revisionChanged ? [`revision ${previous.revision} → ${next.revision}`] : []; + const failures: string[] = []; + for (const namespace of next.namespaces) { + const before = previous.namespaces.find((candidate) => candidate.name === namespace.name); + if (before === undefined) { + throw new Error(`${source}: namespace ${namespace.name} is missing from HEAD`); + } + const added = Object.keys(namespace.versions).filter((key) => !Object.hasOwn(before.versions, key)); + const removed = Object.keys(before.versions).filter((key) => !Object.hasOwn(namespace.versions, key)); + changes.push(...added.map((key) => `added ${namespace.name} ${key}`)); + changes.push(...removed.map((key) => `removed ${namespace.name} ${key}`)); + for (const key of Object.keys(namespace.versions)) { + if (!Object.hasOwn(before.versions, key)) { + continue; + } + const paths = diffPaths(before.versions[key], namespace.versions[key], ""); + if (paths.length === 0) { + continue; + } + changes.push(`changed ${namespace.name} ${key}: ${paths.join(", ")}`); + if (!revisionChanged) { + failures.push( + `${source}: ${namespace.name} ${key} changed within revision ${previous.revision}: ${paths.join(", ")}`, + ); + } + } + const beforeOwners = aliasOwners(before.aliases); + const afterOwners = aliasOwners(namespace.aliases); + for (const alias of new Set([...beforeOwners.keys(), ...afterOwners.keys()])) { + const beforeOwner = beforeOwners.get(alias); + const afterOwner = afterOwners.get(alias); + if (beforeOwner === afterOwner) { + continue; + } + if (beforeOwner !== undefined && afterOwner !== undefined) { + changes.push(`alias ${namespace.name} ${alias}: ${beforeOwner} → ${afterOwner}`); + } else if (afterOwner !== undefined && !added.includes(afterOwner)) { + changes.push(`alias ${namespace.name} ${alias}: added to ${afterOwner}`); + } else if (beforeOwner !== undefined && !removed.includes(beforeOwner)) { + changes.push(`alias ${namespace.name} ${alias}: removed from ${beforeOwner}`); + } + } + } + if (failures.length > 0) { + throw new Error(failures.join("\n")); + } + return changes; +}; + +export const changeBody = (changes: readonly string[], filepath: string): string => { + if (changes.length === 0) { + throw new Error(`${filepath} changed but no version, alias or artifact change was found`); + } + return changes.join("\n"); +}; + +const emptyDocument = (document: ConfigDocument): ConfigDocument => ({ + revision: document.revision, + namespaces: document.namespaces.map(({ name }) => ({ name, versions: {}, aliases: {} })), +}); + +export interface FeatureConfigRefresh { + branch: string; + title: string; + filepath: string; + generate: () => Promise; + read: () => Promise; + parse: (text: string, source: string) => Config; + document: (config: Config) => ConfigDocument; +} + +// A refresh regenerates the config, then compares it against HEAD and opens or updates the feature's branch. +export const refreshFeatureConfig = async ( + options: FeatureConfigRefresh, + dryRun: boolean, +): Promise => { + await options.generate(); + await refresh( + { + branch: options.branch, + title: options.title, + filepath: options.filepath, + body: async (headOid) => { + const text = await headFile(headOid, options.filepath); + const previous = text === undefined ? undefined : options.parse(text, `${options.filepath} at HEAD`); + const next = options.document(await options.read()); + const changes = configChanges( + previous === undefined ? emptyDocument(next) : options.document(previous), + next, + options.filepath, + ); + return changeBody(changes, options.filepath); + }, + }, + dryRun, + ); +}; + export interface RefreshOptions { branch: string; title: string; diff --git a/scripts/tasks/features/ripgrep/buildContext.ts b/scripts/tasks/features/ripgrep/buildContext.ts index 58d2346..d96a983 100644 --- a/scripts/tasks/features/ripgrep/buildContext.ts +++ b/scripts/tasks/features/ripgrep/buildContext.ts @@ -7,15 +7,15 @@ import { linkTree } from "@/scripts/tasks/features/linkTree.js"; import type { DockerArch } from "./paths.js"; import { paths } from "./paths.js"; -export const assembleBuildContext = async (version: string, arches: readonly DockerArch[]): Promise => { - const context = paths.contextPath(version); +export const assembleBuildContext = async (key: string, arches: readonly DockerArch[]): Promise => { + const context = paths.contextPath(key); await rm(context, { recursive: true, force: true }); await mkdir(path.join(context, "docker"), { recursive: true }); await copyFile(path.join(projectRoot, "features/ripgrep/Dockerfile"), path.join(context, "docker", "Dockerfile")); for (const arch of arches) { const staged = path.join(context, "linux", arch, "features", "ripgrep"); await mkdir(staged, { recursive: true }); - await linkTree(paths.payloadPath(version, arch), staged); + await linkTree(paths.payloadPath(key, arch), staged); await copyFile(path.join(projectRoot, "features/ripgrep/install"), path.join(staged, "install")); } return context; diff --git a/scripts/tasks/features/ripgrep/buildImage.ts b/scripts/tasks/features/ripgrep/buildImage.ts index 76bb85a..751feab 100644 --- a/scripts/tasks/features/ripgrep/buildImage.ts +++ b/scripts/tasks/features/ripgrep/buildImage.ts @@ -8,6 +8,7 @@ import { assembleBuildContext } from "./buildContext.js"; import { readConfig } from "./generateConfig.js"; import { parseInvocation } from "./invocation.js"; import { paths } from "./paths.js"; +import { derive, loadSection, payloadPresent } from "./payload.js"; import { imageRefs, tagNames } from "./tags.js"; const main = async (): Promise => { @@ -16,11 +17,12 @@ const main = async (): Promise => { version: { type: "string" }, arch: { type: "string" }, channel: { type: "string", default: mainChannel }, + tag: { type: "string", multiple: true }, prefix: { type: "string" }, }, }); - const { version, arch } = parseInvocation(values); + const { key, arch } = parseInvocation(values); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); if (arch !== paths.hostArch()) { @@ -29,16 +31,17 @@ const main = async (): Promise => { ); } - const payload = paths.payloadPath(version, arch); - if (!(await paths.isFile(path.join(payload, "rg")))) { + const section = await loadSection(key); + const payload = derive(section, arch); + if (!(await payloadPresent(payload))) { throw new Error( - `No payload at ${payload}; run scripts/tasks/features/ripgrep/fetchPayload.ts --version ${version} --arch ${arch} first`, + `No payload at ${payload.cache}; run scripts/tasks/features/ripgrep/fetchPayload.ts --version ${key} --arch ${arch} first`, ); } - const context = await assembleBuildContext(version, [arch]); + const context = await assembleBuildContext(key, [arch]); const config = await readConfig(); - const refs = imageRefs(prefix, tagNames(config, version, channel)); + const refs = [...imageRefs(prefix, tagNames(config, key, channel).all), ...(values.tag ?? [])]; await $$`docker buildx build ${[ ...["--platform", `linux/${arch}`], "--load", diff --git a/scripts/tasks/features/ripgrep/fetchPayload.ts b/scripts/tasks/features/ripgrep/fetchPayload.ts index aeb77c8..fa907e9 100644 --- a/scripts/tasks/features/ripgrep/fetchPayload.ts +++ b/scripts/tasks/features/ripgrep/fetchPayload.ts @@ -1,7 +1,7 @@ import { parseArgs } from "node:util"; import { parseInvocation } from "./invocation.js"; -import { ensurePayload } from "./payload.js"; +import { ensurePayload, loadSection } from "./payload.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -12,8 +12,8 @@ const main = async (): Promise => { }, }); - const { version, arch } = parseInvocation(values); - await ensurePayload(version, arch, values.force); + const { key, arch } = parseInvocation(values); + await ensurePayload(await loadSection(key), arch, values.force); }; if (import.meta.main) { diff --git a/scripts/tasks/features/ripgrep/generateConfig.ts b/scripts/tasks/features/ripgrep/generateConfig.ts index f6997c9..b4edaa8 100644 --- a/scripts/tasks/features/ripgrep/generateConfig.ts +++ b/scripts/tasks/features/ripgrep/generateConfig.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; import { projectRoot } from "@/scripts/project.js"; +import { checkTagNamespace } from "@/scripts/tasks/features/config.js"; const releasesUrl = "https://api.github.com/repos/BurntSushi/ripgrep/releases"; export const configPath = path.resolve(projectRoot, "features/ripgrep/config.json"); @@ -34,15 +35,17 @@ const releasesPageSchema = z.array(releaseSchema); const artifactSchema = z.object({ url: z.string(), sha256: z.string() }); -const versionEntrySchema = z.object({ - aliases: z.array(z.string()), +const versionEntrySchema = z.strictObject({ "x86_64-unknown-linux-musl": artifactSchema, "aarch64-unknown-linux-musl": artifactSchema, }); -export const featureConfigSchema = z.object({ +const aliasTableSchema = z.record(z.string(), z.array(z.string())); + +export const featureConfigSchema = z.strictObject({ revision: z.number(), versions: z.record(z.string(), versionEntrySchema), + aliases: aliasTableSchema, }); export type Release = z.infer; @@ -124,13 +127,24 @@ const resolveArtifact = (candidate: Candidate, target: Target): Artifact => { return { url: asset.browser_download_url, sha256: match[1] }; }; -const versionEntry = (candidate: Candidate): VersionEntry => { +const versionEntry = (candidate: Candidate): VersionEntry => ({ + "x86_64-unknown-linux-musl": resolveArtifact(candidate, "x86_64-unknown-linux-musl"), + "aarch64-unknown-linux-musl": resolveArtifact(candidate, "aarch64-unknown-linux-musl"), +}); + +const versionAliases = (candidate: Candidate): string[] => { const [major, minor] = candidate.number; - return { - aliases: [`${major}.${minor}`, `${major}`, "latest"], - "x86_64-unknown-linux-musl": resolveArtifact(candidate, "x86_64-unknown-linux-musl"), - "aarch64-unknown-linux-musl": resolveArtifact(candidate, "aarch64-unknown-linux-musl"), - }; + return [`${major}.${minor}`, `${major}`, "latest"]; +}; + +const validateConfig = (config: FeatureConfig, source: string): void => { + checkTagNamespace({ + source, + revision: config.revision, + versions: config.versions, + aliases: config.aliases, + requiredClaims: ["latest"], + }); }; export const parseConfig = (text: string, source: string): FeatureConfig => { @@ -138,6 +152,7 @@ export const parseConfig = (text: string, source: string): FeatureConfig => { if (!result.success) { throw new Error(`${source}: ${z.prettifyError(result.error)}`); } + validateConfig(result.data, source); return result.data; }; @@ -160,7 +175,9 @@ export const generateConfig = async (): Promise => { const config: FeatureConfig = { revision: await readRevision(), versions: { [candidate.tag]: versionEntry(candidate) }, + aliases: { [candidate.tag]: versionAliases(candidate) }, }; + validateConfig(config, configPath); await writeFile( configPath, await prettier.format(JSON.stringify(config), { ...prettierOptions, filepath: configPath }), diff --git a/scripts/tasks/features/ripgrep/invocation.ts b/scripts/tasks/features/ripgrep/invocation.ts index 4896a65..b632dc1 100644 --- a/scripts/tasks/features/ripgrep/invocation.ts +++ b/scripts/tasks/features/ripgrep/invocation.ts @@ -2,11 +2,11 @@ import type { DockerArch } from "./paths.js"; import { paths } from "./paths.js"; export interface Invocation { - version: string; + key: string; arch: DockerArch; } -export const parseVersion = (value: string | undefined): string => { +export const parseKey = (value: string | undefined): string => { if (value === undefined || value === "") { throw new Error("--version is required"); } @@ -14,7 +14,7 @@ export const parseVersion = (value: string | undefined): string => { }; export const parseInvocation = (values: { version?: string; arch?: string }): Invocation => { - const version = parseVersion(values.version); + const key = parseKey(values.version); const arch = values.arch === undefined ? paths.hostArch() @@ -22,5 +22,5 @@ export const parseInvocation = (values: { version?: string; arch?: string }): In if (arch === undefined) { throw new Error(`--arch must be one of: ${paths.dockerArchOptions.join(", ")}`); } - return { version, arch }; + return { key, arch }; }; diff --git a/scripts/tasks/features/ripgrep/paths.ts b/scripts/tasks/features/ripgrep/paths.ts index efcb2a3..0fae7cd 100644 --- a/scripts/tasks/features/ripgrep/paths.ts +++ b/scripts/tasks/features/ripgrep/paths.ts @@ -26,9 +26,9 @@ const targets: Record = { const payloadRoot = path.resolve(workspaces, "payload/ripgrep"); -const payloadPath = (version: string, arch: DockerArch): string => path.join(payloadRoot, version, arch); +const payloadPath = (key: string, arch: DockerArch): string => path.join(payloadRoot, key, arch); -const contextPath = (version: string): string => path.join(payloadRoot, "ctx", version); +const contextPath = (key: string): string => path.join(payloadRoot, "ctx", key); const isFile = async (candidate: string): Promise => { try { diff --git a/scripts/tasks/features/ripgrep/payload.ts b/scripts/tasks/features/ripgrep/payload.ts index ba548a6..f4ea908 100644 --- a/scripts/tasks/features/ripgrep/payload.ts +++ b/scripts/tasks/features/ripgrep/payload.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { $$ } from "@/scripts/shell.js"; import { download } from "@/scripts/tasks/features/download.js"; +import type { Artifact, FeatureConfig, VersionEntry } from "./generateConfig.js"; import { configPath, readConfig } from "./generateConfig.js"; import type { DockerArch } from "./paths.js"; import { paths } from "./paths.js"; @@ -19,44 +20,74 @@ const entries = [ "UNLICENSE", ] as const; -export const ensurePayload = async (version: string, arch: DockerArch, force: boolean): Promise => { - const outPath = paths.payloadPath(version, arch); - if (!force && (await paths.isFile(path.join(outPath, "rg")))) { - console.log(`Payload already present at ${outPath}; skipping fetch`); - return; - } - const config = await readConfig(); - const versionEntry = config.versions[version]; - if (versionEntry === undefined) { - throw new Error(`Version ${version} is not in ${configPath}`); +export interface PayloadSection { + key: string; + entry: VersionEntry; +} + +export interface Payload { + key: string; + target: string; + cache: string; + present: string; + top: string; + archive: Artifact; +} + +export const sectionOf = (config: FeatureConfig, key: string): PayloadSection => { + const entry = config.versions[key]; + if (entry === undefined) { + throw new Error(`Version ${key} is not in ${configPath}`); } + return { key, entry }; +}; + +export const loadSection = async (key: string): Promise => sectionOf(await readConfig(), key); + +export const derive = (section: PayloadSection, arch: DockerArch): Payload => { const target = paths.targets[arch]; - const { url, sha256 } = versionEntry[target]; - const tempPath = `${outPath}.tmp`; + return { + key: section.key, + target, + cache: paths.payloadPath(section.key, arch), + present: path.join(paths.payloadPath(section.key, arch), "rg"), + top: `ripgrep-${section.key}-${target}`, + archive: section.entry[target], + }; +}; + +export const payloadPresent = (payload: Payload): Promise => paths.isFile(payload.present); + +export const ensurePayload = async (section: PayloadSection, arch: DockerArch, force: boolean): Promise => { + const payload = derive(section, arch); + if (!force && (await payloadPresent(payload))) { + console.log(`Payload already present at ${payload.cache}; skipping fetch`); + return; + } + const tempPath = `${payload.cache}.tmp`; await rm(tempPath, { recursive: true, force: true }); const archivePath = path.join(tempPath, "archive.tar.gz"); const extractedPath = path.join(tempPath, "extracted"); await mkdir(extractedPath, { recursive: true }); - const actual = await download(url, archivePath); - if (actual !== sha256) { - throw new Error(`${url}: sha256 ${actual} does not match the configured ${sha256}`); + const actual = await download(payload.archive.url, archivePath); + if (actual !== payload.archive.sha256) { + throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`); } - const top = `ripgrep-${version}-${target}`; await $$`tar ${[ "-xzf", archivePath, "-C", extractedPath, "--strip-components=1", - ...entries.map((entry) => `${top}/${entry}`), + ...entries.map((entry) => `${payload.top}/${entry}`), ]}`; for (const entry of entries) { if (!(await paths.isFile(path.join(extractedPath, entry)))) { - throw new Error(`${url}: the archive has no ${entry}`); + throw new Error(`${payload.archive.url}: the archive has no ${entry}`); } } - await rm(outPath, { recursive: true, force: true }); - await rename(extractedPath, outPath); + await rm(payload.cache, { recursive: true, force: true }); + await rename(extractedPath, payload.cache); await rm(tempPath, { recursive: true, force: true }); - console.log(`Payload written to ${outPath}`); + console.log(`Payload written to ${payload.cache}`); }; diff --git a/scripts/tasks/features/ripgrep/planPublish.ts b/scripts/tasks/features/ripgrep/planPublish.ts index e17a4c5..619e3a1 100644 --- a/scripts/tasks/features/ripgrep/planPublish.ts +++ b/scripts/tasks/features/ripgrep/planPublish.ts @@ -4,7 +4,7 @@ import { planPublish } from "@/scripts/tasks/features/planPublish.js"; import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { readConfig } from "./generateConfig.js"; -import { canonicalTag, imageRef, tagNames } from "./tags.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -19,12 +19,15 @@ const main = async (): Promise => { const prefix = values.prefix ?? (await defaultRefPrefix()); const config = await readConfig(); await planPublish( - Object.keys(config.versions).map((version) => ({ - label: version, - canonical: imageRef(prefix, canonicalTag(config, version, channel)), - tags: tagNames(config, version, channel), - entry: { version }, - })), + Object.keys(config.versions).map((key) => { + const tags = tagNames(config, key, channel); + return { + label: key, + canonical: imageRef(prefix, tags.canonical), + tags: tags.all, + entry: { key }, + }; + }), { channel, prefix, out: values.out }, ); }; diff --git a/scripts/tasks/features/ripgrep/publishImage.ts b/scripts/tasks/features/ripgrep/publishImage.ts index f45a600..e953e9d 100644 --- a/scripts/tasks/features/ripgrep/publishImage.ts +++ b/scripts/tasks/features/ripgrep/publishImage.ts @@ -6,10 +6,10 @@ import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js" import { assembleBuildContext } from "./buildContext.js"; import { readConfig } from "./generateConfig.js"; -import { parseVersion } from "./invocation.js"; +import { parseKey } from "./invocation.js"; import { paths } from "./paths.js"; -import { ensurePayload } from "./payload.js"; -import { imageRefs, tagNames } from "./tags.js"; +import { ensurePayload, loadSection } from "./payload.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -20,17 +20,18 @@ const main = async (): Promise => { }, }); - const version = parseVersion(values.version); + const key = parseKey(values.version); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); const config = await readConfig(); - const tags = tagNames(config, version, channel); + const canonical = tagNames(config, key, channel).canonical; + const section = await loadSection(key); for (const arch of paths.dockerArchOptions) { - await ensurePayload(version, arch, false); + await ensurePayload(section, arch, false); } - const context = await assembleBuildContext(version, paths.dockerArchOptions); + const context = await assembleBuildContext(key, paths.dockerArchOptions); await login(prefix); - const refs = imageRefs(prefix, tags); + const refs = [imageRef(prefix, canonical)]; await pushImage(context, refs); console.log(refs.join("\n")); }; diff --git a/scripts/tasks/features/ripgrep/releaseTags.ts b/scripts/tasks/features/ripgrep/releaseTags.ts new file mode 100644 index 0000000..1ce5a9a --- /dev/null +++ b/scripts/tasks/features/ripgrep/releaseTags.ts @@ -0,0 +1,51 @@ +import { parseArgs } from "node:util"; + +import type { ReleaseEntry } from "@/scripts/tasks/features/release.js"; +import { releaseTags } from "@/scripts/tasks/features/release.js"; +import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { readConfig } from "./generateConfig.js"; +import { imageRef, imageRefs, tagNames } from "./tags.js"; + +interface FeatureReleaseEntry extends ReleaseEntry { + gitTag: string; +} + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + "git-tags": { type: "boolean", default: false }, + }, + }); + + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const config = await readConfig(); + const entries: FeatureReleaseEntry[] = Object.keys(config.versions).map((key) => { + const tags = tagNames(config, key, channel); + return { + label: key, + canonical: imageRef(prefix, tags.canonical), + tags: imageRefs(prefix, tags.claims), + gitTag: `features-ripgrep-${tagNames(config, key, mainChannel).canonical}`, + }; + }); + await releaseTags(entries, prefix); + if (values["git-tags"]) { + for (const { gitTag } of entries) { + await tagPublished(gitTag); + } + } +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/ripgrep/renovate.ts b/scripts/tasks/features/ripgrep/renovate.ts index acebb20..d803557 100644 --- a/scripts/tasks/features/ripgrep/renovate.ts +++ b/scripts/tasks/features/ripgrep/renovate.ts @@ -2,32 +2,16 @@ import path from "node:path"; import { parseArgs } from "node:util"; import { projectRoot } from "@/scripts/project.js"; -import { headFile, refresh } from "@/scripts/tasks/features/renovate.js"; +import type { ConfigDocument } from "@/scripts/tasks/features/renovate.js"; +import { refreshFeatureConfig } from "@/scripts/tasks/features/renovate.js"; import type { FeatureConfig } from "./generateConfig.js"; import { configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; -const title = "Update ripgrep version pins"; -const configFilepath = path.relative(projectRoot, configPath); - -const versionPins = (previous: FeatureConfig | undefined, next: FeatureConfig): string[] => { - const after = Object.keys(next.versions); - if (previous === undefined) { - return after.length === 0 ? [] : [`added ${after.join(", ")}`]; - } - const before = Object.keys(previous.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 [`${removed.join(", ")} → ${added.join(", ")}`]; -}; - -const headConfig = async (oid: string): Promise => { - const text = await headFile(oid, configFilepath); - return text === undefined ? undefined : parseConfig(text, `${configFilepath} at HEAD`); -}; +const document = (config: FeatureConfig): ConfigDocument => ({ + revision: config.revision, + namespaces: [{ name: "ripgrep", versions: config.versions, aliases: config.aliases }], +}); const main = async (): Promise => { const { values } = parseArgs({ @@ -36,13 +20,15 @@ const main = async (): Promise => { }, }); - await generateConfig(); - await refresh( + await refreshFeatureConfig( { - branch: "renovate/ripgrep", - title, - filepath: configFilepath, - body: async (headOid) => versionPins(await headConfig(headOid), await readConfig()).join("\n"), + branch: "renovate-ripgrep", + title: "Update ripgrep version pins", + filepath: path.relative(projectRoot, configPath), + generate: generateConfig, + read: readConfig, + parse: parseConfig, + document, }, values["dry-run"], ); diff --git a/scripts/tasks/features/ripgrep/tagPublished.ts b/scripts/tasks/features/ripgrep/tagPublished.ts deleted file mode 100644 index 120c828..0000000 --- a/scripts/tasks/features/ripgrep/tagPublished.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { parseArgs } from "node:util"; - -import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; -import { mainChannel } from "@/scripts/tasks/features/tags.js"; - -import { readConfig } from "./generateConfig.js"; -import { parseVersion } from "./invocation.js"; -import { canonicalTag } from "./tags.js"; - -const main = async (): Promise => { - const { values } = parseArgs({ - options: { - version: { type: "string" }, - }, - }); - - const version = parseVersion(values.version); - const config = await readConfig(); - await tagPublished(`features-ripgrep-${canonicalTag(config, version, mainChannel)}`); -}; - -if (import.meta.main) { - try { - await main(); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -} diff --git a/scripts/tasks/features/ripgrep/tags.ts b/scripts/tasks/features/ripgrep/tags.ts index c5d59eb..d6de045 100644 --- a/scripts/tasks/features/ripgrep/tags.ts +++ b/scripts/tasks/features/ripgrep/tags.ts @@ -1,22 +1,19 @@ -import type { TagSource } from "@/scripts/tasks/features/tags.js"; -import { canonicalTag as baseCanonicalTag, tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; +import type { TagSet, TagSource } from "@/scripts/tasks/features/tags.js"; +import { tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; import type { FeatureConfig } from "./generateConfig.js"; -const sourceOf = (config: FeatureConfig, version: string): TagSource => { - const entry = config.versions[version]; +const sourceOf = (config: FeatureConfig, key: string): TagSource => { + const entry = config.versions[key]; if (entry === undefined) { const available = Object.keys(config.versions).join(", "); - throw new Error(`Version ${version} is not tracked (available: ${available})`); + throw new Error(`Version ${key} is not tracked (available: ${available})`); } - return { version, revision: config.revision, aliases: entry.aliases }; + return { key, revision: config.revision, aliases: config.aliases[key] }; }; -export const tagNames = (config: FeatureConfig, version: string, channel: string): string[] => - baseTagNames(sourceOf(config, version), channel); - -export const canonicalTag = (config: FeatureConfig, version: string, channel: string): string => - baseCanonicalTag(sourceOf(config, version), channel); +export const tagNames = (config: FeatureConfig, key: string, channel: string): TagSet => + baseTagNames(sourceOf(config, key), channel); export const imageRef = (prefix: string, tag: string): string => `${prefix}/ripgrep:${tag}`; diff --git a/scripts/tasks/features/tags.ts b/scripts/tasks/features/tags.ts index 1a44074..29a55e4 100644 --- a/scripts/tasks/features/tags.ts +++ b/scripts/tasks/features/tags.ts @@ -1,14 +1,19 @@ import { getRemoteInfo } from "@/scripts/git.js"; +import { tagPattern } from "@/scripts/tasks/features/config.js"; export const mainChannel = "main"; export interface TagSource { - version: string; + key: string; revision: number; aliases: readonly string[]; } -const tagPattern = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; +export interface TagSet { + canonical: string; + claims: readonly string[]; + all: readonly string[]; +} const channelSuffix = (channel: string): string => { const suffix = channel.replaceAll(/[^a-zA-Z0-9._-]/gu, "-").replaceAll(/^[-.]+|[-.]+$/gu, ""); @@ -19,8 +24,8 @@ const channelSuffix = (channel: string): string => { }; const mainTagNames = (source: TagSource): readonly string[] => [ - `${source.version}-r${source.revision}`, - source.version, + `${source.key}-r${source.revision}`, + source.key, ...source.aliases, ]; @@ -39,20 +44,18 @@ const channelTagNames = (names: readonly string[], channel: string): readonly st }); }; -export const tagNames = (source: TagSource, channel: string): string[] => { - const names = channelTagNames(mainTagNames(source), channel); - return names.map((tag) => { +export const tagNames = (source: TagSource, channel: string): TagSet => { + const names = channelTagNames(mainTagNames(source), channel).map((tag) => { if (!tagPattern.test(tag)) { throw new Error(`Channel ${channel}: derived tag ${tag} is not a valid registry tag`); } return tag; }); + const [canonical, ...claims] = names; + return { canonical, claims, all: names }; }; -export const canonicalTag = (source: TagSource, channel: string): string => { - const [canonical] = tagNames(source, channel); - return canonical; -}; +export const canonicalTag = (source: TagSource, channel: string): string => tagNames(source, channel).canonical; export const defaultRefPrefix = async (): Promise => { const { owner, repo } = await getRemoteInfo(); diff --git a/scripts/tasks/features/uv/buildContext.ts b/scripts/tasks/features/uv/buildContext.ts index 4b1554a..4b2efeb 100644 --- a/scripts/tasks/features/uv/buildContext.ts +++ b/scripts/tasks/features/uv/buildContext.ts @@ -10,11 +10,11 @@ import { paths } from "./paths.js"; export const assembleBuildContext = async ( component: Component, - version: string, + key: string, arches: readonly DockerArch[], featureBinaries: Partial>, ): Promise => { - const context = paths.contextPath(component, version); + const context = paths.contextPath(component, key); await rm(context, { recursive: true, force: true }); await mkdir(path.join(context, "docker"), { recursive: true }); await copyFile(path.join(projectRoot, "features/uv/Dockerfile"), path.join(context, "docker", "Dockerfile")); @@ -26,13 +26,13 @@ export const assembleBuildContext = async ( throw new Error(`No Feature binary for ${arch}`); } await mkdir(path.join(stagedRoot, "uv"), { recursive: true }); - await linkTree(paths.payloadPath(component, version, arch), path.join(stagedRoot, "uv")); + await linkTree(paths.payloadPath(component, key, arch), path.join(stagedRoot, "uv")); await copyFile(path.join(projectRoot, "features/uv/install"), path.join(stagedRoot, "install")); await copyFile(path.join(projectRoot, "features/uv/onCreate"), path.join(stagedRoot, "onCreate")); await copyFile(featureBinary, path.join(stagedRoot, "Feature")); } else { await mkdir(path.join(stagedRoot, "python"), { recursive: true }); - await linkTree(paths.payloadPath(component, version, arch), path.join(stagedRoot, "python")); + await linkTree(paths.payloadPath(component, key, arch), path.join(stagedRoot, "python")); } } return context; diff --git a/scripts/tasks/features/uv/buildImage.ts b/scripts/tasks/features/uv/buildImage.ts index f5c4b77..ad9ccd7 100644 --- a/scripts/tasks/features/uv/buildImage.ts +++ b/scripts/tasks/features/uv/buildImage.ts @@ -10,7 +10,7 @@ import { parseInvocation } from "./invocation.js"; import { ensureFeatureBinary } from "./packBinary.js"; import type { DockerArch } from "./paths.js"; import { paths } from "./paths.js"; -import { payloadPresent } from "./payload.js"; +import { derive, payloadPresent, sectionOf } from "./payload.js"; import { imageRefs, tagNames } from "./tags.js"; const main = async (): Promise => { @@ -26,7 +26,7 @@ const main = async (): Promise => { }, }); - const { component, version, arch } = parseInvocation(values); + const { component, key, arch } = parseInvocation(values); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); if (arch !== paths.hostArch()) { @@ -39,15 +39,17 @@ const main = async (): Promise => { if (component === "uv") { featureBinaries[arch] = await ensureFeatureBinary(paths.ridFor(arch), values.force); } - if (!(await payloadPresent(component, version, arch))) { + const config = await readConfig(); + const payload = derive(sectionOf(config, component, key), arch); + if (!(await payloadPresent(payload))) { throw new Error( - `No payload at ${paths.payloadPath(component, version, arch)}; run scripts/tasks/features/uv/fetchPayload.ts --component ${component} --version ${version} --arch ${arch} first`, + `No payload at ${payload.cache}; run scripts/tasks/features/uv/fetchPayload.ts --component ${component} --version ${key} --arch ${arch} first`, ); } - const context = await assembleBuildContext(component, version, [arch], featureBinaries); - const config = await readConfig(); - const refs = [...imageRefs(prefix, component, tagNames(config, component, version, channel)), ...(values.tag ?? [])]; + const context = await assembleBuildContext(component, key, [arch], featureBinaries); + const tags = tagNames(config, component, key, channel); + const refs = [...imageRefs(prefix, component, tags.all), ...(values.tag ?? [])]; await $$`docker buildx build ${[ ...["--platform", `linux/${arch}`], "--load", diff --git a/scripts/tasks/features/uv/fetchPayload.ts b/scripts/tasks/features/uv/fetchPayload.ts index e891c8e..cfe297c 100644 --- a/scripts/tasks/features/uv/fetchPayload.ts +++ b/scripts/tasks/features/uv/fetchPayload.ts @@ -1,7 +1,7 @@ import { parseArgs } from "node:util"; import { parseInvocation } from "./invocation.js"; -import { ensurePayload } from "./payload.js"; +import { ensurePayload, loadSection } from "./payload.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -13,8 +13,8 @@ const main = async (): Promise => { }, }); - const { component, version, arch } = parseInvocation(values); - await ensurePayload(component, version, arch, values.force); + const { component, key, arch } = parseInvocation(values); + await ensurePayload(await loadSection(component, key), arch, values.force); }; if (import.meta.main) { diff --git a/scripts/tasks/features/uv/generateConfig.ts b/scripts/tasks/features/uv/generateConfig.ts index 2f0f8b9..9b93d1b 100644 --- a/scripts/tasks/features/uv/generateConfig.ts +++ b/scripts/tasks/features/uv/generateConfig.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" }; import { projectRoot } from "@/scripts/project.js"; +import { checkTagNamespace } from "@/scripts/tasks/features/config.js"; import { verifyAttestation } from "./attestations.js"; import type { DockerArch } from "./paths.js"; @@ -16,6 +17,7 @@ const pythonIndexUrl = "https://raw.githubusercontent.com/astral-sh/versions/mai const uvSlug = "astral-sh/uv"; const pythonSlug = "astral-sh/python-build-standalone"; const stablePattern = /^\d+\.\d+\.\d+$/u; +const buildPattern = /^\d+$/u; export const configPath = path.resolve(projectRoot, "features/uv/config.json"); @@ -38,26 +40,37 @@ const indexReleaseSchema = z.object({ artifacts: z.array(indexArtifactSchema), }); -const artifactSchema = z.object({ url: z.string(), sha256: z.string() }); +const artifactSchema = z.strictObject({ url: z.string(), sha256: z.string() }); -const uvVersionSchema = z.object({ - aliases: z.array(z.string()), +const uvVersionSchema = z.strictObject({ "linux-x64": artifactSchema, "linux-arm64": artifactSchema, }); -const pythonVersionSchema = z.object({ - aliases: z.array(z.string()), +const pythonVersionSchema = z.strictObject({ + pythonVersion: z.string(), build: z.string(), "linux-x64": artifactSchema, "linux-arm64": artifactSchema, }); -export const featureConfigSchema = z.object({ +const aliasTableSchema = z.record(z.string(), z.array(z.string())); + +const uvComponentSchema = z.strictObject({ + versions: z.record(z.string(), uvVersionSchema), + aliases: aliasTableSchema, +}); + +const pythonComponentSchema = z.strictObject({ + versions: z.record(z.string(), pythonVersionSchema), + aliases: aliasTableSchema, +}); + +export const featureConfigSchema = z.strictObject({ revision: z.number(), - components: z.object({ - uv: z.object({ versions: z.record(z.string(), uvVersionSchema) }), - python: z.object({ versions: z.record(z.string(), pythonVersionSchema) }), + components: z.strictObject({ + uv: uvComponentSchema, + python: pythonComponentSchema, }), }); @@ -124,17 +137,9 @@ const trackedUvRelease = (releases: readonly IndexRelease[]): IndexRelease => { return release; }; -const uvVersionEntry = async (release: IndexRelease): Promise => { - const [major, minor] = release.version.split("."); - return { - aliases: [`${major}.${minor}`, "latest"], - ...(await resolveArtifacts(uvSlug, release.version, release.artifacts, paths.muslTriples, "default")), - }; -}; - const pythonReleaseParts = (release: IndexRelease): { version: string; build: string } => { const [version, build, ...rest] = release.version.split("+"); - if (version === undefined || build === undefined || rest.length > 0 || !/^\d+$/u.test(build)) { + if (version === undefined || build === undefined || rest.length > 0 || !buildPattern.test(build)) { throw new Error(`Version index entry ${release.version} is not +`); } return { version, build }; @@ -169,26 +174,53 @@ const compareVersions = (left: string, right: string): number => { return 0; }; -const pythonVersions = async (releases: readonly IndexRelease[]): Promise> => { +const pythonVersions = async (releases: readonly IndexRelease[]): Promise => { const { build, entries } = trackedPythonReleases(releases); const resolved = await Promise.all( entries.map(async (release) => { const { version } = pythonReleaseParts(release); const [major, minor] = version.split("."); + const key = `${version}-${build}`; const entry: PythonVersion = { - aliases: [`${major}.${minor}`], + pythonVersion: version, build, ...(await resolveArtifacts(pythonSlug, version, release.artifacts, paths.gnuTriples, "install_only_stripped")), }; - return [version, entry] as const; + return { key, version, aliases: [`${major}.${minor}`, version], entry } as const; }), ); - resolved.sort(([left], [right]) => compareVersions(left, right)); + resolved.sort((left, right) => compareVersions(left.version, right.version)); const versions: Record = {}; - resolved.forEach(([version, entry], index) => { - versions[version] = index === resolved.length - 1 ? { ...entry, aliases: [...entry.aliases, "latest"] } : entry; - }); - return versions; + const aliases: Record = {}; + for (const [index, { key, aliases: row, entry }] of resolved.entries()) { + versions[key] = entry; + aliases[key] = index === resolved.length - 1 ? [...row, "latest"] : [...row]; + } + return { versions, aliases }; +}; + +type Components = FeatureConfig["components"]; + +const validatePython = (config: FeatureConfig, source: string): void => { + for (const [key, entry] of Object.entries(config.components.python.versions)) { + const expected = `${entry.pythonVersion}-${entry.build}`; + if (key !== expected) { + throw new Error(`${source}: python version key ${key} is not - (${expected})`); + } + } +}; + +const validateConfig = (config: FeatureConfig, source: string): void => { + for (const component of components) { + checkTagNamespace({ + source: `${source} component ${component}`, + revision: config.revision, + versions: config.components[component].versions, + aliases: config.components[component].aliases, + requiredClaims: ["latest"], + }); + } + validatePython(config, `${source} component python`); }; export const parseConfig = (text: string, source: string): FeatureConfig => { @@ -196,6 +228,7 @@ export const parseConfig = (text: string, source: string): FeatureConfig => { if (!result.success) { throw new Error(`${source}: ${z.prettifyError(result.error)}`); } + validateConfig(result.data, source); return result.data; }; @@ -216,13 +249,26 @@ const readRevision = async (): Promise => { export const generateConfig = async (): Promise => { const uvRelease = trackedUvRelease(parseIndex(await fetchText(uvIndexUrl), uvIndexUrl)); const pythonReleases = parseIndex(await fetchText(pythonIndexUrl), pythonIndexUrl); + const [major, minor] = uvRelease.version.split("."); const config: FeatureConfig = { revision: await readRevision(), components: { - uv: { versions: { [uvRelease.version]: await uvVersionEntry(uvRelease) } }, - python: { versions: await pythonVersions(pythonReleases) }, + uv: { + versions: { + [uvRelease.version]: await resolveArtifacts( + uvSlug, + uvRelease.version, + uvRelease.artifacts, + paths.muslTriples, + "default", + ), + }, + aliases: { [uvRelease.version]: [`${major}.${minor}`, "latest"] }, + }, + python: await pythonVersions(pythonReleases), }, }; + validateConfig(config, configPath); await writeFile( configPath, await prettier.format(JSON.stringify(config), { ...prettierOptions, filepath: configPath }), diff --git a/scripts/tasks/features/uv/invocation.ts b/scripts/tasks/features/uv/invocation.ts index 1e22ccb..4766be0 100644 --- a/scripts/tasks/features/uv/invocation.ts +++ b/scripts/tasks/features/uv/invocation.ts @@ -5,7 +5,7 @@ import { paths } from "./paths.js"; export interface Invocation { component: Component; - version: string; + key: string; arch: DockerArch; } @@ -17,7 +17,7 @@ export const parseComponent = (value: string | undefined): Component => { return component; }; -export const parseVersion = (value: string | undefined): string => { +export const parseKey = (value: string | undefined): string => { if (value === undefined || value === "") { throw new Error("--version is required"); } @@ -26,7 +26,7 @@ export const parseVersion = (value: string | undefined): string => { export const parseInvocation = (values: { component?: string; version?: string; arch?: string }): Invocation => { const component = parseComponent(values.component); - const version = parseVersion(values.version); + const key = parseKey(values.version); const arch = values.arch === undefined ? paths.hostArch() @@ -34,5 +34,5 @@ export const parseInvocation = (values: { component?: string; version?: string; if (arch === undefined) { throw new Error(`--arch must be one of: ${paths.dockerArchOptions.join(", ")}`); } - return { component, version, arch }; + return { component, key, arch }; }; diff --git a/scripts/tasks/features/uv/payload.ts b/scripts/tasks/features/uv/payload.ts index f172c76..06b9c9d 100644 --- a/scripts/tasks/features/uv/payload.ts +++ b/scripts/tasks/features/uv/payload.ts @@ -4,22 +4,51 @@ import path from "node:path"; import { $$ } from "@/scripts/shell.js"; import { download } from "@/scripts/tasks/features/download.js"; -import type { Component, PythonVersion, UvVersion } from "./generateConfig.js"; +import type { Artifact, Component, FeatureConfig, PythonVersion, UvVersion } from "./generateConfig.js"; import { configPath, readConfig } from "./generateConfig.js"; import type { DockerArch } from "./paths.js"; import { paths } from "./paths.js"; const pythonUrlPrefix = "https://github.com/astral-sh/python-build-standalone/releases/download/"; const uvBinaries = ["uv", "uvx"] as const; +const pythonVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/u; -interface PythonArchive { +export interface UvSection { + component: "uv"; + key: string; + entry: UvVersion; +} + +export interface PythonSection { + component: "python"; + key: string; + entry: PythonVersion; +} + +export type PayloadSection = UvSection | PythonSection; + +export interface Sections { + uv: UvSection; + python: PythonSection; +} + +export interface UvPayload { + component: "uv"; + key: string; + cache: string; + present: string; + triple: string; + archive: Artifact; +} + +export interface PythonArchive { build: string; name: string; url: string; sha256: string; } -interface PythonDownloadEntry { +export interface PythonDownloadEntry { name: string; arch: { family: string; variant: null }; os: string; @@ -34,16 +63,50 @@ interface PythonDownloadEntry { build: string; } -const versionPin = (versions: Record, version: string): Entry => { - const entry = versions[version]; +export interface PythonPayload { + component: "python"; + key: string; + pythonVersion: string; + build: string; + cache: string; + present: string; + archive: PythonArchive; + archivePath: string; + entryKey: string; + entryPath: string; + entry: PythonDownloadEntry; +} + +export type Payload = UvPayload | PythonPayload; + +const sectionOfUntyped = (config: FeatureConfig, component: Component, key: string): PayloadSection => { + if (component === "uv") { + const entry = config.components.uv.versions[key]; + if (entry === undefined) { + throw new Error(`${component} version ${key} is not in ${configPath}`); + } + return { component, key, entry }; + } + const entry = config.components.python.versions[key]; if (entry === undefined) { - throw new Error(`Version ${version} is not in ${configPath}`); + throw new Error(`${component} version ${key} is not in ${configPath}`); } - return entry; + return { component, key, entry }; }; -const pythonArchive = (pin: PythonVersion, arch: DockerArch): PythonArchive => { - const { url, sha256 } = pin[paths.ridFor(arch)]; +export const sectionOf = ( + config: FeatureConfig, + component: ComponentT, + key: string, +): Sections[ComponentT] => sectionOfUntyped(config, component, key) as Sections[ComponentT]; + +export const loadSection = async ( + component: ComponentT, + key: string, +): Promise => sectionOf(await readConfig(), component, key); + +const pythonArchive = (section: PythonSection, arch: DockerArch): PythonArchive => { + const { url, sha256 } = section.entry[paths.ridFor(arch)]; if (!url.startsWith(pythonUrlPrefix)) { throw new Error(`${url}: a python archive url must start with ${pythonUrlPrefix}`); } @@ -51,24 +114,24 @@ const pythonArchive = (pin: PythonVersion, arch: DockerArch): PythonArchive => { if (build === undefined || encodedName === undefined || rest.length > 0) { throw new Error(`${url}: a python archive url must be //`); } - if (build !== pin.build) { - throw new Error(`${url}: the url build ${build} does not match the configured ${pin.build}`); + if (build !== section.entry.build) { + throw new Error(`${url}: the url build ${build} does not match the configured ${section.entry.build}`); } return { build, name: decodeURIComponent(encodedName), url, sha256 }; }; const pythonEntry = ( - version: string, + section: PythonSection, arch: DockerArch, archive: PythonArchive, ): { key: string; entry: PythonDownloadEntry } => { - const match = /^(\d+)\.(\d+)\.(\d+)$/u.exec(version); + const match = pythonVersionPattern.exec(section.entry.pythonVersion); if (match === null) { - throw new Error(`Version ${version} is not ..`); + throw new Error(`Version ${section.key} is not ..`); } const [, major, minor, patch] = match; const machine = paths.machineOf(arch); - const key = `cpython-${version}-linux-${machine}-gnu`; + const key = `cpython-${section.entry.pythonVersion}-linux-${machine}-gnu`; const entry: PythonDownloadEntry = { name: "cpython", arch: { family: machine, variant: null }, @@ -86,11 +149,37 @@ const pythonEntry = ( return { key, entry }; }; -const uvPayloadPresent = (version: string, arch: DockerArch): Promise => - paths.isFile(path.join(paths.payloadPath("uv", version, arch), "uv")); +export const derive = (section: PayloadSection, arch: DockerArch): Payload => { + const cache = paths.payloadPath(section.component, section.key, arch); + if (section.component === "uv") { + return { + component: "uv", + key: section.key, + cache, + present: path.join(cache, "uv"), + triple: paths.muslTriples[arch], + archive: section.entry[paths.ridFor(arch)], + }; + } + const archive = pythonArchive(section, arch); + const { key: entryKey, entry } = pythonEntry(section, arch, archive); + const archivePath = path.join(cache, archive.build, archive.name); + return { + component: "python", + key: section.key, + pythonVersion: section.entry.pythonVersion, + build: archive.build, + cache, + present: archivePath, + archive, + archivePath, + entryKey, + entryPath: path.join(cache, "entries", `${entryKey}.json`), + entry, + }; +}; -const pythonArchivePath = (version: string, arch: DockerArch, archive: PythonArchive): string => - path.join(paths.payloadPath("python", version, arch), archive.build, archive.name); +export const payloadPresent = (payload: Payload): Promise => paths.isFile(payload.present); export interface PythonPayloadPaths { archive: string; @@ -99,46 +188,30 @@ export interface PythonPayloadPaths { name: string; } -export const pythonPayloadPaths = async (version: string, arch: DockerArch): Promise => { - const config = await readConfig(); - const pin = versionPin(config.components.python.versions, version); - const archive = pythonArchive(pin, arch); - const { key } = pythonEntry(version, arch, archive); +export const pythonPayloadPaths = (section: PythonSection, arch: DockerArch): PythonPayloadPaths => { + const payload = derive(section, arch); + if (payload.component !== "python") { + throw new Error(`Version ${section.key} is not a python payload`); + } return { - archive: pythonArchivePath(version, arch, archive), - entry: path.join(paths.payloadPath("python", version, arch), "entries", `${key}.json`), - build: archive.build, - name: archive.name, + archive: payload.archivePath, + entry: payload.entryPath, + build: payload.build, + name: payload.archive.name, }; }; -export const payloadPresent = async (component: Component, version: string, arch: DockerArch): Promise => { - const config = await readConfig(); - if (component === "uv") { - versionPin(config.components.uv.versions, version); - return uvPayloadPresent(version, arch); - } - const pin = versionPin(config.components.python.versions, version); - return paths.isFile(pythonArchivePath(version, arch, pythonArchive(pin, arch))); -}; - -const ensureUvPayload = async (version: string, arch: DockerArch, pin: UvVersion, force: boolean): Promise => { - const outPath = paths.payloadPath("uv", version, arch); - if (!force && (await uvPayloadPresent(version, arch))) { - console.log(`Payload already present at ${outPath}; skipping fetch`); - return; - } - const tempPath = `${outPath}.tmp`; +const fetchUvPayload = async (payload: UvPayload): Promise => { + const tempPath = `${payload.cache}.tmp`; await rm(tempPath, { recursive: true, force: true }); const archivePath = path.join(tempPath, "archive.tar.gz"); const extractedPath = path.join(tempPath, "extracted"); await mkdir(extractedPath, { recursive: true }); - const { url, sha256 } = pin[paths.ridFor(arch)]; - const actual = await download(url, archivePath); - if (actual !== sha256) { - throw new Error(`${url}: sha256 ${actual} does not match the configured ${sha256}`); + const actual = await download(payload.archive.url, archivePath); + if (actual !== payload.archive.sha256) { + throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`); } - const top = `uv-${paths.muslTriples[arch]}`; + const top = `uv-${payload.triple}`; await $$`tar ${[ "-xzf", archivePath, @@ -149,54 +222,41 @@ const ensureUvPayload = async (version: string, arch: DockerArch, pin: UvVersion ]}`; for (const binary of uvBinaries) { if (!(await paths.isFile(path.join(extractedPath, binary)))) { - throw new Error(`${url}: the archive has no ${binary}`); + throw new Error(`${payload.archive.url}: the archive has no ${binary}`); } } - await rm(outPath, { recursive: true, force: true }); - await rename(extractedPath, outPath); + await rm(payload.cache, { recursive: true, force: true }); + await rename(extractedPath, payload.cache); await rm(tempPath, { recursive: true, force: true }); - console.log(`Payload written to ${outPath}`); + console.log(`Payload written to ${payload.cache}`); }; -const ensurePythonPayload = async ( - version: string, - arch: DockerArch, - pin: PythonVersion, - force: boolean, -): Promise => { - const outPath = paths.payloadPath("python", version, arch); - const archive = pythonArchive(pin, arch); - if (!force && (await paths.isFile(pythonArchivePath(version, arch, archive)))) { - console.log(`Payload already present at ${outPath}; skipping fetch`); - return; - } - const tempPath = `${outPath}.tmp`; +const fetchPythonPayload = async (payload: PythonPayload): Promise => { + const tempPath = `${payload.cache}.tmp`; await rm(tempPath, { recursive: true, force: true }); - const downloadPath = path.join(tempPath, archive.build, archive.name); + const downloadPath = path.join(tempPath, payload.build, payload.archive.name); await mkdir(path.dirname(downloadPath), { recursive: true }); - const actual = await download(archive.url, downloadPath); - if (actual !== archive.sha256) { - throw new Error(`${archive.url}: sha256 ${actual} does not match the configured ${archive.sha256}`); + const actual = await download(payload.archive.url, downloadPath); + if (actual !== payload.archive.sha256) { + throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`); } - const { key, entry } = pythonEntry(version, arch, archive); - const entriesPath = path.join(tempPath, "entries"); - await mkdir(entriesPath, { recursive: true }); - await writeFile(path.join(entriesPath, `${key}.json`), `${JSON.stringify({ [key]: entry }, null, 2)}\n`); - await rm(outPath, { recursive: true, force: true }); - await rename(tempPath, outPath); - console.log(`Payload written to ${outPath}`); + const entryPath = path.join(tempPath, "entries", `${payload.entryKey}.json`); + await mkdir(path.dirname(entryPath), { recursive: true }); + await writeFile(entryPath, `${JSON.stringify({ [payload.entryKey]: payload.entry }, null, 2)}\n`); + await rm(payload.cache, { recursive: true, force: true }); + await rename(tempPath, payload.cache); + console.log(`Payload written to ${payload.cache}`); }; -export const ensurePayload = async ( - component: Component, - version: string, - arch: DockerArch, - force: boolean, -): Promise => { - const config = await readConfig(); - if (component === "uv") { - await ensureUvPayload(version, arch, versionPin(config.components.uv.versions, version), force); +export const ensurePayload = async (section: PayloadSection, arch: DockerArch, force: boolean): Promise => { + const payload = derive(section, arch); + if (!force && (await payloadPresent(payload))) { + console.log(`Payload already present at ${payload.cache}; skipping fetch`); + return; + } + if (payload.component === "uv") { + await fetchUvPayload(payload); } else { - await ensurePythonPayload(version, arch, versionPin(config.components.python.versions, version), force); + await fetchPythonPayload(payload); } }; diff --git a/scripts/tasks/features/uv/planPublish.ts b/scripts/tasks/features/uv/planPublish.ts index 19119f9..c0d292f 100644 --- a/scripts/tasks/features/uv/planPublish.ts +++ b/scripts/tasks/features/uv/planPublish.ts @@ -4,7 +4,7 @@ import { planPublish } from "@/scripts/tasks/features/planPublish.js"; import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; import { components, readConfig } from "./generateConfig.js"; -import { canonicalTag, imageRef, tagNames } from "./tags.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -19,15 +19,18 @@ const main = async (): Promise => { 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 })), + Object.keys(config.components[component].versions).map((key) => ({ component, key })), ); await planPublish( - pairs.map(({ component, version }) => ({ - label: `${component} ${version}`, - canonical: imageRef(prefix, component, canonicalTag(config, component, version, channel)), - tags: tagNames(config, component, version, channel), - entry: { component, version }, - })), + pairs.map(({ component, key }) => { + const tags = tagNames(config, component, key, channel); + return { + label: `${component} ${key}`, + canonical: imageRef(prefix, component, tags.canonical), + tags: tags.all, + entry: { component, key }, + }; + }), { channel, prefix, out: values.out }, ); }; diff --git a/scripts/tasks/features/uv/publishImage.ts b/scripts/tasks/features/uv/publishImage.ts index 3e71321..86da99b 100644 --- a/scripts/tasks/features/uv/publishImage.ts +++ b/scripts/tasks/features/uv/publishImage.ts @@ -7,10 +7,10 @@ import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js" import { assembleBuildContext } from "./buildContext.js"; import { resolveFeatureBinaries } from "./featureBinary.js"; import { readConfig } from "./generateConfig.js"; -import { parseComponent, parseVersion } from "./invocation.js"; +import { parseComponent, parseKey } from "./invocation.js"; import { paths } from "./paths.js"; -import { ensurePayload } from "./payload.js"; -import { imageRefs, tagNames } from "./tags.js"; +import { ensurePayload, sectionOf } from "./payload.js"; +import { imageRef, tagNames } from "./tags.js"; const main = async (): Promise => { const { values } = parseArgs({ @@ -24,18 +24,19 @@ const main = async (): Promise => { }); const component = parseComponent(values.component); - const version = parseVersion(values.version); + const key = parseKey(values.version); const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); const config = await readConfig(); - const tags = tagNames(config, component, version, channel); + const section = sectionOf(config, component, key); + const canonical = tagNames(config, component, key, channel).canonical; const featureBinaries = component === "uv" ? await resolveFeatureBinaries(values["install-dir"]) : {}; for (const arch of paths.dockerArchOptions) { - await ensurePayload(component, version, arch, false); + await ensurePayload(section, arch, false); } - const context = await assembleBuildContext(component, version, paths.dockerArchOptions, featureBinaries); + const context = await assembleBuildContext(component, key, paths.dockerArchOptions, featureBinaries); await login(prefix); - const refs = imageRefs(prefix, component, tags); + const refs = [imageRef(prefix, component, canonical)]; await pushImage(context, refs); console.log(refs.join("\n")); }; diff --git a/scripts/tasks/features/uv/releaseTags.ts b/scripts/tasks/features/uv/releaseTags.ts new file mode 100644 index 0000000..6cc080a --- /dev/null +++ b/scripts/tasks/features/uv/releaseTags.ts @@ -0,0 +1,53 @@ +import { parseArgs } from "node:util"; + +import type { ReleaseEntry } from "@/scripts/tasks/features/release.js"; +import { releaseTags } from "@/scripts/tasks/features/release.js"; +import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; +import { defaultRefPrefix, mainChannel } from "@/scripts/tasks/features/tags.js"; + +import { components, readConfig } from "./generateConfig.js"; +import { imageRef, imageRefs, tagNames } from "./tags.js"; + +interface FeatureReleaseEntry extends ReleaseEntry { + gitTag: string; +} + +const main = async (): Promise => { + const { values } = parseArgs({ + options: { + channel: { type: "string", default: mainChannel }, + prefix: { type: "string" }, + "git-tags": { type: "boolean", default: false }, + }, + }); + + const channel = values.channel; + const prefix = values.prefix ?? (await defaultRefPrefix()); + const config = await readConfig(); + const entries: FeatureReleaseEntry[] = components.flatMap((component) => + Object.keys(config.components[component].versions).map((key) => { + const tags = tagNames(config, component, key, channel); + return { + label: `${component} ${key}`, + canonical: imageRef(prefix, component, tags.canonical), + tags: imageRefs(prefix, component, tags.claims), + gitTag: `features-uv-${component}-${tagNames(config, component, key, mainChannel).canonical}`, + }; + }), + ); + await releaseTags(entries, prefix); + if (values["git-tags"]) { + for (const { gitTag } of entries) { + await tagPublished(gitTag); + } + } +}; + +if (import.meta.main) { + try { + await main(); + } catch (error) { + console.error(error); + process.exitCode = 1; + } +} diff --git a/scripts/tasks/features/uv/renovate.ts b/scripts/tasks/features/uv/renovate.ts index e389867..cf4cbca 100644 --- a/scripts/tasks/features/uv/renovate.ts +++ b/scripts/tasks/features/uv/renovate.ts @@ -2,68 +2,20 @@ import path from "node:path"; import { parseArgs } from "node:util"; import { projectRoot } from "@/scripts/project.js"; -import { headFile, refresh } from "@/scripts/tasks/features/renovate.js"; +import type { ConfigDocument } from "@/scripts/tasks/features/renovate.js"; +import { refreshFeatureConfig } from "@/scripts/tasks/features/renovate.js"; -import type { Artifact, FeatureConfig } from "./generateConfig.js"; -import { components, configPath, generateConfig, parseConfig, readConfig, rids } from "./generateConfig.js"; +import type { FeatureConfig } from "./generateConfig.js"; +import { components, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js"; -const title = "Update uv version pins"; -const configFilepath = path.relative(projectRoot, configPath); - -interface Pins { - aliases: string[]; - build?: string; - "linux-x64": Artifact; - "linux-arm64": Artifact; -} - -const versionChanges = (label: string, before: Pins, after: Pins): string[] => { - const changes: string[] = []; - if (before.aliases.join(", ") !== after.aliases.join(", ")) { - changes.push(`${label}: aliases ${before.aliases.join(", ")} → ${after.aliases.join(", ")}`); - } - if (before.build !== after.build) { - changes.push(`${label}: build ${before.build ?? "-"} → ${after.build ?? "-"}`); - } - for (const rid of rids) { - if (before[rid].url !== after[rid].url) { - changes.push(`${label}: ${rid} url ${before[rid].url} → ${after[rid].url}`); - } - if (before[rid].sha256 !== after[rid].sha256) { - changes.push(`${label}: ${rid} sha256 ${before[rid].sha256} → ${after[rid].sha256}`); - } - } - return changes; -}; - -const pinChanges = (previous: FeatureConfig | undefined, next: FeatureConfig): string[] => { - if (previous === undefined) { - return components.flatMap((component) => { - const versions = Object.keys(next.components[component].versions); - return versions.length === 0 ? [] : [`${component}: added ${versions.join(", ")}`]; - }); - } - return components.flatMap((component) => { - const before = previous.components[component].versions; - const after = next.components[component].versions; - return [ - ...Object.keys(before) - .filter((version) => !(version in after)) - .map((version) => `${component}: removed ${version}`), - ...Object.keys(after) - .filter((version) => !(version in before)) - .map((version) => `${component}: added ${version}`), - ...Object.keys(before) - .filter((version) => version in after) - .flatMap((version) => versionChanges(`${component} ${version}`, before[version], after[version])), - ]; - }); -}; - -const headConfig = async (oid: string): Promise => { - const text = await headFile(oid, configFilepath); - return text === undefined ? undefined : parseConfig(text, `${configFilepath} at HEAD`); -}; +const document = (config: FeatureConfig): ConfigDocument => ({ + revision: config.revision, + namespaces: components.map((component) => ({ + name: component, + versions: config.components[component].versions, + aliases: config.components[component].aliases, + })), +}); const main = async (): Promise => { const { values } = parseArgs({ @@ -72,19 +24,15 @@ const main = async (): Promise => { }, }); - await generateConfig(); - await refresh( + await refreshFeatureConfig( { - branch: "renovate/uv", - title, - filepath: configFilepath, - body: async (headOid) => { - const changes = pinChanges(await headConfig(headOid), await readConfig()); - if (changes.length === 0) { - throw new Error(`${configFilepath} changed but no version, alias, build or artifact change was found`); - } - return changes.join("\n"); - }, + branch: "renovate-uv", + title: "Update uv version pins", + filepath: path.relative(projectRoot, configPath), + generate: generateConfig, + read: readConfig, + parse: parseConfig, + document, }, values["dry-run"], ); diff --git a/scripts/tasks/features/uv/tagPublished.ts b/scripts/tasks/features/uv/tagPublished.ts deleted file mode 100644 index 7a8f28e..0000000 --- a/scripts/tasks/features/uv/tagPublished.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { parseArgs } from "node:util"; - -import { tagPublished } from "@/scripts/tasks/features/tagPublished.js"; -import { mainChannel } from "@/scripts/tasks/features/tags.js"; - -import { readConfig } from "./generateConfig.js"; -import { parseComponent, parseVersion } from "./invocation.js"; -import { canonicalTag } from "./tags.js"; - -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 config = await readConfig(); - await tagPublished(`features-uv-${component}-${canonicalTag(config, component, version, mainChannel)}`); -}; - -if (import.meta.main) { - try { - await main(); - } catch (error) { - console.error(error); - process.exitCode = 1; - } -} diff --git a/scripts/tasks/features/uv/tags.ts b/scripts/tasks/features/uv/tags.ts index 8668b5c..a70306f 100644 --- a/scripts/tasks/features/uv/tags.ts +++ b/scripts/tasks/features/uv/tags.ts @@ -1,24 +1,21 @@ -import type { TagSource } from "@/scripts/tasks/features/tags.js"; -import { canonicalTag as baseCanonicalTag, tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; +import type { TagSet, TagSource } from "@/scripts/tasks/features/tags.js"; +import { tagNames as baseTagNames } from "@/scripts/tasks/features/tags.js"; import type { Component, FeatureConfig } from "./generateConfig.js"; const repositories: Record = { uv: "uv", python: "uv/python" }; -const sourceOf = (config: FeatureConfig, component: Component, version: string): TagSource => { - const entry = config.components[component].versions[version]; +const sourceOf = (config: FeatureConfig, component: Component, key: string): TagSource => { + const entry = config.components[component].versions[key]; if (entry === undefined) { const available = Object.keys(config.components[component].versions).join(", "); - throw new Error(`Component ${component} has no version ${version} (available: ${available})`); + throw new Error(`Component ${component} has no version ${key} (available: ${available})`); } - return { version, revision: config.revision, aliases: entry.aliases }; + return { key, revision: config.revision, aliases: config.components[component].aliases[key] }; }; -export const tagNames = (config: FeatureConfig, component: Component, version: string, channel: string): string[] => - baseTagNames(sourceOf(config, component, version), channel); - -export const canonicalTag = (config: FeatureConfig, component: Component, version: string, channel: string): string => - baseCanonicalTag(sourceOf(config, component, version), channel); +export const tagNames = (config: FeatureConfig, component: Component, key: string, channel: string): TagSet => + baseTagNames(sourceOf(config, component, key), channel); export const imageRef = (prefix: string, component: Component, tag: string): string => `${prefix}/${repositories[component]}:${tag}`; diff --git a/scripts/tasks/imageFeatures.ts b/scripts/tasks/imageFeatures.ts index c80d7e0..cdfedf6 100644 --- a/scripts/tasks/imageFeatures.ts +++ b/scripts/tasks/imageFeatures.ts @@ -135,7 +135,10 @@ const render = async (manifest: Manifest, notes: string, readmePath: string): Pr const { owner, repo } = await getRemoteInfo(); const url = `https://github.com/${owner}/${repo}/blob/main/features/${manifest.id}/feature.json`; const text = readmeTemplate - .replace("#{Name}", manifest.name === undefined ? manifest.id : `${manifest.name} (${manifest.id})`) + .replace( + "#{Name}", + manifest.name === manifest.id || manifest.name === undefined ? manifest.id : `${manifest.name} (${manifest.id})`, + ) .replace("#{Description}", manifest.description) .replace("#{Image}", manifest.image) .replaceAll("#{Id}", manifest.id) diff --git a/scripts/verify/dotnet/lifecycle.ts b/scripts/verify/dotnet/lifecycle.ts index 7164994..29cec88 100644 --- a/scripts/verify/dotnet/lifecycle.ts +++ b/scripts/verify/dotnet/lifecycle.ts @@ -80,14 +80,14 @@ export interface ProvisionOptions { } const provisionComponent = async ( - { component, version, ref }: ComponentImage, + { component, key, ref }: ComponentImage, options: ProvisionOptions, ): Promise => { if (options.images === "pull") { await project$$`docker pull ${ref}`; return; } - const args = [...["--component", component], ...["--version", version], ...(options.force ? ["--force"] : [])]; + const args = [...["--component", component], ...["--version", key], ...(options.force ? ["--force"] : [])]; await project$$`tsx scripts/tasks/features/dotnet/fetchPayload.ts ${args}`; await project$$`tsx scripts/tasks/features/dotnet/buildImage.ts ${[ ...args, diff --git a/scripts/verify/dotnet/scenarios.ts b/scripts/verify/dotnet/scenarios.ts index b2358bb..7a13508 100644 --- a/scripts/verify/dotnet/scenarios.ts +++ b/scripts/verify/dotnet/scenarios.ts @@ -160,7 +160,7 @@ const sdkVariants: readonly ScenarioVariantDefinition[] = [ const sdkBehavior = ({ selection, workspace }: ScenarioContext): ScenarioBehavior => ({ execs: [ - { command: ["dotnet", "--version"], expect: { stdout: [selection.components.sdk.version] } }, + { command: ["dotnet", "--version"], expect: { stdout: [selection.components.sdk.key] } }, { command: ["dotnet", "run", "--file", "hello.cs"], expect: { stdout: ["file-based-ok"] } }, { command: [ @@ -225,14 +225,14 @@ const multiBehavior = ({ selection }: ScenarioContext): ScenarioBehavior => ({ execs: [ { command: ["dotnet", "--list-sdks"], - expect: { stdout: [`${selection.components.sdk.version} [/opt/dotnet/sdk]`] }, + expect: { stdout: [`${selection.components.sdk.key} [/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]`, + `Microsoft.AspNetCore.App ${selection.components.aspnet.key} [/opt/dotnet/shared/Microsoft.AspNetCore.App]`, + `Microsoft.NETCore.App ${selection.components.runtime.key} [/opt/dotnet/shared/Microsoft.NETCore.App]`, ], sorted: true, }, @@ -256,7 +256,7 @@ const testImageOf = (name: ScenarioName, selection: Selection, variant?: string) throw new Error(`Scenario ${name} declares no components`); } const image = variant === undefined ? name : `${name}-${variant}`; - return { tag: `${testImagePrefix}:${image}-${label.version}`, components }; + return { tag: `${testImagePrefix}:${image}-${label.key}`, components }; }; const materializeVariant = ( diff --git a/scripts/verify/dotnet/selection.ts b/scripts/verify/dotnet/selection.ts index 5e01092..a2ff622 100644 --- a/scripts/verify/dotnet/selection.ts +++ b/scripts/verify/dotnet/selection.ts @@ -4,9 +4,10 @@ import path, { posix } from "node:path"; import { z } from "zod"; import { projectRoot } from "@/scripts/project.js"; +import { claimedKey } from "@/scripts/tasks/features/config.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"; +import { imageRef, tagNames } from "@/scripts/tasks/features/dotnet/tags.js"; import { remoteUser } from "./lifecycle.js"; @@ -19,7 +20,7 @@ const userInitFeatureSchema = z.object({ export interface ComponentImage { component: Component; - version: string; + key: string; ref: string; } @@ -29,23 +30,14 @@ export interface Selection { 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 key = claimedKey(config.components[component].aliases, "lts", `${configPath} component ${component}`); + return { component, key, ref: imageRef(prefix, component, tagNames(config, component, key, channel).canonical) }; }; const tfmOf = (sdkVersion: string): string => { @@ -78,7 +70,7 @@ const derive = async (channel: string, prefix: string): Promise => { runtime: componentImage(config, "runtime", channel, prefix), aspnet: componentImage(config, "aspnet", channel, prefix), }, - tfm: tfmOf(sdk.version), + tfm: tfmOf(sdk.key), globalPackagesPath: await readGlobalPackagesPath(), }; }; diff --git a/scripts/verify/dotnet/verify.ts b/scripts/verify/dotnet/verify.ts index 6258aa9..d9e56d4 100644 --- a/scripts/verify/dotnet/verify.ts +++ b/scripts/verify/dotnet/verify.ts @@ -47,7 +47,7 @@ export const verify = async (argv: string[]): Promise => { 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}`, + `>>> verify dotnet: selection sdk=${derived.components.sdk.key} runtime=${derived.components.runtime.key} aspnet=${derived.components.aspnet.key} tfm=${derived.tfm}`, ); const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-dotnet-")); diff --git a/scripts/verify/ripgrep/lifecycle.ts b/scripts/verify/ripgrep/lifecycle.ts index 7f57b86..41cf20e 100644 --- a/scripts/verify/ripgrep/lifecycle.ts +++ b/scripts/verify/ripgrep/lifecycle.ts @@ -56,7 +56,7 @@ export type ImagesMode = (typeof imagesModes)[number]; export interface ProvisionOptions { images: ImagesMode; - version: string; + key: string; ref: string; channel: string; prefix: string; @@ -69,11 +69,11 @@ const provision = async (options: ProvisionOptions): Promise => { await project$$`docker pull ${options.ref}`; } else { await project$$`tsx scripts/tasks/features/ripgrep/fetchPayload.ts ${[ - ...["--version", options.version], + ...["--version", options.key], ...(options.force ? ["--force"] : []), ]}`; await project$$`tsx scripts/tasks/features/ripgrep/buildImage.ts ${[ - ...["--version", options.version], + ...["--version", options.key], ...["--channel", options.channel], ...["--prefix", options.prefix], ]}`; @@ -84,12 +84,12 @@ const provision = async (options: ProvisionOptions): Promise => { } }; -const buildTestImage = async (ctxPath: string, version: string, ref: string): Promise => { +const buildTestImage = async (ctxPath: string, key: string, ref: string): Promise => { await mkdir(ctxPath, { recursive: true }); await project$$`docker buildx build ${[ "--network=none", "--load", - ...["--tag", `${testImagePrefix}:${version}`], + ...["--tag", `${testImagePrefix}:${key}`], ...["--file", path.join(import.meta.dirname, "test-image.Dockerfile")], ...["--build-arg", `RIPGREP_IMAGE_REF=${ref}`], ctxPath, diff --git a/scripts/verify/ripgrep/selection.ts b/scripts/verify/ripgrep/selection.ts index 6a1b18d..714f3af 100644 --- a/scripts/verify/ripgrep/selection.ts +++ b/scripts/verify/ripgrep/selection.ts @@ -1,24 +1,19 @@ +import { claimedKey } from "@/scripts/tasks/features/config.js"; import type { FeatureConfig } from "@/scripts/tasks/features/ripgrep/generateConfig.js"; import { configPath, readConfig } from "@/scripts/tasks/features/ripgrep/generateConfig.js"; -import { canonicalTag, imageRef } from "@/scripts/tasks/features/ripgrep/tags.js"; +import { imageRef, tagNames } from "@/scripts/tasks/features/ripgrep/tags.js"; export interface Selection { - version: string; + key: string; ref: string; } -const latestVersion = (config: FeatureConfig): string => { - const matches = Object.keys(config.versions).filter((version) => config.versions[version].aliases.includes("latest")); - if (matches.length !== 1) { - throw new Error(`${configPath}: exactly one version must carry the latest alias, found ${matches.length}`); - } - return matches[0]; -}; +const latestKey = (config: FeatureConfig): string => claimedKey(config.aliases, "latest", configPath); const derive = async (channel: string, prefix: string): Promise => { const config = await readConfig(); - const version = latestVersion(config); - return { version, ref: imageRef(prefix, canonicalTag(config, version, channel)) }; + const key = latestKey(config); + return { key, ref: imageRef(prefix, tagNames(config, key, channel).canonical) }; }; export const selection = { derive } as const; diff --git a/scripts/verify/ripgrep/verify.ts b/scripts/verify/ripgrep/verify.ts index 0933b00..0744f6c 100644 --- a/scripts/verify/ripgrep/verify.ts +++ b/scripts/verify/ripgrep/verify.ts @@ -72,17 +72,17 @@ export const verify = async (argv: string[]): Promise => { const channel = values.channel; const prefix = values.prefix ?? (await defaultRefPrefix()); const derived = await selection.derive(channel, prefix); - console.log(`>>> verify ripgrep: selection version=${derived.version} ref=${derived.ref}`); + console.log(`>>> verify ripgrep: selection version=${derived.key} ref=${derived.ref}`); const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-ripgrep-")); const workspace = lifecycle.workspaceAt(tempPath.path, "default"); - const testImage = `${testImagePrefix}:${derived.version}`; + const testImage = `${testImagePrefix}:${derived.key}`; try { console.log(">>> verify ripgrep: provision"); await lifecycle.provision({ images, - version: derived.version, + key: derived.key, ref: derived.ref, channel, prefix, @@ -90,7 +90,7 @@ export const verify = async (argv: string[]): Promise => { }); console.log(">>> verify ripgrep: test image"); - await lifecycle.buildTestImage(path.join(tempPath.path, "ctx"), derived.version, derived.ref); + await lifecycle.buildTestImage(path.join(tempPath.path, "ctx"), derived.key, derived.ref); console.log(">>> verify ripgrep: workspace"); await lifecycle.createWorkspace(workspace, { image: testImage }); @@ -99,7 +99,7 @@ export const verify = async (argv: string[]): Promise => { console.log(">>> verify ripgrep: scenario default"); await lifecycle.buildContainer(workspace); await lifecycle.startContainer(workspace); - for (const exec of defaultExecs(derived.version)) { + for (const exec of defaultExecs(derived.key)) { assertions.exec(await lifecycle.execInContainer(workspace, exec), exec.expect); } } finally { diff --git a/scripts/verify/uv/lifecycle.ts b/scripts/verify/uv/lifecycle.ts index e7866c9..fac0a05 100644 --- a/scripts/verify/uv/lifecycle.ts +++ b/scripts/verify/uv/lifecycle.ts @@ -78,14 +78,14 @@ export interface ProvisionOptions { } const provisionComponent = async ( - { component, version, ref }: ComponentImage, + { component, key, ref }: ComponentImage, options: ProvisionOptions, ): Promise => { if (options.images === "pull") { await project$$`docker pull ${ref}`; return; } - const args = [...["--component", component], ...["--version", version], ...(options.force ? ["--force"] : [])]; + const args = [...["--component", component], ...["--version", key], ...(options.force ? ["--force"] : [])]; await project$$`tsx scripts/tasks/features/uv/fetchPayload.ts ${args}`; await project$$`tsx scripts/tasks/features/uv/buildImage.ts ${[ ...args, @@ -100,7 +100,7 @@ const provision = async (components: readonly ComponentImage[], options: Provisi } const unique = new Map(); for (const component of components) { - unique.set(`${component.component}:${component.version}`, component); + unique.set(`${component.component}:${component.key}`, component); } for (const component of unique.values()) { await provisionComponent(component, options); diff --git a/scripts/verify/uv/scenarios.ts b/scripts/verify/uv/scenarios.ts index d036fe6..03bd74f 100644 --- a/scripts/verify/uv/scenarios.ts +++ b/scripts/verify/uv/scenarios.ts @@ -74,41 +74,41 @@ const testImageOf = (name: ScenarioName, selection: Selection, variant?: string) } const image = variant === undefined ? name : `${name}-${variant}`; return { - tag: `${testImagePrefix}:${image}-${label.version}`, + tag: `${testImagePrefix}:${image}-${label.key}`, components, argNames: definitions[name].argNames, dockerfile: definitions[name].dockerfile, }; }; -const minorOf = (version: string): string => { - const [major, minor] = version.split("."); +const minorOf = (pythonVersion: string): string => { + const [major, minor] = pythonVersion.split("."); return `${major}.${minor}`; }; -const pythonKey = (selection: Selection, version: string): string => - `cpython-${version}-linux-${selection.machine}-gnu`; +const pythonKey = (selection: Selection, pythonVersion: string): string => + `cpython-${pythonVersion}-linux-${selection.machine}-gnu`; -const pythonPatchDir = (selection: Selection, version: string): string => - `${selection.xdgDataPath}/uv/python/${pythonKey(selection, version)}`; +const pythonPatchDir = (selection: Selection, pythonVersion: string): string => + `${selection.xdgDataPath}/uv/python/${pythonKey(selection, pythonVersion)}`; -const pythonMinorDir = (selection: Selection, version: string): string => - `${selection.xdgDataPath}/uv/python/cpython-${minorOf(version)}-linux-${selection.machine}-gnu`; +const pythonMinorDir = (selection: Selection, pythonVersion: string): string => + `${selection.xdgDataPath}/uv/python/cpython-${minorOf(pythonVersion)}-linux-${selection.machine}-gnu`; -const pythonExecutable = (selection: Selection, version: string): string => - `${pythonPatchDir(selection, version)}/bin/python${minorOf(version)}`; +const pythonExecutable = (selection: Selection, pythonVersion: string): string => + `${pythonPatchDir(selection, pythonVersion)}/bin/python${minorOf(pythonVersion)}`; -const pythonListLine = (selection: Selection, version: string): string => - `${pythonKey(selection, version)} ${pythonMinorDir(selection, version)}/bin/python${minorOf(version)}`; +const pythonListLine = (selection: Selection, pythonVersion: string): string => + `${pythonKey(selection, pythonVersion)} ${pythonMinorDir(selection, pythonVersion)}/bin/python${minorOf(pythonVersion)}`; const linkTarget = (name: string, target: string): Exec => ({ command: ["sh", "-c", `readlink -f "$XDG_BIN_HOME/${name}"`], expect: { stdout: [target] }, }); -const linkVersion = (name: string, version: string): Exec => ({ +const linkVersion = (name: string, pythonVersion: string): Exec => ({ command: ["sh", "-c", `"$XDG_BIN_HOME/${name}" -V`], - expect: { stdout: [`Python ${version}`] }, + expect: { stdout: [`Python ${pythonVersion}`] }, }); const completionFiles = [ @@ -121,8 +121,8 @@ const completionFiles = [ ]; const binariesExecs = (selection: Selection): Exec[] => [ - { command: ["uv", "--version"], expect: { stdout: [`uv ${selection.uv.version} (${selection.muslTriple})`] } }, - { command: ["uvx", "--version"], expect: { stdout: [`uvx ${selection.uv.version} (${selection.muslTriple})`] } }, + { command: ["uv", "--version"], expect: { stdout: [`uv ${selection.uv.key} (${selection.muslTriple})`] } }, + { command: ["uvx", "--version"], expect: { stdout: [`uvx ${selection.uv.key} (${selection.muslTriple})`] } }, { command: ["sh", "-c", "command -v uv; command -v uvx"], expect: { stdout: ["/usr/local/bin/uv", "/usr/local/bin/uvx"] }, @@ -135,38 +135,44 @@ const binariesExecs = (selection: Selection): Exec[] => [ ]; const pythonExecs = (selection: Selection): Exec[] => { - const { version } = selection.python; + const pythonVersion = selection.python.pythonVersion; return [ - { command: ["cat", `${featureDir}/default-python`], expect: { stdout: [version] } }, - { command: ["uv", "python", "list", "--only-installed"], expect: { stdout: [pythonListLine(selection, version)] } }, - linkTarget("python", pythonExecutable(selection, version)), - linkTarget("python3", pythonExecutable(selection, version)), - linkTarget(`python${minorOf(version)}`, pythonExecutable(selection, version)), - linkVersion("python", version), + { command: ["cat", `${featureDir}/default-python`], expect: { stdout: [pythonVersion] } }, { - command: ["uv", "run", "-q", "--no-project", "--python", version, "python", "-V"], - expect: { stdout: [`Python ${version}`] }, + command: ["uv", "python", "list", "--only-installed"], + expect: { stdout: [pythonListLine(selection, pythonVersion)] }, + }, + linkTarget("python", pythonExecutable(selection, pythonVersion)), + linkTarget("python3", pythonExecutable(selection, pythonVersion)), + linkTarget(`python${minorOf(pythonVersion)}`, pythonExecutable(selection, pythonVersion)), + linkVersion("python", pythonVersion), + { + command: ["uv", "run", "-q", "--no-project", "--python", pythonVersion, "python", "-V"], + expect: { stdout: [`Python ${pythonVersion}`] }, }, - { command: ["uv", "venv", "-q", "--python", version], expect: { stdout: [] } }, - { command: [".venv/bin/python", "-V"], expect: { stdout: [`Python ${version}`] } }, + { command: ["uv", "venv", "-q", "--python", pythonVersion], expect: { stdout: [] } }, + { command: [".venv/bin/python", "-V"], expect: { stdout: [`Python ${pythonVersion}`] } }, ]; }; const pythonRestartExecs = (selection: Selection): Exec[] => { - const { version } = selection.python; + const pythonVersion = selection.python.pythonVersion; return [ - { command: ["cat", `${featureDir}/default-python`], expect: { stdout: [version] } }, - { command: ["uv", "python", "list", "--only-installed"], expect: { stdout: [pythonListLine(selection, version)] } }, - linkTarget("python", pythonExecutable(selection, version)), - linkTarget("python3", pythonExecutable(selection, version)), - linkTarget(`python${minorOf(version)}`, pythonExecutable(selection, version)), - linkVersion("python", version), + { command: ["cat", `${featureDir}/default-python`], expect: { stdout: [pythonVersion] } }, + { + command: ["uv", "python", "list", "--only-installed"], + expect: { stdout: [pythonListLine(selection, pythonVersion)] }, + }, + linkTarget("python", pythonExecutable(selection, pythonVersion)), + linkTarget("python3", pythonExecutable(selection, pythonVersion)), + linkTarget(`python${minorOf(pythonVersion)}`, pythonExecutable(selection, pythonVersion)), + linkVersion("python", pythonVersion), ]; }; const multiExecs = (selection: Selection): Exec[] => { - const latest = selection.python.version; - const previous = selection.previous.version; + const latest = selection.python.pythonVersion; + const previous = selection.previous.pythonVersion; return [ { command: ["uv", "python", "list", "--only-installed"], @@ -182,15 +188,18 @@ const multiExecs = (selection: Selection): Exec[] => { }; const downloadExecs = (selection: Selection): Exec[] => { - const { version } = selection.previous; + const pythonVersion = selection.previous.pythonVersion; return [ - { command: ["uv", "python", "list", "--only-installed"], expect: { stdout: [pythonListLine(selection, version)] } }, - linkTarget("python", pythonExecutable(selection, version)), - linkTarget("python3", pythonExecutable(selection, version)), - linkVersion("python", version), { - command: ["uv", "run", "-q", "--no-project", "--python", version, "python", "-V"], - expect: { stdout: [`Python ${version}`] }, + command: ["uv", "python", "list", "--only-installed"], + expect: { stdout: [pythonListLine(selection, pythonVersion)] }, + }, + linkTarget("python", pythonExecutable(selection, pythonVersion)), + linkTarget("python3", pythonExecutable(selection, pythonVersion)), + linkVersion("python", pythonVersion), + { + command: ["uv", "run", "-q", "--no-project", "--python", pythonVersion, "python", "-V"], + expect: { stdout: [`Python ${pythonVersion}`] }, }, ]; }; @@ -236,7 +245,7 @@ const createScenario = async (name: ScenarioName, context: ScenarioContext): Pro } case "python": { const testImage = await optionsTestImage(name, selection, root, { - defaultPythonVersion: selection.python.version, + defaultPythonVersion: selection.python.pythonVersion, }); return { name, @@ -259,7 +268,7 @@ const createScenario = async (name: ScenarioName, context: ScenarioContext): Pro }; case "download": { const testImage = await optionsTestImage(name, selection, root, { - defaultPythonVersion: minorOf(selection.previous.version), + defaultPythonVersion: minorOf(selection.previous.pythonVersion), }); return { name, diff --git a/scripts/verify/uv/selection.ts b/scripts/verify/uv/selection.ts index d1b6030..4145710 100644 --- a/scripts/verify/uv/selection.ts +++ b/scripts/verify/uv/selection.ts @@ -4,11 +4,12 @@ import path, { posix } from "node:path"; import { z } from "zod"; import { projectRoot } from "@/scripts/project.js"; -import type { Component, FeatureConfig } from "@/scripts/tasks/features/uv/generateConfig.js"; +import { claimedKey } from "@/scripts/tasks/features/config.js"; +import type { Component, FeatureConfig, PythonVersion } from "@/scripts/tasks/features/uv/generateConfig.js"; import { configPath, readConfig } from "@/scripts/tasks/features/uv/generateConfig.js"; import type { DockerArch } from "@/scripts/tasks/features/uv/paths.js"; import { paths } from "@/scripts/tasks/features/uv/paths.js"; -import { canonicalTag, imageRef } from "@/scripts/tasks/features/uv/tags.js"; +import { imageRef, tagNames } from "@/scripts/tasks/features/uv/tags.js"; import { remoteUser } from "./lifecycle.js"; @@ -21,30 +22,24 @@ const userInitFeatureSchema = z.object({ export interface ComponentImage { component: Component; - version: string; + key: string; ref: string; } +export interface PythonImage extends ComponentImage { + component: "python"; + pythonVersion: string; +} + export interface Selection { uv: ComponentImage; - python: ComponentImage; - previous: ComponentImage; + python: PythonImage; + previous: PythonImage; xdgDataPath: string; machine: string; muslTriple: string; } -const latestVersion = (config: FeatureConfig, component: Component): string => { - const versions = config.components[component].versions; - const matches = Object.keys(versions).filter((version) => versions[version].aliases.includes("latest")); - if (matches.length !== 1) { - throw new Error( - `${configPath}: component ${component} must have exactly one latest version, found ${matches.length}`, - ); - } - return matches[0]; -}; - const compareVersions = (left: string, right: string): number => { const leftParts = left.split(".").map(Number); const rightParts = right.split(".").map(Number); @@ -56,30 +51,51 @@ const compareVersions = (left: string, right: string): number => { return 0; }; -const previousPythonVersion = (config: FeatureConfig, latest: string): string => { - const versions = Object.keys(config.components.python.versions).filter( - (version) => compareVersions(version, latest) < 0, - ); - versions.sort(compareVersions); - const previous = versions.at(-1); +const comparePython = (left: PythonVersion, right: PythonVersion): number => + compareVersions(left.pythonVersion, right.pythonVersion) || Number(left.build) - Number(right.build); + +const previousPython = (config: FeatureConfig, latest: string): { key: string; entry: PythonVersion } => { + const { versions } = config.components.python; + const latestEntry = versions[latest]; + if (latestEntry === undefined) { + throw new Error(`${configPath}: component python has no version ${latest}`); + } + const below = Object.entries(versions) + .filter(([key, entry]) => key !== latest && comparePython(entry, latestEntry) < 0) + .sort((left, right) => comparePython(left[1], right[1])); + const previous = below.at(-1); if (previous === undefined) { throw new Error(`${configPath}: component python carries no version below ${latest}`); } - return previous; + const [key, entry] = previous; + return { key, entry }; }; const componentImage = ( config: FeatureConfig, component: Component, - version: string, + key: string, channel: string, prefix: string, ): ComponentImage => ({ component, - version, - ref: imageRef(prefix, component, canonicalTag(config, component, version, channel)), + key, + ref: imageRef(prefix, component, tagNames(config, component, key, channel).canonical), }); +const pythonImage = (config: FeatureConfig, key: string, channel: string, prefix: string): PythonImage => { + const entry = config.components.python.versions[key]; + if (entry === undefined) { + throw new Error(`${configPath}: component python has no version ${key}`); + } + return { + component: "python", + key, + ref: imageRef(prefix, "python", tagNames(config, "python", key, channel).canonical), + pythonVersion: entry.pythonVersion, + }; +}; + const readXdgDataPath = async (): Promise => { const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8")); const result = userInitFeatureSchema.safeParse(data); @@ -95,13 +111,14 @@ const readXdgDataPath = async (): Promise => { const derive = async (channel: string, prefix: string): Promise => { const config = await readConfig(); - const uvVersion = latestVersion(config, "uv"); - const pythonVersion = latestVersion(config, "python"); + const uvKey = claimedKey(config.components.uv.aliases, "latest", `${configPath} component uv`); + const pythonKey = claimedKey(config.components.python.aliases, "latest", `${configPath} component python`); + const previous = previousPython(config, pythonKey); const arch: DockerArch = paths.hostArch(); return { - uv: componentImage(config, "uv", uvVersion, channel, prefix), - python: componentImage(config, "python", pythonVersion, channel, prefix), - previous: componentImage(config, "python", previousPythonVersion(config, pythonVersion), channel, prefix), + uv: componentImage(config, "uv", uvKey, channel, prefix), + python: pythonImage(config, pythonKey, channel, prefix), + previous: pythonImage(config, previous.key, channel, prefix), xdgDataPath: await readXdgDataPath(), machine: paths.machineOf(arch), muslTriple: paths.muslTriples[arch], diff --git a/scripts/verify/uv/staging.ts b/scripts/verify/uv/staging.ts index 806c7cf..bc6e265 100644 --- a/scripts/verify/uv/staging.ts +++ b/scripts/verify/uv/staging.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { projectRoot } from "@/scripts/project.js"; import { project$$ } from "@/scripts/shell.js"; import { paths } from "@/scripts/tasks/features/uv/paths.js"; -import { pythonPayloadPaths } from "@/scripts/tasks/features/uv/payload.js"; +import { loadSection, pythonPayloadPaths } from "@/scripts/tasks/features/uv/payload.js"; import type { Workspace } from "./lifecycle.js"; import type { Selection } from "./selection.js"; @@ -18,13 +18,10 @@ const stageFeatures = async (workspace: Workspace): Promise => { }; const stageDownloadMirror = async (workspace: Workspace, selection: Selection, force: boolean): Promise => { - const args = [ - ...["--component", "python"], - ...["--version", selection.previous.version], - ...(force ? ["--force"] : []), - ]; + const args = [...["--component", "python"], ...["--version", selection.previous.key], ...(force ? ["--force"] : [])]; await project$$`tsx scripts/tasks/features/uv/fetchPayload.ts ${args}`; - const payload = await pythonPayloadPaths(selection.previous.version, paths.hostArch()); + const section = await loadSection("python", selection.previous.key); + const payload = pythonPayloadPaths(section, paths.hostArch()); const mirrorPath = path.join(workspace.path, "mirror", payload.build); await mkdir(mirrorPath, { recursive: true }); await cp(payload.archive, path.join(mirrorPath, payload.name)); diff --git a/scripts/verify/uv/verify.ts b/scripts/verify/uv/verify.ts index 0b5378d..3be1a0c 100644 --- a/scripts/verify/uv/verify.ts +++ b/scripts/verify/uv/verify.ts @@ -37,7 +37,7 @@ export const verify = async (argv: string[]): Promise => { const selected = scenarios.select(values.scenario ?? []); const derived = await selection.derive(channel, prefix); console.log( - `>>> verify uv: selection uv=${derived.uv.version} python=${derived.python.version} previous=${derived.previous.version}`, + `>>> verify uv: selection uv=${derived.uv.key} python=${derived.python.key} previous=${derived.previous.key}`, ); const tempPath = await mkdtempDisposable(path.join(tmpdir(), "devcontainer-verify-uv-"));