diff --git a/__fixtures__/events/default.json b/__fixtures__/events/default.json new file mode 100644 index 0000000..f6be12a --- /dev/null +++ b/__fixtures__/events/default.json @@ -0,0 +1,21 @@ +{ + "inputs": { + "jobID": "1", + "jobToken": "xxx", + "credentialsToken": "yyy", + "dependabotAPIURL": "http://localhost:9000" + }, + "ref": "main", + "repository": { + "owner": { + "login": "dependabot" + }, + "name": "dependabot-core" + }, + "sender": { + "type": "User" + }, + "installation": null, + "organization": null, + "workflow": "--workflow-yaml-goes-here--" +} diff --git a/__tests__/helpers.ts b/__tests__/helpers.ts index 6cbc29e..2c9ab7b 100644 --- a/__tests__/helpers.ts +++ b/__tests__/helpers.ts @@ -25,7 +25,7 @@ export const removeDanglingUpdaterContainers = async (): Promise => { await docker.pruneContainers() } -export const runFakeDependabotApi = async (port: number): Promise => { +export const runFakeDependabotApi = async (port = 9000): Promise => { const server = spawn('node', [ `${path.join(__dirname, 'server/server.js')}`, `${port}` @@ -44,3 +44,13 @@ export const runFakeDependabotApi = async (port: number): Promise => { server.kill() } } + +export const eventFixturePath = (fixtureName: string): string => { + return path.join( + __dirname, + '..', + '__fixtures__', + 'events', + `${fixtureName}.json` + ) +} diff --git a/__tests__/main.test.ts b/__tests__/main.test.ts index 3c1f2fe..8e467da 100644 --- a/__tests__/main.test.ts +++ b/__tests__/main.test.ts @@ -1,2 +1,249 @@ -// shows how the runner will run a javascript action with env / stdout protocol -test('test runs', () => {}) +import * as core from '@actions/core' +import {Context} from '@actions/github/lib/context' +import {APIClient} from '../src/api-client' +import {Updater} from '../src/updater' +import {ImageService} from '../src/image-service' +import * as inputs from '../src/inputs' +import {run} from '../src/main' + +import {eventFixturePath} from './helpers' + +// We do not need to build actual containers or run updates for this test. +jest.mock('../src/api-client') +jest.mock('../src/image-service') +jest.mock('../src/updater') + +describe('run', () => { + let context: Context + + let markJobAsProcessedSpy: any + let reportJobErrorSpy: any + + beforeEach(async () => { + markJobAsProcessedSpy = jest.spyOn( + APIClient.prototype, + 'markJobAsProcessed' + ) + reportJobErrorSpy = jest.spyOn(APIClient.prototype, 'reportJobError') + + jest.spyOn(core, 'info').mockImplementation(jest.fn()) + jest.spyOn(core, 'setFailed').mockImplementation(jest.fn()) + }) + + afterEach(async () => { + jest.clearAllMocks() // Reset any mocked classes + }) + + describe('when the run follows the happy path', () => { + beforeAll(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it signs off at completion without any errors', async () => { + await run(context) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith( + expect.stringContaining('🤖 ~fin~') + ) + }) + + test('it defers reporting back to dependabot-api to the updater itself', async () => { + await run(context) + + expect(markJobAsProcessedSpy).not.toHaveBeenCalled() + expect(reportJobErrorSpy).not.toHaveBeenCalled() + }) + }) + + describe('when the action is triggered on an unsupported event', () => { + beforeAll(() => { + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'issue_created' + context = new Context() + }) + + test('it fails the workflow', async () => { + await run(context) + + expect(core.setFailed).not.toHaveBeenCalled() + expect(core.info).toHaveBeenCalledWith( + "Dependabot Updater Action does not support 'issue_created' events." + ) + }) + + test('it does not report this failed run to dependabot-api', async () => { + await run(context) + + expect(markJobAsProcessedSpy).not.toHaveBeenCalled() + expect(reportJobErrorSpy).not.toHaveBeenCalled() + }) + }) + + describe('when there is an error retrieving job parameters', () => { + beforeEach(() => { + jest.spyOn(inputs, 'getJobParameters').mockImplementationOnce( + jest.fn(() => { + throw new Error('unexpected error retrieving job params') + }) + ) + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it fails the workflow with the raw error', async () => { + await run(context) + + expect(core.setFailed).toHaveBeenCalledWith( + new Error('unexpected error retrieving job params') + ) + }) + + test('it does not inform dependabot-api as it cannot instantiate a client without the params', async () => { + await run(context) + + expect(markJobAsProcessedSpy).not.toHaveBeenCalled() + expect(reportJobErrorSpy).not.toHaveBeenCalled() + }) + }) + + describe('when there is an error retrieving job details from DependabotAPI', () => { + beforeEach(() => { + jest + .spyOn(APIClient.prototype, 'getJobDetails') + .mockImplementationOnce( + jest.fn(async () => + Promise.reject(new Error('error getting job details')) + ) + ) + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it fails the workflow', async () => { + await run(context) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('error getting job details') + ) + }) + + test('it relays a failure message to the dependabot service', async () => { + await run(context) + + expect(reportJobErrorSpy).toHaveBeenCalledWith({ + 'error-type': 'actions_workflow_unknown', + 'error-detail': 'error getting job details' + }) + expect(markJobAsProcessedSpy).toHaveBeenCalled() + }) + }) + + describe('when there is an error retrieving job credentials from DependabotAPI', () => { + beforeEach(() => { + jest + .spyOn(APIClient.prototype, 'getCredentials') + .mockImplementationOnce( + jest.fn(async () => + Promise.reject(new Error('error getting credentials')) + ) + ) + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it fails the workflow', async () => { + await run(context) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('error getting credentials') + ) + }) + + test('it relays a failure message to the dependabot service', async () => { + await run(context) + + expect(reportJobErrorSpy).toHaveBeenCalledWith({ + 'error-type': 'actions_workflow_unknown', + 'error-detail': 'error getting credentials' + }) + expect(markJobAsProcessedSpy).toHaveBeenCalled() + }) + }) + + describe('when there is an error pulling images', () => { + beforeEach(() => { + jest + .spyOn(ImageService, 'pull') + .mockImplementationOnce( + jest.fn(async () => + Promise.reject(new Error('error pulling an image')) + ) + ) + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it fails the workflow', async () => { + await run(context) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('error pulling an image') + ) + }) + + test('it relays a failure message to the dependabot service', async () => { + await run(context) + + expect(reportJobErrorSpy).toHaveBeenCalledWith({ + 'error-type': 'actions_workflow_image', + 'error-detail': 'error pulling an image' + }) + expect(markJobAsProcessedSpy).toHaveBeenCalled() + }) + }) + + describe('when there is an error running the update', () => { + beforeEach(() => { + jest + .spyOn(Updater.prototype, 'runUpdater') + .mockImplementationOnce( + jest.fn(async () => + Promise.reject(new Error('error running the update')) + ) + ) + + process.env.GITHUB_EVENT_PATH = eventFixturePath('default') + process.env.GITHUB_EVENT_NAME = 'workflow_dispatch' + context = new Context() + }) + + test('it fails the workflow', async () => { + await run(context) + + expect(core.setFailed).toHaveBeenCalledWith( + expect.stringContaining('error running the update') + ) + }) + + test('it relays a failure message to the dependabot service', async () => { + await run(context) + + expect(reportJobErrorSpy).toHaveBeenCalledWith({ + 'error-type': 'actions_workflow_updater', + 'error-detail': 'error running the update' + }) + expect(markJobAsProcessedSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/src/api-client.ts b/src/api-client.ts index f09c900..1dac7bc 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -10,6 +10,8 @@ export class JobParameters { ) {} } +// TODO: Populate with enabled values +// TODO: Rescue unsupported values export enum PackageManager { NpmAndYarn = 'npm_and_yarn' } @@ -23,6 +25,11 @@ export type JobDetails = { 'package-manager': PackageManager } +export type JobError = { + 'error-type': string + 'error-detail': any +} + export type Credential = { type: string host: string @@ -50,8 +57,8 @@ export class APIClient { } async getCredentials(): Promise { - const detailsURL = `/update_jobs/${this.params.jobID}/credentials` - const res = await this.client.get(detailsURL, { + const credentialsURL = `/update_jobs/${this.params.jobID}/credentials` + const res = await this.client.get(credentialsURL, { headers: {Authorization: this.params.credentialsToken} }) if (res.status !== 200) { @@ -60,4 +67,28 @@ export class APIClient { return res.data.data.attributes.credentials } + + async reportJobError(error: JobError): Promise { + const recordErrorURL = `/update_jobs/${this.params.jobID}/record_update_job_error` + const res = await this.client.post(recordErrorURL, error, { + headers: {Authorization: this.params.jobToken} + }) + if (res.status !== 200) { + throw new Error(`Unexpected status code: ${res.status}`) + } + + return res.data.data.attributes + } + + async markJobAsProcessed(): Promise { + const markAsProcessedURL = `/update_jobs/${this.params.jobID}/mark_as_processed` + const res = await this.client.get(markAsProcessedURL, { + headers: {Authorization: this.params.credentialsToken} + }) + if (res.status !== 200) { + throw new Error(`Unexpected status code: ${res.status}`) + } + + return res.data.data.attributes + } } diff --git a/src/main.ts b/src/main.ts index f100120..764531d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import * as core from '@actions/core' import * as github from '@actions/github' +import {Context} from '@actions/github/lib/context' import {getJobParameters} from './inputs' import {ImageService} from './image-service' import {Updater} from './updater' @@ -11,37 +12,79 @@ export const UPDATER_IMAGE_NAME = export const PROXY_IMAGE_NAME = 'docker.pkg.github.com/github/dependabot-update-job-proxy:latest' -async function run(): Promise { +export enum DependabotErrorType { + Unknown = 'actions_workflow_unknown', + Image = 'actions_workflow_image', + UpdateRun = 'actions_workflow_updater' +} + +export async function run(context: Context): Promise { try { // Decode JobParameters: - const params = getJobParameters(github.context) + const params = getJobParameters(context) if (params === null) { return // No parameters, nothing to do } - core.info(JSON.stringify(params)) + core.debug(JSON.stringify(params)) core.setSecret(params.jobToken) core.setSecret(params.credentialsToken) const client = axios.create({baseURL: params.dependabotAPIURL}) const apiClient = new APIClient(client, params) - const details = await apiClient.getJobDetails() - const credentials = await apiClient.getCredentials() - const updater = new Updater( - UPDATER_IMAGE_NAME, - PROXY_IMAGE_NAME, - apiClient, - details, - credentials - ) - await ImageService.pull(UPDATER_IMAGE_NAME) - await ImageService.pull(PROXY_IMAGE_NAME) - - await updater.runUpdater() + + try { + const details = await apiClient.getJobDetails() + const credentials = await apiClient.getCredentials() + const updater = new Updater( + UPDATER_IMAGE_NAME, + PROXY_IMAGE_NAME, + apiClient, + details, + credentials + ) + + try { + await ImageService.pull(UPDATER_IMAGE_NAME) + await ImageService.pull(PROXY_IMAGE_NAME) + } catch (error) { + await failJob(apiClient, error, DependabotErrorType.Image) + return + } + + try { + await updater.runUpdater() + } catch (error) { + await failJob(apiClient, error, DependabotErrorType.UpdateRun) + return + } + core.info('🤖 ~fin~') + } catch (error) { + // Update Dependabot API on the job failure + await failJob(apiClient, error) + } } catch (error) { - core.setFailed(error.message) + // If we've reached this point, we do not have a viable + // API client to report back to Dependabot API. + // + // We output the raw error in the Action logs and defer + // to workflow_run monitoring to detect the job failure. + core.setFailed(error) } } -run() +async function failJob( + apiClient: APIClient, + error: Error, + errorType = DependabotErrorType.Unknown +): Promise { + await apiClient.reportJobError({ + 'error-type': errorType, + 'error-detail': error.message + }) + await apiClient.markJobAsProcessed() + core.setFailed(error.message) +} + +run(github.context)