ci: stage generated plugin manifests for npm publish

This commit is contained in:
Peter Steinberger
2026-05-02 01:25:49 +01:00
parent 74e18266d3
commit d8c3e9ed6d
4 changed files with 345 additions and 21 deletions

View File

@@ -8,6 +8,7 @@ on:
- ".github/workflows/plugin-npm-release.yml"
- "extensions/**"
- "package.json"
- "scripts/lib/plugin-npm-package-manifest.mjs"
- "scripts/lib/plugin-npm-release.ts"
- "scripts/plugin-npm-publish.sh"
- "scripts/plugin-npm-release-check.ts"
@@ -168,8 +169,7 @@ jobs:
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
- name: Preview npm pack contents
working-directory: ${{ matrix.plugin.packageDir }}
run: npm pack --dry-run --json --ignore-scripts
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
publish_plugins_npm:
needs: [preview_plugins_npm, preview_plugin_pack]

View File

@@ -0,0 +1,208 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH =
"src/config/bundled-channel-config-metadata.generated.ts";
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function writeJsonFile(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function resolvePackageDir(repoRoot, packageDir) {
return path.isAbsolute(packageDir) ? packageDir : path.resolve(repoRoot, packageDir);
}
function readGeneratedBundledChannelConfigs(repoRoot) {
const metadataPath = path.join(repoRoot, GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA_PATH);
if (!fs.existsSync(metadataPath)) {
return new Map();
}
const source = fs.readFileSync(metadataPath, "utf8");
const match = source.match(
/export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = ([\s\S]*?) as const;/u,
);
if (!match?.[1]) {
return new Map();
}
let entries;
try {
entries = Function(`"use strict"; return (${match[1]});`)();
} catch {
return new Map();
}
if (!Array.isArray(entries)) {
return new Map();
}
const byPlugin = new Map();
for (const entry of entries) {
if (
!entry ||
typeof entry !== "object" ||
typeof entry.pluginId !== "string" ||
typeof entry.channelId !== "string" ||
!entry.schema ||
typeof entry.schema !== "object"
) {
continue;
}
const pluginConfigs = byPlugin.get(entry.pluginId) ?? {};
pluginConfigs[entry.channelId] = {
schema: entry.schema,
...(typeof entry.label === "string" && entry.label ? { label: entry.label } : {}),
...(typeof entry.description === "string" && entry.description
? { description: entry.description }
: {}),
...(entry.uiHints && typeof entry.uiHints === "object" ? { uiHints: entry.uiHints } : {}),
};
byPlugin.set(entry.pluginId, pluginConfigs);
}
return byPlugin;
}
function mergeGeneratedChannelConfigs(manifest, generatedChannelConfigs) {
if (!generatedChannelConfigs || Object.keys(generatedChannelConfigs).length === 0) {
return manifest;
}
const existingChannelConfigs =
manifest.channelConfigs && typeof manifest.channelConfigs === "object"
? manifest.channelConfigs
: {};
const channelConfigs = { ...existingChannelConfigs };
for (const [channelId, generated] of Object.entries(generatedChannelConfigs)) {
const existing =
existingChannelConfigs[channelId] && typeof existingChannelConfigs[channelId] === "object"
? existingChannelConfigs[channelId]
: {};
channelConfigs[channelId] = {
...generated,
...existing,
schema: generated.schema,
...(generated.uiHints || existing.uiHints
? { uiHints: { ...generated.uiHints, ...existing.uiHints } }
: {}),
...(existing.label || generated.label ? { label: existing.label ?? generated.label } : {}),
...(existing.description || generated.description
? { description: existing.description ?? generated.description }
: {}),
};
}
return {
...manifest,
channelConfigs,
};
}
export function resolveAugmentedPluginNpmManifest(params) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const manifestPath = path.join(packageDir, "openclaw.plugin.json");
if (!fs.existsSync(manifestPath)) {
return {
manifestPath,
pluginId: path.basename(packageDir),
changed: false,
manifest: undefined,
reason: "missing-manifest",
};
}
const manifest = readJsonFile(manifestPath);
const pluginId =
typeof manifest.id === "string" && manifest.id ? manifest.id : path.basename(packageDir);
const generatedChannelConfigs = readGeneratedBundledChannelConfigs(repoRoot).get(pluginId);
const augmentedManifest = mergeGeneratedChannelConfigs(manifest, generatedChannelConfigs);
const changed = JSON.stringify(augmentedManifest) !== JSON.stringify(manifest);
return {
manifestPath,
pluginId,
changed,
manifest: augmentedManifest,
reason: changed ? "generated-channel-configs" : "unchanged",
};
}
export function withAugmentedPluginNpmManifestForPackage(params, callback) {
const repoRoot = path.resolve(params.repoRoot ?? ".");
const packageDir = resolvePackageDir(repoRoot, params.packageDir);
const resolved = resolveAugmentedPluginNpmManifest({
repoRoot,
packageDir,
});
if (!resolved.changed || !resolved.manifest) {
return callback({
...resolved,
packageDir,
repoRoot,
applied: false,
});
}
const originalManifest = fs.readFileSync(resolved.manifestPath, "utf8");
console.error(
`[plugin-npm-publish] overlaying generated channel config metadata for ${resolved.pluginId}`,
);
writeJsonFile(resolved.manifestPath, resolved.manifest);
try {
return callback({
...resolved,
packageDir,
repoRoot,
applied: true,
});
} finally {
fs.writeFileSync(resolved.manifestPath, originalManifest, "utf8");
}
}
function parseRunArgs(argv) {
if (argv[0] !== "--run") {
throw new Error(
"usage: node scripts/lib/plugin-npm-package-manifest.mjs --run <package-dir> -- <command> [args...]",
);
}
const packageDir = argv[1];
const separatorIndex = argv.indexOf("--", 2);
if (!packageDir || separatorIndex === -1 || separatorIndex === argv.length - 1) {
throw new Error(
"usage: node scripts/lib/plugin-npm-package-manifest.mjs --run <package-dir> -- <command> [args...]",
);
}
return {
packageDir,
command: argv[separatorIndex + 1],
args: argv.slice(separatorIndex + 2),
};
}
export function main(argv = process.argv.slice(2)) {
const { packageDir, command, args } = parseRunArgs(argv);
return withAugmentedPluginNpmManifestForPackage({ packageDir }, ({ packageDir: cwd }) => {
const result = spawnSync(command, args, {
cwd,
env: process.env,
stdio: "inherit",
});
if (result.error) {
throw result.error;
}
return result.status ?? 1;
});
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
process.exitCode = main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View File

@@ -5,8 +5,8 @@ set -euo pipefail
mode="${1:-}"
package_dir="${2:-}"
if [[ "${mode}" != "--dry-run" && "${mode}" != "--publish" ]]; then
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--publish] <package-dir>" >&2
if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>" >&2
exit 2
fi
@@ -18,6 +18,13 @@ fi
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")"
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")"
current_beta_version="$(npm view "${package_name}" dist-tags.beta 2>/dev/null || true)"
log() {
if [[ "${mode}" == "--pack-dry-run" ]]; then
printf '%s\n' "$*" >&2
else
printf '%s\n' "$*"
fi
}
publish_plan_output="$(
PACKAGE_VERSION="${package_version}" CURRENT_BETA_VERSION="${current_beta_version}" PUBLISH_MODE="${mode}" node --input-type=module <<'EOF'
import {
@@ -55,15 +62,15 @@ mirror_auth_source="${mirror_auth_source:-none}"
mirror_auth_requirement="${mirror_auth_requirement:-optional}"
publish_cmd=(npm publish --access public --tag "${publish_tag}" --provenance)
echo "Resolved package dir: ${package_dir}"
echo "Resolved package name: ${package_name}"
echo "Resolved package version: ${package_version}"
echo "Current beta dist-tag: ${current_beta_version:-<missing>}"
echo "Resolved release channel: ${release_channel}"
echo "Resolved publish tag: ${publish_tag}"
echo "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-<none>}"
echo "Mirror dist-tag auth source: ${mirror_auth_source}"
echo "Mirror dist-tag auth requirement: ${mirror_auth_requirement}"
log "Resolved package dir: ${package_dir}"
log "Resolved package name: ${package_name}"
log "Resolved package version: ${package_version}"
log "Current beta dist-tag: ${current_beta_version:-<missing>}"
log "Resolved release channel: ${release_channel}"
log "Resolved publish tag: ${publish_tag}"
log "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-<none>}"
log "Mirror dist-tag auth source: ${mirror_auth_source}"
log "Mirror dist-tag auth requirement: ${mirror_auth_requirement}"
mirror_auth_token=""
case "${mirror_auth_source}" in
@@ -77,9 +84,9 @@ esac
publish_auth_token="${mirror_auth_token}"
publish_auth_source="${mirror_auth_source}"
if [[ -n "${publish_auth_token}" ]]; then
echo "Publish auth: ${publish_auth_source} with provenance"
log "Publish auth: ${publish_auth_source} with provenance"
else
echo "Publish auth: GitHub OIDC trusted publishing"
log "Publish auth: GitHub OIDC trusted publishing"
fi
if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]]; then
@@ -88,27 +95,43 @@ if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]]
exit 1
fi
printf 'Publish command:'
printf ' %q' "${publish_cmd[@]}"
printf '\n'
if [[ "${mode}" == "--pack-dry-run" ]]; then
{
printf 'Publish command:'
printf ' %q' "${publish_cmd[@]}"
printf '\n'
} >&2
else
printf 'Publish command:'
printf ' %q' "${publish_cmd[@]}"
printf '\n'
fi
if [[ "${mode}" == "--dry-run" ]]; then
exit 0
fi
if [[ "${mode}" == "--pack-dry-run" ]]; then
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \
npm pack --dry-run --json --ignore-scripts
exit 0
fi
(
cd "${package_dir}"
cleanup_files=()
trap 'rm -f "${cleanup_files[@]}"' EXIT
run_with_manifest_overlay() {
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- "$@"
}
publish_userconfig=""
if [[ -n "${publish_auth_token}" ]]; then
publish_userconfig="$(mktemp)"
cleanup_files+=("${publish_userconfig}")
chmod 0600 "${publish_userconfig}"
printf '%s\n' "//registry.npmjs.org/:_authToken=${publish_auth_token}" > "${publish_userconfig}"
NPM_CONFIG_USERCONFIG="${publish_userconfig}" "${publish_cmd[@]}"
NPM_CONFIG_USERCONFIG="${publish_userconfig}" run_with_manifest_overlay "${publish_cmd[@]}"
else
"${publish_cmd[@]}"
run_with_manifest_overlay "${publish_cmd[@]}"
fi
if [[ -n "${mirror_dist_tags_csv}" ]]; then

View File

@@ -0,0 +1,93 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
resolveAugmentedPluginNpmManifest,
withAugmentedPluginNpmManifestForPackage,
} from "../scripts/lib/plugin-npm-package-manifest.mjs";
import { cleanupTempDirs, makeTempRepoRoot, writeJsonFile } from "./helpers/temp-repo.js";
const tempDirs: string[] = [];
afterEach(() => {
cleanupTempDirs(tempDirs);
});
function writeGeneratedChannelMetadata(repoDir: string): void {
const metadataPath = join(
repoDir,
"src",
"config",
"bundled-channel-config-metadata.generated.ts",
);
mkdirSync(join(repoDir, "src", "config"), { recursive: true });
writeFileText(
metadataPath,
`export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
{
pluginId: "twitch",
channelId: "twitch",
label: "Twitch",
description: "Twitch chat integration",
schema: {
type: "object",
required: ["channelName"],
properties: {
channelName: { type: "string" },
},
},
},
] as const;
`,
);
}
function writeFileText(filePath: string, text: string): void {
mkdirSync(dirname(filePath), { recursive: true });
// writeJsonFile intentionally owns JSON formatting only.
writeFileSync(filePath, text, "utf8");
}
describe("plugin npm package manifest staging", () => {
it("overlays generated channel configs while packing and restores source manifest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-package-manifest-");
const packageDir = join(repoDir, "extensions", "twitch");
mkdirSync(packageDir, { recursive: true });
const sourceManifest = {
id: "twitch",
channels: ["twitch"],
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
};
writeJsonFile(join(packageDir, "openclaw.plugin.json"), sourceManifest);
writeGeneratedChannelMetadata(repoDir);
const resolved = resolveAugmentedPluginNpmManifest({
repoRoot: repoDir,
packageDir,
});
expect(resolved.changed).toBe(true);
expect(resolved.manifest).toMatchObject({
channelConfigs: {
twitch: {
label: "Twitch",
schema: {
required: ["channelName"],
},
},
},
});
const originalText = readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8");
withAugmentedPluginNpmManifestForPackage({ repoRoot: repoDir, packageDir }, () => {
const stagedManifest = JSON.parse(
readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8"),
);
expect(stagedManifest.channelConfigs.twitch.description).toBe("Twitch chat integration");
});
expect(readFileSync(join(packageDir, "openclaw.plugin.json"), "utf8")).toBe(originalText);
});
});