mirror of
https://github.com/renovatebot/renovate.git
synced 2026-11-08 04:53:36 +00:00
fba1a9de30
* refactor(manager/github-actions): make `extractPackageFile` async As part of follow-up changes we'll need to read `.github/workflows/actions.lock` while extracting, which requires we are an async function. To reduce the diff, we can prefactor this change first. Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io> * feat(manager/github-actions): add support for Actions Lockfiles In June, GitHub announced a public preview for the GitHub Actions lockfile functionality[0]. To add support for this, we can extend our support for GitHub Actions to handle the presence of an `actions.lock` file, and if so, update it after updating other manager files. Although our ideal case would be to have a pure Typescript-based parsing and updating of the `actions.lock`, due to the early nature of the Actions Lockfile syntax, and its lack of stability, we can invoke the `gh actions-lock` extension when updating. After the schema has reached a v1 release, we can then migrate to Typescript. To make sure that the version of the `actions-lock` extension is always version pinned, we can expose a `constraints` to control the version, but allow Renovate to keep the pinned default updated as needed. This doesn't imply that we have support for `lockFileMaintenance`, as we are only updating the lockfile when there is a dependency update, not periodically recreating the lockfile from the current ranges. `gh actions-lock` re-reads every workflow from disk and rewrites the whole lockfile, so it does not fit `updateArtifacts`, which is called once per updated package file. We can instead follow the shape the `npm` manager uses for its lock files, and have `processBranch` invoke this once per branch, after every updated package file has been written to disk. We skip workflows that aren't onboarded to the lockfile, but still run for any other package file, as a local composite action can be a transitive dependency of a workflow which is. `gh actions-lock` rewrites the workflows it manages back to plain refs when it regenerates them, which strips out any inline digest pin: ```diff - uses: actions/setup-java@1bcf9fb12c # v5.4.0 + uses: actions/setup-java@v5.7.0 ``` This would lead to a repository with `pinDigests` set "flip flopping", and would cause a lot of erroneous noise for repository owners. Instead, we can make sure that: - we don't suggest a `pinDigest` for workflows that are managed by the lockfile - digest pinning is still enabled for `docker://` references - already-committed digests are updated when they're present - we only avoid suggesting /new/ digests to be pinned Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com> Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io> Co-authored-by: Claude Fable 5 <jamie.tanna+claude-code@mend.io> [0]: https://github.com/github/actions-lockfile * coverage Co-authored-by: Claude Sonnet 5 <jamie.tanna+claude-code@mend.io> * fixup! feat(manager/github-actions): add support for Actions Lockfiles Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io> --------- Co-authored-by: Claude Opus 5 <jamie.tanna+claude-code@mend.io>
256 lines
9.9 KiB
TypeScript
256 lines
9.9 KiB
TypeScript
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 } 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', '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<string, PackageFile[]> | undefined,
|
|
): Promise<ActionsLockfileResult> {
|
|
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<string, string>();
|
|
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 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: config.constraints?.gh }],
|
|
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: config.constraints?.ghActionsLock },
|
|
'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 (config.constraints?.ghActionsLock) {
|
|
commands.unshift(
|
|
`gh extension install ${extensionName} --force --pin ${quote(config.constraints?.ghActionsLock)}`,
|
|
);
|
|
} 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: [] };
|
|
}
|