diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts index a384a6f9e74..0a967e6202a 100644 --- a/lib/modules/versioning/api.ts +++ b/lib/modules/versioning/api.ts @@ -15,6 +15,7 @@ import * as docker from './docker/index.ts'; import * as elm from './elm/index.ts'; import * as exact from './exact/index.ts'; import * as git from './git/index.ts'; +import * as githubActions from './github-actions/index.ts'; import * as glasskube from './glasskube/index.ts'; import * as goModDirective from './go-mod-directive/index.ts'; import * as gradle from './gradle/index.ts'; @@ -72,6 +73,7 @@ api.set(docker.id, docker.api); api.set(elm.id, elm.api); api.set(exact.id, exact.api); api.set(git.id, git.api); +api.set(githubActions.id, githubActions.api); api.set(glasskube.id, glasskube.api); api.set(goModDirective.id, goModDirective.api); api.set(gradle.id, gradle.api); diff --git a/lib/modules/versioning/github-actions/index.spec.ts b/lib/modules/versioning/github-actions/index.spec.ts new file mode 100644 index 00000000000..e36105a72e3 --- /dev/null +++ b/lib/modules/versioning/github-actions/index.spec.ts @@ -0,0 +1,571 @@ +import { logger } from '~test/util.ts'; +import githubActions from './index.ts'; + +describe('modules/versioning/github-actions/index', () => { + describe('.isValid()', () => { + it.each` + version | expected + ${'1'} | ${true} + ${'1.2'} | ${true} + ${'1.2.3'} | ${true} + ${'~latest'} | ${false} + ${'1.2.3-alpha'} | ${true} + ${'v1'} | ${true} + ${'v1.2'} | ${true} + ${'v1.2.3'} | ${true} + ${'v1.2.3-alpha'} | ${true} + ${'invalid'} | ${false} + ${''} | ${false} + `('isValid("$version") === $expected', ({ version, expected }) => { + expect(githubActions.isValid(version)).toBe(expected); + }); + }); + + describe('.isVersion()', () => { + it.each` + version | expected + ${'1'} | ${false} + ${'1.2'} | ${false} + ${'1.2.3'} | ${true} + ${'~latest'} | ${false} + ${'1.2.3-alpha'} | ${true} + ${'1.2.3-rc.1'} | ${true} + ${'v1'} | ${false} + ${'v1.2'} | ${false} + ${'v1.2.3'} | ${true} + ${'v1.2.3-alpha'} | ${true} + ${'v1.2.3-rc.1'} | ${true} + ${'invalid'} | ${false} + ${''} | ${false} + ${'#1.0.0'} | ${false} + ${'x1.0.0'} | ${false} + `('isVersion("$version") === $expected', ({ version, expected }) => { + expect(githubActions.isVersion(version)).toBe(expected); + }); + }); + + describe('.isStable()', () => { + it.each` + version | expected + ${'1.0.0-alpha'} | ${false} + ${'1.0.0-beta'} | ${false} + ${'1.0.0-rc'} | ${false} + ${'1.0.0-pre'} | ${false} + ${'1.0.0-dev'} | ${false} + ${'1.0.0-snapshot'} | ${false} + ${'1.0.0-unstable'} | ${false} + ${'1.0.0-Alpha'} | ${false} + ${'1.0.0-1'} | ${false} + ${'1.0.0-build.1'} | ${false} + ${'1.0.0'} | ${true} + ${'v1.0.0'} | ${true} + ${'v1.0.0-alpha'} | ${false} + ${'1'} | ${false} + ${'not-a-version'} | ${false} + `('isStable("$version") === $expected', ({ version, expected }) => { + expect(githubActions.isStable(version)).toBe(expected); + }); + }); + + describe('.isSingleVersion()', () => { + it.each` + version | expected + ${'1'} | ${false} + ${'1.2'} | ${false} + ${'1.2.3'} | ${true} + ${'~latest'} | ${false} + ${'1.2.3-alpha'} | ${true} + ${'v1'} | ${false} + ${'v1.2'} | ${false} + ${'v1.2.3'} | ${true} + ${'v1.2.3-alpha'} | ${true} + `('isSingleVersion("$version") === $expected', ({ version, expected }) => { + expect(githubActions.isSingleVersion(version)).toBe(expected); + }); + }); + + describe('.matches()', () => { + it.each` + version | range | expected + ${'1.1.0'} | ${'1.0'} | ${false} + ${'1.0.0'} | ${'1'} | ${true} + ${'1.2.0'} | ${'1'} | ${true} + ${'1.2.3'} | ${'1'} | ${true} + ${'2.0.0'} | ${'1'} | ${false} + ${'1.1.0'} | ${'1.1'} | ${true} + ${'1.1.5'} | ${'1.1'} | ${true} + ${'1.2.0'} | ${'1.1'} | ${false} + ${'1.0.0'} | ${'1.1'} | ${false} + ${'1.2.3'} | ${'1.2'} | ${true} + ${'1.2.0'} | ${'1.2'} | ${true} + ${'1.3.0'} | ${'1.2'} | ${false} + ${'1.0.0'} | ${'~latest'} | ${false} + ${'2.1.0'} | ${'~latest'} | ${false} + ${'1.0.0-rc'} | ${'1'} | ${false} + ${'1.0.0-rc'} | ${'1.0'} | ${false} + ${'invalid'} | ${'1'} | ${false} + ${'~latest'} | ${'1'} | ${false} + ${'1'} | ${'1'} | ${false} + ${'1.2'} | ${'1.2'} | ${false} + ${'1.2.3'} | ${'1.2.3'} | ${true} + ${'1.2.4'} | ${'1.2.3'} | ${false} + ${'not-semver-ver'} | ${'1'} | ${false} + ${'1.0.0-alpha'} | ${'1'} | ${false} + ${'1.0.0-beta'} | ${'1.0'} | ${false} + ${'v1.0.0'} | ${'v1'} | ${true} + ${'v1.2.0'} | ${'v1'} | ${true} + ${'v1.1.0'} | ${'v1.1'} | ${true} + ${'v1.2.3'} | ${'v1.2.3'} | ${true} + ${'v2.0.0'} | ${'v1'} | ${false} + ${'v1.0.0'} | ${'1'} | ${true} + ${'1.0.0'} | ${'v1'} | ${true} + ${'v1.1.5'} | ${'1.1'} | ${true} + ${'1.1.5'} | ${'v1.1'} | ${true} + ${'v2.1.0'} | ${'2'} | ${true} + ${'2.1.0'} | ${'v2'} | ${true} + ${'v1.2.0'} | ${'1.1'} | ${false} + ${'1.2.0'} | ${'v1.1'} | ${false} + ${'v1.0.0-rc'} | ${'v1'} | ${false} + ${'1.0.0-rc'} | ${'v1'} | ${false} + ${'v1.0.0-rc'} | ${'1'} | ${false} + ${'v1.2.4'} | ${'1.2.3'} | ${false} + ${'1.2.4'} | ${'v1.2.3'} | ${false} + ${'v1.2.3'} | ${'1.2.3'} | ${true} + ${'1.2.3'} | ${'v1.2.3'} | ${true} + `( + 'matches("$version", "$range") === $expected', + ({ version, range, expected }) => { + expect(githubActions.matches(version, range)).toBe(expected); + }, + ); + + it('should not handle invalid range that is not ~latest or valid version', () => { + expect(githubActions.matches('1.0.0', 'completely-invalid-range')).toBe( + false, + ); + }); + }); + + describe('.getSatisfyingVersion()', () => { + it.each` + versions | range | expected + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0', '2.0.1']} | ${'1'} | ${'1.2.0'} + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0', '2.0.1']} | ${'1.1'} | ${'1.1.1'} + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0', '2.0.1']} | ${'2'} | ${'2.0.1'} + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0', '2.1.0']} | ${'~latest'} | ${null} + ${['1.0.0', '1.1.0', '1.2.0', '2.0.0', '2.0.1', '2.1.0-rc']} | ${'2'} | ${'2.0.1'} + ${['1.0.0', '1.0.1-rc', '1.1.0']} | ${'1.0'} | ${'1.0.0'} + ${['0.5.0', '1.0.0', '2.0.0']} | ${'3'} | ${null} + ${['invalid-version', '1.0.0']} | ${'1'} | ${'1.0.0'} + ${['1.0', '1.1', '1.2']} | ${'1'} | ${null} + ${['0.9.0-alpha', '0.9.0-beta', '0.9.0']} | ${'~latest'} | ${null} + ${['some-text', 'another-text']} | ${'1'} | ${null} + ${['not-valid', 'also-bad']} | ${'1'} | ${null} + ${['1.0.0', '1.0.1-alpha', '1.0.2', '1.1.0-beta', '1.1.1']} | ${'1'} | ${'1.1.1'} + ${['1.0.0', '1.0.1-alpha', '1.0.2', '1.1.0-beta', '1.1.1']} | ${'1.0'} | ${'1.0.2'} + ${['v1.0.0', 'v1.1.0', 'v1.1.1', 'v1.2.0', 'v2.0.0']} | ${'v1'} | ${'v1.2.0'} + ${['v1.0.0', 'v1.1.0', 'v1.1.1']} | ${'v1.1'} | ${'v1.1.1'} + ${['v1.0.0', 'v2.0.0', 'v3.0.0']} | ${'v2'} | ${'v2.0.0'} + ${['1.0.0', 'v1.1.0', '1.1.1', 'v1.2.0', '2.0.0']} | ${'1'} | ${'v1.2.0'} + ${['v1.0.0', '1.1.0', 'v1.1.1']} | ${'v1.1'} | ${'v1.1.1'} + ${['1.0.0', 'v1.1.0', '1.1.1']} | ${'1'} | ${'1.1.1'} + ${['v1.0.0', '1.1.0', 'v1.1.1']} | ${'1'} | ${'v1.1.1'} + ${['1.0.0', '1.1.0', '1.2.0']} | ${'v1'} | ${'1.2.0'} + ${['v1.0.0', 'v1.1.0', '1.2.0']} | ${'1'} | ${'1.2.0'} + `( + 'getSatisfyingVersion($versions, "$range") === $expected', + ({ versions, range, expected }) => { + expect(githubActions.getSatisfyingVersion(versions, range)).toBe( + expected, + ); + }, + ); + }); + + describe('.minSatisfyingVersion()', () => { + it.each` + versions | range | expected + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0']} | ${'1'} | ${'1.0.0'} + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0']} | ${'1.1'} | ${'1.1.0'} + ${['1.0.0', '1.1.0', '1.1.1', '1.2.0', '2.0.0', '2.0.1']} | ${'2'} | ${'2.0.0'} + ${['1.0.0', '1.1.0', '1.2.0', '2.0.0', '2.0.1', '2.1.0']} | ${'~latest'} | ${null} + ${['1.0.0', '1.0.1-rc', '1.1.0']} | ${'1.0'} | ${'1.0.0'} + ${['0.5.0', '1.0.0', '2.0.0']} | ${'3'} | ${null} + ${['v0.5.0', 'v1.0.0', 'v2.0.0']} | ${'v3'} | ${null} + ${['v1.0.0', '1.1.0', 'v1.2.0']} | ${'1'} | ${'v1.0.0'} + ${['1.0.0', 'v1.1.0', '1.2.0']} | ${'v1'} | ${'1.0.0'} + ${['v1.0.0', 'v1.1.0', '1.2.0', 'v2.0.0']} | ${'v1'} | ${'v1.0.0'} + ${['1.0.0', '1.1.0', 'v1.2.0', '2.0.0']} | ${'1'} | ${'1.0.0'} + `( + 'minSatisfyingVersion($versions, "$range") === $expected', + ({ versions, range, expected }) => { + expect(githubActions.minSatisfyingVersion(versions, range)).toBe( + expected, + ); + }, + ); + }); + + describe('.isLessThanRange()', () => { + it.each` + version | range | expected + ${'0.9.0'} | ${'1'} | ${true} + ${'1.0.0'} | ${'1'} | ${false} + ${'1.5.0'} | ${'1'} | ${false} + ${'2.0.0'} | ${'1'} | ${false} + ${'1.0.0'} | ${'1.1'} | ${true} + ${'1.1.0'} | ${'1.1'} | ${false} + ${'1.2.0'} | ${'1.1'} | ${false} + ${'0.9.0'} | ${'~latest'} | ${false} + ${'1.0.0'} | ${'~latest'} | ${false} + ${'1.5.0'} | ${'1'} | ${false} + ${'invalid'} | ${'1'} | ${false} + ${'v0.9.0'} | ${'v1'} | ${true} + ${'v1.0.0'} | ${'v1'} | ${false} + ${'v1.0.0'} | ${'v1.1'} | ${true} + ${'0.9.0'} | ${'v1'} | ${true} + ${'v0.9.0'} | ${'1'} | ${true} + ${'1.0.0'} | ${'v1.1'} | ${true} + ${'v1.0.0'} | ${'1.1'} | ${true} + ${'v2.0.0'} | ${'1'} | ${false} + ${'2.0.0'} | ${'v1'} | ${false} + `( + 'isLessThanRange("$version", "$range") === $expected', + ({ version, range, expected }) => { + expect(githubActions.isLessThanRange?.(version, range)).toBe(expected); + }, + ); + }); + + describe('.equals()', () => { + it.each` + version | other | expected + ${'1.0.0'} | ${'1.0.0'} | ${true} + ${'1.0.0'} | ${'1.0.1'} | ${false} + ${'invalid'} | ${'1.0.0'} | ${false} + ${'1.0.0'} | ${'invalid'} | ${false} + ${'invalid'} | ${'invalid'} | ${false} + ${'v1.0.0'} | ${'v1.0.0'} | ${true} + ${'v1.0.0'} | ${'v1.0.1'} | ${false} + ${'v1.0.0'} | ${'1.0.0'} | ${true} + ${'1.0.0'} | ${'v1.0.0'} | ${true} + ${'v1.0.0'} | ${'1.0.1'} | ${false} + ${'1.0.1'} | ${'v1.0.0'} | ${false} + `( + 'equals("$version", "$other") === $expected', + ({ version, other, expected }) => { + expect(githubActions.equals(version, other)).toBe(expected); + }, + ); + }); + + describe('.getMajor()', () => { + it.each` + version | expected + ${'1.0.0'} | ${1} + ${'2.3.4'} | ${2} + ${'v1.0.0'} | ${1} + ${'v2.3.4'} | ${2} + ${'invalid'} | ${null} + `('getMajor("$version") === $expected', ({ version, expected }) => { + expect(githubActions.getMajor(version)).toBe(expected); + }); + }); + + describe('.getMinor()', () => { + it.each` + version | expected + ${'1.0.0'} | ${0} + ${'2.3.4'} | ${3} + ${'v1.0.0'} | ${0} + ${'v2.3.4'} | ${3} + ${'invalid'} | ${null} + `('getMinor("$version") === $expected', ({ version, expected }) => { + expect(githubActions.getMinor(version)).toBe(expected); + }); + }); + + describe('.getPatch()', () => { + it.each` + version | expected + ${'1.0.0'} | ${0} + ${'2.3.4'} | ${4} + ${'v1.0.0'} | ${0} + ${'v2.3.4'} | ${4} + ${'invalid'} | ${null} + `('getPatch("$version") === $expected', ({ version, expected }) => { + expect(githubActions.getPatch(version)).toBe(expected); + }); + }); + + describe('.isGreaterThan()', () => { + it.each` + version | other | expected + ${'1.0.1'} | ${'1.0.0'} | ${true} + ${'1.0.0'} | ${'1.0.1'} | ${false} + ${'2.0.0'} | ${'1.9.9'} | ${true} + ${'invalid'} | ${'1.0.0'} | ${false} + ${'1.0.0'} | ${'invalid'} | ${false} + ${'v1.0.1'} | ${'v1.0.0'} | ${true} + ${'v1.0.0'} | ${'v1.0.1'} | ${false} + ${'v2.0.0'} | ${'v1.9.9'} | ${true} + ${'v1.0.0'} | ${'1.0.1'} | ${false} + ${'1.0.1'} | ${'v1.0.0'} | ${true} + ${'v2.0.0'} | ${'1.0.0'} | ${true} + ${'2.0.0'} | ${'v1.0.0'} | ${true} + ${'v1.9.9'} | ${'1.9.8'} | ${true} + ${'1.9.9'} | ${'v1.9.8'} | ${true} + `( + 'isGreaterThan("$version", "$other") === $expected', + ({ version, other, expected }) => { + expect(githubActions.isGreaterThan(version, other)).toBe(expected); + }, + ); + }); + + describe('.sortVersions()', () => { + it.each` + a | b | expected + ${'1.0.0'} | ${'1.0.0'} | ${0} + ${'1.0.0'} | ${'1.0.1'} | ${-1} + ${'1.0.1'} | ${'1.0.0'} | ${1} + ${'2.0.0'} | ${'1.9.9'} | ${1} + ${'invalid'} | ${'1.0.0'} | ${0} + ${'1.0.0'} | ${'invalid'} | ${0} + ${'invalid'} | ${'invalid'} | ${0} + ${'v1.0.0'} | ${'v1.0.0'} | ${0} + ${'v1.0.0'} | ${'v1.0.1'} | ${-1} + ${'v1.0.1'} | ${'v1.0.0'} | ${1} + ${'v1.0.0'} | ${'1.0.0'} | ${0} + ${'1.0.0'} | ${'v1.0.0'} | ${0} + ${'v1.0.0'} | ${'1.0.1'} | ${-1} + ${'1.0.1'} | ${'v1.0.0'} | ${1} + `('sortVersions("$a", "$b") === $expected', ({ a, b, expected }) => { + expect(githubActions.sortVersions(a, b)).toBe(expected); + }); + }); + + describe('.isBreaking()', () => { + it.each` + version | current | expected + ${'2.0.0'} | ${'1.0.0'} | ${true} + ${'1.1.0'} | ${'1.0.0'} | ${false} + ${'1.0.1'} | ${'1.0.0'} | ${false} + ${'0.2.0'} | ${'0.1.0'} | ${true} + ${'0.1.1'} | ${'0.1.0'} | ${false} + ${'1.0.0'} | ${'0.9.0'} | ${true} + ${'invalid'} | ${'1.0.0'} | ${false} + ${'1.0.0'} | ${'invalid'} | ${false} + ${'v2.0.0'} | ${'v1.0.0'} | ${true} + ${'v1.1.0'} | ${'v1.0.0'} | ${false} + ${'v0.2.0'} | ${'v0.1.0'} | ${true} + ${'v2.0.0'} | ${'1.0.0'} | ${true} + ${'2.0.0'} | ${'v1.0.0'} | ${true} + ${'1.1.0'} | ${'v1.0.0'} | ${false} + ${'v1.1.0'} | ${'1.0.0'} | ${false} + ${'v1.0.0'} | ${'0.9.0'} | ${true} + ${'1.0.0'} | ${'v0.9.0'} | ${true} + `( + 'isBreaking("$version", "$current") === $expected', + ({ version, current, expected }) => { + expect(githubActions.isBreaking!(version, current)).toBe(expected); + }, + ); + }); + + describe('.isCompatible()', () => { + it.each` + version | expected + ${'1.0.0'} | ${true} + ${'1'} | ${true} + ${'~latest'} | ${false} + ${'v1.0.0'} | ${true} + ${'v1'} | ${true} + ${'invalid'} | ${false} + `('isCompatible("$version") === $expected', ({ version, expected }) => { + expect(githubActions.isCompatible(version)).toBe(expected); + }); + }); + + describe('.getNewValue()', () => { + it.each` + currentValue | rangeStrategy | currentVersion | newVersion | expected + ${'1'} | ${'pin'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'1.2'} | ${'pin'} | ${'1.2.0'} | ${'1.2.1'} | ${'1.2.1'} + ${'1.2.3'} | ${'pin'} | ${'1.2.3'} | ${'1.2.4'} | ${'1.2.4'} + ${'2'} | ${'pin'} | ${'2.0.0'} | ${'2.1.0'} | ${'2.1.0'} + ${'2.5'} | ${'pin'} | ${'2.5.0'} | ${'2.5.3'} | ${'2.5.3'} + ${'10'} | ${'pin'} | ${'10.0.0'} | ${'10.1.0'} | ${'10.1.0'} + ${'~latest'} | ${'pin'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'1.0.0'} | ${'pin'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'v1'} | ${'pin'} | ${'v1.0.0'} | ${'v1.1.0'} | ${'v1.1.0'} + ${'v1.2'} | ${'pin'} | ${'v1.2.0'} | ${'v1.2.1'} | ${'v1.2.1'} + ${'v1.2.3'} | ${'pin'} | ${'v1.2.3'} | ${'v1.2.4'} | ${'v1.2.4'} + ${'v1'} | ${'pin'} | ${'v1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'v1.2.3'} | ${'pin'} | ${'v1.2.3'} | ${'1.2.4'} | ${'1.2.4'} + ${'1'} | ${'pin'} | ${'1.0.0'} | ${'v1.1.0'} | ${'v1.1.0'} + ${'1.2.3'} | ${'pin'} | ${'1.2.3'} | ${'v1.2.4'} | ${'v1.2.4'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'1'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'2.0.0'} | ${'2'} + ${'1.2'} | ${'replace'} | ${'1.2.0'} | ${'1.2.1'} | ${'1.2'} + ${'1.2'} | ${'replace'} | ${'1.2.0'} | ${'1.3.0'} | ${'1.3'} + ${'1.2.3'} | ${'replace'} | ${'1.2.3'} | ${'1.2.4'} | ${'1.2.4'} + ${'1.2.3'} | ${'replace'} | ${'1.2.3'} | ${'1.3.0'} | ${'1.3.0'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'1.2.0'} | ${'1'} + ${'1.2'} | ${'replace'} | ${'1.2.0'} | ${'2.0.0'} | ${'2.0'} + ${'2'} | ${'replace'} | ${'2.0.0'} | ${'3.0.0'} | ${'3'} + ${'2.1'} | ${'replace'} | ${'2.1.0'} | ${'2.2.0'} | ${'2.2'} + ${'10.5'} | ${'replace'} | ${'10.5.0'} | ${'10.6.0'} | ${'10.6'} + ${'~latest'} | ${'replace'} | ${'1.0.0'} | ${'2.0.0'} | ${'2.0.0'} + ${'1.0.0'} | ${'replace'} | ${'1.0.0'} | ${'1.1.0'} | ${'1.1.0'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'invalid'} | ${'invalid'} + ${'v1'} | ${'replace'} | ${'v1.0.0'} | ${'v1.1.0'} | ${'v1'} + ${'v1'} | ${'replace'} | ${'v1.0.0'} | ${'v2.0.0'} | ${'v2'} + ${'v1.2'} | ${'replace'} | ${'v1.2.0'} | ${'v1.2.1'} | ${'v1.2'} + ${'v1.2'} | ${'replace'} | ${'v1.2.0'} | ${'v1.3.0'} | ${'v1.3'} + ${'v1.2.3'} | ${'replace'} | ${'v1.2.3'} | ${'v1.2.4'} | ${'v1.2.4'} + ${'v1.2.3'} | ${'replace'} | ${'v1.2.3'} | ${'v1.3.0'} | ${'v1.3.0'} + ${'v1'} | ${'replace'} | ${'v1.0.0'} | ${'v1.2.0'} | ${'v1'} + ${'v2'} | ${'replace'} | ${'v2.0.0'} | ${'v3.0.0'} | ${'v3'} + ${'v1'} | ${'replace'} | ${'v1.0.0'} | ${'1.1.0'} | ${'v1'} + ${'v1'} | ${'replace'} | ${'v1.0.0'} | ${'2.0.0'} | ${'v2'} + ${'v1.2.3'} | ${'replace'} | ${'v1.2.3'} | ${'1.2.4'} | ${'1.2.4'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'v1.1.0'} | ${'1'} + ${'1'} | ${'replace'} | ${'1.0.0'} | ${'v2.0.0'} | ${'2'} + ${'1.2.3'} | ${'replace'} | ${'1.2.3'} | ${'v1.2.4'} | ${'v1.2.4'} + `( + 'getNewValue("$currentValue", "$rangeStrategy", "$currentVersion", "$newVersion") === "$expected"', + ({ + currentValue, + rangeStrategy, + currentVersion, + newVersion, + expected, + }) => { + expect( + githubActions.getNewValue({ + currentValue, + rangeStrategy, + currentVersion, + newVersion, + }), + ).toBe(expected); + }, + ); + + describe('allVersions', () => { + it('does not determine if the proposed newVersion exists, if allVersions is not set', () => { + const res = githubActions.getNewValue({ + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.1.0', + rangeStrategy: 'replace', + }); + // NOTE that this may not actually be valid, depending on whether the Action is using Immutable Tags, which requires full SemVer pinning + expect(res).toEqual('v8'); + }); + + // because this is not a valid case - if there were no versions, we'd not be called + it.each([ + ['v7', 'v8'], + ['v7.6.1', 'v8.1.0'], + ])( + 'does not determine if the proposed newVersion exists, if allVersions is an empty array: %s -> %s', + (currentValue, expected) => { + const res = githubActions.getNewValue({ + currentValue, + currentVersion: 'v7.6.0', + newVersion: 'v8.1.0', + rangeStrategy: 'replace', + allVersions: [], + }); + // NOTE that this may not actually be valid, depending on whether the Action is using Immutable Tags, which requires full SemVer pinning + expect(res).toEqual(expected); + }, + ); + + it.each([ + { + description: 'when a major version exists', + availableVersions: ['v8.0.0'], + expected: 'v8.0.0', + }, + { + description: 'when a minor version exists', + availableVersions: [ + 'v8.0.0', // ignored + 'v8.0', + ], + expected: 'v8.0', + }, + { + description: 'when a patch version exists', + availableVersions: ['v8.0.0'], + expected: 'v8.0.0', + }, + ])('%s', ({ availableVersions, expected }) => { + const res = githubActions.getNewValue({ + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.0.0', + rangeStrategy: 'replace', + allVersions: ['v7.5.0', 'v7.6.0', ...availableVersions], + }); + expect(res).toEqual(expected); + }); + + it('when a release candidate version exists, that exact version is used', () => { + const res = githubActions.getNewValue({ + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.0.0-rc3', + rangeStrategy: 'replace', + allVersions: [ + 'v7.5.0', + 'v7.6.0', + 'v8.0.0-rc1', + 'v8.0.0-rc2', + 'v8.0.0-rc3', + ], + }); + expect(res).toEqual('v8.0.0-rc3'); + }); + + describe('if the newVersion is not found in allVersions', () => { + // because this is not a valid case - if the newVersion wasn't found in allVersions, how would we have been called? + it('newVersion is returned anyway', () => { + const res = githubActions.getNewValue({ + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.5.0', + rangeStrategy: 'replace', + allVersions: ['v7.5.0', 'v7.6.0'], + }); + expect(res).toEqual('v8.5.0'); + }); + + it('debug logs', () => { + githubActions.getNewValue({ + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.5.0', + rangeStrategy: 'replace', + allVersions: ['v7.5.0', 'v7.6.0'], + }); + + expect(logger.logger.once.debug).toHaveBeenCalledWith( + { + versioning: 'github-actions', + currentValue: 'v7', + currentVersion: 'v7.6.0', + newVersion: 'v8.5.0', + rangeStrategy: 'replace', + allVersions: ['v7.5.0', 'v7.6.0'], + }, + 'Suggested newValue `v8.5.0` was not included in allVersions, but it should have been. Returning it anyway', + ); + }); + }); + }); + }); +}); diff --git a/lib/modules/versioning/github-actions/index.ts b/lib/modules/versioning/github-actions/index.ts new file mode 100644 index 00000000000..c62170a5f24 --- /dev/null +++ b/lib/modules/versioning/github-actions/index.ts @@ -0,0 +1,307 @@ +import { isEmptyArray, isUndefined } from '@sindresorhus/is'; +import type { SemVer } from 'semver'; +import semver from 'semver'; +import { logger } from '../../../logger/index.ts'; +import { regEx } from '../../../util/regex.ts'; +import type { NewValueConfig, VersioningApi } from '../types.ts'; + +export const id = 'github-actions'; +export const displayName = 'GitHub Actions'; +export const urls = [ + 'https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/find-and-customize-actions#using-release-management-for-your-custom-actions', +]; +export const supportsRanges = true; +export const supportedRangeStrategies = ['pin', 'replace']; + +function massageValue(input: string): string { + return input.trim().replace(regEx(/^v/i), ''); +} + +function parseVersion(input: string): SemVer | null { + return semver.parse(massageValue(input)); +} + +interface Range { + major: number; + minor?: number; +} + +function parseRange(input: string): Range | null { + const stripped = massageValue(input); + const coerced = semver.coerce(stripped); + if (!coerced) { + return null; + } + const { major, minor } = coerced; + + if (regEx(/^\d+$/).test(stripped)) { + return { major }; + } + + return { major, minor }; +} + +function isValid(input: string): boolean { + return !!parseVersion(input) || !!parseRange(input); +} + +function isVersion(input: string | undefined | null): boolean { + if (!input) { + return false; + } + + return !!parseVersion(input); +} + +function isStable(version: string): boolean { + const v = parseVersion(version); + if (!v) { + return false; + } + + return v.prerelease.length === 0; +} + +function isSingleVersion(input: string): boolean { + return isVersion(input); +} + +function getMajor(version: string): number | null { + return parseVersion(version)?.major ?? null; +} + +function getMinor(version: string): number | null { + return parseVersion(version)?.minor ?? null; +} + +function getPatch(version: string): number | null { + return parseVersion(version)?.patch ?? null; +} + +function sortVersions(x: string, y: string): number { + const a = parseVersion(x); + const b = parseVersion(y); + if (!a || !b) { + return 0; + } + return semver.compare(a, b); +} + +function equals(x: string, y: string): boolean { + const a = parseVersion(x); + const b = parseVersion(y); + if (!a || !b) { + return false; + } + return semver.eq(a, b); +} + +function isGreaterThan(x: string, y: string): boolean { + const a = parseVersion(x); + const b = parseVersion(y); + if (!a || !b) { + return false; + } + return semver.gt(a, b); +} + +function matches(version: string, range: string): boolean { + const v = parseVersion(version); + if (!v) { + return false; + } + + const rv = parseVersion(range); + if (rv) { + return semver.eq(v, rv); + } + + const r = parseRange(range); + if (!r) { + return false; + } + + if (v.prerelease.length > 0) { + return false; + } + + if (v.major !== r.major) { + return false; + } + + if (isUndefined(r.minor)) { + return true; + } + + return v.minor === r.minor; +} + +function getSatisfyingVersion( + versions: string[], + range: string, +): string | null { + const sortedVersions = versions.sort(sortVersions).reverse(); + for (const version of sortedVersions) { + if (matches(version, range)) { + return version; + } + } + return null; +} + +function minSatisfyingVersion( + versions: string[], + range: string, +): string | null { + const sortedVersions = versions.sort(sortVersions); + for (const version of sortedVersions) { + if (matches(version, range)) { + return version; + } + } + return null; +} + +function isLessThanRange(version: string, range: string): boolean { + const v = parseVersion(version); + const r = parseRange(range); + + if (!v || !r) { + return false; + } + + if (v.major !== r.major) { + return v.major < r.major; + } + + if (isUndefined(r.minor)) { + return false; + } + + if (v.minor !== r.minor) { + return v.minor < r.minor; + } + + return false; +} + +function getNewValue({ + currentValue, + currentVersion, + rangeStrategy, + newVersion, + allVersions, +}: NewValueConfig): string | null { + if (rangeStrategy === 'pin') { + return newVersion; + } + + const range = parseRange(currentValue); + if (!range) { + return newVersion; + } + + const newParsed = parseVersion(newVersion); + if (!newParsed) { + return newVersion; + } + + // Check if currentValue is a full version (has patch component) + const currentParsed = parseVersion(currentValue); + if (currentParsed) { + // currentValue is a full version, return full newVersion + return newVersion; + } + + const [prefix] = currentValue.split(massageValue(currentValue)); + + if (isUndefined(allVersions) || isEmptyArray(allVersions)) { + if (isUndefined(range.minor)) { + return `${prefix}${newParsed.major}`; + } + + return `${prefix}${newParsed.major}.${newParsed.minor}`; + } + + const shortest = getShortestMatchingVersion(prefix, newParsed, allVersions); + if (shortest) { + return shortest; + } + + logger.once.debug( + { + versioning: id, + currentValue, + currentVersion, + newVersion, + rangeStrategy, + allVersions, + }, + `Suggested newValue \`${newVersion}\` was not included in allVersions, but it should have been. Returning it anyway`, + ); + + return newVersion; +} + +function getShortestMatchingVersion( + prefix: string, + newParsed: SemVer, + allVersions: string[], +): string | null { + // in shortest-first order + const options = [ + `${prefix}${newParsed.major}`, + `${prefix}${newParsed.major}.${newParsed.minor}`, + `${prefix}${newParsed.major}.${newParsed.minor}.${newParsed.patch}`, + `${prefix}${newParsed.toString()}`, + ]; + + for (const option of options) { + if (allVersions.includes(option)) { + return option; + } + } + + return null; +} + +function isCompatible(version: string): boolean { + return isValid(version); +} + +function isBreaking(version: string, current: string): boolean { + const versionParsed = parseVersion(version); + const currentParsed = parseVersion(current); + + if (!versionParsed || !currentParsed) { + return false; + } + + if (currentParsed.major === 0) { + return versionParsed.major > 0 || versionParsed.minor > currentParsed.minor; + } + + return versionParsed.major > currentParsed.major; +} + +export const api: VersioningApi = { + equals, + getMajor, + getMinor, + getPatch, + isBreaking, + isCompatible, + isGreaterThan, + isLessThanRange, + isSingleVersion, + isStable, + isValid, + isVersion, + matches, + getSatisfyingVersion, + minSatisfyingVersion, + getNewValue, + sortVersions, +}; + +export default api; diff --git a/lib/modules/versioning/github-actions/readme.md b/lib/modules/versioning/github-actions/readme.md new file mode 100644 index 00000000000..350b325220a --- /dev/null +++ b/lib/modules/versioning/github-actions/readme.md @@ -0,0 +1,14 @@ +Versioning specific to GitHub Actions' usage of Semantic Versioning, for mutable and immutable tags. + +The "shortest" tag will be used where possible - if you are previously using `v7`, and there is an upgrade to `v7.5.3`, `v7` will be used. + +### Immutable Tags + +When using [Immutable Releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) for GitHub Tags, it is required to use explicit SemVer `major.minor.patch` versions. + +If you have previously used an unpinned version, such as `@v7`, Renovate will migrate you on the next update, such as: + +```diff +- uses: astral-sh/setup-uv@v7 ++ uses: astral-sh/setup-uv@v8.1.0 +```