Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 20 additions & 1 deletion scripts/ci/package-metadata.mjs
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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:");
Expand Down
142 changes: 142 additions & 0 deletions scripts/version-families.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading