fix: address post-merge vercel workflow/docs issues (#1386)

* fix: address post-merge vercel review issues

* fix: address remaining publish flow review findings

* docs: note explicit plugin compatibility requirements
This commit is contained in:
George Zhang
2026-03-29 10:38:27 -07:00
committed by GitHub
parent 51beceeb20
commit eeb0ecd932
9 changed files with 260 additions and 29 deletions
+13 -4
View File
@@ -80,19 +80,27 @@ jobs:
- name: Validate publish mode inputs
env:
DRY_RUN: ${{ inputs.dry_run }}
JSON_MODE: ${{ inputs.json }}
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
run: |
if [[ "$DRY_RUN" != "true" && -z "$CLAWHUB_TOKEN" ]]; then
echo "::error::secrets.clawhub_token is required when dry_run is false."
exit 1
fi
if [[ "$JSON_MODE" != "true" ]]; then
echo "::warning::This reusable workflow always emits JSON output; forcing --json for downstream parsing."
fi
- name: Write ClawHub config
if: secrets.clawhub_token != ''
env:
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
CLAWHUB_REGISTRY: ${{ inputs.registry }}
run: |
if [[ -z "$CLAWHUB_TOKEN" ]]; then
echo "No ClawHub token provided, skipping config file creation."
exit 0
fi
python3 - <<'PY'
import json
import os
@@ -119,13 +127,13 @@ jobs:
INPUT_SOURCE: ${{ inputs.source }}
INPUT_REF: ${{ inputs.ref }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
INPUT_JSON: ${{ inputs.json }}
INPUT_OWNER: ${{ inputs.owner }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SITE: ${{ inputs.site }}
INPUT_REGISTRY: ${{ inputs.registry }}
INPUT_CLAWHUB_VERSION: ${{ inputs.clawhub_version }}
GITHUB_SHA: ${{ github.sha }}
run: |
python3 - <<'PY'
import json
@@ -137,6 +145,8 @@ jobs:
if not source:
source = os.environ["GITHUB_REPOSITORY"]
ref = os.environ["INPUT_REF"].strip()
if not ref and source == os.environ["GITHUB_REPOSITORY"]:
ref = os.environ["GITHUB_SHA"].strip()
if ref and "@" not in source and not source.startswith("http"):
source = f"{source}@{ref}"
@@ -154,8 +164,7 @@ jobs:
if os.environ["INPUT_DRY_RUN"] == "true":
cmd.append("--dry-run")
if os.environ["INPUT_JSON"] == "true":
cmd.append("--json")
cmd.append("--json")
owner = os.environ["INPUT_OWNER"].strip()
version = os.environ["INPUT_VERSION"].strip()
+3 -1
View File
@@ -219,6 +219,8 @@ Stores your API token + cached registry URL.
- Metadata is auto-detected from `package.json`, `openclaw.plugin.json`, and `openclaw.bundle.json`.
- For GitHub sources, source attribution is auto-populated from the repo, resolved commit, ref, and subpath.
- For local folders, source attribution is auto-detected from local git when the origin remote points at GitHub.
- External code plugins must declare `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion` explicitly.
Top-level `package.json.version` is not used as a fallback for publish validation.
- `--dry-run` previews the resolved publish payload without uploading.
- `--json` emits machine-readable output for CI.
- `--owner <handle>` lets admins publish under a shared owner account while keeping their own token as the actor.
@@ -228,7 +230,7 @@ Stores your API token + cached registry URL.
#### GitHub Actions
ClawHub also ships an official reusable workflow at
[`/.github/workflows/package-publish.yml`](/Users/tengjizhang/.codex/worktrees/7d03/clawhub/.github/workflows/package-publish.yml)
[`/.github/workflows/package-publish.yml`](../.github/workflows/package-publish.yml)
for plugin repos.
Typical caller setup:
+1 -1
View File
@@ -401,7 +401,7 @@ packageCmd
.option("--source-repo <repo>", "GitHub repo (owner/repo or URL)")
.option("--source-commit <sha>", "Git commit SHA")
.option("--source-ref <ref>", "Git ref/tag/branch")
.option("--source-path <path>", "Repo subpath", ".")
.option("--source-path <path>", "Repo subpath")
.option("--dry-run", "Preview what would be published without uploading")
.option("--json", "Output JSON (for CI pipelines)")
.action(async (source, options) => {
@@ -27,6 +27,37 @@ afterEach(() => {
vi.restoreAllMocks();
});
function mockGitHubCommitLookup(validRefs: string[]) {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn<typeof fetch>(async (input) => {
const url = input instanceof Request ? input.url : input.toString();
const match = url.match(/\/repos\/owner\/repo\/commits\/(.+)$/);
if (!match) {
throw new Error(`Unexpected fetch: ${url}`);
}
const ref = decodeURIComponent(match[1] ?? "");
if (!validRefs.includes(ref)) {
return new Response("not found", { status: 404 });
}
return new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
Object.defineProperty(globalThis, "fetch", {
value: fetchMock,
configurable: true,
writable: true,
});
return () => {
Object.defineProperty(globalThis, "fetch", {
value: originalFetch,
configurable: true,
writable: true,
});
};
}
describe("github publish source helpers", () => {
it.each([
["owner/repo", { kind: "github", owner: "owner", repo: "repo", path: ".", url: "https://github.com/owner/repo" }],
@@ -107,9 +138,36 @@ describe("github publish source helpers", () => {
],
])("parses %s as a GitHub source", async (input, expected) => {
const workdir = await makeTmpDir();
const restoreFetch =
input.includes("/tree/") || input.includes("/blob/")
? mockGitHubCommitLookup([String((expected as { ref?: string }).ref ?? "")])
: null;
try {
await expect(resolveSourceInput(input, { workdir })).resolves.toEqual(expected);
} finally {
restoreFetch?.();
await rm(workdir, { recursive: true, force: true });
}
});
it("parses tree URLs whose refs contain slashes", async () => {
const workdir = await makeTmpDir();
const restoreFetch = mockGitHubCommitLookup(["feature/new-ui"]);
try {
await expect(
resolveSourceInput("https://github.com/owner/repo/tree/feature/new-ui/plugins/demo", {
workdir,
}),
).resolves.toEqual({
kind: "github",
owner: "owner",
repo: "repo",
ref: "feature/new-ui",
path: "plugins/demo",
url: "https://github.com/owner/repo",
});
} finally {
restoreFetch();
await rm(workdir, { recursive: true, force: true });
}
});
@@ -241,4 +299,61 @@ describe("github publish source helpers", () => {
});
}
});
it("rejects GitHub archives with unsafe paths", async () => {
const archiveBytes = zipSync({
"repo-root/../../escape.txt": new TextEncoder().encode("bad\n"),
"repo-root/package.json": new TextEncoder().encode('{"name":"demo","version":"1.0.0"}\n'),
"repo-root/openclaw.plugin.json": new TextEncoder().encode('{"id":"demo","configSchema":{"type":"object"}}\n'),
});
const archiveBody = archiveBytes.buffer.slice(
archiveBytes.byteOffset,
archiveBytes.byteOffset + archiveBytes.byteLength,
) as ArrayBuffer;
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ default_branch: "main" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ sha: "0123456789abcdef0123456789abcdef01234567" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(archiveBody, {
status: 200,
headers: { "content-type": "application/zip" },
}),
);
const originalFetch = globalThis.fetch;
Object.defineProperty(globalThis, "fetch", {
value: fetchMock,
configurable: true,
writable: true,
});
try {
await expect(
fetchGitHubSource({
kind: "github",
owner: "owner",
repo: "repo",
path: ".",
url: "https://github.com/owner/repo",
}),
).rejects.toThrow(/Unsafe path in archive/i);
} finally {
Object.defineProperty(globalThis, "fetch", {
value: originalFetch,
configurable: true,
writable: true,
});
}
});
});
+51 -21
View File
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { unzipSync } from "fflate";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { dirname, join, resolve, sep } from "node:path";
const GITHUB_API = "https://api.github.com";
const GITHUB_HOSTS = new Set(["github.com", "www.github.com"]);
@@ -52,7 +52,7 @@ export async function resolveSourceInput(
if (!trimmed) throw new Error("Path required");
if (trimmed.startsWith("https://")) {
return parseGitHubUrl(trimmed);
return await parseGitHubUrl(trimmed);
}
const shorthand = parseGitHubShorthand(trimmed);
@@ -178,7 +178,7 @@ function parseGitHubShorthand(input: string): Extract<ResolvedPublishSource, { k
};
}
function parseGitHubUrl(input: string): Extract<ResolvedPublishSource, { kind: "github" }> {
async function parseGitHubUrl(input: string): Promise<Extract<ResolvedPublishSource, { kind: "github" }>> {
let url: URL;
try {
url = new URL(input);
@@ -204,29 +204,14 @@ function parseGitHubUrl(input: string): Extract<ResolvedPublishSource, { kind: "
};
}
const ref = segments[3] ?? "";
if (!ref) throw new Error("Missing ref in GitHub URL");
const rest = segments.slice(4).join("/");
const normalizedPath = normalizeRepoSubpath(rest || ".");
if (kind === "blob") {
if (!rest) throw new Error("Missing path in GitHub URL");
const parent = normalizeRepoSubpath(rest.split("/").slice(0, -1).join("/") || ".");
return {
kind: "github",
owner,
repo,
ref,
path: parent,
url: `https://github.com/${owner}/${repo}`,
};
}
const { ref, path } = await resolveGitHubUrlRefAndPath(owner, repo, kind, segments.slice(3));
return {
kind: "github",
owner,
repo,
ref,
path: normalizedPath,
path,
url: `https://github.com/${owner}/${repo}`,
};
}
@@ -295,6 +280,19 @@ async function resolveCommitSha(owner: string, repo: string, ref: string, token?
return sha;
}
async function tryResolveCommitSha(owner: string, repo: string, ref: string, token?: string) {
const response = await fetch(
`${GITHUB_API}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
{
headers: buildGitHubHeaders(token),
},
);
if (!response.ok) return null;
const parsed = (await response.json()) as { sha?: unknown };
const sha = typeof parsed.sha === "string" ? parsed.sha.trim().toLowerCase() : "";
return /^[a-f0-9]{40}$/.test(sha) ? sha : null;
}
async function downloadGitHubZip(owner: string, repo: string, ref: string, token?: string) {
const response = await fetch(
`${GITHUB_API}/repos/${owner}/${repo}/zipball/${encodeURIComponent(ref)}`,
@@ -346,14 +344,46 @@ function filterEntriesForSubpath(entries: Record<string, Uint8Array>, subpath: s
}
async function writeEntries(root: string, entries: Record<string, Uint8Array>) {
const absRoot = resolve(root);
for (const [path, bytes] of Object.entries(entries)) {
if (!path || path.endsWith("/")) continue;
const absPath = join(root, ...path.split("/"));
const absPath = resolve(absRoot, ...path.split("/"));
if (absPath !== absRoot && !absPath.startsWith(`${absRoot}${sep}`)) {
throw new Error(`Unsafe path in archive: ${path}`);
}
await mkdir(dirname(absPath), { recursive: true });
await writeFile(absPath, Buffer.from(bytes));
}
}
async function resolveGitHubUrlRefAndPath(
owner: string,
repo: string,
kind: "tree" | "blob",
segments: string[],
) {
if (segments.length === 0) throw new Error("Missing ref in GitHub URL");
const token = process.env.GITHUB_TOKEN?.trim() || undefined;
const minPathSegments = kind === "blob" ? 1 : 0;
const maxRefSegments = segments.length - minPathSegments;
for (let refSegmentCount = maxRefSegments; refSegmentCount >= 1; refSegmentCount -= 1) {
const ref = segments.slice(0, refSegmentCount).join("/");
const pathRemainder = segments.slice(refSegmentCount).join("/");
if (kind === "blob" && !pathRemainder) continue;
const commit = await tryResolveCommitSha(owner, repo, ref, token);
if (!commit) continue;
const path =
kind === "blob"
? normalizeRepoSubpath(pathRemainder.split("/").slice(0, -1).join("/") || ".")
: normalizeRepoSubpath(pathRemainder || ".");
return { ref, path };
}
throw new Error("GitHub ref not found in URL");
}
function runGit(cwd: string, args: string[]) {
const result = spawnSync("git", ["-C", cwd, ...args], {
encoding: "utf8",
@@ -599,6 +599,63 @@ describe("package commands", () => {
}
});
it("preserves inferred source subpaths for nested local plugin folders", async () => {
const workdir = await makeTmpWorkdir();
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(333_333_333);
try {
const folder = join(workdir, "packages", "demo-plugin");
await mkdir(folder, { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.0.0",
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "demo.plugin", configSchema: { type: "object" } }),
"utf8",
);
runGit(workdir, ["init", "-b", "main"]);
runGit(workdir, ["remote", "add", "origin", "git@github.com:openclaw/demo-plugin.git"]);
runGit(workdir, ["add", "."]);
runGit(workdir, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"]);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
});
await cmdPublishPackage(makeOpts(workdir), "packages/demo-plugin", {});
expect(getPublishPayload()).toEqual({
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
version: "1.0.0",
changelog: "",
tags: ["latest"],
source: {
kind: "github",
url: "https://github.com/openclaw/demo-plugin",
repo: "openclaw/demo-plugin",
ref: "main",
commit: expect.any(String),
path: "packages/demo-plugin",
importedAt: 333_333_333,
},
});
dateSpy.mockRestore();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("supports dry-run without auth or publish and prints a summary", async () => {
const workdir = await makeTmpWorkdir();
const dateSpy = vi.spyOn(Date, "now").mockReturnValue(444_444_444);
@@ -792,7 +792,8 @@ function buildSource(
const rawRepo = options.sourceRepo?.trim() || inferred?.repo?.trim();
const rawCommit = options.sourceCommit?.trim() || inferred?.commit?.trim();
const rawRef = options.sourceRef?.trim() || inferred?.ref?.trim();
const rawPath = options.sourcePath?.trim() || inferred?.path?.trim();
const explicitPath = options.sourcePath?.trim();
const rawPath = explicitPath !== undefined ? explicitPath : inferred?.path?.trim();
if (!rawRepo && !rawCommit && !rawRef && !rawPath) return undefined;
if (!rawRepo || !rawCommit) fail("--source-repo and --source-commit must be set together");
const repo = normalizeGitHubRepo(rawRepo);
@@ -281,6 +281,23 @@ describe("plugins publish route", () => {
expect(publishRelease).not.toHaveBeenCalled();
});
it("does not mark the upload summary ready while validation errors are present", async () => {
renderPublishRoute();
const bigFile = new File([new Uint8Array(10 * 1024 * 1024 + 1)], "too-big.bin", {
type: "application/octet-stream",
});
fireEvent.change(getFileInput(), { target: { files: [bigFile] } });
await waitFor(() => {
expect(screen.getByText(/Each file must be 10MB or smaller/i)).toBeTruthy();
});
const summary = document.querySelector(".plugin-upload-summary");
expect(summary?.classList.contains("is-ready")).toBe(false);
});
it("publishes a bundle plugin folder with bundle metadata", async () => {
renderPublishRoute();
+1 -1
View File
@@ -21,7 +21,7 @@ export function PackageSourceChooser(props: {
const [isDragging, setIsDragging] = useState(false);
const archiveInputRef = useRef<HTMLInputElement | null>(null);
const directoryInputRef = useRef<HTMLInputElement | null>(null);
const isMetadataLocked = props.files.length === 0;
const isMetadataLocked = props.files.length === 0 || Boolean(props.validationError);
const setDirectoryInputRef = (node: HTMLInputElement | null) => {
directoryInputRef.current = node;