build(rail): the 10.4.12 candidate takes main — the walls leave this repo, the rail writes its entry into soulcraftlabs/releases
Some checks failed
Delta Gate / Delta gate — candidate vs control (push) Failing after 8s
CI / Node 24 (push) Failing after 7m58s
CI / Node 22 (push) Failing after 8m3s
CI / Integration + conformance (Node 22) (push) Failing after 16m26s
CI / Bun (latest) (push) Successful in 12m28s
Some checks failed
Delta Gate / Delta gate — candidate vs control (push) Failing after 8s
CI / Node 24 (push) Failing after 7m58s
CI / Node 22 (push) Failing after 8m3s
CI / Integration + conformance (Node 22) (push) Failing after 16m26s
CI / Bun (latest) (push) Successful in 12m28s
This commit is contained in:
commit
4c81d7d4c3
6 changed files with 454 additions and 335 deletions
|
|
@ -1,5 +1,12 @@
|
|||
# @soulcraft/brainy — Release Notes for Consumers
|
||||
|
||||
Machine-readable release notes are published at
|
||||
https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/open-brainy.json
|
||||
(this engine) and
|
||||
https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/brainy.json
|
||||
(the product engine) — read by HQ's `/hq/releases` door, and the source of
|
||||
truth ahead of this file.
|
||||
|
||||
This file is the **quick reference for downstream sessions** tracking Brainy changes.
|
||||
Full auto-generated changelog: `CHANGELOG.md` · Releases: https://source.soulcraft.com/soulcraftlabs/open-brainy/releases
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
{
|
||||
"product": "brainy",
|
||||
"entries": [
|
||||
{
|
||||
"version": "11.0.5",
|
||||
"date": "2026-09-02",
|
||||
"headline": "Graph-first finds in production, and opens that stop rescanning history",
|
||||
"items": [
|
||||
"find({ connected, where }) now walks the neighbours first and filters only those rows through a native door — correct at every page and O(neighbours), never the whole store.",
|
||||
"related() with a list of verb types returns every requested kind (a fast path had silently kept only the first).",
|
||||
"Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "11.0.4",
|
||||
"date": "2026-09-01",
|
||||
"headline": "Closes in milliseconds, index rebuilds without the disk-sync storm",
|
||||
"items": [
|
||||
"close() no longer pays deferred compaction or waits out an in-flight rebuild — measured 8 ms against the 4-minute closes it replaces; deferred work resumes at the next open, in the background.",
|
||||
"The metadata index's rebuild syncs to disk per shard instead of per row, and the durability point moved to the publish step — the same guarantee, a fraction of the disk traffic.",
|
||||
"A new native filter door evaluates queries over exactly the candidate rows a graph walk found, never the whole store."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "11.0.3",
|
||||
"date": "2026-09-01",
|
||||
"headline": "The embedding upgrade ceremony runs on every brain",
|
||||
"items": [
|
||||
"A brain opened through the standard plugin now carries its embedding-model identity, so the full-precision upgrade ceremony can run on it.",
|
||||
"A one-fix release; nothing else changed."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "11.0.2",
|
||||
"date": "2026-08-31",
|
||||
"headline": "One embedding quality everywhere, 3–4× faster imports",
|
||||
"items": [
|
||||
"Every runtime embeds with the same full-precision model — search quality no longer depends on where you run.",
|
||||
"Bulk embedding measured 3.1–4.2× faster, and an online re-embed ceremony upgrades existing stores without downtime.",
|
||||
"The engine's change feed is documented, with the SSE/WebSocket fan-out pattern for realtime surfaces."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "11.0.1",
|
||||
"date": "2026-08-31",
|
||||
"headline": "Deletes inside transactions are safe",
|
||||
"items": [
|
||||
"Deleting relations inside a transact() no longer corrupts index bookkeeping.",
|
||||
"A store that deletes its last relation keeps serving instead of refusing."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "11.0.0",
|
||||
"date": "2026-08-28",
|
||||
"headline": "One install, one engine — Brainy",
|
||||
"items": [
|
||||
"The former two-package pair is one package: the native engine under the familiar API. One import is the whole install.",
|
||||
"A missing native build refuses loudly with its cures named; nothing falls back silently.",
|
||||
"Stores open in place — no migration."
|
||||
],
|
||||
"url": null,
|
||||
"thumb": null
|
||||
}
|
||||
],
|
||||
"history": "The version line continues from the 4.3.x native-engine releases; their record lives in the product repository's CHANGELOG.md."
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
{
|
||||
"product": "open-brainy",
|
||||
"entries": [
|
||||
{
|
||||
"version": "10.4.11",
|
||||
"date": "2026-09-02",
|
||||
"headline": "Hybrid finds filter before they hydrate, one owner per shutdown, and a faster open",
|
||||
"items": [
|
||||
"Hybrid finds (query/vector combined with a filter, including connected and fusion finds) now filter first and hydrate only the page — one batchGet of exactly the requested rows, instead of hydrating everything the search side found. Fixes a bug where any page after the first came back empty.",
|
||||
"A brain now has exactly one shutdown owner — a host and its engine no longer race to close the same store, and a follow-up flush requested during a running flush is handed off cleanly instead of ever risking a stall.",
|
||||
"find({ path }) and other path-scoped VFS searches now serve a real range over the indexed path (O(log n)) instead of refusing the query outright — both scoped and recursive:false searches were silently broken before this.",
|
||||
"Open no longer rescans a brain's whole fact log on every open — sealed segments the manifest already accounts for are skipped, collapsing a multi-second open term to near-zero on large brains.",
|
||||
"commitTransaction() now refuses by name if single-ops are still pending, and a read-only open no longer writes clean-shutdown evidence it didn't earn — two correctness invariants that were previously assumed, not enforced."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.11",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.10",
|
||||
"date": "2026-09-02",
|
||||
"headline": "A planner door for indexes, batched containment repair, and a fixed near()",
|
||||
"items": [
|
||||
"An optional planFindPage door lets an index plan a find() and answer it in one call, instead of the engine assembling the plan itself.",
|
||||
"repairContainment's reconcile pass now walks paged edges once instead of issuing one graph call per file.",
|
||||
"find({ near }) now searches around the anchor's own vector and refuses by name when none is available, instead of silently querying with no vector at all."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.10",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.9",
|
||||
"date": "2026-09-02",
|
||||
"headline": "Graph-first finds, honest verb arrays, and opens that stop rescanning history",
|
||||
"items": [
|
||||
"find({ connected, where }) now walks the neighbours first and filters only those rows — correct at every page, and O(neighbours) instead of O(store).",
|
||||
"related() with a list of verb types (or sources, or targets) returns every requested kind — four fast paths silently kept only the first.",
|
||||
"Deferred-embedding recovery resumes from a low-water mark instead of rescanning the whole generation log at every open — measured at two minutes on a large brain, now milliseconds."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.9",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.7",
|
||||
"date": "2026-09-01",
|
||||
"headline": "Count ledgers can no longer race themselves",
|
||||
"items": [
|
||||
"Concurrent count flushes coalesce into one writer with a trailing pass — parallel flushes can no longer corrupt a store's count ledger.",
|
||||
"Atomic writes carry a per-process sequence, so two processes' temp files can never collide."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.7",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.6",
|
||||
"date": "2026-08-31",
|
||||
"headline": "Transactions cross the index seam safely",
|
||||
"items": [
|
||||
"Deleting relations inside a transact() no longer fails against the metadata index — operations take a JSON-safe view at the moment they execute.",
|
||||
"Fixes a class of transaction failures on stores with integer-mapped relation endpoints."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.6",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.5",
|
||||
"date": "2026-08-31",
|
||||
"headline": "Recovery tells the truth, docs live at home",
|
||||
"items": [
|
||||
"A torn generation-log tail is a terminal verdict with a named cure — never an endless wait at open.",
|
||||
"A sealed segment declares only the generations it actually holds.",
|
||||
"The engine's documentation now publishes from its own repository."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.5",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.4",
|
||||
"date": "2026-08-28",
|
||||
"headline": "Faster opens, quieter idle",
|
||||
"items": [
|
||||
"Opening a store discovers generations from directory names instead of walking the log, and answers \"any entities?\" with one directory read.",
|
||||
"The flush-request watch is event-driven; idle stores stop paying a polling heartbeat.",
|
||||
"A slow open now names the exact step it is in, so operators see what is being paid and why."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.4",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.3",
|
||||
"date": "2026-08-27",
|
||||
"headline": "Open Brainy, under its own name",
|
||||
"items": [
|
||||
"The same engine as 10.4.2, now published as @soulcraftlabs/brainy — the MIT reference engine, on The Source.",
|
||||
"No code changes; your imports change once and everything else stays put."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.3",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.2",
|
||||
"date": "2026-08-27",
|
||||
"headline": "Vectors that lie are refused, counts that drift are caught",
|
||||
"items": [
|
||||
"A zero-norm vector is not a vector: the index refuses them, rebuilds skip them, and a sanctioned unvector door removes them cleanly.",
|
||||
"The canonical count ledger derives from identity records and marks legacy-derived ledgers suspect at load.",
|
||||
"Plugin activation failures keep their original error as cause, so the real frame reaches your logs."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.2",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.1",
|
||||
"date": "2026-08-26",
|
||||
"headline": "Writes that change nothing cost nothing",
|
||||
"items": [
|
||||
"The read gate is per index family, and a write carrying unchanged data never re-embeds.",
|
||||
"The vectored-row count joins the ledger, so vector coverage is a number you can read, not a guess."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.1",
|
||||
"thumb": null
|
||||
},
|
||||
{
|
||||
"version": "10.4.0",
|
||||
"date": "2026-08-26",
|
||||
"headline": "Repair routing, the vector ledger, and honest empties",
|
||||
"items": [
|
||||
"Repairs route to the index that owns the damage, and the open gate closes the vector leg until coverage is proven.",
|
||||
"An empty string is real data, not a missing field.",
|
||||
"The metadata crossing never carries raw integer relation endpoints — a whole class of serialization faults closed."
|
||||
],
|
||||
"url": "https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v10.4.0",
|
||||
"thumb": null
|
||||
}
|
||||
],
|
||||
"history": "Earlier releases are recorded in CHANGELOG.md in this repository."
|
||||
}
|
||||
|
|
@ -176,16 +176,21 @@ 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).
|
||||
# CHANGELOG entry just composed. The fleet's HQ page reads open-brainy.json
|
||||
# from the one shared releases repo, soulcraftlabs/releases on The Source —
|
||||
# this used to be hand-written after every release (David: never again —
|
||||
# make it a step of the rail, landed in the one shared home; this repo no
|
||||
# longer hosts its own copy). This step clones/fetches that repo into a
|
||||
# local cache, prepends the entry, and pushes it directly — a real
|
||||
# cross-repo push, refusing loudly (never skipping) on any
|
||||
# clone/validation/commit/push failure.
|
||||
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 releases/open-brainy.json
|
||||
git add package.json package-lock.json CHANGELOG.md
|
||||
git commit -m "chore(release): ${NEW_VERSION}"
|
||||
echo -e "${GREEN}✅ Release commit created${NC}\n"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,48 +2,85 @@
|
|||
/**
|
||||
* @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.
|
||||
* reads one public JSON per product from the ONE releases repo on The Source
|
||||
* (soulcraftlabs/releases, files <product>.json at its root — shape
|
||||
* {product, entries:[{version, date, headline, items, url, thumb?}]}), at
|
||||
* https://source.soulcraft.com/soulcraftlabs/releases/raw/branch/main/<product>.json.
|
||||
* Those entries were hand-written after every release, then briefly written
|
||||
* into this repo's own releases/<product>.json; this script is the one door
|
||||
* that composes an entry and lands it in the shared repo, so it is never
|
||||
* hand-written and never forked across repos again.
|
||||
*
|
||||
* Two modes:
|
||||
*
|
||||
* 1. Generate + write in place (default):
|
||||
* 1. Generate + publish (default):
|
||||
* node wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> \
|
||||
* --from-changelog <CHANGELOG.md> [--file releases/<p>.json]
|
||||
* --from-changelog <CHANGELOG.md>
|
||||
* 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.
|
||||
* commit hash), then:
|
||||
* - clones (or, if a cached clone already exists, fetches and resets)
|
||||
* the releases repo into a local cache directory,
|
||||
* - prepends the entry to <cache>/<p>.json, newest first — replacing
|
||||
* any existing entry for the same version so a re-run is idempotent,
|
||||
* - validates the file's shape before and after,
|
||||
* - commits the change as "chore(wall): <p> <v>" and pushes main.
|
||||
* A failure at any step (clone, validation, commit, push, a
|
||||
* non-fast-forward remote) exits non-zero naming the cure. Nothing is
|
||||
* ever skipped — the wall either lands correctly or the release fails.
|
||||
*
|
||||
* 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.
|
||||
* 2. Dry run:
|
||||
* node wall-entry.mjs --dry-run --product <p> --version <v> \
|
||||
* --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md>
|
||||
* Derives the entry exactly as above and prints it, along with the file
|
||||
* it would be written to, but touches no clone and no remote — usable
|
||||
* from a fresh checkout with no cache and no network.
|
||||
*
|
||||
* No dependencies — CHANGELOG parsing, semver comparison, and JSON shape
|
||||
* checking are all hand-rolled below.
|
||||
* 3. Validate only (--check):
|
||||
* node wall-entry.mjs --check --file <path/to/product.json>
|
||||
* Validates an arbitrary wall 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.
|
||||
*
|
||||
* The remote and the local cache directory are each overridable
|
||||
* (--remote / --cache-dir, or WALL_ENTRY_RELEASES_REMOTE /
|
||||
* WALL_ENTRY_RELEASES_CACHE_DIR) so tests can point at a throwaway local
|
||||
* bare repo and a throwaway cache directory — never the real remote or the
|
||||
* real developer cache.
|
||||
*
|
||||
* No dependencies beyond the system `git` binary — CHANGELOG parsing,
|
||||
* semver comparison, and JSON shape checking are all hand-rolled below.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const ENTRY_KEYS = ['version', 'date', 'headline', 'items', 'url', 'thumb']
|
||||
const FILE_KEYS = ['product', 'entries', 'history']
|
||||
const DEFAULT_REMOTE = 'git@source.soulcraft.com:soulcraftlabs/releases.git'
|
||||
|
||||
// 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.
|
||||
/** @returns {string} */
|
||||
function defaultCacheDir() {
|
||||
const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache')
|
||||
return join(base, 'soulcraft-releases')
|
||||
}
|
||||
|
||||
// Required on every entry; "thumb" is optional (may be absent, or present as
|
||||
// string | null) — matching the HQ contract's {..., thumb?}.
|
||||
const ENTRY_REQUIRED_KEYS = ['version', 'date', 'headline', 'items', 'url']
|
||||
const ENTRY_OPTIONAL_KEYS = ['thumb']
|
||||
const ENTRY_ALLOWED_KEYS = [...ENTRY_REQUIRED_KEYS, ...ENTRY_OPTIONAL_KEYS]
|
||||
const FILE_KEYS = ['product', 'entries']
|
||||
|
||||
// The public permalink pattern, by product. Every entry MUST carry an https
|
||||
// permalink: HQ's parser rejects a wall whose entries carry url: null (the
|
||||
// whole feed became unreadable on 2026-09-02). A product whose forge repo is
|
||||
// private links its PUBLIC package page on The Source instead of a release
|
||||
// page that would 404 for HQ's readers.
|
||||
const RELEASE_URL_PATTERNS = {
|
||||
'open-brainy': (version) => `https://source.soulcraft.com/soulcraftlabs/open-brainy/releases/tag/v${version}`,
|
||||
'brainy': (version) => `https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/${version}`,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -110,10 +147,11 @@ function compareSemver(a, b) {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Validate a wall file's full shape: top-level keys ("product", "entries" —
|
||||
* no more, no less), per-entry keys and field types ("thumb" optional), and
|
||||
* strict-descending semver ordering with no duplicates. Collects every
|
||||
* violation instead of failing on the first, so a caller reports the whole
|
||||
* picture in one pass.
|
||||
* @param {unknown} data
|
||||
* @returns {string[]} Violation messages; empty means the file is clean.
|
||||
*/
|
||||
|
|
@ -135,9 +173,6 @@ function validateShape(data) {
|
|||
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
|
||||
|
|
@ -152,8 +187,8 @@ function validateShape(data) {
|
|||
}
|
||||
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))
|
||||
const missing = ENTRY_REQUIRED_KEYS.filter((k) => !(k in entry))
|
||||
const extra = keys.filter((k) => !ENTRY_ALLOWED_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(', ')}`)
|
||||
|
||||
|
|
@ -169,11 +204,11 @@ function validateShape(data) {
|
|||
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 (typeof entry.url !== 'string' || !/^https:\/\/\S+$/.test(entry.url)) {
|
||||
errors.push(`${label}: "url" must be an https permalink — never null; HQ's parser rejects the whole feed`)
|
||||
}
|
||||
if (!(entry.thumb === null || typeof entry.thumb === 'string')) {
|
||||
errors.push(`${label}: "thumb" must be a string or null`)
|
||||
if ('thumb' in entry && !(entry.thumb === null || typeof entry.thumb === 'string')) {
|
||||
errors.push(`${label}: "thumb" must be a string or null when present`)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -240,8 +275,8 @@ function extractChangelogBullets(changelog, version) {
|
|||
|
||||
/**
|
||||
* 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}}
|
||||
* @param {{product: string, version: string, date: string, changelogPath: string, url?: string, thumb?: string | null}} opts
|
||||
* @returns {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}}
|
||||
*/
|
||||
function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
||||
if (!parseSemver(version)) fail(`--version "${version}" is not a semver string`)
|
||||
|
|
@ -254,7 +289,11 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
|||
const items = extractChangelogBullets(changelog, version)
|
||||
const headline = items[0]
|
||||
|
||||
const resolvedUrl = url !== undefined ? url : (RELEASE_URL_PATTERNS[product]?.(version) ?? null)
|
||||
const pattern = RELEASE_URL_PATTERNS[product]
|
||||
if (url === undefined && pattern === undefined) {
|
||||
throw new Error(`wall-entry: no permalink pattern for product "${product}" — add one to RELEASE_URL_PATTERNS or pass --url; entries never carry url: null`)
|
||||
}
|
||||
const resolvedUrl = url !== undefined ? url : pattern(version)
|
||||
const resolvedThumb = thumb !== undefined ? thumb : null
|
||||
|
||||
return { version, date, headline, items, url: resolvedUrl, thumb: resolvedThumb }
|
||||
|
|
@ -266,43 +305,110 @@ function deriveEntry({ product, version, date, changelogPath, url, thumb }) {
|
|||
* @returns {Record<string, any>}
|
||||
*/
|
||||
function loadWallFile(filePath) {
|
||||
if (!existsSync(filePath)) fail(`--file "${filePath}" does not exist`)
|
||||
if (!existsSync(filePath)) fail(`"${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}`)
|
||||
fail(`"${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 ')}`)
|
||||
fail(`"${filePath}" fails shape validation —\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
|
||||
* Run a git command, throwing an Error whose message is git's own stderr
|
||||
* (trimmed) on failure — every caller wraps this to name the cure.
|
||||
* @param {string[]} args
|
||||
* @param {string} cwd
|
||||
* @returns {string} stdout, trimmed.
|
||||
*/
|
||||
function applyEntry(entry, filePath, expectedProduct) {
|
||||
const wall = loadWallFile(filePath)
|
||||
function git(args, cwd) {
|
||||
try {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
||||
} catch (err) {
|
||||
const stderr = /** @type {any} */ (err).stderr
|
||||
const message = (typeof stderr === 'string' && stderr.trim()) || /** @type {Error} */ (err).message
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedProduct && wall.product !== expectedProduct) {
|
||||
/**
|
||||
* Ensure a clean, up-to-date local clone of the releases repo at
|
||||
* `cacheDir`, checked out on `main` — cloning fresh if `cacheDir` has no
|
||||
* `.git`, otherwise fetching and hard-resetting onto `origin/main` (so a
|
||||
* stray local commit or edit left by a previous failed run can never leak
|
||||
* into the next one).
|
||||
* @param {string} remote
|
||||
* @param {string} cacheDir
|
||||
*/
|
||||
function ensureReleasesClone(remote, cacheDir) {
|
||||
if (existsSync(join(cacheDir, '.git'))) {
|
||||
try {
|
||||
git(['remote', 'set-url', 'origin', remote], cacheDir)
|
||||
git(['fetch', '--prune', 'origin'], cacheDir)
|
||||
git(['checkout', 'main'], cacheDir)
|
||||
git(['reset', '--hard', 'origin/main'], cacheDir)
|
||||
git(['clean', '-fd'], cacheDir)
|
||||
} catch (err) {
|
||||
fail(
|
||||
`cannot refresh the cached releases checkout at "${cacheDir}" from "${remote}" — ${/** @type {Error} */ (err).message}\n` +
|
||||
` cure: delete "${cacheDir}" and re-run so it re-clones from scratch, or confirm SSH access with "ssh -T git@source.soulcraft.com"`,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mkdirSync(dirname(cacheDir), { recursive: true })
|
||||
try {
|
||||
git(['clone', remote, cacheDir], dirname(cacheDir))
|
||||
} catch (err) {
|
||||
fail(
|
||||
`--file "${filePath}" has product "${wall.product}", but --product "${expectedProduct}" was given — refusing a cross-product write`,
|
||||
`cannot clone "${remote}" — ${/** @type {Error} */ (err).message}\n` +
|
||||
` cure: confirm SSH access with "ssh -T git@source.soulcraft.com" and that the soulcraftlabs/releases repo exists yet`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
git(['checkout', 'main'], cacheDir)
|
||||
} catch (err) {
|
||||
fail(
|
||||
`cloned "${remote}" into "${cacheDir}" but could not check out "main" — ${/** @type {Error} */ (err).message}\n` +
|
||||
` cure: confirm the releases repo's default branch is named "main"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (wall.entries.some((e) => e.version === entry.version)) {
|
||||
fail(`refusing — version ${entry.version} is already present in "${filePath}"`)
|
||||
/**
|
||||
* Prepend `entry` to the wall at `<cacheDir>/<product>.json`, replacing any
|
||||
* existing entry for the same version (idempotent re-runs), validating
|
||||
* before and after, committing, and pushing — or refusing loudly, naming
|
||||
* the cure, at whichever step fails.
|
||||
* @param {{version: string, date: string, headline: string, items: string[], url: string, thumb: string | null}} entry
|
||||
* @param {string} product
|
||||
* @param {string} remote
|
||||
* @param {string} cacheDir
|
||||
*/
|
||||
function publishEntry(entry, product, remote, cacheDir) {
|
||||
ensureReleasesClone(remote, cacheDir)
|
||||
|
||||
const filePath = join(cacheDir, `${product}.json`)
|
||||
if (!existsSync(filePath)) {
|
||||
fail(
|
||||
`"${filePath}" does not exist in the releases repo — cure: seed "${product}.json" at the repo root first (it must exist before any release rail can prepend to it)`,
|
||||
)
|
||||
}
|
||||
const wall = loadWallFile(filePath)
|
||||
|
||||
if (wall.product !== product) {
|
||||
fail(`"${filePath}" has product "${wall.product}", but --product "${product}" was given — refusing a cross-product write`)
|
||||
}
|
||||
|
||||
wall.entries = [entry, ...wall.entries]
|
||||
const replacing = wall.entries.some((e) => e.version === entry.version)
|
||||
wall.entries = [entry, ...wall.entries.filter((e) => e.version !== entry.version)]
|
||||
|
||||
const postErrors = validateShape(wall)
|
||||
if (postErrors.length) {
|
||||
|
|
@ -310,22 +416,48 @@ function applyEntry(entry, filePath, expectedProduct) {
|
|||
}
|
||||
|
||||
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)`)
|
||||
|
||||
const status = git(['status', '--porcelain', '--', `${product}.json`], cacheDir)
|
||||
if (status === '') {
|
||||
console.log(`wall-entry: "${product}.json" already carries an identical entry for ${entry.version} — nothing to commit or push`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
git(['add', `${product}.json`], cacheDir)
|
||||
git(['commit', '-m', `chore(wall): ${product} ${entry.version}`], cacheDir)
|
||||
} catch (err) {
|
||||
fail(`cannot commit the wall entry in "${cacheDir}" — ${/** @type {Error} */ (err).message}\n cure: inspect "${cacheDir}" by hand and re-run once its git state is clean`)
|
||||
}
|
||||
|
||||
try {
|
||||
git(['push', 'origin', 'main'], cacheDir)
|
||||
} catch (err) {
|
||||
fail(
|
||||
`push to "${remote}" failed (likely a non-fast-forward — another release landed on main first) — ${/** @type {Error} */ (err).message}\n` +
|
||||
` cure: re-run this release step; it re-fetches and resets onto the latest origin/main before retrying`,
|
||||
)
|
||||
}
|
||||
|
||||
const sha = git(['rev-parse', 'HEAD'], cacheDir)
|
||||
console.log(
|
||||
`wall-entry: ${replacing ? 'replaced' : 'wrote'} v${entry.version} in "${product}.json" (${wall.entries.length} entries, newest first) — pushed ${sha} to ${remote} main`,
|
||||
)
|
||||
}
|
||||
|
||||
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 filePath = /** @type {string | undefined} */ (args.file)
|
||||
if (!filePath) fail('--check needs --file <path>')
|
||||
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.
|
||||
// Generate mode (default, also covers --dry-run): --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)
|
||||
|
|
@ -340,8 +472,8 @@ function main() {
|
|||
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>',
|
||||
' wall-entry.mjs --product <p> --version <v> --date <YYYY-MM-DD> --from-changelog <CHANGELOG.md> [--dry-run]\n' +
|
||||
' wall-entry.mjs --check --file <path/to/product.json>',
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -357,8 +489,16 @@ function main() {
|
|||
thumb: thumbArg,
|
||||
})
|
||||
|
||||
const filePath = /** @type {string} */ (args.file ?? `releases/${product}.json`)
|
||||
applyEntry(entry, filePath, /** @type {string} */ (product))
|
||||
const remote = /** @type {string} */ (args.remote ?? process.env.WALL_ENTRY_RELEASES_REMOTE ?? DEFAULT_REMOTE)
|
||||
const cacheDir = /** @type {string} */ (args['cache-dir'] ?? process.env.WALL_ENTRY_RELEASES_CACHE_DIR ?? defaultCacheDir())
|
||||
|
||||
if (args['dry-run']) {
|
||||
console.log(`wall-entry --dry-run: would write to "${join(cacheDir, `${product}.json`)}" in ${remote} (main), pushed as "chore(wall): ${product} ${version}"`)
|
||||
console.log(JSON.stringify(entry, null, 2))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
publishEntry(entry, /** @type {string} */ (product), remote, cacheDir)
|
||||
}
|
||||
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
* 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.
|
||||
* process, against a fixture CHANGELOG and a throwaway local bare repo
|
||||
* standing in for git@source.soulcraft.com:soulcraftlabs/releases.git
|
||||
* (--remote) plus a throwaway cache directory (--cache-dir) standing in
|
||||
* for ~/.cache/soulcraft-releases — never the real remote, never the
|
||||
* real developer cache.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
|
|
@ -25,6 +28,10 @@ function run(args: string[], cwd: string): { status: number; stdout: string; std
|
|||
}
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8' }).trim()
|
||||
}
|
||||
|
||||
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. */
|
||||
|
|
@ -41,11 +48,7 @@ function buildChangelog(entries: Array<{ version: string; date: string; bullets:
|
|||
}
|
||||
|
||||
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'
|
||||
return JSON.stringify({ product, entries }, null, 2) + '\n'
|
||||
}
|
||||
|
||||
const BASE_ENTRY = {
|
||||
|
|
@ -57,31 +60,76 @@ const BASE_ENTRY = {
|
|||
thumb: null,
|
||||
}
|
||||
|
||||
/** A throwaway bare repo standing in for the real soulcraftlabs/releases remote. */
|
||||
function initBareRemote(): string {
|
||||
const remoteDir = mkdtempSync(join(tmpdir(), 'wall-remote-'))
|
||||
execFileSync('git', ['init', '--bare', '-b', 'main', remoteDir])
|
||||
return remoteDir
|
||||
}
|
||||
|
||||
/** Seed the bare remote with an initial <product>.json, via a throwaway clone. */
|
||||
function seedRemote(remoteDir: string, product: string, entries: unknown[]): void {
|
||||
const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-'))
|
||||
execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' })
|
||||
git(['config', 'user.email', 'seed@example.com'], seedDir)
|
||||
git(['config', 'user.name', 'Seed'], seedDir)
|
||||
writeFileSync(join(seedDir, `${product}.json`), wallFile(product, entries))
|
||||
git(['add', `${product}.json`], seedDir)
|
||||
git(['commit', '-m', 'seed'], seedDir)
|
||||
git(['push', 'origin', 'main'], seedDir)
|
||||
rmSync(seedDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** Read <product>.json back out of the bare remote's main tip, via a throwaway clone. */
|
||||
function readRemote(remoteDir: string, product: string): any {
|
||||
const readDir = mkdtempSync(join(tmpdir(), 'wall-read-'))
|
||||
execFileSync('git', ['clone', remoteDir, readDir], { stdio: 'ignore' })
|
||||
const data = JSON.parse(readFileSync(join(readDir, `${product}.json`), 'utf8'))
|
||||
rmSync(readDir, { recursive: true, force: true })
|
||||
return data
|
||||
}
|
||||
|
||||
/** Reject every push — stands in for any push failure (including a genuine
|
||||
* non-fast-forward raced by a concurrent release rail), which this script
|
||||
* treats identically: refuse loudly, name the cure, touch nothing further. */
|
||||
function makeRemoteRejectPushes(remoteDir: string): void {
|
||||
const hookPath = join(remoteDir, 'hooks', 'pre-receive')
|
||||
writeFileSync(hookPath, '#!/bin/sh\necho "remote: simulated push rejection" >&2\nexit 1\n')
|
||||
chmodSync(hookPath, 0o755)
|
||||
}
|
||||
|
||||
let dir: string
|
||||
let remoteDir: string
|
||||
let cacheDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'wall-entry-test-'))
|
||||
remoteDir = initBareRemote()
|
||||
cacheDir = join(mkdtempSync(join(tmpdir(), 'wall-cache-')), 'soulcraft-releases')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
rmSync(remoteDir, { recursive: true, force: true })
|
||||
rmSync(cacheDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('wall-entry.mjs — generate + prepend', () => {
|
||||
it('derives headline from the first bullet and items from every bullet, hashes stripped', () => {
|
||||
describe('wall-entry.mjs — generate + publish', () => {
|
||||
it('derives headline from the first bullet and items from every bullet, hashes stripped, and pushes it to the remote', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
||||
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'],
|
||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/wrote v10\.4\.12.*pushed/i)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
const wall = readRemote(remoteDir, 'open-brainy')
|
||||
expect(wall.entries).toHaveLength(2)
|
||||
expect(wall.entries[0]).toEqual({
|
||||
version: '10.4.12',
|
||||
|
|
@ -96,68 +144,192 @@ describe('wall-entry.mjs — generate + prepend', () => {
|
|||
})
|
||||
|
||||
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' }]))
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY, { ...BASE_ENTRY, version: '10.4.10' }])
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.5.0', date: '2026-09-03', bullets: ['feat: ten five'] }]))
|
||||
|
||||
run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'], dir)
|
||||
run(['--product', 'open-brainy', '--version', '10.5.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
const wall = readRemote(remoteDir, 'open-brainy')
|
||||
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', () => {
|
||||
it('replaces an entry with the same version instead of duplicating it — idempotent re-runs', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [
|
||||
{ ...BASE_ENTRY, headline: 'stale headline, pre-fix' },
|
||||
{ ...BASE_ENTRY, version: '10.4.10' },
|
||||
])
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['fix: the corrected headline'] }]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/replaced v10\.4\.11/i)
|
||||
|
||||
const wall = readRemote(remoteDir, 'open-brainy')
|
||||
expect(wall.entries).toHaveLength(2) // not 3 — replaced, not duplicated
|
||||
expect(wall.entries[0].version).toBe('10.4.11')
|
||||
expect(wall.entries[0].headline).toBe('fix: the corrected headline')
|
||||
expect(wall.entries[1].version).toBe('10.4.10')
|
||||
})
|
||||
|
||||
it('a re-run with byte-identical content commits nothing and still succeeds', () => {
|
||||
// headline always equals items[0] for a derived entry, so this fixture
|
||||
// (unlike BASE_ENTRY, whose headline/items intentionally diverge for the
|
||||
// shape-only tests below) has to keep the two in lockstep to ever roundtrip.
|
||||
const stableEntry = { ...BASE_ENTRY, headline: 'A faster open.', items: ['A faster open.'] }
|
||||
seedRemote(remoteDir, 'open-brainy', [stableEntry])
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.11', date: '2026-09-02', bullets: ['A faster open.'] }]))
|
||||
const before = readRemote(remoteDir, 'open-brainy')
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.11', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/nothing to commit/i)
|
||||
expect(readRemote(remoteDir, 'open-brainy')).toEqual(before)
|
||||
})
|
||||
|
||||
it('derives the public package-page permalink for the product engine (private repo, never null)', () => {
|
||||
seedRemote(remoteDir, 'brainy', [{ ...BASE_ENTRY, version: '11.0.5', url: 'https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.5' }])
|
||||
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 result = run(
|
||||
['--product', 'brainy', '--version', '11.0.6', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
expect(result.status).toBe(0)
|
||||
|
||||
const wall = JSON.parse(readFileSync(join(dir, 'wall.json'), 'utf8'))
|
||||
expect(wall.entries[0].url).toBeNull()
|
||||
const wall = readRemote(remoteDir, 'brainy')
|
||||
expect(wall.entries[0].url).toBe('https://source.soulcraft.com/soulcraft/-/packages/npm/@soulcraft%2Fbrainy/11.0.6')
|
||||
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)
|
||||
it('refuses a product with no permalink pattern, naming the cure', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['feat: first'] }]))
|
||||
|
||||
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
|
||||
const result = run(['--product', 'mystery', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir], dir)
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/no permalink pattern for product "mystery"/)
|
||||
expect(result.stderr).toMatch(/never carry url: null/)
|
||||
})
|
||||
|
||||
it('refuses when the CHANGELOG has no entry yet for the target version', () => {
|
||||
it('refuses when the CHANGELOG has no entry yet for the target version, and touches no remote', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [])
|
||||
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 beforeSha = git(['rev-parse', 'main'], remoteDir)
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
['--product', 'open-brainy', '--version', '99.0.0', '--date', '2026-09-02', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/no CHANGELOG entry yet/i)
|
||||
expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha)
|
||||
})
|
||||
|
||||
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]))
|
||||
it('refuses by naming the cure when the remote cannot be cloned', () => {
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }]))
|
||||
const noSuchRemote = join(tmpdir(), 'wall-remote-does-not-exist-' + Date.now())
|
||||
|
||||
const result = run(
|
||||
['--product', 'brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--file', 'wall.json'],
|
||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', noSuchRemote, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/product "open-brainy".*--product "brainy"/i)
|
||||
expect(result.stderr).toMatch(/cannot clone/i)
|
||||
expect(result.stderr).toMatch(/cure:/i)
|
||||
})
|
||||
|
||||
it('refuses by naming the cure, and touches no remote, when the fetched wall fails shape validation', () => {
|
||||
const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-broken-'))
|
||||
execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' })
|
||||
git(['config', 'user.email', 'seed@example.com'], seedDir)
|
||||
git(['config', 'user.name', 'Seed'], seedDir)
|
||||
writeFileSync(
|
||||
join(seedDir, 'open-brainy.json'),
|
||||
JSON.stringify({ product: 'open-brainy', entries: [{ version: '10.4.11', date: '2026-09-02', items: ['x'], url: null }] }, null, 2),
|
||||
)
|
||||
git(['add', 'open-brainy.json'], seedDir)
|
||||
git(['commit', '-m', 'seed broken'], seedDir)
|
||||
git(['push', 'origin', 'main'], seedDir)
|
||||
rmSync(seedDir, { recursive: true, force: true })
|
||||
const beforeSha = git(['rev-parse', 'main'], remoteDir)
|
||||
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/fails shape validation/i)
|
||||
expect(result.stderr).toMatch(/missing key\(s\) headline/i)
|
||||
expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha)
|
||||
})
|
||||
|
||||
it('refuses by naming the cure when the remote rejects the push (stands in for a raced non-fast-forward)', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
||||
makeRemoteRejectPushes(remoteDir)
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: whatever'] }]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/push to .* failed/i)
|
||||
expect(result.stderr).toMatch(/cure:/i)
|
||||
})
|
||||
|
||||
it('refuses a cross-product write when the file\'s "product" field does not match --product', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
||||
const seedDir = mkdtempSync(join(tmpdir(), 'wall-seed-mismatch-'))
|
||||
execFileSync('git', ['clone', remoteDir, seedDir], { stdio: 'ignore' })
|
||||
git(['config', 'user.email', 'seed@example.com'], seedDir)
|
||||
git(['config', 'user.name', 'Seed'], seedDir)
|
||||
const corrupted = JSON.parse(readFileSync(join(seedDir, 'open-brainy.json'), 'utf8'))
|
||||
corrupted.product = 'brainy'
|
||||
writeFileSync(join(seedDir, 'open-brainy.json'), JSON.stringify(corrupted, null, 2) + '\n')
|
||||
git(['add', 'open-brainy.json'], seedDir)
|
||||
git(['commit', '-m', 'corrupt product field'], seedDir)
|
||||
git(['push', 'origin', 'main'], seedDir)
|
||||
rmSync(seedDir, { recursive: true, force: true })
|
||||
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '1.0.0', date: '2026-09-03', bullets: ['fix: wrong repo'] }]))
|
||||
|
||||
const result = run(
|
||||
['--product', 'open-brainy', '--version', '1.0.0', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toMatch(/product "brainy".*--product "open-brainy"/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('wall-entry.mjs — --dry-run', () => {
|
||||
it('prints the entry and the target path, and touches neither the cache dir nor the remote', () => {
|
||||
seedRemote(remoteDir, 'open-brainy', [BASE_ENTRY])
|
||||
writeFileSync(join(dir, 'CHANGELOG.md'), buildChangelog([{ version: '10.4.12', date: '2026-09-03', bullets: ['fix: a dry run'] }]))
|
||||
const beforeSha = git(['rev-parse', 'main'], remoteDir)
|
||||
|
||||
const result = run(
|
||||
['--dry-run', '--product', 'open-brainy', '--version', '10.4.12', '--date', '2026-09-03', '--from-changelog', 'CHANGELOG.md', '--remote', remoteDir, '--cache-dir', cacheDir],
|
||||
dir,
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toMatch(/would write to/i)
|
||||
expect(result.stdout).toMatch(/"version": "10\.4\.12"/)
|
||||
expect(git(['rev-parse', 'main'], remoteDir)).toBe(beforeSha)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -169,21 +341,28 @@ describe('wall-entry.mjs — --check', () => {
|
|||
expect(result.stdout).toMatch(/OK/)
|
||||
})
|
||||
|
||||
it('passes a file where "thumb" is entirely absent (optional per the HQ contract)', () => {
|
||||
const { thumb, ...noThumb } = BASE_ENTRY as any
|
||||
writeFileSync(join(dir, 'wall.json'), wallFile('open-brainy', [noThumb]))
|
||||
const result = run(['--check', '--file', 'wall.json'], dir)
|
||||
expect(result.status).toBe(0)
|
||||
})
|
||||
|
||||
it('catches a missing entry key', () => {
|
||||
const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'], url: null } // no "thumb"
|
||||
const broken = { version: '1.0.0', date: '2026-09-03', headline: 'h', items: ['i'] } // no "url"
|
||||
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/)
|
||||
expect(result.stderr).toMatch(/missing key\(s\) url/)
|
||||
})
|
||||
|
||||
it('catches an unexpected top-level key', () => {
|
||||
it('catches an unexpected top-level key (e.g. the retired "history" field)', () => {
|
||||
const raw = JSON.parse(wallFile('open-brainy', [BASE_ENTRY]))
|
||||
raw.extra = 'not allowed'
|
||||
raw.history = 'retired field'
|
||||
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/)
|
||||
expect(result.stderr).toMatch(/unexpected key\(s\) history/)
|
||||
})
|
||||
|
||||
it('catches entries that are not newest-first', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue