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