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 { api as semver } from '../../versioning/semver/index.ts'; import type { PackageFile, PostUpdateConfig, Upgrade } from '../types.ts'; import { artifactErrorMessageFromExecError, resolveToolConstraint, } from '../util.ts'; import { actionsLockFile, isLockfileManaged } from './common.ts'; import { ActionsLockfile, type Finding, FindingsOutput } 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']; /** The first `gh-actions-lock` release with `--ignore-local-actions`, and a plain run which keeps moved floating refs locked. */ const minPlainRunVersion = '0.3.0'; /** Whether a `gh-actions-lock` release tag predates `--ignore-local-actions` and needs `--relock` to write after a floating ref moved. */ function needsRelock(version: string | undefined): boolean { return ( !!version && semver.isVersion(version) && semver.isGreaterThan(minPlainRunVersion, version) ); } 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; } /** * The updated dependencies which the lockfile records an edge for, and which sit in a file `gh actions-lock` will look at. */ function getLockedUpgrades( config: PostUpdateConfig, onboardedWorkflows: OnboardedWorkflows, ): Upgrade[] { return config.upgrades.filter((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); }); } /** One line per finding, followed by how the tool suggests fixing it. */ function formatFindings(findings: Finding[]): string { return findings .map(({ workflow, dependency, detail, remediation }) => { const location = dependency ? `${workflow}: ${dependency}` : workflow; const fix = remediation ? ` (${remediation})` : ''; return `${location}: ${detail}${fix}`; }) .join('\n'); } /** * 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; } const lockedUpgrades = getLockedUpgrades( config, parsedLockfile.data.workflows, ); if (!lockedUpgrades.length) { 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: {}, }; // A replacement introduces a package the workflow did not use before, which `--no-onboard` refuses to add const hasReplacement = lockedUpgrades.some( (upgrade) => upgrade.updateType === 'replacement', ); // `binarySource=global` leaves the extension version to the administrator and it is unknown here, so assume v0.3.0 or later and only treat a version we install as old const relock = GlobalConfig.get('binarySource') !== 'global' && needsRelock(ghActionsLockConstraint); // From v0.3.0 a plain run re-pins only the refs whose `uses:` changed, and keeps moved floating refs (like `@v4`) locked, so unrelated pins do not advance. // Before that, a plain run refuses to write when a path or composite action's floating ref has moved, so `--relock` is needed there const flags = [ // we have no terminal to answer prompts with '--no-interactive', // do not add workflows which the lockfile does not track yet, as that is unrelated to the update. Omitted for a replacement, as the new action is not in the workflow's lockfile entry yet ...(hasReplacement ? [] : ['--no-onboard']), // keep the ref that we wrote, to avoid a `@v4` being rewritten as `@v4.2.1` '--no-narrow', ...(relock ? [ // a plain run before v0.3.0 refuses to write when a floating ref has moved upstream. This also advances such refs '--relock', // do not migrate `uses: ./…` to `uses: $/…`. `--ignore-local-actions` does not exist before v0.3.0 '--no-migrate-local-actions', ] : [ // warn instead of failing when a local action uses remote actions the lockfile cannot cover, and do not migrate `uses: ./…` to `uses: $/…` '--ignore-local-actions', ]), // print the findings as JSON on stdout, so a failure can be reported readably. Available since v0.1.6 '--json=findings', ]; const commands = [`gh actions-lock ${flags.join(' ')}`]; // `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}`); const findings = FindingsOutput.safeParse(err.stdout); 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: findings.success && findings.data.length ? formatFindings(findings.data) : 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: [] }; }