Skip to content

Commit eb4acfb

Browse files
committed
chore(NODE-5389): add release automation
1 parent 69ddcda commit eb4acfb

File tree

7 files changed

+336
-0
lines changed

7 files changed

+336
-0
lines changed

Diff for: .github/actions/setup/action.yml

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Setup
2+
description: 'Installs node, driver dependencies, and builds source'
3+
4+
runs:
5+
using: composite
6+
steps:
7+
- uses: actions/setup-node@v4
8+
with:
9+
node-version: 'lts/*'
10+
cache: 'npm'
11+
registry-url: 'https://registry.npmjs.org'
12+
- run: npm install -g npm@latest
13+
shell: bash
14+
- run: npm clean-install
15+
shell: bash

Diff for: .github/scripts/highlights.mjs

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

Diff for: .github/scripts/pr_list.mjs

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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+)\)/iu;
16+
return Array.from(
17+
new Set(
18+
history
19+
.split('\n')
20+
.map(line => prRegexp.exec(line)?.groups?.prNum ?? '')
21+
.filter(prNum => prNum !== '')
22+
)
23+
);
24+
}
25+
26+
const historyContents = await fs.readFile(historyFilePath, { encoding: 'utf8' });
27+
28+
const currentHistorySection = getCurrentHistorySection(historyContents);
29+
30+
const prs = parsePRList(currentHistorySection);
31+
32+
await output('pr_list', prs.join(','));

Diff for: .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)

Diff for: .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+
}

Diff for: .github/workflows/release.yml

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
on:
2+
push:
3+
branches: [main]
4+
workflow_dispatch: {}
5+
6+
permissions:
7+
contents: write
8+
pull-requests: write
9+
id-token: write
10+
11+
name: release
12+
13+
jobs:
14+
release-please:
15+
runs-on: ubuntu-latest
16+
steps:
17+
- id: release
18+
uses: googleapis/release-please-action@v4
19+
20+
# If release-please created a release, publish to npm
21+
- if: ${{ steps.release.outputs.release_created }}
22+
uses: actions/checkout@v4
23+
- if: ${{ steps.release.outputs.release_created }}
24+
name: actions/setup
25+
uses: ./.github/actions/setup
26+
- if: ${{ steps.release.outputs.release_created }}
27+
run: npm publish --provenance
28+
env:
29+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Diff for: .github/workflows/release_notes.yml

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
issue_comment:
11+
types: [created]
12+
13+
permissions:
14+
contents: write
15+
pull-requests: write
16+
17+
jobs:
18+
release_notes:
19+
runs-on: ubuntu-latest
20+
# Run only if dispatched or comment on a pull request
21+
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.body == 'run release_notes') }}
22+
steps:
23+
# Determine if the triggering_actor is allowed to run this action
24+
# We only permit maintainers
25+
# Not only is 'triggering_actor' common between the trigger events it will also change if someone re-runs an old job
26+
- name: check if triggering_actor is allowed to generate notes
27+
env:
28+
GITHUB_TOKEN: ${{ github.token }}
29+
COMMENTER: ${{ github.triggering_actor && github.triggering_actor || 'empty_triggering_actor' }}
30+
API_ENDPOINT: /repos/${{ github.repository }}/collaborators?permission=maintain
31+
shell: bash
32+
run: |
33+
if [ $COMMENTER = "empty_triggering_actor" ]; then exit 1; fi
34+
set -o pipefail
35+
if gh api "$API_ENDPOINT" --paginate --jq ".[].login" | grep -q "^$COMMENTER\$"; then
36+
echo "$COMMENTER permitted to trigger notes!" && exit 0
37+
else
38+
echo "$COMMENTER not permitted to trigger notes" && exit 1
39+
fi
40+
41+
# checkout the HEAD ref from prNumber
42+
- uses: actions/checkout@v4
43+
with:
44+
ref: refs/pull/${{ github.event_name == 'issue_comment' && github.event.issue.number || inputs.releasePr }}/head
45+
46+
47+
# Setup Node.js and npm install
48+
- name: actions/setup
49+
uses: ./.github/actions/setup
50+
51+
# See: https://github.com/googleapis/release-please/issues/1274
52+
53+
# Get the PRs that are in this release
54+
# Outputs a list of comma seperated PR numbers, parsed from HISTORY.md
55+
- id: pr_list
56+
run: node .github/scripts/pr_list.mjs
57+
env:
58+
GITHUB_TOKEN: ${{ github.token }}
59+
60+
# From the list of PRs, gather the highlight sections of the PR body
61+
# output JSON with "highlights" key (to preserve newlines)
62+
- id: highlights
63+
run: node .github/scripts/highlights.mjs
64+
env:
65+
GITHUB_TOKEN: ${{ github.token }}
66+
PR_LIST: ${{ steps.pr_list.outputs.pr_list }}
67+
REPOSITORY: ${{ github.repository }}
68+
69+
# The combined output is available
70+
- id: release_notes
71+
run: node .github/scripts/release_notes.mjs
72+
env:
73+
GITHUB_TOKEN: ${{ github.token }}
74+
HIGHLIGHTS: ${{ steps.highlights.outputs.highlights }}
75+
76+
# Update the release PR body
77+
- run: gh pr edit ${{ github.event_name == 'issue_comment' && github.event.issue.number || inputs.releasePr }} --body-file ${{ steps.release_notes.outputs.release_notes_path }}
78+
shell: bash
79+
env:
80+
GITHUB_TOKEN: ${{ github.token }}

0 commit comments

Comments
 (0)