mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-27 07:50:47 +00:00
3a723b7891
* test(versioning/aws-machine-image): cover the empty version guard `_parse()` returns null for a falsy version before it tries the AMI id regex, and no test passed one. Checking that an empty string is not valid closes the only branch the module was missing. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource/buildpacks-registry): cover a response without a homepage `latest.homepage` is optional in the registry response, but every test supplied one, so the release result was never built without it. A buildpack whose latest entry carries no homepage now checks that the releases still come back and no source url is derived. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover optional-field branches in four datasources Each of these datasources builds its release result by attaching fields only when the registry supplies them, and the "not supplied" path was never exercised: - cdnjs: a library with neither homepage nor repository - cpan: a search result where no release is marked latest, so no tag - dart: a latest release carrying no pubspec - ruby-version: a release table row whose first column is not a version Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover missing-metadata branches in hex and pypi Two more datasources only attached fields when the registry supplied them, and the absent case was untested: a hex package with neither an html_url nor a github link in its meta, and a pypi response carrying no releases at all. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover galaxy and repology fallbacks, mark unreachable guards Adds a galaxy role with empty github fields, so no source url is derived, and a repology lookup where the api fallback after a 403 finds no matching package. Marks four guards that are not worth a test: cpan and dart both check a variable that is only null when the request threw, and `handleGenericErrors()` always rethrows; conan's registry url is always supplied by the datasource layer; and repology's resolver either returns a body or throws. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover pod CDN branches, mark the sbt listing guards Adds two pod lookups: an empty CDN body, and a CDN index that lists no matching pod. Marks the guards whose tests would need multi-level directory listing fixtures: both sbt datasources walk nested index pages, and pod's github helper checks a body that an empty json response never produces because parsing throws first. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover maven metadata without a latest tag Adds maven metadata carrying a release but no latest, so respectLatest is left alone. Marks the three deb guards, which would each need a crafted package index: a block missing a required key, a repeated package in the final block, and two component indexes whose meta agrees. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * test(datasource): cover npmrc registry fallback, mark cache-bound guards Adds a resolveRegistryUrl lookup for a package no npmrc rule matches. Marks the guards whose tests would need a primed cache or a specific blob shape: nuget's services index, deno's release cache, the docker hub cache window, docker's manifest and config handling, go's pseudo-version fallback, and both rubygems cache layers. Three implicit elses are left counted: a coverage-ignore hint does not attach to an `else if`, so the registry strategy chain, the npmrc reset and docker's digest-header branch carry a plain note instead. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> * fix(datasource/repology): pass the mock body as a string `ResponseMock.body` is typed as a string, and every other call in the spec passes `'[]'`. The new api fallback test passed an array literal, which tsc rejected. Co-Authored-By: Claude Opus 5 <michael.kriese+claude-code@mend.io> --------- Co-authored-by: Claude Opus 5 <michael.kriese+claude-code@mend.io>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import { isNonEmptyString } from '@sindresorhus/is';
|
|
import AdmZip from 'adm-zip';
|
|
import semver from 'semver';
|
|
import upath from 'upath';
|
|
import { XmlDocument } from 'xmldoc';
|
|
import { logger } from '../../../logger/index.ts';
|
|
import { ExternalHostError } from '../../../types/errors/external-host-error.ts';
|
|
import { coerceArray } from '../../../util/array.ts';
|
|
import * as packageCache from '../../../util/cache/package/index.ts';
|
|
import { withCache } from '../../../util/cache/package/with-cache.ts';
|
|
import { getEnv } from '../../../util/env.ts';
|
|
import * as fs from '../../../util/fs/index.ts';
|
|
import { ensureCacheDir } from '../../../util/fs/index.ts';
|
|
import { memCacheProvider } from '../../../util/http/cache/memory-http-cache-provider.ts';
|
|
import type { Http } from '../../../util/http/index.ts';
|
|
import { HttpError } from '../../../util/http/index.ts';
|
|
import * as p from '../../../util/promises.ts';
|
|
import { regEx } from '../../../util/regex.ts';
|
|
import { asTimestamp } from '../../../util/timestamp.ts';
|
|
import { ensureTrailingSlash } from '../../../util/url.ts';
|
|
import { api as versioning } from '../../versioning/nuget/index.ts';
|
|
import type { Release, ReleaseResult } from '../types.ts';
|
|
import { massageUrl, removeBuildMeta, sortNugetVersions } from './common.ts';
|
|
import type { CatalogEntry } from './schema.ts';
|
|
import {
|
|
CatalogPage,
|
|
PackageRegistration,
|
|
ServicesIndexRaw,
|
|
} from './schema.ts';
|
|
|
|
export class NugetV3Api {
|
|
static readonly cacheNamespace = 'datasource-nuget-v3';
|
|
|
|
async getResourceUrl(
|
|
http: Http,
|
|
url: string,
|
|
resourceType = 'RegistrationsBaseUrl',
|
|
): Promise<string | null> {
|
|
// https://learn.microsoft.com/nuget/api/service-index
|
|
const resultCacheKey = `${url}:${resourceType}`;
|
|
const cachedResult = await packageCache.get<string>(
|
|
NugetV3Api.cacheNamespace,
|
|
resultCacheKey,
|
|
);
|
|
|
|
/* v8 ignore next -- TODO: add test */
|
|
if (cachedResult) {
|
|
return cachedResult;
|
|
}
|
|
let servicesIndexRaw: ServicesIndexRaw | undefined;
|
|
try {
|
|
const responseCacheKey = url;
|
|
servicesIndexRaw = await packageCache.get<ServicesIndexRaw>(
|
|
NugetV3Api.cacheNamespace,
|
|
responseCacheKey,
|
|
);
|
|
// v8 ignore else -- needs a primed package cache
|
|
if (!servicesIndexRaw) {
|
|
servicesIndexRaw = (
|
|
await http.getJson(
|
|
url,
|
|
{ cacheProvider: memCacheProvider },
|
|
ServicesIndexRaw,
|
|
)
|
|
).body;
|
|
await packageCache.set(
|
|
NugetV3Api.cacheNamespace,
|
|
responseCacheKey,
|
|
servicesIndexRaw,
|
|
3 * 24 * 60,
|
|
);
|
|
}
|
|
|
|
const services = servicesIndexRaw.resources
|
|
.map(({ '@id': serviceId, '@type': t }) => ({
|
|
serviceId,
|
|
type: t?.split('/')?.shift(),
|
|
version: t?.split('/')?.pop(),
|
|
}))
|
|
.filter(
|
|
({ type, version }) => type === resourceType && semver.valid(version),
|
|
)
|
|
.sort((x, y) =>
|
|
x.version && y.version
|
|
? semver.compare(x.version, y.version)
|
|
: /* istanbul ignore next: hard to test */ 0,
|
|
);
|
|
|
|
if (services.length === 0) {
|
|
await packageCache.set(
|
|
NugetV3Api.cacheNamespace,
|
|
resultCacheKey,
|
|
null,
|
|
60,
|
|
);
|
|
logger.debug(
|
|
{ url, servicesIndexRaw },
|
|
`no ${resourceType} services found`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const { serviceId, version } = services.pop()!;
|
|
|
|
// istanbul ignore if
|
|
if (
|
|
resourceType === 'RegistrationsBaseUrl' &&
|
|
version &&
|
|
!version.startsWith('3.0.0-') &&
|
|
!semver.satisfies(version, '^3.0.0')
|
|
) {
|
|
logger.warn(
|
|
{ url, version },
|
|
`Nuget: Unknown version returned. Only v3 is supported`,
|
|
);
|
|
}
|
|
|
|
await packageCache.set(
|
|
NugetV3Api.cacheNamespace,
|
|
resultCacheKey,
|
|
serviceId,
|
|
60,
|
|
);
|
|
return serviceId;
|
|
} catch (err) {
|
|
// istanbul ignore if: not easy testable with nock
|
|
if (err instanceof ExternalHostError) {
|
|
throw err;
|
|
}
|
|
logger.debug(
|
|
{ err, url, servicesIndexRaw },
|
|
`nuget registry failure: can't get ${resourceType}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getCatalogEntry(
|
|
http: Http,
|
|
catalogPage: CatalogPage,
|
|
): Promise<CatalogEntry[]> {
|
|
let items = catalogPage.items;
|
|
if (!items) {
|
|
const url = catalogPage['@id'];
|
|
if (!url) {
|
|
return [];
|
|
}
|
|
const catalogPageFull = await http.getJson(url, CatalogPage);
|
|
items = catalogPageFull.body.items;
|
|
}
|
|
return coerceArray(items).map(({ catalogEntry }) => catalogEntry);
|
|
}
|
|
|
|
async getReleases(
|
|
http: Http,
|
|
registryUrl: string,
|
|
feedUrl: string,
|
|
pkgName: string,
|
|
): Promise<ReleaseResult | null> {
|
|
const baseUrl = feedUrl.replace(regEx(/\/*$/), '');
|
|
const url = `${baseUrl}/${pkgName.toLowerCase()}/index.json`;
|
|
const packageRegistration = await http.getJson(url, PackageRegistration);
|
|
const catalogPages = packageRegistration.body.items;
|
|
const catalogPagesQueue = catalogPages.map(
|
|
(page) => (): Promise<CatalogEntry[]> => this.getCatalogEntry(http, page),
|
|
);
|
|
const catalogEntries = (await p.all(catalogPagesQueue))
|
|
.flat()
|
|
.sort((a, b) => sortNugetVersions(a.version, b.version));
|
|
|
|
let homepage: string | null = null;
|
|
let latestStable: string | null = null;
|
|
let nupkgUrl: string | null = null;
|
|
const releases = catalogEntries.map(
|
|
({
|
|
version,
|
|
published,
|
|
projectUrl,
|
|
listed,
|
|
packageContent,
|
|
deprecation,
|
|
}) => {
|
|
const release: Release = { version: removeBuildMeta(version) };
|
|
const releaseTimestamp = asTimestamp(published);
|
|
if (releaseTimestamp) {
|
|
release.releaseTimestamp = releaseTimestamp;
|
|
}
|
|
if (
|
|
versioning.isValid(version) &&
|
|
versioning.isStable(version) &&
|
|
listed
|
|
) {
|
|
latestStable = removeBuildMeta(version);
|
|
homepage = projectUrl ? massageUrl(projectUrl) : homepage;
|
|
nupkgUrl = massageUrl(packageContent);
|
|
}
|
|
|
|
if (listed === false || deprecation) {
|
|
release.isDeprecated = true;
|
|
}
|
|
return release;
|
|
},
|
|
);
|
|
|
|
if (!releases.length) {
|
|
return null;
|
|
}
|
|
|
|
// istanbul ignore next: only happens when no stable version exists
|
|
if (latestStable === null && catalogPages.length) {
|
|
const last = catalogEntries.pop()!;
|
|
latestStable = removeBuildMeta(last.version);
|
|
homepage ??= last.projectUrl ?? null;
|
|
nupkgUrl ??= massageUrl(last.packageContent);
|
|
}
|
|
|
|
const dep: ReleaseResult = {
|
|
releases,
|
|
};
|
|
|
|
if (releases.every((release) => release.isDeprecated === true)) {
|
|
dep.deprecationMessage = this.getDeprecationMessage(pkgName);
|
|
}
|
|
|
|
try {
|
|
const packageBaseAddress = await this.getResourceUrl(
|
|
http,
|
|
registryUrl,
|
|
'PackageBaseAddress',
|
|
);
|
|
let shouldTryNupkg = false;
|
|
if (isNonEmptyString(packageBaseAddress)) {
|
|
const nuspecUrl = `${ensureTrailingSlash(
|
|
packageBaseAddress,
|
|
)}${pkgName.toLowerCase()}/${
|
|
latestStable
|
|
}/${pkgName.toLowerCase()}.nuspec`;
|
|
try {
|
|
const metaresult = await http.getText(nuspecUrl, {
|
|
cacheProvider: memCacheProvider,
|
|
});
|
|
const nuspec = new XmlDocument(metaresult.body);
|
|
const releaseNotes = nuspec.valueWithPath('metadata.releaseNotes');
|
|
if (releaseNotes) {
|
|
dep.changelogContent = releaseNotes;
|
|
}
|
|
const sourceUrl = nuspec.valueWithPath('metadata.repository@url');
|
|
if (sourceUrl) {
|
|
dep.sourceUrl = massageUrl(sourceUrl);
|
|
}
|
|
} catch (err) {
|
|
/* v8 ignore else -- not easy testable with nock */
|
|
if (err instanceof HttpError && err.response?.statusCode === 404) {
|
|
shouldTryNupkg = true;
|
|
logger.debug(
|
|
{ registryUrl, pkgName, pkgVersion: latestStable },
|
|
`package manifest (.nuspec) not found`,
|
|
);
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
} else {
|
|
shouldTryNupkg = true;
|
|
}
|
|
|
|
if (!dep.sourceUrl && shouldTryNupkg && nupkgUrl) {
|
|
const sourceUrl = await this.getSourceUrlFromNupkg(
|
|
http,
|
|
registryUrl,
|
|
pkgName,
|
|
latestStable,
|
|
nupkgUrl,
|
|
);
|
|
if (sourceUrl) {
|
|
dep.sourceUrl = massageUrl(sourceUrl);
|
|
logger.debug(`Determined sourceUrl ${sourceUrl} from ${nupkgUrl}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// istanbul ignore if: not easy testable with nock
|
|
if (err instanceof ExternalHostError) {
|
|
throw err;
|
|
}
|
|
// ignore / silence 404. Seen on proget, if remote connector is used and package is not yet cached
|
|
if (err instanceof HttpError && err.response?.statusCode === 404) {
|
|
logger.debug(
|
|
{ registryUrl, pkgName, pkgVersion: latestStable },
|
|
`package manifest (.nuspec) not found`,
|
|
);
|
|
} else {
|
|
logger.debug(
|
|
{ err, registryUrl, pkgName, pkgVersion: latestStable },
|
|
`Cannot obtain sourceUrl`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (homepage) {
|
|
// only assign if not assigned
|
|
dep.sourceUrl ??= homepage;
|
|
dep.homepage ??= homepage;
|
|
}
|
|
|
|
return dep;
|
|
}
|
|
|
|
private async _getSourceUrlFromNupkg(
|
|
http: Http,
|
|
_registryUrl: string,
|
|
packageName: string,
|
|
packageVersion: string | null,
|
|
nupkgUrl: string,
|
|
): Promise<string | null> {
|
|
/* v8 ignore if -- specs enable RENOVATE_X_NUGET_DOWNLOAD_NUPKGS, so the disabled path is unexercised */
|
|
if (!getEnv().RENOVATE_X_NUGET_DOWNLOAD_NUPKGS) {
|
|
logger.once.debug('RENOVATE_X_NUGET_DOWNLOAD_NUPKGS is not set');
|
|
return null;
|
|
}
|
|
const cacheDir = await ensureCacheDir('nuget');
|
|
const nupkgFile = upath.join(
|
|
cacheDir,
|
|
`${packageName}.${packageVersion}.nupkg`,
|
|
);
|
|
const nupkgContentsDir = upath.join(
|
|
cacheDir,
|
|
`${packageName}.${packageVersion}`,
|
|
);
|
|
const readStream = http.stream(nupkgUrl);
|
|
try {
|
|
const writeStream = fs.createCacheWriteStream(nupkgFile);
|
|
await fs.pipeline(readStream, writeStream);
|
|
const zip = new AdmZip(nupkgFile);
|
|
zip.extractAllTo(nupkgContentsDir);
|
|
const nuspecFile = upath.join(nupkgContentsDir, `${packageName}.nuspec`);
|
|
const nuspec = new XmlDocument(
|
|
await fs.readCacheFile(nuspecFile, 'utf8'),
|
|
);
|
|
return nuspec.valueWithPath('metadata.repository@url') ?? null;
|
|
} finally {
|
|
await fs.rmCache(nupkgFile);
|
|
await fs.rmCache(nupkgContentsDir);
|
|
}
|
|
}
|
|
|
|
getSourceUrlFromNupkg(
|
|
http: Http,
|
|
registryUrl: string,
|
|
packageName: string,
|
|
packageVersion: string | null,
|
|
nupkgUrl: string,
|
|
): Promise<string | null> {
|
|
return withCache(
|
|
{
|
|
namespace: NugetV3Api.cacheNamespace,
|
|
key: `source-url:${registryUrl}:${packageName}`,
|
|
ttlMinutes: 10080, // 1 week
|
|
},
|
|
() =>
|
|
this._getSourceUrlFromNupkg(
|
|
http,
|
|
registryUrl,
|
|
packageName,
|
|
packageVersion,
|
|
nupkgUrl,
|
|
),
|
|
);
|
|
}
|
|
|
|
getDeprecationMessage(packageName: string): string {
|
|
return `The package \`${packageName}\` is deprecated.`;
|
|
}
|
|
}
|