#!/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 = '' 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/: ${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('|')}>--.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 }