Fixes #6 Add support for updating releases

This commit is contained in:
Nick Cipollo
2019-12-12 17:09:21 -05:00
parent de74298134
commit 7319d490c8
12 changed files with 297 additions and 51 deletions

View File

@@ -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.

View File

@@ -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<Releases, any>(() => {
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<Inputs, any>(() => {
return {
allowUpdates: allowUpdates,
artifacts: inputArtifact,
body: body,
commit: commit,

View File

@@ -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 = {

View File

@@ -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')
})
})

View File

@@ -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('')

View File

@@ -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: ''

View File

@@ -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<string> {
try {
return await this.createRelease()
} catch (error) {
if (this.releaseAlreadyExisted(error) && this.inputs.allowUpdates) {
return this.updateRelease()
} else {
throw error
}
}
}
private async createRelease(): Promise<string> {
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<string> {
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
}
}

View File

@@ -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<void>
}
export class GithubArtifactUploader implements ArtifactUploader {

View File

@@ -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")
}
}

View File

@@ -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.`

View File

@@ -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) {

View File

@@ -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<Response<ReposCreateReleaseResponse>>
getByTag(tag: string): Promise<Response<ReposGetReleaseByTagResponse>>
update(
id: number,
tag: string,
body?: string,
commitHash?: string,
draft?: boolean,
name?: string
): Promise<Response<ReposCreateReleaseResponse>>
uploadArtifact(
assetUrl: string,
contentLength: number,
@@ -20,7 +31,7 @@ export interface Releases {
): Promise<Response<AnyResponse>>
}
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<Response<ReposGetReleaseByTagResponse>> {
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<Response<ReposCreateReleaseResponse>> {
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,