mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-31 12:37:59 +00:00
Related: #166404 ## What Problem This Solves Fixes: shipped Plugin SDK subpaths and exports could be removed without the surface check noticing, which broke published third-party plugins such as `@tencent-weixin/openclaw-weixin` on 2026.10.1-beta.1. ## User Impact User impact: the stable Plugin SDK surface can no longer silently disappear. Removing a subpath or named export that shipped in the latest stable release now fails `pnpm plugin-sdk:surface:check` unless a compatibility record covers it with a final compatibility day that has passed. No runtime behavior changes; nothing is restored here (restoring the removed subpaths for #166404 is a separate product decision). ## Why This Change Was Made `plugin-sdk:surface:check` only enforced maximum export-count budgets, and the API baseline diff is report-only. The commits that retired `config-runtime`, `infra-runtime`, `channel-message`, `command-auth` and four other subpaths (912+ named exports that shipped in v2026.9.8) lowered those budgets in the same change, so the check passed. A ceiling cannot detect a removal. This adds a committed inventory of the latest stable release's typed public surface (`scripts/lib/plugin-sdk-shipped-surface.json`, pinned to the current npm `latest` v2026.9.9: 158 typed subpaths, 4,591 named exports, identical to v2026.9.8). It is generated from the release tag's package exports and declarations by `pnpm plugin-sdk:shipped-surface:gen -- --release <tag>`, reusing the existing tagged-revision renderer from `plugin-sdk:api:diff`, and is regenerated during stable closeout (documented in RELEASING.md). The surface check compares the current tree against it: - A missing shipped subpath, or a missing name in a surviving subpath, needs a compat record whose `surfaces` names `openclaw/plugin-sdk/<subpath>` (whole subpath) or the qualified `openclaw/plugin-sdk/<subpath>.<Name>`. Bare names do not count. - The record must be `deprecated`, `removal-pending`, or `removed` with a `removeAfter` strictly before the current UTC date (the date is the final compatibility day). A `removalGate` alone or a future date does not authorize; budgets play no role. - Packaged private runtime facades without a `types` condition are excluded: they are not a declared contract. To make current `main` pass without restoring anything, the already-landed removals are recorded honestly: - Dated tombstones for the five subpaths retired under the Sept 30 owner decision (`removeAfter` 2026-09-30) and new tombstones for `command-auth`, `discord`, `telegram-account`, whose records were deleted in0d530ea5(`removeAfter` 2026-10-01). - 14 later named-export removals from surviving subpaths (2fec39f5#165431,bd64d93f#165288,f2de06b3#163587) recorded retroactively per owner decision; each release note states the export was removed without a compatibility window. - The Skill Workshop proposal-hook types removed bycfee8511(#161057) already had a tombstone; its surfaces are now qualified and dated. Release-note context: Plugin SDK authors get a migration-guide row for each of the 14 retroactively recorded exports in `docs/plugins/sdk-migration/removed-surfaces.md`. ## Evidence - Inventory generation from tag `v2026.9.8` (fc23bc86): 158 typed subpaths, 4,591 names; `config-runtime` 115, `infra-runtime` 583, `channel-message` 136, `command-auth` 78 names. - Historical transition, guard + inventory overlaid on throwaway worktrees with `OPENCLAW_PLUGIN_SDK_SURFACE_NOW=2026-10-01`: `e649be31^` shows only pre-existing gaps; `e649be31` additionally fails on exactly `channel-lifecycle` (23), `channel-message` (136), `channel-reply-pipeline` (12), `config-runtime` (115), `infra-runtime` (583) despite its lowered budgets. - Final tree: `pnpm plugin-sdk:surface:check` → `shipped Plugin SDK v2026.9.8: no unauthorized removals`. Reverting the tombstone dates re-reports all eight subpaths; pinning `OPENCLAW_PLUGIN_SDK_SURFACE_NOW=2026-10-07` re-reports the eight Skill Workshop types (strict date semantics). - The guard already caught a live regression during this PR:cfee8511landed on main mid-review and the rebased check failed on its eight unqualified types until the record was fixed. - Regenerated against v2026.9.9 (bcfc8881) after it went stable mid-review: zero subpaths or names added or dropped versus v2026.9.8; check still clean. - PR wrapper inventory lists the new compat-record module (first CI run failed `eager-import-closure` / `pr-worktree-provision` on its absence). - Focused tests (`--maxWorkers=1`): `test/scripts/plugin-sdk-shipped-surface.test.ts` 20 passed (~2.2 s wall), `src/plugins/compat/registry.test.ts` 12 passed, `test/scripts/plugin-sdk-surface-report.test.ts` 11 passed / 1 Linux-only skip; all three ~11.7 s wall. Pure evaluator tests, no TS project boots, no timers. - `node scripts/check-changed.mjs`: every lane passed except two inherited from `main`: the dead-export scan (`observePlacementAuthority`, already fixed on main by017842386a) and core lint on three over-length files this PR does not touch. Direct oxlint on all changed `.ts`/`.mts` files is clean. - Autoreview (Codex, P2 threshold): scoped-clean.
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { format } from "oxfmt";
|
|
import {
|
|
compareStableReleases,
|
|
isStableRelease,
|
|
parsePluginSdkShippedSurface,
|
|
shippedSurfacePath,
|
|
typedPluginSdkSubpaths,
|
|
} from "./lib/plugin-sdk-shipped-surface.mts";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
import { renderPluginSdkApiRevisions } from "./plugin-sdk-api-diff.mts";
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2).filter((arg) => arg !== "--");
|
|
const release = args[1];
|
|
if (args.length !== 2 || args[0] !== "--release" || !release) {
|
|
throw new Error("Usage: pnpm plugin-sdk:shipped-surface:gen -- --release <stable tag>");
|
|
}
|
|
if (!isStableRelease(release)) {
|
|
throw new Error(`Expected a stable release tag (vX.Y.Z), got ${release}`);
|
|
}
|
|
const repoRoot = resolveRepoRoot(import.meta.url);
|
|
const git = (gitArgs: string[]) =>
|
|
execFileSync("git", gitArgs, { cwd: repoRoot, encoding: "utf8" }).trim();
|
|
const output = path.join(repoRoot, shippedSurfacePath);
|
|
let existing: string | undefined;
|
|
try {
|
|
existing = await fs.readFile(output, "utf8");
|
|
} catch (error) {
|
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
throw error;
|
|
}
|
|
}
|
|
const committed = git(["ls-tree", "HEAD", "--", shippedSurfacePath])
|
|
? git(["show", `HEAD:${shippedSurfacePath}`])
|
|
: undefined;
|
|
for (const content of [committed, existing]) {
|
|
if (content === undefined) {
|
|
continue;
|
|
}
|
|
const inventory = parsePluginSdkShippedSurface(JSON.parse(content));
|
|
if (compareStableReleases(release, inventory.release) < 0) {
|
|
throw new Error(`Refusing to replace ${inventory.release} with older release ${release}`);
|
|
}
|
|
}
|
|
const commit = git(["rev-parse", "--verify", `refs/tags/${release}^{commit}`]);
|
|
const subpaths = typedPluginSdkSubpaths(JSON.parse(git(["show", `${commit}:package.json`])));
|
|
const rendered = (await renderPluginSdkApiRevisions(repoRoot, [commit])).get(commit);
|
|
if (!rendered) {
|
|
throw new Error(`No rendered Plugin SDK surface for ${release}`);
|
|
}
|
|
const modules = new Map(rendered.modules.map((module) => [module.entrypoint, module]));
|
|
const entrypoints = Object.fromEntries(
|
|
subpaths.map((subpath) => {
|
|
const module = modules.get(subpath);
|
|
if (!module) {
|
|
throw new Error(`Typed package subpath ${subpath} in ${release} has no rendered module`);
|
|
}
|
|
return [
|
|
subpath,
|
|
module.exports
|
|
.map((entry) => entry.exportName)
|
|
.filter((name) => name !== "default")
|
|
.toSorted(),
|
|
];
|
|
}),
|
|
);
|
|
const inventory = parsePluginSdkShippedSurface({
|
|
commit,
|
|
entrypoints,
|
|
release,
|
|
schema: "openclaw.plugin-sdk-shipped-surface/v1",
|
|
});
|
|
const result = await format(
|
|
shippedSurfacePath,
|
|
JSON.stringify({
|
|
commit: inventory.commit,
|
|
entrypoints: inventory.entrypoints,
|
|
release: inventory.release,
|
|
schema: inventory.schema,
|
|
}),
|
|
);
|
|
if (result.errors.length) {
|
|
throw new Error(`Could not format ${shippedSurfacePath}: ${JSON.stringify(result.errors)}`);
|
|
}
|
|
await fs.writeFile(output, result.code);
|
|
console.log(
|
|
`Generated ${shippedSurfacePath} from ${release} (${commit}): ${subpaths.length} typed subpaths, ${Object.values(entrypoints).reduce((count, names) => count + names.length, 0)} named exports.`,
|
|
);
|
|
}
|
|
|
|
await main().catch((error: unknown) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode ||= 1;
|
|
});
|