Files
Gregor Zeitlinger 0d9888a4f2 feat(datasource): add GraalVM version datasource (#40497)
* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* feat(datasource): add GraalVM version datasource with support for JDK and JRE releases

* add coverage

* fix

* fix

* fix

* code cov

* code cov

* code cov

* refactor(datasource): integrate GraalVM into java-version datasource

Merges graalvm-version datasource into java-version as a unified multi-vendor
Java distribution datasource. Implements vendor-based modular architecture with
routing layer for Adoptium (Eclipse Temurin) and Oracle GraalVM.

Architecture:
- Vendor-specific handlers (adoptium.ts, graalvm.ts) with routing in index.ts
- Vendor-aware system detection with different arch/os mappings per vendor
- Custom registry support for GraalVM's mise-java API
- Adoptium remains the default registry for backwards compatibility

Package names:
- New explicit: adoptium-jdk, adoptium-jre, oracle-graalvm-jdk, oracle-graalvm-jre
- Legacy (map to Adoptium): java, java-jdk, java-jre

Changes:
- Created adoptium.ts and graalvm.ts vendor handlers
- Enhanced common.ts with vendor detection and vendor-specific system mappings
- Updated index.ts with routing layer and custom registry support
- Merged test suites with comprehensive coverage for both vendors
- Updated mise manager integration to use JavaVersionDatasource
- Removed graalvm-version datasource directory

All 65 datasource tests and 93 mise manager tests passing.

* fix

* test(datasource): add coverage for non-HttpError paths in java-version

* fix(datasource): remove dead code and add coverage for java-version

Remove unreachable HttpError handling from _getReleases catch block
since both getAdoptiumReleases and getGraalvmReleases handle HttpError
internally. Add test for the graalvmRegistryUrl fallback when
registryUrl is undefined.

* fix(datasource): address review comments for GraalVM datasource

- Remove unused 'datasource-graalvm-version' cache namespace
- Convert MiseJavaRelease interface to Zod schema and derive type from it
- Use http.getJson() with the Zod schema in getGraalvmReleases instead
  of getJsonUnchecked, letting the schema handle invalid/empty responses
- Update getGraalvmReleases test mock to use getJson

* docs: remove unused datasource-graalvm-version from cacheTtlOverride docs

* fix(datasource): preserve Adoptium config typing

* fix(datasource): harden GraalVM release parsing

* fix(datasource): address GraalVM review feedback

* test: cover GraalVM mise package mapping

* test(datasource): cover default GraalVM registry fallback

* test(datasource): cover legacy registry resolution

* test(datasource): type legacy datasource mocks

* fix(datasource): harden GraalVM defaults and responses

* refactor(datasource): use function-only registry accessors

* test(datasource): cover registry accessor functions

* test: restore coverage exclusions

* chore: retrigger CI checks
2026-09-23 08:40:24 +00:00

289 lines
8.8 KiB
TypeScript

import { isBoolean } from '@sindresorhus/is';
import { logger } from '../../../logger/index.ts';
import { withCache } from '../../../util/cache/package/with-cache.ts';
import { queryReleases } from '../../../util/github/graphql/index.ts';
import type {
GithubDigestFile,
GithubRestAsset,
GithubRestRelease,
} from '../../../util/github/types.ts';
import { getApiBaseUrl, getSourceUrl } from '../../../util/github/url.ts';
import { hashStream } from '../../../util/hash.ts';
import { GithubHttp } from '../../../util/http/github.ts';
import { newlineRegex, regEx } from '../../../util/regex.ts';
import { Datasource } from '../datasource.ts';
import type {
DigestConfig,
GetReleasesConfig,
Release,
ReleaseResult,
} from '../types.ts';
export const cacheNamespace = 'datasource-github-releases';
function inferHashAlg(digest: string): string {
switch (digest.length) {
case 64:
return 'sha256';
default:
case 96:
return 'sha512';
}
}
export class GithubReleaseAttachmentsDatasource extends Datasource {
static readonly id = 'github-release-attachments';
override getDefaultRegistryUrls(_packageName: string): string[] {
return ['https://github.com'];
}
override http: GithubHttp;
override readonly releaseTimestampSupport = true;
// Note: not sure
override readonly releaseTimestampNote =
'The release timestamp is determined from the `releaseTimestamp` field in the results.';
override readonly sourceUrlSupport = 'package';
override readonly sourceUrlNote =
'The source URL is determined by using the `packageName` and `registryUrl`.';
constructor() {
super(GithubReleaseAttachmentsDatasource.id);
this.http = new GithubHttp(GithubReleaseAttachmentsDatasource.id);
}
private async _findDigestFile(
release: GithubRestRelease,
digest: string,
): Promise<GithubDigestFile | null> {
const smallAssets = release.assets.filter(
(a: GithubRestAsset) => a.size < 5 * 1024,
);
for (const asset of smallAssets) {
const res = await this.http.getText(asset.browser_download_url);
for (const line of res.body.split(newlineRegex)) {
const [lineDigest, lineFilename] = line.split(regEx(/\s+/), 2);
if (lineDigest === digest) {
return {
assetName: asset.name,
digestedFileName: lineFilename,
currentVersion: release.tag_name,
currentDigest: lineDigest,
};
}
}
}
return null;
}
findDigestFile(
release: GithubRestRelease,
digest: string,
): Promise<GithubDigestFile | null> {
return withCache(
{
ttlMinutes: 1440,
namespace: `datasource-${GithubReleaseAttachmentsDatasource.id}`,
key: `findDigestFile:${release.html_url}:${digest}`,
},
() => this._findDigestFile(release, digest),
);
}
private async _downloadAndDigest(
asset: GithubRestAsset,
algorithm: string,
): Promise<string> {
const res = this.http.stream(asset.browser_download_url);
const digest = await hashStream(res, algorithm);
return digest;
}
downloadAndDigest(
asset: GithubRestAsset,
algorithm: string,
): Promise<string> {
return withCache(
{
ttlMinutes: 1440,
namespace: `datasource-${GithubReleaseAttachmentsDatasource.id}`,
key: `downloadAndDigest:${asset.browser_download_url}:${algorithm}`,
},
() => this._downloadAndDigest(asset, algorithm),
);
}
async findAssetWithDigest(
release: GithubRestRelease,
digest: string,
): Promise<GithubDigestFile | null> {
const algorithm = inferHashAlg(digest);
const assetsBySize = release.assets.sort(
(a: GithubRestAsset, b: GithubRestAsset) => {
if (a.size < b.size) {
return -1;
}
if (a.size > b.size) {
return 1;
}
return 0;
},
);
for (const asset of assetsBySize) {
const assetDigest = await this.downloadAndDigest(asset, algorithm);
if (assetDigest === digest) {
return {
assetName: asset.name,
currentVersion: release.tag_name,
currentDigest: assetDigest,
};
}
}
return null;
}
/** Identify the asset associated with a known digest. */
async findDigestAsset(
release: GithubRestRelease,
digest: string,
): Promise<GithubDigestFile | null> {
const digestFile = await this.findDigestFile(release, digest);
if (digestFile) {
return digestFile;
}
const asset = await this.findAssetWithDigest(release, digest);
return asset;
}
/** Given a digest asset, find the equivalent digest in a different release. */
async mapDigestAssetToRelease(
digestAsset: GithubDigestFile,
release: GithubRestRelease,
): Promise<string | null> {
const current = digestAsset.currentVersion.replace(regEx(/^v/), '');
const next = release.tag_name.replace(regEx(/^v/), '');
if (digestAsset.digestedFileName) {
const checksumAssetName = digestAsset.assetName.replace(current, next);
const checksumAsset = release.assets.find(
(a: GithubRestAsset) => a.name === checksumAssetName,
);
// If the checksum asset is not found in the new release, fall back to the download method
if (checksumAsset) {
const releaseFilename = digestAsset.digestedFileName.replace(
current,
next,
);
const res = await this.http.getText(checksumAsset.browser_download_url);
for (const line of res.body.split(newlineRegex)) {
const [lineDigest, lineFn] = line.split(regEx(/\s+/), 2);
if (lineFn === releaseFilename) {
return lineDigest;
}
}
return null;
}
}
const oldFileName = digestAsset.digestedFileName ?? digestAsset.assetName;
const fileName = oldFileName.replace(current, next);
const asset = release.assets.find(
(a: GithubRestAsset) => a.name === fileName,
);
if (!asset) {
return null;
}
const algorithm = inferHashAlg(digestAsset.currentDigest);
const newDigest = await this.downloadAndDigest(asset, algorithm);
return newDigest;
}
/**
* Attempts to resolve the digest for the specified package.
*
* The `newValue` supplied here should be a valid tag for the GitHub release.
* Requires `currentValue` and `currentDigest`.
*
* There may be many assets attached to the release. This function will:
* - Identify the asset pinned by `currentDigest` in the `currentValue` release
* - Download small release assets, parse as checksum manifests (e.g. `SHASUMS.txt`).
* - Download individual assets until `currentDigest` is encountered. This is limited to sha256 and sha512.
* - Map the hashed asset to `newValue` and return the updated digest as a string
*/
override async getDigest(
{
packageName: repo,
currentValue,
currentDigest,
registryUrl,
}: DigestConfig,
newValue: string,
): Promise<string | null> {
logger.debug(
{ repo, currentValue, currentDigest, registryUrl, newValue },
'getDigest',
);
if (!currentDigest) {
return null;
}
if (!currentValue) {
return currentDigest;
}
const apiBaseUrl = getApiBaseUrl(registryUrl);
const { body: currentRelease } =
await this.http.getJsonUnchecked<GithubRestRelease>(
`${apiBaseUrl}repos/${repo}/releases/tags/${currentValue}`,
);
const digestAsset = await this.findDigestAsset(
currentRelease,
currentDigest,
);
let newDigest: string | null;
if (!digestAsset || newValue === currentValue) {
newDigest = currentDigest;
} else {
const { body: newRelease } =
await this.http.getJsonUnchecked<GithubRestRelease>(
`${apiBaseUrl}repos/${repo}/releases/tags/${newValue}`,
);
newDigest = await this.mapDigestAssetToRelease(digestAsset, newRelease);
}
return newDigest;
}
/**
* This function can be used to fetch releases with a customizable versioning
* (e.g. semver) and with releases.
*
* This function will:
* - Fetch all releases
* - Sanitize the versions if desired (e.g. strip out leading 'v')
* - Return a dependency object containing sourceUrl string and releases array
*/
async getReleases(config: GetReleasesConfig): Promise<ReleaseResult> {
const releasesResult = await queryReleases(config, this.http);
const releases = releasesResult.map((item) => {
const { version, releaseTimestamp, isStable } = item;
const result: Release = {
version,
gitRef: version,
releaseTimestamp,
};
if (isBoolean(isStable)) {
result.isStable = isStable;
}
return result;
});
const sourceUrl = getSourceUrl(config.packageName, config.registryUrl);
return { sourceUrl, releases };
}
}