scripts/changelog.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import {
MANIFESTS,
TIERS,
decodeUtf8,
latestRelease,
manifestVersion,
nextVersion,
parseVersion,
} from './changelog-version.mjs'
const KINDS = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security']
const FILENAME = new RegExp(
`^(${TIERS.join('|')})-(${KINDS.join('|')})-[a-z0-9]+(?:-[a-z0-9]+)*\\.md$`,
)
const PRODUCT = /^projects\/([a-z0-9]+(?:-[a-z0-9]+)*)$/
const HIGHLIGHTS = /^## Highlights[ \t]*\r?$/m
const LOCALE_PATTERN = '[a-z]{2}_[A-Z]{2}'
const LOCALE = new RegExp(`^${LOCALE_PATTERN}$`)
const TRANSLATION = new RegExp(`^(.+)\\.(${LOCALE_PATTERN})\\.md$`)
const HIGHLIGHTS_MARKER = '<!-- highlights -->'
const ENGLISH = 'en_US'
function run(command, args, options = {}) {
const result = spawnSync(command, args, { maxBuffer: Infinity, ...options })
if (result.error) throw result.error
if (result.status !== 0)
throw new Error(
decodeUtf8(result.stderr).trim() ||
`${command} failed (${result.status})`,
)
return result.stdout
}
function git(...args) {
return run('git', args)
}
function productPath(value) {
value = value.replace(/\/+$/, '')
if (!PRODUCT.test(value) || !Object.hasOwn(MANIFESTS, basename(value))) {
throw new Error(`expected projects/<product>: ${JSON.stringify(value)}`)
}
return value
}
function fragmentFilename(path) {
const match = FILENAME.exec(basename(path))
if (!match || match[0] !== basename(path))
throw new Error(`${path}: expected <${TIERS.join('|')}>-<kind>-<name>.md`)
return match
}
function isLocale(value) {
return LOCALE.exec(value)?.[0] === value
}
function translationTarget(path) {
const match = TRANSLATION.exec(basename(path))
return match && match[0] === basename(path)
? { path: `${dirname(path)}/${match[1]}.md`, locale: match[2] }
: null
}
function fragmentBody(path, body) {
if (!body) throw new Error(`${path}: empty fragment`)
if (
/^\s{0,3}#{1,6}(?:\s|$)/m.test(body) ||
/^\s{0,3}(?:=+|-+)\s*$/m.test(body)
) {
throw new Error(`${path}: headings are not allowed`)
}
if (!/^[-+*]\s+\S/.test(body))
throw new Error(`${path}: body must start with a Markdown bullet item`)
return body
}
function parseFragment(path, data, requireMetadata = true) {
const match = fragmentFilename(path)
let body = decodeUtf8(data).trim()
let releaseNotes = null
const metadata =
/^---\r?\n(release-notes|user-facing): (true|false)\r?\n---(?:\r?\n|$)/.exec(
body,
)
if (metadata && (!requireMetadata || metadata[1] === 'release-notes')) {
releaseNotes = metadata[2] === 'true'
body = body.slice(metadata[0].length).trim()
} else if (requireMetadata || body.startsWith('---')) {
throw new Error(
`${path}: expected frontmatter with release-notes: true or false`,
)
}
return {
path,
tier: match[1],
kind: match[2],
data,
body: fragmentBody(path, body),
releaseNotes,
translations: new Map(),
}
}
function collectFragments(entries, requireMetadata = true) {
const fragments = new Map()
const translations = []
for (const [path, data] of entries) {
const target = translationTarget(path)
if (target) {
fragmentFilename(target.path)
if (target.locale === ENGLISH)
throw new Error(`${path}: English belongs in the canonical fragment`)
translations.push({
...target,
source: path,
data,
body: fragmentBody(path, decodeUtf8(data).trim()),
})
} else fragments.set(path, parseFragment(path, data, requireMetadata))
}
for (const { path, source, locale, data, body } of translations) {
const fragment = fragments.get(path)
if (!fragment)
throw new Error(`${source}: missing canonical fragment ${path}`)
fragment.translations.set(locale, { path: source, data, body })
}
return fragments
}
function fragmentFiles(fragment) {
return [fragment, ...fragment.translations.values()]
}
function sameFragment(a, b) {
const files = new Map(fragmentFiles(a).map(file => [file.path, file.data]))
return (
files.size === fragmentFiles(b).length &&
fragmentFiles(b).every(file => files.get(file.path)?.equals(file.data))
)
}
function hasSymlink(path) {
for (let part = resolve(path); ; part = dirname(part)) {
try {
if (lstatSync(part).isSymbolicLink()) return true
} catch (error) {
if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error
}
if (dirname(part) === part) return false
}
}
function workingFragments(product) {
const directory = `${product}/changelog`
if (hasSymlink(directory))
throw new Error(`${directory}: symlinks are not allowed`)
if (!existsSync(directory)) return new Map()
if (!lstatSync(directory).isDirectory())
throw new Error(`${directory}: expected directory`)
const fragments = new Map()
for (const name of readdirSync(directory).sort()) {
const path = `${directory}/${name}`
if (!lstatSync(path).isFile())
throw new Error(
`${path}: expected a regular file, not a symlink or directory`,
)
fragments.set(path, readFileSync(path))
}
return collectFragments(fragments)
}
function treeEntry(ref, path) {
return (
decodeUtf8(git('ls-tree', '-z', ref, '--', path)).split('\0')[0] || null
)
}
function regularBlob(metadata, location) {
const [mode, kind, blob] = metadata.split(' ')
if (kind !== 'blob' || !['100644', '100755'].includes(mode))
throw new Error(`${location}: expected a regular file`)
return git('cat-file', 'blob', blob)
}
function taggedFragments(product, ref) {
const directory = `${product}/changelog`
const entry = treeEntry(ref, directory)
if (entry === null) return new Map()
if (!entry.startsWith('040000 tree '))
throw new Error(`${ref}:${directory}: expected directory`)
const fragments = new Map()
for (const entry of decodeUtf8(
git('ls-tree', '-z', `${ref}:${directory}`),
).split('\0')) {
if (!entry) continue
const separator = entry.indexOf('\t')
const path = `${directory}/${entry.slice(separator + 1)}`
fragments.set(
path,
regularBlob(entry.slice(0, separator), `${ref}:${path}`),
)
}
return collectFragments(fragments, false)
}
function taggedFile(path, ref) {
const entry = treeEntry(ref, path)
return entry === null
? Buffer.alloc(0)
: regularBlob(entry.split('\t')[0], `${ref}:${path}`)
}
function sourceFile(path, ref) {
return ref
? taggedFile(path, ref)
: existsSync(path)
? readFileSync(path)
: Buffer.alloc(0)
}
function sourceFragments(product, ref) {
return ref ? taggedFragments(product, ref) : workingFragments(product)
}
function originTags() {
return memo('origin tags', () =>
releaseTags(git('ls-remote', '--tags', 'origin')),
)
}
function localTags() {
return releaseTags(
git(
'for-each-ref',
'--format=%(if)%(*objectname)%(then)%(*objectname)%(else)%(objectname)%(end)%09%(refname)',
'refs/tags',
),
)
}
function releaseTags(output) {
const tags = new Map()
for (const line of decodeUtf8(output).trim().split('\n')) {
if (!line) continue
let [sha, name] = line.split('\t')
name = name.replace(/^refs\/tags\//, '')
const peeled = name.endsWith('^{}')
name = name.replace(/\^\{\}$/, '')
const match = /^([a-z0-9]+(?:-[a-z0-9]+)*)\/v(.+)$/.exec(name)
if (!match || !Object.hasOwn(MANIFESTS, match[1])) continue
const [, prerelease] = parseVersion(match[1], match[2])
if (match[1] === 'start-os' && prerelease?.match(/^rc\.[1-9][0-9]*$/))
continue
if (!tags.has(name) || peeled) tags.set(name, sha)
}
return tags
}
function compareText(a, b) {
return a < b ? -1 : a > b ? 1 : 0
}
const memos = new Map()
function memo(key, compute) {
if (!memos.has(key)) memos.set(key, compute())
return memos.get(key)
}
function originReleases(ref, product = null, tags = originTags()) {
const head = decodeUtf8(
git('rev-parse', '--verify', `${ref}^{commit}`),
).trim()
const positions = memo(`rev-list ${head}`, () => {
const ordered = decodeUtf8(
git('rev-list', '--topo-order', '--reverse', head),
)
.trim()
.split('\n')
return new Map(ordered.map((commit, index) => [commit, index]))
})
const releases = []
for (const [tag, sha] of tags) {
if (product && !tag.startsWith(`${basename(product)}/v`)) continue
const commit = memo(`commit ${sha}`, () =>
decodeUtf8(git('rev-parse', '--verify', `${sha}^{commit}`)).trim(),
)
if (!positions.has(commit)) continue
const [name, version] = tag.split('/v')
const path = `projects/${name}`
releases.push({
product: path,
version,
commit,
fragments: memo(`fragments ${path} ${commit}`, () =>
taggedFragments(path, commit),
),
})
}
return releases.sort(
(a, b) =>
positions.get(a.commit) - positions.get(b.commit) ||
compareText(a.product, b.product) ||
compareText(a.version, b.version),
)
}
function isAncestor(older, newer) {
const result = spawnSync(
'git',
['merge-base', '--is-ancestor', older, newer],
{ maxBuffer: Infinity },
)
if (result.error) throw result.error
if (![0, 1].includes(result.status))
throw new Error(decodeUtf8(result.stderr).trim())
return result.status === 0
}
function deletedBetween(path, source, target) {
return (
git(
'log',
'--format=%H',
'--diff-filter=D',
'--no-renames',
`${source}..${target}`,
'--',
path,
).length > 0
)
}
function ownedFragments(release, earlier) {
const ancestors = earlier.filter(
previous =>
previous.product === release.product &&
isAncestor(previous.commit, release.commit),
)
return [...release.fragments.values()].filter(
fragment =>
!ancestors.some(previous => {
const old = previous.fragments.get(fragment.path)
return (
old &&
sameFragment(old, fragment) &&
!fragmentFiles(fragment).some(file =>
deletedBetween(file.path, previous.commit, release.commit),
)
)
}),
)
}
function fragmentLines(fragments, locale = ENGLISH) {
return [...fragments]
.sort((a, b) => compareText(a.path, b.path))
.flatMap(fragment => [
fragment.translations.get(locale)?.body ?? fragment.body,
'',
])
}
function releaseSection(version, fragments) {
const lines = [`## [${version}]`, '']
for (const kind of KINDS) {
const group = fragments.filter(item => item.kind === kind)
if (group.length)
lines.push(
`### ${kind[0].toUpperCase()}${kind.slice(1)}`,
'',
...fragmentLines(group),
)
}
return Buffer.from(`${lines.join('\n')}\n`)
}
function markdownFormatter() {
const pin = JSON.parse(decodeUtf8(readFileSync('package.json')))
.devDependencies.prettier
const config = resolve('.prettierrc.json')
const cache = new Map()
return data => {
const text = decodeUtf8(data)
if (!cache.has(text)) {
cache.set(
text,
run(
'npx',
[
'--yes',
`--package=prettier@${pin}`,
'prettier',
'--config',
config,
'--parser',
'markdown',
],
{ input: data },
),
)
}
return cache.get(text)
}
}
function compiledSection(history, version) {
const text = decodeUtf8(history)
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const heading = new RegExp(
`^##\\s+\\[${escaped}\\](?:[^\\r\\n]*)\\r?\\n`,
'm',
).exec(text)
if (heading) {
const start = heading.index
const afterHeading = start + heading[0].length
const next = /^## /m.exec(text.slice(afterHeading))
const end = next ? afterHeading + next.index : text.length
return Buffer.from(text.slice(start, end))
}
return Buffer.alloc(0)
}
function prependSection(history, version, fragments, format) {
if (!fragments.length) return history
const section = releaseSection(version, fragments)
const existing = compiledSection(history, version)
if (existing.length) {
if (!format(existing).equals(format(section))) {
throw new Error(
`release ${version} already has a different compiled changelog; do not move released tags`,
)
}
return history
}
const text = decodeUtf8(history)
const first = /^## /m.exec(text)
const position = first
? Buffer.byteLength(text.slice(0, first.index))
: history.length
const prefix = history.subarray(0, position)
const separator =
!prefix.length || /(?:\r?\n){2}$/.test(decodeUtf8(prefix))
? ''
: prefix.at(-1) === 10
? '\n'
: '\n\n'
return Buffer.concat([
prefix,
Buffer.from(separator),
section,
history.subarray(position),
])
}
function compileReleases(history, releases) {
const original = history
let formatter
const format = data => (formatter ??= markdownFormatter())(data)
const earlier = []
const consumed = []
for (const release of releases) {
const owned = ownedFragments(release, earlier)
history = prependSection(history, release.version, owned, format)
consumed.push(...owned.map(fragment => [release.commit, fragment]))
earlier.push(release)
}
return [history.equals(original) ? history : format(history), consumed]
}
function render(product, version, ref) {
parseVersion(basename(product), version)
const commit = decodeUtf8(
git('rev-parse', '--verify', `${ref || 'HEAD'}^{commit}`),
).trim()
const path = `${product}/CHANGELOG.md`
const history = sourceFile(path, ref && commit)
const fragments = sourceFragments(product, ref && commit)
const releases = originReleases(commit, product).filter(
item => item.version !== version,
)
releases.push({ product, version, commit, fragments })
return compileReleases(history, releases)[0]
}
function notesPath(product, version, locale = ENGLISH) {
return `${product}/release-notes/${version}${locale === ENGLISH ? '' : `.${locale}`}.md`
}
function noteLocales(product, version, fragments, ref) {
const locales = new Set(
fragments
.filter(fragment => fragment.releaseNotes)
.flatMap(fragment => [...fragment.translations.keys()]),
)
for (const path of notePaths(product, ref)) {
const name = basename(path)
if (
dirname(path) !== `${product}/release-notes` ||
!name.startsWith(`${version}.`) ||
!name.endsWith('.md')
)
continue
const locale = name.slice(version.length + 1, -3)
if (isLocale(locale) && locale !== ENGLISH) locales.add(locale)
}
return [...locales].sort(compareText)
}
function notePaths(product, ref) {
const directory = `${product}/release-notes`
return ref
? decodeUtf8(
git('ls-tree', '-r', '--name-only', '-z', ref, '--', directory),
).split('\0')
: existsSync(directory)
? readdirSync(directory).map(name => `${directory}/${name}`)
: []
}
function locales(product, version, ref) {
parseVersion(basename(product), version)
return noteLocales(
product,
version,
[...sourceFragments(product, ref).values()],
ref,
)
}
function archivedNotePaths(product, ref = 'HEAD') {
const paths = new Set()
for (const release of originReleases(ref, product, localTags())) {
for (const locale of [
ENGLISH,
...noteLocales(
product,
release.version,
[...release.fragments.values()],
ref,
),
]) {
const path = notesPath(product, release.version, locale)
if (treeEntry(ref, path)) paths.add(path)
}
}
return [...paths].sort(compareText)
}
function hasReleaseNotesMetadata(fragments) {
return fragments.some(fragment => fragment.releaseNotes !== null)
}
function localizedNotesSource(path, fallback, ref) {
const notes = sourceFile(path, ref)
return notes.length || path === fallback
? { path, notes }
: { path: fallback, notes: sourceFile(fallback, ref) }
}
function noteFraming(product, version, locale, ref) {
return localizedNotesSource(
notesPath(product, version, locale),
notesPath(product, version, ENGLISH),
ref,
)
}
function compileNotes(path, notes, fragments, locale = ENGLISH, ref) {
const text = decodeUtf8(notes)
const parts = text.split(HIGHLIGHTS_MARKER)
if (!hasReleaseNotesMetadata(fragments) && parts.length === 1 && notes.length)
return notes
const labels = releaseNoteLabels(ref)
const visible = fragments.filter(fragment => fragment.releaseNotes)
const security = visible.filter(fragment => fragment.kind === 'security')
const securityHeading = `## ${releaseNoteLabel(labels, 'security-title', locale)}`
const englishSecurityHeading = `## ${releaseNoteLabel(labels, 'security-title', ENGLISH)}`
if (
text
.split(/\r?\n/)
.some(line =>
[securityHeading, englishSecurityHeading].includes(line.trimEnd()),
)
)
throw new Error(
`${path}: remove ${securityHeading}; it is compiled from fragments`,
)
const lines = [
...fragmentLines(
visible.filter(fragment => fragment.kind !== 'security'),
locale,
),
...(security.length
? [securityHeading, '', ...fragmentLines(security, locale)]
: []),
]
let composed
if (parts.length > 2)
throw new Error(`${path}: expected one ${HIGHLIGHTS_MARKER}`)
const heading =
parts.length === 2
? [...parts[0].matchAll(/^## [^\r\n]+\r?$/gm)].at(-1)
: null
const authored = HIGHLIGHTS.exec(text)
if (authored && authored.index !== heading?.index)
throw new Error(
`${path}: remove ## Highlights; it is compiled from fragments`,
)
if (parts.length === 2) {
const [before, after] = parts
const next = /^## /m.exec(after)
if (
!heading ||
before.slice(heading.index + heading[0].length).trim() ||
after.slice(0, next?.index ?? after.length).trim()
)
throw new Error(
`${path}: ${HIGHLIGHTS_MARKER} must occupy an empty section`,
)
composed = `${before.trimEnd()}\n\n${lines.join('\n')}\n${after.trimStart()}`
} else {
if (translationTarget(path) && notes.length)
throw new Error(`${path}: localized framing needs ${HIGHLIGHTS_MARKER}`)
const highlights = [
`## ${releaseNoteLabel(labels, 'highlights-title', locale)}`,
'',
...lines,
].join('\n')
const important = /^## Important[ \t]*\r?$/m.exec(text)
const position = important ? important.index : text.length
composed = `${text.slice(0, position).trimEnd()}\n\n${highlights}\n${text.slice(position)}`
}
return markdownFormatter()(Buffer.from(composed.trim() + '\n'))
}
function renderNotes(product, version, ref, locale = ENGLISH) {
parseVersion(basename(product), version)
const { path, notes } = noteFraming(product, version, locale, ref)
return compileNotes(
path,
notes,
releaseFragments(product, version, ref),
locale,
ref,
)
}
function releaseFragments(product, version, ref) {
parseVersion(basename(product), version)
const fragments = sourceFragments(product, ref)
if (!fragments.size) return []
const commit = decodeUtf8(
git('rev-parse', '--verify', `${ref || 'HEAD'}^{commit}`),
).trim()
const releases = originReleases(commit, product, localTags()).filter(
release => release.version !== version,
)
return ownedFragments({ product, version, commit, fragments }, releases)
}
function renderNotesBundle(product, version, ref) {
return Object.fromEntries(
[ENGLISH, ...locales(product, version, ref)].map(locale => [
locale,
decodeUtf8(renderNotes(product, version, ref, locale)),
]),
)
}
function preUpdateNotes(product, version, ref, locale = ENGLISH) {
const path = notesPath(product, version, locale).replace(
/\.md$/,
'.pre-update.md',
)
const fallback = notesPath(product, version).replace(
/\.md$/,
'.pre-update.md',
)
return decodeUtf8(localizedNotesSource(path, fallback, ref).notes)
}
function composeReleaseNotes(notes, warning) {
return warning ? `${warning}\n\n${notes}` : notes
}
function releaseNotes(product, version, ref) {
return composeReleaseNotes(
decodeUtf8(renderNotes(product, version, ref)),
preUpdateNotes(product, version, ref),
)
}
function releaseNoteLabels(ref) {
return memo(`labels ${ref ?? ''}`, () => loadReleaseNoteLabels(ref))
}
function loadReleaseNoteLabels(ref) {
const path = 'shared-libs/crates/start-core/locales/release-notes.json'
const tagged = ref ? decodeUtf8(sourceFile(path, ref)) : ''
const packaged = new URL(`../${path}`, import.meta.url)
// Tag workflows can stage the compiler alone.
const defaults = JSON.parse(
readFileSync(existsSync(packaged) ? packaged : path),
)
return tagged ? { ...defaults, ...JSON.parse(tagged) } : defaults
}
function releaseNoteLabel(labels, key, locale) {
const translations = labels[`release-notes.${key}`]
return translations[locale] ?? translations[ENGLISH]
}
function changelogLink(product, version, ref, locale, labels) {
const base = 'https://github.com/Start9Labs/start-technologies'
let url = `${base}/releases/tag/${basename(product)}/v${version}`
let pending = false
if (ref) {
pending =
spawnSync('git', ['cat-file', '-e', `${ref}:${product}/changelog`])
.status === 0
const anchor = version.replaceAll('.', '').replaceAll('+', '').toLowerCase()
url = pending
? `${base}/tree/${ref}/${product}/changelog`
: `${base}/blob/${ref}/${product}/CHANGELOG.md#${anchor}`
}
const label = releaseNoteLabel(
labels,
pending ? 'full-changelog-fragments' : 'full-changelog',
locale,
)
return label.replaceAll('%{version}', version).replaceAll('%{url}', url)
}
function placeChangelogLink(notes, link) {
const lines = notes.split('\n')
const output = []
let dropped = false
for (const line of lines) {
if (line.startsWith('**[Full changelog')) {
dropped = true
continue
}
if (dropped) {
dropped = false
if (!line) continue
}
output.push(line)
}
const body = output.join('\n').trimEnd()
return `${body}${body ? '\n\n' : ''}${link}\n`
}
function registryNotes(product, version, ref, changelogRef) {
const bundle = renderNotesBundle(product, version, ref)
const labels = releaseNoteLabels(ref)
for (const path of notePaths(product, ref)) {
const prefix = `${product}/release-notes/${version}.`
if (!path.startsWith(prefix) || !path.endsWith('.pre-update.md')) continue
const locale = path.slice(prefix.length, -'.pre-update.md'.length)
if (
isLocale(locale) &&
locale !== ENGLISH &&
!Object.hasOwn(bundle, locale)
)
bundle[locale] = decodeUtf8(renderNotes(product, version, ref, locale))
}
const composed = Object.fromEntries(
Object.entries(bundle).map(([locale, notes]) => {
return [
locale,
placeChangelogLink(
composeReleaseNotes(
notes,
preUpdateNotes(product, version, ref, locale),
),
changelogLink(product, version, changelogRef, locale, labels),
),
]
}),
)
return {
releaseNotes:
Object.keys(composed).length === 1 ? composed[ENGLISH] : composed,
}
}
function checkManifestVersion(
product,
version = manifestVersion(process.cwd(), basename(product)),
) {
const name = basename(product)
parseVersion(name, version)
const fragments = workingFragments(product)
const tags = originTags()
const baseline = latestRelease(name, tags.keys())
if (baseline === null) return
const commit = decodeUtf8(
git('rev-parse', '--verify', 'HEAD^{commit}'),
).trim()
const releases = originReleases(commit, product, tags)
const pending = ownedFragments(
{ product, version, commit, fragments },
releases,
)
const expected = nextVersion(
name,
baseline,
pending.map(fragment => fragment.tier),
)
if (version !== expected)
throw new Error(
`${product}: version '${version}' does not match expected '${expected}' from latest stable origin release '${baseline}' and pending fragments`,
)
}
function changedProducts(base) {
const paths = new Set(
decodeUtf8(
git('diff', '--name-only', '-z', '--no-renames', `${base}...HEAD`),
).split('\0'),
)
return Object.entries(MANIFESTS)
.filter(([name, manifest]) => {
const directory = `projects/${name}/changelog`
return (
paths.has(manifest) ||
paths.has(directory) ||
[...paths].some(path => path.startsWith(`${directory}/`))
)
})
.map(([name]) => `projects/${name}`)
}
function unchangedFragment(fragment, source) {
const files = fragmentFiles(fragment)
for (const file of files) {
if (
deletedBetween(file.path, source, 'HEAD') ||
hasSymlink(file.path) ||
!existsSync(file.path) ||
!lstatSync(file.path).isFile() ||
!readFileSync(file.path).equals(file.data)
)
return false
}
const paths = new Set(files.map(file => file.path))
return readdirSync(dirname(fragment.path)).every(name => {
const path = `${dirname(fragment.path)}/${name}`
return translationTarget(path)?.path !== fragment.path || paths.has(path)
})
}
function sync() {
const releases = originReleases('HEAD')
const updates = []
for (const product of [
...new Set(releases.map(release => release.product)),
].sort()) {
const path = `${product}/CHANGELOG.md`
if (hasSymlink(path)) throw new Error(`${path}: symlinks are not allowed`)
const history = existsSync(path) ? readFileSync(path) : Buffer.alloc(0)
const [compiled, consumed] = compileReleases(
history,
releases.filter(item => item.product === product),
)
for (const [index, release] of releases.entries()) {
if (release.product !== product) continue
const fragments = ownedFragments(release, releases.slice(0, index))
if (!hasReleaseNotesMetadata(fragments)) continue
for (const locale of [
ENGLISH,
...noteLocales(product, release.version, fragments, release.commit),
]) {
const path = notesPath(product, release.version, locale)
if (hasSymlink(path))
throw new Error(`${path}: symlinks are not allowed`)
const source = taggedFile(path, release.commit)
const framing = noteFraming(
product,
release.version,
locale,
release.commit,
)
const compiled = compileNotes(
framing.path,
framing.notes,
fragments,
locale,
release.commit,
)
const existing = existsSync(path) ? readFileSync(path) : Buffer.alloc(0)
if (
existing.length &&
!existing.equals(compiled) &&
!existing.equals(source)
)
throw new Error(
`${path}: release notes differ from the tagged source and compiled output`,
)
updates.push({ path, history: existing, compiled, consumed: [] })
}
}
updates.push({ path, history, compiled, consumed })
}
for (const { path, history, compiled, consumed } of updates) {
if (!compiled.equals(history)) {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, compiled)
}
for (const [source, fragment] of consumed) {
if (!unchangedFragment(fragment, source)) continue
for (const file of fragmentFiles(fragment)) unlinkSync(file.path)
}
}
}
const USAGE =
'usage: changelog.mjs projects | changed BASE | validate PRODUCT | version PRODUCT | check-version PRODUCT [VERSION] | render PRODUCT VERSION [--ref REF] | notes PRODUCT VERSION [--ref REF] [--locale LOCALE] | security PRODUCT VERSION [--ref REF] | notes-bundle PRODUCT VERSION [--ref REF] | release-notes PRODUCT VERSION [--ref REF] | registry-notes PRODUCT VERSION [--ref REF] [--changelog-ref REF] | locales PRODUCT VERSION [--ref REF] | archives PRODUCT [--ref REF] | section VERSION | sync'
try {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
ref: { type: 'string' },
locale: { type: 'string' },
'changelog-ref': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
const [command, ...args] = positionals
const counts = {
projects: [0],
changed: [1],
validate: [1],
version: [1],
'check-version': [1, 2],
render: [2],
notes: [2],
security: [2],
'notes-bundle': [2],
'release-notes': [2],
'registry-notes': [2],
locales: [2],
archives: [1],
section: [1],
sync: [0],
}
if (
!values.help &&
(!counts[command]?.includes(args.length) ||
(values.ref !== undefined &&
![
'render',
'notes',
'security',
'notes-bundle',
'release-notes',
'registry-notes',
'locales',
'archives',
].includes(command)) ||
(values['changelog-ref'] !== undefined && command !== 'registry-notes') ||
(values.locale !== undefined &&
(command !== 'notes' || !isLocale(values.locale))))
) {
throw new Error(USAGE)
}
if (values.help) console.log(USAGE)
else if (command === 'projects') console.log(Object.keys(MANIFESTS).join(' '))
else if (command === 'changed')
console.log(changedProducts(args[0]).join(' '))
else if (command === 'validate') workingFragments(productPath(args[0]))
else if (command === 'version')
console.log(manifestVersion(process.cwd(), basename(productPath(args[0]))))
else if (command === 'check-version')
checkManifestVersion(productPath(args[0]), args[1])
else if (command === 'render')
process.stdout.write(render(productPath(args[0]), args[1], values.ref))
else if (command === 'notes')
process.stdout.write(
renderNotes(productPath(args[0]), args[1], values.ref, values.locale),
)
else if (command === 'security')
console.log(
releaseFragments(productPath(args[0]), args[1], values.ref).some(
fragment => fragment.kind === 'security',
),
)
else if (command === 'notes-bundle')
console.log(
JSON.stringify(
renderNotesBundle(productPath(args[0]), args[1], values.ref),
),
)
else if (command === 'release-notes')
process.stdout.write(
releaseNotes(productPath(args[0]), args[1], values.ref),
)
else if (command === 'registry-notes')
console.log(
JSON.stringify(
registryNotes(
productPath(args[0]),
args[1],
values.ref,
values['changelog-ref'],
),
),
)
else if (command === 'locales')
console.log(locales(productPath(args[0]), args[1], values.ref).join(' '))
else if (command === 'archives')
process.stdout.write(
archivedNotePaths(productPath(args[0]), values.ref)
.map(path => `${path}\n`)
.join(''),
)
else if (command === 'section')
process.stdout.write(compiledSection(readFileSync(0), args[0]))
else sync()
} catch (error) {
process.stderr.write(`changelog: ${error.message}\n`)
process.exitCode = 1
}