diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index 1a51a2fd..87aa7e77 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -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 `
` so the page stays diff --git a/.github/scripts/release-notes.js b/.github/scripts/release-notes.js new file mode 100644 index 00000000..7462662b --- /dev/null +++ b/.github/scripts/release-notes.js @@ -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*\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) +} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index be082145..27c2068a 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -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, @@ -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 @@ -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: @@ -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