diff --git a/README.md b/README.md index bc77474..9222048 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,19 @@ pnpm run repo-hygiene pnpm run security-compliance ``` +Package versions are intentionally scoped by release family instead of one +global monorepo version. `@atomicmemory/core` and `@atomicmemory/sdk` move +independently. Host plugins move together, framework adapters move together, +and the CLI/MCP-server tool pair moves together: + +```bash +pnpm check:version-families # CI guard for drift +``` + +Release bumping, public sync, and registry publish preparation are owned by the +ops repo. Keep this source repo focused on package metadata and version-family +consistency checks. + Build, test, lint, and docs-contract run through Turborepo's task graph. Typecheck declares no cache outputs because package scripts use `tsc --noEmit`. The side-effecting checks (`pack-dry-run`, `public-integration-smoke`, diff --git a/package.json b/package.json index 7d6206c..fd4643a 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,10 @@ "repo-hygiene": "node scripts/ci/repo-hygiene.mjs", "security-compliance": "node scripts/security/security-compliance.mjs", "pack-dry-run": "node scripts/ci/pack-dry-run.mjs", + "check:plugin-versions": "node scripts/version-families.mjs plugin --check", + "check:adapter-versions": "node scripts/version-families.mjs adapter --check", + "check:tool-versions": "node scripts/version-families.mjs tool --check", + "check:version-families": "pnpm check:plugin-versions && pnpm check:adapter-versions && pnpm check:tool-versions", "ci:affected": "turbo run build typecheck lint --affected && turbo run test --affected --filter='!@atomicmemory/core'", "ci:code-health": "node scripts/ci/code-health.mjs --verify && (turbo run code-health --affected || turbo run code-health)", "ci:pack-dry-run": "turbo run build && node scripts/ci/pack-dry-run.mjs", diff --git a/scripts/ci/package-metadata.mjs b/scripts/ci/package-metadata.mjs index fe8c9e8..f862e58 100644 --- a/scripts/ci/package-metadata.mjs +++ b/scripts/ci/package-metadata.mjs @@ -1,12 +1,14 @@ /** * Validate public npm package metadata for monorepo package manifests. */ +import { spawnSync } from "node:child_process"; import { packageJsonFiles, readJson } from "./lib/repo-files.mjs"; const MONOREPO_URL = "git+https://github.com/atomicstrata/atomicmemory.git"; const BUGS_URL = "https://github.com/atomicstrata/atomicmemory/issues"; const HOMEPAGE_ROOT = "https://github.com/atomicstrata/atomicmemory/tree/main"; const PUBLISHABLE_ROOTS = ["packages/", "adapters/", "plugins/"]; +const VERSION_FAMILIES = ["plugin", "adapter", "tool"]; const HOST_PLUGIN_ARTIFACTS = [ ".claude-plugin", "openclaw.plugin.json", @@ -102,8 +104,25 @@ function validateFiles(filePath, files) { return [`${filePath}: publishable packages must declare a non-empty files array`]; } +function validateVersionFamilies() { + return VERSION_FAMILIES.flatMap((family) => { + const result = spawnSync(process.execPath, ["scripts/version-families.mjs", family, "--check"], { + encoding: "utf8", + }); + if (result.status === 0) { + return []; + } + + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + return [`version family '${family}' failed:\n${output || `exit ${result.status}`}`]; + }); +} + function main() { - const failures = packageJsonFiles().flatMap(validateManifest); + const failures = [ + ...packageJsonFiles().flatMap(validateManifest), + ...validateVersionFamilies(), + ]; if (failures.length > 0) { console.error("Package metadata failed:"); diff --git a/scripts/version-families.mjs b/scripts/version-families.mjs new file mode 100644 index 0000000..a0f0ac8 --- /dev/null +++ b/scripts/version-families.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +/** + * Validate lockstep package-version families. + * + * AtomicMemory intentionally avoids one global monorepo version. Instead, + * only tightly coupled release families move together: host plugins, + * framework adapters, and the CLI/MCP tool pair. This script is the single + * source of truth for source-repo CI checks. Release bumping and publish + * preparation live in the ops repo. + */ + +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); +const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +const familyName = process.argv[2]; +const args = process.argv.slice(3); +const checkOnly = args.includes("--check"); +const knownFlags = new Set(["--check"]); +const positional = args.filter((arg) => !arg.startsWith("--")); +const unknownFlag = args.find((arg) => arg.startsWith("--") && !knownFlags.has(arg)); + +const families = { + plugin: [ + marketplacePluginTarget(".claude-plugin/marketplace.json", "claude-code"), + jsonPathTarget("plugins/claude-code/.claude-plugin/plugin.json", ["version"]), + jsonPathTarget("plugins/claude-code/package.json", ["version"]), + jsonPathTarget("plugins/codex/.codex-plugin/plugin.json", ["version"]), + jsonPathTarget("plugins/codex/package.json", ["version"]), + regexTarget("plugins/codex/skills/atomicmemory/SKILL.md", "metadata.version", /^ version: "([^"]+)"$/m), + jsonPathTarget("plugins/openclaw/openclaw.plugin.json", ["version"]), + jsonPathTarget("plugins/openclaw/package.json", ["version"]), + regexTarget("plugins/openclaw/skills/atomicmemory/skill.yaml", "version", /^version:\s*([^\s]+)\s*$/m), + jsonPathTarget("plugins/hermes/package.json", ["version"]), + regexTarget("plugins/hermes/pyproject.toml", "project.version", /^version\s*=\s*"([^"]+)"$/m), + regexTarget("plugins/hermes/plugin.yaml", "version", /^version:\s*([^\s]+)\s*$/m), + jsonPathTarget("plugins/cursor/package.json", ["version"]), + ], + adapter: [ + jsonPathTarget("adapters/langchain/package.json", ["version"]), + jsonPathTarget("adapters/langgraph/package.json", ["version"]), + jsonPathTarget("adapters/mastra/package.json", ["version"]), + jsonPathTarget("adapters/openai-agents/package.json", ["version"]), + jsonPathTarget("adapters/vercel-ai/package.json", ["version"]), + ], + tool: [ + jsonPathTarget("packages/cli/package.json", ["version"]), + jsonPathTarget("packages/cli/cli-spec.json", ["package_version"]), + jsonPathTarget("packages/mcp-server/package.json", ["version"]), + ], +}; + +main(); + +function main() { + if (!families[familyName]) usage(); + if (unknownFlag) fail(`Unknown flag '${unknownFlag}'. Use --check.`); + if (!checkOnly || positional.length > 0) usage(); + + const targets = families[familyName]; + const current = targets.map((target) => ({ target, version: target.read() })); + const uniqueVersions = [...new Set(current.map(({ version }) => version))]; + + if (uniqueVersions.length > 1) { + const details = current.map(({ target, version }) => ` - ${target.file} ${target.label}: ${version}`).join("\n"); + fail(`${familyName} versions are not aligned:\n${details}`); + } + + console.log(`${familyName} versions are aligned at ${uniqueVersions[0]}.`); +} + +function usage() { + const familiesList = Object.keys(families).join("|"); + fail(`Usage: node scripts/version-families.mjs <${familiesList}> --check`); +} + +function jsonPathTarget(file, path) { + return { + file, + label: `/${path.join("/")}`, + read() { + const json = readJson(file); + const version = readAtPath(json, path); + assertVersion(version, `${file} /${path.join("/")}`); + return version; + }, + }; +} + +function marketplacePluginTarget(file, pluginName) { + return { + file, + label: `plugins[${pluginName}].version`, + read() { + const plugin = findMarketplacePlugin(readJson(file), pluginName, file); + assertVersion(plugin.version, `${file} plugins[${pluginName}].version`); + return plugin.version; + }, + }; +} + +function regexTarget(file, label, pattern) { + const absolute = resolve(repoRoot, file); + return { + file, + label, + read() { + const content = readFileSync(absolute, "utf8"); + const match = content.match(pattern); + if (!match) fail(`Could not find ${label} in ${file}`); + assertVersion(match[1], `${file} ${label}`); + return match[1]; + }, + }; +} + +function readJson(file) { + return JSON.parse(readFileSync(resolve(repoRoot, file), "utf8")); +} + +function readAtPath(value, path) { + return path.reduce((current, segment) => current?.[segment], value); +} + +function findMarketplacePlugin(json, pluginName, file) { + const plugin = json.plugins?.find((entry) => entry.name === pluginName); + if (!plugin) fail(`Could not find plugin '${pluginName}' in ${file}`); + return plugin; +} + +function assertVersion(version, label) { + if (!VERSION_RE.test(version)) fail(`${label} has unsupported version '${version}'. Expected x.y.z.`); +} + +function fail(message) { + console.error(message); + process.exit(1); +}