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
7 changes: 7 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,10 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: true

- name: Announce the release on Discord
if: steps.changesets.outputs.published == 'true'
run: node scripts/announce-release.mjs
env:
PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
DISCORD_RELEASES_WEBHOOK_URL: ${{ secrets.DISCORD_RELEASES_WEBHOOK_URL }}
25 changes: 25 additions & 0 deletions .github/workflows/scripts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Scripts

on:
pull_request:
branches:
- main
paths:
- scripts/**

jobs:
test:
name: Test repository scripts
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "24"

- name: Run the script tests
run: node --test 'scripts/**/*.test.mjs'
236 changes: 236 additions & 0 deletions scripts/announce-release.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Posts the packages a release just published to the Discord releases channel: one message per
// package with changes, built from the section changesets wrote into its CHANGELOG.md.
// Usage (the release workflow): PUBLISHED_PACKAGES='[{"name":"…","version":"…"}]' DISCORD_RELEASES_WEBHOOK_URL=… node scripts/announce-release.mjs
// Without DISCORD_RELEASES_WEBHOOK_URL, or with DRY_RUN=1, it prints the messages instead of posting them.
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

export const EXIT_CODES = {
success: 0,
invalid_published_packages: 2,
changelog_section_not_found: 3,
discord_rejected_message: 4,
}

const BRAND_BLUE = 0x3665e1
const BREAKING_RED = 0xcc2222
const LOGO_URL = 'https://simplepdf.com/android-chrome-512x512.png'
// Discord caps an embed description at 4096 characters; the headroom keeps the closing link line.
const DESCRIPTION_LIMIT = 3800

const SECTION_TITLES = {
'Major Changes': 'Breaking changes',
'Minor Changes': "What's new",
'Patch Changes': 'Fixes and improvements',
}

// The `## <version>` section of a changesets CHANGELOG, as { sectionTitle: entries[] }.
export const parseChangelogSection = ({ changelog, version }) => {
const heading = `## ${version}`
const start = changelog.split('\n').findIndex((line) => line.trim() === heading)
if (start === -1) {
return null
}

const lines = changelog.split('\n').slice(start + 1)
const end = lines.findIndex((line) => line.startsWith('## '))
const sectionLines = end === -1 ? lines : lines.slice(0, end)

const sections = {}
const state = { title: null, entry: null }
const closeEntry = () => {
if (state.title !== null && state.entry !== null) {
sections[state.title].push(state.entry.join('\n').trim())
}
state.entry = null
}

for (const line of sectionLines) {
const sectionMatch = /^### (.+)$/.exec(line)
if (sectionMatch !== null) {
closeEntry()
state.title = sectionMatch[1].trim()
sections[state.title] = sections[state.title] ?? []
continue
}
const entryMatch = /^- (?:[0-9a-f]{7,40}: )?(.*)$/.exec(line)
if (entryMatch !== null) {
closeEntry()
state.entry = [entryMatch[1]]
continue
}
if (state.entry !== null) {
state.entry.push(line.startsWith(' ') ? line.slice(2) : line)
}
}
closeEntry()

return sections
}

const isDependencyUpdate = (entry) => entry.startsWith('Updated dependencies')

export const hasNotableChanges = (sections) =>
Object.values(sections).some((entries) => entries.some((entry) => !isDependencyUpdate(entry)))

const toBulletedEntry = (entry) => {
const [firstLine, ...rest] = entry.split('\n')
const indentedRest = rest.map((line) => (line === '' ? '' : ` ${line}`))
return [`- ${firstLine}`, ...indentedRest].join('\n')
}

const buildDescription = ({ sections, releaseUrl }) => {
const blocks = Object.entries(SECTION_TITLES).flatMap(([changesetTitle, title]) => {
const entries = (sections[changesetTitle] ?? []).filter((entry) => !isDependencyUpdate(entry))
return entries.length === 0 ? [] : [{ title, entries }]
})

// A section with one entry reads as a paragraph (its own bullets become the list); several
// entries read as a list.
const additions = blocks.flatMap((block) =>
block.entries.map((entry, index) =>
[...(index === 0 ? [`**${block.title}**`] : []), block.entries.length === 1 ? entry : toBulletedEntry(entry)].join('\n'),
),
)
// Entries are cut at an entry boundary, in order: once one does not fit, the rest go to the link.
const firstOverflow = additions.findIndex(
(_addition, index) => additions.slice(0, index + 1).join('\n\n').length > DESCRIPTION_LIMIT,
)
const lines = firstOverflow === -1 ? additions : additions.slice(0, firstOverflow)
const counts = { shown: lines.length, total: additions.length }

const hidden = counts.total - counts.shown
return hidden > 0
? [...lines, `…and ${hidden} more in the [full release notes](${releaseUrl}).`].join('\n\n')
: lines.join('\n\n')
}

const buildGetStarted = (packageJson) => {
const install = `\`\`\`sh\nnpm install ${packageJson.name}@${packageJson.version}\n\`\`\``
if (packageJson.unpkg === undefined) {
return install
}
const scriptTag = `\`\`\`html\n<script src="https://unpkg.com/${packageJson.name}" defer></script>\n\`\`\``
return `${scriptTag}\n${install}`
}

export const buildReleaseMessage = ({ packageJson, sections, repositoryUrl, alsoReleased }) => {
const tag = `${packageJson.name}@${packageJson.version}`
const releaseUrl = `${repositoryUrl}/releases/tag/${encodeURIComponent(tag)}`
const docsUrl = `${repositoryUrl}/tree/main/${packageJson.repository.directory}#readme`
const npmUrl = `https://www.npmjs.com/package/${packageJson.name}/v/${packageJson.version}`
const isBreaking = (sections['Major Changes'] ?? []).length > 0

const fields = [
{ name: 'Get started', value: buildGetStarted(packageJson) },
{ name: 'Links', value: `[Release notes](${releaseUrl}) · [npm](${npmUrl}) · [Docs](${docsUrl})` },
...(alsoReleased.length === 0 ? [] : [{ name: 'Also released', value: alsoReleased.join('\n') }]),
]

return {
username: 'SimplePDF Releases',
avatar_url: LOGO_URL,
allowed_mentions: { parse: [] },
embeds: [
{
title: `${packageJson.name} ${packageJson.version}`,
url: releaseUrl,
color: isBreaking ? BREAKING_RED : BRAND_BLUE,
description: buildDescription({ sections, releaseUrl }),
fields,
footer: { text: `SimplePDF · ${packageJson.license} license`, icon_url: LOGO_URL },
timestamp: new Date().toISOString(),
},
],
}
}

const readWorkspacePackages = async (rootDir) => {
const rootPackage = JSON.parse(await readFile(path.join(rootDir, 'package.json'), 'utf8'))
const workspaces = await Promise.all(
rootPackage.workspaces.map(async (workspace) => {
const directory = path.join(rootDir, workspace)
const packageJson = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'))
return { directory, packageJson }
}),
)
return new Map(workspaces.map((workspace) => [workspace.packageJson.name, workspace]))
}

const parsePublishedPackages = (value) => {
try {
const parsed = JSON.parse(value ?? '')
const isValid =
Array.isArray(parsed) &&
parsed.every((item) => typeof item?.name === 'string' && typeof item?.version === 'string')
return isValid ? parsed : null
} catch {
return null
}
}

const postToDiscord = async ({ webhookUrl, message }) => {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(message),
})
return response.ok ? null : `${response.status} ${await response.text()}`
}

const main = async () => {
const rootDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const publishedPackages = parsePublishedPackages(process.env.PUBLISHED_PACKAGES)
if (publishedPackages === null) {
console.error(`PUBLISHED_PACKAGES is not a JSON list of { name, version }: ${process.env.PUBLISHED_PACKAGES}`)
return EXIT_CODES.invalid_published_packages
}

const workspaces = await readWorkspacePackages(rootDir)
const releases = []
for (const { name, version } of publishedPackages) {
const workspace = workspaces.get(name)
const changelog = workspace === undefined ? '' : await readFile(path.join(workspace.directory, 'CHANGELOG.md'), 'utf8').catch(() => '')
const sections = parseChangelogSection({ changelog, version })
if (workspace === undefined || sections === null) {
console.error(`No CHANGELOG section ## ${version} for ${name}`)
return EXIT_CODES.changelog_section_not_found
}
releases.push({ packageJson: { ...workspace.packageJson, version }, sections })
}

const notable = releases.filter(({ sections }) => hasNotableChanges(sections))
const dependencyOnly = releases
.filter(({ sections }) => !hasNotableChanges(sections))
.map(({ packageJson }) => `${packageJson.name} ${packageJson.version} (dependency updates)`)
if (notable.length === 0) {
console.log('Only dependency updates were published: nothing to announce.')
return EXIT_CODES.success
}

const repositoryUrl = `https://github.com/${process.env.GITHUB_REPOSITORY ?? 'SimplePDF/simplepdf-embed'}`
const messages = notable.map(({ packageJson, sections }, index) =>
buildReleaseMessage({ packageJson, sections, repositoryUrl, alsoReleased: index === 0 ? dependencyOnly : [] }),
)

const webhookUrl = process.env.DISCORD_RELEASES_WEBHOOK_URL
if (webhookUrl === undefined || webhookUrl === '' || process.env.DRY_RUN === '1') {
console.log(JSON.stringify(messages, null, 2))
return EXIT_CODES.success
}

for (const message of messages) {
const failure = await postToDiscord({ webhookUrl, message })
if (failure !== null) {
console.error(`Discord rejected the announcement for ${message.embeds[0].title}: ${failure}`)
return EXIT_CODES.discord_rejected_message
}
console.log(`Announced ${message.embeds[0].title}`)
}
return EXIT_CODES.success
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
process.exit(await main())
}
100 changes: 100 additions & 0 deletions scripts/announce-release.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { buildReleaseMessage, hasNotableChanges, parseChangelogSection } from './announce-release.mjs'

const CHANGELOG = `# @simplepdf/web-embed-pdf

## 1.9.0

### Minor Changes

- 6662d44: **Your pages are ready for AI agents.** An agent can fill the form.

- More PDF links open in SimplePDF.
- Japanese and Dutch pages open the editor in their language.

### Patch Changes

- Updated dependencies [06a5946]
- @simplepdf/embed@0.7.0

## 1.8.4

### Patch Changes

- 1234567: An older fix.
`

const WEB_PACKAGE = {
name: '@simplepdf/web-embed-pdf',
version: '1.9.0',
license: 'MIT',
unpkg: 'dist/index.min.js',
repository: { directory: 'web' },
}
const REPOSITORY_URL = 'https://github.com/SimplePDF/simplepdf-embed'

test('reads one version section, without commit hashes, keeping nested lines', () => {
const sections = parseChangelogSection({ changelog: CHANGELOG, version: '1.9.0' })

assert.deepEqual(sections['Minor Changes'], [
'**Your pages are ready for AI agents.** An agent can fill the form.\n\n- More PDF links open in SimplePDF.\n- Japanese and Dutch pages open the editor in their language.',
])
assert.equal(sections['Patch Changes'].length, 1)
assert.equal(JSON.stringify(sections).includes('An older fix'), false)
assert.equal(parseChangelogSection({ changelog: CHANGELOG, version: '9.9.9' }), null)
})

test('a release that only updated dependencies has nothing to announce', () => {
const dependencyOnly = parseChangelogSection({
changelog: '## 1.13.1\n\n### Patch Changes\n\n- Updated dependencies [abc1234]\n - @simplepdf/embed@0.7.1\n',
version: '1.13.1',
})

assert.equal(hasNotableChanges(dependencyOnly), false)
assert.equal(hasNotableChanges(parseChangelogSection({ changelog: CHANGELOG, version: '1.9.0' })), true)
})

test('the message links the release, installs the script tag, never pings, and drops dependency noise', () => {
const sections = parseChangelogSection({ changelog: CHANGELOG, version: '1.9.0' })
const message = buildReleaseMessage({ packageJson: WEB_PACKAGE, sections, repositoryUrl: REPOSITORY_URL, alsoReleased: [] })
const [embed] = message.embeds

assert.deepEqual(message.allowed_mentions, { parse: [] })
assert.equal(embed.title, '@simplepdf/web-embed-pdf 1.9.0')
assert.equal(embed.url, `${REPOSITORY_URL}/releases/tag/%40simplepdf%2Fweb-embed-pdf%401.9.0`)
assert.equal(embed.color, 0x3665e1)
assert.match(embed.description, /^\*\*What's new\*\*\n\*\*Your pages are ready for AI agents\.\*\*/)
assert.match(embed.description, /\n- More PDF links open in SimplePDF\./)
assert.equal(embed.description.includes('full release notes'), false)
assert.equal(embed.description.includes('Updated dependencies'), false)
assert.match(embed.fields[0].value, /<script src="https:\/\/unpkg\.com\/@simplepdf\/web-embed-pdf" defer><\/script>/)
assert.match(embed.fields[0].value, /npm install @simplepdf\/web-embed-pdf@1\.9\.0/)
assert.match(embed.fields[1].value, /\[Docs\]\(https:\/\/github\.com\/SimplePDF\/simplepdf-embed\/tree\/main\/web#readme\)/)
})

test('a package without a script tag build only shows the npm install, and breaking releases turn red', () => {
const reactPackage = { ...WEB_PACKAGE, name: '@simplepdf/react-embed-pdf', version: '2.0.0', unpkg: undefined, repository: { directory: 'react' } }
const sections = { 'Major Changes': ['`companyIdentifier` is renamed.'] }
const message = buildReleaseMessage({ packageJson: reactPackage, sections, repositoryUrl: REPOSITORY_URL, alsoReleased: ['@simplepdf/embed 0.8.1 (dependency updates)'] })
const [embed] = message.embeds

assert.equal(embed.color, 0xcc2222)
assert.match(embed.description, /^\*\*Breaking changes\*\*/)
assert.equal(embed.fields[0].value.includes('<script'), false)
assert.deepEqual(embed.fields[2], { name: 'Also released', value: '@simplepdf/embed 0.8.1 (dependency updates)' })
})

test('long notes are cut at an entry boundary, in order, and point to the full release notes', () => {
const longEntry = (label) => `${label} ${'detail '.repeat(150)}`.trim()
const sections = {
'Minor Changes': [longEntry('first'), longEntry('second'), longEntry('third'), longEntry('fourth')],
'Patch Changes': ['short fix'],
}
const { description } = buildReleaseMessage({ packageJson: WEB_PACKAGE, sections, repositoryUrl: REPOSITORY_URL, alsoReleased: [] }).embeds[0]

assert.ok(description.length <= 4096)
assert.match(description, /- first /)
assert.equal(description.includes('short fix'), false)
assert.match(description, /…and \d+ more in the \[full release notes\]\(/)
})
Loading