import { quote } from 'shlex'; import { GlobalConfig } from '../../../config/global.ts'; import { TEMPORARY_ERROR } from '../../../constants/error-messages.ts'; import { logger } from '../../../logger/index.ts'; import { coerceArray } from '../../../util/array.ts'; import { findGithubToken } from '../../../util/check-token.ts'; import { detectPlatform } from '../../../util/common.ts'; import { exec } from '../../../util/exec/index.ts'; import type { ExecOptions, ExtraEnv } from '../../../util/exec/types.ts'; import { readLocalFile, writeLocalFile } from '../../../util/fs/index.ts'; import { getRepoStatus } from '../../../util/git/index.ts'; import type { FileChange } from '../../../util/git/types.ts'; import * as hostRules from '../../../util/host-rules.ts'; import { parseUrl } from '../../../util/url.ts'; import type { PackageFile, PostUpdateConfig } from '../types.ts'; import { artifactErrorMessageFromExecError, resolveToolConstraint, } from '../util.ts'; import { actionsLockFile, isLockfileManaged } from './common.ts'; import { ActionsLockfile } from './schema.ts'; import type { ActionsLockfileResult, OnboardedWorkflows } from './types.ts'; /** The `gh` CLI extension that generates and verifies the lockfile. */ const extensionName = 'github/gh-actions-lock'; /** The `uses:` dep types the lockfile graph is built from. */ const lockedDepTypes = ['action', 'workflow', 'docker']; function findToken(url: string): string | undefined { return findGithubToken(hostRules.find({ hostType: 'github', url })); } /** * `gh` takes the token from a different variable for GitHub Enterprise Server, and has to be told which host to talk to. * * Public actions are still resolved against github.com from there, so pass a token for both hosts. */ function getTokenEnv(): ExtraEnv { const githubComEndpoint = 'https://api.github.com/'; const configuredEndpoint = GlobalConfig.get('endpoint'); // The configured endpoint only says where GitHub lives when Renovate is talking to GitHub: workflows mirrored onto another platform still resolve their actions against github.com. const endpoint = configuredEndpoint && detectPlatform(configuredEndpoint) === 'github' ? configuredEndpoint : githubComEndpoint; const hostname = parseUrl(endpoint)?.hostname; const isEnterprise = !!hostname && hostname !== 'api.github.com' && hostname !== 'github.com'; const env: ExtraEnv = {}; if (isEnterprise) { const enterpriseToken = findToken(endpoint); if (enterpriseToken) { env.GH_HOST = hostname; env.GH_ENTERPRISE_TOKEN = enterpriseToken; } } const token = findToken(githubComEndpoint); if (token) { env.GH_TOKEN = token; } if (!Object.keys(env).length) { logger.debug('No GitHub token found for `gh actions-lock`'); } return env; } /** * Whether any updated dependency is one the lockfile records an edge for, and sits in a file `gh actions-lock` will look at. */ function hasLockedUpdate( config: PostUpdateConfig, onboardedWorkflows: OnboardedWorkflows, ): boolean { return config.upgrades.some((upgrade) => { if (upgrade.manager !== 'github-actions') { return false; } if (!upgrade.depType || !lockedDepTypes.includes(upgrade.depType)) { return false; } const packageFile = upgrade.packageFile; if (!packageFile) { return false; } return isLockfileManaged(packageFile, onboardedWorkflows); }); } /** * Regenerate `.github/workflows/actions.lock` once per branch. * * The lockfile records a transitive graph - resolved commits, numeric owner and repository IDs, and each composite action's own `uses:` edges - so it is regenerated by its own tool rather than patched here. * `gh actions-lock` re-reads every workflow from disk and rewrites the whole file, so this runs after all of the branch's package file updates have been flushed, in the same shape as npm's `getAdditionalFiles`. */ export async function updateActionsLockfile( config: PostUpdateConfig, packageFiles: Record | undefined, ): Promise { const empty: ActionsLockfileResult = { updatedArtifacts: [], artifactErrors: [], }; const ghaPackageFiles = packageFiles?.['github-actions']; if (!ghaPackageFiles?.length) { return empty; } if (config.skipArtifactsUpdate) { logger.debug(`Skipping ${actionsLockFile} generation`); return empty; } const existingLockFileContent = await readLocalFile(actionsLockFile, 'utf8'); if (!existingLockFileContent) { logger.debug(`No ${actionsLockFile} found`); return empty; } const parsedLockfile = ActionsLockfile.safeParse(existingLockFileContent); if (!parsedLockfile.success) { logger.warn( { err: parsedLockfile.error, lockFile: actionsLockFile }, 'Failed to parse the GitHub Actions lockfile, so not regenerating it', ); return empty; } if (!hasLockedUpdate(config, parsedLockfile.data.workflows)) { logger.debug(`No locked dependencies updated for ${actionsLockFile}`); return empty; } logger.debug(`Regenerating ${actionsLockFile}`); const ghaPackageFilePaths = new Set( ghaPackageFiles.map((file) => file.packageFile), ); // Ensure that in-memory file contents are flushed to disk, so `actions-lock` can run against it. // A branch can group updates from other managers, whose contents may be binary - a Gradle wrapper JAR, say - so only write the workflows which the tool actually reads. const writtenContent = new Map(); for (const file of coerceArray(config.updatedPackageFiles)) { if (file.type === 'addition' && ghaPackageFilePaths.has(file.path)) { const contents = file.contents!.toString(); await writeLocalFile(file.path, contents); writtenContent.set(file.path, contents); } } const tokenEnv = getTokenEnv(); const ghConstraint = await resolveToolConstraint(config, 'gh'); const ghActionsLockConstraint = await resolveToolConstraint( config, 'ghActionsLock', ); const execOptions: ExecOptions = { // The tool rescans every workflow, so it runs from the repository root cwd: GlobalConfig.get('localDir'), // `extraEnv` is what forwards a variable into the Docker sidecar, but on its own it is only a default: `getChildEnv` lets a same-named variable in Renovate's own environment win. // The token we resolved from the host rules must not be shadowed by an ambient `GH_TOKEN`, so force it as well. extraEnv: tokenEnv, env: tokenEnv, toolConstraints: [{ toolName: 'gh', constraint: ghConstraint }], docker: {}, }; // `--no-interactive`: we have no terminal to answer prompts with // `--no-narrow`: keep the ref that we wrote, to avoid a `@v4` being rewritten as `@v4.2.1` // `--no-migrate-local-actions`: on-by-default, but unrelated to our lockfile updates const commands = [ 'gh actions-lock --no-interactive --no-narrow --no-migrate-local-actions', ]; // `binarySource=global` means the administrator provisions tooling, so we must not reach into their `gh` install to add or re-pin an extension. // They own the extension version, and `constraints.ghActionsLock` does not apply. // TODO replace once containerbase/base#7279 is available if (GlobalConfig.get('binarySource') === 'global') { // Debug rather than warn: `constraints.ghActionsLock` ships with a default, so we cannot tell an explicit override from the default here, and warning would fire for every repository on this `binarySource`. logger.once.debug( { constraint: ghActionsLockConstraint }, 'Not installing the `gh-actions-lock` extension, and ignoring `constraints.ghActionsLock`, because `binarySource=global` leaves tooling to the administrator', ); } else { // `--force` makes an install a no-op when the specified version is already installed if (ghActionsLockConstraint) { commands.unshift( `gh extension install ${extensionName} --force --pin ${quote(ghActionsLockConstraint)}`, ); } else { commands.unshift(`gh extension install ${extensionName} --force`); } } try { await exec(commands, execOptions); } catch (err) { if (err.message === TEMPORARY_ERROR) { throw err; } logger.debug({ err }, `Error updating ${actionsLockFile}`); return { updatedArtifacts: [], artifactErrors: [ { fileName: actionsLockFile, // an `ExecError` always carries a `stderr`, so fall back to the message rather than rendering an artifact error with nothing in it stderr: artifactErrorMessageFromExecError(err, err.message), }, ], }; } const updatedArtifacts: FileChange[] = []; const newLockFileContent = await readLocalFile(actionsLockFile, 'utf8'); if (newLockFileContent && newLockFileContent !== existingLockFileContent) { updatedArtifacts.push({ type: 'addition', path: actionsLockFile, contents: newLockFileContent, }); } // `actions-lock` may annotate workflows being managed with i.e. `# This workflow is managed by gh actions-lock.`, even if there were no updates to that workflow in this branch. // We should diff against Git, rather than against the branch's own updated package files, otherwise those annotations are left behind as uncommitted changes const status = await getRepoStatus(); for (const path of status.modified) { // ignore the lockfile, as we've already committed it above, and any file which another manager owns if (path === actionsLockFile || !ghaPackageFilePaths.has(path)) { continue; } const newContent = await readLocalFile(path, 'utf8'); // Whatever this branch wrote is committed as a package file update already, so only content which the tool changed on top of it is an artifact if (!newContent || newContent === writtenContent.get(path)) { continue; } logger.debug(`\`gh actions-lock\` annotated ${path}`); updatedArtifacts.push({ type: 'addition', path, contents: newContent, }); } if (!updatedArtifacts.length) { logger.debug(`${actionsLockFile} is unchanged`); } return { updatedArtifacts, artifactErrors: [] }; }