mirror of
https://github.com/renovatebot/renovate.git
synced 2026-10-25 23:11:31 +00:00
b91f928aa0
When a GitHub Actions Workflow digest pins against a repository that
tags releases with the `v` prefix, but the Workflow itself doesn't use
the `v` like:
uses: renovatebot/github-action@abd08c7549 # 46.1.5
we correctly match `46.1.5` to the release `v46.1.5` and propose an
update, but when we do a `digest` update (which confirms whether the
current digest SHA is still what the tag is pointing to) it uses
`46.1.5` as its `newValue`.
We look for a release with that version verbatim, and because it's
missing the `v` prefix, we'll perform a failed tag lookup:
DEBUG: github/tags.findCommitOfTag: Tag 46.1.5 not found for renovatebot/github-action
DEBUG: Could not determine new digest for update.
As we have a `currentDigest`, this also adds a warning for the
dependency, and we'll never be able to update if the underlying tag has
moved, because we can't look up the tag's SHA.
Although we could retry the lookup with the `v` prefix, we should
instead treat this as the misconfiguration it is, and surface it more
visibly to users with a WARN log.
If we did provide a retry, this could lead to a supply chain attack
where a repository maintainer could push both a `1.2.3` and `v1.2.3` and
convince Renovate to update to a malicious version.
Co-authored-by: Claude Sonnet 5.5 <jamie.tanna+claude-code@mend.io>
1074 lines
28 KiB
TypeScript
1074 lines
28 KiB
TypeScript
import { codeBlock } from 'common-tags';
|
|
import { logger, partial } from '~test/util.ts';
|
|
import { getConfig } from '../../../config/defaults.ts';
|
|
import { Result } from '../../../util/result.ts';
|
|
import * as lookup from '../../../workers/repository/process/lookup/index.ts';
|
|
import type { LookupUpdateConfig } from '../../../workers/repository/process/lookup/types.ts';
|
|
import { GithubReleaseAttachmentsDatasource } from '../../datasource/github-release-attachments/index.ts';
|
|
import { GithubTagsDatasource } from '../../datasource/github-tags/index.ts';
|
|
import type { PackageDependency } from '../types.ts';
|
|
import { extractPackageFile } from './index.ts';
|
|
|
|
vi.mock('../../../util/fs/index.ts');
|
|
|
|
describe('modules/manager/github-actions/integration', () => {
|
|
const getGithubTags = vi.spyOn(GithubTagsDatasource.prototype, 'getReleases');
|
|
const getGithubDigest = vi.spyOn(GithubTagsDatasource.prototype, 'getDigest');
|
|
const getGithubReleaseAttachments = vi.spyOn(
|
|
GithubReleaseAttachmentsDatasource.prototype,
|
|
'getReleases',
|
|
);
|
|
const getGithubReleaseAttachmentDigest = vi.spyOn(
|
|
GithubReleaseAttachmentsDatasource.prototype,
|
|
'getDigest',
|
|
);
|
|
|
|
let baseConfig: LookupUpdateConfig;
|
|
|
|
beforeEach(() => {
|
|
// TODO: fix types #22198
|
|
baseConfig = partial<LookupUpdateConfig>(getConfig() as never);
|
|
baseConfig.manager = 'github-actions';
|
|
});
|
|
|
|
function makeConfig(dep: PackageDependency): LookupUpdateConfig {
|
|
return {
|
|
...baseConfig,
|
|
...dep,
|
|
currentValue: dep.currentValue ?? undefined,
|
|
// isGetPkgReleasesConfig() requires a non-empty packageName, even though `extractPackageFile` does not set a `packageName` if it has the same value as `depName`
|
|
packageName: dep.packageName ?? dep.depName!,
|
|
};
|
|
}
|
|
|
|
it('updates the mise version and checksum together', async () => {
|
|
const currentDigest =
|
|
'429f71e7e989908bf975aafac9066329c16e2d8fc7cd8e74fdf21dd6300ffe7c';
|
|
const newDigest =
|
|
'dad54e0b843908324282b8673f9c0ebc3a4da0c49ad2da309a49bfbc918ba180';
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: jdx/mise-action@v4
|
|
with:
|
|
version: v2026.7.7
|
|
sha256: ${currentDigest}
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
const dep = extracted!.deps.find(
|
|
(dependency) => dependency.depType === 'uses-with',
|
|
);
|
|
getGithubReleaseAttachments.mockResolvedValueOnce({
|
|
releases: [{ version: 'v2026.7.7' }, { version: 'v2026.7.12' }],
|
|
});
|
|
getGithubReleaseAttachmentDigest.mockImplementation((_config, newValue) =>
|
|
Promise.resolve(newValue === 'v2026.7.12' ? newDigest : currentDigest),
|
|
);
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
expect.objectContaining({
|
|
newDigest,
|
|
newValue: 'v2026.7.12',
|
|
updateType: 'patch',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('proposes major update when using tagged major, if a major is available', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v1
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v1.0.0' },
|
|
// additional majors to show they're skipped, as expected
|
|
{ version: 'v2' },
|
|
{ version: 'v3' },
|
|
// this will be used as it's the shortest match
|
|
{ version: 'v4' },
|
|
// these are added to make it clear they won't be used
|
|
{ version: 'v4.0' },
|
|
{ version: 'v4.0.0' },
|
|
{ version: 'v4.1.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v4',
|
|
newVersion: 'v4.1.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('switches major-only version to major.minor if no major is available', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v1
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v1.0.0' },
|
|
{ version: 'v2' },
|
|
{ version: 'v3' },
|
|
// no major
|
|
{ version: 'v4.1' },
|
|
// this will not be used as a shorter version is available
|
|
{ version: 'v4.1.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v4.1',
|
|
newVersion: 'v4.1.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes major and minor updates for tagged major.minor', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v1.2
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v1' },
|
|
{ version: 'v1.2.0' },
|
|
{ version: 'v1.6.0' },
|
|
{ version: 'v2.0.0' },
|
|
{ version: 'v3.0.0' },
|
|
{ version: 'v4.0.0' },
|
|
{ version: 'v4.1' },
|
|
// this will not be used, as it's longer than what we're currently using
|
|
{ version: 'v4.1.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 1,
|
|
newMinor: 6,
|
|
newPatch: 0,
|
|
// a non-major bump gets a longer version, as there's no `v1.6`
|
|
newValue: 'v1.6.0',
|
|
newVersion: 'v1.6.0',
|
|
updateType: 'minor',
|
|
},
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v4.1',
|
|
newVersion: 'v4.1.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes minor update for full semver', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4.0.0
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
// won't be used, even though it's shorter than our current tagging
|
|
{ version: 'v4' },
|
|
{ version: 'v4.0.0' },
|
|
{ version: 'v4.1.0' },
|
|
{ version: 'v4.2.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 2,
|
|
newPatch: 0,
|
|
newValue: 'v4.2.0',
|
|
newVersion: 'v4.2.0',
|
|
updateType: 'minor',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes updates for SHA-pinned action with major-only comment', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v5.0.0' },
|
|
{ version: 'v5.1.0' },
|
|
{ version: 'v6.0.0' },
|
|
{ version: 'v6.1.0' },
|
|
],
|
|
});
|
|
// getDigest is called once per update (major version + digest update)
|
|
getGithubDigest
|
|
.mockResolvedValueOnce('aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111') // v6.1.0
|
|
.mockResolvedValueOnce(
|
|
'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
); // digest (v5)
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111',
|
|
newMajor: 6,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v6.1.0',
|
|
newVersion: 'v6.1.0',
|
|
updateType: 'major',
|
|
},
|
|
{
|
|
newDigest: 'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
newValue: 'v5',
|
|
updateType: 'digest',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes updates for SHA-pinned action with major.minor comment', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v5.0.0' },
|
|
{ version: 'v5.1' },
|
|
{ version: 'v5.1.0' },
|
|
{ version: 'v6.0.0' },
|
|
{ version: 'v6.1.0' },
|
|
],
|
|
});
|
|
// getDigest is called once per update (major version + digest update)
|
|
getGithubDigest
|
|
.mockResolvedValueOnce('bbbbbbbbcccccccccceeeeeeeeefffffaaaaaa1111111112') // v5.1.0
|
|
.mockResolvedValueOnce('aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111') // v6.1.0
|
|
.mockResolvedValueOnce(
|
|
'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
); // digest (v5)
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'bbbbbbbbcccccccccceeeeeeeeefffffaaaaaa1111111112',
|
|
newMajor: 5,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v5.1',
|
|
newVersion: 'v5.1.0',
|
|
updateType: 'minor',
|
|
},
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111',
|
|
newMajor: 6,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v6.1.0',
|
|
newVersion: 'v6.1.0',
|
|
updateType: 'major',
|
|
},
|
|
{
|
|
newDigest: 'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
newValue: 'v5.0',
|
|
updateType: 'digest',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes updates for SHA-pinned action with full semver comment', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning-semver.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v7.5.0' },
|
|
{ version: 'v7.6.0' },
|
|
{ version: 'v8.0.0' },
|
|
],
|
|
});
|
|
// getDigest is called once per update (minor + major + digest update)
|
|
getGithubDigest
|
|
.mockResolvedValueOnce('aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111') // v7.6.0
|
|
.mockResolvedValueOnce('ffffffffffffffffffffffffffffffffffffffff22222222') // v8.0.0
|
|
.mockResolvedValueOnce(
|
|
'1111111122222222333333334444444455555555aaaaaaaa',
|
|
); // digest (v7.5.0)
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111',
|
|
newMajor: 7,
|
|
newMinor: 6,
|
|
newPatch: 0,
|
|
newValue: 'v7.6.0',
|
|
newVersion: 'v7.6.0',
|
|
updateType: 'minor',
|
|
},
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
newMajor: 8,
|
|
newMinor: 0,
|
|
newPatch: 0,
|
|
newValue: 'v8.0.0',
|
|
newVersion: 'v8.0.0',
|
|
updateType: 'major',
|
|
},
|
|
{
|
|
newDigest: '1111111122222222333333334444444455555555aaaaaaaa',
|
|
newValue: 'v7.5.0',
|
|
updateType: 'digest',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('warns about a missing `v` prefix when the digest cannot be determined for a SHA-pinned comment', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@0000000000000000000000000000000000000000 # 1.0.0
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning-no-v.yml',
|
|
{},
|
|
);
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{
|
|
version: 'v1.0.0',
|
|
newDigest: 'aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111',
|
|
},
|
|
],
|
|
});
|
|
getGithubDigest.mockResolvedValue(null);
|
|
|
|
const { updates, warnings } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toBeEmpty();
|
|
expect(warnings).toEqual([
|
|
{
|
|
message:
|
|
'Could not determine new digest for update (github-tags package actions/checkout)',
|
|
topic: 'actions/checkout',
|
|
},
|
|
]);
|
|
expect(logger.logger.warn).toHaveBeenCalledWith(
|
|
{
|
|
packageName: 'actions/checkout',
|
|
datasource: GithubTagsDatasource.id,
|
|
newValue: '1.0.0',
|
|
suggestedValue: 'v1.0.0',
|
|
},
|
|
'Could not determine new digest for update. Check the tag in the `newValue` - did you maybe mean the `suggestedValue`, with a `v` prefix?',
|
|
);
|
|
});
|
|
|
|
it('proposes minor and major updates for floating minor tag', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: astral-sh/setup-uv@v7.5
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-another.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v7.5' },
|
|
{ version: 'v7.5.0' },
|
|
{ version: 'v7.5.1' },
|
|
{ version: 'v7.6' },
|
|
{ version: 'v7.6.0' },
|
|
// for instance, now using Immutable Releases so requiring full SemVer
|
|
{ version: 'v8.0.0' },
|
|
{ version: 'v8.1.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 7,
|
|
newMinor: 6,
|
|
newPatch: 0,
|
|
newValue: 'v7.6',
|
|
newVersion: 'v7.6.0',
|
|
updateType: 'minor',
|
|
},
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 8,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v8.1.0',
|
|
newVersion: 'v8.1.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes no update for major, when only newer patch/minor releases exist', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v4.0.0' },
|
|
{ version: 'v4.3.1' },
|
|
// no v4.3 floating tag
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([]);
|
|
});
|
|
|
|
it('proposes minor+major+digest updates for SHA-pinned with floating major comment', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning-best-practices.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v7.5.0' },
|
|
{ version: 'v7.6' },
|
|
{ version: 'v7.6.0' },
|
|
{ version: 'v8.0.0' },
|
|
{ version: 'v8.1.0' },
|
|
],
|
|
});
|
|
getGithubDigest
|
|
.mockResolvedValueOnce('aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111') // v8.1.0 major
|
|
.mockResolvedValueOnce(
|
|
'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
); // digest (v7)
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newDigest: 'aaaaaaaabbbbbbbbccccccccddddddddeeeeeeee11111111',
|
|
newMajor: 8,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v8.1.0',
|
|
newVersion: 'v8.1.0',
|
|
updateType: 'major',
|
|
},
|
|
{
|
|
newDigest: 'ffffffffffffffffffffffffffffffffffffffff22222222',
|
|
newValue: 'v7',
|
|
updateType: 'digest',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes no update for SHA-pinned when only patch version available and digest unchanged', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-pinning.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'actions/checkout');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [{ version: 'v5.0.0' }, { version: 'v5.0.1' }],
|
|
});
|
|
// the same SHA as we're already using
|
|
getGithubDigest.mockResolvedValueOnce(
|
|
'93cb6efe18208431cddfb8368fd83d5badbf9bfd',
|
|
);
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([]);
|
|
});
|
|
|
|
it('preserves floating major tag when newer patch/minor versions exist with full semver', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@v7
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v7' },
|
|
{ version: 'v7.5.0' },
|
|
{ version: 'v7.6' },
|
|
{ version: 'v7.6.0' },
|
|
{ version: 'v8.0.0' },
|
|
{ version: 'v8.1.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 8,
|
|
newMinor: 1,
|
|
newPatch: 0,
|
|
newValue: 'v8.1.0',
|
|
newVersion: 'v8.1.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('preserves floating major tag when only floating minor tags exist', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@v7
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [{ version: 'v7' }, { version: 'v7.5' }, { version: 'v7.6' }],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([]);
|
|
});
|
|
|
|
it('migrates floating major tag to major.minor when only floating minor tags exist', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@v7
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
// no major
|
|
{ version: 'v7.5' },
|
|
{ version: 'v7.6' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 7,
|
|
newMinor: 6,
|
|
newPatch: 0,
|
|
newValue: 'v7.6',
|
|
newVersion: 'v7.6',
|
|
updateType: 'minor',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes minor update for floating minor tag without returning less-specific floating major', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: astral-sh/setup-uv@v7.5
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find((d) => d.depName === 'astral-sh/setup-uv');
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v7' },
|
|
{ version: 'v7.5.0' },
|
|
{ version: 'v7.6' },
|
|
{ version: 'v7.6.0' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 7,
|
|
newMinor: 6,
|
|
newPatch: 0,
|
|
newValue: 'v7.6',
|
|
newVersion: 'v7.6.0',
|
|
updateType: 'minor',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('handles multiple deps in one workflow', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
test:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v1
|
|
- uses: actions/setup-node@v2
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/test.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const actionDeps = extracted!.deps.filter(
|
|
(d) => d.datasource === GithubTagsDatasource.id,
|
|
);
|
|
expect(actionDeps).toHaveLength(2);
|
|
|
|
getGithubTags
|
|
.mockResolvedValueOnce({
|
|
releases: [{ version: 'v1.0.0' }, { version: 'v4.0.0' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
releases: [{ version: 'v2.0.0' }, { version: 'v4.0.0' }],
|
|
});
|
|
|
|
const results = await Promise.all(
|
|
actionDeps.map(async (dep) => {
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep)),
|
|
).unwrapOrThrow();
|
|
return { depName: dep.depName, updates };
|
|
}),
|
|
);
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
depName: 'actions/checkout',
|
|
updates: [
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 0,
|
|
newPatch: 0,
|
|
newValue: 'v4.0.0',
|
|
newVersion: 'v4.0.0',
|
|
updateType: 'major',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
depName: 'actions/setup-node',
|
|
updates: [
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 4,
|
|
newMinor: 0,
|
|
newPatch: 0,
|
|
newValue: 'v4.0.0',
|
|
newVersion: 'v4.0.0',
|
|
updateType: 'major',
|
|
},
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('proposes minor and major updates for semver tag', async () => {
|
|
const workflow = codeBlock`
|
|
on: push
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: codecov/codecov-action@v5.5.3
|
|
`;
|
|
|
|
const extracted = await extractPackageFile(
|
|
workflow,
|
|
'.github/workflows/ci-another.yml',
|
|
{},
|
|
);
|
|
expect(extracted).not.toBeNull();
|
|
|
|
const dep = extracted!.deps.find(
|
|
(d) => d.depName === 'codecov/codecov-action',
|
|
);
|
|
expect(dep).toBeDefined();
|
|
|
|
getGithubTags.mockResolvedValueOnce({
|
|
releases: [
|
|
{ version: 'v6.0.0' },
|
|
{ version: 'v6' },
|
|
{ version: 'v5.5.4' },
|
|
{ version: 'v5' },
|
|
{ version: 'v5.5.3' },
|
|
],
|
|
});
|
|
|
|
const { updates } = await Result.wrap(
|
|
lookup.lookupUpdates(makeConfig(dep!)),
|
|
).unwrapOrThrow();
|
|
|
|
expect(updates).toEqual([
|
|
{
|
|
bucket: 'non-major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 5,
|
|
newMinor: 5,
|
|
newPatch: 4,
|
|
newValue: 'v5.5.4',
|
|
newVersion: 'v5.5.4',
|
|
updateType: 'patch',
|
|
},
|
|
{
|
|
bucket: 'major',
|
|
hasAttestation: undefined,
|
|
isBreaking: false,
|
|
newMajor: 6,
|
|
newMinor: 0,
|
|
newPatch: 0,
|
|
newValue: 'v6.0.0',
|
|
newVersion: 'v6.0.0',
|
|
updateType: 'major',
|
|
},
|
|
]);
|
|
});
|
|
});
|