diff --git a/docs/tools.md b/docs/tools.md index de094daee6..fc7ef635bf 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -110,6 +110,15 @@ Some tools accept partial versions and install a matching release: A major or major.minor version resolves to the newest matching release even when pip would read it as `X.Y.0`, eg. `poetry 1.8` installs the newest `1.8.x`, not `1.8.0`; pin the full version for an exact one. Without a version, or with `latest`, the latest release is installed. For `poetry` the latest release is the newest one supported by `poetry-plugin-pypi-mirror`, a partial version ignores that limit. +- `android-sdk-cmdline-tools`: A major (`14`) or major.minor (`14.1`) version installs the newest matching stable release, compared numerically. + A version which exists as a release, like `13`, is kept; the repository lists versions without zero segments, so `13.0` installs the release listed as `13`. + The lookup needs the android repository, which the install needs too, so it fails when the repository can't be loaded. + Without a version, or with `latest`, the `latest` package is installed. +- `cocoapods`, `bundler` and any package installed with [`install-gem`](#install-gem-command): A major (`1`) or major.minor (`1.16`) version installs the newest matching release, prereleases are skipped. + A version which exists as a release, like `5.2`, is kept. + Other major or major.minor versions resolve to the newest matching release even when RubyGems would read them as `X.Y.0`, eg. `bundler 2.0` installs the newest `2.0.x`, not `2.0.0`; pin the full version for an exact one. + The lookup goes to rubygems.org, through the CDN and URL replacements like other lookups, and a configured gem registry is only used for the install, so a gem from a private registry needs a full version, and a partial version fails when rubygems.org can't be reached. + Without a version, or with `latest`, the latest release is installed. - `nuget`: A major (`6`) or major.minor (`6.11`) version installs the newest matching released and blessed release. A version which exists in the nuget feed is kept, and so is the version when `tools.json` can't be loaded. Without a version, or with `latest`, the newest released and blessed release is installed. @@ -155,6 +164,14 @@ Note: requires ruby to be installed. ```bash install-gem rake ``` +- Installs the newest rake 13 release + ```bash + install-gem rake 13 + ``` + +#### Tool-specific versions + +A major or major.minor version installs the newest matching release, see the [install-tool versions](#Versions-install-tool).
diff --git a/src/cli/command/install-gem.spec.ts b/src/cli/command/install-gem.spec.ts index 6edbc964e7..6af7bec2df 100644 --- a/src/cli/command/install-gem.spec.ts +++ b/src/cli/command/install-gem.spec.ts @@ -1,7 +1,9 @@ import { Cli } from 'clipanion'; import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { partialVersionHelp } from '../install-tool/tool-version-resolver.ts'; import { MissingVersion } from '../utils/codes.ts'; import { registerCommands } from './index.ts'; +import { InstallGemCommand, InstallGemShortCommand } from './install-gem.ts'; const mocks = vi.hoisted(() => ({ installTool: vi.fn(), @@ -17,6 +19,11 @@ describe('cli/command/install-gem', () => { vi.stubEnv('RAKE_VERSION', undefined); }); + test('describes partial versions', () => { + expect(InstallGemCommand.usage?.details).toBe(partialVersionHelp); + expect(InstallGemShortCommand.usage?.details).toBe(partialVersionHelp); + }); + test('install-gem', async () => { const cli = new Cli({ binaryName: 'install-gem' }); registerCommands(cli, 'install-gem'); diff --git a/src/cli/command/install-gem.ts b/src/cli/command/install-gem.ts index 091da6d2cf..30612f1cf6 100644 --- a/src/cli/command/install-gem.ts +++ b/src/cli/command/install-gem.ts @@ -1,4 +1,5 @@ import { Command } from 'clipanion'; +import { partialVersionHelp } from '../install-tool/tool-version-resolver.ts'; import { InstallToolCommand } from './install-tool.ts'; import { command } from './utils.ts'; @@ -14,7 +15,9 @@ export class InstallGemCommand extends InstallToolCommand { 'RAKE_VERSION=13.0.6 $0 install gem rake', ], // ['Installs latest rake version', '$0 install gem rake'], // not yet supported + ['Installs the newest rake 13 release', '$0 install gem rake 13'], ], + details: partialVersionHelp, }); protected override type = 'gem' as const; @@ -33,6 +36,8 @@ export class InstallGemShortCommand extends InstallGemCommand { 'RAKE_VERSION=13.0.6 $0 rake', ], // ['Installs latest rake version', '$0 rake'], // not yet supported + ['Installs the newest rake 13 release', '$0 rake 13'], ], + details: partialVersionHelp, }); } diff --git a/src/cli/install-tool/tool-version-resolver.spec.ts b/src/cli/install-tool/tool-version-resolver.spec.ts new file mode 100644 index 0000000000..75ea8d14de --- /dev/null +++ b/src/cli/install-tool/tool-version-resolver.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'vitest'; +import { newestVersion } from './tool-version-resolver.ts'; + +describe('cli/install-tool/tool-version-resolver', () => { + describe('newestVersion', () => { + test('returns undefined without versions', () => { + expect(newestVersion([])).toBeUndefined(); + }); + + test.each([ + { versions: ['6.1.7', '6.1.7.9', '6.1.7.10'], expected: '6.1.7.10' }, + { versions: ['6.1.7.10', '6.1.7.9', '6.1.7'], expected: '6.1.7.10' }, + { versions: ['6.1.7.9', '6.1.7.10', '6.1.7'], expected: '6.1.7.10' }, + { versions: ['2.9.1', '2.10.0', '2.5.11'], expected: '2.10.0' }, + { versions: ['4.8', '4.9', '4.8.1'], expected: '4.9' }, + { versions: ['1.0.0.pre2', '1.0.0.pre10'], expected: '1.0.0.pre10' }, + ])('picks $expected from $versions', ({ versions, expected }) => { + expect(newestVersion(versions)).toBe(expected); + }); + + test('treats a missing segment as 0 and keeps the first of equal versions', () => { + expect(newestVersion(['6.1', '6.1.0'])).toBe('6.1'); + expect(newestVersion(['6.1.0', '6.1'])).toBe('6.1.0'); + }); + }); +}); diff --git a/src/cli/install-tool/tool-version-resolver.ts b/src/cli/install-tool/tool-version-resolver.ts index 56ed871bab..00317023e0 100644 --- a/src/cli/install-tool/tool-version-resolver.ts +++ b/src/cli/install-tool/tool-version-resolver.ts @@ -15,6 +15,48 @@ export function isPartialVersion(version: string): boolean { return /^\d+(\.\d+)?$/.test(version); } +/** + * Compares two versions segment by segment, a missing segment counts as `0`, + * so `4.9` equals `4.9.0`. Segments are compared numerically, a non-numeric + * one is compared as text, with the digits in it compared as numbers. + * @returns a negative number if `a` is older, a positive one if it is newer, + * else `0` + */ +function compareSegments(a: string, b: string): number { + const left = a.split('.'); + const right = b.split('.'); + const length = Math.max(left.length, right.length); + for (let i = 0; i < length; i++) { + const diff = (left[i] ?? '0').localeCompare(right[i] ?? '0', 'en', { + numeric: true, + }); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +/** + * The newest of the versions, independent of their order. Dot-separated + * segments are compared one by one, so `6.1.7.10` is newer than `6.1.7.9`, + * which is newer than `6.1.7`, and entries like `4.9` count as `4.9.0`. Of + * equal versions the first one wins. + * + * Expects release versions only: a prerelease like `1.0.0.rc1` would rank + * above its release `1.0.0`, so callers filter prereleases out first. + * @returns the newest version as listed, or `undefined` without any + */ +export function newestVersion(versions: string[]): string | undefined { + let result: string | undefined; + for (const version of versions) { + if (!result || compareSegments(version, result) > 0) { + result = version; + } + } + return result; +} + @injectable() export abstract class ToolVersionResolver { abstract readonly tool: string; diff --git a/src/cli/install-tool/version-resolvers.spec.ts b/src/cli/install-tool/version-resolvers.spec.ts index 7d15fb1073..95865d4a56 100644 --- a/src/cli/install-tool/version-resolvers.spec.ts +++ b/src/cli/install-tool/version-resolvers.spec.ts @@ -11,7 +11,7 @@ describe('cli/install-tool/version-resolvers', () => { expect(getVersionHelp()).toBe(codeBlock` Some tools accept partial versions: - - \`checkov\`, \`conan\`, \`copier\`, \`hashin\`, \`kas\`, \`node\`, \`nuget\`, \`pdm\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release. + - \`android-sdk-cmdline-tools\`, \`bundler\`, \`checkov\`, \`cocoapods\`, \`conan\`, \`copier\`, \`hashin\`, \`kas\`, \`node\`, \`nuget\`, \`pdm\`, \`pip-tools\`, \`pipenv\`, \`poetry\`, \`uv\`: A major or major.minor version installs the newest matching release. - \`corepack\`, \`npm\`, \`pnpm\`, \`yarn\`: A major or major.minor version installs the matching \`latest\` release, else the newest matching one. - \`java\`, \`java-jdk\`, \`java-jre\`: A major, major.minor or major.minor.patch version, like \`21\` or \`11.0\`, installs the newest matching release. `); diff --git a/src/cli/tools/dotnet/nuget.ts b/src/cli/tools/dotnet/nuget.ts index 2c4faf5f75..92bcc73ec6 100644 --- a/src/cli/tools/dotnet/nuget.ts +++ b/src/cli/tools/dotnet/nuget.ts @@ -7,24 +7,10 @@ import { BaseInstallService } from '../../install-tool/base-install.service.ts'; import { ToolVersionResolver, isPartialVersion, + newestVersion, partialVersionHelp, } from '../../install-tool/tool-version-resolver.ts'; -import { logger, semverCoerce, semverGte } from '../../utils/index.ts'; - -/** - * The newest of the versions, compared coerced, so entries like `4.9` count - * as `4.9.0`. - * @returns the newest version as listed, or `undefined` without any - */ -function newest(versions: string[]): string | undefined { - let result: string | undefined; - for (const version of versions) { - if (!result || semverGte(semverCoerce(version)!, semverCoerce(result)!)) { - result = version; - } - } - return result; -} +import { logger } from '../../utils/index.ts'; @injectable() @injectFromHierarchy() @@ -123,7 +109,7 @@ export class NugetVersionResolver extends ToolVersionResolver { if (meta.some((v) => v.version === version)) { return version; } - const release = newest( + const release = newestVersion( meta .filter( (v) => diff --git a/src/cli/tools/java/android-partial.spec.ts b/src/cli/tools/java/android-partial.spec.ts new file mode 100644 index 0000000000..b51f787103 --- /dev/null +++ b/src/cli/tools/java/android-partial.spec.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, test } from 'vitest'; +import { AndroidSdkCmdlineToolsVersionResolver } from './android.ts'; +import { scope } from '~test/http-mock.ts'; +import { toolContext } from '~test/tool.ts'; + +/** + * `android.ts` caches the sdk repository in a module level variable, so a + * single spec file can only ever see one version of it. This one holds a + * repository with several versions to resolve partial versions from. + */ + +/** A repository package, the revision is written without trailing zeros. */ +function remotePackage( + path: string, + major: number, + minor: number | undefined, + channel = 'channel-0', +): string { + const minorTag = minor === undefined ? '' : `${minor}`; + return ` + + + ${major}${minorTag} + Android SDK Command-line Tools + + + + + + 1 + deadbeef + commandlinetools-linux-${major}.zip + + linux + + + `; +} + +const repository = ` + + Terms and Conditions + stable + beta + ${remotePackage('cmdline-tools;11.0', 11, undefined)} + ${remotePackage('cmdline-tools;12.0', 12, undefined)} + ${remotePackage('cmdline-tools;12.1', 12, 1)} + ${remotePackage('cmdline-tools;13.0', 13, undefined)} + ${remotePackage('cmdline-tools;14.1', 14, 1)} + ${remotePackage('cmdline-tools;14.3', 14, 3)} + ${remotePackage('cmdline-tools;15.1', 15, 1, 'channel-1')} + ${remotePackage('cmdline-tools;16.0', 16, undefined)} + ${remotePackage('cmdline-tools;16.10', 16, 10)} + ${remotePackage('cmdline-tools;16.2', 16, 2)} + ${remotePackage('cmdline-tools;17.0', 17, undefined, 'channel-1')} + ${remotePackage('cmdline-tools;160.0', 160, undefined)} + ${remotePackage('cmdline-tools;latest', 16, 10)} + ${remotePackage('platforms;android-35', 18, undefined)} +`; + +describe('cli/tools/java/android-partial', () => { + beforeEach(() => { + // the repository is fetched once per run and cached in the module, so the + // first test serves it and the rest reuse the cache + scope('https://dl.google.com') + .get('/android/repository/repository2-3.xml') + .optionally() + .reply(200, repository); + }); + + test.each([ + // existing releases are kept, in the form the repository lists them + { version: '16', expected: '16' }, + { version: '16.2', expected: '16.2' }, + { version: '12', expected: '12' }, + { version: '11', expected: '11' }, + // a listed `13` is `13.0` + { version: '13', expected: '13' }, + { version: '13.0', expected: '13' }, + { version: '16.0', expected: '16' }, + { version: '160', expected: '160' }, + // an existing beta release is kept too + { version: '17', expected: '17' }, + // no `14` release, so the newest stable 14.x + { version: '14', expected: '14.3' }, + ])('resolves $version to $expected', async ({ version, expected }) => { + const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver); + + expect(await svc.resolve(version)).toBe(expected); + }); + + test.each([ + // only a beta package + '15', + // no such package, even though a platform with that revision exists + '18', + '16.3', + '13.1', + // 14.1 and 14.3 are no 14.0 release + '14.0', + '1', + ])('throws for %s without a matching stable package', async (version) => { + const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver); + + await expect(svc.resolve(version)).rejects.toThrow( + `No android-sdk-cmdline-tools release found for version ${version}`, + ); + }); + + test('keeps a full version', async () => { + const { svc } = await toolContext(AndroidSdkCmdlineToolsVersionResolver); + + expect(await svc.resolve('16.10.1')).toBe('16.10.1'); + }); +}); diff --git a/src/cli/tools/java/android.ts b/src/cli/tools/java/android.ts index ce0ea9fa00..32262912ee 100644 --- a/src/cli/tools/java/android.ts +++ b/src/cli/tools/java/android.ts @@ -4,7 +4,12 @@ import { XMLParser } from '@nodable/flexible-xml-parser'; import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../../install-tool/tool-version-resolver.ts'; +import { + ToolVersionResolver, + isPartialVersion, + newestVersion, + partialVersionHelp, +} from '../../install-tool/tool-version-resolver.ts'; import type { HttpService } from '../../services/http.service.ts'; import { semverCoerce } from '../../utils/index.ts'; import { AndroidSdkRepo } from './schema.ts'; @@ -81,14 +86,53 @@ export class AndroidSdkCmdlineToolsInstallService extends BaseInstallService { } } +/** + * Checks if a repository version starts with the segments of a partial + * version, compared numerically with missing segments counting as `0`. + */ +function matchesPartial(repoVersion: string, partial: string): boolean { + const segments = repoVersion.split('.'); + return partial + .split('.') + .every((segment, i) => Number(segment) === Number(segments[i] ?? '0')); +} + +/** + * Checks if a repository version is the same release as a requested version, + * compared numerically with missing segments counting as `0`, so `16.0` + * equals the listed `16`. + */ +function isSameRelease(repoVersion: string, version: string): boolean { + return ( + matchesPartial(repoVersion, version) && matchesPartial(version, repoVersion) + ); +} + @injectable() @injectFromHierarchy() export class AndroidSdkCmdlineToolsVersionResolver extends ToolVersionResolver { readonly tool = 'android-sdk-cmdline-tools'; + override readonly versionHelp = partialVersionHelp; + /** - * Resolves a missing version or `latest` to the `cmdline-tools;latest` - * package of the android sdk repository. + * Resolves a version from the android sdk repository. + * + * - A missing version or `latest` resolves to the `cmdline-tools;latest` + * package. + * - A major (`13`) or major.minor (`13.1`) version which is an existing + * release is kept, in the form the repository lists it: versions are + * listed without zero segments, so `13.0` becomes `13`. + * - Any other major or major.minor version resolves to the newest matching + * stable package, compared numerically with missing segments counting as + * `0`, so `14` matches `14.1` but not `140`. + * - Any other version is returned unchanged. + * + * Partial versions need the repository, which the install needs too, so a + * failed lookup is an error. + * + * @throws if the repository can't be loaded or a partial version matches no + * stable package. */ async resolve(version: string | undefined): Promise { if (!isNonEmptyStringAndNotWhitespace(version) || version === 'latest') { @@ -98,6 +142,29 @@ export class AndroidSdkCmdlineToolsVersionResolver extends ToolVersionResolver { return pkg.version; } } + if (version && isPartialVersion(version)) { + const res = await fetchRepo(this.http); + const packages = res.packages.filter( + (p) => + p.path.startsWith('cmdline-tools;') && + p.path !== 'cmdline-tools;latest', + ); + const existing = packages.find((p) => isSameRelease(p.version, version)); + if (existing) { + return existing.version; + } + const release = newestVersion( + packages + .filter( + (p) => p.channel === 'stable' && matchesPartial(p.version, version), + ) + .map((p) => p.version), + ); + if (!release) { + throw new Error(`No ${this.tool} release found for version ${version}`); + } + return release; + } return version; } } diff --git a/src/cli/tools/ruby/cocoapods.spec.ts b/src/cli/tools/ruby/cocoapods.spec.ts index 08b44d4ac5..8ea87aac48 100644 --- a/src/cli/tools/ruby/cocoapods.spec.ts +++ b/src/cli/tools/ruby/cocoapods.spec.ts @@ -99,6 +99,21 @@ describe('cli/tools/ruby/cocoapods', () => { ); }); + test.each(['1', '1.16', '1.16.2', '1.17.0.beta'])( + 'validates %s', + async (version) => { + const { svc } = await toolContext(CocoapodsInstallService); + + expect(await svc.validate(version)).toBe(true); + }, + ); + + test('rejects an invalid version', async () => { + const { svc } = await toolContext(CocoapodsInstallService); + + expect(await svc.validate('latest')).toBe(false); + }); + test('runs the tool test', async () => { const { svc } = await toolContext(CocoapodsInstallService); @@ -121,6 +136,19 @@ describe('cli/tools/ruby/cocoapods', () => { expect(await svc.resolve('latest')).toBe('1.15.2'); }); + test('resolves a partial version', async () => { + scope('https://rubygems.org') + .get('/api/v1/versions/cocoapods.json') + .reply(200, [ + { number: '1.17.0.beta', prerelease: true }, + { number: '1.16.2', prerelease: false }, + { number: '1.15.2', prerelease: false }, + ]); + const { svc } = await toolContext(CocoapodsVersionResolver); + + expect(await svc.resolve('1')).toBe('1.16.2'); + }); + test('keeps a pinned version', async () => { const { svc } = await toolContext(CocoapodsVersionResolver); diff --git a/src/cli/tools/ruby/schema.ts b/src/cli/tools/ruby/schema.ts index 7ee4e7e523..29e9409c46 100644 --- a/src/cli/tools/ruby/schema.ts +++ b/src/cli/tools/ruby/schema.ts @@ -3,3 +3,11 @@ import { z } from 'zod'; export const RubyGemJson = z.object({ version: z.string(), }); + +export const RubyGemVersionsJson = z.array( + z.object({ + number: z.string(), + prerelease: z.boolean(), + }), +); +export type RubyGemVersionsJson = z.infer; diff --git a/src/cli/tools/ruby/utils.spec.ts b/src/cli/tools/ruby/utils.spec.ts index 8ab0e71954..15b84b3397 100644 --- a/src/cli/tools/ruby/utils.spec.ts +++ b/src/cli/tools/ruby/utils.spec.ts @@ -200,6 +200,41 @@ describe('cli/tools/ruby/utils', () => { ); }); + test.each([ + '2', + '2.5', + '5.2', + '2.5.0', + '6.1.7.10', + '1.0.0.beta1', + '7.0.0.rc2', + '1.17.0.beta', + ])('validates %s', async (version) => { + const svc = await child.getAsync(BundlerInstallService); + + expect(await svc.validate(version)).toBe(true); + }); + + test.each([ + '', + ' ', + '2 ', + 'latest', + 'v2.5', + '1a', + '1a.2', + '.2', + '2.', + '2..5', + '2.5-beta', + '2.5 && ls', + '>= 2', + ])('rejects %j', async (version) => { + const svc = await child.getAsync(BundlerInstallService); + + expect(await svc.validate(version)).toBe(false); + }); + test('runs the tool test', async () => { const svc = await child.getAsync(BundlerInstallService); @@ -248,6 +283,79 @@ describe('cli/tools/ruby/utils', () => { expect(await resolver.resolve('2.5.0')).toBe('2.5.0'); }); + describe('partial versions', () => { + const versions = [ + { number: '3.0.0.rc1', prerelease: true }, + { number: '2.10.0', prerelease: false }, + { number: '2.9.1', prerelease: false }, + { number: '2.10.0.beta1', prerelease: true }, + { number: '2.9', prerelease: false }, + { number: '2.5.11', prerelease: false }, + { number: '2.5.9', prerelease: false }, + { number: '2.5.10', prerelease: false }, + { number: '1.17.3.1', prerelease: false }, + { number: '1.17.3', prerelease: false }, + { number: '20.1.0', prerelease: false }, + { number: '4.0.0.pre', prerelease: true }, + { number: '5.0', prerelease: true }, + ]; + + test.each([ + { version: '2', expected: '2.10.0' }, + { version: '2.5', expected: '2.5.11' }, + { version: '2.10', expected: '2.10.0' }, + { version: '1', expected: '1.17.3.1' }, + { version: '20', expected: '20.1.0' }, + // an existing release is kept + { version: '2.9', expected: '2.9' }, + { version: '5.0', expected: '5.0' }, + ])('resolves $version to $expected', async ({ version, expected }) => { + scope('https://rubygems.org') + .get('/api/v1/versions/bundler.json') + .reply(200, versions); + const resolver = await child.getAsync(BundlerVersionResolver); + + expect(await resolver.resolve(version)).toBe(expected); + }); + + test.each(['3', '4', '2.6', '6'])( + 'throws for %s without a matching release', + async (version) => { + scope('https://rubygems.org') + .get('/api/v1/versions/bundler.json') + .reply(200, versions); + const resolver = await child.getAsync(BundlerVersionResolver); + + await expect(resolver.resolve(version)).rejects.toThrow( + `No bundler release found for version ${version}`, + ); + }, + ); + + test('throws for a partial version when rubygems.org is missing the gem', async () => { + scope('https://rubygems.org') + .get('/api/v1/versions/bundler.json') + .reply(404); + const resolver = await child.getAsync(BundlerVersionResolver); + + await expect(resolver.resolve('2')).rejects.toThrow( + 'Could not resolve bundler version 2 on rubygems.org, use a full version', + ); + }); + + test('throws for a partial version when rubygems.org is not reachable', async () => { + scope('https://rubygems.org') + .get('/api/v1/versions/bundler.json') + .times(3) + .replyWithError('connection reset'); + const resolver = await child.getAsync(BundlerVersionResolver); + + await expect(resolver.resolve('2.5')).rejects.toThrow( + 'Could not resolve bundler version 2.5 on rubygems.org, use a full version', + ); + }); + }); + test('createGemVersionResolver', async () => { scope('https://rubygems.org') .get('/api/v1/gems/cocoapods.json') diff --git a/src/cli/tools/ruby/utils.ts b/src/cli/tools/ruby/utils.ts index 9f25a9f700..b660020812 100644 --- a/src/cli/tools/ruby/utils.ts +++ b/src/cli/tools/ruby/utils.ts @@ -5,12 +5,18 @@ import { isNonEmptyStringAndNotWhitespace } from '@sindresorhus/is'; import { execa } from 'execa'; import { inject, injectFromHierarchy, injectable } from 'inversify'; import { BaseInstallService } from '../../install-tool/base-install.service.ts'; -import { ToolVersionResolver } from '../../install-tool/tool-version-resolver.ts'; +import { + ToolVersionResolver, + isPartialVersion, + newestVersion, + partialVersionHelp, +} from '../../install-tool/tool-version-resolver.ts'; import { VersionService } from '../../services/index.ts'; import { logger } from '../../utils/index.ts'; -import { RubyGemJson } from './schema.ts'; +import { RubyGemJson, RubyGemVersionsJson } from './schema.ts'; const defaultRegistry = 'https://rubygems.org/'; +const gemVersionPattern = /^\d+(?:\.[0-9a-z]+)*$/i; @injectable() export abstract class RubyBaseInstallService extends BaseInstallService { @@ -131,6 +137,15 @@ export abstract class RubyBaseInstallService extends BaseInstallService { await this._spawn(this.name, ['--version']); } + /** + * Accepts RubyGems version strings: a numeric first segment followed by + * dot-separated segments of digits and letters, eg. `2`, `5.2`, `6.1.7.10` + * or `7.0.0.rc2`. + */ + override validate(version: string): Promise { + return Promise.resolve(gemVersionPattern.test(version)); + } + /** Runs tool specific steps after the gem install, none by default. */ protected _postInstall( _gem: string, @@ -175,7 +190,22 @@ export abstract class RubyBaseInstallService extends BaseInstallService { @injectable() export abstract class RubyGemVersionResolver extends ToolVersionResolver { - /** Resolves a missing version or `latest` to the latest rubygems release. */ + override readonly versionHelp = partialVersionHelp; + + /** + * Resolves a version from rubygems.org, a configured gem registry is not + * used for the lookup. Like other lookups, the request goes through the + * configured CDN and URL replacements. + * + * - A missing version or `latest` resolves to the latest release. + * - A major (`1`) or major.minor (`1.16`) version which is no existing + * release resolves to the newest matching release, prereleases are + * skipped. An existing release, like `1.2`, is kept. + * - Any other version, like a full `X.Y.Z`, is returned unchanged. + * + * @throws if a partial version can't be looked up on rubygems.org, since + * gem can't install it as given, or if it matches no release. + */ async resolve(version: string | undefined): Promise { if (version === undefined || version === 'latest') { const meta = RubyGemJson.parse( @@ -185,6 +215,35 @@ export abstract class RubyGemVersionResolver extends ToolVersionResolver { ); return meta.version; } + if (isPartialVersion(version)) { + let releases: RubyGemVersionsJson; + try { + releases = RubyGemVersionsJson.parse( + await this.http.getJson( + `https://rubygems.org/api/v1/versions/${this.tool}.json`, + ), + ); + } catch (err) { + // gem would install `= X.Y` and the install then misses its gemspec, + // so fail early like before partial versions were accepted + throw new Error( + `Could not resolve ${this.tool} version ${version} on rubygems.org, use a full version`, + { cause: err }, + ); + } + if (releases.some((r) => r.number === version)) { + return version; + } + const release = newestVersion( + releases + .filter((r) => !r.prerelease && r.number.startsWith(`${version}.`)) + .map((r) => r.number), + ); + if (!release) { + throw new Error(`No ${this.tool} release found for version ${version}`); + } + return release; + } return version; } } diff --git a/test/java/Dockerfile b/test/java/Dockerfile index 6b2b4b39f2..fe729f08c7 100644 --- a/test/java/Dockerfile +++ b/test/java/Dockerfile @@ -299,6 +299,15 @@ RUN set -ex; \ sdkmanager "platforms;android-35"; \ true +# partial version, installing needs root +USER root + +# `16.0` is listed as `16` in the repository +RUN install-tool android-sdk-cmdline-tools 16.0 + +RUN set -ex; \ + sdkmanager --version | grep '^16\.'; + #-------------------------------------- # final #-------------------------------------- diff --git a/test/ruby/Dockerfile b/test/ruby/Dockerfile index 825b55b267..b09fffe1cd 100644 --- a/test/ruby/Dockerfile +++ b/test/ruby/Dockerfile @@ -151,6 +151,22 @@ RUN install-gem rake RUN rake --version +#-------------------------------------- +# test: partial versions +#-------------------------------------- +FROM build AS test-partial + +USER 12021 + +RUN install-tool cocoapods 1 + +RUN set -ex; \ + pod --version | grep '^1\.'; + +RUN install-gem rake 13 + +RUN set -ex; \ + rake --version | grep 'rake, version 13\.'; #-------------------------------------- # final @@ -162,3 +178,4 @@ COPY --from=test-bundler-c /.dummy /.dummy COPY --from=test-bundler-d /.dummy /.dummy COPY --from=test-cocoapods-a /.dummy /.dummy COPY --from=test-gem /.dummy /.dummy +COPY --from=test-partial /.dummy /.dummy