ci(release): mechanize the releases-wall entry — never hand-written again
Some checks are pending
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
Some checks are pending
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
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
61bc5f423b
commit
85b1fa5c1a
3 changed files with 591 additions and 2 deletions
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