import is from '@sindresorhus/is'; import { GlobalConfig } from '../../../config/global.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 { ForgejoTagsDatasource } from '../../datasource/forgejo-tags/index.ts'; import { GiteaTagsDatasource } from '../../datasource/gitea-tags/index.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 & 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': return { registryUrls: [registryUrl], datasource: ForgejoTagsDatasource.id, }; case 'gitea': return { registryUrls: [registryUrl], datasource: GiteaTagsDatasource.id, }; case 'github': return { registryUrls: [registryUrl] }; } return { skipReason: 'unsupported-url', }; } const runnerVersionRegex = regEx( /^\s*(?[a-zA-Z]+)-(?[^\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 { 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 { const cacheKey = `github-actions:${actionsLockFile}`; const cached = memCache.get | 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 { 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 { 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; }