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
12 changes: 9 additions & 3 deletions .github/RELEASE_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Release notes template

Copy everything below the line into a new GitHub release and fill in the two
`EDIT ME` sections. Everything else is standing content that stays the same
release to release, so it should not need retyping or hunting down again.
The GitHub release is created from this file automatically when the
`NPM Package Validation and Publish` workflow publishes (not on a dry run, which
shows a preview in the job summary instead). `.github/scripts/release-notes.js`
keeps everything below the line and fills the two `EDIT ME` sections: "What's
changed" from the PR titles merged since the previous tag (`feat:` / `fix:` /
`perf:`; chore, ci, test, docs and dependency bumps are left out), and
"Upgrading" from the workflow's `upgrading` input, dropping it when that is
empty. Preview locally with `node .github/scripts/release-notes.js --version X.Y.Z`.
Everything else is standing content that stays the same release to release.

Keep the per-release part at the top: that is the only bit a returning reader
needs. The standing sections are collapsed with `<details>` so the page stays
Expand Down
106 changes: 106 additions & 0 deletions .github/scripts/release-notes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Build the GitHub release body for a version from .github/RELEASE_TEMPLATE.md.
//
// The template's standing content (everything below its first `---` line) is
// kept as is. The two per-release sections are filled in here:
// - "What's changed" from the squash merged PRs since the previous tag, taken
// from first parent commit subjects, which are the PR titles ending "(#123)".
// Grouped by conventional commit prefix; chore / ci / build / test / docs /
// style / refactor and dependency bumps are left out, as the template asks.
// - "Upgrading" is dropped unless upgrade notes are given.
//
// node .github/scripts/release-notes.js --version 5.5.2 [--previous v5.5.1]
// [--upgrading "text"] [--out notes.md]
//
// Needs the tags in the checkout (actions/checkout with fetch-depth: 0).

const { execFileSync } = require('child_process')
const fs = require('fs')
const path = require('path')

const root = path.join(__dirname, '..', '..')
const repoUrl = 'https://github.com/TimelordUK/node-sqlserver-v8'

function flag (name, fallback) {
const i = process.argv.indexOf(name)
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback
}

function git (...args) {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim()
}

const version = flag('--version', require(path.join(root, 'package.json')).version)
const thisTag = `v${version}`
const upgrading = (flag('--upgrading', '') || '').trim()
const out = flag('--out', null)

// the newest v* tag behind HEAD that is not this release's own tag, so a re-run
// after the tag exists still compares against the release before it.
function previousTag () {
const given = flag('--previous', null)
if (given) return given
const tags = git('tag', '--merged', 'HEAD', '--list', 'v*', '--sort=-v:refname')
.split('\n').filter(t => t && t !== thisTag)
if (!tags.length) throw new Error('no previous v* tag found - pass --previous')
return tags[0]
}

const groups = [
{ title: 'Features', types: ['feat'] },
{ title: 'Fixes', types: ['fix'] },
{ title: 'Performance', types: ['perf'] }
]
const skipped = ['chore', 'ci', 'build', 'test', 'docs', 'style', 'refactor', 'revert']

function changes (prev) {
const subjects = git('log', '--first-parent', '--format=%s', `${prev}..HEAD`).split('\n').filter(Boolean)
const byGroup = new Map(groups.map(g => [g.title, []]))
const other = []
for (const subject of subjects) {
const m = subject.match(/^(\w+)(\([^)]*\))?(!)?:\s*(.+)$/)
const type = m ? m[1].toLowerCase() : null
if (type && skipped.includes(type)) continue
if (/\bbump\b/i.test(subject) && /\bdeps?\b|dependabot/i.test(subject)) continue
// a version bump commit, e.g. "5.5.2"
if (/^v?\d+\.\d+\.\d+$/.test(subject)) continue
const text = (m ? m[4] : subject).replace(/\(#(\d+)\)\s*$/, `([#$1](${repoUrl}/pull/$1))`)
const breaking = m && m[3] ? '**Breaking:** ' : ''
const group = groups.find(g => g.types.includes(type))
;(group ? byGroup.get(group.title) : other).push(`- ${breaking}${text}`)
}
const sections = []
for (const g of groups) {
const lines = byGroup.get(g.title)
if (lines.length) sections.push(`### ${g.title}\n\n${lines.join('\n')}`)
}
if (other.length) sections.push(`### Other\n\n${other.join('\n')}`)
return sections.length ? sections.join('\n\n') : '- Maintenance release: dependency and build updates only.'
}

function render () {
const prev = previousTag()
const template = fs.readFileSync(path.join(root, '.github', 'RELEASE_TEMPLATE.md'), 'utf8')
// drop the instructions above the first horizontal rule
const cut = template.search(/^---\s*$/m)
if (cut < 0) throw new Error('RELEASE_TEMPLATE.md has no --- line separating the instructions')
let body = template.slice(cut).replace(/^---\s*\n+/, '')

body = body.replace(/(## What's changed\s*\n)[\s\S]*?(\*\*Full Changelog\*\*)/,
(_, head, tail) => `${head}\n${changes(prev)}\n\n${tail}`)
body = body.replace(/vPREV\.\.\.vTHIS/g, `${prev}...${thisTag}`)

// the Upgrading section runs up to the next horizontal rule
body = body.replace(/## Upgrading\s*\n[\s\S]*?(?=^---\s*$)/m,
upgrading ? `## Upgrading\n\n${upgrading}\n\n` : '')
// any remaining EDIT ME comments are for the person editing, not the reader
body = body.replace(/<!--[\s\S]*?-->\s*\n?/g, '')
return body.replace(/\n{3,}/g, '\n\n').trim() + '\n'
}

const notes = render()
if (out) {
fs.writeFileSync(out, notes)
console.error(`wrote ${out}`)
} else {
process.stdout.write(notes)
}
83 changes: 83 additions & 0 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ on:
required: true
default: 'latest'
type: string
upgrading:
description: 'Upgrading notes for the GitHub release (leave empty if a plain npm install is all it takes)'
required: false
default: ''
type: string

jobs:
# Reuses the build workflow rather than duplicating the four platform jobs,
Expand All @@ -38,6 +43,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# tags, so the release notes preview can find the previous release
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
Expand Down Expand Up @@ -131,6 +139,20 @@ jobs:
echo "files=$COUNT" >> $GITHUB_OUTPUT
echo "Package size: $SIZE across $COUNT files"

# The same notes the github-release job will publish, shown on every run so a
# dry run doubles as a preview of the release page.
- name: Preview release notes
env:
VERSION: ${{ steps.package.outputs.version }}
UPGRADING: ${{ github.event.inputs.upgrading }}
run: |
node .github/scripts/release-notes.js --version "$VERSION" --upgrading "$UPGRADING" --out release-notes.md
{
echo "## Release notes preview (v$VERSION)"
echo ""
cat release-notes.md
} >> $GITHUB_STEP_SUMMARY

- name: Publish to NPM (if not dry run)
if: ${{ github.event.inputs.dry_run == 'false' }}
env:
Expand Down Expand Up @@ -176,3 +198,64 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Not published.**" >> $GITHUB_STEP_SUMMARY
fi

# Tags the published commit and creates the GitHub release from
# .github/RELEASE_TEMPLATE.md, with "What's changed" filled from the PRs merged
# since the previous release. Its own job so that, if it fails after npm has
# published, "Re-run failed jobs" retries just this part; it updates an existing
# release rather than failing on one.
github-release:
name: GitHub release
needs: [build, validate-and-publish]
if: ${{ github.event.inputs.dry_run == 'false' }}
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '24'

- name: Download the packed tarball
uses: actions/download-artifact@v8
with:
name: npm-tarball

- name: Create or update the release
env:
GH_TOKEN: ${{ github.token }}
UPGRADING: ${{ github.event.inputs.upgrading }}
NPM_TAG: ${{ github.event.inputs.tag }}
TARBALL: ${{ needs.build.outputs.tarball }}
run: |
VERSION=$(node -p "require('./package.json').version")
TAG="v$VERSION"
node .github/scripts/release-notes.js --version "$VERSION" --upgrading "$UPGRADING" --out release-notes.md

# only a release published to npm 'latest' becomes the latest GitHub release;
# next / beta publishes are marked pre-release.
if [ "$NPM_TAG" = "latest" ]; then
FLAGS="--latest"
else
FLAGS="--prerelease --latest=false"
fi

if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG exists, updating its notes and tarball"
gh release edit "$TAG" --title "Release $TAG" --notes-file release-notes.md $FLAGS
gh release upload "$TAG" "$TARBALL" --clobber
else
gh release create "$TAG" "$TARBALL" --target "$GITHUB_SHA" \
--title "Release $TAG" --notes-file release-notes.md $FLAGS
fi

echo "## GitHub release" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "$(gh release view "$TAG" --json url --jq .url)" >> $GITHUB_STEP_SUMMARY
Loading