import { codeBlock } from 'common-tags'; import { Fixtures } from '~test/fixtures.ts'; import { fs } from '~test/util.ts'; import { GlobalConfig } from '../../../config/global.ts'; import * as memCache from '../../../util/cache/memory/index.ts'; import * as yaml from '../../../util/yaml.ts'; import { extractPackageFile } from './index.ts'; vi.mock('../../../util/fs/index.ts'); describe('modules/manager/github-actions/extract', () => { beforeEach(() => { GlobalConfig.reset(); }); describe('extractPackageFile()', () => { it('returns null for empty', async () => { await expect( extractPackageFile('nothing here', 'empty-workflow.yml'), ).resolves.toBeNull(); }); it('returns null for invalid yaml', async () => { await expect( extractPackageFile('nothing here: [', 'invalid-workflow.yml'), ).resolves.toBeNull(); }); it('extracts multiple docker image lines from yaml configuration file', async () => { const res = await extractPackageFile( Fixtures.get('workflow_1.yml'), 'workflow_1.yml', ); expect(res?.deps).toMatchObject([ { depName: 'actions/bin', currentValue: 'master', datasource: 'github-digest', depType: 'action', }, { depName: 'replicated/dockerfilelint', datasource: 'docker', depType: 'docker', }, { depName: 'actions/docker', currentValue: 'master', datasource: 'github-digest', depType: 'action', }, { depName: 'node', currentValue: '6', currentDigest: 'sha256:7b65413af120ec5328077775022c78101f103258a1876ec2f83890bce416e896', datasource: 'docker', depType: 'docker', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'node', currentValue: '16-bullseye', datasource: 'docker', depType: 'container', }, { depName: 'redis', currentValue: '5', datasource: 'docker', depType: 'service', }, { depName: 'postgres', currentValue: '10', datasource: 'docker', depType: 'service', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'node', currentValue: '16-bullseye', datasource: 'docker', depType: 'container', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, ]); }); it('extracts multiple action tag lines from yaml configuration file', async () => { const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps).toMatchObject([ { depName: 'actions/bin', currentValue: 'master', datasource: 'github-digest', depType: 'action', }, { depName: 'docker/setup-qemu-action', currentValue: 'v1.1.0', currentDigest: 'c308fdd69d26ed66f4506ebd74b180abe5362145', datasource: 'github-tags', depType: 'action', replaceString: 'docker/setup-qemu-action@c308fdd69d26ed66f4506ebd74b180abe5362145 # renovate: tag=v1.1.0', }, { depName: 'actions/checkout', currentValue: '1.0.0', datasource: 'github-tags', depType: 'action', }, { depName: 'docker/setup-qemu-action', currentDigest: 'c308fdd69d26ed66f4506ebd74b180abe5362145', datasource: 'github-tags', depType: 'action', enabled: false, skipReason: 'unversioned-reference', replaceString: 'docker/setup-qemu-action@c308fdd69d26ed66f4506ebd74b180abe5362145', }, { depName: 'docker/build-push-action', currentValue: 'v2', datasource: 'github-tags', depType: 'action', }, { depName: 'actions/checkout', currentValue: 'v2.4.0', currentDigest: 'ec3a7ce113134d7a93b817d10a8272cb61118579', datasource: 'github-tags', depType: 'action', replaceString: 'actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # tag=v2.4.0', }, { depName: 'actions-rs/toolchain', currentValue: 'v1.0.7', currentDigest: '16499b5e05bf2e26879000db0c1d13f7e13fa3af', datasource: 'github-tags', depType: 'action', replaceString: 'actions-rs/toolchain@16499b5e05bf2e26879000db0c1d13f7e13fa3af # renovate: tag=v1.0.7', }, { depName: 'actions-rs/cargo', currentValue: 'v1.0.3', datasource: 'github-tags', depType: 'action', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: 'latest', datasource: 'github-runners', depType: 'github-runner', skipReason: 'invalid-version', }, ]); }); it('use github.com as registry when no settings provided', async () => { const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toBeUndefined(); }); it('use github.enterprise.com first and then github.com as registry running against github.enterprise.com', async () => { GlobalConfig.set({ platform: 'github', endpoint: 'https://github.enterprise.com', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toEqual([ 'https://github.enterprise.com', 'https://github.com', ]); }); it('use github.enterprise.com first and then github.com as registry running against github.enterprise.com/api/v3', async () => { GlobalConfig.set({ platform: 'github', endpoint: 'https://github.enterprise.com/api/v3', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toEqual([ 'https://github.enterprise.com', 'https://github.com', ]); }); it('use github.com only as registry when running against non-GitHub', async () => { GlobalConfig.set({ platform: 'bitbucket', endpoint: 'https://bitbucket.enterprise.com', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toBeUndefined(); }); it('use github.com only as registry when running against github.com', async () => { GlobalConfig.set({ platform: 'github', endpoint: 'https://github.com', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toBeUndefined(); }); it('use github.com only as registry when running against api.github.com', async () => { GlobalConfig.set({ platform: 'github', endpoint: 'https://api.github.com', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toBeUndefined(); }); it('returns undefined registryUrls when endpoint is invalid URL', async () => { GlobalConfig.set({ platform: 'github', endpoint: 'not-a-valid-url', }); const res = await extractPackageFile( Fixtures.get('workflow_2.yml'), 'workflow_2.yml', ); expect(res?.deps[0].registryUrls).toBeUndefined(); }); it('extracts multiple action tag lines with double quotes and comments', async () => { const res = await extractPackageFile( Fixtures.get('workflow_3.yml'), 'workflow_3.yml', ); expect(res?.deps).toMatchObject([ { currentValue: 'v0.13.1', datasource: 'github-tags', depName: 'pascalgn/automerge-action', depType: 'action', replaceString: '"pascalgn/automerge-action@v0.13.1"', versioning: 'github-actions', }, { currentValue: 'v2.3.5', datasource: 'github-tags', depName: 'actions/checkout', depType: 'action', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # renovate: tag=v2.3.5', versioning: 'github-actions', }, { currentValue: 'v1', datasource: 'github-tags', depName: 'actions/checkout', depType: 'action', replaceString: 'actions/checkout@v1', versioning: 'github-actions', }, { currentValue: 'v1.1.2', datasource: 'github-tags', depName: 'actions/checkout', depType: 'action', replaceString: '"actions/checkout@v1.1.2"', versioning: 'github-actions', }, { currentValue: '1.37.0-glibc', datasource: 'docker', depName: 'busybox', depType: 'docker', replaceString: 'busybox:1.37.0-glibc', }, { currentValue: 'latest', datasource: 'github-runners', depName: 'ubuntu', depType: 'github-runner', replaceString: 'ubuntu-latest', }, { currentValue: 'latest', datasource: 'github-runners', depName: 'ubuntu', depType: 'github-runner', replaceString: 'ubuntu-latest', }, ]); }); it('maintains quotes', async () => { const yamlContent = codeBlock` jobs: build: steps: - name: "test1" uses: actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1 - name: "test2" uses: 'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1 - name: "test3" uses: "actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1 - name: "checkout repository" uses: "actions/checkout@v2" # comment after - name: "quoted, no comment, outdated" uses: "actions/setup-java@v2"`; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { depName: 'actions/setup-node', commitMessageTopic: '{{{depName}}} action', datasource: 'github-tags', versioning: 'github-actions', depType: 'action', replaceString: 'actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1', autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', currentValue: 'v3.1.1', currentDigest: '56337c425554a6be30cdef71bf441f15be286854', }, { depName: 'actions/setup-node', commitMessageTopic: '{{{depName}}} action', datasource: 'github-tags', versioning: 'github-actions', depType: 'action', replaceString: "'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1", autoReplaceStringTemplate: "'{{depName}}@{{#if newDigest}}{{newDigest}}'{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}'{{/unless}}", currentValue: 'v3.1.1', currentDigest: '1f8c6b94b26d0feae1e387ca63ccbdc44d27b561', }, { depName: 'actions/setup-node', commitMessageTopic: '{{{depName}}} action', datasource: 'github-tags', versioning: 'github-actions', depType: 'action', replaceString: '"actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1', autoReplaceStringTemplate: '"{{depName}}@{{#if newDigest}}{{newDigest}}"{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}"{{/unless}}', currentValue: 'v2.5.1', currentDigest: '1f8c6b94b26d0feae1e387ca63ccbdc44d27b561', }, { depName: 'actions/checkout', commitMessageTopic: '{{{depName}}} action', datasource: 'github-tags', versioning: 'github-actions', depType: 'action', replaceString: '"actions/checkout@v2"', autoReplaceStringTemplate: '"{{depName}}@{{#if newDigest}}{{newDigest}}"{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}"{{/unless}}', currentValue: 'v2', }, { depName: 'actions/setup-java', commitMessageTopic: '{{{depName}}} action', datasource: 'github-tags', versioning: 'github-actions', depType: 'action', replaceString: '"actions/setup-java@v2"', autoReplaceStringTemplate: '"{{depName}}@{{#if newDigest}}{{newDigest}}"{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}"{{/unless}}', currentValue: 'v2', }, ]); }); it('maintains spaces between hash and comment', async () => { const yamlContent = codeBlock` jobs: build: steps: # One space - name: "test1" uses: actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1 - name: "test2" uses: 'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1 - name: "test3" uses: "actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1 # Two space - name: "test1" uses: actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1 - name: "test2" uses: 'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1 - name: "test3" uses: "actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1 "`; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res).toMatchObject({ deps: [ { replaceString: 'actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1', }, { replaceString: "'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1", }, { replaceString: '"actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1', }, { replaceString: 'actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # tag=v3.1.1', }, { replaceString: "'actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561' # tag=v3.1.1", }, { replaceString: '"actions/setup-node@1f8c6b94b26d0feae1e387ca63ccbdc44d27b561" # tag=v2.5.1', }, ], }); }); it('extracts tags in different formats', async () => { const res = await extractPackageFile( Fixtures.get('workflow_4.yml'), 'workflow_4.yml', ); expect(res?.deps).toMatchObject([ { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: '1.2.3', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # 1.2.3', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: '1.2', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # 1.2', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: '1', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # 1', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v1.2.3', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # v1.2.3', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v1.2', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # v1.2', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v1', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # v1', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # @v2.1.0', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # pin @v2.1.0', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # tag=v2.1.0', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 # v2.1.0', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 #v2.1.0', }, { currentDigest: '1e204e9a9253d643386038d443f96446fa156a97', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e9a9253d643386038d443f96446fa156a97 #v2.1.0', }, { currentDigestShort: '1e204e', currentValue: 'v2.1.0', replaceString: 'actions/checkout@1e204e # v2.1.0', }, { currentDigestShort: '1e204e', currentValue: 'some-ref-name', replaceString: 'actions/checkout@1e204e # some-ref-name', }, { currentValue: '01aecc#v2.1.0', replaceString: 'actions/checkout@01aecc#v2.1.0', }, { currentDigest: '689fcce700ae7ffc576f2b029b51b2ffb66d3abd', currentValue: undefined, replaceString: 'actions/checkout@689fcce700ae7ffc576f2b029b51b2ffb66d3abd', }, { currentDigest: '689fcce700ae7ffc576f2b029b51b2ffb66d3abd', currentValue: 'v2.1.0', replaceString: 'actions/checkout@689fcce700ae7ffc576f2b029b51b2ffb66d3abd # v2.1.0', }, { currentDigest: '689fcce700ae7ffc576f2b029b51b2ffb66d3abd', currentValue: 'v2.1.0', replaceString: 'actions/checkout@689fcce700ae7ffc576f2b029b51b2ffb66d3abd # ratchet:actions/checkout@v2.1.0', }, { currentDigest: '689fcce700ae7ffc576f2b029b51b2ffb66d3abd', currentValue: undefined, replaceString: 'actions/checkout@689fcce700ae7ffc576f2b029b51b2ffb66d3abd # ratchet:exclude', }, { currentDigest: 'f1d7c52253b89f0beae60141f8465d9495cdc2cf', currentValue: 'actions-runner-controller-0.23.5', replaceString: 'actions-runner-controller/execute-assert-arc-e2e@f1d7c52253b89f0beae60141f8465d9495cdc2cf # actions-runner-controller-0.23.5', }, { currentDigest: '4b1248585248751e3b12fd020cf7ac91540ca09c', currentValue: 'prettier/v2.0.1', replaceString: 'grafana/writers-toolkit@4b1248585248751e3b12fd020cf7ac91540ca09c # prettier/v2.0.1', }, ]); expect(res!.deps[14]).not.toHaveProperty('skipReason'); }); it('extracts non-semver ref automatically', async () => { const res = await extractPackageFile( ` jobs: build: steps: - uses: taiki-e/install-action@cargo-llvm-cov `, 'workflow.yml', ); expect(res?.deps[0]).toMatchObject({ depName: 'taiki-e/install-action', currentValue: 'cargo-llvm-cov', datasource: 'github-digest', versioning: 'exact', autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', }); }); it('extracts pinned non-semver ref with digest', async () => { const res = await extractPackageFile( ` jobs: build: steps: - uses: taiki-e/install-action@4b1248585248751e3b12fd020cf7ac91540ca09c # cargo-llvm-cov `, 'workflow.yml', ); expect(res?.deps[0]).toMatchObject({ depName: 'taiki-e/install-action', currentValue: 'cargo-llvm-cov', currentDigest: '4b1248585248751e3b12fd020cf7ac91540ca09c', datasource: 'github-digest', versioning: 'exact', replaceString: 'taiki-e/install-action@4b1248585248751e3b12fd020cf7ac91540ca09c # cargo-llvm-cov', autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', }); }); it('extracts ratchet pinned action in subdirectory', async () => { const res = await extractPackageFile( ` jobs: build: steps: - uses: actions/cache/restore@b7e8d49f17405cc70c1c120101943203c98d3a4b # ratchet:actions/cache/restore@v4 `, 'workflow.yml', ); expect(res?.deps[0]).toMatchObject({ depName: 'actions/cache', currentValue: 'v4', currentDigest: 'b7e8d49f17405cc70c1c120101943203c98d3a4b', datasource: 'github-tags', versioning: 'github-actions', replaceString: 'actions/cache/restore@b7e8d49f17405cc70c1c120101943203c98d3a4b # ratchet:actions/cache/restore@v4', }); }); it('extracts a reusable workflow call as a workflow', async () => { const res = await extractPackageFile( codeBlock` jobs: release: uses: some-org/some-repo/.github/workflows/release.yml@v1.2.3 `, '.github/workflows/ci.yml', ); expect(res?.deps).toMatchObject([ { depName: 'some-org/some-repo', currentValue: 'v1.2.3', datasource: 'github-tags', versioning: 'github-actions', depType: 'workflow', replaceString: 'some-org/some-repo/.github/workflows/release.yml@v1.2.3', autoReplaceStringTemplate: '{{depName}}/.github/workflows/release.yml@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', }, ]); }); it.each` uses | depType ${'some-org/some-repo/.github/workflows/release.yml@v1'} | ${'workflow'} ${'some-org/some-repo/.github/workflows/release.yaml@v1'} | ${'workflow'} ${'actions/checkout@v4'} | ${'action'} ${'github/codeql-action/init@v3'} | ${'action'} ${'some-org/some-repo/.github/workflows/sub/release.yml@v1'} | ${'action'} ${'some-org/some-repo/.github/workflows@v1'} | ${'action'} ${'some-org/some-repo/.github/workflows/release.json@v1'} | ${'action'} ${'some-org/some-repo/nested/.github/workflows/release.yml@v1'} | ${'action'} `( 'gives $uses the depType $depType', async ({ uses, depType }: { uses: string; depType: string }) => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - uses: ${uses} `, '.github/workflows/ci.yml', ); expect(res?.deps[0]).toMatchObject({ depType }); }, ); it('disables naked SHA pins without version comment', async () => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 `, 'workflow.yml', ); expect(res?.deps[0]).toMatchObject({ depName: 'actions/checkout', currentDigest: 'c85c95e3d7251135ab7dc9ce3241c5835cc595a9', currentValue: undefined, enabled: false, skipReason: 'unversioned-reference', }); }); it('disables naked short SHA pins without version comment', async () => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - uses: actions/checkout@c85c95e `, 'workflow.yml', ); expect(res?.deps[0]).toMatchObject({ depName: 'actions/checkout', currentDigestShort: 'c85c95e', currentValue: undefined, enabled: false, skipReason: 'unversioned-reference', }); }); it('does not disable SHA pins with version comment', async () => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v4 `, 'workflow.yml', ); expect(res?.deps[0]).toEqual({ depName: 'actions/checkout', commitMessageTopic: '{{{depName}}} action', versioning: 'github-actions', depType: 'action', replaceString: 'actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v4', autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', currentValue: 'v4', currentDigest: 'c85c95e3d7251135ab7dc9ce3241c5835cc595a9', datasource: 'github-tags', }); }); it('does not disable short SHA pins with version comment', async () => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - uses: actions/checkout@c85c95e # v4 `, 'workflow.yml', ); expect(res?.deps[0]).toEqual({ depName: 'actions/checkout', commitMessageTopic: '{{{depName}}} action', versioning: 'github-actions', depType: 'action', replaceString: 'actions/checkout@c85c95e # v4', autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', currentValue: 'v4', currentDigestShort: 'c85c95e', datasource: 'github-tags', }); }); it('extracts actions with fqdn', async () => { const res = await extractPackageFile( codeBlock` jobs: build: steps: - name: "test1" uses: https://github.com/actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # tag=v4.2.0 - name: "test2" uses: https://code.forgejo.org/actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # v3.1.1 - name: "test3" uses: https://gitea.com/actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # v3.1.1 - name: "test4" uses: https://code.domain.test/actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # v3.1.1 `, 'sample.yml', ); expect(res).toMatchObject({ deps: [ { depName: 'https://github.com/actions/cache', packageName: 'actions/cache', currentDigest: '1bd1e32a3bdc45362d1e726936510720a7c30a57', currentValue: 'v4.2.0', replaceString: 'https://github.com/actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # tag=v4.2.0', datasource: 'github-tags', registryUrls: ['https://github.com/'], }, { depName: 'https://code.forgejo.org/actions/setup-node', packageName: 'actions/setup-node', currentDigest: '56337c425554a6be30cdef71bf441f15be286854', currentValue: 'v3.1.1', replaceString: 'https://code.forgejo.org/actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # v3.1.1', datasource: 'forgejo-tags', registryUrls: ['https://code.forgejo.org/'], }, { depName: 'https://gitea.com/actions/setup-node', packageName: 'actions/setup-node', currentDigest: '56337c425554a6be30cdef71bf441f15be286854', currentValue: 'v3.1.1', replaceString: 'https://gitea.com/actions/setup-node@56337c425554a6be30cdef71bf441f15be286854 # v3.1.1', datasource: 'gitea-tags', registryUrls: ['https://gitea.com/'], }, { skipReason: 'unsupported-url', }, ], }); expect(res!.deps[3]).not.toHaveProperty('registryUrls'); }); it('extracts multiple macos action runners from yaml configuration file', async () => { const runnerTestWorkflowMacos = codeBlock` jobs: test1: runs-on: \${{ env.RUNNER }} test2: runs-on: abc-123 test3: runs-on: "macos-12-large" test4: runs-on: 'macos-latest' test5: runs-on: macos-15-intel test6: runs-on: macos-26-intel `; const res = await extractPackageFile( runnerTestWorkflowMacos, 'workflow.yml', ); expect(res?.deps).toMatchObject([ { depName: 'macos', currentValue: '12-large', replaceString: 'macos-12-large', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'macos', currentValue: 'latest', replaceString: 'macos-latest', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', skipReason: 'invalid-version', }, { depName: 'macos', currentValue: '15-intel', replaceString: 'macos-15-intel', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'macos', currentValue: '26-intel', replaceString: 'macos-26-intel', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, ]); expect( res?.deps.filter((d) => d.datasource === 'github-runners'), ).toHaveLength(4); }); it('extracts multiple ubuntu action runners from yaml configuration file', async () => { const runnerTestWorkflowUbuntu = codeBlock` jobs: test1: runs-on: ubuntu-latest test2: runs-on: ubuntu-22.04 test3: runs-on: group: ubuntu-runners labels: ubuntu-20.04-16core test4: runs-on: ubuntu-22.04-arm `; const res = await extractPackageFile( runnerTestWorkflowUbuntu, 'workflow.yml', ); expect(res?.deps).toMatchObject([ { depName: 'ubuntu', currentValue: 'latest', replaceString: 'ubuntu-latest', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', skipReason: 'invalid-version', }, { depName: 'ubuntu', currentValue: '22.04', replaceString: 'ubuntu-22.04', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'ubuntu', currentValue: '22.04-arm', replaceString: 'ubuntu-22.04-arm', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, ]); expect( res?.deps.filter((d) => d.datasource === 'github-runners'), ).toHaveLength(3); }); it('extracts multiple windows action runners from yaml configuration file', async () => { const runnerTestWorkflowWindows = codeBlock` jobs: test1: runs-on: | windows-2019 test2: runs-on: > windows-2022 test3: runs-on: [windows-2022, selfhosted] test4: runs-on: windows-11-arm test5: runs-on: windows-2025 `; const res = await extractPackageFile( runnerTestWorkflowWindows, 'workflow.yml', ); expect(res?.deps).toMatchObject([ { depName: 'windows', currentValue: '2019', replaceString: 'windows-2019', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'windows', currentValue: '2022', replaceString: 'windows-2022', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'windows', currentValue: '2022', replaceString: 'windows-2022', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'windows', currentValue: '11-arm', replaceString: 'windows-11-arm', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, { depName: 'windows', currentValue: '2025', replaceString: 'windows-2025', depType: 'github-runner', datasource: 'github-runners', autoReplaceStringTemplate: '{{depName}}-{{newValue}}', }, ]); expect( res?.deps.filter((d) => d.datasource === 'github-runners'), ).toHaveLength(5); }); it('extracts x-version from actions/setup-x', async () => { const yamlContent = codeBlock` jobs: build: steps: - name: "Setup Node.js" uses: actions/setup-node@v3 with: node-version: '16.x' - name: "Setup Node.js with exact version" uses: actions/setup-node@v3 with: node-version: '20.0.0' - name: "Setup Go" uses: actions/setup-go@v5 with: go-version: '1.23' - name: "Setup Python with range" uses: actions/setup-python@v3 with: python-version: '>=3.8.0 <3.10.0' - name: "Setup Node.js with latest" uses: actions/setup-node@v3 with: node-version: 'latest' `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v5', datasource: 'github-tags', depName: 'actions/setup-go', depType: 'action', replaceString: 'actions/setup-go@v5', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-python', depType: 'action', replaceString: 'actions/setup-python@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: '16.x', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: '20.0.0', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'go', packageName: 'actions/go-versions', currentValue: '1.23', datasource: 'github-releases', versioning: 'npm', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'python', packageName: 'actions/python-versions', currentValue: '>=3.8.0 <3.10.0', datasource: 'github-releases', versioning: 'npm', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: 'latest', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, ]); }); it('extracts x-version from actions/setup-x referenced by an https:// URL', async () => { const yamlContent = codeBlock` jobs: build: steps: - uses: https://github.com/actions/setup-node@v4.4.0 with: node-version: '23.7.0' `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { depName: 'https://github.com/actions/setup-node', depType: 'action', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: '23.7.0', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, ]); }); it('handles actions/setup-x without x-version field', async () => { const yamlContent = codeBlock` jobs: build: steps: - name: "Setup Node.js without version" uses: actions/setup-node@v3 with: registry-url: 'https://npm.pkg.github.com' `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toHaveLength(1); expect(res?.deps[0]).toMatchObject({ depName: 'actions/setup-node', depType: 'action', }); }); it('extracts x-version from actions/setup-x in composite action', async () => { const yamlContent = codeBlock` runs: using: 'composite' steps: - name: "Setup Node.js" uses: actions/setup-node@v3 with: node-version: '16.x' - name: "Setup Node.js with exact version" uses: actions/setup-node@v3 with: node-version: '20.0.0' - name: "Setup Go" uses: actions/setup-go@v5 with: go-version: '1.23' - name: "Setup Python with range" uses: actions/setup-python@v3 with: python-version: '>=3.8.0 <3.10.0' - name: "Setup Node.js with latest" uses: actions/setup-node@v3 with: node-version: 'latest' `; const res = await extractPackageFile(yamlContent, 'action.yml'); expect(res?.deps).toMatchObject([ { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v5', datasource: 'github-tags', depName: 'actions/setup-go', depType: 'action', replaceString: 'actions/setup-go@v5', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-python', depType: 'action', replaceString: 'actions/setup-python@v3', versioning: 'github-actions', }, { autoReplaceStringTemplate: '{{depName}}@{{#if newDigest}}{{newDigest}}{{#if newValue}} # {{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}', commitMessageTopic: '{{{depName}}} action', currentValue: 'v3', datasource: 'github-tags', depName: 'actions/setup-node', depType: 'action', replaceString: 'actions/setup-node@v3', versioning: 'github-actions', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: '16.x', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: '20.0.0', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'go', packageName: 'actions/go-versions', currentValue: '1.23', datasource: 'github-releases', versioning: 'npm', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'python', packageName: 'actions/python-versions', currentValue: '>=3.8.0 <3.10.0', datasource: 'github-releases', versioning: 'npm', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, { depName: 'node', packageName: 'actions/node-versions', currentValue: 'latest', datasource: 'github-releases', versioning: 'node', extractVersion: '^(?\\d+\\.\\d+\\.\\d+)(-\\d+)?$', depType: 'uses-with', }, ]); }); it('logs unknown schema', async () => { const yamlContent = codeBlock` runs: using: 'node20' main: 'index.js' `; await expect( extractPackageFile(yamlContent, 'action.yml'), ).resolves.toBeNull(); }); it('extracts actions and with-version inputs nested in a parallel block', async () => { const yamlContent = codeBlock` jobs: build: steps: - uses: actions/checkout@v4 - parallel: - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: '20.0.0' - name: Setup Go uses: actions/setup-go@v5 with: go-version: '1.23' - run: npm test `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { depName: 'actions/checkout', depType: 'action' }, { depName: 'actions/setup-node', depType: 'action' }, { depName: 'actions/setup-go', depType: 'action' }, { depName: 'node', depType: 'uses-with', currentValue: '20.0.0' }, { depName: 'go', depType: 'uses-with', currentValue: '1.23' }, ]); }); it('extracts community action with-inputs nested in a parallel block', async () => { const yamlContent = codeBlock` jobs: build: steps: - parallel: - uses: astral-sh/setup-uv@v8.2.0 with: version: '0.4.x' `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { depName: 'astral-sh/setup-uv', depType: 'action' }, { depName: 'astral-sh/uv', depType: 'uses-with', currentValue: '0.4.x', datasource: 'github-releases', }, ]); }); it.each` sha256 | currentDigest ${'dad54e0b843908324282b8673f9c0ebc3a4da0c49ad2da309a49bfbc918ba180'} | ${'dad54e0b843908324282b8673f9c0ebc3a4da0c49ad2da309a49bfbc918ba180'} ${'DAD54E0B843908324282B8673F9C0EBC3A4DA0C49AD2DA309A49BFBC918BA180'} | ${undefined} ${'${{ inputs.mise-sha256 }}'} | ${undefined} ${undefined} | ${undefined} `( 'extracts the mise version and checksum from jdx/mise-action', async ({ sha256, currentDigest }) => { const yamlContent = codeBlock` jobs: build: steps: - uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0 with: version: 2026.7.10 ${sha256 ? `sha256: ${sha256}` : ''} install_args: cargo:cargo-deny `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { currentDigest: 'e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d', currentValue: 'v4.2.0', datasource: 'github-tags', depName: 'jdx/mise-action', depType: 'action', }, { ...(currentDigest ? { currentDigest } : {}), currentValue: '2026.7.10', datasource: 'github-release-attachments', depName: 'jdx/mise', depType: 'uses-with', packageName: 'jdx/mise', }, ]); }, ); it('extracts steps nested in nested parallel blocks', async () => { const yamlContent = codeBlock` jobs: build: steps: - parallel: - name: Setup Node.js uses: actions/setup-node@v5 with: node-version: '18.0.0' - parallel: - name: Setup Go uses: actions/setup-go@v5 with: go-version: '1.22' `; const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps).toMatchObject([ { depName: 'actions/setup-node', depType: 'action' }, { depName: 'actions/setup-go', depType: 'action' }, { depName: 'node', depType: 'uses-with', currentValue: '18.0.0' }, { depName: 'go', depType: 'uses-with', currentValue: '1.22' }, ]); }); }); it.each([ { step: { uses: 'actions/setup-dotnet@v4', with: { 'dotnet-version': '8.0.404' }, }, expected: [ { currentValue: '8.0.404', datasource: 'dotnet-version', depName: 'dotnet-sdk', depType: 'uses-with', packageName: 'dotnet-sdk', }, ], }, { step: { uses: 'actions/setup-dotnet@v4', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'dotnet-version', depName: 'dotnet-sdk', depType: 'uses-with', packageName: 'dotnet-sdk', }, ], }, { // multiple SDKs, one per line, aren't a single version we can update step: { uses: 'actions/setup-dotnet@v4', with: { 'dotnet-version': '3.1.x\n5.0.x' }, }, expected: [ { skipStage: 'extract', skipReason: 'invalid-version', currentValue: '3.1.x\n5.0.x', datasource: 'dotnet-version', depName: 'dotnet-sdk', depType: 'uses-with', packageName: 'dotnet-sdk', }, ], }, { step: { uses: 'actions/setup-java@v4', with: { distribution: 'temurin', 'java-version': '21' }, }, expected: [ { currentValue: '21', datasource: 'java-version', depName: 'java-jdk', depType: 'uses-with', packageName: 'java-jdk', }, ], }, { step: { uses: 'actions/setup-java@v4', with: { distribution: 'adopt', 'java-version': '11' }, }, expected: [ { currentValue: '11', datasource: 'java-version', depName: 'java-jdk', depType: 'uses-with', packageName: 'java-jdk', }, ], }, { step: { uses: 'actions/setup-java@v4', with: { distribution: 'temurin', 'java-version': '21', 'java-package': 'jre', }, }, expected: [ { currentValue: '21', datasource: 'java-version', depName: 'java-jre', depType: 'uses-with', packageName: 'java-jre', }, ], }, { step: { uses: 'actions/setup-java@v4', with: { distribution: 'temurin' }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'java-version', depName: 'java-jdk', depType: 'uses-with', packageName: 'java-jdk', }, ], }, { // we can't reliably track version updates for distributions other // than Temurin/Adopt step: { uses: 'actions/setup-java@v4', with: { distribution: 'zulu', 'java-version': '21' }, }, expected: [ { skipStage: 'extract', skipReason: 'unsupported', datasource: 'java-version', depName: 'java-jdk', depType: 'uses-with', packageName: 'java-jdk', }, ], }, { step: { uses: 'actions/setup-java@v4', with: { 'java-version': '21' }, }, expected: [ { skipStage: 'extract', skipReason: 'unsupported', datasource: 'java-version', depName: 'java-jdk', depType: 'uses-with', packageName: 'java-jdk', }, ], }, { step: { uses: 'aquasecurity/setup-trivy@v0.2.6', with: {}, }, expected: [ { datasource: 'github-releases', depName: 'aquasecurity/trivy', depType: 'uses-with', packageName: 'aquasecurity/trivy', skipStage: 'extract', skipReason: 'unspecified-version', }, ], }, { step: { uses: 'aquasecurity/setup-trivy@v0.2.6', with: { version: 'latest', }, }, expected: [ { currentValue: 'latest', datasource: 'github-releases', depName: 'aquasecurity/trivy', depType: 'uses-with', packageName: 'aquasecurity/trivy', }, ], }, { step: { uses: 'aquasecurity/setup-trivy@v0.2.6', with: { version: 'v0.70.0', }, }, expected: [ { currentValue: 'v0.70.0', datasource: 'github-releases', depName: 'aquasecurity/trivy', depType: 'uses-with', packageName: 'aquasecurity/trivy', }, ], }, { step: { uses: 'aquasecurity/trivy-action@v0.35.0', with: { version: 'latest', }, }, expected: [ { currentValue: 'latest', datasource: 'github-releases', depName: 'aquasecurity/trivy', depType: 'uses-with', packageName: 'aquasecurity/trivy', }, ], }, { step: { uses: 'aquasecurity/trivy-action@v0.35.0', with: { version: 'v0.70.0', }, }, expected: [ { currentValue: 'v0.70.0', datasource: 'github-releases', depName: 'aquasecurity/trivy', depType: 'uses-with', packageName: 'aquasecurity/trivy', }, ], }, { step: { uses: 'astral-sh/setup-uv@v5', with: { version: 'latest', }, }, expected: [ { currentValue: 'latest', datasource: 'github-releases', depName: 'astral-sh/uv', depType: 'uses-with', packageName: 'astral-sh/uv', }, ], }, { step: { name: 'Pinning a minor version of uv', uses: 'astral-sh/setup-uv@v5', with: { version: '0.4.x', }, }, expected: [ { currentValue: '0.4.x', datasource: 'github-releases', depName: 'astral-sh/uv', depType: 'uses-with', packageName: 'astral-sh/uv', }, ], }, { step: { name: 'Pinning a minor version of uv', uses: 'https://github.com/astral-sh/setup-uv@v5', with: { version: '0.4.x', }, }, expected: [ { currentValue: '0.4.x', datasource: 'github-releases', depName: 'astral-sh/uv', depType: 'uses-with', packageName: 'astral-sh/uv', versioning: 'npm', }, ], }, { step: { uses: 'https://github.com/astral-sh/setup-uv@v5', with: {}, }, expected: [ { datasource: 'github-releases', depName: 'astral-sh/uv', depType: 'uses-with', skipStage: 'extract', skipReason: 'unspecified-version', packageName: 'astral-sh/uv', versioning: 'npm', }, ], }, { step: { uses: 'pnpm/action-setup@v4', with: { version: 'latest', }, }, expected: [ { currentValue: 'latest', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, ], }, { step: { uses: 'pnpm/action-setup@v4', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, ], }, { step: { uses: 'pnpm/setup@v1', with: { version: '12.0.0', runtime: 'node@24.1.0', }, }, expected: [ { currentValue: '12.0.0', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { currentValue: '24.1.0', datasource: 'node-version', depName: 'node', depType: 'uses-with', packageName: 'node', }, ], }, { step: { uses: 'pnpm/setup@v1', with: { install: false, runtime: 'bun@1.2.x', }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { currentValue: '1.2.x', datasource: 'npm', depName: 'bun', depType: 'uses-with', packageName: 'bun', }, ], }, { step: { uses: 'pnpm/setup@v1', with: { runtime: 'deno@2', }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { currentValue: '2', datasource: 'npm', depName: 'deno', depType: 'uses-with', packageName: 'deno', }, ], }, { // no version pinned: the runtime version comes from `devEngines.runtime` step: { uses: 'pnpm/setup@v1', with: { runtime: 'node', }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'node-version', depName: 'node', depType: 'uses-with', packageName: 'node', }, ], }, { step: { uses: 'pnpm/setup@v1', with: { version: '12.0.0', runtime: 'python@3.13', }, }, expected: [ { currentValue: '12.0.0', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { skipStage: 'extract', skipReason: 'invalid-name', depName: 'python', depType: 'uses-with', packageName: 'python', }, ], }, { // missing name: reported under the raw input step: { uses: 'pnpm/setup@v1', with: { runtime: '@24', }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { skipStage: 'extract', skipReason: 'invalid-name', depName: '@24', depType: 'uses-with', packageName: '@24', }, ], }, { // the runtime is not a literal, so there's no name to resolve step: { uses: 'pnpm/setup@v1', with: { runtime: '${{ env.RUNTIME }}', }, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, { skipStage: 'extract', skipReason: 'invalid-name', depName: '${{ env.RUNTIME }}', depType: 'uses-with', packageName: '${{ env.RUNTIME }}', }, ], }, { step: { uses: 'pnpm/setup@v1', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, ], }, { step: { name: 'Install gotestsum', uses: 'jaxxstorm/action-install-gh-release@v1.10.0', with: { repo: 'gotestyourself/gotestsum', tag: 'v1.12.1', platform: 'linux', arch: 'amd64', }, }, expected: [ { currentValue: 'v1.12.1', datasource: 'github-releases', depName: 'gotestyourself/gotestsum', depType: 'uses-with', packageName: 'gotestyourself/gotestsum', }, ], }, { step: { uses: 'pnpm/action-setup@v4', with: { version: 10, }, }, expected: [ { currentValue: '10', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, ], }, { step: { uses: 'pnpm/action-setup@v4', with: { version: '10.x', }, }, expected: [ { currentValue: '10.x', datasource: 'npm', depName: 'pnpm', depType: 'uses-with', packageName: 'pnpm', }, ], }, { step: { uses: 'pdm-project/setup-pdm@v4.2', with: { version: '1.2.3', }, }, expected: [ { currentValue: '1.2.3', datasource: 'pypi', depName: 'pdm', depType: 'uses-with', packageName: 'pdm', }, ], }, { step: { uses: 'pdm-project/setup-pdm@v4.2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'pypi', depName: 'pdm', depType: 'uses-with', packageName: 'pdm', }, ], }, { step: { uses: 'prefix-dev/setup-pixi@v0.8.3', with: { 'pixi-version': 'v0.41.4', }, }, expected: [ { currentValue: 'v0.41.4', datasource: 'github-releases', depName: 'prefix-dev/pixi', depType: 'uses-with', packageName: 'prefix-dev/pixi', versioning: 'conda', }, ], }, { step: { uses: 'oven-sh/setup-bun@v2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'bun', depType: 'uses-with', packageName: 'bun', }, ], }, { step: { uses: 'oven-sh/setup-bun@v2', with: { 'bun-version': '1.2.0' }, }, expected: [ { currentValue: '1.2.0', datasource: 'npm', depName: 'bun', depType: 'uses-with', packageName: 'bun', }, ], }, { step: { uses: 'denoland/setup-deno@v2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'deno', depType: 'uses-with', packageName: 'deno', }, ], }, { step: { uses: 'denoland/setup-deno@v2', with: { 'deno-version': '2.4.0' }, }, expected: [ { currentValue: '2.4.0', datasource: 'npm', depName: 'deno', depType: 'uses-with', packageName: 'deno', }, ], }, { step: { uses: 'expo/expo-github-action@v8', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'eas-cli', depType: 'uses-with', packageName: 'eas-cli', }, ], }, { step: { uses: 'expo/expo-github-action@v8', with: { 'eas-version': '23.2.0' }, }, expected: [ { currentValue: '23.2.0', datasource: 'npm', depName: 'eas-cli', depType: 'uses-with', packageName: 'eas-cli', }, ], }, { step: { uses: 'ruby/setup-ruby@v1', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'ruby-version', depName: 'ruby', depType: 'uses-with', packageName: 'ruby', }, ], }, { step: { uses: 'ruby/setup-ruby@v1', with: { 'ruby-version': '3.4' }, }, expected: [ { currentValue: '3.4', datasource: 'ruby-version', depName: 'ruby', depType: 'uses-with', packageName: 'ruby', }, ], }, { step: { uses: 'pypa/hatch@install', with: { version: '1.14.2' }, }, expected: [ { currentValue: '1.14.2', datasource: 'github-releases', depName: 'pypa/hatch', depType: 'uses-with', packageName: 'pypa/hatch', }, ], }, { step: { uses: 'pypa/hatch@install', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'pypa/hatch', depType: 'uses-with', packageName: 'pypa/hatch', }, ], }, { step: { uses: 'jakebailey/pyright-action@v2', with: { version: '1.1.100' }, }, expected: [ { currentValue: '1.1.100', datasource: 'npm', depName: 'pyright', depType: 'uses-with', packageName: 'pyright', }, ], }, { step: { uses: 'jakebailey/pyright-action@v2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'npm', depName: 'pyright', depType: 'uses-with', packageName: 'pyright', }, ], }, { step: { uses: 'jakebailey/pyright-action@v2', with: { version: 'PATH' }, }, expected: [ { currentValue: 'PATH', skipStage: 'extract', skipReason: 'invalid-version', datasource: 'npm', depName: 'pyright', depType: 'uses-with', packageName: 'pyright', }, ], }, { step: { uses: 'golangci/golangci-lint-action@v9', with: { version: 'v2.5.0' }, }, expected: [ { currentValue: 'v2.5.0', datasource: 'github-releases', depName: 'golangci/golangci-lint', depType: 'uses-with', packageName: 'golangci/golangci-lint', }, ], }, { step: { uses: 'golangci/golangci-lint-action@v9', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'golangci/golangci-lint', depType: 'uses-with', packageName: 'golangci/golangci-lint', }, ], }, { step: { uses: 'zizmorcore/zizmor-action@v0.5.2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'docker', depName: 'ghcr.io/zizmorcore/zizmor', depType: 'uses-with', packageName: 'ghcr.io/zizmorcore/zizmor', }, ], }, { step: { uses: 'zizmorcore/zizmor-action@v0.5.2', with: { version: 'v1.23.1', }, }, expected: [ { currentValue: 'v1.23.1', datasource: 'docker', depName: 'ghcr.io/zizmorcore/zizmor', depType: 'uses-with', packageName: 'ghcr.io/zizmorcore/zizmor', }, ], }, { step: { uses: 'zizmorcore/zizmor-action@v0.5.2', with: { version: '1.23.1', }, }, expected: [ { currentValue: '1.23.1', datasource: 'docker', depName: 'ghcr.io/zizmorcore/zizmor', depType: 'uses-with', packageName: 'ghcr.io/zizmorcore/zizmor', }, ], }, { step: { uses: 'docker/setup-docker-action@v4', with: { version: 'v27.1.0' }, }, expected: [ { currentValue: 'v27.1.0', datasource: 'github-releases', depName: 'docker', depType: 'uses-with', packageName: 'moby/moby', extractVersion: '^docker-(?.+)$', }, ], }, { step: { uses: 'dtolnay/rust-toolchain@master', with: { toolchain: '1.89.0' }, }, expected: [ { currentValue: '1.89.0', datasource: 'rust-version', depName: 'rust', depType: 'uses-with', packageName: 'rust', }, ], }, { step: { uses: 'dtolnay/rust-toolchain@master', with: { toolchain: 'nightly-2025-01-01' }, }, expected: [ { currentValue: 'nightly-2025-01-01', datasource: 'rust-version', depName: 'rust', depType: 'uses-with', packageName: 'rust', }, ], }, { step: { uses: 'dtolnay/rust-toolchain@stable', with: { toolchain: 'stable' }, }, expected: [ { currentValue: 'stable', datasource: 'rust-version', depName: 'rust', depType: 'uses-with', packageName: 'rust', }, ], }, { step: { uses: 'dtolnay/rust-toolchain@master', with: {}, }, expected: [ { datasource: 'rust-version', depName: 'rust', depType: 'uses-with', packageName: 'rust', skipStage: 'extract', skipReason: 'unspecified-version', }, ], }, { step: { uses: 'azure/setup-helm@v4', with: { version: 'v3.17.0' }, }, expected: [ { currentValue: 'v3.17.0', datasource: 'github-releases', depName: 'helm', depType: 'uses-with', packageName: 'helm/helm', }, ], }, { step: { uses: 'azure/setup-helm@v4', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'helm', depType: 'uses-with', packageName: 'helm/helm', }, ], }, { step: { uses: 'docker/setup-buildx-action@v3', with: { version: 'v0.19.3' }, }, expected: [ { currentValue: 'v0.19.3', datasource: 'github-releases', depName: 'buildx', depType: 'uses-with', packageName: 'docker/buildx', }, ], }, { step: { uses: 'docker/setup-buildx-action@v3', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'buildx', depType: 'uses-with', packageName: 'docker/buildx', }, ], }, { step: { uses: 'docker/setup-compose-action@v1', with: { version: 'v2.36.1' }, }, expected: [ { currentValue: 'v2.36.1', datasource: 'github-releases', depName: 'docker/compose', depType: 'uses-with', packageName: 'docker/compose', }, ], }, { step: { uses: 'docker/setup-compose-action@v1', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'docker/compose', depType: 'uses-with', packageName: 'docker/compose', }, ], }, { step: { uses: 'helm/chart-testing-action@v2', with: { version: 'v3.12.0' }, }, expected: [ { currentValue: 'v3.12.0', datasource: 'github-releases', depName: 'chart-testing', depType: 'uses-with', packageName: 'helm/chart-testing', }, ], }, { step: { uses: 'helm/chart-testing-action@v2', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'github-releases', depName: 'chart-testing', depType: 'uses-with', packageName: 'helm/chart-testing', }, ], }, { step: { uses: 'sigoden/install-binary@v1', with: { repo: 'sigoden/argc', tag: 'v1.22.0', }, }, expected: [ { currentValue: 'v1.22.0', datasource: 'github-releases', depName: 'sigoden/argc', depType: 'uses-with', packageName: 'sigoden/argc', }, ], }, { step: { uses: 'sigstore/cosign-installer@v4.1.2', with: { 'cosign-release': 'v3.1.2' }, }, expected: [ { currentValue: 'v3.1.2', datasource: 'github-releases', depName: 'sigstore/cosign', depType: 'uses-with', packageName: 'sigstore/cosign', }, ], }, { step: { uses: 'renovatebot/github-action@v43.0.0', with: { 'renovate-version': '43.100.0' }, }, expected: [ { currentValue: '43.100.0', datasource: 'docker', depName: 'ghcr.io/renovatebot/renovate', depType: 'uses-with', packageName: 'ghcr.io/renovatebot/renovate', }, ], }, { // `renovate-version: '43'`, quoted, matching the action's own default step: { uses: 'renovatebot/github-action@v43.0.0', with: { 'renovate-version': '43' }, }, expected: [ { currentValue: '43', datasource: 'docker', depName: 'ghcr.io/renovatebot/renovate', depType: 'uses-with', packageName: 'ghcr.io/renovatebot/renovate', }, ], }, { // `renovate-version: 43`, unquoted, parses as a YAML number rather than a string step: { uses: 'renovatebot/github-action@v43.0.0', with: { 'renovate-version': 43 }, }, expected: [ { currentValue: '43', datasource: 'docker', depName: 'ghcr.io/renovatebot/renovate', depType: 'uses-with', packageName: 'ghcr.io/renovatebot/renovate', }, ], }, { step: { uses: 'renovatebot/github-action@v43.0.0', with: {}, }, expected: [ { skipStage: 'extract', skipReason: 'unspecified-version', datasource: 'docker', depName: 'ghcr.io/renovatebot/renovate', depType: 'uses-with', packageName: 'ghcr.io/renovatebot/renovate', }, ], }, { step: { uses: 'renovatebot/github-action@v43.0.0', with: { 'renovate-version': '43.100.0', 'renovate-image': 'ghcr.io/my-org/renovate', }, }, expected: [ { currentValue: '43.100.0', datasource: 'docker', depName: 'ghcr.io/my-org/renovate', depType: 'uses-with', packageName: 'ghcr.io/my-org/renovate', }, ], }, { step: { uses: 'renovatebot/github-action@v43.0.0', with: { 'renovate-image': 'ghcr.io/renovatebot/renovate@sha256:0f7ba2b70c5d1a7e2d95b0f6c3d5b4a1e2f6b6a1e2f6b6a1e2f6b6a1e2f6b6a1', }, }, expected: [ { currentDigest: 'sha256:0f7ba2b70c5d1a7e2d95b0f6c3d5b4a1e2f6b6a1e2f6b6a1e2f6b6a1e2f6b6a1', datasource: 'docker', depName: 'ghcr.io/renovatebot/renovate', depType: 'uses-with', packageName: 'ghcr.io/renovatebot/renovate', skipStage: 'extract', skipReason: 'unspecified-version', }, ], }, { step: { uses: 'UpCloudLtd/upcloud-cli-action@main', with: { version: 'v3.35.0' }, }, expected: [ { currentValue: 'v3.35.0', datasource: 'github-releases', depName: 'UpCloudLtd/upcloud-cli', depType: 'uses-with', packageName: 'UpCloudLtd/upcloud-cli', }, ], }, ])('extract from $step.uses', async ({ step, expected }) => { const yamlContent = yaml.dump({ jobs: { build: { steps: [step] } } }); const res = await extractPackageFile(yamlContent, 'workflow.yml'); expect(res?.deps.filter((pkg) => pkg.depType !== 'action')).toMatchObject( expected, ); }); describe('actions.lock', () => { beforeEach(() => { // the lock file is read through the memory cache, which production initialises per repository memCache.init(); }); const workflow = codeBlock` jobs: build: steps: - uses: actions/checkout@v4.3.1 - uses: docker://alpine:3.20 `; const lockfile = codeBlock` version: 'v0.0.2' workflows: '.github/workflows/ci.yml': - 'actions/checkout@v4.3.1' `; it('marks digests as externally managed for an onboarded workflow', async () => { fs.readLocalFile.mockResolvedValueOnce(lockfile); const res = await extractPackageFile( workflow, '.github/workflows/ci.yml', ); // `gh actions-lock` strips an inline pin back out, so the lookup must not offer one expect(res?.deps).toMatchObject([ { depName: 'actions/checkout', digestManagedExternally: true }, { depName: 'alpine' }, ]); // the lockfile only records `OWNER/REPO@REF` pins, so a `docker://` image is still ours to pin expect(res?.deps[1]).not.toHaveProperty('digestManagedExternally'); }); it('marks a reusable workflow call, which the lockfile records too', async () => { fs.readLocalFile.mockResolvedValueOnce(lockfile); const res = await extractPackageFile( codeBlock` jobs: release: uses: some-org/some-repo/.github/workflows/release.yml@v1 `, '.github/workflows/ci.yml', ); expect(res?.deps).toMatchObject([ { depType: 'workflow', digestManagedExternally: true }, ]); }); it('leaves a workflow which is not onboarded alone', async () => { fs.readLocalFile.mockResolvedValueOnce(lockfile); // a repository can deliberately keep workflows out of the lockfile const res = await extractPackageFile( workflow, '.github/workflows/not-onboarded.yml', ); expect(res?.deps[0]).not.toHaveProperty('digestManagedExternally'); }); it.each` packageFile ${'workflow-templates/ci.yml'} ${'.gitea/workflows/ci.yml'} ${'.forgejo/workflows/ci.yml'} ${'.gitea/actions/build/action.yml'} `( 'leaves $packageFile alone, as the tool never reads it', async ({ packageFile }: { packageFile: string }) => { fs.readLocalFile.mockResolvedValueOnce(lockfile); const res = await extractPackageFile(workflow, packageFile); expect(res?.deps[0]).not.toHaveProperty('digestManagedExternally'); }, ); it('marks a local composite action, which the lockfile does not key', async () => { fs.readLocalFile.mockResolvedValueOnce(lockfile); // composite actions are transitive dependencies of onboarded workflows, so the tool rewrites them too const res = await extractPackageFile( workflow, '.github/actions/build/action.yml', ); expect(res?.deps[0].digestManagedExternally).toBe(true); }); it('marks a local composite action when the lockfile has onboarded nothing', async () => { fs.readLocalFile.mockResolvedValueOnce( "version: 'v0.0.2'\nworkflows: {}", ); // a composite action isn't keyed by `workflows:`, so it's still managed even when that key is missing entirely const res = await extractPackageFile( workflow, '.github/actions/build/action.yml', ); expect(res?.deps[0].digestManagedExternally).toBe(true); }); it('leaves digests alone when there is no lockfile', async () => { fs.readLocalFile.mockResolvedValueOnce(null); const res = await extractPackageFile( workflow, '.github/workflows/ci.yml', ); expect(res?.deps[0]).not.toHaveProperty('digestManagedExternally'); }); it('leaves the digests to the tool when the lockfile cannot be parsed', async () => { fs.readLocalFile.mockResolvedValueOnce('workflows: [a, b]'); const res = await extractPackageFile( workflow, '.github/workflows/ci.yml', ); // `updateActionsLockfile` refuses to regenerate a lockfile it cannot parse, so pinning inline would leave the lockfile stale expect(res?.deps[0].digestManagedExternally).toBe(true); }); it('does not read the lockfile when nothing was extracted', async () => { const res = await extractPackageFile('nothing: here', 'workflow.yml'); expect(res).toBeNull(); expect(fs.readLocalFile).not.toHaveBeenCalled(); }); it('reads the lockfile once for all package files', async () => { fs.readLocalFile.mockResolvedValue(lockfile); const [onboarded, notOnboarded] = await Promise.all([ extractPackageFile(workflow, '.github/workflows/ci.yml'), extractPackageFile(workflow, '.github/workflows/not-onboarded.yml'), ]); expect(onboarded?.deps[0].digestManagedExternally).toBe(true); expect(notOnboarded?.deps[0]).not.toHaveProperty( 'digestManagedExternally', ); expect(fs.readLocalFile).toHaveBeenCalledOnce(); }); }); });