Add package changelog previews for plugin publishing (#2947)

* Add package changelog previews

* fix: gate package changelog previews

* Fix package changelog preview races
This commit is contained in:
Andy Ye
2026-07-28 15:45:41 -07:00
committed by GitHub
parent 2448414b44
commit 9822af3917
6 changed files with 660 additions and 7 deletions
+12
View File
@@ -31,4 +31,16 @@ describe("changelog utils", () => {
});
expect(text).toMatch(/Initial release/i);
});
it("generates a package-specific fallback update note", () => {
const text = __test.generatePackageFallback({
name: "demo-plugin",
version: "1.2.0",
oldReadme: "old",
nextReadme: "new",
fileDiff: { added: ["src/index.ts"], changed: [], removed: [] },
});
expect(text).toContain("Updated README and package contents");
expect(text).not.toContain("SKILL.md");
});
});
+99 -5
View File
@@ -61,6 +61,8 @@ function pickPaths(values: string[]) {
}
async function generateWithOpenAI(args: {
subjectLabel?: string;
readmeLabel?: string;
slug: string;
version: string;
oldReadme: string | null;
@@ -70,6 +72,8 @@ async function generateWithOpenAI(args: {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) return null;
const subjectLabel = args.subjectLabel ?? "Skill";
const readmeLabel = args.readmeLabel ?? "SKILL.md";
const oldReadme = args.oldReadme ? clampText(args.oldReadme, MAX_README_CHARS) : "";
const nextReadme = clampText(args.nextReadme, MAX_README_CHARS);
@@ -80,14 +84,14 @@ async function generateWithOpenAI(args: {
const removedPaths = fileDiff ? pickPaths(fileDiff.removed) : [];
const input = [
`Skill: ${args.slug}`,
`${subjectLabel}: ${args.slug}`,
`Version: ${args.version}`,
`File changes: ${diffSummary}`,
changedPaths.length ? `Changed files (sample): ${changedPaths.join(", ")}` : null,
addedPaths.length ? `Added files (sample): ${addedPaths.join(", ")}` : null,
removedPaths.length ? `Removed files (sample): ${removedPaths.join(", ")}` : null,
oldReadme ? `Previous SKILL.md:\n${oldReadme}` : null,
`New SKILL.md:\n${nextReadme}`,
oldReadme ? `Previous ${readmeLabel}:\n${oldReadme}` : null,
`New ${readmeLabel}:\n${nextReadme}`,
]
.filter(Boolean)
.join("\n\n");
@@ -100,8 +104,7 @@ async function generateWithOpenAI(args: {
},
body: JSON.stringify({
model: CHANGELOG_MODEL,
instructions:
"Write a concise changelog for this skill version. Audience: everyone. Output plain text. Prefer 26 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Dont mention that you are AI. Dont invent details; only use the inputs.",
instructions: `Write a concise changelog for this ${subjectLabel.toLowerCase()} version. Audience: everyone. Output plain text. Prefer 26 bullet points. If it is a big change, include a short 1-line summary first, then bullets. Dont mention that you are AI. Dont invent details; only use the inputs.`,
input,
max_output_tokens: 220,
}),
@@ -138,6 +141,32 @@ function generateFallback(args: {
return lines.join("\n");
}
function generatePackageFallback(args: {
name: string;
version: string;
oldReadme: string | null;
nextReadme: string;
fileDiff: FileDiffSummary | null;
}) {
const lines: string[] = [];
if (!args.oldReadme) {
lines.push(`- Initial release.`);
return lines.join("\n");
}
const diff = args.fileDiff;
if (diff) {
const parts: string[] = [];
if (diff.added.length) parts.push(`added ${diff.added.length}`);
if (diff.changed.length) parts.push(`updated ${diff.changed.length}`);
if (diff.removed.length) parts.push(`removed ${diff.removed.length}`);
if (parts.length) lines.push(`- ${parts.join(", ")} file(s).`);
}
lines.push(`- Updated README and package contents.`);
return lines.join("\n");
}
export async function generateChangelogForPublish(
ctx: ActionCtx,
args: { slug: string; version: string; readmeText: string; files: FileMeta[] },
@@ -184,6 +213,59 @@ export async function generateChangelogForPublish(
}
}
export async function generatePackageChangelogPreview(
ctx: ActionCtx,
args: {
name: string;
version: string;
readmeText: string;
filePaths?: string[];
latestReleaseId?: Id<"packageReleases"> | null;
},
): Promise<string> {
try {
const previous: Doc<"packageReleases"> | null = args.latestReleaseId
? ((await ctx.runQuery(internal.packages.getReleaseByIdInternal, {
releaseId: args.latestReleaseId,
})) as Doc<"packageReleases"> | null)
: null;
const oldReadmeText: string | null = previous
? await readReadmeFromPackageRelease(ctx, previous)
: null;
const fileDiff =
previous && args.filePaths
? summarizeFileDiff(
previous.files.map((file) => ({ path: file.path, sha256: file.sha256 })),
args.filePaths.map((path) => ({ path })),
)
: null;
const ai = await generateWithOpenAI({
subjectLabel: "Package",
readmeLabel: "README",
slug: args.name,
version: args.version,
oldReadme: oldReadmeText,
nextReadme: args.readmeText,
fileDiff,
}).catch(() => null);
return (
ai ??
generatePackageFallback({
name: args.name,
version: args.version,
oldReadme: oldReadmeText,
nextReadme: args.readmeText,
fileDiff,
})
);
} catch {
return "- Updated package.";
}
}
export async function generateChangelogPreview(
ctx: ActionCtx,
args: {
@@ -249,10 +331,22 @@ async function readReadmeFromVersion(ctx: ActionCtx, version: Doc<"skillVersions
return blob.text();
}
async function readReadmeFromPackageRelease(ctx: ActionCtx, release: Doc<"packageReleases">) {
const readmeFile = release.files.find((file) => {
const lower = file.path.toLowerCase();
return lower === "readme.md" || lower === "readme.mdx";
});
if (!readmeFile) return null;
const blob = await ctx.storage.get(readmeFile.storageId as Id<"_storage">);
if (!blob) return null;
return blob.text();
}
export const __test = {
clampText,
extractResponseText,
formatDiffSummary,
generatePackageFallback,
summarizeFileDiff,
generateFallback,
};
+216
View File
@@ -18,6 +18,8 @@ import {
list,
publishPackageForTrustedPublisherInternal,
publishPackageForUserInternal,
generateChangelogPreview,
assertCanGenerateChangelogPreviewInternal,
listPackageReportsInternal,
getPackageModerationStatusForUserInternal,
getManageContext,
@@ -409,6 +411,27 @@ const publishPackageForTrustedPublisherInternalHandler = (
unknown
>
)._handler;
const generateChangelogPreviewHandler = (
generateChangelogPreview as unknown as WrappedHandler<
{
name: string;
family: "code-plugin" | "bundle-plugin";
version: string;
readmeText: string;
filePaths?: string[];
},
{ changelog: string }
>
)._handler;
const assertCanGenerateChangelogPreviewInternalHandler = (
assertCanGenerateChangelogPreviewInternal as unknown as WrappedHandler<
{
actorUserId: string;
name: string;
},
{ ok: true }
>
)._handler;
const packageManifestFile = {
path: "openclaw.plugin.json",
@@ -2697,7 +2720,200 @@ function makeSoftDeletePackageCtx(options?: {
};
}
function makePackagePreviewAccessCtx(options: {
actorUserId: string;
actorRole?: "user" | "admin" | "moderator";
pkg: ReturnType<typeof makePackageDoc> | null;
membershipRole?: "owner" | "admin" | "publisher";
}) {
const publisher = options.pkg?.ownerPublisherId
? {
_id: options.pkg.ownerPublisherId,
kind: "org",
handle: "owner-org",
displayName: "Owner Org",
}
: null;
return {
db: {
get: vi.fn(async (id: string) => {
if (id === options.actorUserId) return { _id: id, role: options.actorRole ?? "user" };
if (publisher && id === publisher._id) return publisher;
return null;
}),
query: vi.fn((table: string) => {
if (table === "packages") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(options.pkg),
})),
};
}
if (table === "publisherMembers") {
return {
withIndex: vi.fn(() => ({
unique: vi.fn().mockResolvedValue(
options.membershipRole && publisher
? {
_id: "publisherMembers:preview",
publisherId: publisher._id,
userId: options.actorUserId,
role: options.membershipRole,
}
: null,
),
})),
};
}
throw new Error(`Unexpected table ${table}`);
}),
},
};
}
describe("packages public queries", () => {
it("generates a package changelog preview from prior release history", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:preview" as never);
const previousKey = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
const runQuery = vi
.fn()
.mockResolvedValueOnce({
_id: "users:preview",
role: "user",
})
.mockResolvedValueOnce({ ok: true, latestReleaseId: "packageReleases:demo-1" })
.mockResolvedValueOnce({
_id: "packageReleases:demo-1",
files: [{ path: "README.md", storageId: "storage:readme", sha256: "old" }],
});
const storageGet = vi.fn(async () => new Blob(["# Demo\n\nOld README"]));
const result = await generateChangelogPreviewHandler(
{
runQuery,
storage: { get: storageGet },
} as never,
{
name: "demo-plugin",
family: "code-plugin",
version: "1.2.0",
readmeText: "# Demo\n\nNew README",
filePaths: ["README.md", "src/index.ts"],
},
);
expect(result.changelog).toContain("Updated README and package contents");
expect(runQuery).toHaveBeenCalledTimes(3);
expect(storageGet).toHaveBeenCalledWith("storage:readme");
} finally {
if (previousKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = previousKey;
}
}
});
it("blocks package changelog previews before reading release storage when access is denied", async () => {
vi.mocked(getAuthUserId).mockResolvedValue("users:preview" as never);
const runQuery = vi
.fn()
.mockResolvedValueOnce({
_id: "users:preview",
role: "user",
})
.mockRejectedValueOnce(new Error("Forbidden"));
const storageGet = vi.fn();
await expect(
generateChangelogPreviewHandler(
{
runQuery,
storage: { get: storageGet },
} as never,
{
name: "demo-plugin",
family: "code-plugin",
version: "1.2.0",
readmeText: "# Demo\n\nNew README",
filePaths: ["README.md"],
},
),
).rejects.toThrow("Forbidden");
expect(runQuery).toHaveBeenCalledTimes(2);
expect(storageGet).not.toHaveBeenCalled();
});
it("rejects non-owner package changelog previews for existing packages", async () => {
const pkg = makePackageDoc({
ownerUserId: "users:owner",
ownerPublisherId: "publishers:owner-org",
});
await expect(
assertCanGenerateChangelogPreviewInternalHandler(
makePackagePreviewAccessCtx({
actorUserId: "users:viewer",
pkg,
}) as never,
{ actorUserId: "users:viewer", name: "demo-plugin" },
),
).rejects.toThrow("Forbidden");
});
it("allows direct owners to generate package changelog previews", async () => {
await expect(
assertCanGenerateChangelogPreviewInternalHandler(
makePackagePreviewAccessCtx({
actorUserId: "users:owner",
pkg: makePackageDoc({ ownerUserId: "users:owner" }),
}) as never,
{ actorUserId: "users:owner", name: "demo-plugin" },
),
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
});
it.each(["owner", "admin", "publisher"] as const)(
"allows org %s members to generate package changelog previews",
async (membershipRole) => {
const pkg = makePackageDoc({
ownerUserId: "users:creator",
ownerPublisherId: "publishers:owner-org",
});
await expect(
assertCanGenerateChangelogPreviewInternalHandler(
makePackagePreviewAccessCtx({
actorUserId: "users:publisher-member",
pkg,
membershipRole,
}) as never,
{ actorUserId: "users:publisher-member", name: "demo-plugin" },
),
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
},
);
it.each(["admin", "moderator"] as const)(
"allows platform %s users to generate package changelog previews",
async (actorRole) => {
await expect(
assertCanGenerateChangelogPreviewInternalHandler(
makePackagePreviewAccessCtx({
actorUserId: "users:staff",
actorRole,
pkg: makePackageDoc({ ownerUserId: "users:owner" }),
}) as never,
{ actorUserId: "users:staff", name: "demo-plugin" },
),
).resolves.toMatchObject({ ok: true, latestReleaseId: expect.anything() });
},
);
it("pages eligible plugins by immutable creation time without scanning the catalog client-side", async () => {
const ctx = makePluginExportCtx([
makeDigest("updated-old", {
+55
View File
@@ -53,6 +53,7 @@ import {
readArtifactReportStatus,
appendPackageModerationEventLog,
} from "./lib/artifactModeration";
import { generatePackageChangelogPreview } from "./lib/changelog";
import { sha256Hex } from "./lib/clawpack";
import {
ACTIVITY_TREND_DAYS,
@@ -477,6 +478,7 @@ const internalRefs = internal as unknown as {
getByNameForViewerInternal: unknown;
getPackageByIdInternal: unknown;
getReleaseByIdInternal: unknown;
assertCanGenerateChangelogPreviewInternal: unknown;
getReleaseByPackageAndVersionInternal: unknown;
getPackageReleaseScanBackfillBatchInternal: unknown;
listVersionsForViewerInternal: unknown;
@@ -2905,6 +2907,29 @@ export const getManageContext = query({
},
});
export const assertCanGenerateChangelogPreviewInternal = internalQuery({
args: {
actorUserId: v.id("users"),
name: v.string(),
},
handler: async (ctx, args) => {
const actor = await ctx.db.get(args.actorUserId);
if (!actor || actor.deletedAt || actor.deactivatedAt) {
throw new ConvexError("Unauthorized");
}
const pkg = await getPackageByNormalizedName(ctx, args.name);
if (!pkg || pkg.softDeletedAt) return { ok: true as const, latestReleaseId: null };
if (pkg.family === "skill") throw new ConvexError("Forbidden");
const result = { ok: true as const, latestReleaseId: pkg.latestReleaseId ?? null };
if (actor.role === "admin" || actor.role === "moderator") return result;
const canPublish = await viewerCanAccessPackageOwner(ctx, pkg, args.actorUserId);
if (!canPublish) throw new ConvexError("Forbidden");
return result;
},
});
export const canDeleteVersions = query({
args: {
name: v.string(),
@@ -8953,6 +8978,36 @@ export const finalizePackagePublishAttemptInternal = internalAction({
},
});
export const generateChangelogPreview: ReturnType<typeof action> = action({
args: {
name: v.string(),
family: v.union(v.literal("code-plugin"), v.literal("bundle-plugin")),
version: v.string(),
readmeText: v.string(),
filePaths: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const { userId } = await requireUserFromAction(ctx);
const name = normalizePackageName(args.name);
const version = assertPackageVersion(args.family, args.version);
const authorizedPreview = await runQueryRef<{
ok: true;
latestReleaseId?: Id<"packageReleases"> | null;
}>(ctx, internalRefs.packages.assertCanGenerateChangelogPreviewInternal, {
actorUserId: userId,
name,
});
const changelog = await generatePackageChangelogPreview(ctx, {
name,
version,
readmeText: args.readmeText,
filePaths: args.filePaths,
latestReleaseId: authorizedPreview.latestReleaseId ?? null,
});
return { changelog };
},
});
export const publishPackageForTrustedPublisherInternal = internalAction({
args: {
publishTokenId: v.id("packagePublishTokens"),
+194 -1
View File
@@ -32,6 +32,7 @@ vi.mock("sonner", () => ({
const generateUploadUrl = vi.fn();
const publishRelease = vi.fn();
const generateChangelogPreview = vi.fn();
const fetchMock = vi.fn();
const writeTextMock = vi.fn();
const useAuthStatusMock = vi.fn();
@@ -42,7 +43,10 @@ const originalFetch = globalThis.fetch;
vi.mock("convex/react", () => ({
ConvexReactClient: class {},
useMutation: () => generateUploadUrl,
useAction: () => publishRelease,
useAction: (fn: unknown) => {
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
return name === "packages:generateChangelogPreview" ? generateChangelogPreview : publishRelease;
},
useQuery: (...args: unknown[]) => useQueryMock(...args),
}));
@@ -134,6 +138,7 @@ describe("plugins publish route", () => {
beforeEach(() => {
generateUploadUrl.mockReset();
publishRelease.mockReset();
generateChangelogPreview.mockReset();
fetchMock.mockReset();
writeTextMock.mockReset();
useAuthStatusMock.mockReset();
@@ -163,6 +168,7 @@ describe("plugins publish route", () => {
});
generateUploadUrl.mockResolvedValue("https://upload.local");
publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
generateChangelogPreview.mockResolvedValue({ changelog: "- Updated package." });
fetchMock.mockImplementation(async (_url: string, init?: RequestInit) => ({
ok: true,
json: async () => ({
@@ -582,6 +588,193 @@ describe("plugins publish route", () => {
});
});
it("auto-fills a package changelog preview from the uploaded README", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
nextVersion: "1.2.4",
sourceRepo: "openclaw/demo-plugin",
});
generateChangelogPreview.mockResolvedValueOnce({
changelog: "- Added README-driven install guidance.",
});
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.4",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo-plugin/openclaw.plugin.json",
);
const readme = withRelativePath(
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(generateChangelogPreview).toHaveBeenCalledWith({
name: "demo-plugin",
family: "code-plugin",
version: "1.2.4",
readmeText: "# Demo Plugin\n\nAdds install guidance.",
filePaths: ["package.json", "openclaw.plugin.json", "README.md"],
});
expect(
(
screen.getByPlaceholderText(
"Describe what changed in this release...",
) as HTMLTextAreaElement
).value,
).toBe("- Added README-driven install guidance.");
});
});
it("does not let a stale package changelog preview overwrite manual text", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
nextVersion: "1.2.4",
sourceRepo: "openclaw/demo-plugin",
});
let resolvePreview: ((value: { changelog: string }) => void) | undefined;
generateChangelogPreview.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.4",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo-plugin/openclaw.plugin.json",
);
const readme = withRelativePath(
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(generateChangelogPreview).toHaveBeenCalledTimes(1);
});
const changelog = screen.getByPlaceholderText(
"Describe what changed in this release...",
) as HTMLTextAreaElement;
fireEvent.change(changelog, { target: { value: "Manual release notes." } });
resolvePreview?.({ changelog: "- Stale generated text." });
await waitFor(() => {
expect(changelog.value).toBe("Manual release notes.");
});
});
it("does not apply a package changelog preview after preview inputs change", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
name: "demo-plugin",
displayName: "Demo Plugin",
family: "code-plugin",
nextVersion: "1.2.4",
sourceRepo: "openclaw/demo-plugin",
});
let resolvePreview: ((value: { changelog: string }) => void) | undefined;
generateChangelogPreview.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePreview = resolve;
}),
);
generateChangelogPreview.mockImplementationOnce(() => new Promise(() => {}));
renderPublishRoute();
const packageJson = withRelativePath(
new File(
[
makeCodePluginPackageJson({
name: "demo-plugin",
displayName: "Demo Plugin",
version: "1.2.4",
repository: "https://github.com/openclaw/demo-plugin.git",
}),
],
"package.json",
{ type: "application/json" },
),
"demo-plugin/package.json",
);
const manifest = withRelativePath(
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
"demo-plugin/openclaw.plugin.json",
);
const readme = withRelativePath(
new File(["# Demo Plugin\n\nAdds install guidance."], "README.md", {
type: "text/markdown",
}),
"demo-plugin/README.md",
);
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest, readme] } });
await waitFor(() => {
expect(generateChangelogPreview).toHaveBeenCalledTimes(1);
});
fireEvent.change(screen.getByPlaceholderText("Version"), {
target: { value: "1.2.5" },
});
await waitFor(() => {
expect(generateChangelogPreview).toHaveBeenCalledTimes(2);
});
const changelog = screen.getByPlaceholderText(
"Describe what changed in this release...",
) as HTMLTextAreaElement;
resolvePreview?.({ changelog: "- Stale generated text." });
await waitFor(() => {
expect(changelog.value).toBe("");
});
});
it("sends explicit empty catalog metadata when it is cleared on a plugin version publish", async () => {
useSearchMock.mockReturnValue({
ownerHandle: "vintageayu",
+84 -1
View File
@@ -68,6 +68,7 @@ export const Route = createFileRoute("/plugins/publish")({
const apiRefs = api as unknown as {
packages: {
generateChangelogPreview: unknown;
publishRelease: unknown;
};
};
@@ -248,6 +249,15 @@ export function PublishPluginRoute() {
const publishRelease = useAction(apiRefs.packages.publishRelease as never) as unknown as (args: {
payload: unknown;
}) => Promise<unknown>;
const generateChangelogPreview = useAction(
apiRefs.packages.generateChangelogPreview as never,
) as unknown as (args: {
name: string;
family: "code-plugin" | "bundle-plugin";
version: string;
readmeText: string;
filePaths?: string[];
}) => Promise<{ changelog: string }>;
const [family, setFamily] = useState<"code-plugin" | "bundle-plugin">(
search.family === "bundle-plugin" ? "bundle-plugin" : "code-plugin",
);
@@ -256,6 +266,9 @@ export function PublishPluginRoute() {
const [ownerHandle, setOwnerHandle] = useState(search.ownerHandle ?? "");
const [version, setVersion] = useState(search.nextVersion ?? "0.1.0");
const [changelog, setChangelog] = useState("");
const changelogTouchedRef = useRef(false);
const changelogRequestRef = useRef(0);
const changelogKeyRef = useRef<string | null>(null);
const [categories, setCategories] = useState<string[]>([]);
const [suggestedCategories, setSuggestedCategories] = useState<string[]>();
const [topics, setTopics] = useState("");
@@ -436,6 +449,9 @@ export function PublishPluginRoute() {
setFiles(filtered.files);
setPackageSourceKind(sourceKind);
setIgnoredPaths(nextIgnoredPaths);
changelogRequestRef.current += 1;
changelogKeyRef.current = null;
if (!changelogTouchedRef.current) setChangelog("");
setError(null);
setStatus(null);
setSubmittedPlugin(null);
@@ -460,6 +476,9 @@ export function PublishPluginRoute() {
setDetectedPrefillFields([]);
setCodePluginFieldIssues([]);
setSuggestedCategories(undefined);
changelogRequestRef.current += 1;
changelogKeyRef.current = null;
if (!changelogTouchedRef.current) setChangelog("");
// Without this reset the README warning Badge keeps showing the previous
// package's relative-asset findings until the next pick's async scan
// finishes — which is misleading both while no package is selected and
@@ -498,6 +517,66 @@ export function PublishPluginRoute() {
}
}, [existing]);
useEffect(() => {
changelogRequestRef.current += 1;
changelogKeyRef.current = null;
if (!changelogTouchedRef.current) setChangelog("");
}, [family, name, normalizedPaths, version]);
useEffect(() => {
if (!showChangelogField) return;
if (isMetadataLocked) return;
if (changelogTouchedRef.current) return;
if (changelog.trim()) return;
const packageName = name.trim();
const packageVersion = version.trim();
if (!packageName || !semver.valid(packageVersion)) return;
const readmeFile = findReadmeFile(files);
if (!readmeFile) return;
const key = [
packageName,
family,
packageVersion,
readmeFile.name,
readmeFile.size,
readmeFile.lastModified,
normalizedPaths.join("\0"),
].join(":");
if (changelogKeyRef.current === key) return;
changelogKeyRef.current = key;
const requestId = ++changelogRequestRef.current;
void readmeFile
.text()
.then((text) => {
if (changelogRequestRef.current !== requestId) return null;
return generateChangelogPreview({
name: packageName,
family,
version: packageVersion,
readmeText: text.slice(0, 20_000),
filePaths: normalizedPaths,
});
})
.then((result) => {
if (!result) return;
if (changelogRequestRef.current !== requestId || changelogTouchedRef.current) return;
setChangelog(result.changelog);
})
.catch(() => {});
}, [
changelog,
family,
files,
generateChangelogPreview,
isMetadataLocked,
name,
normalizedPaths,
showChangelogField,
version,
]);
if (isAuthLoading) {
return <PublishFormSkeleton />;
}
@@ -872,7 +951,11 @@ export function PublishPluginRoute() {
rows={4}
value={changelog}
disabled={metadataDisabled}
onChange={(event) => setChangelog(event.target.value)}
onChange={(event) => {
changelogTouchedRef.current = true;
changelogRequestRef.current += 1;
setChangelog(event.target.value);
}}
/>
</Card>
) : null}