mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 17:34:23 +00:00
ci: publish contributor credits in GitHub release notes (#1787)
release-please stores the release notes in the Release PR body when it opens that PR, and builds the GitHub Release from that stored body at merge time. The contributor credit step runs afterwards and only rewrites CHANGELOG.md on the branch, so credits reached the changelog but never the published notes. Everything that reads release bodies, including the releases page and the website changelog, showed uncredited text. Add a step that re-publishes the notes from CHANGELOG.md after a release is created. The two are otherwise byte-identical, so the edit is a no-op when there is nothing to credit. It runs before the credit step on purpose: a single run can both publish a release and open the next Release PR, and the credit step checks out that new branch, which would leave the wrong CHANGELOG.md in the working tree.
This commit is contained in:
@@ -246,7 +246,11 @@ jobs:
|
||||
args: .github/workflows/release-please.yml .github/workflows/ci.yml
|
||||
|
||||
- name: Syntax check
|
||||
run: node --check scripts/credit-changelog-contributors.mjs
|
||||
run: |
|
||||
node --check scripts/credit-changelog-contributors.mjs
|
||||
node --check scripts/release-notes-from-changelog.mjs
|
||||
|
||||
- name: Unit tests
|
||||
run: node --test scripts/credit-changelog-contributors.test.mjs
|
||||
run: |
|
||||
node --test scripts/credit-changelog-contributors.test.mjs
|
||||
node --test scripts/release-notes-from-changelog.test.mjs
|
||||
|
||||
@@ -58,6 +58,21 @@ jobs:
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
|
||||
# release-please stores the release notes in the release PR body when it
|
||||
# opens that PR, and builds the GitHub Release from that stored body at
|
||||
# merge time. The credit step below runs afterwards and only rewrites
|
||||
# CHANGELOG.md on the branch, so credits never reach the published notes.
|
||||
# Re-publish them from CHANGELOG.md, which by now carries the credits.
|
||||
# Runs before the credit step, which checks out the next release branch
|
||||
# and would otherwise leave the wrong CHANGELOG.md in the working tree.
|
||||
- name: Sync published release notes with credited changelog
|
||||
if: ${{ steps.release.outputs.releases_created == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
TAG="$(node scripts/release-notes-from-changelog.mjs "$RUNNER_TEMP/release-notes.md")"
|
||||
gh release edit "$TAG" --notes-file "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
- name: Credit external contributors in changelog
|
||||
if: ${{ steps.release.outputs.prs_created == 'true' }}
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Emits the newest CHANGELOG.md version section so a published GitHub Release
|
||||
* can be re-synced from it.
|
||||
*
|
||||
* release-please writes the release notes into the release pull request body
|
||||
* when it opens that PR, and builds the GitHub Release from that stored body
|
||||
* at merge time. The contributor credit pass runs after the PR body is
|
||||
* written and only rewrites CHANGELOG.md on the branch, so credits reach the
|
||||
* repository changelog but never the published release notes. Anything
|
||||
* reading release bodies (the releases page, the website changelog) therefore
|
||||
* shows uncredited text.
|
||||
*
|
||||
* Re-publishing the notes from CHANGELOG.md closes that gap. The two are
|
||||
* otherwise byte-identical, so this is idempotent when there is nothing to
|
||||
* credit.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/release-notes-from-changelog.mjs <notes-output-path>
|
||||
*
|
||||
* Writes the section body to <notes-output-path> and prints the matching tag
|
||||
* (e.g. "v0.97.0") to stdout, so the caller can pair the two:
|
||||
*
|
||||
* TAG=$(node scripts/release-notes-from-changelog.mjs notes.md)
|
||||
* gh release edit "$TAG" --notes-file notes.md
|
||||
*
|
||||
* Exits nonzero without writing if the newest section cannot be located or
|
||||
* its heading carries no parsable version.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { parseVersionSection } from './credit-changelog-contributors.mjs'
|
||||
|
||||
/** Read the version out of a "## [1.2.3](compare-url) (date)" heading. */
|
||||
export function versionFromHeading(sectionText) {
|
||||
const match = sectionText.match(/^##\s+\[([\d.]+)]/)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
const notesPath = argv[2]
|
||||
if (!notesPath) {
|
||||
console.error('Usage: release-notes-from-changelog.mjs <notes-output-path>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const changelogPath = resolve(process.cwd(), 'CHANGELOG.md')
|
||||
let text
|
||||
try {
|
||||
text = readFileSync(changelogPath, 'utf-8')
|
||||
} catch (err) {
|
||||
console.error(`Cannot read CHANGELOG.md: ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const section = parseVersionSection(text)
|
||||
if (!section) {
|
||||
console.error('No version heading found in CHANGELOG.md')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const notes = text.slice(section.start, section.end).trim()
|
||||
const version = versionFromHeading(notes)
|
||||
if (!version) {
|
||||
console.error('Newest CHANGELOG.md section has no parsable version heading')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(process.cwd(), notesPath), `${notes}\n`, 'utf-8')
|
||||
process.stdout.write(`v${version}\n`)
|
||||
}
|
||||
|
||||
const runningDirectly =
|
||||
process.argv[1] &&
|
||||
(process.argv[1].endsWith('release-notes-from-changelog.mjs') ||
|
||||
process.argv[1].endsWith('release-notes-from-changelog'))
|
||||
|
||||
if (runningDirectly) {
|
||||
main(process.argv)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for scripts/release-notes-from-changelog.mjs
|
||||
*
|
||||
* Run: node --test scripts/release-notes-from-changelog.test.mjs
|
||||
*
|
||||
* Fixtures are inline strings, except the end-to-end cases which write a
|
||||
* CHANGELOG.md into a temp directory and run main() with cwd pointed at it.
|
||||
* main() is never invoked on import because the CLI guard fires.
|
||||
*/
|
||||
|
||||
import { describe, it, afterEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { versionFromHeading, main } from './release-notes-from-changelog.mjs'
|
||||
|
||||
const PREAMBLE = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'All notable changes are documented here.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
function section(version, date, bullets) {
|
||||
return [
|
||||
`## [${version}](https://github.com/Studio-Saelix/sencho/compare/v0.0.0...v${version}) (${date})`,
|
||||
'',
|
||||
'',
|
||||
'### Added',
|
||||
'',
|
||||
...bullets,
|
||||
'',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// versionFromHeading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('versionFromHeading', () => {
|
||||
it('reads the version out of a release-please heading', () => {
|
||||
const heading =
|
||||
'## [0.97.0](https://github.com/Studio-Saelix/sencho/compare/v0.96.0...v0.97.0) (2026-08-06)'
|
||||
assert.equal(versionFromHeading(heading), '0.97.0')
|
||||
})
|
||||
|
||||
it('reads a patch version', () => {
|
||||
const heading =
|
||||
'## [0.94.1](https://github.com/Studio-Saelix/sencho/compare/v0.94.0...v0.94.1) (2026-07-06)'
|
||||
assert.equal(versionFromHeading(heading), '0.94.1')
|
||||
})
|
||||
|
||||
it('returns null when the heading is not a version heading', () => {
|
||||
assert.equal(versionFromHeading('### Added\n\n* something'), null)
|
||||
})
|
||||
|
||||
it('returns null when the version heading is not at the start', () => {
|
||||
assert.equal(versionFromHeading('intro\n## [0.97.0](url) (2026-08-06)'), null)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('main', () => {
|
||||
let dir
|
||||
let cwd
|
||||
let stdout
|
||||
let written
|
||||
|
||||
function run(changelog, notesName = 'notes.md') {
|
||||
dir = mkdtempSync(join(tmpdir(), 'release-notes-'))
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), changelog, 'utf-8')
|
||||
cwd = process.cwd()
|
||||
process.chdir(dir)
|
||||
|
||||
written = ''
|
||||
stdout = process.stdout.write
|
||||
process.stdout.write = (chunk) => {
|
||||
written += chunk
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
main(['node', 'release-notes-from-changelog.mjs', notesName])
|
||||
} finally {
|
||||
process.stdout.write = stdout
|
||||
}
|
||||
|
||||
return {
|
||||
tag: written.trim(),
|
||||
notes: readFileSync(join(dir, notesName), 'utf-8'),
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (cwd) process.chdir(cwd)
|
||||
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||
dir = undefined
|
||||
cwd = undefined
|
||||
})
|
||||
|
||||
it('emits the newest section and its tag', () => {
|
||||
const changelog =
|
||||
PREAMBLE +
|
||||
section('0.97.0', '2026-08-06', ['* newest change']) +
|
||||
section('0.96.0', '2026-07-26', ['* older change'])
|
||||
|
||||
const { tag, notes } = run(changelog)
|
||||
|
||||
assert.equal(tag, 'v0.97.0')
|
||||
assert.match(notes, /^## \[0\.97\.0]/)
|
||||
assert.match(notes, /newest change/)
|
||||
assert.doesNotMatch(notes, /older change/)
|
||||
})
|
||||
|
||||
it('preserves inline contributor credits verbatim', () => {
|
||||
const changelog =
|
||||
PREAMBLE +
|
||||
section('0.97.0', '2026-08-06', [
|
||||
'* account for VM memory ballooning ([#1750](https://github.com/Studio-Saelix/sencho/issues/1750)), thanks @Crosis47',
|
||||
'* auto-update stacks by Stack Label ([#1717](https://github.com/Studio-Saelix/sencho/issues/1717)), thanks @Sn00zEZA',
|
||||
])
|
||||
|
||||
const { notes } = run(changelog)
|
||||
|
||||
assert.match(notes, /, thanks @Crosis47$/m)
|
||||
assert.match(notes, /, thanks @Sn00zEZA$/m)
|
||||
})
|
||||
|
||||
it('excludes the Keep a Changelog preamble', () => {
|
||||
const changelog = PREAMBLE + section('0.97.0', '2026-08-06', ['* a change'])
|
||||
|
||||
const { notes } = run(changelog)
|
||||
|
||||
assert.doesNotMatch(notes, /All notable changes/)
|
||||
assert.doesNotMatch(notes, /^# Changelog/m)
|
||||
})
|
||||
|
||||
it('handles a changelog with a single version section', () => {
|
||||
const changelog = PREAMBLE + section('0.1.0', '2026-01-01', ['* first release'])
|
||||
|
||||
const { tag, notes } = run(changelog)
|
||||
|
||||
assert.equal(tag, 'v0.1.0')
|
||||
assert.match(notes, /first release/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user