From 39fd629dbb8df66476f8499452401eab01d81a8f Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:37:16 +0800 Subject: [PATCH 1/5] chore(release): bump version 4.8.3 -> 4.8.4 All 9 version anchors updated (package.json, package-lock.json x2, .claude-plugin/plugin.json, .codex-plugin/plugin.json, .claude-plugin/marketplace.json, CHANGELOG.md, CODEMAP.md, docs/ARCHITECTURE.md, docs/api/API_REFERENCE.md, herdr-plugin.toml), confirmed agreeing via scripts/check-version-coherence.mjs. CHANGELOG's [Unreleased] section moved to a dated [4.8.4] section, with a fresh empty [Unreleased] above it. dist/skills-manifest.json rebuilt to match. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 2 ++ CODEMAP.md | 2 +- dist/skills-manifest.json | 4 ++-- docs/ARCHITECTURE.md | 2 +- docs/api/API_REFERENCE.md | 2 +- herdr-plugin.toml | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- 11 files changed, 15 insertions(+), 13 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 56327736a..91d6c84f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "memesh", "source": "./", "description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", - "version": "4.8.3", + "version": "4.8.4", "author": { "name": "PCIRCLE AI" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3c755d535..fd6c90cac 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "author": { "name": "PCIRCLE AI" }, - "version": "4.8.3", + "version": "4.8.4", "mcpServers": "./.claude-plugin/mcp.json", "homepage": "https://github.com/PCIRCLE-AI/memesh", "repository": "https://github.com/PCIRCLE-AI/memesh", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f6f98b03a..fc78e2419 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memesh", "description": "MeMesh — agentic memory for coding agents.", - "version": "4.8.3", + "version": "4.8.4", "mcpServers": "./.codex-plugin/mcp.json" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b07d3d584..71c96a495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to MeMesh are documented here. ## [Unreleased] +## [4.8.4] — 2026-09-04 + ### Added - **`npm run qa:pre-release` — one door before a release, that says what it did diff --git a/CODEMAP.md b/CODEMAP.md index 281fd08a8..bbca61c76 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -1,6 +1,6 @@ # CODEMAP -**Version**: 4.8.3 +**Version**: 4.8.4 A navigation map for the codebase: *"I want to change X — which file?"* For the design rationale behind these modules see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); diff --git a/dist/skills-manifest.json b/dist/skills-manifest.json index 29e2b3423..55a7f6760 100644 --- a/dist/skills-manifest.json +++ b/dist/skills-manifest.json @@ -8,7 +8,7 @@ }, { "path": ".claude-plugin/plugin.json", - "sha256": "fe68e32aaf8cd1ec56472bd058b1149f8451938524bf945ad27c4ba9cd362136", + "sha256": "2b425a0b6ca6c6a2a549d273616ee0aa418a485960aa5f05ba44a38a6d49e9b5", "bytes": 572 }, { @@ -18,7 +18,7 @@ }, { "path": ".codex-plugin/plugin.json", - "sha256": "4a31bc9ce1ef62f7203ed9b3a9d4a9aa4b9a95a19f37f60cf4cfbdffa0ca95d8", + "sha256": "1ce4c7b5a8f04cfe1dde55adb6b1af580141de3a73eca0ee5a7bbb066d39b725", "bytes": 154 }, { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b315abe4d..9bbc7de48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # MeMesh Plugin Architecture -**Version**: 4.8.3 +**Version**: 4.8.4 > Looking for "which file do I change for X?" — see [CODEMAP.md](../CODEMAP.md). diff --git a/docs/api/API_REFERENCE.md b/docs/api/API_REFERENCE.md index 53cd1a087..df91315f8 100644 --- a/docs/api/API_REFERENCE.md +++ b/docs/api/API_REFERENCE.md @@ -1,7 +1,7 @@ # MeMesh Plugin -- API Reference **Protocol**: Model Context Protocol (MCP) over stdio -**Version**: 4.8.3 +**Version**: 4.8.4 **Compatibility**: Works with Claude Code plugins, Claude Managed Agents (via MCP connector), and any MCP-compatible client. **Native Integrations**: Beyond MCP, MeMesh integrates as a native memory provider for Hermes Agent (Python `MemoryProvider` plugin) and OpenClaw (TypeScript memory-capability plugin) — same tier as their built-in backends, not HTTP bridges. See [docs/platforms/](../platforms/) for platform-specific guides. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 75e4663cb..9969db470 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -13,7 +13,7 @@ # is the honest floor rather than a copied one. id = "memesh" name = "MeMesh" -version = "4.8.3" +version = "4.8.4" min_herdr_version = "0.7.0" description = "Local SQLite memory shared across coding agents — decisions, lessons, and why the code looks the way it does." platforms = ["linux", "macos", "windows"] diff --git a/package-lock.json b/package-lock.json index c635a696e..20cde7f69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pcircle/memesh", - "version": "4.8.3", + "version": "4.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pcircle/memesh", - "version": "4.8.3", + "version": "4.8.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 24a7d88fa..f88ba8cca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@pcircle/memesh", - "version": "4.8.3", - "description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", + "version": "4.8.4", + "description": "MeMesh — agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", "main": "dist/index.js", "type": "module", "bin": { From 3dba6d5a5c3df0d20f22cfbffcbcc3c9c4cc1089 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:24:05 +0800 Subject: [PATCH 2/5] docs(changelog): merge the duplicate ### Added section under [4.8.4] Two PRs merged into main between 4.8.3 and this release cut each carried their own ### Added block under [Unreleased]; the version bump moved both into [4.8.4] as-is instead of merging them. Combined into one Added section, content unchanged. --- CHANGELOG.md | 58 ++++++++++++++++++++++++---------------------------- 1 file changed, 27 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c96a495..6980635e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,33 @@ All notable changes to MeMesh are documented here. resolve, not the first one: two installs on one PATH, four releases apart, is the shape of the incident, and a check that stops at the first hit cannot see it. +- **Repeatable owner-run live delivery checks.** `npm run qa:live-journey -- --host codex|claude` + (`scripts/qa/live-journey.mjs`) starts this checkout's router in a throwaway + `MEMESH_DIR`, registers one real host session, sends one exact-session + envelope, and passes only on model-visible proof: the Codex path requires the + model to quote the envelope's `message_id` and `delivery_id` back on its next + turn; the Claude path requires an `intake` receipt written by the interactive + session the operator launched with the printed command. Both then stop the + session and require `recipient_unavailable` while the durable row stays + fetchable and the router still answers `discover`. It refuses to run against + `$HOME/.memesh`, reads no auth files, never runs in CI, and records its + limitations in the JSON report — including that the Codex registration is + harness-driven because `codex exec --ignore-user-config` bypasses the plugin + `SessionStart` hook, and that print-mode Claude is unsupported (#275). + Closes the "repeatable check in the repository" box on #270 and #272. +- **A write-side reminder hook.** The read side of MeMesh was already automatic + (SessionStart and PreToolUse inject memories) but nothing prompted an agent + to *store* anything, so decisions made mid-session were routinely lost until + the user said "remember this" (#277). A ninth hook, + `scripts/hooks/decision-nudge.js`, runs on `PostToolUse` for `ExitPlanMode` + and `AskUserQuestion` — the two calls where a decision has most likely just + been made — and adds one line of context asking the model to `remember` it + if it is worth keeping. At most once per tool per session, enforced by an + `O_EXCL` flag file under `MEMESH_DIR/decision-nudge-flags/`; the hook never + opens the database, exits 0 on any malformed input, and writes nothing to + the graph itself. Hook inventories in the READMEs (three languages), + `docs/ARCHITECTURE.md`, `AGENTS.md`, `CODEMAP.md` and the skill now list nine + hooks, and `check-codemap-parity` no longer hard-codes the count. ### Changed @@ -453,37 +480,6 @@ All notable changes to MeMesh are documented here. independent `readUpdateCheckCache()` path formula is untouched by this — it only reads, and the filename scheme did not change. -### Added - -- **Repeatable owner-run live delivery checks.** `npm run qa:live-journey -- --host codex|claude` - (`scripts/qa/live-journey.mjs`) starts this checkout's router in a throwaway - `MEMESH_DIR`, registers one real host session, sends one exact-session - envelope, and passes only on model-visible proof: the Codex path requires the - model to quote the envelope's `message_id` and `delivery_id` back on its next - turn; the Claude path requires an `intake` receipt written by the interactive - session the operator launched with the printed command. Both then stop the - session and require `recipient_unavailable` while the durable row stays - fetchable and the router still answers `discover`. It refuses to run against - `$HOME/.memesh`, reads no auth files, never runs in CI, and records its - limitations in the JSON report — including that the Codex registration is - harness-driven because `codex exec --ignore-user-config` bypasses the plugin - `SessionStart` hook, and that print-mode Claude is unsupported (#275). - Closes the "repeatable check in the repository" box on #270 and #272. - -- **A write-side reminder hook.** The read side of MeMesh was already automatic - (SessionStart and PreToolUse inject memories) but nothing prompted an agent - to *store* anything, so decisions made mid-session were routinely lost until - the user said "remember this" (#277). A ninth hook, - `scripts/hooks/decision-nudge.js`, runs on `PostToolUse` for `ExitPlanMode` - and `AskUserQuestion` — the two calls where a decision has most likely just - been made — and adds one line of context asking the model to `remember` it - if it is worth keeping. At most once per tool per session, enforced by an - `O_EXCL` flag file under `MEMESH_DIR/decision-nudge-flags/`; the hook never - opens the database, exits 0 on any malformed input, and writes nothing to - the graph itself. Hook inventories in the READMEs (three languages), - `docs/ARCHITECTURE.md`, `AGENTS.md`, `CODEMAP.md` and the skill now list nine - hooks, and `check-codemap-parity` no longer hard-codes the count. - ## [4.8.3] — 2026-08-31 ### Fixed From 838dcbc32ceb0c241f275e907cfb11acc91748a6 Mon Sep 17 00:00:00 2001 From: KT <677465+kevintseng@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:28:49 +0800 Subject: [PATCH 3/5] fix(cli): sweep every stale plugin cache version, not just the one just replaced upgrade-plugin.sh's atomic swap always removed the ONE version directory it had just replaced (PREVIOUS_PATH), but anything left behind by an interrupted upgrade, or one predating this swap mechanism, had no path back to zero. Measured on a real machine: 9 stale version directories under plugins/cache/pcircle-memesh/memesh/, 1.2 GB, accumulated with no bound. sweep_stale_cache_versions() removes every directory under the cache root whose name is exactly .., except the version just installed and the registry's own recorded install path (protected even when it is a stray non-canonical directory the existing "noncanonical ... repairing it" branch deliberately leaves for a human -- a regression caught by the existing same-version-repair test suite and fixed by threading that path through as a second protected name, not by narrowing the sweep). Defined near the top of the script behind a MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1 guard so tests/upgrade-plugin-cache-sweep.test.ts can source the real function and test it in isolation, without running the rest of the script against a real Claude Code marketplace checkout. Mutation-verified: breaking the keep-version exclusion turns 4 of 6 tests red. --- CHANGELOG.md | 13 +++ scripts/audit/baseline.json | 10 ++ scripts/upgrade-plugin.sh | 73 +++++++++++- tests/upgrade-plugin-cache-sweep.test.ts | 135 +++++++++++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 tests/upgrade-plugin-cache-sweep.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6980635e5..ec61a33c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,19 @@ All notable changes to MeMesh are documented here. ### Fixed +- **The Claude Code plugin cache never cleaned up an old version after an + upgrade — only the ONE it had just replaced.** `upgrade-plugin.sh`'s atomic + swap always removed the previous cache directory, but anything left behind + by an interrupted upgrade, or one from before this swap mechanism existed, + had no path back to zero: measured on the maintainer's machine, 9 stale + version directories, 1.2 GB, with nothing ever sweeping them. A new + `sweep_stale_cache_versions` removes every OTHER directory under the cache + root whose name is exactly `..` after a successful + upgrade — never the version just installed, and never the registry's own + recorded install path even when that path is a stray non-canonical + directory the "repairing it" branch above deliberately leaves for a human, + whatever it happens to be named. + - **Three release-gate guards that could not fail, and the silent failure one of them was hiding.** A mutation audit reintroduced the defect each guard was written for and re-ran the suite: with all three defects present at once, diff --git a/scripts/audit/baseline.json b/scripts/audit/baseline.json index ba5a8876e..11c8b3470 100644 --- a/scripts/audit/baseline.json +++ b/scripts/audit/baseline.json @@ -690,6 +690,16 @@ "class": "NOT-A-GATE", "reason": "real-browser (real HTTP server + real Chromium) verification that the dashboard doctor banner renders the mcp-config.placeholder-unresolved (quiet) and mcp-config.missing (translated banner) states correctly across all 11 locales, for evidence-gates output_readback/process_boundary/failure_path evidence -- invoked directly by `eg record`, not by any npm script or CI workflow. Renamed from verify-dashboard-doctor-banner-i18n.mjs during a rebase over PR #293, which already claimed that filename for its own llm-telemetry.silent-failure banner script -- distinct doctor codes, distinct files. A human or eg re-runs it on demand when the dashboard i18n catalogue changes; it does not gate a commit or a release on its own.", "triaged": "2026-09-04" + }, + "C1 tests/upgrade-plugin-cache-sweep.test.ts": { + "class": "SAFE", + "reason": "the one emptiness assertion ('is a no-op on an empty cache root', toEqual([])) is the genuinely-expected outcome of sweeping an empty directory -- there is no vacuity risk to guard against, because the setup creates zero entries on purpose and the test is pinning that the sweep does not fabricate a failure or create anything. Every other test in the file pins real content (toEqual(['4.8.4']), file-existence checks) after seeding non-empty fixtures, so the file is not relying on an empty-by-accident setup anywhere.", + "triaged": "2026-09-05" + }, + "C4 scripts/upgrade-plugin.sh:62": { + "class": "SAFE", + "reason": "set -uo pipefail at file top (line 31) preserves the left command's verdict; printf cannot meaningfully fail on a literal string, so the `if printf ... | grep -qE ...; then` reads grep's own match result -- the intended verdict, not a swallowed one.", + "triaged": "2026-09-05" } } } diff --git a/scripts/upgrade-plugin.sh b/scripts/upgrade-plugin.sh index 5e8f6459c..9580e428f 100755 --- a/scripts/upgrade-plugin.sh +++ b/scripts/upgrade-plugin.sh @@ -30,6 +30,54 @@ set -uo pipefail +# D9: removes every OTHER stale version directory under $root, not just the +# one an upgrade just swapped out. Before the atomic-swap rename elsewhere in +# this script, an interrupted or pre-this-mechanism upgrade could leave a +# version directory behind with nothing left to remove it later — measured +# on a real machine: 9 old version directories, 1.2 GB, accumulated with no +# bound. Only entries whose full name is exactly `..` +# are touched, so a `.staging-*`/`.previous-*` marker from a genuinely +# concurrent run (should be impossible under $LOCK_DIR, but this check does +# not rely on that) or anything else unexpected under $root is left alone. +# $keep_version is always excluded; an optional $also_keep protects a second +# name — the registry's OWN recorded install path (however it is spelled), +# so the noncanonical-path repair in section 2 below can keep leaving that +# one directory alone for a human to clean up, exactly as it already did +# before this function existed. +# +# Defined this early, and callable on its own, so +# `tests/upgrade-plugin-cache-sweep.test.ts` can source this file with +# MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1 (below) and call it directly against a +# throwaway root — testing the shipped function, not a hand-copied +# reimplementation of it — without running the rest of this script, which +# talks to a real Claude Code marketplace checkout. +sweep_stale_cache_versions() { + local root="$1" keep_version="$2" also_keep="${3:-}" entry name + [ -d "$root" ] || return 0 + for entry in "$root"/*; do + [ -d "$entry" ] || continue + name="$(basename "$entry")" + [ "$name" = "$keep_version" ] && continue + [ -n "$also_keep" ] && [ "$name" = "$also_keep" ] && continue + if printf '%s' "$name" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + rm -rf "$entry" 2>/dev/null + if [ -e "$entry" ]; then + echo "WARNING: could not remove stale cached version at $entry — remove it manually." >&2 + fi + fi + done +} + +# Test-only escape hatch: source this file with this variable set to load +# `sweep_stale_cache_versions` (and any other function defined above this +# guard) without running the rest of the script. `return` exits a sourced +# file without killing the parent shell; `|| exit 0` is the fallback for the +# (unsupported, but harmless to guard) case of someone executing the script +# directly with the variable set. +if [ "${MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then CLAUDE_CONFIG_ROOT="$CLAUDE_CONFIG_DIR" elif [ -n "${HOME:-}" ]; then @@ -282,12 +330,28 @@ ENTRY_SNAPSHOT="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" CACHE_ROOT="$CACHE_ROOT" && path.resolve(entry.installPath) === expectedPath ? 'canonical' : 'noncanonical'; - process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino)].join('\\t')); + // D9's cache sweep must never remove a directory the registry ITSELF + // still points to, canonical or not — this is the one directory the + // 'noncanonical … repairing it' path above (section 2) deliberately + // leaves in place. Reported as a bare basename, and only when that + // basename resolves back under this same cache root with no '..' + // segment, so a crafted absolute installPath from a tampered registry + // cannot smuggle an arbitrary path into the sweep's exclusion list. + const rootDir = path.resolve(process.env.CACHE_ROOT); + let recordedBasename = ''; + if (typeof entry.installPath === 'string' && path.isAbsolute(entry.installPath)) { + const resolved = path.resolve(entry.installPath); + const base = path.basename(resolved); + if (path.join(rootDir, base) === resolved && !/[\\r\\n\\t]/.test(base)) { + recordedBasename = base; + } + } + process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino), recordedBasename].join('\\t')); ")" || { echo "ERROR: could not read the installed memesh entries from $INSTALL_REGISTRY" >&2 exit 1 } -IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO <<< "$ENTRY_SNAPSHOT" +IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO RECORDED_INSTALL_BASENAME <<< "$ENTRY_SNAPSHOT" case "$ENTRY_STATE" in identity-changed) echo "ERROR: installed_plugins.json changed file identity while this upgrade was reading it — refusing to continue." >&2 @@ -620,6 +684,11 @@ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then echo " Remove it manually when no Claude Code process is using it: rm -rf \"$PREVIOUS_PATH\"" >&2 fi +# D9: sweep every OTHER stale version directory under $CACHE_ROOT, not just +# the one this run just swapped out — see `sweep_stale_cache_versions`'s own +# definition near the top of this file for why. +sweep_stale_cache_versions "$CACHE_ROOT" "$NEW_VERSION" "$RECORDED_INSTALL_BASENAME" + # ─── 7. Done ───────────────────────────────────────────────────────────── echo "" echo "✓ MeMesh upgraded: $CURRENT_VERSION (${INSTALLED_SHA:0:8}) -> $NEW_VERSION (${MARKETPLACE_SHA:0:8})" diff --git a/tests/upgrade-plugin-cache-sweep.test.ts b/tests/upgrade-plugin-cache-sweep.test.ts new file mode 100644 index 000000000..072e9d539 --- /dev/null +++ b/tests/upgrade-plugin-cache-sweep.test.ts @@ -0,0 +1,135 @@ +/** + * `sweep_stale_cache_versions` in scripts/upgrade-plugin.sh (D9). + * + * Measured on a real machine: 9 stale version directories under + * `~/.claude/plugins/cache/pcircle-memesh/memesh/`, 1.2 GB, with nothing + * ever removing them — the script's own swap logic only ever deletes the + * ONE version it just replaced, so a directory left behind by an + * interrupted or pre-swap-mechanism upgrade had no path back to zero. + * + * The test sources the real script with `MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1` + * (a guard defined at the top of the script, before anything that touches a + * real Claude Code marketplace checkout) and calls the function directly — + * exercising the shipped code, not a hand-copied reimplementation of it. + * POSIX-only: the script is bash and the Windows CI runner has no bash + * shell in the sense this script needs. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const scriptPath = path.resolve(__dirname, '../scripts/upgrade-plugin.sh'); +const posixOnly = it.skipIf(process.platform === 'win32'); + +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-cache-sweep-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +function sweep(keepVersion: string, alsoKeep?: string): void { + execFileSync( + 'bash', + ['-c', `source "${scriptPath}"; sweep_stale_cache_versions "$1" "$2" "$3"`, '--', root, keepVersion, alsoKeep ?? ''], + { env: { ...process.env, MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY: '1' } }, + ); +} + +function makeDir(name: string, withFile = false): void { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + if (withFile) fs.writeFileSync(path.join(dir, 'marker'), 'x'); +} + +describe('sweep_stale_cache_versions (D9)', () => { + posixOnly('removes every semver-named directory except the one being kept', () => { + makeDir('4.8.1', true); + makeDir('4.8.2', true); + makeDir('4.8.3', true); + makeDir('4.8.4', true); + + sweep('4.8.4'); + + const remaining = fs.readdirSync(root).sort(); + expect(remaining).toEqual(['4.8.4']); + expect(fs.existsSync(path.join(root, '4.8.4', 'marker'))).toBe(true); + }); + + posixOnly('leaves a non-semver entry alone', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + makeDir('not-a-version'); + + sweep('4.8.4'); + + expect(fs.readdirSync(root).sort()).toEqual(['4.8.4', 'not-a-version']); + }); + + posixOnly('leaves an in-flight .staging-*/.previous-* marker alone', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + makeDir('.staging-4.8.5-12345'); + makeDir('.previous-4.8.5-12345'); + + sweep('4.8.4'); + + const remaining = fs.readdirSync(root, { withFileTypes: true }) + .map((e) => e.name) + .sort(); + expect(remaining).toEqual(['.previous-4.8.5-12345', '.staging-4.8.5-12345', '4.8.4']); + }); + + posixOnly('does nothing, without error, when the cache root does not exist yet', () => { + const missing = path.join(root, 'does-not-exist'); + expect(() => + execFileSync( + 'bash', + ['-c', `source "${scriptPath}"; sweep_stale_cache_versions "$1" "$2"`, '--', missing, '4.8.4'], + { env: { ...process.env, MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY: '1' } }, + ), + ).not.toThrow(); + expect(fs.existsSync(missing)).toBe(false); + }); + + posixOnly('is a no-op on an empty cache root', () => { + sweep('4.8.4'); + expect(fs.readdirSync(root)).toEqual([]); + }); + + posixOnly('keeps a version that happens to equal the only entry present', () => { + makeDir('4.8.4', true); + sweep('4.8.4'); + expect(fs.readdirSync(root)).toEqual(['4.8.4']); + expect(fs.existsSync(path.join(root, '4.8.4', 'marker'))).toBe(true); + }); + + posixOnly('the optional third argument protects a second, non-semver-shaped name too', () => { + // The registry's own recorded install path — upgrade-plugin.sh's + // "noncanonical … repairing it" path deliberately leaves this exact + // directory alone for a human to inspect, whatever it is named. + makeDir('4.7.9', true); + makeDir('4.8.3'); + makeDir('4.8.4', true); + + sweep('4.8.4', '4.7.9'); + + const remaining = fs.readdirSync(root).sort(); + expect(remaining).toEqual(['4.7.9', '4.8.4']); + expect(fs.existsSync(path.join(root, '4.7.9', 'marker'))).toBe(true); + }); + + posixOnly('an empty third argument protects nothing extra', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + + sweep('4.8.4', ''); + + expect(fs.readdirSync(root)).toEqual(['4.8.4']); + }); +}); From 1dd8f020816e2918e5d6800588cece063b02bc9c Mon Sep 17 00:00:00 2001 From: verify-sim Date: Sat, 5 Sep 2026 01:12:23 +0800 Subject: [PATCH 4/5] feat(release): wire the entry-point gate and real-credential checks into the release path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify:release now ends with check:entry-points-start, which spawns all 17 shipped bins and hooks for real instead of only checking that their files exist — a CLI must accept --version, an MCP server must exit 0 on stdin EOF, a long-lived daemon must reach its "running" signal, and a host runtime with no config must fail closed with a named message, never a raw stack trace. It also fails on any unresolved ${...} left in .mcp.json or hooks/hooks.json, evaluated against each manifest's own real substitution environment. finish-release.mjs now runs qa:pre-release itself and blocks on its real exit code, and requires a qa:live-journey receipt (.qa/codex-report.json or .qa/claude-report.json, either host) that is PASS, clean-tree, and names the exact commit being released before it will cut a tag. Both qa:pre-release and qa:live-journey were available since the previous release but neither was in the one command that actually ships one — a check nobody has to run is a check that gets skipped exactly when a release is rushed. --- .gitignore | 3 + CHANGELOG.md | 46 +- CODEMAP.md | 25 +- docs/platforms/agent-messaging.md | 9 +- package.json | 3 +- scripts/check-entry-points-start.mjs | 649 ++++++++++++++++++++++++++ scripts/finish-release.mjs | 77 ++- scripts/lib/release-preconditions.mjs | 93 ++++ scripts/qa/pre-release.mjs | 4 +- tests/entry-points-start.test.ts | 174 +++++++ tests/qa-pre-release.test.ts | 21 +- tests/release-preconditions.test.ts | 157 ++++++- 12 files changed, 1237 insertions(+), 24 deletions(-) create mode 100644 scripts/check-entry-points-start.mjs create mode 100644 tests/entry-points-start.test.ts diff --git a/.gitignore b/.gitignore index 1a6e42853..b654c74ca 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ docs/guides/ PRE_RELEASE_CHECKLIST.md docs/notes/ .smoke-test*.db-* + +# === qa:live-journey receipts (owner-machine evidence, never shipped) === +.qa/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ec61a33c7..8cef68702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,28 @@ All notable changes to MeMesh are documented here. - **`npm run qa:pre-release` — one door before a release, that says what it did not check.** It runs `npm run build`, `verify:artifact` (lint, typecheck, - version coherence, doc claims, the isolated suite, the packed artifact and - every derived upgrade path — the same sequence `prepublishOnly` runs, now - named once instead of copied) and `audit:memory`, reports each step's real - exit code, and prints the checks it cannot run: the interactive live journey, - the post-release check, and the entry-point start gate. + version coherence, doc claims, the isolated suite, the packed artifact, + every derived upgrade path, and now the entry-point start gate below — the + same sequence `prepublishOnly` runs, now named once instead of copied) and + `audit:memory`, reports each step's real exit code, and prints the checks it + still cannot run here: the interactive live journey (gated separately by + `release:finish`, below) and the post-release check. +- **A release gate that actually starts every shipped entry point.** Eight + binaries and nine hooks ship; every existing check only asked whether a + file existed, whether JSON parsed, or whether a referenced path resolved — + none of them ran the code. `scripts/check-entry-points-start.mjs` + (`npm run check:entry-points-start`, folded into `verify:release`) spawns + each of the 17 for real against a throwaway `MEMESH_DIR`: a CLI must accept + `--version`, an MCP server must exit 0 on stdin EOF, a long-lived daemon + must reach its "running" signal (a log line, or its socket file appearing), + and a host runtime with no config must fail closed with a named message, + never a raw stack trace. It also fails on any unresolved `${...}` left in + `.mcp.json` or `hooks/hooks.json`, evaluated against each manifest's own + real substitution environment — `CLAUDE_PLUGIN_ROOT` is always defined for + the plugin-loader-only `hooks/hooks.json`, but not for `.mcp.json`, which + Claude Code also auto-discovers with no plugin loader involved at all. The + Windows skip list is pinned to exactly one entry (`memesh-router`, no + `AF_UNIX` there) so it cannot grow silently. - **`npm run qa:post-release` — the check that runs on the machine, not on a fresh clone.** Every gate here runs on a fresh checkout or a fresh install, and all three release incidents lived in state that already existed: v4.7.0 @@ -58,6 +75,25 @@ All notable changes to MeMesh are documented here. ### Changed +- **`npm run release:finish` now runs the real-credential checks instead of + merely documenting that they exist.** `qa:pre-release` and `qa:live-journey` + were both available since the previous release but neither was in the one + command that actually cuts a release — a check nobody has to run is a check + that gets skipped exactly when a release is rushed. `finish-release.mjs` now + runs `npm run qa:pre-release` itself (build + `verify:artifact` + + `audit:memory`, several minutes, streamed live) and blocks on its exit code; + a receipt cannot substitute here because it can go stale the moment the next + commit lands. `qa:live-journey` cannot run unattended — it needs a Codex + login or a person at an interactive Claude Code session — so it stays + receipt-based: `npm run qa:live-journey -- --host codex|claude --out + .qa/-report.json` writes a report, and `release:finish` requires ONE + of the two hosts' reports to be readable, `verdict: "PASS"`, recorded + against a clean tree, and naming this exact commit — an older PASS proves an + earlier revision, not this one. Neither host is preferred; only Codex can be + driven unattended today, but a human-run Claude receipt satisfies the gate + exactly as well. `.qa/` is gitignored — a receipt is owner-machine evidence, + never shipped. + - **The packed-upgrade gate derives its upgrade paths instead of pinning them.** `scripts/smoke-packed-upgrade.mjs` named both ends by hand (`expectedPreviousVersion = '4.8.2'`, `expectedCandidateVersion = '4.8.3'`, diff --git a/CODEMAP.md b/CODEMAP.md index bbca61c76..edf4100d0 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -146,12 +146,21 @@ The same `operations.ts` memory functions run identically from all three transpo - Owner-run live checks (never CI): `scripts/qa/live-journey.mjs` — `npm run qa:live-journey -- --host codex|claude` drives a real Codex thread or an interactive Claude channel session and requires model-visible proof. Its pure half is pinned by `tests/qa/live-journey.test.ts`; the contract is in `docs/platforms/agent-messaging.md`. -- Release gates, in the order a release meets them: `npm run qa:pre-release` - (`scripts/qa/pre-release.mjs`) runs build + `verify:artifact` + `audit:memory` as one door - and prints what it could not check; `verify:artifact` is the same sequence `prepublishOnly` - runs, named once. `scripts/smoke-packed-upgrade.mjs` derives every upgrade path it proves - from `package.json` and the registry (`scripts/lib/upgrade-matrix.mjs`) instead of pinning - a version pair. After publishing, `npm run qa:post-release` (`scripts/qa/post-release.mjs`) - checks registry acceptance, a fresh install from the registry, and whether this machine is - on the release — read-only, printing fixes rather than running them. +- Release gates, in the order a release meets them: `verify:release` (called by `qa:pre-release` + below) now ends with `npm run check:entry-points-start` + (`scripts/check-entry-points-start.mjs`), which spawns every one of the 17 shipped bins and + hooks for real and fails on any unresolved `${...}` left in `.mcp.json`/`hooks/hooks.json`. + `npm run qa:pre-release` (`scripts/qa/pre-release.mjs`) runs build + `verify:artifact` + + `audit:memory` as one door and prints what it could not check; `verify:artifact` is the same + sequence `prepublishOnly` runs, named once. `scripts/smoke-packed-upgrade.mjs` derives every + upgrade path it proves from `package.json` and the registry (`scripts/lib/upgrade-matrix.mjs`) + instead of pinning a version pair. `npm run release:finish` (`scripts/finish-release.mjs`) runs + `qa:pre-release` itself and blocks on its exit code, and requires a `qa:live-journey` receipt + under `.qa/` (named `-report.json` — codex or claude, either satisfies it) that is + `PASS`, clean-tree, and names the exact commit being released + (`scripts/lib/release-preconditions.mjs`'s + `findUsableLiveJourneyReceipt`) — real-credential checks CI cannot run, now required rather + than merely available. After publishing, `npm run qa:post-release` + (`scripts/qa/post-release.mjs`) checks registry acceptance, a fresh install from the registry, + and whether this machine is on the release — read-only, printing fixes rather than running them. - Version anchors that must agree on a bump: `package.json`, both root entries in `package-lock.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `herdr-plugin.toml`, `CHANGELOG.md`, `CODEMAP.md`, `docs/ARCHITECTURE.md`, and `docs/api/API_REFERENCE.md`. Run `npm run build` after to regenerate `dist/skills-manifest.json`. diff --git a/docs/platforms/agent-messaging.md b/docs/platforms/agent-messaging.md index 47b363c97..13c5b70b2 100644 --- a/docs/platforms/agent-messaging.md +++ b/docs/platforms/agent-messaging.md @@ -212,10 +212,15 @@ close that gap by requiring evidence that could only have come out of a running model. ```bash -TMPDIR=/private/tmp npm run qa:live-journey -- --host codex --out codex-report.json -TMPDIR=/private/tmp npm run qa:live-journey -- --host claude --out claude-report.json +TMPDIR=/private/tmp npm run qa:live-journey -- --host codex --out .qa/codex-report.json +TMPDIR=/private/tmp npm run qa:live-journey -- --host claude --out .qa/claude-report.json ``` +`.qa/` is where `npm run release:finish` looks for these reports (any ONE +host's PASS, against the exact commit being released, is enough — see +`scripts/lib/release-preconditions.mjs`'s `findUsableLiveJourneyReceipt`). The +directory is gitignored; a report is owner-machine evidence, never shipped. + `TMPDIR` is not decoration on macOS. The router's Unix socket lives beside the database inside the temporary directory, and `AF_UNIX` caps a socket path at 104 bytes; the platform default `os.tmpdir()` spends about half of that before diff --git a/package.json b/package.json index f88ba8cca..15936c7a0 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,8 @@ "check:surface-parity": "node scripts/check-readme-tool-parity.mjs && node scripts/check-codemap-parity.mjs", "audit:prod": "node scripts/check-consumer-audit.mjs", "audit:memory": "node scripts/audit/memory-invariants.mjs", - "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod", + "check:entry-points-start": "node scripts/check-entry-points-start.mjs", + "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod && npm run check:entry-points-start", "release:finish": "node scripts/finish-release.mjs", "verify:artifact": "npm run verify:release && npm run test:isolated && npm run test:packaged && npm run test:packaged:upgrade", "prepublishOnly": "npm run build && npm run verify:artifact", diff --git a/scripts/check-entry-points-start.mjs b/scripts/check-entry-points-start.mjs new file mode 100644 index 000000000..b7bc69cdd --- /dev/null +++ b/scripts/check-entry-points-start.mjs @@ -0,0 +1,649 @@ +#!/usr/bin/env node +// +// Entry-point execution gate +// =========================== +// +// Every other gate in this repo asks "does the file exist", "does it parse", +// "is it in the tarball". None of them ever run the thing. That is how +// `.mcp.json` shipped naming a deleted server file — every MCP tool failed +// with `-32000 failed to reconnect`, and no gate said a word (see the +// comment on `mcpEntry()` in scripts/lib/executable-targets.mjs) — and it is +// how a `${CLAUDE_PLUGIN_ROOT}` placeholder that cannot resolve outside a +// plugin loader has shipped in `.mcp.json` since 2026-05-13 without any +// check noticing. +// +// This gate spawns every declared bin (`package.json` `bin`) and every +// declared hook (`hooks/hooks.json`) once, with `process.execPath` — never a +// shell, never PATH — and checks that it starts. "Starts" means something +// different for each kind of entry point; see ASSERTIONS below for the +// per-binary reasoning. +// +// It runs with MEMESH_DIR/MEMESH_DB_PATH pointed at a throwaway directory it +// creates and removes. Nothing it spawns is allowed to touch a real +// ~/.memesh, and nothing it spawns is allowed to make a network call: +// MEMESH_AUTO_UPDATE=0 and MEMESH_AUTO_DETECT_LLM=0 keep it hermetic and +// keep a developer's real OPENAI_API_KEY/ANTHROPIC_API_KEY out of a +// subprocess this gate spawns. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { binTargets, hookCommands } from './lib/executable-targets.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const rootOption = process.argv.indexOf('--root'); +const packageDir = rootOption >= 0 ? path.resolve(process.argv[rootOption + 1] ?? '') : path.resolve(here, '..'); + +const DEFAULT_TIMEOUT_MS = 10_000; +// Measured locally (this session, darwin): a fresh memesh-router socket +// appears in ~1.5s, including opening and migrating a brand-new SQLite +// database. That is not this gate's floor — it runs unskipped on +// ubuntu-latest with no local measurement to lean on, and this repo has +// already paid for the lesson that a timeout sized to the fastest machine +// becomes a flaky red build elsewhere (vitest.config.ts's hookTimeout going +// 10s→30s for the same reason; CLAUDE.md records a degraded CI runner +// measured at 5x slower). This is a poll that returns the instant the +// readiness signal appears, so a generous ceiling costs the happy path +// nothing. +const DAEMON_READY_TIMEOUT_MS = 15_000; +const DAEMON_SHUTDOWN_GRACE_MS = 10_000; + +/** + * Which platforms cannot prove a given entry point starts, and why. + * + * Exported (not inlined) so a test can pin it without a Windows machine: + * `tests/entry-points-start.test.ts` calls this with `'win32'`/`'darwin'` + * directly rather than relying on CI to happen to run on the right OS the + * day the pin needs checking. + * + * Only one entry exists today. `memesh-router` (src/host-runtime/router.ts) + * calls `assertSecureLocalHostRuntimeSupported()` at MODULE SCOPE — line 16, + * before any function boundary — and that call throws + * `SECURE_LOCAL_HOST_RUNTIME_UNSUPPORTED` on win32 with no surrounding + * try/catch anywhere between it and the module's top level. Windows support + * for the secure local host runtime is an explicit, documented design + * decision (config.ts:6-13), not a bug this gate should be reporting as one + * — but the resulting failure is also not a clean "starts and reports + * failure" the gate can assert on with confidence without a Windows machine + * to verify the exact shape against. Skipping it, loudly, is more honest + * than guessing at an assertion this repo cannot verify in this session. + * + * `memesh-host-claude`/`memesh-host-codex`/`memesh-host-acp` are NOT here: + * all three read `MEMESH_HOST_CONFIG`/`--config` and throw "A host config + * file is required…" BEFORE any Windows-specific code path is reached + * (config.ts `readHostConfig` checks `configuredPath` before calling + * `readHostConfigFile`, which is the only place `assertSecureLocalHostRuntimeSupported` + * runs for those three). Their missing-config failure mode is identical on + * every platform, so it is asserted on every platform — see ASSERTIONS + * below, where two of the three currently fail that assertion for an + * unrelated reason (a missing try/catch, not a Windows gap). + * + * @param {NodeJS.Platform} platform + * @returns {{relativePath: string, reason: string}[]} + */ +export function computeSkipList(platform) { + if (platform !== 'win32') return []; + return [ + { + relativePath: 'dist/host-runtime/router.js', + reason: + 'memesh-router calls assertSecureLocalHostRuntimeSupported() at module scope ' + + '(src/host-runtime/router.ts:16) with no try/catch — Windows is explicitly ' + + 'unsupported by design for the secure local host runtime (src/host-runtime/config.ts:6-13).', + }, + ]; +} + +/** + * `.mcp.json`/`hooks/hooks.json` both name their entry point as + * `${CLAUDE_PLUGIN_ROOT}/...`. That placeholder is legitimate in exactly one + * of the two manifests. + * + * `hooks/hooks.json` is reachable ONLY through Claude Code's plugin loader — + * there is no "project-scoped hooks" auto-discovery the way there is for MCP + * servers, so every hook command this repo ships is invoked with + * CLAUDE_PLUGIN_ROOT defined by the harness that read the plugin manifest in + * the first place. `scripts/hooks/_shared.js`'s `resolvePluginRoot()` even + * recomputes the same root independently from `import.meta.url`, which is + * belt-and-braces for a hook script that already knows it will always be + * plugin-loaded — not evidence that the manifest string itself needs to + * resolve some other way. + * + * `.mcp.json` is different: Claude Code ALSO auto-discovers a project-root + * `.mcp.json` as a project-scoped MCP server declaration, independent of + * whether MeMesh is installed as a plugin at all. In that load path nothing + * defines CLAUDE_PLUGIN_ROOT, so `${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js` + * is not a path — it is the literal seven characters `${CLAUDE_PLUGIN_ROOT}` + * followed by a filename Claude Code will never resolve. This is the + * `-32000 failed to reconnect` defect referenced at the top of this file. + * + * So each manifest gets checked against the substitution environment its + * OWN real load path actually provides — not the pre-stripped path + * `hookCommands()`/`mcpEntry()` in executable-targets.mjs return, which + * unconditionally strips the `${CLAUDE_PLUGIN_ROOT}/` prefix for BOTH + * manifests (that helper answers "what file, once resolved" — a different + * question from "does this manifest's own load path resolve it"). Checking + * the stripped path here would make this whole rule vacuous: it would find + * nothing, in every manifest, forever. + * + * @param {string} rootDir + * @returns {{manifest: string, variable: string, raw: string}[]} + */ +export function findUnresolvedPlaceholders(rootDir) { + const PLACEHOLDER = /\$\{([A-Z0-9_]+)\}/g; + + /** @type {Record>} */ + const substitutionEnvByManifest = { + // Plugin-loader-only: CLAUDE_PLUGIN_ROOT is always defined when this file + // is read. + 'hooks/hooks.json': { CLAUDE_PLUGIN_ROOT: rootDir }, + // Also reachable project-scoped, with no plugin loader in the picture — + // nothing substitutes CLAUDE_PLUGIN_ROOT there. + '.mcp.json': {}, + }; + + const findings = []; + for (const [manifest, substitutionEnv] of Object.entries(substitutionEnvByManifest)) { + const full = path.join(rootDir, manifest); + if (!fs.existsSync(full)) continue; + const raw = fs.readFileSync(full, 'utf8'); + for (const match of raw.matchAll(PLACEHOLDER)) { + const variable = match[1]; + if (!(variable in substitutionEnv)) { + findings.push({ manifest, variable, raw: match[0] }); + } + } + } + return findings; +} + +/** Poll `condition` until it is true or `timeoutMs` elapses. */ +async function waitFor(condition, description, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${description}`); +} + +/** Run a child to completion, capturing stdout/stderr/exit under a bound. */ +function run(absolutePath, args, { input, env, timeoutMs = DEFAULT_TIMEOUT_MS }) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [absolutePath, ...args], { + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve({ stdout, stderr, status: null, timedOut: true }); + }, timeoutMs); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, status: code, timedOut: false }); + }); + + if (input !== undefined) child.stdin.end(input); + else child.stdin.end(); + }); +} + +// A minimal but valid top-level shape for a hook's stdout, per the real +// Claude Code contract this repo derived in +// tests/helpers/hook-output-contract.ts. That file is the source of truth — +// it is a full contract test wired into the ordinary vitest suite +// (tests/hooks/hook-output-contract.test.ts) and is NOT re-derived here. +// `scripts/` runs under plain `node`, with no TypeScript loader, so it +// cannot import a `.ts` module; duplicating its full ~200-line contract +// (per-event hookSpecificOutput field allowlists) into this file would be +// the second copy of a list this repo has already been bitten by (see the +// header comment in executable-targets.mjs). This is deliberately a lighter +// subset — enough to catch a hook that starts printing raw text or an +// invalid event name, not a replacement for the real contract test. +const HOOK_TOP_LEVEL_FIELDS = [ + 'continue', 'suppressOutput', 'stopReason', 'decision', 'reason', 'systemMessage', 'hookSpecificOutput', +]; + +function isPlausibleHookOutput(stdout, boundEvent) { + const trimmed = stdout.trim(); + if (trimmed === '') return { ok: true }; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + return { ok: false, reason: `printed non-JSON, non-empty stdout: ${trimmed.slice(0, 200)}` }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, reason: 'stdout JSON is not an object' }; + } + const unknown = Object.keys(parsed).filter((key) => !HOOK_TOP_LEVEL_FIELDS.includes(key)); + if (unknown.length > 0) { + return { ok: false, reason: `unknown top-level field(s): ${unknown.join(', ')}` }; + } + if ('hookSpecificOutput' in parsed) { + const event = parsed.hookSpecificOutput?.hookEventName; + if (event !== undefined && event !== boundEvent) { + return { ok: false, reason: `hookSpecificOutput.hookEventName is "${event}", bound event is "${boundEvent}"` }; + } + } + return { ok: true }; +} + +/** A raw Node uncaught-exception dump has indented `at ...` stack frames. */ +function looksLikeStackTrace(stderr) { + return /^\s{2,}at .+/m.test(stderr); +} + +async function main() { + const gateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mep-')); + const memeshDir = path.join(gateRoot, '.memesh'); + fs.mkdirSync(memeshDir, { recursive: true }); + const memeshDbPath = path.join(memeshDir, 'kg.db'); + // AF_UNIX paths are capped at 103 bytes (src/core/agent-router.ts:1482) — + // sibling of gateRoot, not nested under it, to stay short regardless of + // how deep the platform's tmpdir already is. + const routerSocket = `${gateRoot}-r.sock`; + const routerTokenFile = `${gateRoot}-r.token`; + + const baseEnv = { + ...process.env, + MEMESH_DIR: memeshDir, + MEMESH_DB_PATH: memeshDbPath, + MEMESH_AUTO_UPDATE: '0', + MEMESH_AUTO_DETECT_LLM: '0', + }; + delete baseEnv.MEMESH_HOST_CONFIG; + + // Two distinct kinds of finding, reported and counted separately: an + // entry point that did not start (counted against `checked`) and a + // manifest placeholder finding (not an entry point at all — findings.mjs + // once conflated these into one `failures` list, which made + // `checked - failures.length` under-report the started count by one for + // every placeholder finding and mislabeled the FAIL summary as "entry + // point(s) did not start" when one of them was a manifest string. + const entryFailures = []; + const placeholderFindings = []; + const skipped = []; + let checked = 0; + + const record = (label, promise) => promise.then( + (detail) => { checked += 1; process.stdout.write(` ok ${label}\n`); return detail; }, + (error) => { + checked += 1; + entryFailures.push({ label, message: error.message }); + process.stdout.write(` FAIL ${label} — ${error.message}\n`); + }, + ); + + // --- Assertions, one per entry-point kind ------------------------------ + // + // "Starts" means something different per kind; each function below is the + // cheapest thing that actually proves it, chosen after reading the source + // it exercises (cited in each comment). + + /** `memesh` — a commander CLI. --version exits 0 and prints the version + * without touching the DB or the network; it is the cheapest proof the + * binary parses argv and its command tree loads at all. */ + async function assertCliVersion(absolutePath, env) { + const result = await run(absolutePath, ['--version'], { env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status}\nstderr: ${result.stderr.slice(0, 500)}`); + if (result.stdout.trim() === '') throw new Error('--version printed nothing'); + } + + /** `memesh-mcp` — an MCP stdio server. Its entire contract is "read + * JSON-RPC frames from stdin until it closes, then exit"; EOF on stdin is + * the exact signal a disconnecting client sends, and is the only thing + * this gate can prove without a live MCP handshake. Verified empirically + * (2026-09-02, this session): closing stdin makes it exit 0 immediately + * rather than hang, so a bound here is a real assertion, not a safety net + * for a process this gate is about to kill regardless. */ + async function assertMcpStdioEof(absolutePath, env) { + const result = await run(absolutePath, [], { input: '', env }); + if (result.timedOut) throw new Error(`did not exit on stdin EOF within ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status} on stdin EOF\nstderr: ${result.stderr.slice(0, 500)}`); + } + + /** `memesh-http` — a long-lived Express server (src/transports/http/server.ts). + * It never reads stdin, so EOF proves nothing; it prints + * "MeMesh HTTP server running at …" from inside its `listen` callback + * (server.ts:1643) once it has actually bound a socket, which is the + * earliest true "started" signal available without an HTTP round trip. + * MEMESH_HTTP_PORT=0 asks the OS for an ephemeral port so this cannot + * collide with a real server or another gate run in CI. */ + async function assertHttpDaemon(absolutePath, env) { + const child = spawn(process.execPath, [absolutePath], { + env: { ...env, MEMESH_HTTP_PORT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let exitInfo = null; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code, signal) => { exitInfo = { code, signal }; }); + try { + await waitFor(() => stdout.includes('running at') || exitInfo !== null, 'memesh-http to report it is listening', DAEMON_READY_TIMEOUT_MS); + if (exitInfo !== null) throw new Error(`exited before listening (${JSON.stringify(exitInfo)})\nstderr: ${stderr.slice(0, 500)}`); + } finally { + if (exitInfo === null) { + child.kill('SIGTERM'); + try { + await waitFor(() => exitInfo !== null, 'memesh-http to exit after SIGTERM', DAEMON_SHUTDOWN_GRACE_MS); + } catch { + child.kill('SIGKILL'); + } + } + } + if (exitInfo !== null && exitInfo.code !== 0 && exitInfo.code !== null) { + throw new Error(`memesh-http did not shut down cleanly on SIGTERM (${JSON.stringify(exitInfo)})`); + } + } + + /** `memesh-router` — a Unix-domain-socket daemon (src/host-runtime/router.ts). + * Like the HTTP server it never reads stdin. It creates the socket file + * inline in module top-level code (no "ready" log line to grep for), so + * the socket's existence on disk IS the started signal — the same proof + * scripts/smoke-packed-artifact.mjs uses for the same reason. */ + async function assertRouterDaemon(absolutePath, env) { + const child = spawn(process.execPath, [absolutePath], { + env: { ...env, MEMESH_ROUTER_SOCKET: routerSocket, MEMESH_ROUTER_TOKEN_FILE: routerTokenFile }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + let exitInfo = null; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code, signal) => { exitInfo = { code, signal }; }); + try { + await waitFor(() => fs.existsSync(routerSocket) || exitInfo !== null, 'memesh-router to create its socket', DAEMON_READY_TIMEOUT_MS); + if (exitInfo !== null) throw new Error(`exited before creating a socket (${JSON.stringify(exitInfo)})\nstderr: ${stderr.slice(0, 500)}`); + } finally { + if (exitInfo === null) { + child.kill('SIGTERM'); + try { + await waitFor(() => exitInfo !== null, 'memesh-router to exit after SIGTERM', DAEMON_SHUTDOWN_GRACE_MS); + } catch { + child.kill('SIGKILL'); + } + } + for (const f of [routerSocket, routerTokenFile]) { + try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } + } + } + if (exitInfo !== null && exitInfo.code !== 0 && exitInfo.code !== null) { + throw new Error(`memesh-router did not shut down cleanly on SIGTERM (${JSON.stringify(exitInfo)})`); + } + } + + /** `memesh-host-claude` / `memesh-host-codex` / `memesh-host-acp` — each + * needs a host config file (`--config` or MEMESH_HOST_CONFIG) it will not + * find here. All three call `readHostConfig()` (src/host-runtime/config.ts:15), + * which throws "A host config file is required…" before touching stdin or + * any Windows-gated code path. The REQUIRED behaviour (spec, and this + * gate) is: fail closed with a named error and a non-crash exit — not a + * raw Node stack trace. + * + * All three now meet this — `codex.ts`/`acp.ts` originally called + * `await run…Host()` at top level with no try/catch and Node dumped the + * raw exception with stack frames; CHANGELOG.md's "Every host runtime now + * fails closed with the reason, not a stack trace" entry (fix/host-runtimes- + * fail-closed) gave every host runtime one shared `runHostEntry` wrapper + * instead of three hand-copied try/catch blocks, precisely so this + * assertion could stop being written to a spec none of them met yet. */ + async function assertHostRuntimeFailsClosed(absolutePath, env) { + const result = await run(absolutePath, [], { input: '', env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms instead of failing closed`); + if (result.status === 0) throw new Error('exited 0 with no host config — expected a fail-closed non-zero exit'); + if (result.stderr.trim() === '') throw new Error(`exited ${result.status} with no stderr message — a fail-closed exit must name the error`); + if (looksLikeStackTrace(result.stderr)) { + throw new Error( + `exited ${result.status} but stderr is a raw stack trace, not a named error:\n${result.stderr.slice(0, 800)}`, + ); + } + } + + /** `memesh-host-codex-session` (both the `bin` entry and the async + * SessionStart hook — same file, src/host-runtime/codex-session.ts). + * Unlike the three above, its `main()` checks + * `fs.existsSync(configPath)` FIRST (codex-session.ts:99-100) and returns + * before ever reading stdin when no host config exists — a silent, + * successful no-op is the documented safe behaviour here, not a bug. */ + async function assertCodexSessionOptionalConfig(absolutePath, env, payload) { + const result = await run(absolutePath, [], { input: JSON.stringify(payload), env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status} with no host config present (expected a quiet no-op)\nstderr: ${result.stderr.slice(0, 500)}`); + } + + /** The eight ordinary hook scripts under scripts/hooks/. Each gets the + * minimal well-formed payload for the event it is bound to (same shapes + * used by tests/hooks/hook-output-contract.test.ts's HOOK_CASES, without + * the memory seeding that test uses to force specific branches — this + * gate only needs to prove the process starts and speaks the contract, + * not exercise every branch). */ + async function assertHookStarts(absolutePath, env, boundEvent, payload) { + const result = await run(absolutePath, [], { + input: JSON.stringify(payload), + env: { ...env, MEMESH_AUTO_UPDATE: '0' }, + timeoutMs: 15_000, + }); + if (result.timedOut) throw new Error('timed out after 15000ms'); + if (result.status !== 0) throw new Error(`exited ${result.status}\nstderr: ${result.stderr.slice(0, 500)}`); + const shape = isPlausibleHookOutput(result.stdout, boundEvent); + if (!shape.ok) throw new Error(shape.reason); + } + + // --- Profiles: which assertion applies to which declared relativePath -- + // + // Keyed by relativePath (unique across the whole derived list, unlike + // basename — dist/mcp/server.js and dist/transports/http/server.js are + // both literally "server.js"). An entry the derivation produces with no + // profile here is NOT silently passed — see the fallback below. + const projectCwd = path.join(gateRoot, 'project'); + const hookPayload = (overrides) => ({ + session_id: 'entry-point-gate', + cwd: projectCwd, + ...overrides, + }); + + const PROFILES = { + 'dist/transports/cli/cli.js': { run: (abs, env) => assertCliVersion(abs, env) }, + 'dist/mcp/server.js': { run: (abs, env) => assertMcpStdioEof(abs, env) }, + 'dist/transports/http/server.js': { run: (abs, env) => assertHttpDaemon(abs, env) }, + 'dist/host-runtime/router.js': { run: (abs, env) => assertRouterDaemon(abs, env) }, + 'dist/host-runtime/claude.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/codex.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/acp.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/codex-session.js': { + run: (abs, env) => assertCodexSessionOptionalConfig(abs, env, hookPayload({ hook_event_name: 'SessionStart', source: 'startup' })), + }, + 'scripts/hooks/pre-edit-recall.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreToolUse', hookPayload({ + hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: path.join(projectCwd, 'src/x.ts') }, + })), + }, + 'scripts/hooks/guard-check.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreToolUse', hookPayload({ + hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo hi' }, + })), + }, + 'scripts/hooks/session-start.js': { + run: (abs, env) => assertHookStarts(abs, env, 'SessionStart', hookPayload({ + hook_event_name: 'SessionStart', source: 'startup', + })), + }, + 'scripts/hooks/post-commit.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PostToolUse', hookPayload({ + hook_event_name: 'PostToolUse', tool_name: 'Bash', + tool_input: { command: 'git commit -m test' }, + tool_response: { stdout: '[main abc1234] test' }, + })), + }, + 'scripts/hooks/decision-nudge.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PostToolUse', hookPayload({ + hook_event_name: 'PostToolUse', tool_name: 'ExitPlanMode', tool_input: { plan: 'entry-point gate smoke' }, + })), + }, + 'scripts/hooks/session-summary.js': { + run: (abs, env) => assertHookStarts(abs, env, 'Stop', hookPayload({ + hook_event_name: 'Stop', transcript_path: '', + })), + }, + 'scripts/hooks/pre-compact.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreCompact', hookPayload({ + hook_event_name: 'PreCompact', transcript_path: '', reason: 'auto', + })), + }, + 'scripts/hooks/user-prompt-intent.js': { + run: (abs, env) => assertHookStarts(abs, env, 'UserPromptSubmit', hookPayload({ + hook_event_name: 'UserPromptSubmit', prompt: 'hello', + })), + }, + }; + + try { + // --- Requirement 1: the list is derived, never hand-written ---------- + // `binTargets`/`hookCommands` (scripts/lib/executable-targets.mjs) return + // plain relative-path lists — the single derivation every consumer in + // this repo shares (see that file's own header comment for why there is + // exactly one). The bin NAME (e.g. "memesh-mcp") is not part of that + // return value, so it is recovered here, once, from the same + // package.json `bin` map the derivation itself reads — for the log + // label only; PROFILES below is keyed on relativePath, not on this name. + const bins = binTargets(packageDir); + const hooks = hookCommands(packageDir); + const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + const binNameByPath = new Map( + Object.entries(pkg.bin ?? {}).map(([name, relativePath]) => [relativePath, name]), + ); + const skipList = computeSkipList(process.platform); + const skippedPaths = new Set(skipList.map((s) => s.relativePath)); + + process.stdout.write(`entry-point-start: ${bins.length} bin(s) + ${hooks.length} hook(s) derived from package.json / hooks/hooks.json\n`); + + for (const skip of skipList) { + skipped.push(skip); + process.stdout.write(` SKIP ${skip.relativePath} — ${skip.reason}\n`); + } + + const entries = [ + ...bins.map((relativePath) => ({ + label: `bin ${binNameByPath.get(relativePath) ?? relativePath} (${relativePath})`, + relativePath, + })), + ...hooks.map((relativePath) => ({ label: `hook (${relativePath})`, relativePath })), + ]; + + for (const entry of entries) { + if (skippedPaths.has(entry.relativePath)) continue; + const absolutePath = path.join(packageDir, entry.relativePath); + if (!fs.existsSync(absolutePath)) { + entryFailures.push({ label: entry.label, message: `${entry.relativePath} does not exist — run \`npm run build\` first` }); + process.stdout.write(` FAIL ${entry.label} — does not exist\n`); + checked += 1; + continue; + } + const profile = PROFILES[entry.relativePath]; + if (!profile) { + // Requirement 1: a new manifest entry must be covered automatically. + // It cannot be covered by a correct assertion nobody wrote yet, so + // it is covered by failing loudly instead of silently passing. + entryFailures.push({ + label: entry.label, + message: `no execution profile defined for ${entry.relativePath} in scripts/check-entry-points-start.mjs — add one to PROFILES`, + }); + process.stdout.write(` FAIL ${entry.label} — no execution profile\n`); + checked += 1; + continue; + } + await record(entry.label, profile.run(absolutePath, baseEnv)); + } + + // --- Requirement 5: unresolved ${...} placeholders are a failure ----- + // Not an entry point, so kept out of `checked`/`entryFailures` — folding + // it in there is what previously made `checked - entryFailures.length` + // under-report the started count and mislabeled the FAIL summary as + // "entry point(s) did not start" for a manifest string. + const placeholders = findUnresolvedPlaceholders(packageDir); + for (const finding of placeholders) { + placeholderFindings.push({ + label: `placeholder in ${finding.manifest}`, + message: `${finding.manifest} still contains the unresolved placeholder ${finding.raw} — nothing substitutes ${finding.variable} in this manifest's real load path`, + }); + process.stdout.write(` FAIL placeholder ${finding.raw} in ${finding.manifest}\n`); + } + } finally { + // Best-effort, not guaranteed: session-start.js always spawns a + // detached, unref'd `memesh status` to refresh its update-check cache + // (scripts/hooks/session-start.js `spawnFreshUpdateCheck` — this is + // unconditional, MEMESH_AUTO_UPDATE only gates the separate auto-INSTALL + // policy, not this cache refresh). That child can still be writing + // `update-check..json` into gateRoot after this hook process + // has already exited, which occasionally beats this rmSync and leaves a + // small directory behind. tests/helpers/temp-dir.ts documents the exact + // same race for the vitest suite (which spawns session-start.js far + // more often, in the same CI job, via `npm test -- --run` right after + // this gate); a leaked temp file is harmless on a CI runner and retrying + // forever cannot outlast a process still writing, so this is a bounded + // best-effort clean, not a correctness requirement. + try { fs.rmSync(gateRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best effort */ } + for (const f of [routerSocket, routerTokenFile]) { + try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } + } + } + + process.stdout.write( + `entry-point-start: ${checked - entryFailures.length}/${checked} entry point(s) started` + + (skipped.length ? `, ${skipped.length} skipped` : '') + + (placeholderFindings.length ? `, ${placeholderFindings.length} manifest placeholder finding(s)` : '') + + '\n', + ); + + const totalFailures = entryFailures.length + placeholderFindings.length; + if (totalFailures > 0) { + process.stderr.write( + `entry-point-start: FAIL (${entryFailures.length} entry point(s) did not start, ` + + `${placeholderFindings.length} manifest placeholder finding(s))\n`, + ); + for (const f of [...entryFailures, ...placeholderFindings]) process.stderr.write(` - ${f.label}: ${f.message}\n`); + process.exit(1); + } + + process.stdout.write('entry-point-start: PASS\n'); +} + +// Guard `main()` behind an entrypoint check — mirroring the same pattern +// src/host-runtime/{claude,codex,acp,codex-session}.ts already use — so that +// `computeSkipList`/`findUnresolvedPlaceholders` can be imported directly by +// tests (tests/entry-points-start.test.ts) without running the whole gate, +// spawning 17 processes, and calling `process.exit()`, as an import side +// effect inside the vitest worker. +function isMainModule() { + const entrypoint = process.argv[1]; + if (!entrypoint) return false; + try { + return fs.realpathSync(entrypoint) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + main(); +} diff --git a/scripts/finish-release.mjs b/scripts/finish-release.mjs index 4c660badc..e2643691f 100644 --- a/scripts/finish-release.mjs +++ b/scripts/finish-release.mjs @@ -36,16 +36,27 @@ // object either: "Protect release and benchmark tags" covers `refs/tags/v*` // with `deletion` and `non_fast_forward` rules only, so creating a new tag is // not what it blocks. +// +// REAL-CREDENTIAL CHECKS, NOT JUST DOCUMENTED ONES +// +// `npm run qa:pre-release` and `npm run qa:live-journey` both existed before +// this file called either — available, but not required, which is a check +// that gets skipped exactly when a release is rushed. This file now runs +// `qa:pre-release` itself and blocks on its real exit code, and requires a +// `qa:live-journey` receipt for THIS exact commit (either host) before it +// will proceed. See the "G4" comment further down for the mechanics. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { checkReleasePreconditions, shippedPathsFromPackageJson, extractChangelogSection, + findUsableLiveJourneyReceipt, + LIVE_JOURNEY_RECEIPT_PATHS, } from './lib/release-preconditions.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -100,6 +111,21 @@ const statusOut = capture('git', ['status', '--porcelain']); const isClean = statusOut === null ? null : statusOut === ''; const headSha = capture('git', ['rev-parse', 'HEAD']); +// Fail fast on the single most common mistake — running this from the +// release branch itself, before its PR is merged — before paying for the +// several-minutes-long `qa:pre-release` run below. This duplicates exactly +// ONE condition from `checkReleasePreconditions`, not the sequence itself; +// that function still re-checks it for real once every input is gathered, +// so this early exit can never be the only thing standing between a mistake +// and a tag. +if (branch !== 'main') { + console.error( + `\n✗ refusing to cut ${tag}: not on main (branch: ${branch ?? 'undiscoverable'}) — ` + + 'a release is cut from main after the release PR is merged, never from the branch that raised it' + ); + process.exit(1); +} + const remoteMainLine = capture('git', ['ls-remote', 'origin', 'refs/heads/main']); const remoteHeadSha = remoteMainLine ? remoteMainLine.split(/\s+/)[0] : null; @@ -138,6 +164,44 @@ const shippedFilesChangedSinceBump = bumpCommit && shippedPaths // the three things that must be true before anything is created. const repoSlug = capture('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']); +// --- G4: real-credential checks, run HERE rather than merely documented ---- +// +// `npm run qa:pre-release` (build + verify:artifact + audit:memory) and +// `npm run qa:live-journey` (a real Codex thread or a real interactive Claude +// Code session) both exist and both catch what CI structurally cannot — CI +// runs on a fresh checkout with no logins, while the incidents this command +// exists to stop (v4.7.0's ghost publish, v4.8.2's stale plugin cache) lived +// in state only THIS machine has. Before this, both were available but +// optional — `scripts/qa/pre-release.mjs`'s own NOT_CHECKED list named this +// gap. A check nobody has to run is a check that gets skipped exactly when a +// release is rushed, which is when it is needed most. +// +// `qa:pre-release` is fully scriptable, so it is RUN, not merely checked for +// — a receipt can go stale the moment the next commit lands, and re-running a +// few minutes of build+test is cheaper than trusting a stale one. +console.log(`\n--- npm run qa:pre-release (build + verify:artifact + audit:memory; several minutes)`); +const qaPreReleaseResult = spawnSync('npm', ['run', 'qa:pre-release'], { + cwd: repoRoot, + stdio: 'inherit', + shell: process.platform === 'win32', +}); +const qaPreReleaseStatus = qaPreReleaseResult.status; + +// `qa:live-journey` needs a Codex login or a person at an interactive Claude +// Code session — nothing this script can open itself, so this stays +// receipt-based. `--host codex --out .qa/codex-report.json` (or `claude`) +// writes a `memesh-live-journey/v1` report; any ONE of the two hosts is +// accepted; the check is which is on-disk, current-revision and PASS, not a +// fixed host, because only Codex can be driven unattended today. +const liveJourneyCandidates = LIVE_JOURNEY_RECEIPT_PATHS.map(({ host, relativePath }) => { + const receiptPath = path.join(repoRoot, relativePath); + try { + return { host, path: receiptPath, report: JSON.parse(fs.readFileSync(receiptPath, 'utf8')), readError: null }; + } catch (e) { + return { host, path: receiptPath, report: null, readError: e.code === 'ENOENT' ? 'not found' : e.message }; + } +}); + let notes = null; if (notesFile) { try { @@ -164,13 +228,22 @@ const { ok, blockers } = checkReleasePreconditions({ repoSlug, notes, shippedFilesChangedSinceBump, + qaPreReleaseStatus, + liveJourneyCandidates, }); -console.log(`finish-release: ${tag}`); +console.log(`\nfinish-release: ${tag}`); console.log(` repo: ${repoSlug ?? '(gh could not say)'}`); console.log(` branch: ${branch ?? '(undiscoverable)'}`); console.log(` commit: ${headSha ? headSha.slice(0, 8) : '(unknown)'}`); console.log(` notes: ${notesFile ?? `CHANGELOG.md [${pkgVersion}]`} (${notes ? notes.length : 0} chars)`); +console.log(` qa:pre-release: ${qaPreReleaseStatus === 0 ? 'PASS' : `FAIL (exit ${qaPreReleaseStatus ?? '(could not run)'})`}`); +{ + const liveJourney = findUsableLiveJourneyReceipt(liveJourneyCandidates, headSha); + console.log( + ` live-journey: ${liveJourney.ok ? `PASS (${liveJourney.usable.host}, ${liveJourney.usable.path})` : 'no usable receipt — see blockers below if any'}` + ); +} // Print the head of the body BEFORE acting, in both paths. The default source // is the CHANGELOG section, which for 4.6.1 was 26,355 characters — while the diff --git a/scripts/lib/release-preconditions.mjs b/scripts/lib/release-preconditions.mjs index 3106b2889..80686b7b9 100644 --- a/scripts/lib/release-preconditions.mjs +++ b/scripts/lib/release-preconditions.mjs @@ -34,6 +34,8 @@ * @param {string[]|null} input.remoteTags Every `v*` tag on `origin`. * @param {string|null} input.repoSlug `owner/name` as `gh` reports it — proof gh is authenticated. * @param {string|null} input.notes Release body, already resolved. + * @param {number|null} input.qaPreReleaseStatus Exit code of `npm run qa:pre-release`, run fresh by this script; null if it could not run at all. + * @param {LiveJourneyCandidate[]} input.liveJourneyCandidates See `findUsableLiveJourneyReceipt`. * @returns {{ok: boolean, blockers: string[]}} */ export function checkReleasePreconditions({ @@ -47,6 +49,8 @@ export function checkReleasePreconditions({ repoSlug, notes, shippedFilesChangedSinceBump, + qaPreReleaseStatus, + liveJourneyCandidates, }) { const blockers = []; const tag = `v${pkgVersion}`; @@ -150,9 +154,98 @@ export function checkReleasePreconditions({ ); } + // G4: real-credential checks CI cannot run — see finish-release.mjs for why + // each is gathered the way it is. + if (qaPreReleaseStatus !== 0) { + blockers.push( + `\`npm run qa:pre-release\` did not pass (${ + qaPreReleaseStatus === null ? 'could not run it at all' : `exit ${qaPreReleaseStatus}` + }) — fix what it reported, then re-run \`npm run release:finish\`` + ); + } + + const liveJourney = findUsableLiveJourneyReceipt(liveJourneyCandidates, headSha); + if (!liveJourney.ok) { + blockers.push( + 'no usable `npm run qa:live-journey` receipt for this exact commit — run ' + + `\`npm run qa:live-journey -- --host codex --out ${LIVE_JOURNEY_RECEIPT_PATHS[0].relativePath}\` ` + + '(or `--host claude` from an interactive Claude Code session) first. ' + + (Array.isArray(liveJourneyCandidates) && liveJourneyCandidates.length > 0 + ? `Checked: ${liveJourney.reasons.join('; ')}` + : 'No candidates were even checked — this is a caller bug, not a missing receipt.') + ); + } + return { ok: blockers.length === 0, blockers }; } +/** + * @typedef {{host: string, path: string, report: object|null, readError: string|null}} LiveJourneyCandidate + */ + +/** + * Where `finish-release.mjs` looks for a `qa:live-journey` report, relative to + * the repo root. `npm run qa:live-journey -- --host --out ` + * writes exactly this shape. Order does not encode preference — either host + * satisfies the gate — it only fixes which command this file's own messages + * suggest first. + */ +export const LIVE_JOURNEY_RECEIPT_PATHS = [ + { host: 'codex', relativePath: '.qa/codex-report.json' }, + { host: 'claude', relativePath: '.qa/claude-report.json' }, +]; + +/** + * Is any ONE of the candidate `qa:live-journey` reports usable as proof for + * THIS release? Any host qualifies — only the Codex path can be driven + * unattended today, but nothing here prefers it over a Claude-host receipt a + * human actually produced. + * + * A receipt is usable only if it is readable, is the report shape + * `live-journey.mjs` actually emits, passed, was not run against a dirty + * tree (a dirty-tree run does not describe any single commit), and names + * THIS exact commit — an older PASS proves an earlier revision, not this one. + * + * @param {LiveJourneyCandidate[]} candidates + * @param {string|null} headSha + * @returns {{ok: boolean, usable: LiveJourneyCandidate|null, reasons: string[]}} + */ +export function findUsableLiveJourneyReceipt(candidates, headSha) { + const reasons = []; + if (!Array.isArray(candidates) || candidates.length === 0) return { ok: false, usable: null, reasons }; + if (!headSha) { + return { ok: false, usable: null, reasons: ['HEAD sha is unknown, so no receipt could be matched to it'] }; + } + for (const candidate of candidates) { + const label = `${candidate.path} (${candidate.host})`; + if (!candidate.report) { + reasons.push(`${label}: ${candidate.readError === 'not found' ? 'not found' : `unreadable — ${candidate.readError}`}`); + continue; + } + const report = candidate.report; + if (report.schema_version !== 'memesh-live-journey/v1') { + reasons.push(`${label}: not a memesh-live-journey/v1 report`); + continue; + } + if (report.verdict !== 'PASS') { + reasons.push(`${label}: verdict is ${JSON.stringify(report.verdict ?? null)}, not PASS`); + continue; + } + if (report.dirty !== false) { + reasons.push(`${label}: ran against a dirty working tree, so it does not describe one commit`); + continue; + } + if (report.revision !== headSha) { + reasons.push( + `${label}: revision ${String(report.revision ?? '?').slice(0, 8)} does not match HEAD ${headSha.slice(0, 8)}` + ); + continue; + } + return { ok: true, usable: candidate, reasons }; + } + return { ok: false, usable: null, reasons }; +} + /** * The body of `## [X.Y.Z]` in a CHANGELOG, up to the next `## ` heading. * diff --git a/scripts/qa/pre-release.mjs b/scripts/qa/pre-release.mjs index f4bdd8485..2dd1443e3 100644 --- a/scripts/qa/pre-release.mjs +++ b/scripts/qa/pre-release.mjs @@ -45,9 +45,9 @@ export const STEPS = [ * cannot run here, so nobody reads a green verdict as more than it is. */ export const NOT_CHECKED = [ - 'npm run qa:live-journey -- --host claude — needs an interactive Claude Code session a script cannot open.', + 'npm run qa:live-journey -- --host claude — needs an interactive Claude Code session a script cannot open. ' + + 'A --host codex or --host claude receipt is required by `release:finish` instead (see release-preconditions.mjs).', 'npm run qa:post-release — only meaningful after the release is published; run it next.', - 'The entry-point start gate — unreviewed on branch fix/entry-point-execution-gate, deliberately not wired in.', ]; /** diff --git a/tests/entry-points-start.test.ts b/tests/entry-points-start.test.ts new file mode 100644 index 000000000..98291e55a --- /dev/null +++ b/tests/entry-points-start.test.ts @@ -0,0 +1,174 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { binTargets, hookCommands } from '../scripts/lib/executable-targets.mjs'; +import { computeSkipList, findUnresolvedPlaceholders } from '../scripts/check-entry-points-start.mjs'; + +const script = path.resolve('scripts/check-entry-points-start.mjs'); +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function write(root: string, file: string, value = ''): void { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, value); +} + +function fixtureRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-entry-gate-fixture-')); + dirs.push(root); + return root; +} + +function run(root: string) { + return spawnSync(process.execPath, [script, '--root', root], { encoding: 'utf8', timeout: 30_000 }); +} + +describe('executable-targets derivation picks up new manifest entries', () => { + // Requirement: "the list is derived, never hand-written... a new entry + // point added to either manifest must be covered automatically, and a + // test must prove that". This proves it at the derivation-function level + // — the layer both the gate and every other executable-targets.mjs + // consumer share (tests/installation.test.ts derives its own expected + // file lists from these same two functions) — rather than only at the + // whole-gate level, so a future third consumer inherits the same + // guarantee. + it('binTargets reflects a bin entry no fixture author hand-listed here', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ + bin: { memesh: 'dist/cli.js', 'memesh-brand-new-thing': 'dist/brand-new-thing.js' }, + })); + const entries = binTargets(root); + expect(entries).toHaveLength(2); + expect(entries).toContain('dist/brand-new-thing.js'); + }); + + it('hookCommands reflects a hook command no fixture author hand-listed here', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start.js' }] }], + PreCompact: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/brand-new-hook.js', async: true }] }], + }, + })); + const entries = hookCommands(root); + expect(entries).toHaveLength(2); + expect(entries).toContain('scripts/hooks/brand-new-hook.js'); + }); +}); + +describe('gate fails when a declared entry point does not start', () => { + // A minimal-but-complete fixture: one bin whose real file is broken, and + // one hook whose real file is a trivial no-op — both keyed at the SAME + // relative paths the real repo uses, so the gate's actual PROFILES table + // (keyed by relative path, not by root) applies to them unmodified. This + // exercises the real assertCliVersion/assertHookStarts logic against a + // controlled fixture instead of only ever running against the live repo. + function brokenBinFixture(): string { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { memesh: 'dist/transports/cli/cli.js' } })); + write(root, 'dist/transports/cli/cli.js', '#!/usr/bin/env node\nprocess.exit(7);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + return root; + } + + it('a broken bin fails the gate while an unrelated working hook still passes', () => { + const result = run(brokenBinFixture()); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).not.toBe(0); + expect(result.stderr).toContain('bin memesh (dist/transports/cli/cli.js)'); + expect(result.stderr).toContain('exited 7'); + expect(result.stdout).toContain('ok hook (scripts/hooks/pre-edit-recall.js)'); + }); + + it('a manifest entry with no execution profile fails loudly, not silently', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { 'memesh-unknown-thing': 'dist/unknown-thing.js' } })); + write(root, 'dist/unknown-thing.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + const result = run(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('no execution profile defined for dist/unknown-thing.js'); + }); +}); + +describe('unresolved ${...} placeholders fail the gate', () => { + // The manifest's own load path decides which substitutions are real. + // hooks/hooks.json is reachable ONLY through the plugin loader, so + // CLAUDE_PLUGIN_ROOT there is always defined and must NOT be flagged — + // otherwise this rule is vacuous for the one manifest (.mcp.json) it + // exists to catch, which is exactly the trap the header comment on + // findUnresolvedPlaceholders warns the next reader away from. + it('flags an unresolved placeholder in .mcp.json', () => { + const root = fixtureRoot(); + write(root, '.mcp.json', JSON.stringify({ + mcpServers: { memesh: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js'] } }, + })); + const findings = findUnresolvedPlaceholders(root); + expect(findings).toEqual([{ manifest: '.mcp.json', variable: 'CLAUDE_PLUGIN_ROOT', raw: '${CLAUDE_PLUGIN_ROOT}' }]); + }); + + it('does NOT flag ${CLAUDE_PLUGIN_ROOT} in hooks/hooks.json — plugin-loader-only, always defined there', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start.js' }] }] }, + })); + expect(findUnresolvedPlaceholders(root)).toEqual([]); + }); + + it('still flags a DIFFERENT unresolved variable in hooks/hooks.json — the exemption is CLAUDE_PLUGIN_ROOT only', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command: '${SOME_OTHER_VAR}/scripts/hooks/session-start.js' }] }] }, + })); + expect(findUnresolvedPlaceholders(root)).toEqual([{ manifest: 'hooks/hooks.json', variable: 'SOME_OTHER_VAR', raw: '${SOME_OTHER_VAR}' }]); + }); + + it('the whole gate fails, not just the helper function, when .mcp.json has an unresolved placeholder', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { memesh: 'dist/cli.js' } })); + write(root, 'dist/cli.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, '.mcp.json', JSON.stringify({ + mcpServers: { memesh: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js'] } }, + })); + const result = run(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('.mcp.json still contains the unresolved placeholder ${CLAUDE_PLUGIN_ROOT}'); + }); +}); + +describe('Windows skip list does not grow silently', () => { + // Requirement: "If any binary genuinely cannot be started in CI, do not + // skip it silently... add a test that the skip list is not silently + // growing." Pinned as an exact list (not just a length) so an addition + // must edit this test, not merely bump a number. + it('is empty on POSIX platforms', () => { + expect(computeSkipList('darwin')).toEqual([]); + expect(computeSkipList('linux')).toEqual([]); + }); + + it('is pinned to exactly memesh-router on win32', () => { + const relativePaths = computeSkipList('win32').map((entry) => entry.relativePath); + expect(relativePaths).toEqual(['dist/host-runtime/router.js']); + }); + + it('every skipped entry names a non-empty reason', () => { + for (const entry of computeSkipList('win32')) { + expect(entry.reason.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/qa-pre-release.test.ts b/tests/qa-pre-release.test.ts index c9a75eb5f..27aaaaf35 100644 --- a/tests/qa-pre-release.test.ts +++ b/tests/qa-pre-release.test.ts @@ -65,11 +65,28 @@ describe('the plan', () => { expect(scripts.prepublishOnly).not.toContain('test:packaged:upgrade'); }); - it('says what it cannot check, including the two gates that live elsewhere', () => { + it('reaches the entry-point start gate transitively, through verify:release', () => { + // `qa:pre-release` runs `verify:artifact`, which runs `verify:release`. + // Pinned here, not just in verify:release's own test, because THIS is + // the file whose NOT_CHECKED list claims the entry-point gate is covered + // — if `verify:release` ever stopped calling it, this is where that + // claim would go silently false. + const scripts = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).scripts; + expect(scripts['verify:release']).toContain('check:entry-points-start'); + expect(scripts['check:entry-points-start']).toContain('check-entry-points-start.mjs'); + }); + + it('says what it cannot check, including the one gate that still lives elsewhere', () => { + // The entry-point start gate used to be named here too — it is not + // anymore, because it is wired into `verify:release` (and therefore into + // `verify:artifact`, one of the STEPS above) rather than merely + // documented as missing. This test would go green again if that wiring + // were ever quietly removed while this line was deleted along with it, + // so the negative assertion matters as much as the positive ones. const text = NOT_CHECKED.join('\n'); expect(text).toMatch(/live-journey/); expect(text).toMatch(/qa:post-release/); - expect(text).toMatch(/entry-point/); + expect(text).not.toMatch(/entry-point/); }); it('detects a step whose npm script has been renamed away', () => { diff --git a/tests/release-preconditions.test.ts b/tests/release-preconditions.test.ts index 200fb5877..b09f26741 100644 --- a/tests/release-preconditions.test.ts +++ b/tests/release-preconditions.test.ts @@ -22,23 +22,41 @@ import { checkReleasePreconditions, extractChangelogSection, shippedPathsFromPackageJson, + findUsableLiveJourneyReceipt, + LIVE_JOURNEY_RECEIPT_PATHS, } from '../scripts/lib/release-preconditions.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HEAD = 'a'.repeat(40); + +/** A `qa:live-journey` candidate list with exactly one usable PASS receipt for HEAD. */ +function readyLiveJourney() { + return [ + { + host: 'codex', + path: '.qa/codex-report.json', + report: { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'codex' }, + readError: null, + }, + { host: 'claude', path: '.qa/claude-report.json', report: null, readError: 'not found' }, + ]; +} /** A state where cutting 4.7.0 is exactly the right thing to do. */ function ready(overrides: Record = {}) { return { branch: 'main', isClean: true, - headSha: 'a'.repeat(40), - remoteHeadSha: 'a'.repeat(40), + headSha: HEAD, + remoteHeadSha: HEAD, pkgVersion: '4.7.0', localTags: ['v4.6.0', 'v4.6.1'], remoteTags: ['v4.6.0', 'v4.6.1'], repoSlug: 'PCIRCLE-AI/memesh', notes: '### Added\n\n- something', shippedFilesChangedSinceBump: [], + qaPreReleaseStatus: 0, + liveJourneyCandidates: readyLiveJourney(), ...overrides, }; } @@ -153,6 +171,119 @@ describe('release preconditions', () => { const r = checkReleasePreconditions(ready({ branch: 'topic', isClean: false, notes: null })); expect(r.blockers.length).toBeGreaterThanOrEqual(3); }); + + // G4: real-credential gates. `qa:pre-release` is run fresh by + // finish-release.mjs and its EXIT CODE gates; `qa:live-journey` cannot run + // unattended, so a recorded receipt gates instead — see + // `findUsableLiveJourneyReceipt` below for what makes one usable. + it('refuses when `npm run qa:pre-release` did not pass', () => { + const r = checkReleasePreconditions(ready({ qaPreReleaseStatus: 1 })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('qa:pre-release'); + }); + + it('refuses when `npm run qa:pre-release` could not even run', () => { + const r = checkReleasePreconditions(ready({ qaPreReleaseStatus: null })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('could not run it at all'); + }); + + it('refuses when no qa:live-journey receipt is usable', () => { + const r = checkReleasePreconditions(ready({ liveJourneyCandidates: [] })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('qa:live-journey'); + }); + + it('accepts a Claude-host receipt just as readily as a Codex one', () => { + const claudeOnly = [ + { host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }, + { + host: 'claude', + path: '.qa/claude-report.json', + report: { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'claude' }, + readError: null, + }, + ]; + const r = checkReleasePreconditions(ready({ liveJourneyCandidates: claudeOnly })); + expect(r.ok).toBe(true); + }); +}); + +describe('findUsableLiveJourneyReceipt', () => { + const pass = { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'codex' }; + + it('accepts a PASS receipt for the exact HEAD revision', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: pass, readError: null }], HEAD); + expect(r.ok).toBe(true); + expect(r.usable?.host).toBe('codex'); + }); + + it('refuses a receipt for a different revision', () => { + const stale = { ...pass, revision: 'b'.repeat(40) }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: stale, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('does not match HEAD'); + }); + + it('refuses a FAIL verdict', () => { + const failed = { ...pass, verdict: 'FAIL' }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: failed, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not PASS'); + }); + + it('refuses a receipt recorded against a dirty working tree', () => { + const dirty = { ...pass, dirty: true }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: dirty, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('dirty'); + }); + + it('refuses a missing file rather than treating absence as pass', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not found'); + }); + + it('refuses an unreadable file distinctly from a missing one', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'Unexpected token' }], HEAD); + expect(r.reasons.join('\n')).toContain('unreadable — Unexpected token'); + }); + + it('refuses a report of the wrong shape (not a memesh-live-journey/v1 report)', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: { foo: 'bar' }, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not a memesh-live-journey/v1 report'); + }); + + it('falls through to the second candidate when the first is unusable', () => { + const r = findUsableLiveJourneyReceipt( + [ + { host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }, + { host: 'claude', path: '.qa/claude-report.json', report: pass, readError: null }, + ], + HEAD, + ); + expect(r.ok).toBe(true); + expect(r.usable?.host).toBe('claude'); + }); + + it('refuses with no candidates rather than vacuously passing', () => { + expect(findUsableLiveJourneyReceipt([], HEAD).ok).toBe(false); + }); + + it('refuses when headSha itself is unknown, rather than matching against null', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: pass, readError: null }], null); + expect(r.ok).toBe(false); + }); + + it('LIVE_JOURNEY_RECEIPT_PATHS names both hosts under the gitignored .qa/ directory', () => { + const hosts = LIVE_JOURNEY_RECEIPT_PATHS.map((c: { host: string }) => c.host).sort(); + expect(hosts).toEqual(['claude', 'codex']); + for (const c of LIVE_JOURNEY_RECEIPT_PATHS) { + expect((c as { relativePath: string }).relativePath.startsWith('.qa/')).toBe(true); + } + }); }); describe('release notes come from the CHANGELOG section', () => { @@ -272,6 +403,28 @@ describe('finish-release cuts the release in one call', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(pkg.scripts['release:finish']).toContain('scripts/finish-release.mjs'); }); + + it('runs `npm run qa:pre-release` itself, gating on its real exit code (G4)', () => { + expect(code).toMatch(/'run',\s*'qa:pre-release'/); + expect(code).toMatch(/qaPreReleaseStatus/); + }); + + it('checks for a qa:live-journey receipt for THIS commit, not merely that the command exists (G4)', () => { + expect(code).toMatch(/findUsableLiveJourneyReceipt/); + expect(code).toMatch(/LIVE_JOURNEY_RECEIPT_PATHS/); + }); + + it('fails fast on the wrong branch before spending minutes on qa:pre-release', () => { + // The wrong-branch exit and the expensive spawn are both pinned, and in + // that order — deleting the fast-fail (or moving it after the spawn) + // would satisfy every other test in this file while reintroducing the + // multi-minute wait on a one-line mistake this check exists to avoid. + const branchGuardAt = code.indexOf("branch !== 'main'"); + const qaSpawnAt = code.indexOf("'run', 'qa:pre-release'"); + expect(branchGuardAt, 'no fast-fail branch guard found').toBeGreaterThan(-1); + expect(qaSpawnAt, 'the qa:pre-release spawn is not there — this file is not what it was').toBeGreaterThan(-1); + expect(branchGuardAt).toBeLessThan(qaSpawnAt); + }); }); describe('shipped paths come from package.json, not a hand-kept list', () => { From 1674dc4dc03752f71422a381cea4150fe258727f Mon Sep 17 00:00:00 2001 From: verify-sim Date: Sat, 5 Sep 2026 01:25:02 +0800 Subject: [PATCH 5/5] fix(gate): router socket lived beside the private gate dir, not inside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-entry-points-start.mjs put the router's probe socket at `${gateRoot}-r.sock` — a sibling of the mkdtemp'd gate directory, so its parent was os.tmpdir() itself. On ubuntu-latest CI that is the shared, world-writable /tmp (mode 1777), and agent-router.ts's own startup check correctly refuses to bind there: insecure_socket_directory, "Router socket directory must be private." Invisible locally because macOS's os.tmpdir() already resolves to a per-user 0700 directory. Nest the socket and token file inside gateRoot instead (already 0700 via mkdtemp, now also chmod'd explicitly rather than assumed) and measured the resulting path at 66 bytes on macOS's unusually long tmpdir — well under the 103-byte AF_UNIX limit, shorter still on Linux. Never shipped: caught by this PR's own CI before merge, so no CHANGELOG entry for a bug no release ever had. --- scripts/check-entry-points-start.mjs | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/scripts/check-entry-points-start.mjs b/scripts/check-entry-points-start.mjs index b7bc69cdd..5c2295fea 100644 --- a/scripts/check-entry-points-start.mjs +++ b/scripts/check-entry-points-start.mjs @@ -250,14 +250,30 @@ function looksLikeStackTrace(stderr) { async function main() { const gateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mep-')); + // Belt-and-suspenders: mkdtemp already defaults to 0700 on every platform + // this gate runs on, but the router's own startup check (agent-router.ts + // `start()`: `(directoryMode & 0o077) !== 0` throws `insecure_socket_directory`) + // is exactly the kind of check this gate exists to actually exercise, not + // assume past. Making it explicit removes any dependency on umask. + fs.chmodSync(gateRoot, 0o700); const memeshDir = path.join(gateRoot, '.memesh'); fs.mkdirSync(memeshDir, { recursive: true }); const memeshDbPath = path.join(memeshDir, 'kg.db'); - // AF_UNIX paths are capped at 103 bytes (src/core/agent-router.ts:1482) — - // sibling of gateRoot, not nested under it, to stay short regardless of - // how deep the platform's tmpdir already is. - const routerSocket = `${gateRoot}-r.sock`; - const routerTokenFile = `${gateRoot}-r.token`; + // NESTED under gateRoot, not a sibling of it — a sibling's directory is + // `os.tmpdir()` itself, which is the shared, world-writable system temp dir + // on Linux (`/tmp`, mode 1777), not gateRoot's own private (0700) + // directory. That is precisely the failure this router rejects: measured + // on ubuntu-latest CI, `memesh-router` refused to start with + // `insecure_socket_directory: Router socket directory must be private` — + // masked locally on macOS only because `os.tmpdir()` there already + // resolves to a per-user 0700 directory, so the bug was invisible on the + // one platform this gate had been run on before it first reached CI. + // AF_UNIX paths are capped at 103 bytes (agent-router.ts `validateSocketPath`); + // nesting one level costs one path separator — measured 66 bytes total on + // macOS's unusually long tmpdir (`/var/folders/.../T/mep-XXXXXX/r.sock`), + // well inside the limit, and shorter still on Linux's `/tmp`. + const routerSocket = path.join(gateRoot, 'r.sock'); + const routerTokenFile = path.join(gateRoot, 'r.token'); const baseEnv = { ...process.env,