ci(release): mechanize the releases-wall entry — never hand-written again
Some checks failed
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run
Delta Gate / Delta gate — candidate vs control (push) Failing after 2s
Some checks failed
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run
Delta Gate / Delta gate — candidate vs control (push) Failing after 2s
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.
This commit is contained in:
parent
10a6e81a88
commit
a2820e81af
3 changed files with 591 additions and 2 deletions
|
|
@ -154,7 +154,8 @@ else
|
|||
fi
|
||||
|
||||
# Create new changelog entry
|
||||
CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) ($(date +%Y-%m-%d))
|
||||
RELEASE_DATE=$(date +%Y-%m-%d)
|
||||
CHANGELOG_ENTRY="### [${NEW_VERSION}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/v${CURRENT_VERSION}...v${NEW_VERSION}) (${RELEASE_DATE})
|
||||
|
||||
${COMMITS}
|
||||
"
|
||||
|
|
@ -174,9 +175,17 @@ if [ -f "CHANGELOG.md" ]; then
|
|||
fi
|
||||
echo -e "${GREEN}✅ CHANGELOG updated${NC}\n"
|
||||
|
||||
# Step 6b: Update the releases wall entry — mechanical, derived from the
|
||||
# CHANGELOG entry just composed. The fleet's HQ page reads releases/open-brainy.json
|
||||
# directly; this used to be hand-written after every release (David: never
|
||||
# again — make it a step of the rail).
|
||||
echo -e "${BLUE}5️⃣▸ Updating the releases wall...${NC}"
|
||||
node scripts/wall-entry.mjs --product open-brainy --version "${NEW_VERSION}" --date "${RELEASE_DATE}" --from-changelog CHANGELOG.md
|
||||
echo -e "${GREEN}✅ Releases wall updated${NC}\n"
|
||||
|
||||
# Step 7: Create release commit
|
||||
echo -e "${BLUE}6️⃣ Creating release commit...${NC}"
|
||||
git add package.json package-lock.json CHANGELOG.md
|
||||
git add package.json package-lock.json CHANGELOG.md releases/open-brainy.json
|
||||
git commit -m "chore(release): ${NEW_VERSION}"
|
||||
echo -e "${GREEN}✅ Release commit created${NC}\n"
|
||||
|
||||
|
|
|
|||
364
scripts/wall-entry.mjs
Normal file
364
scripts/wall-entry.mjs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
#!/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()
|
||||
216
tests/unit/release/wall-entry.test.ts
Normal file
216
tests/unit/release/wall-entry.test.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* scripts/wall-entry.mjs — the mechanical releases-wall entry.
|
||||
*
|
||||
* The script's only real interface is its CLI (it has no importable
|
||||
* exports by design — one door, no parallel API to drift from it), so
|
||||
* these tests spawn it exactly as scripts/release.sh does: as a child
|
||||
* process, against a temp copy of a wall file and a fixture CHANGELOG,
|
||||
* never against the repo's real releases/*.json.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SCRIPT = join(process.cwd(), 'scripts/wall-entry.mjs')
|
||||
|
||||
/** Run the script and capture the outcome without throwing on a non-zero exit. */
|
||||
function run(args: string[], cwd: string): { status: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], { cwd, encoding: 'utf8' })
|
||||
return { status: 0, stdout, stderr: '' }
|
||||
} catch (err: any) {
|
||||
return { status: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
const CHANGELOG_HEADER = '# Changelog\n\nAll notable changes, in this fixture.\n'
|
||||
|
||||
/** Build a CHANGELOG.md with one entry per [version, bullets[]] pair, newest first. */
|
||||
function buildChangelog(entries: Array<{ version: string; date: string; bullets: string[] }>): string {
|
||||
const body = entries
|
||||
.map(
|
||||
(e) =>
|
||||
`### [${e.version}](https://source.soulcraft.com/soulcraftlabs/open-brainy/compare/vX...v${e.version}) (${e.date})\n\n` +
|
||||
e.bullets.map((b) => `- ${b} (abc1234)`).join('\n') +
|
||||
'\n',
|
||||
)
|
||||
.join('\n')
|
||||
return CHANGELOG_HEADER + '\n' + body
|
||||
}
|
||||
|
||||
function wallFile(product: string, entries: unknown[]): string {
|
||||
return JSON.stringify(
|
||||
{ product, entries, history: 'Earlier releases are recorded in CHANGELOG.md in this repository.' },
|
||||
null,
|
||||
2,
|
||||
) + '\n'
|
||||
}
|
||||
|
||||
const BASE_ENTRY = {
|
||||
version: '10.4.11',
|
||||
date: '2026-09-02',
|
||||
headline: 'A faster open',
|
||||
items: ['A faster open.'],
|
||||
url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11',
|
||||
thumb: null,
|
||||
}
|
||||
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('wall-entry.mjs — generate + prepend', () => {
|
||||
it('derives headline from the first bullet and items from every bullet, hashes stripped', () => {
|
||||
writeFileSync(
|
||||
join(dir, 'CHANGELOG.md'),
|
||||
buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'] }]),
|
||||
)
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
dir,
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
expect(wall.entries).toHaveLength(2)
|
||||
expect(wall.entries[0]).toEqual({
|
||||
version: '10.4.12',
|
||||
date: '2026-09-03',
|
||||
headline: 'fix(wall): mechanize the entry',
|
||||
items: ['fix(wall): mechanize the entry', 'test(wall): pin the shape'],
|
||||
url: 'https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.12',
|
||||
thumb: null,
|
||||
})
|
||||
// the older entry stays put, still second
|
||||
expect(wall.entries[1].version).toBe('10.4.11')
|
||||
})
|
||||
|
||||
it('prepends newest-first — the new entry lands at index 0 ahead of every existing one', () => {
|
||||
writeFileSync(
|
||||
join(dir, 'CHANGELOG.md'),
|
||||
buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]),
|
||||
)
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]))
|
||||
|
||||
run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
expect(wall.entries.map((e: any) => e.version)).toEqual(['10.5.0', '10.4.11', '10.4.10'])
|
||||
})
|
||||
|
||||
it('derives no URL (null) for a product with no known public release-page pattern', () => {
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '11.0.6', date: '2026-09-03', bullets: ['fix: a native-only fix'] }]))
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: null }]))
|
||||
|
||||
run(['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
expect(wall.entries[0].url).toBeNull()
|
||||
expect(wall.entries[0].thumb).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses by name when the version is already present, and leaves the file untouched', () => {
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }]))
|
||||
const before = wallFile('open-brainy', [BASE_ENTRY])
|
||||
writeFileSync(join(dir, 'wall.json'), before)
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/refusing.*10\.4\.11.*already present/i)
|
||||
expect(readFileSync(join(dir, 'wall.json'), 'utf8')).toBe(before) // untouched
|
||||
})
|
||||
|
||||
it('refuses when the CHANGELOG has no entry yet for the target version', () => {
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: whatever'] }]))
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', []))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/no CHANGELOG entry yet/i)
|
||||
})
|
||||
|
||||
it('refuses a cross-product write when --product does not match the target file', () => {
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }]))
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/product "open-brainy".*--product "brainy"/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wall-entry.mjs — --check', () => {
|
||||
it('passes a well-formed, newest-first file with no duplicates', () => {
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/OK/)
|
||||
})
|
||||
|
||||
it('catches a missing entry key', () => {
|
||||
const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb"
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [broken]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/missing key\(s\) thumb/)
|
||||
})
|
||||
|
||||
it('catches an unexpected top-level key', () => {
|
||||
const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY]))
|
||||
raw.extra = 'not allowed'
|
||||
writeFileSync(join(dir, 'wall.json'), JSON.stringify(raw))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/unexpected key\(s\) extra/)
|
||||
})
|
||||
|
||||
it('catches entries that are not newest-first', () => {
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, version: '10.4.10' }, BASE_ENTRY]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/not newest-first/)
|
||||
})
|
||||
|
||||
it('catches a duplicate version even with identical entries', () => {
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [BASE_ENTRY, { ...BASE_ENTRY }]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/duplicate version 10\.4\.11/)
|
||||
})
|
||||
|
||||
it('catches an empty items array', () => {
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, items: [] }]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/"items" must be a non-empty array/)
|
||||
})
|
||||
|
||||
it('catches a malformed date', () => {
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [{ ...BASE_ENTRY, date: '09/03/2026' }]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/"date" must be a YYYY-MM-DD string/)
|
||||
})
|
||||
})
|
||||
Reference in a new issue