fix(cli): clarify pending publication results (#3193)

This commit is contained in:
Patrick Erichsen
2026-07-20 12:58:04 -07:00
committed by GitHub
parent b0984d33c0
commit 3ff331925b
10 changed files with 337 additions and 31 deletions
+4
View File
@@ -147,6 +147,10 @@ description: Automation workflow for recurring reports.
skillId: "skills:demo",
versionId: "skillVersions:demo",
embeddingId: "skillEmbeddings:demo",
status: "published",
slug: "automation-helper",
version: "1.0.0",
publicationStatus: "published",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
+14 -2
View File
@@ -510,7 +510,13 @@ async function publishVersionForUserInternal(
skillInsertArgs,
)) as PublishResult;
await scheduleSkillPublishFollowups(ctx, publishResult, followup);
return publishResult;
return {
...publishResult,
status: "published",
slug,
version,
publicationStatus: "published",
};
}
const pendingInsertArgs = {
@@ -565,7 +571,13 @@ async function publishVersionForUserInternal(
};
if (staged.status === "finalized" && staged.result) {
return staged.result;
return {
...staged.result,
status: "published",
slug,
version,
publicationStatus: "published",
};
}
return {
+6 -1
View File
@@ -9009,7 +9009,12 @@ describe("packages public queries", () => {
},
},
}),
).resolves.toEqual({ ok: true, packageId: "packages:demo", releaseId: "releases:demo-1" });
).resolves.toEqual({
ok: true,
packageId: "packages:demo",
releaseId: "releases:demo-1",
publicationStatus: "published",
});
expect(runMutation).toHaveBeenCalledWith(
expect.anything(),
+12 -2
View File
@@ -7899,7 +7899,13 @@ async function publishPackageImpl(
}
if (staged.status === "finalized" && staged.result) {
return inspectorFindings.length > 0 ? { ...staged.result, inspectorFindings } : staged.result;
const finalizedResult = {
...staged.result,
publicationStatus: "published" as const,
};
return inspectorFindings.length > 0
? { ...finalizedResult, inspectorFindings }
: finalizedResult;
}
return {
@@ -8003,7 +8009,11 @@ async function publishPackageImpl(
source: "publish",
});
return inspectorFindings.length > 0 ? { ...publishResult, inspectorFindings } : publishResult;
const publishedResult = {
...publishResult,
publicationStatus: "published" as const,
};
return inspectorFindings.length > 0 ? { ...publishedResult, inspectorFindings } : publishedResult;
}
function toPackageInspectorPublishResponseFinding(
@@ -1225,6 +1225,7 @@ describe("package commands", () => {
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
publicationStatus: "published",
});
const options = {
@@ -1320,7 +1321,7 @@ describe("package commands", () => {
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Uploaded @scope/pending-plugin@1.0.0; security checks are pending before it becomes public (rel_1)",
"Update submitted for @scope/pending-plugin@1.0.0; pending security scans before it becomes public.",
);
expect(mockWrite).not.toHaveBeenCalled();
} finally {
@@ -1371,11 +1372,54 @@ describe("package commands", () => {
expect(output).toEqual(
expect.objectContaining({
name: "@scope/json-pending-plugin",
status: "pending-publication",
releaseId: "rel_1",
publicationStatus: "pending",
attemptId: "attempt_1",
}),
);
expect(output.status).not.toBe("published");
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("does not claim a package was published when the server omits publication status", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "unknown-status-plugin");
await mkdir(join(folder, "dist"), { recursive: true });
await writeFile(
join(folder, "package.json"),
makeCodePluginPackageJson({
name: "@scope/unknown-status-plugin",
displayName: "Unknown Status Plugin",
version: "1.0.0",
files: ["dist", "openclaw.plugin.json"],
}),
"utf8",
);
await writeFile(
join(folder, "openclaw.plugin.json"),
JSON.stringify({ id: "unknown.status.plugin" }),
"utf8",
);
await writeFile(join(folder, "dist", "index.js"), "export const demo = true;\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
});
await cmdPublishPackage(makeOpts(workdir), "unknown-status-plugin", {
owner: "@openclaw",
sourceRepo: "openclaw/unknown-status-plugin",
sourceCommit: "abc123",
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"Update submitted for @scope/unknown-status-plugin@1.0.0; publication status was not reported.",
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
@@ -1601,6 +1645,7 @@ describe("package commands", () => {
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
publicationStatus: "published",
});
await cmdPublishPackage(makeOpts(workdir), packName, {
@@ -2645,6 +2690,7 @@ describe("package commands", () => {
ok: true,
packageId: "pkg_1",
releaseId: "rel_1",
publicationStatus: "published",
inspectorFindings: [
{
findingKind: "warning",
+22 -7
View File
@@ -975,14 +975,21 @@ export async function cmdPublishPackage(
ApiV1PackagePublishResponseSchema,
);
const isPendingPublication = result.publicationStatus === "pending";
const publicationStatus = result.publicationStatus;
const outputStatus =
publicationStatus === "pending"
? "pending-publication"
: publicationStatus === "published"
? "published"
: "submitted";
if (options.json) {
process.stdout.write(
`${JSON.stringify(
{
...plan.output,
status: outputStatus,
releaseId: result.releaseId,
publicationStatus: result.publicationStatus,
publicationStatus,
attemptId: result.attemptId,
inspectorFindings: result.inspectorFindings,
},
@@ -991,11 +998,19 @@ export async function cmdPublishPackage(
)}\n`,
);
} else {
spinner?.succeed(
isPendingPublication
? `OK. Uploaded ${plan.payload.name}@${plan.payload.version}; security checks are pending before it becomes public (${result.releaseId})`
: `OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
);
if (publicationStatus === "pending") {
spinner?.succeed(
`Update submitted for ${plan.payload.name}@${plan.payload.version}; pending security scans before it becomes public.`,
);
} else if (publicationStatus === "published") {
spinner?.succeed(
`OK. Published ${plan.payload.name}@${plan.payload.version} (${result.releaseId})`,
);
} else {
spinner?.succeed(
`Update submitted for ${plan.payload.name}@${plan.payload.version}; publication status was not reported.`,
);
}
printPackageInspectorFindings(result);
}
} catch (error) {
@@ -82,6 +82,7 @@ describe("cmdPublish", () => {
ok: true,
skillId: "skill_1",
versionId: "ver_2",
publicationStatus: "published",
});
const result = await cmdPublish(makeOpts(workdir), "metadata-update", {
@@ -117,6 +118,7 @@ describe("cmdPublish", () => {
ok: true,
skillId: "skill_1",
versionId: "ver_1",
publicationStatus: "published",
});
const result = await cmdPublish(makeOpts(workdir), "new-skill", {});
@@ -160,7 +162,67 @@ describe("cmdPublish", () => {
attemptId: "attempt_1",
});
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"OK. Uploaded pending-skill@1.0.0; security checks are pending before it becomes public (ver_pending)",
"Update submitted for pending-skill@1.0.0; pending security scans before it becomes public.",
);
} finally {
await rm(workdir, { recursive: true, force: true });
}
});
it("makes pending publication unambiguous in json output", async () => {
const workdir = await makeTmpWorkdir();
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
try {
const folder = join(workdir, "json-pending-skill");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
httpMocks.apiRequest.mockRejectedValueOnce(
new Error("Skill not found or unavailable to this account."),
);
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
skillId: "skill_1",
versionId: "ver_pending",
publicationStatus: "pending",
attemptId: "attempt_1",
});
await cmdPublish(makeOpts(workdir), "json-pending-skill", { json: true });
expect(uiMocks.spinner.succeed).not.toHaveBeenCalled();
expect(writeSpy).toHaveBeenCalledTimes(1);
const output = JSON.parse(String(writeSpy.mock.calls[0]?.[0] ?? ""));
expect(output).toMatchObject({
status: "pending-publication",
slug: "json-pending-skill",
version: "1.0.0",
publicationStatus: "pending",
attemptId: "attempt_1",
});
expect(output.status).not.toBe("published");
} finally {
writeSpy.mockRestore();
await rm(workdir, { recursive: true, force: true });
}
});
it("does not claim a skill was published when the server omits publication status", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "unknown-status-skill");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
httpMocks.apiRequestForm.mockResolvedValueOnce({
ok: true,
skillId: "skill_1",
versionId: "ver_1",
});
const result = await cmdPublish(makeOpts(workdir), "unknown-status-skill", {});
expect(result.status).toBe("submitted");
expect(uiMocks.spinner.succeed).toHaveBeenCalledWith(
"Update submitted for unknown-status-skill@1.0.0; publication status was not reported.",
);
} finally {
await rm(workdir, { recursive: true, force: true });
@@ -181,6 +243,7 @@ describe("cmdPublish", () => {
ok: true,
skillId: "skill_1",
versionId: "ver_2",
publicationStatus: "published",
});
const result = await cmdPublish(makeOpts(workdir), "changed-skill", {});
@@ -210,6 +273,7 @@ describe("cmdPublish", () => {
ok: true,
skillId: "skill_1",
versionId: "ver_2",
publicationStatus: "published",
});
const result = await cmdPublish(makeOpts(workdir), "explicit-version", {
+19 -8
View File
@@ -18,7 +18,7 @@ import { normalizeGitHubRepo } from "./github.js";
type SkillPublishResult = {
ok: true;
status: "unchanged" | "would-publish" | "published" | "pending-publication";
status: "unchanged" | "would-publish" | "submitted" | "published" | "pending-publication";
slug: string;
displayName: string;
folder: string;
@@ -200,9 +200,14 @@ export async function cmdPublish(
ApiV1PublishResponseSchema,
);
const isPendingPublication = result.publicationStatus === "pending";
const publicationStatus = result.publicationStatus;
const publishResult = buildPublishResult({
status: isPendingPublication ? "pending-publication" : "published",
status:
publicationStatus === "pending"
? "pending-publication"
: publicationStatus === "published"
? "published"
: "submitted",
slug,
displayName,
folder,
@@ -214,11 +219,17 @@ export async function cmdPublish(
publicationStatus: result.publicationStatus,
attemptId: result.attemptId,
});
spinner?.succeed(
isPendingPublication
? `OK. Uploaded ${slug}@${version}; security checks are pending before it becomes public (${result.versionId})`
: `OK. Published ${slug}@${version} (${result.versionId})`,
);
if (publicationStatus === "pending") {
spinner?.succeed(
`Update submitted for ${slug}@${version}; pending security scans before it becomes public.`,
);
} else if (publicationStatus === "published") {
spinner?.succeed(`OK. Published ${slug}@${version} (${result.versionId})`);
} else {
spinner?.succeed(
`Update submitted for ${slug}@${version}; publication status was not reported.`,
);
}
writePublishJsonIfRequested(options.json, publishResult);
return publishResult;
} catch (error) {
@@ -345,7 +345,9 @@ describe("cmdSync", () => {
process.stdout.write("child publish output\n");
}
return {
status: "published",
version: (options as { version?: string } | undefined)?.version ?? "1.0.0",
publicationStatus: "published",
};
});
@@ -368,6 +370,97 @@ describe("cmdSync", () => {
);
});
it("keeps pending sync submissions out of the published json summary", async () => {
interactive = false;
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
mockApiRequest.mockImplementation(async (_registry: string, args: { path?: string }) => {
if (args.path?.startsWith("/api/v1/resolve?")) {
const u = new URL(`https://x.test${args.path}`);
const slug = u.searchParams.get("slug");
if (slug === "new-skill") throw new Error("Skill not found");
if (slug === "synced-skill") {
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
}
if (slug === "update-skill") {
return { match: null, latestVersion: { version: "1.0.0" } };
}
}
throw new Error(`Unexpected apiRequest: ${String(args.path)}`);
});
mockCmdPublish.mockImplementation(
(_opts: unknown, _folder: unknown, options?: { slug?: string; version?: string }) =>
options?.slug === "new-skill"
? {
status: "pending-publication",
version: options.version,
publicationStatus: "pending",
}
: {
status: "published",
version: options?.version,
publicationStatus: "published",
},
);
let output = "";
try {
await cmdSync(makeOpts(), { root: ["/scan"], all: true, json: true }, false);
output = String(stdoutWrite.mock.calls[0]?.[0] ?? "");
} finally {
stdoutWrite.mockRestore();
}
const parsed = JSON.parse(output);
expect(parsed.summary).toMatchObject({ published: 1, submitted: 1, failed: 0 });
expect(parsed.published).toEqual([
expect.objectContaining({ slug: "update-skill", version: "1.0.1" }),
]);
expect(parsed.submitted).toEqual([
expect.objectContaining({
slug: "new-skill",
version: "1.0.0",
status: "pending-publication",
publicationStatus: "pending",
}),
]);
});
it("does not call pending sync submissions published in human summaries", async () => {
interactive = false;
mockApiRequest.mockImplementation(async (_registry: string, args: { path?: string }) => {
if (args.path?.startsWith("/api/v1/resolve?")) {
const u = new URL(`https://x.test${args.path}`);
const slug = u.searchParams.get("slug");
if (slug === "new-skill") throw new Error("Skill not found");
if (slug === "synced-skill") {
return { match: { version: "1.2.3" }, latestVersion: { version: "1.2.3" } };
}
if (slug === "update-skill") {
return { match: null, latestVersion: { version: "1.0.0" } };
}
}
throw new Error(`Unexpected apiRequest: ${String(args.path)}`);
});
mockCmdPublish.mockImplementation(
(_opts: unknown, _folder: unknown, options?: { slug?: string; version?: string }) =>
options?.slug === "new-skill"
? {
status: "pending-publication",
version: options.version,
publicationStatus: "pending",
}
: {
status: "published",
version: options?.version,
publicationStatus: "published",
},
);
await cmdSync(makeOpts(), { root: ["/scan"], all: true }, false);
expect(mockOutro).toHaveBeenCalledWith("Published 1 skill(s). Submitted 1 update(s).");
});
it("does not report a raced unchanged publish as uploaded", async () => {
interactive = false;
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+55 -9
View File
@@ -197,6 +197,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
alreadySynced: synced.map(formatSyncedJson),
wouldPublish,
published: [],
submitted: [],
failed: [],
}),
);
@@ -209,6 +210,13 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
const tags = options.tags ?? "latest";
const failedUploads: Array<{ slug: string; message: string }> = [];
const published: Array<{ slug: string; folder: string; version: string }> = [];
const submitted: Array<{
slug: string;
folder: string;
version: string;
status: "pending-publication" | "submitted";
publicationStatus?: "pending";
}> = [];
const racedNoOps: Array<{ slug: string; folder: string; version: string }> = [];
for (const { skill, source } of plannedPublishes) {
@@ -254,11 +262,20 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
});
continue;
}
published.push({
const output = {
slug: skill.slug,
folder: skill.folder,
version: result?.version ?? publishVersion,
});
};
if (result?.status === "published") {
published.push(output);
} else {
submitted.push({
...output,
status: result?.status === "pending-publication" ? "pending-publication" : "submitted",
...(result?.publicationStatus === "pending" ? { publicationStatus: "pending" } : {}),
});
}
} catch (error) {
failedUploads.push({ slug: skill.slug, message: formatError(error) });
}
@@ -277,6 +294,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
alreadySynced: [...synced.map(formatSyncedJson), ...racedNoOps],
wouldPublish: [],
published,
submitted,
failed: failedUploads,
}),
);
@@ -290,9 +308,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
20,
),
);
outro(
`Published ${published.length} of ${selected.length} skill(s). ${failedUploads.length} failed.`,
);
outro(formatSyncPublishSummary({ published, submitted, selected, failedUploads }));
process.exitCode = 1;
return;
}
@@ -309,6 +325,7 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
alreadySynced: [...synced.map(formatSyncedJson), ...racedNoOps],
wouldPublish: [],
published,
submitted,
failed: [],
}),
);
@@ -316,11 +333,9 @@ export async function cmdSync(opts: GlobalOpts, options: SyncOptions, inputAllow
}
if (racedNoOps.length > 0) {
outro(
`Published ${published.length} of ${selected.length} skill(s). ${racedNoOps.length} already synced.`,
);
outro(formatSyncPublishSummary({ published, submitted, selected, racedNoOps }));
} else {
outro(`Published ${published.length} skill(s).`);
outro(formatSyncPublishSummary({ published, submitted }));
}
}
@@ -346,6 +361,7 @@ function writeNoActionOutput(params: {
alreadySynced: params.synced.map(formatSyncedJson),
wouldPublish: [],
published: [],
submitted: [],
failed: [],
}),
);
@@ -479,6 +495,13 @@ function buildSyncJsonOutput(params: {
alreadySynced: Array<{ slug: string; folder: string; version: string }>;
wouldPublish: Array<ReturnType<typeof formatPublishJson>>;
published: Array<{ slug: string; folder: string; version: string }>;
submitted: Array<{
slug: string;
folder: string;
version: string;
status: "pending-publication" | "submitted";
publicationStatus?: "pending";
}>;
failed: Array<{ slug: string; message: string }>;
}) {
const skipped = params.duplicates.map((duplicate) => ({
@@ -495,18 +518,41 @@ function buildSyncJsonOutput(params: {
summary: {
wouldPublish: params.wouldPublish.length,
published: params.published.length,
submitted: params.submitted.length,
alreadySynced: params.alreadySynced.length,
skipped: skipped.length,
failed: params.failed.length,
},
wouldPublish: params.wouldPublish,
published: params.published,
submitted: params.submitted,
alreadySynced: params.alreadySynced,
skipped,
failed: params.failed,
};
}
function formatSyncPublishSummary(params: {
published: unknown[];
submitted: unknown[];
selected?: unknown[];
racedNoOps?: unknown[];
failedUploads?: unknown[];
}) {
const selectedSuffix = params.selected ? ` of ${params.selected.length}` : "";
const parts = [`Published ${params.published.length}${selectedSuffix} skill(s).`];
if (params.submitted.length > 0) {
parts.push(`Submitted ${params.submitted.length} update(s).`);
}
if (params.racedNoOps && params.racedNoOps.length > 0) {
parts.push(`${params.racedNoOps.length} already synced.`);
}
if (params.failedUploads && params.failedUploads.length > 0) {
parts.push(`${params.failedUploads.length} failed.`);
}
return parts.join(" ");
}
function writeSyncJson(value: unknown) {
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
}