diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50600c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + build: + name: CI + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Setup nodejs + uses: actions/setup-node@v2 + with: + node-version: '14' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Run tests + run: npm test diff --git a/src/dependabot/verified_commits.test.ts b/src/dependabot/verified_commits.test.ts new file mode 100644 index 0000000..f143706 --- /dev/null +++ b/src/dependabot/verified_commits.test.ts @@ -0,0 +1,160 @@ +import * as github from '@actions/github' +import * as core from '@actions/core' +import nock from 'nock' +import { Context } from '@actions/github/lib/context' +import { getMessage } from './verified_commits' + +beforeAll(() => { + nock.disableNetConnect() +}) + +beforeEach(() => { + jest.restoreAllMocks() + + jest.spyOn(core, 'debug').mockImplementation(jest.fn()) + jest.spyOn(core, 'warning').mockImplementation(jest.fn()) + + process.env.GITHUB_REPOSITORY = 'dependabot/dependabot' +}) + +test('it returns false if the action is not invoked on a PullRequest', async () => { + expect(await getMessage(mockGitHubClient, mockGitHubOtherContext())).toBe(false) + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining('Event payload missing `pull_request` key.') + ) +}) + +test('it returns false for an event triggered by someone other than Dependabot', async () => { + expect(await getMessage(mockGitHubClient, mockGitHubPullContext('jane-doe'))).toBe(false) + + expect(core.debug).toHaveBeenCalledWith( + expect.stringContaining("Event actor 'jane-doe' is not Dependabot.") + ) +}) + +test('it returns false if there is more than 1 commit', async () => { + nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + .reply(200, [ + { + commit: { + message: 'Bump lodash from 1.0.0 to 2.0.0' + } + }, + { + commit: { + message: 'Add some more things.' + } + } + ]) + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("It looks like this PR has contains commits that aren't part of a Dependabot update.") + ) +}) + +test('it returns false if the commit was authored by someone other than Dependabot', async () => { + nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + .reply(200, [ + { + author: { + login: 'dependanot' + }, + commit: { + message: 'Bump lodash from 1.0.0 to 2.0.0' + } + } + ]) + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) + + expect(core.warning).toHaveBeenCalledWith( + expect.stringContaining("It looks like this PR has contains commits that aren't part of a Dependabot update.") + ) +}) + +test('it returns false if the commit is has no verification payload', async () => { + nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + .reply(200, [ + { + author: { + login: 'dependabot[bot]' + }, + commit: { + message: 'Bump lodash from 1.0.0 to 2.0.0', + verification: null + } + } + ]) + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) +}) + +test('it returns false if the commit is not verified', async () => { + nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + .reply(200, [ + { + author: { + login: 'dependabot[bot]' + }, + commit: { + message: 'Bump lodash from 1.0.0 to 2.0.0', + verification: { + verified: false + } + } + } + ]) + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toBe(false) +}) + +test('it returns the commit message for a PR authored exclusively by Dependabot with verified commits', async () => { + nock('https://api.github.com').get('/repos/dependabot/dependabot/pulls/101/commits') + .reply(200, [ + { + author: { + login: 'dependabot[bot]' + }, + commit: { + message: 'Bump lodash from 1.0.0 to 2.0.0', + verification: { + verified: true + } + } + } + ]) + + expect(await getMessage(mockGitHubClient, mockGitHubPullContext())).toEqual('Bump lodash from 1.0.0 to 2.0.0') +}) + +const mockGitHubClient = github.getOctokit('mock-token') + +function mockGitHubOtherContext (): Context { + const ctx = new Context() + ctx.payload = { + issue: { + number: 100 + } + } + return ctx +} + +function mockGitHubPullContext (actor = 'dependabot[bot]'): Context { + const ctx = new Context() + ctx.payload = { + pull_request: { + number: 101 + }, + repository: { + name: 'dependabot', + owner: { + login: 'dependabot' + } + } + } + ctx.actor = actor + return ctx +} diff --git a/src/dependabot/verified_commits.ts b/src/dependabot/verified_commits.ts new file mode 100644 index 0000000..976a0cd --- /dev/null +++ b/src/dependabot/verified_commits.ts @@ -0,0 +1,63 @@ +import * as core from '@actions/core' +import { GitHub } from '@actions/github/lib/utils' +import { Context } from '@actions/github/lib/context' + +const DEPENDABOT_LOGIN = 'dependabot[bot]' + +export async function getMessage (client: InstanceType, context: Context): Promise { + core.debug('Verifying the job is for an authentic Dependabot Pull Request') + + const { pull_request: pr } = context.payload + + if (!pr) { + core.warning( + "Event payload missing `pull_request` key. Make sure you're " + + 'triggering this action on the `pull_request` or `pull_request_target` events.' + ) + return false + } + + // Don't bother hitting the API if the event actor isn't Dependabot + if (context.actor !== DEPENDABOT_LOGIN) { + core.debug(`Event actor '${context.actor}' is not Dependabot.`) + return false + } + + core.debug('Verifying the Pull Request contents are from Dependabot') + + const { data: commits } = await client.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }) + + if (commits.length > 1) { + warnOtherCommits() + return false + } + + const { commit, author } = commits[0] + + if (author?.login !== DEPENDABOT_LOGIN) { + warnOtherCommits() + return false + } + + if (!commit.verification?.verified) { + // TODO: Promote to setFailed + core.warning( + "Dependabot's commit signature is not verified, refusing to proceed." + ) + return false + } + + return commit.message +} + +function warnOtherCommits (): void { + core.warning( + "It looks like this PR has contains commits that aren't part of a Dependabot update. " + + "Try using '@dependabot rebase' to remove merge commits or '@dependabot recreate' to remove " + + 'any non-Dependabot changes.' + ) +}