diff --git a/README.md b/README.md index 9f36c75..ceac696 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ This action will create a github release and optionally upload an artifact to it. ## Action Inputs +- **allowUpdates**: An optional flag which indicates if we should update a release if it already exists. Defaults to false. - **artifact**: An optional set of paths representing artifacts to upload to the release. This may be a single path or a comma delimited list of paths (or globs). - **artifacts**: An optional set of paths representing artifacts to upload to the release. This may be a single path or a comma delimited list of paths (or globs). - **artifactContentType**: The content type of the artifact. Defaults to raw. diff --git a/__tests__/Action.test.ts b/__tests__/Action.test.ts index 3c3ea6c..a2c663d 100644 --- a/__tests__/Action.test.ts +++ b/__tests__/Action.test.ts @@ -5,6 +5,8 @@ import { Releases } from "../src/Releases"; import { ArtifactUploader } from "../src/ArtifactUploader"; const createMock = jest.fn() +const getMock = jest.fn() +const updateMock = jest.fn() const uploadMock = jest.fn() const artifacts = [ @@ -14,9 +16,8 @@ const artifacts = [ const artifactData = Buffer.from('blob', 'utf-8') const body = 'body' const commit = 'commit' -const contentType = "raw" -const contentLength = 100 const draft = true +const id = 100 const name = 'name' const tag = 'tag' const token = 'token' @@ -25,11 +26,13 @@ const url = 'http://api.example.com' describe("Action", () => { beforeEach(() => { createMock.mockClear() + getMock.mockClear() + updateMock.mockClear() uploadMock.mockClear() }) it('creates release but does not upload if no artifact', async () => { - const action = createAction(false) + const action = createAction(false, false) await action.perform() @@ -38,12 +41,7 @@ describe("Action", () => { }) it('creates release then uploads artifact', async () => { - const action = createAction(true) - createMock.mockResolvedValue({ - data: { - upload_url: url - } - }) + const action = createAction(false, true) await action.perform() @@ -52,7 +50,7 @@ describe("Action", () => { }) it('throws error when create fails', async () => { - const action = createAction(true) + const action = createAction(false, true) createMock.mockRejectedValue("error") expect.hasAssertions() @@ -66,13 +64,56 @@ describe("Action", () => { expect(uploadMock).not.toBeCalled() }) + it('throws error when get fails', async () => { + const action = createAction(true, false) + const error = { + errors: [ + { + code: 'already_exists' + } + ] + } + + createMock.mockRejectedValue(error) + getMock.mockRejectedValue("error") + expect.hasAssertions() + try { + await action.perform() + } catch (error) { + expect(error).toEqual("error") + } + + expect(createMock).toBeCalledWith(tag, body, commit, draft, name) + expect(uploadMock).not.toBeCalled() + + }) + + it('throws error when update fails', async () => { + const action = createAction(true, false) + const error = { + errors: [ + { + code: 'already_exists' + } + ] + } + + createMock.mockRejectedValue(error) + updateMock.mockRejectedValue("error") + expect.hasAssertions() + try { + await action.perform() + } catch (error) { + expect(error).toEqual("error") + } + + expect(createMock).toBeCalledWith(tag, body, commit, draft, name) + expect(uploadMock).not.toBeCalled() + + }) + it('throws error when upload fails', async () => { - const action = createAction(true) - createMock.mockResolvedValue({ - data: { - upload_url: url - } - }) + const action = createAction(false, true) uploadMock.mockRejectedValue("error") expect.hasAssertions() @@ -86,7 +127,45 @@ describe("Action", () => { expect(uploadMock).toBeCalledWith(artifacts, url) }) - function createAction(hasArtifact: boolean): Action { + it('updates release but does not upload if no artifact', async () => { + const action = createAction(true, false) + const error = { + errors: [ + { + code: 'already_exists' + } + ] + } + + createMock.mockRejectedValue(error) + + await action.perform() + + expect(createMock).toBeCalledWith(tag, body, commit, draft, name) + expect(uploadMock).not.toBeCalled() + + }) + + it('updates release then uploads artifact', async () => { + const action = createAction(true, true) + const error = { + errors: [ + { + code: 'already_exists' + } + ] + } + + createMock.mockRejectedValue(error) + + await action.perform() + + expect(createMock).toBeCalledWith(tag, body, commit, draft, name) + expect(uploadMock).toBeCalledWith(artifacts, url) + + }) + + function createAction(allowUpdates: boolean, hasArtifact: boolean): Action { let inputArtifact: Artifact[] if (hasArtifact) { inputArtifact = artifacts @@ -96,11 +175,32 @@ describe("Action", () => { const MockReleases = jest.fn(() => { return { create: createMock, - uploadArtifact: jest.fn() + getByTag: getMock, + update: updateMock, + uploadArtifact: uploadMock } }) + + createMock.mockResolvedValue({ + data: { + upload_url: url + } + }) + getMock.mockResolvedValue({ + data: { + id: id + } + }) + updateMock.mockResolvedValue({ + data: { + upload_url: url + } + }) + uploadMock.mockResolvedValue({}) + const MockInputs = jest.fn(() => { return { + allowUpdates: allowUpdates, artifacts: inputArtifact, body: body, commit: commit, diff --git a/__tests__/ErrorMessage.test.ts b/__tests__/ErrorMessage.test.ts index 3073e0b..65596b4 100644 --- a/__tests__/ErrorMessage.test.ts +++ b/__tests__/ErrorMessage.test.ts @@ -1,6 +1,33 @@ import { ErrorMessage } from "../src/ErrorMessage" describe('ErrorMessage', () => { + + describe('has error with code', () => { + const error = { + message: 'something bad happened', + errors: [ + { + code: 'missing', + resource: 'release' + }, + { + code: 'already_exists', + resource: 'release' + } + ] + } + + it('does not have error', ()=> { + const errorMessage = new ErrorMessage(error) + expect(errorMessage.hasErrorWithCode('missing_field')).toBeFalsy() + }) + + it('has error', ()=> { + const errorMessage = new ErrorMessage(error) + expect(errorMessage.hasErrorWithCode('missing')).toBeTruthy() + }) + }) + it('generates message with errors', () => { const resource = "release" const error = { diff --git a/__tests__/GithubError.test.ts b/__tests__/GithubError.test.ts index 70906dc..657a756 100644 --- a/__tests__/GithubError.test.ts +++ b/__tests__/GithubError.test.ts @@ -1,6 +1,17 @@ import { GithubError } from "../src/GithubError" describe('GithubError', () => { + + it('provides error code', () => { + const error = { + code: "missing" + } + + const githubError = new GithubError(error) + + expect(githubError.code).toBe('missing') + }) + it('generates missing resource error message', () => { const resource = "release" const error = { @@ -66,10 +77,10 @@ describe('GithubError', () => { message: "foo", documentation_url: url } - + const githubError = new GithubError(error) const message = githubError.toString() - + expect(message).toBe(`foo\nPlease see ${url}.`) }) @@ -78,10 +89,10 @@ describe('GithubError', () => { code: "custom", message: "foo" } - + const githubError = new GithubError(error) const message = githubError.toString() - + expect(message).toBe('foo') }) }) diff --git a/__tests__/Inputs.test.ts b/__tests__/Inputs.test.ts index 79819da..a8d49e1 100644 --- a/__tests__/Inputs.test.ts +++ b/__tests__/Inputs.test.ts @@ -43,6 +43,17 @@ describe('Inputs', () => { expect(inputs.token).toBe('42') }) + describe('allowsUpdates', () => { + it('returns false', () => { + expect(inputs.allowUpdates).toBe(false) + }) + + it('returns true', () => { + mockGetInput.mockReturnValue('true') + expect(inputs.allowUpdates).toBe(true) + }) + }) + describe('artifacts', () => { it('returns empty artifacts', () => { mockGetInput.mockReturnValueOnce('') diff --git a/action.yml b/action.yml index c7ea266..34bc480 100644 --- a/action.yml +++ b/action.yml @@ -2,6 +2,9 @@ name: 'Create Release' description: 'Creates github releases' author: 'Nick Cipollo' inputs: + allowUpdates: + description: 'An optional flag which indicates if we should update a release if it already exists. Defaults to false.' + default: '' artifact: description: 'An optional set of paths representing artifacts to upload to the release. This may be a single path or a comma delimited list of paths (or globs)' default: '' diff --git a/src/Action.ts b/src/Action.ts index 7c42da3..4f4d741 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -1,6 +1,8 @@ import { Inputs } from "./Inputs"; import { Releases } from "./Releases"; import { ArtifactUploader } from "./ArtifactUploader"; +import { ErrorMessage } from "./ErrorMessage"; +import { Response, ReposCreateReleaseResponse } from "@octokit/rest"; export class Action { private inputs: Inputs @@ -14,7 +16,28 @@ export class Action { } async perform() { - const createResult = await this.releases.create( + const uploadUrl = await this.createOrUpdateRelease() + + const artifacts = this.inputs.artifacts + if (artifacts.length > 0) { + await this.uploader.uploadArtifacts(artifacts, uploadUrl) + } + } + + private async createOrUpdateRelease(): Promise { + try { + return await this.createRelease() + } catch (error) { + if (this.releaseAlreadyExisted(error) && this.inputs.allowUpdates) { + return this.updateRelease() + } else { + throw error + } + } + } + + private async createRelease(): Promise { + const response = await this.releases.create( this.inputs.tag, this.inputs.body, this.inputs.commit, @@ -22,12 +45,27 @@ export class Action { this.inputs.name ) - const artifacts = this.inputs.artifacts - if (artifacts.length > 0) { - await this.uploader.uploadArtifacts( - artifacts, - createResult.data.upload_url - ) - } + return response.data.upload_url + } + + private releaseAlreadyExisted(error: any): boolean { + const errorMessage = new ErrorMessage(error) + return errorMessage.hasErrorWithCode('already_exists') + } + + private async updateRelease(): Promise { + const getResponse = await this.releases.getByTag(this.inputs.tag) + const id = getResponse.data.id + + const response = await this.releases.update( + id, + this.inputs.tag, + this.inputs.body, + this.inputs.commit, + this.inputs.draft, + this.inputs.name + ) + + return response.data.upload_url } } \ No newline at end of file diff --git a/src/ArtifactUploader.ts b/src/ArtifactUploader.ts index a6f7f7e..2cb234b 100644 --- a/src/ArtifactUploader.ts +++ b/src/ArtifactUploader.ts @@ -2,7 +2,7 @@ import { Artifact } from "./Artifact"; import { Releases } from "./Releases"; export interface ArtifactUploader { - uploadArtifacts(artifacts: Artifact[], uploadUrl: string): void + uploadArtifacts(artifacts: Artifact[], uploadUrl: string): Promise } export class GithubArtifactUploader implements ArtifactUploader { diff --git a/src/ErrorMessage.ts b/src/ErrorMessage.ts index e10f440..63e7046 100644 --- a/src/ErrorMessage.ts +++ b/src/ErrorMessage.ts @@ -1,23 +1,15 @@ import { GithubError } from "./GithubError" export class ErrorMessage { - error: any + private error: any + private githubErrors: GithubError[] constructor(error: any) { this.error = error + this.githubErrors = this.generateGithubErrors() } - toString(): string { - const message = this.error.message - const errors = this.githubErrors() - if (errors.length > 0) { - return `${message}\nErrors:\n${this.errorBulletedList(errors)}` - } else { - return message - } - } - - githubErrors(): GithubError[] { + private generateGithubErrors(): GithubError[] { const errors = this.error.errors if (errors instanceof Array) { return errors.map((err) => new GithubError(err)) @@ -26,7 +18,21 @@ export class ErrorMessage { } } - errorBulletedList(errors: GithubError[]): string { + hasErrorWithCode(code: String): boolean { + return this.githubErrors.some((err) => err.code == code) + } + + toString(): string { + const message = this.error.message + const errors = this.githubErrors + if (errors.length > 0) { + return `${message}\nErrors:\n${this.errorBulletedList(errors)}` + } else { + return message + } + } + + private errorBulletedList(errors: GithubError[]): string { return errors.map((err) => `- ${err}`).join("\n") } } diff --git a/src/GithubError.ts b/src/GithubError.ts index 72fecd0..be2d29b 100644 --- a/src/GithubError.ts +++ b/src/GithubError.ts @@ -1,13 +1,17 @@ export class GithubError { - error: any; + private error: any; constructor(error: any) { this.error = error } + get code(): string { + return this.error.code + } + toString(): string { - const code = this.error.code - switch(code) { + const code = this.error.code + switch (code) { case 'missing': return this.missingResourceMessage() case 'missing_field': @@ -26,7 +30,7 @@ export class GithubError { const documentation = this.error.documentation_url let documentationMessage: string - if(documentation) { + if (documentation) { documentationMessage = `\nPlease see ${documentation}.` } else { documentationMessage = "" @@ -41,7 +45,7 @@ export class GithubError { return `The ${field} field on ${resource} is an invalid format.` } - + private missingResourceMessage(): string { const resource = this.error.resource return `${resource} does not exist.` diff --git a/src/Inputs.ts b/src/Inputs.ts index 6dc0091..21c53d3 100644 --- a/src/Inputs.ts +++ b/src/Inputs.ts @@ -5,6 +5,7 @@ import { ArtifactGlobber } from './ArtifactGlobber'; import { Artifact } from './Artifact'; export interface Inputs { + readonly allowUpdates: boolean readonly artifacts: Artifact[] readonly body: string readonly commit: string @@ -23,6 +24,11 @@ export class CoreInputs implements Inputs { this.context = context } + get allowUpdates(): boolean { + const allow = core.getInput('allowUpdates') + return allow == 'true' + } + get artifacts(): Artifact[] { let artifacts = core.getInput('artifacts') if (!artifacts) { diff --git a/src/Releases.ts b/src/Releases.ts index 4b92cca..2ce99dc 100644 --- a/src/Releases.ts +++ b/src/Releases.ts @@ -1,6 +1,6 @@ import { Context } from "@actions/github/lib/context"; import { GitHub } from "@actions/github"; -import { AnyResponse, Response, ReposCreateReleaseResponse } from "@octokit/rest"; +import { AnyResponse, Response, ReposCreateReleaseResponse, ReposGetReleaseByTagResponse } from "@octokit/rest"; export interface Releases { create( @@ -11,6 +11,17 @@ export interface Releases { name?: string ): Promise> + getByTag(tag: string): Promise> + + update( + id: number, + tag: string, + body?: string, + commitHash?: string, + draft?: boolean, + name?: string + ): Promise> + uploadArtifact( assetUrl: string, contentLength: number, @@ -20,7 +31,7 @@ export interface Releases { ): Promise> } -export class GithubReleases implements Releases{ +export class GithubReleases implements Releases { context: Context git: GitHub @@ -47,6 +58,34 @@ export class GithubReleases implements Releases{ }) } + async getByTag(tag: string): Promise> { + return this.git.repos.getReleaseByTag({ + owner: this.context.repo.owner, + repo: this.context.repo.repo, + tag: tag + }) + } + + async update( + id: number, + tag: string, + body?: string, + commitHash?: string, + draft?: boolean, + name?: string + ): Promise> { + return this.git.repos.updateRelease({ + release_id: id, + body: body, + name: name, + draft: draft, + owner: this.context.repo.owner, + repo: this.context.repo.repo, + target_commitish: commitHash, + tag_name: tag + }) + } + async uploadArtifact( assetUrl: string, contentLength: number,