ci: credit changelog contributors inline and harden empty pr fromJSON (#1625)

Release publish runs leave steps.release.outputs.pr empty, so fromJSON crashed
even when the credit step was gated off. Credit external issue openers with an
inline thanks suffix on logical changelog bullets instead of a Thanks section.
Keep fatal API lookup logs status-only.
This commit is contained in:
Anso
2026-07-13 15:49:04 -04:00
committed by GitHub
parent 23cd9d41cc
commit 21633b87e3
3 changed files with 526 additions and 376 deletions
+3 -1
View File
@@ -63,7 +63,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
# fromJSON must tolerate empty pr: env is evaluated even when if: skips
# (publish runs leave pr unset). Empty object yields empty RELEASE_BRANCH.
RELEASE_BRANCH: ${{ fromJSON(steps.release.outputs.pr || '{}').headBranchName || '' }}
MAINTAINER_LOGINS: ""
run: |
if [ -z "$RELEASE_BRANCH" ]; then
+231 -146
View File
@@ -1,13 +1,32 @@
#!/usr/bin/env node
/**
* Post-processes CHANGELOG.md on a release-please PR branch, adding a
* "### Thanks" section that credits external, non-bot contributors whose
* issue or PR is referenced in the latest release block.
* Post-processes CHANGELOG.md on a release-please PR branch, appending an
* inline ", thanks @login" suffix to each logical top-level changelog bullet
* whose same-repo issue or PR references were opened by external, non-bot
* contributors. The managed suffix is written on the last line of the logical
* entry (the top-level bullet when single-line, otherwise the last indented
* non-list continuation).
*
* Logical entries: a top-level "* " / "- " bullet plus immediately following
* indented non-list continuation lines. Indented nested list items are copied
* unchanged and are not association or suffix targets. A blank line, heading,
* nested list item, or another top-level bullet terminates the entry.
*
* Generated suffix grammar (machine-managed only):
* , thanks @login
* , thanks @alice, @bob
* Exact manually authored suffixes of this form are indistinguishable and may
* be stripped or regenerated. Other prose containing "thanks" is preserved.
*
* Credits are regenerated from current API classifications each run. Stale
* suffixes are removed when no mapped external contributors remain. Legacy
* "### Thanks" blocks are removed from the latest version section only.
*
* Atomic: builds the complete result in memory before touching the file.
* If any retryable lookup fails, exits nonzero without writing so the
* existing changelog (including any prior Thanks section) is preserved.
* existing changelog (including any prior credits) is preserved.
* (Not a filesystem-level atomic rename.)
*
* Inputs (env):
* GITHUB_TOKEN (required) GitHub API token
@@ -40,6 +59,15 @@ const RETRY_WAITS_MS = [1000, 2000]
const MAX_WAIT_PER_RETRY_MS = 15_000
const TOTAL_RETRY_CAP_MS = 40_000
/** Exact trailing generated suffix: ", thanks @a" or ", thanks @a, @b" */
const INLINE_THANKS_RE = /, thanks @[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?(?:, @[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)*\s*$/
const TOP_LEVEL_BULLET_RE = /^[*-] /
const NESTED_LIST_RE = /^\s+[*-] /
const HEADING_RE = /^#{1,3} /
const THANKS_HEADING_RE = /^###\s+Thanks\s*$/
const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
// ---------------------------------------------------------------------------
// GitHub API
// ---------------------------------------------------------------------------
@@ -62,8 +90,10 @@ async function fetchIssue(repo, number, token) {
const err = new Error(`GitHub API ${res.status} for #${number}`)
err.status = res.status
err.headers = res.headers
// Attach body text for diagnostics but do not leak into error messages
try { err.body = await res.text() } catch { /* ignore */ }
err.body = await res.text().catch((readErr) => {
console.warn(`Could not read error body for #${number}: ${readErr.message}`)
return undefined
})
throw err
}
return res.json()
@@ -71,13 +101,10 @@ async function fetchIssue(repo, number, token) {
/** True when the HTTP status is a retryable server/rate-limit error. */
export function shouldRetry(status, headers) {
if (status === 429) return true
if (status === 429 || status === 502 || status === 503 || status === 504) return true
if (status === 403 && headers) {
const remaining = headers.get('x-ratelimit-remaining')
const retryAfter = headers.get('retry-after')
if (remaining === '0' || retryAfter !== null) return true
return headers.get('x-ratelimit-remaining') === '0' || headers.get('retry-after') !== null
}
if (status === 502 || status === 503 || status === 504) return true
return false
}
@@ -160,10 +187,53 @@ export function classifyAuthor(response) {
}
// ---------------------------------------------------------------------------
// Changelog parsing
// Line splitting (LF / CRLF preserving)
// ---------------------------------------------------------------------------
const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
/**
* Split text into lines preserving each line's trailing newline (or none on EOF).
* @returns {{ content: string, newline: string, raw: string }[]}
* content = line without trailing newline; newline = '\n' | '\r\n' | '\r' | ''
*/
export function splitLines(text) {
const result = []
let i = 0
while (i < text.length) {
let j = i
while (j < text.length && text[j] !== '\n' && text[j] !== '\r') j++
const content = text.slice(i, j)
let newline = ''
if (j < text.length) {
if (text[j] === '\r' && text[j + 1] === '\n') {
newline = '\r\n'
j += 2
} else if (text[j] === '\r') {
newline = '\r'
j += 1
} else {
newline = '\n'
j += 1
}
}
result.push({ content, newline, raw: content + newline })
i = j
}
return result
}
export function joinLines(lines) {
return lines.map((l) => l.raw).join('')
}
/** Rebuild a split line after changing its content (preserves newline). */
function withContent(line, content) {
if (content === line.content) return line
return { content, newline: line.newline, raw: content + line.newline }
}
// ---------------------------------------------------------------------------
// Changelog parsing
// ---------------------------------------------------------------------------
/**
* Locate the first version heading and return its character-index range.
@@ -171,35 +241,28 @@ const VERSION_HEADING_RE = /^##\s+\[[\d.]+]/
* @returns {{ start: number, end: number } | null}
*/
export function parseVersionSection(text) {
const lines = text.split('\n')
const lines = splitLines(text)
let headingLine = -1
for (let i = 0; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
if (VERSION_HEADING_RE.test(lines[i].content)) {
headingLine = i
break
}
}
if (headingLine === -1) return null
// Start: character index of the heading line in the original text
const start = text.indexOf(lines[headingLine])
const start = text.indexOf(lines[headingLine].raw)
// Find end line: next version heading or EOF
let endLine = lines.length
for (let i = headingLine + 1; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
if (VERSION_HEADING_RE.test(lines[i].content)) {
endLine = i
break
}
}
// End: if EOF, text.length; otherwise start of the next heading
let end
if (endLine === lines.length) {
end = text.length
} else {
end = text.indexOf(lines[endLine], start)
}
const end =
endLine === lines.length ? text.length : text.indexOf(lines[endLine].raw, start)
return { start, end }
}
@@ -212,32 +275,15 @@ export function parseVersionSection(text) {
export function extractReferences(text, owner, repo) {
const numbers = new Set()
// Match markdown links: [text](url)
// Patterns we capture:
// ([#N](https://github.com/OWNER/REPO/issues/N))
// ([#N](https://github.com/OWNER/REPO/pull/N))
// closes [#N](https://github.com/OWNER/REPO/issues/N)
// fixes [#N](...), resolves [#N](...)
const linkRe = /\[#(\d+)]\(https:\/\/github\.com\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)\)/gi
let match
while ((match = linkRe.exec(text)) !== null) {
const linkOwner = match[2]
const linkRepo = match[3]
const pathType = match[4]
const linkNumber = Number(match[5])
const capturedNumber = Number(match[1])
// The captured #N must match the URL number
if (capturedNumber !== linkNumber) continue
// Must be same repo (case-insensitive)
const [, capturedText, linkOwner, linkRepo, , linkPathNumber] = match
const linkNumber = Number(linkPathNumber)
if (Number(capturedText) !== linkNumber) continue
if (linkOwner.toLowerCase() !== owner.toLowerCase()) continue
if (linkRepo.toLowerCase() !== repo.toLowerCase()) continue
// Must be /issues/ or /pull/
if (pathType !== 'issues' && pathType !== 'pull') continue
numbers.add(linkNumber)
}
@@ -245,46 +291,68 @@ export function extractReferences(text, owner, repo) {
}
// ---------------------------------------------------------------------------
// Thanks section
// Inline thanks suffix
// ---------------------------------------------------------------------------
/** Sort logins case-insensitively with deterministic tie-break on original. */
export function sortLogins(logins) {
return [...logins].sort((a, b) => {
const al = a.toLowerCase()
const bl = b.toLowerCase()
if (al !== bl) return al < bl ? -1 : 1
if (a !== b) return a < b ? -1 : 1
return 0
})
}
/** Deduplicate by case-insensitive key, keeping first-seen casing. */
export function dedupeLogins(logins) {
const seen = new Set()
const out = []
for (const login of logins) {
const key = login.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
out.push(login)
}
return out
}
/**
* Remove only the exact trailing generated suffix from a line's content
* (no trailing newline). Returns content unchanged if no match.
*/
export function stripInlineThanks(content) {
return content.replace(INLINE_THANKS_RE, '')
}
/**
* Append ", thanks @a, @b" after stripping any existing generated suffix.
* Empty logins yields stripped content with no suffix.
*/
export function applyInlineThanks(content, logins) {
const base = stripInlineThanks(content)
const unique = sortLogins(dedupeLogins(logins))
if (unique.length === 0) return base
return `${base}, thanks ${unique.map((l) => `@${l}`).join(', ')}`
}
// ---------------------------------------------------------------------------
// Legacy ### Thanks removal (latest section only)
// ---------------------------------------------------------------------------
/**
* Build the "### Thanks" section from a contributor map.
* @param {Map<string, { displayName: string, items: { kind: string, number: number, url: string }[] }>} contributors
*/
export function buildThanksSection(contributors) {
if (contributors.size === 0) return ''
const sorted = [...contributors.entries()].sort(([a], [b]) =>
a.toLowerCase().localeCompare(b.toLowerCase()),
)
const lines = ['### Thanks', '']
for (const [login, entry] of sorted) {
const refs = entry.items
.sort((a, b) => a.number - b.number)
.map((item) => `[#${item.number}](${item.url})`)
.join(', ')
lines.push(`* @${entry.displayName} for ${refs}`)
}
return lines.join('\n')
}
const THANKS_HEADING_RE = /^###\s+Thanks\s*$/
/**
* Remove any existing "### Thanks" block from within [start, end).
* A Thanks block starts with "### Thanks" and ends at the next "### " heading,
* blank line before a "## " heading, or the section boundary.
* A Thanks block starts with "### Thanks" and ends at the next "### " heading
* or the section boundary.
*/
export function removeThanksSection(text, start, end) {
const section = text.slice(start, end)
const lines = section.split('\n')
const lines = splitLines(section)
let thanksStart = -1
for (let i = 0; i < lines.length; i++) {
if (THANKS_HEADING_RE.test(lines[i])) {
if (THANKS_HEADING_RE.test(lines[i].content)) {
thanksStart = i
break
}
@@ -292,58 +360,100 @@ export function removeThanksSection(text, start, end) {
if (thanksStart === -1) return text
// Find the end: next "### " heading or section boundary
let thanksEnd = lines.length
for (let i = thanksStart + 1; i < lines.length; i++) {
if (/^###\s/.test(lines[i])) {
if (/^###\s/.test(lines[i].content)) {
thanksEnd = i
break
}
}
// Rebuild: drop lines [thanksStart, thanksEnd), preserve trailing blank
const before = lines.slice(0, thanksStart)
const after = lines.slice(thanksEnd)
// Trim trailing blank lines between thanks and next subsection
while (after.length > 0 && after[0] === '') after.shift()
while (after.length > 0 && after[0].content === '') after.shift()
while (before.length > 0 && before[before.length - 1].content === '') {
before.pop()
}
const newSection = [...before, ...after].join('\n')
if (before.length > 0 && after.length > 0) {
const nl = before[before.length - 1].newline || after[0].newline || '\n'
before.push({ content: '', newline: nl, raw: nl })
}
const newSection = joinLines([...before, ...after])
return text.slice(0, start) + newSection + text.slice(end)
}
// ---------------------------------------------------------------------------
// Logical bullet entries
// ---------------------------------------------------------------------------
/**
* Inject a Thanks section into the version block after the version heading
* line and its trailing blank lines, before the first subsection.
* Credit logical top-level bullets in a version section.
* Strips any managed suffix from every line of the entry, then writes the
* regenerated suffix on the last line of the logical entry only.
* @param {string} sectionText latest version section only
* @param {Map<number, string>} loginByNumber external login per issue/PR number
* @param {string} owner
* @param {string} repo
*/
export function injectThanksSection(text, thanksSection, start, end) {
const section = text.slice(start, end)
const lines = section.split('\n')
export function creditChangelogEntries(sectionText, loginByNumber, owner, repo) {
const lines = splitLines(sectionText)
const out = []
let i = 0
// Find where the heading + trailing blanks end
let insertAfter = 0 // line index of the version heading
for (let i = 0; i < lines.length; i++) {
if (VERSION_HEADING_RE.test(lines[i])) {
insertAfter = i
break
while (i < lines.length) {
const line = lines[i]
if (!TOP_LEVEL_BULLET_RE.test(line.content)) {
out.push(line)
i++
continue
}
}
// Skip trailing blank lines after the heading
while (insertAfter + 1 < lines.length && lines[insertAfter + 1] === '') {
insertAfter++
// Collect logical entry: top-level bullet + indented non-list continuations
const entryLines = [line]
i++
while (i < lines.length) {
const next = lines[i]
if (
next.content === '' ||
HEADING_RE.test(next.content) ||
TOP_LEVEL_BULLET_RE.test(next.content) ||
NESTED_LIST_RE.test(next.content)
) {
break
}
if (!/^\s+\S/.test(next.content)) break
entryLines.push(next)
i++
}
// Strip managed suffixes from every line so a prior single-line credit
// does not linger after a continuation is added on a later run.
for (let j = 0; j < entryLines.length; j++) {
entryLines[j] = withContent(entryLines[j], stripInlineThanks(entryLines[j].content))
}
const blockText = entryLines.map((l) => l.content).join('\n')
const numbers = extractReferences(blockText, owner, repo)
const logins = []
for (const num of numbers) {
const login = loginByNumber.get(num)
if (login) logins.push(login)
}
const lastIdx = entryLines.length - 1
entryLines[lastIdx] = withContent(
entryLines[lastIdx],
applyInlineThanks(entryLines[lastIdx].content, logins),
)
out.push(...entryLines)
}
const before = lines.slice(0, insertAfter + 1)
const after = lines.slice(insertAfter + 1)
// Ensure blank separation
const parts = [...before]
if (parts[parts.length - 1] !== '') parts.push('')
parts.push(thanksSection)
if (after.length > 0 && after[0] !== '') parts.push('')
const newSection = [...parts, ...after].join('\n')
return text.slice(0, start) + newSection + text.slice(end)
return joinLines(out)
}
// ---------------------------------------------------------------------------
@@ -351,7 +461,6 @@ export function injectThanksSection(text, thanksSection, start, end) {
// ---------------------------------------------------------------------------
export async function main() {
// Validate inputs
if (!TOKEN) {
console.error('GITHUB_TOKEN is required')
process.exit(1)
@@ -382,18 +491,17 @@ export async function main() {
process.exit(1)
}
const sectionText = text.slice(section.start, section.end)
const numbers = extractReferences(sectionText, owner, repoName)
if (numbers.length === 0) {
// No references at all -- remove any stale Thanks, exit if no change
const cleaned = removeThanksSection(text, section.start, section.end)
writeFileSync(changelogPath, cleaned, 'utf-8')
process.exit(0)
text = removeThanksSection(text, section.start, section.end)
const sectionAfterThanks = parseVersionSection(text)
if (!sectionAfterThanks) {
console.error('No version heading found after Thanks removal')
process.exit(1)
}
// Look up every unique number
const contributors = new Map()
const sectionText = text.slice(sectionAfterThanks.start, sectionAfterThanks.end)
const numbers = extractReferences(sectionText, owner, repoName)
const loginByNumber = new Map()
for (const num of numbers) {
let response
try {
@@ -405,50 +513,27 @@ export async function main() {
continue
}
if (err.status !== undefined && !shouldRetry(err.status, err.headers)) {
// Non-retryable error -- fail immediately
console.error(`Fatal error looking up #${num}: HTTP ${err.status}`)
process.exit(1)
}
// Retryable failure after all retries
console.error(`Failed to look up #${num} after ${MAX_RETRIES} attempts: ${err.message}`)
process.exit(1)
}
const classification = classifyAuthor(response)
if (classification === 'external') {
const login = response.user.login
if (!contributors.has(login)) {
contributors.set(login, {
displayName: login,
items: [],
})
}
const entry = contributors.get(login)
const url = response.html_url
const kind = response.pull_request ? 'pr' : 'issue'
// Avoid duplicate entries for the same number
if (!entry.items.some((item) => item.number === num)) {
entry.items.push({ kind, number: num, url })
}
loginByNumber.set(num, response.user.login)
}
}
// Build or remove Thanks section
let newText
if (contributors.size > 0) {
const thanks = buildThanksSection(contributors)
// Remove any existing Thanks first, then inject
const cleaned = removeThanksSection(text, section.start, section.end)
newText = injectThanksSection(cleaned, thanks, section.start, section.end)
} else {
newText = removeThanksSection(text, section.start, section.end)
}
const credited = creditChangelogEntries(sectionText, loginByNumber, owner, repoName)
const newText =
text.slice(0, sectionAfterThanks.start) + credited + text.slice(sectionAfterThanks.end)
writeFileSync(changelogPath, newText, 'utf-8')
process.exit(0)
}
// Only run main() when invoked as a script, not when imported for tests.
const runningDirectly =
process.argv[1] &&
(process.argv[1].endsWith('credit-changelog-contributors.mjs') ||
+292 -229
View File
@@ -10,17 +10,28 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
// Import pure helpers from the script
import {
parseVersionSection,
extractReferences,
classifyAuthor,
shouldRetry,
buildThanksSection,
stripInlineThanks,
applyInlineThanks,
sortLogins,
dedupeLogins,
removeThanksSection,
injectThanksSection,
creditChangelogEntries,
splitLines,
joinLines,
} from './credit-changelog-contributors.mjs'
const OWNER = 'Studio-Saelix'
const REPO = 'sencho'
function sameRepo(n, kind = 'issues') {
return `[#${n}](https://github.com/${OWNER}/${REPO}/${kind}/${n})`
}
// ---------------------------------------------------------------------------
// parseVersionSection
// ---------------------------------------------------------------------------
@@ -43,9 +54,7 @@ describe('parseVersionSection', () => {
const result = parseVersionSection(text)
assert.notEqual(result, null)
// The 0.93.0 block starts at the "## [0.93.0]" line
assert.ok(text.slice(result.start, result.end).includes('## [0.93.0]'))
// It must NOT include 0.92.0 content
assert.ok(!text.slice(result.start, result.end).includes('## [0.92.0]'))
})
@@ -65,7 +74,6 @@ describe('parseVersionSection', () => {
const text = '## [1.0.0]\n\n### Added\n* feat\n'
const result = parseVersionSection(text)
assert.notEqual(result, null)
// The block should contain the full text (no trailing \n means end === text.length)
assert.equal(result.end, text.length)
})
@@ -73,7 +81,6 @@ describe('parseVersionSection', () => {
const text = '# Changelog — all notable changes\n\n## [0.94.0]\n\n### Added\n* item ✓\n'
const result = parseVersionSection(text)
assert.notEqual(result, null)
// String.indexOf works at code-unit level; the block should contain the heading
assert.ok(text.slice(result.start, result.end).startsWith('## [0.94.0]'))
})
@@ -99,34 +106,29 @@ describe('parseVersionSection', () => {
// ---------------------------------------------------------------------------
describe('extractReferences', () => {
const OWNER = 'Studio-Saelix'
const REPO = 'sencho'
it('extracts PR references from ([#N](.../issues/N))', () => {
const text =
'* feat: add thing ([#1442](https://github.com/Studio-Saelix/sencho/issues/1442))'
const text = `* feat: add thing (${sameRepo(1442)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1442])
})
it('extracts linked-issue references from closes [#N](...)', () => {
const text =
'* fix: clear logs ([#1448](https://github.com/Studio-Saelix/sencho/issues/1448)) ([abc](...)), closes [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'
`* fix: clear logs (${sameRepo(1448)}) ([abc](...)), closes ${sameRepo(1444)}`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1444, 1448])
})
it('extracts fixes/resolves variants', () => {
const text =
'* fix: a ([#1](https://github.com/Studio-Saelix/sencho/issues/1)) ([...]), fixes [#2](https://github.com/Studio-Saelix/sencho/issues/2)\n' +
'* fix: b ([#3](https://github.com/Studio-Saelix/sencho/issues/3)) ([...]), resolves [#4](https://github.com/Studio-Saelix/sencho/issues/4)'
`* fix: a (${sameRepo(1)}) ([...]), fixes ${sameRepo(2)}\n` +
`* fix: b (${sameRepo(3)}) ([...]), resolves ${sameRepo(4)}`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [1, 2, 3, 4])
})
it('accepts /pull/ URLs', () => {
const text =
'* feat: pr ref ([#5](https://github.com/Studio-Saelix/sencho/pull/5))'
const text = `* feat: pr ref (${sameRepo(5, 'pull')})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [5])
})
@@ -141,7 +143,6 @@ describe('extractReferences', () => {
it('accepts pre-transfer org (AnsoCode/Sencho)', () => {
const text =
'* old: thing ([#583](https://github.com/AnsoCode/Sencho/issues/583))'
// The owner/repo must be passed as the pre-transfer ones to match
const refs = extractReferences(text, 'AnsoCode', 'Sencho')
assert.deepEqual(refs, [583])
})
@@ -155,17 +156,17 @@ describe('extractReferences', () => {
it('deduplicates repeated numbers', () => {
const text =
'* feat: a ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
'* feat: b ([#10](https://github.com/Studio-Saelix/sencho/issues/10))'
`* feat: a (${sameRepo(10)})\n` +
`* feat: b (${sameRepo(10)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [10])
})
it('returns sorted numbers', () => {
const text =
'* feat: c ([#30](https://github.com/Studio-Saelix/sencho/issues/30))\n' +
'* feat: a ([#10](https://github.com/Studio-Saelix/sencho/issues/10))\n' +
'* feat: b ([#20](https://github.com/Studio-Saelix/sencho/issues/20))'
`* feat: c (${sameRepo(30)})\n` +
`* feat: a (${sameRepo(10)})\n` +
`* feat: b (${sameRepo(20)})`
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, [10, 20, 30])
})
@@ -181,7 +182,7 @@ describe('extractReferences', () => {
const text =
'* feat: wrong ([#42](https://github.com/Studio-Saelix/sencho/issues/99))'
const refs = extractReferences(text, OWNER, REPO)
assert.deepEqual(refs, []) // #42 != #99
assert.deepEqual(refs, [])
})
})
@@ -261,11 +262,7 @@ describe('classifyAuthor', () => {
)
})
it('respects MAINTAINER_LOGINS override', () => {
// MAINTAINER_LOGINS is read at module import time, so changing the env
// var here does not affect classifyAuthor's internal constant. This test
// verifies the baseline: overrideUser with NONE association is external
// when no maintainer logins are configured.
it('respects MAINTAINER_LOGINS override baseline (module-load constant)', () => {
const prev = process.env.MAINTAINER_LOGINS
process.env.MAINTAINER_LOGINS = 'overrideUser'
assert.equal(
@@ -279,7 +276,6 @@ describe('classifyAuthor', () => {
})
it('case-insensitive login matching', () => {
// Bot pattern is case-insensitive
assert.equal(
classifyAuthor({
user: { type: 'User', login: 'SomeBot[BOT]' },
@@ -334,289 +330,356 @@ describe('shouldRetry', () => {
})
// ---------------------------------------------------------------------------
// buildThanksSection
// Inline thanks helpers
// ---------------------------------------------------------------------------
describe('buildThanksSection', () => {
it('returns empty string for empty map', () => {
assert.equal(buildThanksSection(new Map()), '')
describe('inline thanks helpers', () => {
it('strips exact trailing suffix', () => {
assert.equal(
stripInlineThanks('closes #1, thanks @auspex'),
'closes #1',
)
})
it('builds section for a single contributor', () => {
const map = new Map()
map.set('Crosis47', {
displayName: 'Crosis47',
items: [{ kind: 'issue', number: 1444, url: 'https://github.com/Studio-Saelix/sencho/issues/1444' }],
})
const result = buildThanksSection(map)
assert.ok(result.startsWith('### Thanks'))
assert.ok(result.includes('@Crosis47'))
assert.ok(result.includes('[#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'))
it('preserves near-match prose that is not the managed suffix', () => {
const prose = 'special thanks to everyone who filed bugs'
assert.equal(stripInlineThanks(prose), prose)
assert.equal(applyInlineThanks(prose, []), prose)
})
it('groups multiple contributions for same login', () => {
const map = new Map()
map.set('alice', {
displayName: 'alice',
items: [
{ kind: 'issue', number: 10, url: 'https://github.com/Studio-Saelix/sencho/issues/10' },
{ kind: 'pr', number: 5, url: 'https://github.com/Studio-Saelix/sencho/pull/5' },
],
})
const result = buildThanksSection(map)
assert.ok(result.includes('#5') && result.includes('#10'))
// Numbers sorted
const idx5 = result.indexOf('#5')
const idx10 = result.indexOf('#10')
assert.ok(idx5 < idx10)
it('dedupes case-insensitively keeping first casing', () => {
assert.deepEqual(dedupeLogins(['Auspex', 'auspex', 'Bob']), ['Auspex', 'Bob'])
})
it('sorts contributors alphabetically by login', () => {
const map = new Map()
map.set('zoe', { displayName: 'zoe', items: [{ kind: 'issue', number: 1, url: 'u' }] })
map.set('alice', { displayName: 'alice', items: [{ kind: 'issue', number: 2, url: 'u' }] })
const result = buildThanksSection(map)
const idxA = result.indexOf('@alice')
const idxZ = result.indexOf('@zoe')
assert.ok(idxA < idxZ)
it('sorts case-insensitively with deterministic tie-break', () => {
assert.deepEqual(sortLogins(['bob', 'Alice', 'alice']), ['Alice', 'alice', 'bob'])
})
it('applies sorted unique suffix', () => {
// First-seen casing wins ('alice'); Alice is dropped as a case-insensitive dup.
assert.equal(
applyInlineThanks('line', ['bob', 'alice', 'Alice']),
'line, thanks @alice, @bob',
)
})
it('removes stale suffix when logins empty', () => {
assert.equal(
applyInlineThanks('line, thanks @stale', []),
'line',
)
})
})
// ---------------------------------------------------------------------------
// removeThanksSection
// creditChangelogEntries
// ---------------------------------------------------------------------------
describe('removeThanksSection', () => {
it('removes existing Thanks from the block', () => {
describe('creditChangelogEntries', () => {
it('0.95.0-style bullet: PR plus closes #1581 becomes thanks @auspex', () => {
const section = [
'## [0.95.0](...) (2026-07-12)',
'',
'### Fixed',
`* **drift:** resolve explicit network names that equal compose keys (${sameRepo(1588)}) ([4123793](...)), closes ${sameRepo(1581)}`,
].join('\n')
const map = new Map([[1581, 'auspex']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`, closes ${sameRepo(1581)}, thanks @auspex`))
assert.ok(!result.includes('thanks @auspex, thanks'))
})
it('credits reference on indented non-list continuation line', () => {
const section = [
'## [0.93.0]',
'',
'### Thanks',
'',
'* @alice for [#1](u)',
'',
'### Added',
'* feat',
`* feat (${sameRepo(1448)})`,
` ([abc](...)), closes ${sameRepo(1444)}`,
].join('\n')
const text = `# Changelog\n\n${section}\n\n## [0.92.0]`
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
assert.ok(!result.includes('### Thanks'))
assert.ok(result.includes('### Added'))
assert.ok(result.includes('## [0.92.0]'))
const map = new Map([[1444, 'Crosis47']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`closes ${sameRepo(1444)}, thanks @Crosis47`))
assert.ok(!result.split('\n')[3].includes('thanks'))
})
it('is no-op when no Thanks section exists', () => {
const text = [
'## [0.93.0]',
it('dash top-level bullet is credited', () => {
const section = [
'## [1.0.0]',
'',
'### Added',
'* feat',
`### Fixed`,
`- fix: thing (${sameRepo(10)})`,
].join('\n')
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
assert.equal(result, text)
const map = new Map([[10, 'dashUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(`- fix: thing (${sameRepo(10)}), thanks @dashUser`))
})
it('does not touch previous release Thanks', () => {
const text = [
'## [0.94.0]',
'',
'### Added',
'* new feat',
'',
'## [0.93.0]',
'',
'### Thanks',
'',
'* @alice for [#1](u)',
it('same-repo #123 credited; cross-repo #123 on other bullet is not', () => {
const section = [
'## [1.0.0]',
'',
'### Fixed',
'* old fix',
`* same (${sameRepo(123)})`,
'* cross ([#123](https://github.com/OtherOrg/other/issues/123))',
].join('\n')
const parsed = parseVersionSection(text)
const result = removeThanksSection(text, parsed.start, parsed.end)
// Latest (0.94.0) has no Thanks, so no-op
assert.equal(result, text)
// But previous release still has its Thanks
assert.ok(result.includes('### Thanks'))
const map = new Map([[123, 'sameRepoUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.ok(lines[3].includes(', thanks @sameRepoUser'))
assert.equal(lines[4], '* cross ([#123](https://github.com/OtherOrg/other/issues/123))')
})
})
// ---------------------------------------------------------------------------
// injectThanksSection
// ---------------------------------------------------------------------------
it('two externals on one logical bullet: sorted and deduplicated', () => {
const section = [
'## [1.0.0]',
'',
`* fix (${sameRepo(1)}) closes ${sameRepo(2)}`,
].join('\n')
const map = new Map([
[1, 'zoe'],
[2, 'alice'],
])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.ok(result.includes(', thanks @alice, @zoe'))
})
describe('injectThanksSection', () => {
it('inserts Thanks after version heading', () => {
const text = [
it('one contributor on multiple bullets credits each applicable bullet', () => {
const section = [
'## [1.0.0]',
'',
`* a (${sameRepo(1)})`,
`* b (${sameRepo(1)})`,
`* c (${sameRepo(2)})`,
].join('\n')
const map = new Map([
[1, 'helper'],
[2, 'other'],
])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.ok(lines[2].endsWith(', thanks @helper'))
assert.ok(lines[3].endsWith(', thanks @helper'))
assert.ok(lines[4].endsWith(', thanks @other'))
})
it('second run produces byte-identical output', () => {
const section = [
'## [0.93.0]',
'',
'### Added',
'* feat',
`* feat (${sameRepo(1448)})`,
` ([abc](...)), closes ${sameRepo(1444)}`,
].join('\n')
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @alice for [#1](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@alice'))
const thanksIdx = result.indexOf('### Thanks')
const addedIdx = result.indexOf('### Added')
assert.ok(thanksIdx < addedIdx)
const map = new Map([[1444, 'Crosis47']])
const first = creditChangelogEntries(section, map, OWNER, REPO)
const second = creditChangelogEntries(first, map, OWNER, REPO)
assert.equal(second, first)
})
it('injects into empty block (no subsections)', () => {
const text = '## [0.93.0]\n'
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @bob for [#2](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@bob'))
it('strips stale suffix from first line when a continuation is added later', () => {
const section = [
'## [1.0.0]',
'',
`* feat (${sameRepo(1)}), thanks @old`,
` continuation closes ${sameRepo(2)}`,
].join('\n')
const map = new Map([[2, 'newUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* feat (${sameRepo(1)})`)
assert.ok(lines[3].endsWith(', thanks @newUser'))
assert.ok(!lines[2].includes('thanks'))
})
it('does not mutate content outside the block', () => {
const before = '# Changelog\n\n'
const block = '## [0.93.0]\n\n### Added\n* feat\n'
const after = '\n## [0.92.0]\n\n### Fixed\n* old fix\n'
const text = before + block + after
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @carol for [#3](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
assert.ok(result.startsWith(before))
assert.ok(result.endsWith(after))
it('removes stale inline credit when login map has no matching externals', () => {
const section = [
'## [1.0.0]',
'',
`* fix (${sameRepo(1)}), thanks @stale`,
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result.split('\n')[2], `* fix (${sameRepo(1)})`)
})
it('bullet with no external refs and no suffix remains byte-identical', () => {
const section = [
'## [1.0.0]',
'',
'### Added',
'* feat: plain entry',
'',
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result, section)
})
it('preserves near-match thanks prose that is not the managed suffix', () => {
const section = [
'## [1.0.0]',
'',
'* feat: special thanks to reviewers',
].join('\n')
const result = creditChangelogEntries(section, new Map(), OWNER, REPO)
assert.equal(result, section)
})
it('nested list child remains unchanged; suffix on parent', () => {
const section = [
'## [1.0.0]',
'',
`* parent with ref (${sameRepo(7)})`,
' * nested child stays put',
'* sibling',
].join('\n')
const map = new Map([[7, 'parentUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* parent with ref (${sameRepo(7)}), thanks @parentUser`)
assert.equal(lines[3], ' * nested child stays put')
assert.equal(lines[4], '* sibling')
})
it('nested list refs are excluded from parent association', () => {
const section = [
'## [1.0.0]',
'',
'* parent with no refs',
` * nested closes ${sameRepo(7)}`,
].join('\n')
const map = new Map([[7, 'nestedUser']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
assert.equal(result, section)
assert.ok(!result.includes('thanks'))
})
it('blank line terminates logical entry (later prose not absorbed)', () => {
const section = [
'## [1.0.0]',
'',
`* parent (${sameRepo(1)})`,
'',
' later prose that looks indented',
].join('\n')
const map = new Map([[1, 'user']])
const result = creditChangelogEntries(section, map, OWNER, REPO)
const lines = result.split('\n')
assert.equal(lines[2], `* parent (${sameRepo(1)}), thanks @user`)
assert.equal(lines[3], '')
assert.equal(lines[4], ' later prose that looks indented')
})
it('preserves LF after an actual inline rewrite', () => {
const section = `## [1.0.0]\n\n* feat (${sameRepo(1)})\n`
assert.ok(!section.includes('\r'))
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
assert.ok(!result.includes('\r'))
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\n`))
})
it('preserves CRLF after an actual inline rewrite', () => {
const section = `## [1.0.0]\r\n\r\n* feat (${sameRepo(1)})\r\n`
const result = creditChangelogEntries(section, new Map([[1, 'u']]), OWNER, REPO)
assert.ok(result.includes('## [1.0.0]\r\n'))
assert.ok(result.includes(`* feat (${sameRepo(1)}), thanks @u\r\n`))
assert.equal(result.includes('\n') && !result.includes('\r\n'), false)
})
})
// ---------------------------------------------------------------------------
// Bounded mutation: both releases have Thanks
// removeThanksSection + bounded mutation
// ---------------------------------------------------------------------------
describe('bounded mutation with multiple Thanks sections', () => {
it('removes Thanks only from latest, leaves previous intact', () => {
describe('removeThanksSection and bounded mutation', () => {
it('removes legacy Thanks from latest while preserving historical Thanks', () => {
const text = [
'## [0.94.0]',
'# Changelog',
'',
'## [0.95.0]',
'',
'### Thanks',
'',
'* @new for [#10](u)',
'* @auspex for [#1581](u)',
'',
'### Added',
'* latest feat',
'* feat',
'',
'## [0.93.0]',
'## [0.94.0]',
'',
'### Thanks',
'',
'* @old for [#5](u)',
'',
'### Fixed',
'* old fix',
'* old',
].join('\n')
const parsed = parseVersionSection(text)
assert.notEqual(parsed, null)
// Verify latest section contains Thanks
const latest = text.slice(parsed.start, parsed.end)
assert.ok(latest.includes('### Thanks'))
assert.ok(latest.includes('@new'))
// Remove from latest
const result = removeThanksSection(text, parsed.start, parsed.end)
// Re-parse the result to get the new latest section boundaries
const reparsed = parseVersionSection(result)
const newLatest = result.slice(reparsed.start, reparsed.end)
assert.ok(!newLatest.includes('### Thanks'))
// Previous release still has its Thanks
const latest = result.slice(reparsed.start, reparsed.end)
assert.ok(!latest.includes('### Thanks'))
assert.ok(!latest.includes('@auspex'))
assert.ok(result.includes('@old for [#5](u)'))
})
it('second-run idempotency: same output when run twice', () => {
const text = [
'## [0.93.0]',
it('content before and after latest version remains byte-identical after credit', () => {
const prefix = '# Changelog\n\nIntro prose.\n\n'
const latest = [
'## [1.0.0]',
'',
'### Added',
'* feat ([#1448](https://github.com/Studio-Saelix/sencho/issues/1448))',
' ([abc](...)), closes [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)',
`* feat (${sameRepo(1)})`,
'',
].join('\n')
const suffix = '## [0.9.0]\n\n### Fixed\n* old\n'
const full = prefix + latest + suffix
// First "run": no Thanks yet
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @Crosis47 for [#1444](https://github.com/Studio-Saelix/sencho/issues/1444)'
const first = injectThanksSection(text, thanks, parsed.start, parsed.end)
const parsed = parseVersionSection(full)
const section = full.slice(parsed.start, parsed.end)
const credited = creditChangelogEntries(section, new Map([[1, 'x']]), OWNER, REPO)
const out = full.slice(0, parsed.start) + credited + full.slice(parsed.end)
// Second "run": inject same thanks into already-injected result
// (remove first, then inject)
const parsed2 = parseVersionSection(first)
const cleaned = removeThanksSection(first, parsed2.start, parsed2.end)
const second = injectThanksSection(cleaned, thanks, parsed2.start, parsed2.end)
assert.equal(second, first)
})
it('empty-result removal is idempotent', () => {
const text = [
'## [0.93.0]',
'',
'### Thanks',
'',
'* @stale for [#1](u)',
'',
'### Added',
'* feat',
].join('\n')
const parsed = parseVersionSection(text)
const first = removeThanksSection(text, parsed.start, parsed.end)
const second = removeThanksSection(first, parsed.start, parsed.end)
assert.equal(second, first)
assert.equal(out.slice(0, parsed.start), prefix)
assert.equal(out.slice(out.length - suffix.length), suffix)
assert.ok(out.includes(', thanks @x'))
})
})
// ---------------------------------------------------------------------------
// End-to-end: no version heading
// splitLines / joinLines round-trip
// ---------------------------------------------------------------------------
describe('missing version heading', () => {
it('parseVersionSection returns null, main() would exit 1', () => {
const text = '# Changelog\n\nNo version here.\n'
assert.equal(parseVersionSection(text), null)
describe('splitLines', () => {
it('round-trips LF and CRLF', () => {
const lf = 'a\nb\n'
assert.equal(joinLines(splitLines(lf)), lf)
const crlf = 'a\r\nb\r\n'
assert.equal(joinLines(splitLines(crlf)), crlf)
})
})
// ---------------------------------------------------------------------------
// Line ending preservation (LF input)
// Workflow expression documentation (empty vs populated pr)
// ---------------------------------------------------------------------------
describe('line ending preservation', () => {
it('preserves LF line endings', () => {
const text = '## [0.93.0]\n\n### Added\n* feat\n'
assert.ok(!text.includes('\r'))
const parsed = parseVersionSection(text)
const result = injectThanksSection(
text,
'### Thanks\n\n* @x for [#1](u)',
parsed.start,
parsed.end,
)
assert.ok(!result.includes('\r'))
// Verify the injected section separator uses LF
assert.ok(result.includes('\n\n### Added'))
describe('workflow RELEASE_BRANCH expression (documented behavior)', () => {
it('empty pr || {} yields no headBranchName (publish-run safe)', () => {
// Mirrors: fromJSON(steps.release.outputs.pr || '{}').headBranchName || ''
const pr = ''
const parsed = JSON.parse(pr || '{}')
const branch = parsed.headBranchName || ''
assert.equal(branch, '')
})
it('preserves CRLF line endings', () => {
const text = '## [0.93.0]\r\n\r\n### Added\r\n* feat\r\n'
assert.ok(text.includes('\r\n'))
const parsed = parseVersionSection(text)
const thanks = '### Thanks\n\n* @x for [#1](u)'
const result = injectThanksSection(text, thanks, parsed.start, parsed.end)
// Existing CRLF content survives; the injected section uses LF internally.
// The version heading and ### Added heading keep their CRLF endings.
assert.ok(result.includes('## [0.93.0]\r\n'))
assert.ok(result.includes('\r\n### Added'))
assert.ok(result.includes('### Thanks'))
assert.ok(result.includes('@x'))
it('populated pr JSON yields headBranchName', () => {
const pr = JSON.stringify({
headBranchName: 'release-please--branches--main--components--sencho',
})
const parsed = JSON.parse(pr || '{}')
const branch = parsed.headBranchName || ''
assert.equal(branch, 'release-please--branches--main--components--sencho')
})
})