ci(aio): do not deploy PR if preconditions not met

This avoids incorrectly failing the build if the PR author is not a member of one of the whitelisted GitHub teams.
This commit is contained in:
Georgios Kalpakas 2017-03-07 11:39:37 +02:00 committed by Chuck Jazdzewski
parent 4ca772eea3
commit 174d4c8ef7
6 changed files with 176 additions and 53 deletions

View File

@ -1,6 +1,6 @@
// Imports // Imports
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
import {GithubPullRequests, PullRequest} from '../common/github-pull-requests'; import {GithubPullRequests} from '../common/github-pull-requests';
import {GithubTeams} from '../common/github-teams'; import {GithubTeams} from '../common/github-teams';
import {assertNotMissingOrEmpty} from '../common/utils'; import {assertNotMissingOrEmpty} from '../common/utils';
import {UploadError} from './upload-error'; import {UploadError} from './upload-error';
@ -31,13 +31,20 @@ export class BuildVerifier {
} }
// Methods - Public // Methods - Public
public verify(pr: number, authHeader: string): Promise<void> { public getPrAuthorTeamMembership(pr: number): Promise<{author: string, isMember: boolean}> {
return Promise.resolve().
then(() => this.githubPullRequests.fetch(pr)).
then(prInfo => prInfo.user.login).
then(author => this.githubTeams.isMemberBySlug(author, this.allowedTeamSlugs).
then(isMember => ({author, isMember})));
}
public verify(expectedPr: number, authHeader: string): Promise<void> {
return Promise.resolve(). return Promise.resolve().
then(() => this.extractJwtString(authHeader)). then(() => this.extractJwtString(authHeader)).
then(jwtString => this.verifyJwt(pr, jwtString)). then(jwtString => this.verifyJwt(expectedPr, jwtString)).
then(jwtPayload => this.fetchPr(jwtPayload['pull-request'])). then(jwtPayload => this.verifyPr(jwtPayload['pull-request'])).
then(prInfo => this.verifyPr(prInfo.user.login)). catch(err => { throw new UploadError(403, `Error while verifying upload for PR ${expectedPr}: ${err}`); });
catch(err => { throw new UploadError(403, `Error while verifying upload for PR ${pr}: ${err}`); });
} }
// Methods - Protected // Methods - Protected
@ -45,19 +52,15 @@ export class BuildVerifier {
return input.replace(/^token +/i, ''); return input.replace(/^token +/i, '');
} }
protected fetchPr(pr: number): Promise<PullRequest> { protected verifyJwt(expectedPr: number, token: string): Promise<JwtPayload> {
return this.githubPullRequests.fetch(pr);
}
protected verifyJwt(pr: number, token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
jwt.verify(token, this.secret, {issuer: 'Travis CI, GmbH'}, (err, payload) => { jwt.verify(token, this.secret, {issuer: 'Travis CI, GmbH'}, (err, payload) => {
if (err) { if (err) {
reject(err.message || err); reject(err.message || err);
} else if (payload.slug !== this.repoSlug) { } else if (payload.slug !== this.repoSlug) {
reject(`jwt slug invalid. expected: ${this.repoSlug}`); reject(`jwt slug invalid. expected: ${this.repoSlug}`);
} else if (payload['pull-request'] !== pr) { } else if (payload['pull-request'] !== expectedPr) {
reject(`jwt pull-request invalid. expected: ${pr}`); reject(`jwt pull-request invalid. expected: ${expectedPr}`);
} else { } else {
resolve(payload); resolve(payload);
} }
@ -65,11 +68,11 @@ export class BuildVerifier {
}); });
} }
protected verifyPr(username: string): Promise<void> { protected verifyPr(pr: number): Promise<void> {
const errorMessage = `User '${username}' is not an active member of any of: ` + return this.getPrAuthorTeamMembership(pr).
`${this.allowedTeamSlugs.join(', ')}`; then(({author, isMember}) => isMember ? Promise.resolve() : Promise.reject(
`User '${author}' is not an active member of any of the following teams: ` +
return this.githubTeams.isMemberBySlug(username, this.allowedTeamSlugs). `${this.allowedTeamSlugs.join(', ')}`,
then(isMember => isMember ? Promise.resolve() : Promise.reject(errorMessage)); ));
} }
} }

View File

@ -0,0 +1,39 @@
// Imports
import {getEnvVar} from '../common/utils';
import {BuildVerifier} from './build-verifier';
// Run
_main();
// Functions
function _main() {
const secret = 'unused';
const githubToken = getEnvVar('AIO_GITHUB_TOKEN');
const repoSlug = getEnvVar('AIO_REPO_SLUG');
const organization = getEnvVar('AIO_GITHUB_ORGANIZATION');
const allowedTeamSlugs = getEnvVar('AIO_GITHUB_TEAM_SLUGS').split(',');
const pr = +getEnvVar('AIO_PREVERIFY_PR');
const buildVerifier = new BuildVerifier(secret, githubToken, repoSlug, organization, allowedTeamSlugs);
// Exit codes:
// - 0: The PR author is a member.
// - 1: The PR author is not a member.
// - 2: An error occurred.
buildVerifier.getPrAuthorTeamMembership(pr).
then(({author, isMember}) => {
if (isMember) {
process.exit(0);
} else {
const errorMessage = `User '${author}' is not an active member of any of the following teams: ` +
`${allowedTeamSlugs.join(', ')}`;
onError(errorMessage, 1);
}
}).
catch(err => onError(err, 2));
}
function onError(err: string, exitCode: number) {
console.error(err);
process.exit(exitCode || 1);
}

View File

@ -53,19 +53,15 @@ describe('BuildVerifier', () => {
'pull-request': pr, 'pull-request': pr,
'slug': defaultConfig.repoSlug, 'slug': defaultConfig.repoSlug,
}; };
let prsFetchSpy: jasmine.Spy; let bvGetPrAuthorTeamMembership: jasmine.Spy;
let teamsIsMemberBySlugSpy: jasmine.Spy;
// Heleprs // Heleprs
const createAuthHeader = (partialJwt: Partial<typeof defaultJwt> = {}, secret: string = defaultConfig.secret) => const createAuthHeader = (partialJwt: Partial<typeof defaultJwt> = {}, secret: string = defaultConfig.secret) =>
`Token ${jwt.sign({...defaultJwt, ...partialJwt}, secret)}`; `Token ${jwt.sign({...defaultJwt, ...partialJwt}, secret)}`;
beforeEach(() => { beforeEach(() => {
prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch'). bvGetPrAuthorTeamMembership = spyOn(bv, 'getPrAuthorTeamMembership').
and.returnValue(Promise.resolve({user: {login: 'username'}})); and.returnValue(Promise.resolve({author: 'some-author', isMember: true}));
teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug').
and.returnValue(Promise.resolve(true));
}); });
@ -148,16 +144,16 @@ describe('BuildVerifier', () => {
}); });
it('should fetch the corresponding PR if the token is valid', done => { it('should call \'getPrAuthorTeamMembership()\' if the token is valid', done => {
bv.verify(pr, createAuthHeader()).then(() => { bv.verify(pr, createAuthHeader()).then(() => {
expect(prsFetchSpy).toHaveBeenCalledWith(pr); expect(bvGetPrAuthorTeamMembership).toHaveBeenCalledWith(pr);
done(); done();
}); });
}); });
it('should fail if fetching the PR errors', done => { it('should fail if \'getPrAuthorTeamMembership()\' rejects', done => {
prsFetchSpy.and.callFake(() => Promise.reject('Test')); bvGetPrAuthorTeamMembership.and.callFake(() => Promise.reject('Test'));
bv.verify(pr, createAuthHeader()).catch(err => { bv.verify(pr, createAuthHeader()).catch(err => {
expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`); expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`);
done(); done();
@ -165,29 +161,12 @@ describe('BuildVerifier', () => {
}); });
it('should verify the PR author\'s membership in the specified teams', done => { it('should fail if \'getPrAuthorTeamMembership()\' reports no membership', done => {
bv.verify(pr, createAuthHeader()).then(() => { const errorMessage = `Error while verifying upload for PR ${pr}: User 'test' is not an active member of any of ` +
expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']); 'the following teams: team1, team2';
done();
});
});
bvGetPrAuthorTeamMembership.and.returnValue(Promise.resolve({author: 'test', isMember: false}));
it('should fail if verifying membership errors', done => {
teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test'));
bv.verify(pr, createAuthHeader()).catch(err => { bv.verify(pr, createAuthHeader()).catch(err => {
expectToBeUploadError(err, 403, `Error while verifying upload for PR ${pr}: Test`);
done();
});
});
it('should fail if the PR author is not a member of the specified teams', done => {
teamsIsMemberBySlugSpy.and.callFake(() => Promise.resolve(false));
bv.verify(pr, createAuthHeader()).catch(err => {
const errorMessage = `Error while verifying upload for PR ${pr}: ` +
`User 'username' is not an active member of any of: team1, team2`;
expectToBeUploadError(err, 403, errorMessage); expectToBeUploadError(err, 403, errorMessage);
done(); done();
}); });
@ -200,4 +179,75 @@ describe('BuildVerifier', () => {
}); });
describe('getPrAuthorTeamMembership()', () => {
const pr = 9;
let prsFetchSpy: jasmine.Spy;
let teamsIsMemberBySlugSpy: jasmine.Spy;
beforeEach(() => {
prsFetchSpy = spyOn(GithubPullRequests.prototype, 'fetch').
and.returnValue(Promise.resolve({user: {login: 'username'}}));
teamsIsMemberBySlugSpy = spyOn(GithubTeams.prototype, 'isMemberBySlug').
and.returnValue(Promise.resolve(true));
});
it('should return a promise', () => {
expect(bv.getPrAuthorTeamMembership(pr)).toEqual(jasmine.any(Promise));
});
it('should fetch the corresponding PR', done => {
bv.getPrAuthorTeamMembership(pr).then(() => {
expect(prsFetchSpy).toHaveBeenCalledWith(pr);
done();
});
});
it('should fail if fetching the PR errors', done => {
prsFetchSpy.and.callFake(() => Promise.reject('Test'));
bv.getPrAuthorTeamMembership(pr).catch(err => {
expect(err).toBe('Test');
done();
});
});
it('should verify the PR author\'s membership in the specified teams', done => {
bv.getPrAuthorTeamMembership(pr).then(() => {
expect(teamsIsMemberBySlugSpy).toHaveBeenCalledWith('username', ['team1', 'team2']);
done();
});
});
it('should fail if verifying membership errors', done => {
teamsIsMemberBySlugSpy.and.callFake(() => Promise.reject('Test'));
bv.getPrAuthorTeamMembership(pr).catch(err => {
expect(err).toBe('Test');
done();
});
});
it('should return the PR\'s author and whether they are members', done => {
teamsIsMemberBySlugSpy.and.returnValues(Promise.resolve(true), Promise.resolve(false));
Promise.all([
bv.getPrAuthorTeamMembership(pr).then(({author, isMember}) => {
expect(author).toBe('username');
expect(isMember).toBe(true);
}),
bv.getPrAuthorTeamMembership(pr).then(({author, isMember}) => {
expect(author).toBe('username');
expect(isMember).toBe(false);
}),
]).then(done);
});
});
}); });

View File

@ -0,0 +1,13 @@
#!/bin/bash
set -eux -o pipefail
# Set up env
source "`dirname $0`/env.sh"
# Preverify PR
AIO_GITHUB_ORGANIZATION="angular" \
AIO_GITHUB_TEAM_SLUGS="angular-core" \
AIO_GITHUB_TOKEN=$(echo ${GITHUB_TEAM_MEMBERSHIP_CHECK_KEY} | rev) \
AIO_REPO_SLUG=$TRAVIS_REPO_SLUG \
AIO_PREVERIFY_PR=$TRAVIS_PULL_REQUEST \
node "$SCRIPTS_JS_DIR/dist/lib/upload-server/index-preverify-pr"

View File

@ -54,11 +54,26 @@ case ${CI_MODE} in
# This is a PR: deploy a snapshot for previewing # This is a PR: deploy a snapshot for previewing
travisFoldStart "deploy.aio.pr-preview" travisFoldStart "deploy.aio.pr-preview"
# Only deploy if this PR has touched relevant files. # Only deploy if this PR has touched relevant files.
AIO_CHANGED_FILES_COUNT=$(git diff --name-only $TRAVIS_COMMIT_RANGE | grep ^aio/ | wc -l) readonly AIO_CHANGED_FILES_COUNT=$(git diff --name-only $TRAVIS_COMMIT_RANGE | grep ^aio/ | wc -l)
if [[ AIO_CHANGED_FILES_COUNT -eq 0 ]]; then if [[ AIO_CHANGED_FILES_COUNT -eq 0 ]]; then
echo "Skipping deploy because this PR did not touch any files inside 'aio/'." echo "Skipping deploy because this PR did not touch any files inside 'aio/'."
else else
yarn run deploy-preview # Only deploy if this PR meets certain preconditions.
readonly AIO_PREVERIFY_EXIT_CODE=$(./aio-builds-setup/scripts/travis-preverify-pr.sh && echo 0 || echo $?)
case $AIO_PREVERIFY_EXIT_CODE in
2)
# An error occurred: Fail the build
exit 1;
;;
1)
# Preconditions not met: Skip deploy
echo "Skipping deploy because this PR did not meet the preconditions."
;;
0)
# Preconditions met: Deploy
yarn run deploy-preview
;;
esac
fi fi
travisFoldEnd "deploy.aio.pr-preview" travisFoldEnd "deploy.aio.pr-preview"
else else

View File

@ -77,6 +77,9 @@ if [[ ${TRAVIS:-} ]]; then
# WARNING: NGBUILDS_IO_KEY should NOT be printed # WARNING: NGBUILDS_IO_KEY should NOT be printed
export NGBUILDS_IO_KEY=${NGBUILDS_IO_KEY:-$SAUCE_ACCESS_KEY} export NGBUILDS_IO_KEY=${NGBUILDS_IO_KEY:-$SAUCE_ACCESS_KEY}
# Personal token generated by mary-poppins, with only `read_org` permission
export GITHUB_TEAM_MEMBERSHIP_CHECK_KEY=35fc4093c1f29a2ddaf60cce5d57065454180bf6
# Used by karma and karma-chrome-launcher # Used by karma and karma-chrome-launcher
# In order to have a meaningful SauceLabs badge on the repo page, # In order to have a meaningful SauceLabs badge on the repo page,
# the angular2-ci account is used only when pushing commits to master; # the angular2-ci account is used only when pushing commits to master;