Skip to content

Commit ad33378

Browse files
committed
feat: support security release prepare
1 parent 267dde0 commit ad33378

File tree

7 files changed

+505
-16
lines changed

7 files changed

+505
-16
lines changed

components/git/release.js

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ const releaseOptions = {
2121
describe: 'Demarcate the new security release as a security release',
2222
type: 'boolean'
2323
},
24+
filterLabel: {
25+
describe: 'Labels separated by "," to filter security PRs',
26+
type: 'string'
27+
},
2428
startLTS: {
2529
describe: 'Mark the release as the transition from Current to LTS',
2630
type: 'boolean'

docs/git-node.md

+6
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,7 @@ Options:
219219
--prepare Prepare a new release of Node.js [boolean]
220220
--security Demarcate the new security release as a security release [boolean]
221221
--startLTS Mark the release as the transition from Current to LTS [boolean]
222+
--filterLabel Filter PR by label when preparing a security release [string]
222223
```
223224

224225
### Example
@@ -238,6 +239,11 @@ git node release --prepare
238239
git node release --prepare --startLTS
239240
```
240241

242+
```sh
243+
# Prepare security release
244+
git node release --prepare --security --filterLabel 18.x 18.20.1
245+
```
246+
241247
## `git node sync`
242248

243249
Demo: https://asciinema.org/a/221230

lib/cherry_pick.js

+302
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import os from 'node:os';
2+
import path from 'node:path';
3+
import { getMetadata } from '../components/metadata.js';
4+
5+
import {
6+
runAsync, runSync, forceRunAsync
7+
} from './run.js';
8+
import { writeFile } from './file.js';
9+
import {
10+
shortSha, getEditor
11+
} from './utils.js';
12+
import { getNcuDir } from './config.js';
13+
14+
const LINT_RESULTS = {
15+
SKIPPED: 'skipped',
16+
FAILED: 'failed',
17+
SUCCESS: 'success'
18+
};
19+
20+
export default class CheckPick {
21+
constructor(prid, dir, cli, {
22+
owner,
23+
repo,
24+
lint,
25+
includeCVE
26+
} = {}) {
27+
this.prid = prid;
28+
this.cli = cli;
29+
this.dir = dir;
30+
this.options = { owner, repo, lint, includeCVE };
31+
}
32+
33+
get includeCVE() {
34+
return this.options.includeCVE ?? false;
35+
}
36+
37+
get owner() {
38+
return this.options.owner || 'nodejs';
39+
}
40+
41+
get repo() {
42+
return this.options.repo || 'node';
43+
}
44+
45+
get lint() {
46+
return this.options.lint;
47+
}
48+
49+
getUpstreamHead() {
50+
const { upstream, branch } = this;
51+
return runSync('git', ['rev-parse', `${upstream}/${branch}`]).trim();
52+
}
53+
54+
getCurrentRev() {
55+
return runSync('git', ['rev-parse', 'HEAD']).trim();
56+
}
57+
58+
getStrayCommits(verbose) {
59+
const { upstream, branch } = this;
60+
const ref = `${upstream}/${branch}...HEAD`;
61+
const gitCmd = verbose
62+
? ['log', '--oneline', '--reverse', ref]
63+
: ['rev-list', '--reverse', ref];
64+
const revs = runSync('git', gitCmd).trim();
65+
return revs ? revs.split('\n') : [];
66+
}
67+
68+
get ncuDir() {
69+
return getNcuDir(this.dir);
70+
}
71+
72+
get pullDir() {
73+
return path.join(this.ncuDir, `${this.prid}`);
74+
}
75+
76+
getMessagePath(rev) {
77+
return path.join(this.pullDir, `${shortSha(rev)}.COMMIT_EDITMSG`);
78+
}
79+
80+
saveMessage(rev, message) {
81+
const file = this.getMessagePath(rev);
82+
writeFile(file, message);
83+
return file;
84+
}
85+
86+
async start() {
87+
const { cli } = this;
88+
89+
const metadata = await getMetadata({
90+
prid: this.prid,
91+
owner: this.owner,
92+
repo: this.repo
93+
}, false, cli);
94+
const expectedCommitShas =
95+
metadata.data.commits.map(({ commit }) => commit.oid);
96+
97+
const amend = await cli.prompt(
98+
'Would you like to amend this PR to the proposal?',
99+
{ default: true }
100+
);
101+
102+
if (!amend) {
103+
return true;
104+
}
105+
106+
try {
107+
const commitInfo = await this.downloadAndPatch(expectedCommitShas);
108+
const cleanLint = await this.validateLint();
109+
if (cleanLint === LINT_RESULTS.FAILED) {
110+
cli.error('Patch still contains lint errors. ' +
111+
'Please fix manually before proceeding');
112+
return false;
113+
} else if (cleanLint === LINT_RESULTS.SUCCESS) {
114+
cli.ok('Lint passed cleanly');
115+
}
116+
return this.amend(metadata.metadata, commitInfo);
117+
} catch (e) {
118+
cli.error(e.message);
119+
return false;
120+
}
121+
}
122+
123+
async downloadAndPatch(expectedCommitShas) {
124+
const { cli, repo, owner, prid } = this;
125+
126+
cli.startSpinner(`Downloading patch for ${prid}`);
127+
// fetch via ssh to handle private repo
128+
await runAsync('git', [
129+
'fetch', `[email protected]:${owner}/${repo}.git`,
130+
`refs/pull/${prid}/merge`]);
131+
// We fetched the commit that would result if we used `git merge`.
132+
// ^1 and ^2 refer to the PR base and the PR head, respectively.
133+
const [base, head] = await runAsync('git',
134+
['rev-parse', 'FETCH_HEAD^1', 'FETCH_HEAD^2'],
135+
{ captureStdout: 'lines' });
136+
const commitShas = await runAsync('git',
137+
['rev-list', `${base}..${head}`],
138+
{ captureStdout: 'lines' });
139+
cli.stopSpinner(`Fetched commits as ${shortSha(base)}..${shortSha(head)}`);
140+
cli.separator();
141+
142+
const mismatchedCommits = [
143+
...commitShas.filter((sha) => !expectedCommitShas.includes(sha))
144+
.map((sha) => `Unexpected commit ${sha}`),
145+
...expectedCommitShas.filter((sha) => !commitShas.includes(sha))
146+
.map((sha) => `Missing commit ${sha}`)
147+
].join('\n');
148+
if (mismatchedCommits.length > 0) {
149+
throw new Error(`Mismatched commits:\n${mismatchedCommits}`);
150+
}
151+
152+
const commitInfo = { base, head, shas: commitShas };
153+
154+
try {
155+
await forceRunAsync('git', ['cherry-pick', `${base}..${head}`], {
156+
ignoreFailure: false
157+
});
158+
} catch (ex) {
159+
await forceRunAsync('git', ['cherry-pick', '--abort']);
160+
throw new Error('Failed to apply patches');
161+
}
162+
163+
cli.ok('Patches applied');
164+
return commitInfo;
165+
}
166+
167+
async validateLint() {
168+
// The linter is currently only run on non-Windows platforms.
169+
if (os.platform() === 'win32') {
170+
return LINT_RESULTS.SKIPPED;
171+
}
172+
173+
if (!this.lint) {
174+
return LINT_RESULTS.SKIPPED;
175+
}
176+
177+
try {
178+
await runAsync('make', ['lint']);
179+
return LINT_RESULTS.SUCCESS;
180+
} catch {
181+
return LINT_RESULTS.FAILED;
182+
}
183+
}
184+
185+
async amend(metadata, commitInfo) {
186+
const { cli } = this;
187+
const subjects = await runAsync('git',
188+
['log', '--pretty=format:%s', `${commitInfo.base}..${commitInfo.head}`],
189+
{ captureStdout: 'lines' });
190+
191+
if (commitInfo.shas.length !== 1) {
192+
const fixupAll = await cli.prompt(
193+
`There are ${subjects.length} commits in the PR. ` +
194+
'Would you like to fixup everything into first commit?');
195+
if (!fixupAll) {
196+
// TODO: add this support?
197+
throw new Error(`There are ${subjects.length} commits in the PR ` +
198+
'and the ammend were not able to succeed');
199+
}
200+
await runAsync('git', ['reset', '--soft', `HEAD~${subjects.length - 1}`]);
201+
await runAsync('git', ['commit', '--amend', '--no-edit']);
202+
}
203+
204+
return this._amend(metadata);
205+
}
206+
207+
async _amend(metadataStr) {
208+
const { cli } = this;
209+
210+
const rev = this.getCurrentRev();
211+
const original = runSync('git', [
212+
'show', 'HEAD', '-s', '--format=%B'
213+
]).trim();
214+
// git has very specific rules about what is a trailer and what is not.
215+
// Instead of trying to implement those ourselves, let git parse the
216+
// original commit message and see if it outputs any trailers.
217+
const originalHasTrailers = runSync('git', [
218+
'interpret-trailers', '--parse', '--no-divider'
219+
], {
220+
input: `${original}\n`
221+
}).trim().length !== 0;
222+
const metadata = metadataStr.trim().split('\n');
223+
const amended = original.split('\n');
224+
225+
// If the original commit message already contains trailers (such as
226+
// "Co-authored-by"), we simply add our own metadata after those. Otherwise,
227+
// we have to add an empty line so that git recognizes our own metadata as
228+
// trailers in the amended commit message.
229+
if (!originalHasTrailers) {
230+
amended.push('');
231+
}
232+
233+
const BACKPORT_RE = /BACKPORT-PR-URL\s*:\s*(\S+)/i;
234+
const PR_RE = /PR-URL\s*:\s*(\S+)/i;
235+
const REVIEW_RE = /Reviewed-By\s*:\s*(\S+)/i;
236+
const CVE_RE = /CVE-ID\s*:\s*(\S+)/i;
237+
238+
let containCVETrailer = false;
239+
for (const line of metadata) {
240+
if (line.length !== 0 && original.includes(line)) {
241+
if (line.match(CVE_RE)) {
242+
containCVETrailer = true;
243+
}
244+
if (originalHasTrailers) {
245+
cli.warn(`Found ${line}, skipping..`);
246+
} else {
247+
throw new Error(
248+
'Git found no trailers in the original commit message, ' +
249+
`but '${line}' is present and should be a trailer.`);
250+
}
251+
} else {
252+
if (line.match(BACKPORT_RE)) {
253+
let prIndex = amended.findIndex(datum => datum.match(PR_RE));
254+
if (prIndex === -1) {
255+
prIndex = amended.findIndex(datum => datum.match(REVIEW_RE)) - 1;
256+
}
257+
amended.splice(prIndex + 1, 0, line);
258+
} else {
259+
amended.push(line);
260+
}
261+
}
262+
}
263+
264+
if (!containCVETrailer && this.includeCVE) {
265+
const cveID = await cli.prompt(
266+
'Git found no CVE-ID trailer in the original commit message. ' +
267+
'Please, provide the CVE-ID',
268+
{ questionType: 'input', defaultAnswer: 'CVE-2023-XXXXX' }
269+
);
270+
amended.push('CVE-ID: ' + cveID);
271+
}
272+
273+
const message = amended.join('\n');
274+
const messageFile = this.saveMessage(rev, message);
275+
cli.separator('New Message');
276+
cli.log(message.trim());
277+
const takeMessage = await cli.prompt('Use this message?');
278+
if (takeMessage) {
279+
await runAsync('git', ['commit', '--amend', '-F', messageFile]);
280+
return true;
281+
}
282+
283+
const editor = await getEditor({ git: true });
284+
if (editor) {
285+
try {
286+
await forceRunAsync(
287+
editor,
288+
[`"${messageFile}"`],
289+
{ ignoreFailure: false, spawnArgs: { shell: true } }
290+
);
291+
await runAsync('git', ['commit', '--amend', '-F', messageFile]);
292+
return true;
293+
} catch {
294+
cli.warn(`Please manually edit ${messageFile}, then run\n` +
295+
`\`git commit --amend -F ${messageFile}\` ` +
296+
'to finish amending the message');
297+
throw new Error(
298+
'Failed to edit the message using the configured editor');
299+
}
300+
}
301+
}
302+
}

lib/collaborators.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export async function getCollaborators(cli, request, argv) {
4949
readmeText = fs.readFileSync(readme, 'utf8');
5050
} else {
5151
cli.updateSpinner(
52-
`Getting collaborator contacts from README of ${owner}/${repo}`);
53-
const url = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/README.md`;
52+
'Getting collaborator contacts from README of nodejs/node');
53+
const url = 'https://raw.githubusercontent.com/nodejs/node/HEAD/README.md';
5454
readmeText = await request.text(url);
5555
}
5656

0 commit comments

Comments
 (0)