Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions lib/cherry_pick.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,31 @@ export default class CherryPick {
lint,
includeCVE,
cveIds,
vulnCveMap
vulnCveMap,
promptAmend,
skipMessagePrompt
} = {}) {
this.prid = prid;
this.cli = cli;
this.dir = dir;
this.upstream = upstream;
this.gpgSign = gpgSign;
this.options = { owner, repo, lint, includeCVE, cveIds, vulnCveMap };
this.skipMessagePrompt = skipMessagePrompt ?? false;
this.options = {
owner, repo, lint, includeCVE, cveIds, vulnCveMap, promptAmend
};
}

get includeCVE() {
return this.options.includeCVE ?? false;
}

// Whether to ask before amending the PR to the proposal. Callers that already
// asked opt out of it.
get promptAmend() {
return this.options.promptAmend ?? true;
}

get cveIds() {
return this.options.cveIds ?? null;
}
Expand Down Expand Up @@ -82,14 +93,22 @@ export default class CherryPick {
}, false, cli);
this.expectedCommitShas =
metadata.data.commits.map(({ commit }) => commit.oid);

const amend = await cli.prompt(
'Would you like to amend this PR to the proposal?',
{ default: true }
);

if (!amend) {
return true;
// In security releases the fix may have already landed publicly, so the
// PR is merged and refs/pull/<prid>/merge no longer exists - pick the
// commits as they landed on the base branch instead.
this.landedCommitSha = metadata.data.pr.merged
? metadata.data.pr.mergeCommit?.oid
: undefined;

if (this.promptAmend) {
const amend = await cli.prompt(
'Would you like to amend this PR to the proposal?',
{ default: true }
);

if (!amend) {
return true;
}
}

try {
Expand Down Expand Up @@ -117,7 +136,7 @@ export default class CherryPick {
{ captureStdout: 'lines' });

if (commitInfo.shas.length !== 1) {
const fixupAll = await cli.prompt(
const fixupAll = this.skipMessagePrompt || await cli.prompt(
`${subjects.length} commits from the original PR are going to be` +
'squashed into a single commit. OK to proceed?', {
defaultAnswer: true
Expand Down
80 changes: 61 additions & 19 deletions lib/landing_session.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export default class LandingSession extends Session {
}

async downloadAndPatch() {
const { cli, upstream, prid, expectedCommitShas, crossRepoPR } = this;
const {
cli, upstream, prid, expectedCommitShas, crossRepoPR, landedCommitSha
} = this;

if (crossRepoPR) {
const { owner, repo } = this;
Expand All @@ -107,33 +109,70 @@ export default class LandingSession extends Session {
{ ignoreFailure: false });
}
} while (!isHeadAMergeCommit());
} else if (landedCommitSha) {
// The PR already landed (e.g. a security fix released publicly), so
// its merge ref is gone - fetch the commits from the base branch.
cli.startSpinner(`Downloading landed commits for ${prid}`);
await forceRunAsync('git', ['fetch', upstream, landedCommitSha],
{ ignoreFailure: false });
} else {
cli.startSpinner(`Downloading patch for ${prid}`);
await runAsync('git', [
'fetch', upstream,
`refs/pull/${prid}/merge`]);
}

// We fetched the commit that would result if we used `git merge`.
// ^1 and ^2 refer to the PR base and the PR head, respectively.
const [base, head, rebaseHead] = await runAsync('git',
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2', 'HEAD'],
{ captureStdout: 'lines' });
let base, head, rebaseHead;
if (landedCommitSha && !crossRepoPR) {
// FETCH_HEAD is the last commit of the PR as landed on the base branch.
// Walk back while the PR-URL trailer still points to this PR to find
// the base of the landed series (landing may have squashed commits).
const prUrlRe = new RegExp(`^PR-URL: \\S+/pull/${prid}$`, 'm');
let count = 0;
while (count < expectedCommitShas.length) {
const message = await runAsync('git',
['log', '-1', '--format=%B', `FETCH_HEAD~${count}`],
{ captureStdout: true });
if (!prUrlRe.test(message)) break;
count++;
}
if (count === 0) {
cli.error(`Landed commit ${landedCommitSha} does not have a ` +
`PR-URL trailer pointing to pull request ${prid}`);
process.exit(1);
}
[base, head, rebaseHead] = await runAsync('git',
['rev-parse', `FETCH_HEAD~${count}`, 'FETCH_HEAD', 'HEAD'],
{ captureStdout: 'lines' });
} else {
// We fetched the commit that would result if we used `git merge`.
// ^1 and ^2 refer to the PR base and the PR head, respectively.
[base, head, rebaseHead] = await runAsync('git',
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2', 'HEAD'],
{ captureStdout: 'lines' });
}
const commitShas = await runAsync('git',
['rev-list', `${base}..${head}`],
{ captureStdout: 'lines' });
cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`);
cli.separator();

const mismatchedCommits = [
...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
.map((sha) => `Unexpected commit ${sha}`),
...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
.map((sha) => `Missing commit ${sha}`)
].join('\n');
if (mismatchedCommits.length > 0) {
cli.error(`Mismatched commits:\n${mismatchedCommits}`);
process.exit(1);
if (landedCommitSha && !crossRepoPR) {
// The landed commits have different SHAs from the ones in the PR, so
// they cannot be compared - they were validated by their PR-URL
// trailers above.
cli.ok(`Using ${commitShas.length} commit(s) as landed for PR ${prid}`);
} else {
const mismatchedCommits = [
...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
.map((sha) => `Unexpected commit ${sha}`),
...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
.map((sha) => `Missing commit ${sha}`)
].join('\n');
if (mismatchedCommits.length > 0) {
cli.error(`Mismatched commits:\n${mismatchedCommits}`);
process.exit(1);
}
}

const commitInfo = { base, head, shas: commitShas, rebaseHead };
Expand Down Expand Up @@ -398,14 +437,14 @@ export default class LandingSession extends Session {
if (!cveID) {
cveID = await cli.prompt(
'Git found no CVE-ID trailer in the original commit message. ' +
'Please, provide the CVE-ID or leave it empty',
{ questionType: 'input', defaultAnswer: 'CVE-2026-XXXXX' }
'Please, provide the CVE-ID (e.g. CVE-2026-12345) or leave it empty',
{ questionType: 'input', defaultAnswer: '' }
);
}
}
// Some commits might not address a vulnerability, but it is necessary
// for the security release to happen.
if (cveID !== '') {
if (cveID) {
amended.push('CVE-ID: ' + cveID);
}
}
Expand All @@ -430,7 +469,10 @@ export default class LandingSession extends Session {
const messageFile = this.saveMessage(rev, message);
cli.separator('New Message');
cli.log(message.trim());
const takeMessage = await cli.prompt('Use this message?');
// `skipMessagePrompt` is only set by callers that already got a blanket
// confirmation; it is undefined on a regular landing session.
const takeMessage = this.skipMessagePrompt ||
await cli.prompt('Use this message?');
if (takeMessage) {
await runAsync('git', ['commit', '--amend', '-F', messageFile, ...this.gpgSign]);
return true;
Expand Down
Loading
Loading