Skip to content

Commit be11dc8

Browse files
authored
chore(NODE-5347): add standard release automation (mongodb#3717)
1 parent d9c2600 commit be11dc8

12 files changed

+471
-1401
lines changed

.github/pull_request_template.md

+8
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ If you haven't already, it would greatly help the team review this work in a tim
1616
You can do that here: https://jira.mongodb.org/projects/NODE
1717
-->
1818

19+
#### Release Highlight
20+
21+
<!-- RELEASE_HIGHLIGHT_START -->
22+
23+
## Fill in title or leave empty for no highlight
24+
25+
<!-- RELEASE_HIGHLIGHT_END -->
26+
1927
### Double check the following
2028

2129
- [ ] Ran `npm run check:lint` script

.github/scripts/highlights.mjs

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// @ts-check
2+
import * as process from 'node:process';
3+
import { Octokit } from '@octokit/core';
4+
import { output } from './util.mjs';
5+
6+
const {
7+
GITHUB_TOKEN = '',
8+
PR_LIST = '',
9+
owner = 'mongodb',
10+
repo = 'node-mongodb-native'
11+
} = process.env;
12+
if (GITHUB_TOKEN === '') throw new Error('GITHUB_TOKEN cannot be empty');
13+
14+
const octokit = new Octokit({
15+
auth: GITHUB_TOKEN,
16+
log: {
17+
debug: msg => console.error('Octokit.debug', msg),
18+
info: msg => console.error('Octokit.info', msg),
19+
warn: msg => console.error('Octokit.warn', msg),
20+
error: msg => console.error('Octokit.error', msg)
21+
}
22+
});
23+
24+
const prs = PR_LIST.split(',').map(pr => {
25+
const prNum = Number(pr);
26+
if (Number.isNaN(prNum))
27+
throw Error(`expected PR number list: ${PR_LIST}, offending entry: ${pr}`);
28+
return prNum;
29+
});
30+
31+
/** @param {number} pull_number */
32+
async function getPullRequestContent(pull_number) {
33+
const startIndicator = 'RELEASE_HIGHLIGHT_START -->';
34+
const endIndicator = '<!-- RELEASE_HIGHLIGHT_END';
35+
36+
let body;
37+
try {
38+
const res = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', {
39+
owner,
40+
repo,
41+
pull_number,
42+
headers: { 'X-GitHub-Api-Version': '2022-11-28' }
43+
});
44+
body = res.data.body;
45+
} catch (error) {
46+
console.log(`Could not get PR ${pull_number}, skipping. ${error.status}`);
47+
return '';
48+
}
49+
50+
if (body == null || !(body.includes(startIndicator) && body.includes(endIndicator))) {
51+
console.log(`PR #${pull_number} has no highlight`);
52+
return '';
53+
}
54+
55+
const start = body.indexOf('## ', body.indexOf(startIndicator));
56+
const end = body.indexOf(endIndicator);
57+
const highlightSection = body.slice(start, end).trim();
58+
59+
console.log(`PR #${pull_number} has a highlight ${highlightSection.length} characters long`);
60+
return highlightSection;
61+
}
62+
63+
/** @param {number[]} prs */
64+
async function pullRequestHighlights(prs) {
65+
const highlights = [];
66+
for (const pr of prs) {
67+
const content = await getPullRequestContent(pr);
68+
highlights.push(content);
69+
}
70+
return highlights.join('');
71+
}
72+
73+
console.log('List of PRs to collect highlights from:', prs);
74+
const highlights = await pullRequestHighlights(prs);
75+
76+
await output('highlights', JSON.stringify({ highlights }));

.github/scripts/nightly.mjs

+4-11
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,18 @@ import * as path from 'node:path';
55
import * as process from 'node:process';
66
import * as child_process from 'node:child_process';
77
import * as util from 'node:util';
8+
import { output } from './util.mjs';
89
const exec = util.promisify(child_process.exec);
910

1011
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
1112
const pkgFilePath = path.join(__dirname, '..', '..', 'package.json');
1213

1314
process.env.TZ = 'Etc/UTC';
1415

16+
/** @param {boolean} publish */
1517
async function shouldPublish(publish) {
16-
const githubOutput = process.env.GITHUB_OUTPUT ?? '';
17-
if (githubOutput.length === 0) {
18-
console.log('output file does not exist');
19-
process.exit(1);
20-
}
21-
22-
const outputFile = await fs.open(githubOutput, 'a');
23-
const output = publish ? 'publish=yes' : 'publish=no';
24-
console.log('outputting:', output, 'to', githubOutput);
25-
await outputFile.appendFile(output, { encoding: 'utf8' });
26-
await outputFile.close();
18+
const answer = publish ? 'yes' : 'no';
19+
await output('publish', answer);
2720
}
2821

2922
/**

.github/scripts/pr_list.mjs

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// @ts-check
2+
import * as url from 'node:url';
3+
import * as fs from 'node:fs/promises';
4+
import * as path from 'node:path';
5+
import { getCurrentHistorySection, output } from './util.mjs';
6+
7+
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
8+
const historyFilePath = path.join(__dirname, '..', '..', 'HISTORY.md');
9+
10+
/**
11+
* @param {string} history
12+
* @returns {string[]}
13+
*/
14+
function parsePRList(history) {
15+
const prRegexp = /node-mongodb-native\/issues\/(?<prNum>\d+)\)/giu;
16+
return history
17+
.split('\n')
18+
.map(line => prRegexp.exec(line)?.groups?.prNum ?? '')
19+
.filter(prNum => prNum !== '');
20+
}
21+
22+
const historyContents = await fs.readFile(historyFilePath, { encoding: 'utf8' });
23+
24+
const currentHistorySection = getCurrentHistorySection(historyContents);
25+
26+
const prs = parsePRList(currentHistorySection);
27+
28+
await output('pr_list', prs.join(','));

.github/scripts/release_notes.mjs

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//@ts-check
2+
import * as url from 'node:url';
3+
import * as fs from 'node:fs/promises';
4+
import * as path from 'node:path';
5+
import * as process from 'node:process';
6+
import * as semver from 'semver';
7+
import { getCurrentHistorySection, output } from './util.mjs';
8+
9+
const { HIGHLIGHTS = '' } = process.env;
10+
if (HIGHLIGHTS === '') throw new Error('HIGHLIGHTS cannot be empty');
11+
12+
const { highlights } = JSON.parse(HIGHLIGHTS);
13+
14+
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
15+
const historyFilePath = path.join(__dirname, '..', '..', 'HISTORY.md');
16+
const packageFilePath = path.join(__dirname, '..', '..', 'package.json');
17+
18+
const historyContents = await fs.readFile(historyFilePath, { encoding: 'utf8' });
19+
20+
const currentHistorySection = getCurrentHistorySection(historyContents);
21+
22+
const version = semver.parse(
23+
JSON.parse(await fs.readFile(packageFilePath, { encoding: 'utf8' })).version
24+
);
25+
if (version == null) throw new Error(`could not create semver from package.json`);
26+
27+
console.log('\n\n--- history entry ---\n\n', currentHistorySection);
28+
29+
const currentHistorySectionLines = currentHistorySection.split('\n');
30+
const header = currentHistorySectionLines[0];
31+
const history = currentHistorySectionLines.slice(1).join('\n').trim();
32+
33+
const releaseNotes = `${header}
34+
35+
The MongoDB Node.js team is pleased to announce version ${version.version} of the \`mongodb\` package!
36+
37+
${highlights}
38+
${history}
39+
## Documentation
40+
41+
* [Reference](https://docs.mongodb.com/drivers/node/current/)
42+
* [API](https://mongodb.github.io/node-mongodb-native/${version.major}.${version.minor}/)
43+
* [Changelog](https://github.com/mongodb/node-mongodb-native/blob/v${version.version}/HISTORY.md)
44+
45+
We invite you to try the \`mongodb\` library immediately, and report any issues to the [NODE project](https://jira.mongodb.org/projects/NODE).
46+
`;
47+
48+
const releaseNotesPath = path.join(process.cwd(), 'release_notes.md');
49+
50+
await fs.writeFile(
51+
releaseNotesPath,
52+
`:seedling: A new release!\n---\n${releaseNotes}\n---\n`,
53+
{ encoding:'utf8' }
54+
);
55+
56+
await output('release_notes_path', releaseNotesPath)

.github/scripts/util.mjs

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// @ts-check
2+
import * as process from 'node:process';
3+
import * as fs from 'node:fs/promises';
4+
5+
export async function output(key, value) {
6+
const { GITHUB_OUTPUT = '' } = process.env;
7+
const output = `${key}=${value}\n`;
8+
console.log('outputting:', output);
9+
10+
if (GITHUB_OUTPUT.length === 0) {
11+
// This is always defined in Github actions, and if it is not for some reason, tasks that follow will fail.
12+
// For local testing it's convenient to see what scripts would output without requiring the variable to be defined.
13+
console.log('GITHUB_OUTPUT not defined, printing only');
14+
return;
15+
}
16+
17+
const outputFile = await fs.open(GITHUB_OUTPUT, 'a');
18+
await outputFile.appendFile(output, { encoding: 'utf8' });
19+
await outputFile.close();
20+
}
21+
22+
/**
23+
* @param {string} historyContents
24+
* @returns {string}
25+
*/
26+
export function getCurrentHistorySection(historyContents) {
27+
/** Markdown version header */
28+
const VERSION_HEADER = /^#.+\(\d{4}-\d{2}-\d{2}\)$/g;
29+
30+
const historyLines = historyContents.split('\n');
31+
32+
// Search for the line with the first version header, this will be the one we're releasing
33+
const headerLineIndex = historyLines.findIndex(line => VERSION_HEADER.test(line));
34+
if (headerLineIndex < 0) throw new Error('Could not find any version header');
35+
36+
console.log('Found markdown header current release', headerLineIndex, ':', historyLines[headerLineIndex]);
37+
38+
// Search lines starting after the first header, and add back the offset we sliced at
39+
const nextHeaderLineIndex = historyLines
40+
.slice(headerLineIndex + 1)
41+
.findIndex(line => VERSION_HEADER.test(line)) + headerLineIndex + 1;
42+
if (nextHeaderLineIndex < 0) throw new Error(`Could not find previous version header, searched ${headerLineIndex + 1}`);
43+
44+
console.log('Found markdown header previous release', nextHeaderLineIndex, ':', historyLines[nextHeaderLineIndex]);
45+
46+
return historyLines.slice(headerLineIndex, nextHeaderLineIndex).join('\n');
47+
}

.github/workflows/build_docs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@ jobs:
2727
- name: Open Pull Request
2828
uses: peter-evans/create-pull-request@v4
2929
with:
30-
title: 'docs: generate docs from latest main'
30+
title: 'docs: generate docs from latest main [skip-ci]'
3131
delete-branch: true

.github/workflows/release.yml

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
on:
2-
# push:
3-
# branches: [ main, 4.x ]
2+
push:
3+
branches: [main]
44
workflow_dispatch: {}
55

66
permissions:
@@ -13,10 +13,25 @@ jobs:
1313
release-please:
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: google-github-actions/release-please-action@v3
16+
- id: release
17+
uses: google-github-actions/release-please-action@v3
1718
with:
1819
release-type: node
1920
package-name: mongodb
20-
pull-request-title-pattern: "chore${scope}: release${component} ${version} [skip-ci]"
21-
# Publication steps would be dependent on the output of the above step:
22-
# steps.release.outputs.release_created
21+
# Example: chore(main): release 5.7.0 [skip-ci]
22+
# ${scope} - parenthesis included, base branch name
23+
pull-request-title-pattern: 'chore${scope}: release ${version} [skip-ci]'
24+
pull-request-header: 'Please run history action before releasing to generate release highlights'
25+
changelog-path: HISTORY.md
26+
default-branch: main
27+
28+
# If release-please created a release, publish to npm
29+
- if: ${{ steps.release.outputs.release_created }}
30+
uses: actions/checkout@v3
31+
- if: ${{ steps.release.outputs.release_created }}
32+
name: actions/setup
33+
uses: ./.github/actions/setup
34+
- if: ${{ steps.release.outputs.release_created }}
35+
run: npm publish --provenance
36+
env:
37+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.github/workflows/release_notes.yml

+48
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: release_notes
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
releasePr:
7+
description: 'Enter release PR number'
8+
required: true
9+
type: number
10+
11+
jobs:
12+
release_notes:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v3
16+
17+
- name: actions/setup
18+
uses: ./.github/actions/setup
19+
20+
# See: https://github.com/googleapis/release-please/issues/1274
21+
22+
# Get the PRs that are in this release
23+
# Outputs a list of comma seperated PR numbers, parsed from HISTORY.md
24+
- id: pr_list
25+
run: node .github/scripts/pr_list.mjs
26+
env:
27+
GITHUB_TOKEN: ${{ github.token }}
28+
29+
# From the list of PRs, gather the highlight sections of the PR body
30+
# output JSON with "highlights" key (to preserve newlines)
31+
- id: highlights
32+
run: node .github/scripts/highlights.mjs
33+
env:
34+
GITHUB_TOKEN: ${{ github.token }}
35+
PR_LIST: ${{ steps.pr_list.outputs.pr_list }}
36+
37+
# The combined output is available
38+
- id: release_notes
39+
run: node .github/scripts/release_notes.mjs
40+
env:
41+
GITHUB_TOKEN: ${{ github.token }}
42+
HIGHLIGHTS: ${{ steps.highlights.outputs.highlights }}
43+
44+
# Update the release PR body
45+
- run: gh pr edit ${{ inputs.releasePr }} --body-file ${{ steps.release_notes.outputs.release_notes_path }}
46+
shell: bash
47+
env:
48+
GITHUB_TOKEN: ${{ github.token }}

etc/check-remote.sh

-6
This file was deleted.

0 commit comments

Comments
 (0)