Files
renovate/lib/modules/manager/github-actions/extract.ts
Sebastian Poxhofer 0583e54718 refactor(constants): add a platform family capability table (#46191)
* 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
2026-09-21 15:27:50 +00:00

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;
}