diff --git a/action.yml b/action.yml index 7b8bb1da8..ca6219456 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,10 @@ inputs: custom_tag: description: "Custom tag name. If specified, it overrides bump settings." required: false + force_update: + description: "Updates the sha of a tag if it already exists" + required: false + default: "false" custom_release_rules: description: "Comma separated list of release rules. Format: `:`. Example: `hotfix:patch,pre-feat:preminor`." required: false diff --git a/src/action.ts b/src/action.ts index 2a073169e..83e3a8d35 100644 --- a/src/action.ts +++ b/src/action.ts @@ -12,7 +12,7 @@ import { mapCustomReleaseRules, mergeWithDefaultChangelogRules, } from './utils'; -import { createTag } from './github'; +import { createTag, listTags } from './github'; import { Await } from './ts'; export default async function main() { @@ -22,6 +22,7 @@ export default async function main() { | 'false'; const tagPrefix = core.getInput('tag_prefix'); const customTag = core.getInput('custom_tag'); + const forceUpdate = /true/i.test(core.getInput('force_update')); const releaseBranches = core.getInput('release_branches'); const preReleaseBranches = core.getInput('pre_release_branches'); const appendToPreReleaseTag = core.getInput('append_to_pre_release_tag'); @@ -69,10 +70,8 @@ export default async function main() { const prefixRegex = new RegExp(`^${tagPrefix}`); - const validTags = await getValidTags( - prefixRegex, - /true/i.test(shouldFetchAllTags) - ); + const tags = await listTags(/true/i.test(shouldFetchAllTags)); + const validTags = await getValidTags(tags, prefixRegex); const latestTag = getLatestTag(validTags, prefixRegex, tagPrefix); const latestPrereleaseTag = getLatestPrereleaseTag( validTags, @@ -218,7 +217,8 @@ export default async function main() { return; } - if (validTags.map((tag) => tag.name).includes(newTag)) { + const tagExists = tags.map((tag) => tag.name).includes(newTag); + if (tagExists && !forceUpdate) { core.info('This tag already exists. Skipping the tag creation.'); return; } @@ -228,5 +228,5 @@ export default async function main() { return; } - await createTag(newTag, createAnnotatedTag, commitRef); + await createTag(newTag, createAnnotatedTag, tagExists, commitRef); } diff --git a/src/github.ts b/src/github.ts index 908a6996e..77cc79c8f 100644 --- a/src/github.ts +++ b/src/github.ts @@ -4,7 +4,7 @@ import { Await } from './ts'; let octokitSingleton: ReturnType; -type Tag = { +export type Tag = { name: string; commit: { sha: string; @@ -15,6 +15,8 @@ type Tag = { node_id: string; }; +export type Tags = Await>; + export function getOctokitSingleton() { if (octokitSingleton) { return octokitSingleton; @@ -68,6 +70,7 @@ export async function compareCommits(baseRef: string, headRef: string) { export async function createTag( newTag: string, createAnnotatedTag: boolean, + update: boolean, GITHUB_SHA: string ) { const octokit = getOctokitSingleton(); @@ -85,10 +88,20 @@ export async function createTag( }); } - core.debug(`Pushing new tag to the repo.`); - await octokit.git.createRef({ - ...context.repo, - ref: `refs/tags/${newTag}`, - sha: annotatedTag ? annotatedTag.data.sha : GITHUB_SHA, - }); + if (update) { + core.info(`Updating existing tag ${newTag} on the repo.`); + await octokit.git.updateRef({ + ...context.repo, + ref: `tags/${newTag}`, + sha: annotatedTag ? annotatedTag.data.sha : GITHUB_SHA, + force: true, + }); + } else { + core.info(`Pushing new tag to the repo.`); + await octokit.git.createRef({ + ...context.repo, + ref: `refs/tags/${newTag}`, + sha: annotatedTag ? annotatedTag.data.sha : GITHUB_SHA, + }); + } } diff --git a/src/utils.ts b/src/utils.ts index 8952c345e..55934c715 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,24 +2,16 @@ import * as core from '@actions/core'; import { prerelease, rcompare, valid } from 'semver'; // @ts-ignore import DEFAULT_RELEASE_TYPES from '@semantic-release/commit-analyzer/lib/default-release-types'; -import { compareCommits, listTags } from './github'; +import { compareCommits, Tags } from './github'; import { defaultChangelogRules } from './defaults'; -import { Await } from './ts'; - -type Tags = Await>; - -export async function getValidTags( - prefixRegex: RegExp, - shouldFetchAllTags: boolean -) { - const tags = await listTags(shouldFetchAllTags); +export async function getValidTags(tags: Tags, prefixRegex: RegExp) { const invalidTags = tags.filter( (tag) => !prefixRegex.test(tag.name) || !valid(tag.name.replace(prefixRegex, '')) ); - invalidTags.forEach((name) => core.debug(`Found Invalid Tag: ${name}.`)); + invalidTags.forEach((tag) => core.debug(`Found Invalid Tag: ${tag.name}.`)); const validTags = tags .filter( diff --git a/tests/action.test.ts b/tests/action.test.ts index 413e7bfae..0ae2e057a 100644 --- a/tests/action.test.ts +++ b/tests/action.test.ts @@ -3,6 +3,7 @@ import * as utils from '../src/utils'; import * as github from '../src/github'; import * as core from '@actions/core'; import { + clearInputs, loadDefaultInputs, setBranch, setCommitSha, @@ -33,6 +34,7 @@ describe('github-tag-action', () => { jest.clearAllMocks(); setBranch('master'); setCommitSha('79e0ea271c26aa152beef77c3275ff7b8f8d8274'); + clearInputs(); loadDefaultInputs(); }); @@ -47,9 +49,7 @@ describe('github-tag-action', () => { .mockImplementation(async (sha) => commits); const validTags: any[] = []; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -62,6 +62,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v0.0.1', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -77,9 +78,7 @@ describe('github-tag-action', () => { .mockImplementation(async (sha) => commits); const validTags: any[] = []; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -92,6 +91,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v0.0.1', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -116,9 +116,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -154,9 +152,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -169,6 +165,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v2.0.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -196,9 +193,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -211,6 +206,53 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.3.0', expect.any(Boolean), + false, + expect.any(String) + ); + expect(mockSetFailed).not.toBeCalled(); + }); + + it('does update existing tag when force enabled', async () => { + /* + * Given + */ + setInput('force_update', 'true'); + setInput('custom_tag', 'latest'); + setInput('tag_prefix', ''); + const commits = [ + { + message: 'feat: some new feature on a pre-release branch', + hash: null, + }, + { message: 'james: this should make a preminor', hash: null }, + ]; + jest + .spyOn(utils, 'getCommits') + .mockImplementation(async (sha) => commits); + + const validTags = [ + { + name: 'latest', + commit: { sha: '012345', url: '' }, + zipball_url: '', + tarball_url: 'string', + node_id: 'string', + }, + ]; + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); + + /* + * When + */ + await action(); + + /* + * Then + */ + expect(mockCreateTag).toHaveBeenCalledWith( + 'latest', + expect.any(Boolean), + true, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -242,9 +284,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -257,6 +297,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.2.4', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -282,9 +323,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -297,6 +336,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.3.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -326,9 +366,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -341,6 +379,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v2.0.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -380,9 +419,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -395,6 +432,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v2.2.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -425,9 +463,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -440,6 +476,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.3.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -522,6 +559,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.2.4-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -560,6 +598,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.2.4-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -600,6 +639,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.3.0-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -644,6 +684,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v2.0.0-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -701,6 +742,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v2.2.0-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -746,6 +788,7 @@ describe('github-tag-action', () => { expect(mockCreateTag).toHaveBeenCalledWith( 'v1.3.0-prerelease.0', expect.any(Boolean), + false, expect.any(String) ); expect(mockSetFailed).not.toBeCalled(); @@ -778,9 +821,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -815,9 +856,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; - jest - .spyOn(utils, 'getValidTags') - .mockImplementation(async () => validTags); + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); /* * When @@ -856,6 +895,7 @@ describe('github-tag-action', () => { node_id: 'string', }, ]; + jest.spyOn(github, 'listTags').mockImplementation(async () => validTags); jest .spyOn(utils, 'getValidTags') .mockImplementation(async () => validTags); diff --git a/tests/helper.test.ts b/tests/helper.test.ts index d56b50be5..3871cb2c8 100644 --- a/tests/helper.test.ts +++ b/tests/helper.test.ts @@ -26,6 +26,12 @@ export function setInputs(map: { [key: string]: string }) { Object.keys(map).forEach((key) => setInput(key, map[key])); } +export function clearInputs() { + Object.keys(process.env) + .filter((key) => key.startsWith('INPUT_')) + .forEach((key) => delete process.env[key]); +} + export function loadDefaultInputs() { const actionYaml = fs.readFileSync( path.join(process.cwd(), 'action.yml'), diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a305eb5a6..6f153479f 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -64,19 +64,15 @@ describe('utils', () => { node_id: 'string', }, ]; - const mockListTags = jest - .spyOn(github, 'listTags') - .mockImplementation(async () => testTags); /* * When */ - const validTags = await getValidTags(regex, false); + const validTags = await getValidTags(testTags, regex); /* * Then */ - expect(mockListTags).toHaveBeenCalled(); expect(validTags).toHaveLength(1); }); @@ -114,19 +110,15 @@ describe('utils', () => { node_id: 'string', }, ]; - const mockListTags = jest - .spyOn(github, 'listTags') - .mockImplementation(async () => testTags); /* * When */ - const validTags = await getValidTags(regex, false); + const validTags = await getValidTags(testTags, regex); /* * Then */ - expect(mockListTags).toHaveBeenCalled(); expect(validTags[0]).toEqual({ name: 'v1.2.4-prerelease.2', commit: { sha: 'string', url: 'string' }, @@ -163,17 +155,13 @@ describe('utils', () => { node_id: 'string', }, ]; - const mockListTags = jest - .spyOn(github, 'listTags') - .mockImplementation(async () => testTags); /* * When */ - const validTags = await getValidTags(/^app1\//, false); + const validTags = await getValidTags(testTags, /^app1\//); /* * Then */ - expect(mockListTags).toHaveBeenCalled(); expect(validTags).toHaveLength(1); expect(validTags[0]).toEqual({ name: 'app1/3.0.0',