Every release used to get its releases/open-brainy.json entry typed by hand after the fact. scripts/wall-entry.mjs derives it from the CHANGELOG entry release.sh just composed (headline = first bullet, items = every bullet, hash stripped) and prepends it, refusing by name on a duplicate version and validating the whole file's shape + newest-first ordering before and after it writes. release.sh now runs it as its own step, between the CHANGELOG update and the release commit, and stages releases/open-brainy.json into that commit. The product engine's rail runs this identical script against its own releases/brainy.json, unchanged — each repo's wall file lives beside the CHANGELOG it derives from; there is no cross-repo step. A --check mode validates a wall file's exact key set, field types, and newest-first ordering with no duplicates, read-only. tests/unit/release/wall-entry.test.ts covers derivation, prepend, duplicate refusal, and --check's shape/ordering checks over temp copies — never the real files. --check also runs green against both releases/open-brainy.json and releases/brainy.json as they stand today.
364 lines
15 KiB
JavaScript
364 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* @module scripts/wall-entry
|
|
* @description The releases-wall entry, made mechanical. The fleet's HQ page
|
|
* reads one public JSON per product (releases/<product>.json — shape
|
|
* {product, entries:[{version, date, headline, items, url, thumb}], history}).
|
|
* Those entries were hand-written after every release; this script is the
|
|
* one door that composes one, so it never has to be typed by hand again.
|
|
*
|
|
* Two modes:
|
|
*
|
|
* 1. Generate + write in place (default):
|
|
* node wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> \
|
|
* --from-changelog <CHANGELOG.md> [--file releases/<p>.json]
|
|
* Derives an entry from the CHANGELOG.md entry for <v> (headline = the
|
|
* entry's first bullet, items = every bullet, trimmed of its trailing
|
|
* commit hash), prepends it to --file (default releases/<product>.json,
|
|
* newest first), refusing by name if <v> is already present, and
|
|
* validates the whole file's shape + ordering before and after writing.
|
|
* Both engines run this identically, each against its own repo's
|
|
* releases/<product>.json — the wall file always lives beside the
|
|
* CHANGELOG it is derived from, never in another repo.
|
|
*
|
|
* 2. Validate only (--check):
|
|
* node wall-entry.mjs --check --file <releases/p.json>
|
|
* Validates the file's exact key set (top-level and per-entry), field
|
|
* types, and strict-descending semver ordering with no duplicates.
|
|
* Read-only; never writes. Exit 0 = clean, exit 1 = named violations
|
|
* printed to stderr.
|
|
*
|
|
* No dependencies — CHANGELOG parsing, semver comparison, and JSON shape
|
|
* checking are all hand-rolled below.
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
|
|
const ENTRY_KEYS = ['version', 'date', 'headline', 'items', 'url', 'thumb']
|
|
const FILE_KEYS = ['product', 'entries', 'history']
|
|
|
|
// The public release-page URL pattern, by product — only products with a
|
|
// PUBLIC forge repo get a derived link. A product without an entry here
|
|
// (e.g. "brainy", whose repo is private) gets url: null, matching every
|
|
// entry the fleet has shipped for it so far — a private link would 404 for
|
|
// anyone reading the public HQ page.
|
|
const RELEASE_URL_PATTERNS = {
|
|
'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`,
|
|
}
|
|
|
|
/**
|
|
* Parse argv into a flag map. `--flag value` sets a string; `--flag` alone
|
|
* (end of argv, or followed by another `--flag`) sets boolean true.
|
|
* @param {string[]} argv
|
|
* @returns {Record<string, string | true>}
|
|
*/
|
|
function parseArgs(argv) {
|
|
/** @type {Record<string, string | true>} */
|
|
const args = {}
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i]
|
|
if (!a.startsWith('--')) continue
|
|
const key = a.slice(2)
|
|
const next = argv[i + 1]
|
|
if (next === undefined || next.startsWith('--')) {
|
|
args[key] = true
|
|
} else {
|
|
args[key] = next
|
|
i++
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
/**
|
|
* Print a loud, named error and exit 1. Every refusal in this script goes
|
|
* through here so the failure mode is always the same shape: "wall-entry: <what>".
|
|
* @param {string} message
|
|
* @returns {never}
|
|
*/
|
|
function fail(message) {
|
|
console.error(`wall-entry: ${message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
/**
|
|
* @param {string} version
|
|
* @returns {{major: number, minor: number, patch: number, pre: string | null} | null}
|
|
*/
|
|
function parseSemver(version) {
|
|
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version)
|
|
if (!m) return null
|
|
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
|
|
}
|
|
|
|
/**
|
|
* @param {string} a
|
|
* @param {string} b
|
|
* @returns {number} positive if a > b, negative if a < b, 0 if equal.
|
|
*/
|
|
function compareSemver(a, b) {
|
|
const pa = parseSemver(a)
|
|
const pb = parseSemver(b)
|
|
if (!pa || !pb) throw new Error(`cannot compare non-semver versions "${a}" vs "${b}"`)
|
|
if (pa.major !== pb.major) return pa.major - pb.major
|
|
if (pa.minor !== pb.minor) return pa.minor - pb.minor
|
|
if (pa.patch !== pb.patch) return pa.patch - pb.patch
|
|
if (pa.pre === pb.pre) return 0
|
|
if (pa.pre === null) return 1 // a release outranks any prerelease of the same core version
|
|
if (pb.pre === null) return -1
|
|
return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
|
|
}
|
|
|
|
/**
|
|
* Validate a wall file's full shape: top-level keys, per-entry keys and
|
|
* field types, and strict-descending semver ordering with no duplicates.
|
|
* Collects every violation instead of failing on the first, so --check
|
|
* reports the whole picture in one pass.
|
|
* @param {unknown} data
|
|
* @returns {string[]} Violation messages; empty means the file is clean.
|
|
*/
|
|
function validateShape(data) {
|
|
/** @type {string[]} */
|
|
const errors = []
|
|
|
|
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
return ['top level: expected a JSON object']
|
|
}
|
|
const obj = /** @type {Record<string, unknown>} */ (data)
|
|
|
|
const topKeys = Object.keys(obj)
|
|
const missingTop = FILE_KEYS.filter((k) => !(k in obj))
|
|
const extraTop = topKeys.filter((k) => !FILE_KEYS.includes(k))
|
|
if (missingTop.length) errors.push(`top level: missing key(s) ${missingTop.join(', ')}`)
|
|
if (extraTop.length) errors.push(`top level: unexpected key(s) ${extraTop.join(', ')}`)
|
|
|
|
if (typeof obj.product !== 'string' || obj.product.trim() === '') {
|
|
errors.push('top level: "product" must be a non-empty string')
|
|
}
|
|
if (typeof obj.history !== 'string' || obj.history.trim() === '') {
|
|
errors.push('top level: "history" must be a non-empty string')
|
|
}
|
|
if (!Array.isArray(obj.entries)) {
|
|
errors.push('top level: "entries" must be an array')
|
|
return errors // nothing further to check without an array
|
|
}
|
|
|
|
const entries = /** @type {unknown[]} */ (obj.entries)
|
|
entries.forEach((rawEntry, i) => {
|
|
const label = `entries[${i}]`
|
|
if (typeof rawEntry !== 'object' || rawEntry === null || Array.isArray(rawEntry)) {
|
|
errors.push(`${label}: expected an object`)
|
|
return
|
|
}
|
|
const entry = /** @type {Record<string, unknown>} */ (rawEntry)
|
|
const keys = Object.keys(entry)
|
|
const missing = ENTRY_KEYS.filter((k) => !(k in entry))
|
|
const extra = keys.filter((k) => !ENTRY_KEYS.includes(k))
|
|
if (missing.length) errors.push(`${label}: missing key(s) ${missing.join(', ')}`)
|
|
if (extra.length) errors.push(`${label}: unexpected key(s) ${extra.join(', ')}`)
|
|
|
|
if (typeof entry.version !== 'string' || !parseSemver(entry.version)) {
|
|
errors.push(`${label}: "version" must be a semver string (got ${JSON.stringify(entry.version)})`)
|
|
}
|
|
if (typeof entry.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(entry.date))) {
|
|
errors.push(`${label}: "date" must be a YYYY-MM-DD string (got ${JSON.stringify(entry.date)})`)
|
|
}
|
|
if (typeof entry.headline !== 'string' || entry.headline.trim() === '') {
|
|
errors.push(`${label}: "headline" must be a non-empty string`)
|
|
}
|
|
if (!Array.isArray(entry.items) || entry.items.length === 0 || entry.items.some((it) => typeof it !== 'string' || it.trim() === '')) {
|
|
errors.push(`${label}: "items" must be a non-empty array of non-empty strings`)
|
|
}
|
|
if (!(entry.url === null || typeof entry.url === 'string')) {
|
|
errors.push(`${label}: "url" must be a string or null`)
|
|
}
|
|
if (!(entry.thumb === null || typeof entry.thumb === 'string')) {
|
|
errors.push(`${label}: "thumb" must be a string or null`)
|
|
}
|
|
})
|
|
|
|
// Ordering: newest first, strictly descending, no duplicate versions —
|
|
// checked only over entries whose version parsed (a bad version is
|
|
// already reported above; comparing it too would just be noise).
|
|
const versioned = entries
|
|
.map((e, i) => ({ i, version: /** @type {any} */ (e)?.version }))
|
|
.filter((e) => typeof e.version === 'string' && parseSemver(e.version))
|
|
for (let i = 0; i < versioned.length - 1; i++) {
|
|
const a = versioned[i]
|
|
const b = versioned[i + 1]
|
|
const cmp = compareSemver(a.version, b.version)
|
|
if (cmp === 0) {
|
|
errors.push(`entries[${a.i}] and entries[${b.i}]: duplicate version ${a.version}`)
|
|
} else if (cmp < 0) {
|
|
errors.push(`entries[${a.i}] (${a.version}) sits above entries[${b.i}] (${b.version}) — not newest-first`)
|
|
}
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
/**
|
|
* Extract one version's entry body from a standard-version-style CHANGELOG.md
|
|
* (headings `### [version](url) (date)`, followed by `- bullet (hash)` lines
|
|
* until the next heading or EOF).
|
|
* @param {string} changelog
|
|
* @param {string} version
|
|
* @returns {string[]} Bullet lines, trimmed of their leading "- " and
|
|
* trailing " (hash)".
|
|
*/
|
|
function extractChangelogBullets(changelog, version) {
|
|
const lines = changelog.split('\n')
|
|
const headingRe = /^### \[([^\]]+)\]\(.*\)\s*\(\d{4}-\d{2}-\d{2}\)\s*$/
|
|
let start = -1
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const m = headingRe.exec(lines[i])
|
|
if (m && m[1] === version) {
|
|
start = i + 1
|
|
break
|
|
}
|
|
}
|
|
if (start === -1) {
|
|
fail(
|
|
`version ${version} has no CHANGELOG entry yet — run this after the CHANGELOG step composes "### [${version}]", not before`,
|
|
)
|
|
}
|
|
/** @type {string[]} */
|
|
const bullets = []
|
|
for (let i = start; i < lines.length; i++) {
|
|
if (headingRe.test(lines[i])) break // next entry starts
|
|
const bulletMatch = /^- (.+?)(?:\s\(([0-9a-f]{6,40})\))?$/.exec(lines[i].trim())
|
|
if (lines[i].trim().startsWith('- ') && bulletMatch) {
|
|
const text = bulletMatch[1].trim()
|
|
if (text) bullets.push(text)
|
|
}
|
|
}
|
|
if (bullets.length === 0) {
|
|
fail(`version ${version}'s CHANGELOG entry has no bullets to derive a headline/items from`)
|
|
}
|
|
return bullets
|
|
}
|
|
|
|
/**
|
|
* Derive a wall entry from a CHANGELOG.md.
|
|
* @param {{product: string, version: string, date: string, changelogPath: string, url?: string | null, thumb?: string | null}} opts
|
|
* @returns {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}}
|
|
*/
|
|
function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
|
if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
|
|
fail(`--date "${date}" is not a YYYY-MM-DD date`)
|
|
}
|
|
if (!existsSync(changelogPath)) fail(`--from-changelog "${changelogPath}" does not exist`)
|
|
|
|
const changelog = readFileSync(changelogPath, 'utf8')
|
|
const items = extractChangelogBullets(changelog, version)
|
|
const headline = items[0]
|
|
|
|
const resolvedUrl = url !== undefined ? url : (RELEASE_URL_PATTERNS[product]?.(version) ?? null)
|
|
const resolvedThumb = thumb !== undefined ? thumb : null
|
|
|
|
return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
|
|
}
|
|
|
|
/**
|
|
* Load and shape-validate a wall file.
|
|
* @param {string} filePath
|
|
* @returns {Record<string, any>}
|
|
*/
|
|
function loadWallFile(filePath) {
|
|
if (!existsSync(filePath)) fail(`--file "${filePath}" does not exist`)
|
|
/** @type {unknown} */
|
|
let data
|
|
try {
|
|
data = JSON.parse(readFileSync(filePath, 'utf8'))
|
|
} catch (err) {
|
|
fail(`--file "${filePath}" is not valid JSON: ${/** @type {Error} */ (err).message}`)
|
|
}
|
|
const errors = validateShape(data)
|
|
if (errors.length) {
|
|
fail(`--file "${filePath}" fails shape validation before any write —\n ${errors.join('\n ')}`)
|
|
}
|
|
return /** @type {Record<string, any>} */ (data)
|
|
}
|
|
|
|
/**
|
|
* Prepend `entry` to the wall file at `filePath`, refusing by name if the
|
|
* version is already present, validating before and after, and writing the
|
|
* file back with the repo's exact formatting (2-space JSON, trailing newline).
|
|
* @param {{version: string, date: string, headline: string, items: string[], url: string | null, thumb: string | null}} entry
|
|
* @param {string} filePath
|
|
* @param {string | undefined} expectedProduct
|
|
*/
|
|
function applyEntry(entry, filePath, expectedProduct) {
|
|
const wall = loadWallFile(filePath)
|
|
|
|
if (expectedProduct && wall.product !== expectedProduct) {
|
|
fail(
|
|
`--file "${filePath}" has product "${wall.product}", but --product "${expectedProduct}" was given — refusing a cross-product write`,
|
|
)
|
|
}
|
|
|
|
if (wall.entries.some((e) => e.version === entry.version)) {
|
|
fail(`refusing — version ${entry.version} is already present in "${filePath}"`)
|
|
}
|
|
|
|
wall.entries = [entry, ...wall.entries]
|
|
|
|
const postErrors = validateShape(wall)
|
|
if (postErrors.length) {
|
|
fail(`the entry for ${entry.version} would leave "${filePath}" invalid —\n ${postErrors.join('\n ')}`)
|
|
}
|
|
|
|
writeFileSync(filePath, JSON.stringify(wall, null, 2) + '\n', 'utf8')
|
|
console.log(`wall-entry: wrote v${entry.version} to "${filePath}" (${wall.entries.length} entries, newest first)`)
|
|
}
|
|
|
|
function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
|
|
if (args.check) {
|
|
const filePath = /** @type {string | undefined} */ (args.file) ??
|
|
(typeof args.product === 'string' ? `releases/${args.product}.json` : undefined)
|
|
if (!filePath) fail('--check needs --file <path> (or --product <name> to default to releases/<name>.json)')
|
|
const wall = loadWallFile(/** @type {string} */ (filePath))
|
|
console.log(`wall-entry --check: "${filePath}" OK — product "${wall.product}", ${wall.entries.length} entries, newest-first, no duplicates`)
|
|
process.exit(0)
|
|
}
|
|
|
|
// Generate mode (default): --product, --version, --date, --from-changelog required.
|
|
const product = /** @type {string | undefined} */ (args.product)
|
|
const version = /** @type {string | undefined} */ (args.version)
|
|
const date = /** @type {string | undefined} */ (args.date)
|
|
const fromChangelog = /** @type {string | undefined} */ (args['from-changelog'])
|
|
|
|
const missing = []
|
|
if (!product) missing.push('--product')
|
|
if (!version) missing.push('--version')
|
|
if (!date) missing.push('--date')
|
|
if (!fromChangelog) missing.push('--from-changelog')
|
|
if (missing.length) {
|
|
fail(
|
|
`missing required flag(s): ${missing.join(', ')}\n` +
|
|
'Usage:\n' +
|
|
' wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md> [--file releases/<p>.json]\n' +
|
|
' wall-entry.mjs --check --file <releases/p.json>',
|
|
)
|
|
}
|
|
|
|
const urlArg = args.url === true ? undefined : /** @type {string | undefined} */ (args.url)
|
|
const thumbArg = args.thumb === true ? undefined : /** @type {string | undefined} */ (args.thumb)
|
|
|
|
const entry = deriveEntry({
|
|
product: /** @type {string} */ (product),
|
|
version: /** @type {string} */ (version),
|
|
date: /** @type {string} */ (date),
|
|
changelogPath: /** @type {string} */ (fromChangelog),
|
|
url: urlArg,
|
|
thumb: thumbArg,
|
|
})
|
|
|
|
const filePath = /** @type {string} */ (args.file ?? `releases/${product}.json`)
|
|
applyEntry(entry, filePath, /** @type {string} */ (product))
|
|
}
|
|
|
|
main()
|