Skip to content

Commit

Permalink
chore(NODE-5388): add release automation
Browse files Browse the repository at this point in the history
  • Loading branch information
nbbeeken committed May 20, 2024
1 parent a3a08c6 commit 8903398
Show file tree
Hide file tree
Showing 10 changed files with 531 additions and 3,595 deletions.
76 changes: 76 additions & 0 deletions .github/scripts/highlights.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @ts-check
import * as process from 'node:process';
import { output } from './util.mjs';

const {
GITHUB_TOKEN = '',
PR_LIST = '',
REPOSITORY = ''
} = process.env;
if (GITHUB_TOKEN === '') throw new Error('GITHUB_TOKEN cannot be empty');
if (REPOSITORY === '') throw new Error('REPOSITORY cannot be empty')

const API_REQ_INFO = {
headers: {
Accept: 'application/vnd.github.v3+json',
'X-GitHub-Api-Version': '2022-11-28',
Authorization: `Bearer ${GITHUB_TOKEN}`
}
}

const prs = PR_LIST.split(',').map(pr => {
const prNum = Number(pr);
if (Number.isNaN(prNum))
throw Error(`expected PR number list: ${PR_LIST}, offending entry: ${pr}`);
return prNum;
});

/** @param {number} pull_number */
async function getPullRequestContent(pull_number) {
const startIndicator = 'RELEASE_HIGHLIGHT_START -->';
const endIndicator = '<!-- RELEASE_HIGHLIGHT_END';

let body;
try {
const response = await fetch(new URL(`https://api.github.com/repos/${REPOSITORY}/pulls/${pull_number}`), API_REQ_INFO);
if (!response.ok) throw new Error(await response.text());
const pr = await response.json();
body = pr.body;
} catch (error) {
console.log(`Could not get PR ${pull_number}, skipping. ${error.status}`);
return '';
}

if (body == null || !(body.includes(startIndicator) && body.includes(endIndicator))) {
console.log(`PR #${pull_number} has no highlight`);
return '';
}

const start = body.indexOf('### ', body.indexOf(startIndicator));
const end = body.indexOf(endIndicator);
const highlightSection = body.slice(start, end).trim();

console.log(`PR #${pull_number} has a highlight ${highlightSection.length} characters long`);
return highlightSection;
}

/** @param {number[]} prs */
async function pullRequestHighlights(prs) {
const highlights = [];
for (const pr of prs) {
const content = await getPullRequestContent(pr);
highlights.push(content);
}
if (!highlights.length) return '';

highlights.unshift('## Release Notes\n\n');

const highlight = highlights.join('\n\n');
console.log(`Total highlight is ${highlight.length} characters long`);
return highlight;
}

console.log('List of PRs to collect highlights from:', prs);
const highlights = await pullRequestHighlights(prs);

await output('highlights', JSON.stringify({ highlights }));
32 changes: 32 additions & 0 deletions .github/scripts/pr_list.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// @ts-check
import * as url from 'node:url';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { getCurrentHistorySection, output } from './util.mjs';

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const historyFilePath = path.join(__dirname, '..', '..', 'HISTORY.md');

/**
* @param {string} history
* @returns {string[]}
*/
function parsePRList(history) {
const prRegexp = /zstd\/issues\/(?<prNum>\d+)\)/iu;
return Array.from(
new Set(
history
.split('\n')
.map(line => prRegexp.exec(line)?.groups?.prNum ?? '')
.filter(prNum => prNum !== '')
)
);
}

const historyContents = await fs.readFile(historyFilePath, { encoding: 'utf8' });

const currentHistorySection = getCurrentHistorySection(historyContents);

const prs = parsePRList(currentHistorySection);

await output('pr_list', prs.join(','));
51 changes: 51 additions & 0 deletions .github/scripts/release_notes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//@ts-check
import * as url from 'node:url';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as process from 'node:process';
import * as semver from 'semver';
import { getCurrentHistorySection, output } from './util.mjs';

const { HIGHLIGHTS = '' } = process.env;
if (HIGHLIGHTS === '') throw new Error('HIGHLIGHTS cannot be empty');

const { highlights } = JSON.parse(HIGHLIGHTS);

const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
const historyFilePath = path.join(__dirname, '..', '..', 'HISTORY.md');
const packageFilePath = path.join(__dirname, '..', '..', 'package.json');

const historyContents = await fs.readFile(historyFilePath, { encoding: 'utf8' });

const currentHistorySection = getCurrentHistorySection(historyContents);

const version = semver.parse(
JSON.parse(await fs.readFile(packageFilePath, { encoding: 'utf8' })).version
);
if (version == null) throw new Error(`could not create semver from package.json`);

console.log('\n\n--- history entry ---\n\n', currentHistorySection);

const currentHistorySectionLines = currentHistorySection.split('\n');
const header = currentHistorySectionLines[0];
const history = currentHistorySectionLines.slice(1).join('\n').trim();

const releaseNotes = `${header}
The MongoDB Node.js team is pleased to announce version ${version.version} of the \`@mongodb-js/zstd\` package!
${highlights}
${history}
We invite you to try the \`@mongodb-js/zstd\` library immediately, and report any issues to the [NODE project](https://jira.mongodb.org/projects/NODE).
`;

const releaseNotesPath = path.join(process.cwd(), 'release_notes.md');

await fs.writeFile(
releaseNotesPath,
`:seedling: A new release!\n---\n${releaseNotes}\n---\n`,
{ encoding:'utf8' }
);

await output('release_notes_path', releaseNotesPath)
47 changes: 47 additions & 0 deletions .github/scripts/util.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// @ts-check
import * as process from 'node:process';
import * as fs from 'node:fs/promises';

export async function output(key, value) {
const { GITHUB_OUTPUT = '' } = process.env;
const output = `${key}=${value}\n`;
console.log('outputting:', output);

if (GITHUB_OUTPUT.length === 0) {
// This is always defined in Github actions, and if it is not for some reason, tasks that follow will fail.
// For local testing it's convenient to see what scripts would output without requiring the variable to be defined.
console.log('GITHUB_OUTPUT not defined, printing only');
return;
}

const outputFile = await fs.open(GITHUB_OUTPUT, 'a');
await outputFile.appendFile(output, { encoding: 'utf8' });
await outputFile.close();
}

/**
* @param {string} historyContents
* @returns {string}
*/
export function getCurrentHistorySection(historyContents) {
/** Markdown version header */
const VERSION_HEADER = /^#.+\(\d{4}-\d{2}-\d{2}\)$/g;

const historyLines = historyContents.split('\n');

// Search for the line with the first version header, this will be the one we're releasing
const headerLineIndex = historyLines.findIndex(line => VERSION_HEADER.test(line));
if (headerLineIndex < 0) throw new Error('Could not find any version header');

console.log('Found markdown header current release', headerLineIndex, ':', historyLines[headerLineIndex]);

// Search lines starting after the first header, and add back the offset we sliced at
const nextHeaderLineIndex = historyLines
.slice(headerLineIndex + 1)
.findIndex(line => VERSION_HEADER.test(line)) + headerLineIndex + 1;
if (nextHeaderLineIndex < 0) throw new Error(`Could not find previous version header, searched ${headerLineIndex + 1}`);

console.log('Found markdown header previous release', nextHeaderLineIndex, ':', historyLines[nextHeaderLineIndex]);

return historyLines.slice(headerLineIndex, nextHeaderLineIndex).join('\n');
}
54 changes: 23 additions & 31 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ env:
DEBUG: napi:*
APP_NAME: zstd
MACOSX_DEPLOYMENT_TARGET: '10.13'
'on':
on:
push:
branches:
- main
Expand All @@ -18,7 +18,6 @@ env:
pull_request: null
jobs:
build:
if: "!contains(github.event.head_commit.message, 'skip ci')"
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -346,44 +345,37 @@ jobs:
- test-linux-aarch64-gnu-binding
- test-linux-aarch64-musl-binding
steps:
- uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16
check-latest: true
cache: npm
- name: Cache NPM dependencies
uses: actions/cache@v3
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
path: node_modules
key: npm-cache-ubuntu-latest-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-cache-
- name: Install dependencies
run: npm clean-install --ignore-scripts
node-version: 'lts/*'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'

- run: npm install -g npm@latest
shell: bash

- run: npm clean-install
shell: bash

- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts

- name: Move artifacts
run: npm run artifacts

- name: List packages
run: ls -R ./npm
shell: bash
- name: Publish
run: |
if git log -1 --pretty=%B | grep "^chore(release): [0-9]\+\.[0-9]\+\.[0-9]\+$";
then
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
npm publish --access public
elif git log -1 --pretty=%B | grep "^chore(release): [0-9]\+\.[0-9]\+\.[0-9]\+";
then
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
npm publish --tag next --access public
else
echo "Not a release, skipping publish"
fi

- id: release
uses: googleapis/release-please-action@v4

- if: ${{ steps.release.outputs.release_created }}
run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
80 changes: 80 additions & 0 deletions .github/workflows/release_notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: release_notes

on:
workflow_dispatch:
inputs:
releasePr:
description: 'Enter release PR number'
required: true
type: number
issue_comment:
types: [created]

permissions:
contents: write
pull-requests: write

jobs:
release_notes:
runs-on: ubuntu-latest
# Run only if dispatched or comment on a pull request
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && github.event.comment.body == 'run release_notes') }}
steps:
# Determine if the triggering_actor is allowed to run this action
# We only permit maintainers
# Not only is 'triggering_actor' common between the trigger events it will also change if someone re-runs an old job
- name: check if triggering_actor is allowed to generate notes
env:
GITHUB_TOKEN: ${{ github.token }}
COMMENTER: ${{ github.triggering_actor && github.triggering_actor || 'empty_triggering_actor' }}
API_ENDPOINT: /repos/${{ github.repository }}/collaborators?permission=maintain
shell: bash
run: |
if [ $COMMENTER = "empty_triggering_actor" ]; then exit 1; fi
set -o pipefail
if gh api "$API_ENDPOINT" --paginate --jq ".[].login" | grep -q "^$COMMENTER\$"; then
echo "$COMMENTER permitted to trigger notes!" && exit 0
else
echo "$COMMENTER not permitted to trigger notes" && exit 1
fi
# checkout the HEAD ref from prNumber
- uses: actions/checkout@v4
with:
ref: refs/pull/${{ github.event_name == 'issue_comment' && github.event.issue.number || inputs.releasePr }}/head


# Setup Node.js and npm install
- name: actions/setup
uses: ./.github/actions/setup

# See: https://github.com/googleapis/release-please/issues/1274

# Get the PRs that are in this release
# Outputs a list of comma seperated PR numbers, parsed from HISTORY.md
- id: pr_list
run: node .github/scripts/pr_list.mjs
env:
GITHUB_TOKEN: ${{ github.token }}

# From the list of PRs, gather the highlight sections of the PR body
# output JSON with "highlights" key (to preserve newlines)
- id: highlights
run: node .github/scripts/highlights.mjs
env:
GITHUB_TOKEN: ${{ github.token }}
PR_LIST: ${{ steps.pr_list.outputs.pr_list }}
REPOSITORY: ${{ github.repository }}

# The combined output is available
- id: release_notes
run: node .github/scripts/release_notes.mjs
env:
GITHUB_TOKEN: ${{ github.token }}
HIGHLIGHTS: ${{ steps.highlights.outputs.highlights }}

# Update the release PR body
- run: gh pr edit ${{ github.event_name == 'issue_comment' && github.event.issue.number || inputs.releasePr }} --body-file ${{ steps.release_notes.outputs.release_notes_path }}
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.2.0"
}
Loading

0 comments on commit 8903398

Please sign in to comment.