Skip to content

Commit 86b220e

Browse files
devversionjelbourn
authored andcommitted
build: do not print github auth token if merge script fails (#19159)
Various improvements to the merge script: 1. No longer print Github auth token if a command fails. 2. No longer print Github auth token when verbose-printing executed git commands. 3. Better error if token does not have required permissions for merging pull request (api-merge strategy)
1 parent 874c754 commit 86b220e

File tree

5 files changed

+103
-26
lines changed

5 files changed

+103
-26
lines changed

scripts/merge-script/cli.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ import {isAbsolute, resolve} from 'path';
1212

1313
import {Config, readAndValidateConfig} from './config';
1414
import {promptConfirm} from './console';
15+
import {GithubApiRequestError} from './git';
1516
import {MergeResult, MergeStatus, PullRequestMergeTask} from './index';
1617

18+
/** URL to the Github page where personal access tokens can be generated. */
19+
const GITHUB_TOKEN_GENERATE_URL = `https://github.com/settings/tokens`;
20+
1721
// Run the CLI.
1822
main();
1923

@@ -30,14 +34,28 @@ async function main() {
3034
// Perform the merge. Force mode can be activated through a command line flag.
3135
// Alternatively, if the merge fails with non-fatal failures, the script
3236
// will prompt whether it should rerun in force mode.
33-
const mergeResult = await api.merge(prNumber, force);
34-
35-
// Handle the result of the merge. If failures have been reported, exit
36-
// the process with a non-zero exit code.
37-
if (!await handleMergeResult(mergeResult, force)) {
37+
if (!await performMerge(force)) {
3838
process.exit(1);
3939
}
4040

41+
/** Performs the merge and returns whether it was successful or not. */
42+
async function performMerge(ignoreFatalErrors: boolean): Promise<boolean> {
43+
try {
44+
const result = await api.merge(prNumber, ignoreFatalErrors);
45+
return await handleMergeResult(result, ignoreFatalErrors);
46+
} catch (e) {
47+
// Catch errors to the Github API for invalid requests. We want to
48+
// exit the script with a better explanation of the error.
49+
if (e instanceof GithubApiRequestError && e.status === 401) {
50+
console.error(chalk.red('Github API request failed. ' + e.message));
51+
console.error(chalk.yellow('Please ensure that your provided token is valid.'));
52+
console.error(chalk.yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`));
53+
process.exit(1);
54+
}
55+
throw e;
56+
}
57+
}
58+
4159
/**
4260
* Prompts whether the specified pull request should be forcibly merged. If so, merges
4361
* the specified pull request forcibly (ignoring non-critical failures).
@@ -47,10 +65,7 @@ async function main() {
4765
if (await promptConfirm('Do you want to forcibly proceed with merging?')) {
4866
// Perform the merge in force mode. This means that non-fatal failures
4967
// are ignored and the merge continues.
50-
const forceMergeResult = await api.merge(prNumber, true);
51-
// Handle the merge result. Note that we disable the force merge prompt since
52-
// a failed force merge will never succeed with a second force merge.
53-
return await handleMergeResult(forceMergeResult, true);
68+
return performMerge(true);
5469
}
5570
return false;
5671
}
@@ -130,6 +145,7 @@ function parseCommandLine():
130145
console.error(
131146
chalk.red('No Github token is set. Please set the `GITHUB_TOKEN` environment variable.'));
132147
console.error(chalk.red('Alternatively, pass the `--github-token` command line flag.'));
148+
console.error(chalk.yellow(`You can generate a token here: ${GITHUB_TOKEN_GENERATE_URL}`));
133149
process.exit(1);
134150
}
135151

scripts/merge-script/failures.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,9 @@ export class PullRequestFailure {
7070
static notFound() {
7171
return new this(`Pull request could not be found upstream.`);
7272
}
73+
74+
static insufficientPermissionsToMerge() {
75+
return new this(`Insufficient Github API permissions to merge pull request. Please ` +
76+
`ensure that your auth token has write access.`);
77+
}
7378
}

scripts/merge-script/git.ts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@ import * as Octokit from '@octokit/rest';
1010
import {spawnSync, SpawnSyncOptions, SpawnSyncReturns} from 'child_process';
1111
import {Config} from './config';
1212

13+
/** Error for failed Github API requests. */
14+
export class GithubApiRequestError extends Error {
15+
constructor(public status: number, message: string) {
16+
super(message);
17+
}
18+
}
19+
1320
/** Error for failed Git commands. */
1421
export class GitCommandError extends Error {
15-
constructor(public commandArgs: string[]) {
16-
super(`Command failed: git ${commandArgs.join(' ')}`);
22+
constructor(client: GitClient, public args: string[]) {
23+
// Errors are not guaranteed to be caught. To ensure that we don't
24+
// accidentally leak the Github token that might be used in a command,
25+
// we sanitize the command that will be part of the error message.
26+
super(`Command failed: git ${client.omitGithubTokenFromMessage(args.join(' '))}`);
1727
}
1828
}
1929

@@ -29,15 +39,23 @@ export class GitClient {
2939
/** Instance of the authenticated Github octokit API. */
3040
api: Octokit;
3141

42+
/** Regular expression that matches the provided Github token. */
43+
private _tokenRegex = new RegExp(this._githubToken, 'g');
44+
3245
constructor(private _githubToken: string, private _config: Config) {
3346
this.api = new Octokit({auth: _githubToken});
47+
this.api.hook.error('request', error => {
48+
// Wrap API errors in a known error class. This allows us to
49+
// expect Github API errors better and in a non-ambiguous way.
50+
throw new GithubApiRequestError(error.status, error.message);
51+
});
3452
}
3553

3654
/** Executes the given git command. Throws if the command fails. */
3755
run(args: string[], options?: SpawnSyncOptions): Omit<SpawnSyncReturns<string>, 'status'> {
3856
const result = this.runGraceful(args, options);
3957
if (result.status !== 0) {
40-
throw new GitCommandError(args);
58+
throw new GitCommandError(this, args);
4159
}
4260
// Omit `status` from the type so that it's obvious that the status is never
4361
// non-zero as explained in the method description.
@@ -46,21 +64,32 @@ export class GitClient {
4664

4765
/**
4866
* Spawns a given Git command process. Does not throw if the command fails. Additionally,
49-
* the "stderr" output is inherited and will be printed in case of errors. This makes it
50-
* easier to debug failed commands.
67+
* if there is any stderr output, the output will be printed. This makes it easier to
68+
* debug failed commands.
5169
*/
5270
runGraceful(args: string[], options: SpawnSyncOptions = {}): SpawnSyncReturns<string> {
53-
// To improve the debugging experience in case something fails, we print
54-
// all executed Git commands.
55-
console.info('Executing: git', ...args);
56-
return spawnSync('git', args, {
71+
// To improve the debugging experience in case something fails, we print all executed
72+
// Git commands. Note that we do not want to print the token if is contained in the
73+
// command. It's common to share errors with others if the tool failed.
74+
console.info('Executing: git', this.omitGithubTokenFromMessage(args.join(' ')));
75+
76+
const result = spawnSync('git', args, {
5777
cwd: this._config.projectRoot,
58-
stdio: ['pipe', 'pipe', 'inherit'],
78+
stdio: 'pipe',
5979
...options,
6080
// Encoding is always `utf8` and not overridable. This ensures that this method
6181
// always returns `string` as output instead of buffers.
6282
encoding: 'utf8',
6383
});
84+
85+
if (result.stderr !== null) {
86+
// Git sometimes prints the command if it failed. This means that it could
87+
// potentially leak the Github token used for accessing the remote. To avoid
88+
// printing a token, we sanitize the string before printing the stderr output.
89+
process.stderr.write(this.omitGithubTokenFromMessage(result.stderr));
90+
}
91+
92+
return result;
6493
}
6594

6695
/** Whether the given branch contains the specified SHA. */
@@ -77,4 +106,9 @@ export class GitClient {
77106
hasUncommittedChanges(): boolean {
78107
return this.runGraceful(['diff-index', '--quiet', 'HEAD']).status !== 0;
79108
}
109+
110+
/** Sanitizes a given message by omitting the provided Github token if present. */
111+
omitGithubTokenFromMessage(value: string): string {
112+
return value.replace(this._tokenRegex, '<TOKEN>');
113+
}
80114
}

scripts/merge-script/pull-request.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ async function fetchPullRequestFromGithub(
9494
try {
9595
const result = await git.api.pulls.get({...git.repoParams, pull_number: prNumber});
9696
return result.data;
97-
} catch {
98-
return null;
97+
} catch (e) {
98+
// If the pull request could not be found, we want to return `null` so
99+
// that the error can be handled gracefully.
100+
if (e.status === 404) {
101+
return null;
102+
}
103+
throw e;
99104
}
100105
}
101106

scripts/merge-script/strategies/api-merge.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,32 @@ export class GithubApiMergeStrategy extends MergeStrategy {
9191
await this._promptCommitMessageEdit(pullRequest, mergeOptions);
9292
}
9393

94-
// Merge the pull request using the Github API into the selected base branch.
95-
const {status, data: {sha}} = await this.git.api.pulls.merge(mergeOptions);
94+
let mergeStatusCode: number;
95+
let targetSha: string;
96+
97+
try {
98+
// Merge the pull request using the Github API into the selected base branch.
99+
const result = await this.git.api.pulls.merge(mergeOptions);
100+
101+
mergeStatusCode = result.status;
102+
targetSha = result.data.sha;
103+
} catch (e) {
104+
// Note: Github usually returns `404` as status code if the API request uses a
105+
// token with insufficient permissions. Github does this because it doesn't want
106+
// to leak whether a repository exists or not. In our case we expect a certain
107+
// repository to exist, so we always treat this as a permission failure.
108+
if (e.status === 403 || e.status === 404) {
109+
return PullRequestFailure.insufficientPermissionsToMerge();
110+
}
111+
throw e;
112+
}
96113

97114
// https://developer.github.com/v3/pulls/#response-if-merge-cannot-be-performed
98115
// Pull request cannot be merged due to merge conflicts.
99-
if (status === 405) {
116+
if (mergeStatusCode === 405) {
100117
return PullRequestFailure.mergeConflicts([githubTargetBranch]);
101118
}
102-
if (status !== 200) {
119+
if (mergeStatusCode !== 200) {
103120
return PullRequestFailure.unknownMergeError();
104121
}
105122

@@ -120,7 +137,7 @@ export class GithubApiMergeStrategy extends MergeStrategy {
120137

121138
// Cherry pick the merged commits into the remaining target branches.
122139
const failedBranches = await this.cherryPickIntoTargetBranches(
123-
`${sha}~${targetCommitsCount}..${sha}`, cherryPickTargetBranches);
140+
`${targetSha}~${targetCommitsCount}..${targetSha}`, cherryPickTargetBranches);
124141

125142
// We already checked whether the PR can be cherry-picked into the target branches,
126143
// but in case the cherry-pick somehow fails, we still handle the conflicts here. The

0 commit comments

Comments
 (0)