mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 15:01:49 +00:00
19306f7e17
* refactor(manager/helmv3): share generateHelmEnvs with kustomize Move kustomize's generateHelmEnvs into helmv3/common.ts and delete the kustomize copy. The merged function always sets the three HELM_* path variables and sets HELM_EXPERIMENTAL_OCI unless a helm constraint is present and proves helm >= 3.8 (which ignores the variable). Behaviour change: kustomize now sets HELM_EXPERIMENTAL_OCI when the helm constraint is unknown (previously it only set it for a known constraint below 3.8). helmv3 and helmfile now omit the variable once a resolved helm constraint proves helm >= 3.8. * refactor(manager/helmv3): share OCI registry login helper with helmfile Extract a shared generateRegistryLoginCmd(name, registry) helper in helmv3/common.ts that strips the oci:// prefix, derives the host and looks up the matching host rule before delegating to generateLoginCmd. helmv3/artifacts.ts and helmfile/artifacts.ts now both call this helper instead of each building a RepositoryRule inline, removing the stale oci:// TODO. helmfile/utils.ts loses its duplicate generateRegistryLoginCmd, and its unrelated isOCIRegistry(repository) (checking the oci flag) is renamed to isOciRepositoryFlagSet so it no longer collides in name with helmv3/oci.ts's prefix-based isOCIRegistry(string), which helmfile/extract.ts also imports. Addresses audit finding M8. Generated `helm registry login` commands are unchanged, proved by the existing artifacts specs passing with their original expectations untouched. * fix(manager): share one local Helm chart path check helmfile, flux, fleet and terraform each decided differently whether a Helm chart reference points at a local path. fleet and terraform used terraform's checkIfStringIsPath, whose regex was unanchored with unescaped dots, so it matched any string containing a slash followed by a non-slash character. Add isLocalChartPath to helmv3/utils.ts with helmfile's prefix check and use it in all four managers. This drops the fleet to terraform dependency and removes checkIfStringIsPath, which had no other users. * refactor(manager/helm-requirements): reuse helmv3 alias and repository helpers Replace the duplicated alias-resolution and repository-classification logic in helm-requirements/extract.ts with the shared isAlias, resolveAlias and parseRepository helpers from helmv3/utils.ts (audit finding M9). Behaviour is unchanged: same skipReasons, same registryUrls, same handling of a missing repository or missing registryAliases config. * refactor(manager/dockerfile): add an options form of getDep for CI managers `getDep()` gains an overload taking a `GetDepOptions` object with `specifyReplaceString`, `registryAliases` and `depType`, replacing the opaque boolean second positional argument at the CI manager call sites. The positional form keeps working for all other callers. Migrated: bitbucket-pipelines, droneci, circleci, github-actions, azure-pipelines and woodpecker. Behaviour changes: - azure-pipelines container resources now honour `registryAliases`, which `extractContainer()` previously ignored - woodpecker dependencies now carry `depType: 'docker'`, matching the other CI managers * refactor(manager/woodpecker): do not set depType on docker image deps * refactor(manager/dockerfile): drop the positional getDep signature * refactor(manager/helm-requirements): reuse helmv3 alias and repository helpers Replace the duplicated alias-resolution and repository-classification logic in helm-requirements/extract.ts with the shared isAlias, resolveAlias and parseRepository helpers from helmv3/utils.ts (audit finding M9). Behaviour is unchanged: same skipReasons, same registryUrls, same handling of a missing repository or missing registryAliases config.
403 lines
12 KiB
TypeScript
403 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, {
|
|
registryAliases: config.registryAliases,
|
|
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) {
|
|
deps.push(
|
|
getDep(job.container, {
|
|
registryAliases: config.registryAliases,
|
|
depType: 'container',
|
|
}),
|
|
);
|
|
}
|
|
|
|
for (const service of job.services) {
|
|
deps.push(
|
|
getDep(service, {
|
|
registryAliases: config.registryAliases,
|
|
depType: 'service',
|
|
}),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|