mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-31 17:51:49 +00:00
0583e54718
* refactor(constants): add a platform family capability table Every consumer of `detectPlatform` carried its own mapping from the detected platform to a tags datasource or a web UI path, and the seven `*_API_USING_HOST_TYPES` arrays were checked one by one to produce that platform in the first place. Introduce `PLATFORM_FAMILIES`, one record keyed by family holding the host types, the hostnames which identify the family outright, the tags datasource, the API base URL derivation and the web UI path to a directory at the default branch. A family is a group of platforms, so the ids are checked against `PlatformId`: a family which is not a platform is a compile error. Not every platform is a family, because codecommit, gerrit, local and scm-manager are never recognized from a URL. `detectPlatform` loops the table for both its hostname and its hostType matching, and the consumers look their answers up: - `github-actions` and `pre-commit` read `tagsDatasource` instead of importing the tags datasource classes. - The PR body reads `webDirPath` instead of special-casing Bitbucket and Bitbucket Server. `apiBaseUrl` has no consumer yet; the changelog sources pick it up in the next commit. `lib/util/http/auth.ts` and `lib/util/http/host-rules.ts` still read six of the old arrays by name, so those stay as aliases of the table and are migrated in a follow-up. `AZURE_API_USING_HOST_TYPES` had no other consumer and is dropped. Behaviour changes: none. `detectPlatform` now tests every known hostname before its name heuristics instead of interleaving the two, which is equivalent because the hostname lists are pairwise disjoint and no known hostname contains the name of another family. * Apply suggestion from @secustor
401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import is from '@sindresorhus/is';
|
|
import { GlobalConfig } from '../../../config/global.ts';
|
|
import { PLATFORM_FAMILIES } from '../../../constants/index.ts';
|
|
import { logger, withMeta } from '../../../logger/index.ts';
|
|
import * as memCache from '../../../util/cache/memory/index.ts';
|
|
import { detectPlatform } from '../../../util/common.ts';
|
|
import { readLocalFile } from '../../../util/fs/index.ts';
|
|
import { newlineRegex, regEx } from '../../../util/regex.ts';
|
|
import { parseUrl } from '../../../util/url.ts';
|
|
import { GithubDigestDatasource } from '../../datasource/github-digest/index.ts';
|
|
import { GithubRunnersDatasource } from '../../datasource/github-runners/index.ts';
|
|
import { GithubTagsDatasource } from '../../datasource/github-tags/index.ts';
|
|
import * as dockerVersioning from '../../versioning/docker/index.ts';
|
|
import * as exactVersioning from '../../versioning/exact/index.ts';
|
|
import * as githubActionsVersioning from '../../versioning/github-actions/index.ts';
|
|
import { getDep } from '../dockerfile/extract.ts';
|
|
import type {
|
|
ExtractConfig,
|
|
PackageDependency,
|
|
PackageFileContent,
|
|
} from '../types.ts';
|
|
import { actionsLockFile, isLockfileManaged } from './common.ts';
|
|
import { isSha, isShortSha, parseUsesLine, versionLikeRe } from './parse.ts';
|
|
import type { UsesStep } from './schema.ts';
|
|
import { ActionsLockfile, CommunityActions, Workflow } from './schema.ts';
|
|
import type {
|
|
DockerReference,
|
|
LockfileState,
|
|
RepositoryReference,
|
|
} from './types.ts';
|
|
|
|
// detects if we run against a Github Enterprise Server and adds the URL to the beginning of the registryURLs for looking up Actions
|
|
// This reflects the behavior of how GitHub looks up Actions
|
|
// First on the Enterprise Server, then on GitHub.com
|
|
function detectCustomGitHubRegistryUrlsForActions(): PackageDependency {
|
|
const endpoint = GlobalConfig.get('endpoint');
|
|
const registryUrls = ['https://github.com'];
|
|
if (endpoint && GlobalConfig.get('platform') === 'github') {
|
|
const parsedEndpoint = parseUrl(endpoint);
|
|
if (!parsedEndpoint) {
|
|
logger.warn({ endpoint }, 'Failed to parse endpoint url');
|
|
return {};
|
|
}
|
|
|
|
if (
|
|
parsedEndpoint.host !== 'github.com' &&
|
|
parsedEndpoint.host !== 'api.github.com'
|
|
) {
|
|
registryUrls.unshift(
|
|
`${parsedEndpoint.protocol}//${parsedEndpoint.host}`,
|
|
);
|
|
return { registryUrls };
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function extractDockerAction(
|
|
actionRef: DockerReference,
|
|
config: ExtractConfig,
|
|
): PackageDependency {
|
|
const dep = getDep(actionRef.originalRef, true, config.registryAliases);
|
|
dep.depType = 'docker';
|
|
dep.replaceString = actionRef.originalRef;
|
|
return dep;
|
|
}
|
|
|
|
const reusableWorkflowPathRe = regEx(/^\.github\/workflows\/[^/]+\.ya?ml$/);
|
|
|
|
function extractRepositoryAction(
|
|
actionRef: RepositoryReference,
|
|
parsed: ReturnType<typeof parseUsesLine> & object,
|
|
customRegistryUrlsPackageDependency: PackageDependency,
|
|
): PackageDependency {
|
|
const {
|
|
replaceString: valueString,
|
|
quote,
|
|
commentData,
|
|
commentPrecedingWhitespace,
|
|
} = parsed;
|
|
const {
|
|
owner,
|
|
repo,
|
|
path: subPath,
|
|
ref,
|
|
hostname,
|
|
isExplicitHostname,
|
|
} = actionRef;
|
|
|
|
const registryUrl = isExplicitHostname ? `https://${hostname}/` : '';
|
|
const packageName = `${owner}/${repo}`;
|
|
const depName = `${registryUrl}${packageName}`;
|
|
const pathSuffix = subPath ? `/${subPath}` : '';
|
|
const commentWs = commentPrecedingWhitespace || ' ';
|
|
const isReusableWorkflow = !!subPath && reusableWorkflowPathRe.test(subPath);
|
|
|
|
const dep: PackageDependency = {
|
|
depName,
|
|
commitMessageTopic: '{{{depName}}} action',
|
|
versioning: githubActionsVersioning.id,
|
|
depType: isReusableWorkflow ? 'workflow' : 'action',
|
|
replaceString: valueString,
|
|
autoReplaceStringTemplate: `${quote}{{depName}}${pathSuffix}@{{#if newDigest}}{{newDigest}}${quote}{{#if newValue}}${commentWs}# {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}${quote}{{/unless}}`,
|
|
...(isExplicitHostname
|
|
? detectDatasource(registryUrl)
|
|
: customRegistryUrlsPackageDependency),
|
|
};
|
|
|
|
if (packageName !== depName) {
|
|
dep.packageName = packageName;
|
|
}
|
|
|
|
// Extend replaceString to include relevant comment portions:
|
|
// - Pinned version or ref: include only up to the matched token (truncate trailing text)
|
|
// - Ratchet exclude: include the full comment to preserve the marker
|
|
const pinComment =
|
|
commentData.pinnedVersion ??
|
|
(isSha(ref) || isShortSha(ref) ? commentData.ref : undefined);
|
|
if (
|
|
pinComment &&
|
|
!is.undefined(commentData.index) &&
|
|
!is.undefined(commentData.matchedString)
|
|
) {
|
|
const cleanComment = parsed.commentString.slice(1);
|
|
const matchEndIndex = commentData.index + commentData.matchedString.length;
|
|
const commentSuffix = cleanComment.slice(0, matchEndIndex);
|
|
dep.replaceString = `${valueString}${commentPrecedingWhitespace}#${commentSuffix}`;
|
|
} else if (commentData.ratchetExclude) {
|
|
dep.replaceString =
|
|
valueString + commentPrecedingWhitespace + parsed.commentString;
|
|
}
|
|
|
|
if (isSha(ref)) {
|
|
dep.currentValue = commentData.pinnedVersion ?? commentData.ref;
|
|
dep.currentDigest = ref;
|
|
} else if (isShortSha(ref)) {
|
|
dep.currentValue = commentData.pinnedVersion ?? commentData.ref;
|
|
dep.currentDigestShort = ref;
|
|
} else {
|
|
dep.currentValue = ref;
|
|
}
|
|
|
|
if (!dep.currentValue) {
|
|
dep.enabled = false;
|
|
dep.skipReason = 'unversioned-reference';
|
|
}
|
|
|
|
const isVersionLike =
|
|
dep.currentValue && versionLikeRe.test(dep.currentValue);
|
|
if (!dep.datasource && dep.currentValue && !isVersionLike) {
|
|
dep.datasource = GithubDigestDatasource.id;
|
|
dep.versioning = exactVersioning.id;
|
|
}
|
|
|
|
dep.datasource ??= GithubTagsDatasource.id;
|
|
|
|
return dep;
|
|
}
|
|
|
|
function extractWithRegex(
|
|
content: string,
|
|
config: ExtractConfig,
|
|
): PackageDependency[] {
|
|
const customRegistryUrlsPackageDependency =
|
|
detectCustomGitHubRegistryUrlsForActions();
|
|
logger.trace('github-actions.extractWithRegex()');
|
|
const deps: PackageDependency[] = [];
|
|
|
|
for (const line of content.split(newlineRegex)) {
|
|
if (line.trim().startsWith('#')) {
|
|
continue;
|
|
}
|
|
|
|
const parsed = parseUsesLine(line);
|
|
if (!parsed?.actionRef) {
|
|
continue;
|
|
}
|
|
|
|
const { actionRef } = parsed;
|
|
|
|
if (actionRef.kind === 'docker') {
|
|
deps.push(extractDockerAction(actionRef, config));
|
|
continue;
|
|
}
|
|
|
|
// v8 ignore else -- the parsed ref is either a docker or a repository ref
|
|
if (actionRef.kind === 'repository') {
|
|
deps.push(
|
|
extractRepositoryAction(
|
|
actionRef,
|
|
parsed,
|
|
customRegistryUrlsPackageDependency,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return deps;
|
|
}
|
|
|
|
function detectDatasource(registryUrl: string): PackageDependency {
|
|
const platform = detectPlatform(registryUrl);
|
|
|
|
switch (platform) {
|
|
case 'forgejo':
|
|
case 'gitea':
|
|
return {
|
|
registryUrls: [registryUrl],
|
|
datasource: PLATFORM_FAMILIES[platform].tagsDatasource,
|
|
};
|
|
case 'github':
|
|
// GitHub is left without a datasource on purpose: `extractRepositoryAction`
|
|
// then picks `github-digest` or `github-tags` from the ref it parsed.
|
|
return { registryUrls: [registryUrl] };
|
|
}
|
|
|
|
return {
|
|
skipReason: 'unsupported-url',
|
|
};
|
|
}
|
|
|
|
const runnerVersionRegex = regEx(
|
|
/^\s*(?<depName>[a-zA-Z]+)-(?<currentValue>[^\s]+)/,
|
|
);
|
|
|
|
function extractRunner(runner: string): PackageDependency | null {
|
|
const runnerVersionGroups = runnerVersionRegex.exec(runner)?.groups;
|
|
if (!runnerVersionGroups) {
|
|
return null;
|
|
}
|
|
|
|
const { depName, currentValue } = runnerVersionGroups;
|
|
|
|
if (!GithubRunnersDatasource.isValidRunner(depName, currentValue)) {
|
|
return null;
|
|
}
|
|
|
|
const dependency: PackageDependency = {
|
|
depName,
|
|
currentValue,
|
|
replaceString: `${depName}-${currentValue}`,
|
|
depType: 'github-runner',
|
|
datasource: GithubRunnersDatasource.id,
|
|
autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
|
|
};
|
|
|
|
if (!dockerVersioning.api.isValid(currentValue)) {
|
|
dependency.skipReason = 'invalid-version';
|
|
}
|
|
|
|
return dependency;
|
|
}
|
|
|
|
function extractSteps(steps: UsesStep[]): PackageDependency[] {
|
|
const deps: PackageDependency[] = [];
|
|
|
|
for (const step of steps) {
|
|
const res = CommunityActions.safeParse(step);
|
|
if (res.success) {
|
|
deps.push(...res.data);
|
|
}
|
|
}
|
|
|
|
return deps;
|
|
}
|
|
|
|
function extractWithYAMLParser(
|
|
content: string,
|
|
packageFile: string,
|
|
config: ExtractConfig,
|
|
): PackageDependency[] {
|
|
logger.trace('github-actions.extractWithYAMLParser()');
|
|
|
|
const obj = withMeta({ packageFile }, () => Workflow.parse(content));
|
|
if (!obj) {
|
|
return [];
|
|
}
|
|
|
|
if ('runs' in obj && obj.runs.steps) {
|
|
return extractSteps(obj.runs.steps);
|
|
}
|
|
|
|
if (!('jobs' in obj)) {
|
|
return [];
|
|
}
|
|
|
|
const deps: PackageDependency[] = [];
|
|
|
|
for (const job of Object.values(obj.jobs)) {
|
|
if (job.container) {
|
|
const dep = getDep(job.container, true, config.registryAliases);
|
|
// v8 ignore else -- `getDep()` always returns a dep
|
|
if (dep) {
|
|
dep.depType = 'container';
|
|
deps.push(dep);
|
|
}
|
|
}
|
|
|
|
for (const service of job.services) {
|
|
const dep = getDep(service, true, config.registryAliases);
|
|
// v8 ignore else -- `getDep()` always returns a dep
|
|
if (dep) {
|
|
dep.depType = 'service';
|
|
deps.push(dep);
|
|
}
|
|
}
|
|
|
|
for (const runner of job['runs-on']) {
|
|
const dep = extractRunner(runner);
|
|
if (dep) {
|
|
deps.push(dep);
|
|
}
|
|
}
|
|
|
|
deps.push(...extractSteps(job.steps));
|
|
}
|
|
|
|
return deps;
|
|
}
|
|
|
|
async function readLockfile(): Promise<LockfileState> {
|
|
const content = await readLocalFile(actionsLockFile, 'utf8');
|
|
if (!content) {
|
|
return { type: 'missing' };
|
|
}
|
|
|
|
const parsed = ActionsLockfile.safeParse(content);
|
|
if (!parsed.success) {
|
|
// `updateActionsLockfile` warns about this once per branch, so stay quiet
|
|
logger.debug(`Failed to parse ${actionsLockFile}`);
|
|
return { type: 'unparseable' };
|
|
}
|
|
|
|
return { type: 'parsed', onboardedWorkflows: parsed.data.workflows };
|
|
}
|
|
|
|
/**
|
|
* A repository has a single lock file, but can have any number of package files, so read and parse it only once.
|
|
*/
|
|
function getLockfile(): Promise<LockfileState> {
|
|
const cacheKey = `github-actions:${actionsLockFile}`;
|
|
const cached = memCache.get<Promise<LockfileState> | undefined>(cacheKey);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
|
|
const result = readLockfile();
|
|
memCache.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Whether `gh actions-lock` owns the digests in this file.
|
|
*
|
|
* It rewrites the workflows it manages back to plain refs when regenerating, so an inline digest pin would be stripped straight back out.
|
|
*/
|
|
async function isManagedByLockfile(packageFile: string): Promise<boolean> {
|
|
const lockfile = await getLockfile();
|
|
|
|
switch (lockfile.type) {
|
|
case 'missing':
|
|
return false;
|
|
case 'unparseable':
|
|
// The tool refuses to regenerate a lock file which it cannot parse, so treat everything as managed: pinning inline here would raise a PR which pins the digests the tool owns and leaves the lock file stale.
|
|
return true;
|
|
case 'parsed':
|
|
return isLockfileManaged(packageFile, lockfile.onboardedWorkflows);
|
|
}
|
|
}
|
|
|
|
export async function extractPackageFile(
|
|
content: string,
|
|
packageFile: string,
|
|
config: ExtractConfig = {}, // TODO: enforce ExtractConfig
|
|
): Promise<PackageFileContent | null> {
|
|
logger.trace(`github-actions.extractPackageFile(${packageFile})`);
|
|
|
|
const deps = [
|
|
...extractWithRegex(content, config),
|
|
...extractWithYAMLParser(content, packageFile, config),
|
|
];
|
|
|
|
if (!deps.length) {
|
|
return null;
|
|
}
|
|
|
|
const res: PackageFileContent = { deps };
|
|
|
|
if (await isManagedByLockfile(packageFile)) {
|
|
// Deliberately no `lockFiles`: nothing here goes through `updateArtifacts`, and `matchFileNames` also tests `lockFiles`, so declaring it would make a negated rule such as `!.github/workflows/release.yml` match every workflow through the lock file path.
|
|
for (const dep of deps) {
|
|
// The lock file only records `OWNER/REPO@REF` pins, so a `docker://` image in a `uses:` is still ours to pin.
|
|
if (dep.depType === 'action' || dep.depType === 'workflow') {
|
|
dep.digestManagedExternally = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|