From ba080cb4dd9f19be1c58d95d0304754fcfc16804 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:52:29 -0600 Subject: [PATCH 01/34] Fix stale work section overflowing on mobile in inbox Add min-w-0 and overflow-hidden to the stale work row flex containers so the title truncates properly on narrow screens. Add shrink-0 to identifier and assignee spans to prevent them from being compressed. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 25d1da06..2a1e3917 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,26 +841,26 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} {issue.assigneeAgentId && (() => { const name = agentName(issue.assigneeAgentId); return name ? ( ) : ( - + {issue.assigneeAgentId.slice(0, 8)} ); From 7661fae4b3865960d707c09d032f10f35b0e6bca Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 19:55:32 -0600 Subject: [PATCH 02/34] Fix inbox row irregular heights on mobile from unread badge - Give unread dot container fixed h-5 so rows are consistent height regardless of badge presence - Use flex-wrap on mobile so title gets its own line with line-clamp-2 - On sm+ screens, keep single-line truncated layout - Move timestamp to ml-auto with sm:order-last for clean wrapping Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 2a1e3917..ff72947e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -898,9 +898,9 @@ export function Inbox() { return (
- + {(isUnread || isFading) && (
); From 6e86f69f95bd9917a58299b48174669f55877455 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:01:16 -0600 Subject: [PATCH 03/34] Unify issue rows with GitHub-style mobile layout across app On mobile, all issue rows now show title first (up to 2 lines), with metadata (icons, identifier, timestamp) on a second line below. Desktop layout is preserved as single-line rows. Affected locations: Inbox recent issues, Inbox stale work, Dashboard recent tasks, and IssuesList. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 225 ++++++++++++++++--------------- ui/src/pages/Dashboard.tsx | 40 +++--- ui/src/pages/Inbox.tsx | 76 ++++++----- 3 files changed, 180 insertions(+), 161 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 6335f02c..4abd2cea 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -608,41 +608,26 @@ export function IssuesList({ - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} -
-
{ e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> -
- - {issue.identifier ?? issue.id.slice(0, 8)} + {/* Title line - first on mobile, middle on desktop */} + + {issue.title} - {issue.title} - {(issue.labels ?? []).length > 0 && ( -
- {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - +{(issue.labels ?? []).length - 3} - )} -
- )} -
+ + {/* Metadata line - second on mobile, first on desktop */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + {liveIssueIds?.has(issue.id) && ( @@ -652,90 +637,116 @@ export function IssuesList({ Live )} -
- { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} + · + + {formatDate(issue.createdAt)} + + + + {/* Desktop-only trailing content */} + + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + +{(issue.labels ?? []).length - 3} + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} > - + setAssigneeSearch(e.target.value)} + autoFocus + /> +
- - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
- -
- + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name.toLowerCase().includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+ + + {formatDate(issue.createdAt)} -
+
))} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 823d57df..e4b8747e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,26 +313,28 @@ export function Dashboard() { -
-
-
- - -
-

- {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} -

-
- - {timeAgo(issue.updatedAt)} +
+ + {issue.title} + + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index ff72947e..e55b3866 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,38 +841,39 @@ export function Inbox() { {staleIssues.map((issue) => (
+ - - - - - {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} - {issue.title} - {issue.assigneeAgentId && - (() => { - const name = agentName(issue.assigneeAgentId); - return name ? ( - - ) : ( - - {issue.assigneeAgentId.slice(0, 8)} - - ); - })()} - - updated {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && + (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : null; + })()} + · + + updated {timeAgo(issue.updatedAt)} +
); From ce8fe38ffc8f9804a182460ace866b9b98c9a73d Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:32 -0600 Subject: [PATCH 04/34] Unify mobile issue row layout across issues, inbox, and dashboard Add PriorityIcon and timeAgo to IssuesList mobile rows to match the pattern used in Inbox and Dashboard. Align Dashboard row padding to match Inbox. All mobile issue rows now show: title (2-line clamp), then priority + status + identifier + relative time. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 6 ++++-- ui/src/pages/Dashboard.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 4abd2cea..498752b4 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,6 +7,7 @@ import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { formatDate, cn } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; @@ -619,13 +620,14 @@ export function IssuesList({ {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} /> - + {issue.identifier ?? issue.id.slice(0, 8)} {liveIssueIds?.has(issue.id) && ( @@ -639,7 +641,7 @@ export function IssuesList({ )} · - {formatDate(issue.createdAt)} + {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index e4b8747e..23296bba 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -313,7 +313,7 @@ export function Dashboard() {
From a96556b8f4530ad6e8915405f3d8ae855c21b31e Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:04:41 -0600 Subject: [PATCH 05/34] Move unread badge inline with metadata row in inbox Moves the unread dot from a separate left column into the metadata line (alongside status/identifier), with an empty placeholder for read items to keep spacing consistent. Reduces left padding on mobile inbox rows to reclaim space. Co-Authored-By: Claude Opus 4.6 --- ui/src/pages/Inbox.tsx | 54 +++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index e55b3866..8b8a52b5 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -897,12 +897,16 @@ export function Inbox() { const isUnread = issue.isUnreadForMe && !fadingOutIssues.has(issue.id); const isFading = fadingOutIssues.has(issue.id); return ( -
- - {(isUnread || isFading) && ( + + {issue.title} + + + {(isUnread || isFading) ? ( + ) : ( + )} + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} + - - - {issue.title} - - - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} - - - -
+ ); })}
From 45473b3e726c35b5e552c711e37f339fd321ad70 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:07:39 -0600 Subject: [PATCH 06/34] Move scroll-to-bottom button to issue detail and run pages Removed the scroll-to-bottom button from IssuesList (wrong location) and created a shared ScrollToBottom component. Added it to IssueDetail and RunDetail pages. On mobile, the button sits above the bottom nav to avoid overlap. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 29 +------------------- ui/src/components/ScrollToBottom.tsx | 40 ++++++++++++++++++++++++++++ ui/src/pages/AgentDetail.tsx | 2 ++ ui/src/pages/IssueDetail.tsx | 2 ++ 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 ui/src/components/ScrollToBottom.tsx diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 498752b4..e9f7ac8d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -18,7 +18,7 @@ import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search, ArrowDown } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclipai/shared"; @@ -234,24 +234,6 @@ export function IssuesList({ const activeFilterCount = countActiveFilters(viewState); - const [showScrollBottom, setShowScrollBottom] = useState(false); - useEffect(() => { - const el = document.getElementById("main-content"); - if (!el) return; - const check = () => { - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - setShowScrollBottom(distanceFromBottom > 300); - }; - check(); - el.addEventListener("scroll", check, { passive: true }); - return () => el.removeEventListener("scroll", check); - }, [filtered.length]); - - const scrollToBottom = useCallback(() => { - const el = document.getElementById("main-content"); - if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); - }, []); - const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { return [{ key: "__all", label: null as string | null, items: filtered }]; @@ -755,15 +737,6 @@ export function IssuesList({ )) )} - {showScrollBottom && ( - - )}
); } diff --git a/ui/src/components/ScrollToBottom.tsx b/ui/src/components/ScrollToBottom.tsx new file mode 100644 index 00000000..4ea8a494 --- /dev/null +++ b/ui/src/components/ScrollToBottom.tsx @@ -0,0 +1,40 @@ +import { useCallback, useEffect, useState } from "react"; +import { ArrowDown } from "lucide-react"; + +/** + * Floating scroll-to-bottom button that appears when the user is far from the + * bottom of the `#main-content` scroll container. Hides when within 300px of + * the bottom. Positioned to avoid the mobile bottom nav. + */ +export function ScrollToBottom() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = document.getElementById("main-content"); + if (!el) return; + const check = () => { + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + setVisible(distance > 300); + }; + check(); + el.addEventListener("scroll", check, { passive: true }); + return () => el.removeEventListener("scroll", check); + }, []); + + const scroll = useCallback(() => { + const el = document.getElementById("main-content"); + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }, []); + + if (!visible) return null; + + return ( + + ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 06c3a2f4..596cb98d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -24,6 +24,7 @@ import { CopyText } from "../components/CopyText"; import { EntityRow } from "../components/EntityRow"; import { Identity } from "../components/Identity"; import { PageSkeleton } from "../components/PageSkeleton"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { formatCents, formatDate, relativeTime, formatTokens } from "../lib/utils"; import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; @@ -1747,6 +1748,7 @@ function RunDetail({ run, agentRouteId, adapterType }: { run: HeartbeatRun; agen {/* Log viewer */} +
); } diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 90c94888..a0266c16 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -18,6 +18,7 @@ import { CommentThread } from "../components/CommentThread"; import { IssueProperties } from "../components/IssueProperties"; import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; +import { ScrollToBottom } from "../components/ScrollToBottom"; import { StatusIcon } from "../components/StatusIcon"; import { PriorityIcon } from "../components/PriorityIcon"; import { StatusBadge } from "../components/StatusBadge"; @@ -926,6 +927,7 @@ export function IssueDetail() { +
); } From 0f75c35392190e5c90b30e95c90fe96386be8128 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Mar 2026 20:12:26 -0600 Subject: [PATCH 07/34] Fix unread dot making inbox rows taller than read rows Replace + ) : ( - + )} From 31b5ff1c619afc20a2f5f8a23b724a2652698eab Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 08:21:07 -0500 Subject: [PATCH 08/34] Add bottom padding to new issue description editor for iOS keyboard The description text was being covered by the property chips bar when typing long content on iOS with the virtual keyboard open. Co-Authored-By: Claude Opus 4.6 --- ui/src/components/NewIssueDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 99106f9f..4401821f 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -831,7 +831,7 @@ export function NewIssueDialog() { placeholder="Add description..." bordered={false} mentions={mentionOptions} - contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} + contentClassName={cn("text-sm text-muted-foreground pb-12", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); return asset.contentPath; From 2a7043d67761456d92d0b4be8fa9c5b121e9e4b6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:56:17 -0500 Subject: [PATCH 09/34] GitHub-style mobile issue rows: status left column, hide priority, unread dot right - Move status icon to left column on mobile across issues list, inbox, and dashboard - Hide priority icon on mobile (only show on desktop) - Move unread indicator dot to right side vertically centered on mobile inbox - Stale work section: show status icon instead of clock on mobile - Desktop layout unchanged Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 67 +++++++++------- ui/src/pages/Dashboard.tsx | 42 ++++++---- ui/src/pages/Inbox.tsx | 129 ++++++++++++++++++++----------- 3 files changed, 149 insertions(+), 89 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index e9f7ac8d..3770d10d 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -591,39 +591,50 @@ export function IssuesList({ - {/* Title line - first on mobile, middle on desktop */} - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> - {/* Metadata line - second on mobile, first on desktop */} - - {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} - - - { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> + {/* Right column on mobile: title + metadata stacked */} + + {/* Title line */} + + {issue.title} - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - Live + + {/* Metadata line */} + + {/* Spacer matching caret width so status icon aligns with group title (hidden on mobile) */} + + + { e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + Live + + )} + · + + {timeAgo(issue.updatedAt)} - )} - · - - {timeAgo(issue.updatedAt)} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 23296bba..f782555e 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -315,25 +315,33 @@ export function Dashboard() { to={`/issues/${issue.identifier ?? issue.id}`} className="px-4 py-3 text-sm cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit block" > -
- - {issue.title} - - - +
+ {/* Status icon - left column on mobile */} + - - {issue.identifier ?? issue.id.slice(0, 8)} + + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : null; - })()} - · - - {timeAgo(issue.updatedAt)} + + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : null; + })()} + · + + {timeAgo(issue.updatedAt)} +
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 66fe71c4..4091e36e 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -841,9 +841,14 @@ export function Inbox() { {staleIssues.map((issue) => (
- + {/* Status icon - left column on mobile; Clock icon on desktop */} + + + + + - - + + {issue.identifier ?? issue.id.slice(0, 8)} @@ -900,54 +905,90 @@ export function Inbox() { - - {issue.title} + {/* Status icon - left column on mobile, inline on desktop */} + + - - {(isUnread || isFading) ? ( - { - e.preventDefault(); - e.stopPropagation(); - markReadMutation.mutate(issue.id); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { + + {/* Right column on mobile: title + metadata stacked */} + + + {issue.title} + + + {(isUnread || isFading) ? ( + { e.preventDefault(); e.stopPropagation(); markReadMutation.mutate(issue.id); - } - }} - className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" - aria-label="Mark as read" - > - + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="hidden sm:inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + aria-label="Mark as read" + > + + + ) : ( + + )} + + + + {issue.identifier ?? issue.id.slice(0, 8)} + + + · + + + {issue.lastExternalCommentAt + ? `commented ${timeAgo(issue.lastExternalCommentAt)}` + : `updated ${timeAgo(issue.updatedAt)}`} - ) : ( - - )} - - - - {issue.identifier ?? issue.id.slice(0, 8)} - - - · - - - {issue.lastExternalCommentAt - ? `commented ${timeAgo(issue.lastExternalCommentAt)}` - : `updated ${timeAgo(issue.updatedAt)}`} + + {/* Unread dot - right side, vertically centered (mobile only; desktop keeps inline) */} + {(isUnread || isFading) && ( + { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + markReadMutation.mutate(issue.id); + } + }} + className="shrink-0 self-center cursor-pointer sm:hidden" + aria-label="Mark as read" + > + + + )} ); })} From d58f2692816817baa73ff40fe8d40d90cf72fa00 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 10:58:39 -0500 Subject: [PATCH 10/34] Fix mobile status icon vertical alignment: remove pt-0.5 to center with text Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- ui/src/pages/Dashboard.tsx | 2 +- ui/src/pages/Inbox.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 3770d10d..f9b4dcca 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index f782555e..e1f9b9b0 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -317,7 +317,7 @@ export function Dashboard() { >
{/* Status icon - left column on mobile */} - + diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4091e36e..990f30ca 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -844,7 +844,7 @@ export function Inbox() { className="group/stale relative flex items-start gap-2 overflow-hidden px-3 py-3 transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > {/* Status icon - left column on mobile; Clock icon on desktop */} - + @@ -908,7 +908,7 @@ export function Inbox() { className="flex min-w-0 cursor-pointer items-start gap-2 px-3 py-3 no-underline text-inherit transition-colors hover:bg-accent/50 sm:items-center sm:gap-3 sm:px-4" > {/* Status icon - left column on mobile, inline on desktop */} - + From e35e2c43432002004162a376668b270428bdeffd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 8 Mar 2026 11:00:02 -0500 Subject: [PATCH 11/34] Fine-tune mobile status icon alignment on issues page: add 1px top padding Co-Authored-By: Claude Opus 4.6 --- ui/src/components/IssuesList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index f9b4dcca..10d0709b 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -594,7 +594,7 @@ export function IssuesList({ className="flex items-start gap-2 py-2.5 pl-2 pr-3 text-sm border-b border-border last:border-b-0 cursor-pointer hover:bg-accent/50 transition-colors no-underline text-inherit sm:items-center sm:py-2 sm:pl-1" > {/* Status icon - left column on mobile, inline on desktop */} - { e.preventDefault(); e.stopPropagation(); }}> + { e.preventDefault(); e.stopPropagation(); }}> onUpdateIssue(issue.id, { status: s })} From 77e04407b9b412ae051bd8b3805288e43d069ef9 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:21:33 -0500 Subject: [PATCH 12/34] fix(publish): always bundle ui-dist into server package --- scripts/prepare-server-ui-dist.sh | 21 +++++++++++++++++++++ scripts/release.sh | 4 +--- server/package.json | 3 +++ 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100755 scripts/prepare-server-ui-dist.sh diff --git a/scripts/prepare-server-ui-dist.sh b/scripts/prepare-server-ui-dist.sh new file mode 100755 index 00000000..d43807b3 --- /dev/null +++ b/scripts/prepare-server-ui-dist.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +# prepare-server-ui-dist.sh — Build the UI and copy it into server/ui-dist. +# This keeps @paperclipai/server publish artifacts self-contained for static UI serving. + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +UI_DIST="$REPO_ROOT/ui/dist" +SERVER_UI_DIST="$REPO_ROOT/server/ui-dist" + +echo " -> Building @paperclipai/ui..." +pnpm --dir "$REPO_ROOT" --filter @paperclipai/ui build + +if [ ! -f "$UI_DIST/index.html" ]; then + echo "Error: UI build output missing at $UI_DIST/index.html" + exit 1 +fi + +rm -rf "$SERVER_UI_DIST" +cp -r "$UI_DIST" "$SERVER_UI_DIST" +echo " -> Copied ui/dist to server/ui-dist" diff --git a/scripts/release.sh b/scripts/release.sh index 6827e0fa..3668d87c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -283,9 +283,7 @@ pnpm --filter @paperclipai/adapter-openclaw-gateway build pnpm --filter @paperclipai/server build # Build UI and bundle into server package for static serving -pnpm --filter @paperclipai/ui build -rm -rf "$REPO_ROOT/server/ui-dist" -cp -r "$REPO_ROOT/ui/dist" "$REPO_ROOT/server/ui-dist" +bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" # Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do diff --git a/server/package.json b/server/package.json index 3e74286b..5c37c211 100644 --- a/server/package.json +++ b/server/package.json @@ -24,7 +24,10 @@ "scripts": { "dev": "tsx src/index.ts", "dev:watch": "PAPERCLIP_MIGRATION_PROMPT=never tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc", + "prepack": "pnpm run prepare:ui-dist", + "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", "typecheck": "tsc --noEmit" From ee7fddf8d5fcd76d7ffc93164f972e717fb15c25 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:22:34 -0500 Subject: [PATCH 13/34] fix: convert lockfile refresh to PR-based flow for protected master The refresh-lockfile workflow was pushing directly to master, which fails with branch protection rules. Convert to use peter-evans/create-pull-request to create a PR instead. Exempt the bot's branch from the lockfile policy check. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/pr-policy.yml | 1 + .github/workflows/refresh-lockfile.yml | 42 +++++++++----------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml index eb515eda..16953380 100644 --- a/.github/workflows/pr-policy.yml +++ b/.github/workflows/pr-policy.yml @@ -32,6 +32,7 @@ jobs: node-version: 20 - name: Block manual lockfile edits + if: github.head_ref != 'chore/refresh-lockfile' run: | changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")" if printf '%s\n' "$changed" | grep -qx 'pnpm-lock.yaml'; then diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 079fdd4e..b0cfb78a 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -11,11 +11,12 @@ concurrency: cancel-in-progress: false jobs: - refresh_and_verify: + refresh: runs-on: ubuntu-latest - timeout-minutes: 25 + timeout-minutes: 10 permissions: contents: write + pull-requests: write steps: - name: Checkout repository @@ -40,6 +41,7 @@ jobs: run: | changed="$(git status --porcelain)" if [ -z "$changed" ]; then + echo "Lockfile is already up to date." exit 0 fi if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then @@ -48,29 +50,15 @@ jobs: exit 1 fi - - name: Commit refreshed lockfile - run: | - if git diff --quiet -- pnpm-lock.yaml; then - exit 0 - fi - git config user.name "lockfile-bot" - git config user.email "lockfile-bot@users.noreply.github.com" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push || { - echo "Push failed because master moved during lockfile refresh." - echo "A later refresh run should recompute the lockfile from the newer master state." - exit 1 - } + - name: Create pull request + uses: peter-evans/create-pull-request@v7 + with: + commit-message: "chore(lockfile): refresh pnpm-lock.yaml" + branch: chore/refresh-lockfile + delete-branch: true + title: "chore(lockfile): refresh pnpm-lock.yaml" + body: | + Auto-generated lockfile refresh after dependencies changed on `master`. - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Typecheck - run: pnpm -r typecheck - - - name: Run tests - run: pnpm test:run - - - name: Build - run: pnpm build + This PR only updates `pnpm-lock.yaml` — no source changes. + labels: lockfile-bot From f32b76f21343ef415a6389351d6189f8d7d395e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:26:31 -0500 Subject: [PATCH 14/34] fix: replace third-party action with gh CLI for lockfile PR creation Replace peter-evans/create-pull-request with plain gh CLI commands to avoid third-party supply chain risk. Uses only GitHub's own tooling (GITHUB_TOKEN + gh CLI) to create the lockfile refresh PR. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 38 ++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index b0cfb78a..604f394f 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -50,15 +50,31 @@ jobs: exit 1 fi - - name: Create pull request - uses: peter-evans/create-pull-request@v7 - with: - commit-message: "chore(lockfile): refresh pnpm-lock.yaml" - branch: chore/refresh-lockfile - delete-branch: true - title: "chore(lockfile): refresh pnpm-lock.yaml" - body: | - Auto-generated lockfile refresh after dependencies changed on `master`. + - name: Create or update pull request + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- pnpm-lock.yaml; then + echo "Lockfile unchanged, nothing to do." + exit 0 + fi - This PR only updates `pnpm-lock.yaml` — no source changes. - labels: lockfile-bot + BRANCH="chore/refresh-lockfile" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push -f origin "$BRANCH" + + # Create PR if one doesn't already exist for this branch + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -n "$existing" ]; then + echo "PR #$existing already exists for $BRANCH, updated branch." + else + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." + echo "Created new PR." + fi From 035e1a9333965f33547ddd10a00aa57f16dc76fe Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:33:49 -0500 Subject: [PATCH 15/34] fix: use lockfile-bot identity and remove force push in refresh workflow - Use lockfile-bot name/email instead of github-actions[bot] - Remove force push: close any stale PR and delete branch first, then create a fresh branch and PR each time Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 604f394f..4da9d047 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -60,21 +60,21 @@ jobs: fi BRANCH="chore/refresh-lockfile" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "$BRANCH" - git add pnpm-lock.yaml - git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push -f origin "$BRANCH" + git config user.name "lockfile-bot" + git config user.email "lockfile-bot@users.noreply.github.com" - # Create PR if one doesn't already exist for this branch + # Close any stale PR and delete the remote branch so we start fresh existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') if [ -n "$existing" ]; then - echo "PR #$existing already exists for $BRANCH, updated branch." - else - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on \`master\`.\n\nThis PR only updates \`pnpm-lock.yaml\` — no source changes." - echo "Created new PR." + gh pr close "$existing" --delete-branch || true fi + + git checkout -b "$BRANCH" + git add pnpm-lock.yaml + git commit -m "chore(lockfile): refresh pnpm-lock.yaml" + git push origin "$BRANCH" + + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." From f2a0a0b80452165387f519a30abfdd28dc6b40c5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:38:49 -0500 Subject: [PATCH 16/34] fix: restore force push in lockfile refresh workflow Simplify the PR-based flow: force push to update the branch if it already exists, and only create a new PR when one doesn't exist yet. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/refresh-lockfile.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/refresh-lockfile.yml b/.github/workflows/refresh-lockfile.yml index 4da9d047..a879e5bc 100644 --- a/.github/workflows/refresh-lockfile.yml +++ b/.github/workflows/refresh-lockfile.yml @@ -63,18 +63,19 @@ jobs: git config user.name "lockfile-bot" git config user.email "lockfile-bot@users.noreply.github.com" - # Close any stale PR and delete the remote branch so we start fresh - existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') - if [ -n "$existing" ]; then - gh pr close "$existing" --delete-branch || true - fi - - git checkout -b "$BRANCH" + git checkout -B "$BRANCH" git add pnpm-lock.yaml git commit -m "chore(lockfile): refresh pnpm-lock.yaml" - git push origin "$BRANCH" + git push --force origin "$BRANCH" - gh pr create \ - --head "$BRANCH" \ - --title "chore(lockfile): refresh pnpm-lock.yaml" \ - --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + # Create PR if one doesn't already exist + existing=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number') + if [ -z "$existing" ]; then + gh pr create \ + --head "$BRANCH" \ + --title "chore(lockfile): refresh pnpm-lock.yaml" \ + --body "Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml." + echo "Created new PR." + else + echo "PR #$existing already exists, branch updated via force push." + fi From 38cb2bf3c44f0f0454e51b06c42b3eaf66364356 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 07:43:47 -0500 Subject: [PATCH 17/34] fix: add missing disableSignUp to auth config objects in CLI All auth config literals in the CLI were missing the required disableSignUp field after it was added to authConfigSchema. Co-Authored-By: Claude Opus 4.6 --- cli/src/__tests__/allowed-hostname.test.ts | 1 + cli/src/commands/configure.ts | 1 + cli/src/commands/onboard.ts | 1 + cli/src/prompts/server.ts | 4 +++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/allowed-hostname.test.ts b/cli/src/__tests__/allowed-hostname.test.ts index 92dfbf42..572689c4 100644 --- a/cli/src/__tests__/allowed-hostname.test.ts +++ b/cli/src/__tests__/allowed-hostname.test.ts @@ -42,6 +42,7 @@ function writeBaseConfig(configPath: string) { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: { provider: "local_disk", diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index d072fee9..969ead97 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -61,6 +61,7 @@ function defaultConfig(): PaperclipConfig { }, auth: { baseUrlMode: "auto", + disableSignUp: false, }, storage: defaultStorageConfig(), secrets: defaultSecretsConfig(), diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 0e70d9cf..e3f17001 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -185,6 +185,7 @@ function quickstartDefaultsFromEnv(): { }, auth: { baseUrlMode: authBaseUrlMode, + disableSignUp: false, ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), }, storage: { diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index c2ab4218..00611560 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -113,7 +113,7 @@ export async function promptServer(opts?: { } const port = Number(portStr) || 3100; - let auth: AuthConfig = { baseUrlMode: "auto" }; + let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false }; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", @@ -139,11 +139,13 @@ export async function promptServer(opts?: { } auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), }; } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { auth = { baseUrlMode: "explicit", + disableSignUp: false, publicBaseUrl: currentAuth.publicBaseUrl, }; } From ccd501ea02f35c6adb6378860c8c214d100ec93e Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 08:00:08 -0500 Subject: [PATCH 18/34] feat: add Playwright e2e tests for onboarding wizard flow Scaffolds end-to-end testing with Playwright for the onboarding wizard. Runs in skip_llm mode by default (UI-only, no LLM costs). Set PAPERCLIP_E2E_SKIP_LLM=false for full heartbeat verification. - tests/e2e/playwright.config.ts: Playwright config with webServer - tests/e2e/onboarding.spec.ts: 4-step wizard flow test - .github/workflows/e2e.yml: manual workflow_dispatch CI workflow - package.json: test:e2e and test:e2e:headed scripts Co-Authored-By: Claude Opus 4.6 --- .github/workflows/e2e.yml | 44 +++++++++ .gitignore | 6 +- package.json | 5 +- pnpm-lock.yaml | 47 +++++++-- tests/e2e/onboarding.spec.ts | 172 +++++++++++++++++++++++++++++++++ tests/e2e/playwright.config.ts | 35 +++++++ 6 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/onboarding.spec.ts create mode 100644 tests/e2e/playwright.config.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..8d154627 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,44 @@ +name: E2E Tests + +on: + workflow_dispatch: + inputs: + skip_llm: + description: "Skip LLM-dependent assertions (default: true)" + type: boolean + default: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + PAPERCLIP_E2E_SKIP_LLM: ${{ inputs.skip_llm && 'true' || 'false' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 9 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: npx playwright install --with-deps chromium + + - name: Run e2e tests + run: pnpm run test:e2e + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: | + tests/e2e/playwright-report/ + tests/e2e/test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 9d9f5e35..066fcc68 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ tmp/ *.tmp .vscode/ .claude/settings.local.json -.paperclip-local/ \ No newline at end of file +.paperclip-local/ + +# Playwright +tests/e2e/test-results/ +tests/e2e/playwright-report/ \ No newline at end of file diff --git a/package.json b/package.json index 45c02b8b..e19fb785 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,13 @@ "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", - "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh" + "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", + "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", + "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" }, "devDependencies": { "@changesets/cli": "^2.30.0", + "@playwright/test": "^1.58.2", "esbuild": "^0.27.3", "typescript": "^5.7.3", "vitest": "^3.0.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..f2885e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,9 +261,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +376,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1696,6 +1690,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4012,6 +4011,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4798,6 +4802,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7394,6 +7408,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9872,6 +9890,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10923,6 +10944,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts new file mode 100644 index 00000000..f1dbd0f5 --- /dev/null +++ b/tests/e2e/onboarding.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from "@playwright/test"; + +/** + * E2E: Onboarding wizard flow (skip_llm mode). + * + * Walks through the 4-step OnboardingWizard: + * Step 1 — Name your company + * Step 2 — Create your first agent (adapter selection + config) + * Step 3 — Give it something to do (task creation) + * Step 4 — Ready to launch (summary + open issue) + * + * By default this runs in skip_llm mode: we do NOT assert that an LLM + * heartbeat fires. Set PAPERCLIP_E2E_SKIP_LLM=false to enable LLM-dependent + * assertions (requires a valid ANTHROPIC_API_KEY). + */ + +const SKIP_LLM = process.env.PAPERCLIP_E2E_SKIP_LLM !== "false"; + +const COMPANY_NAME = `E2E-Test-${Date.now()}`; +const AGENT_NAME = "CEO"; +const TASK_TITLE = "E2E test task"; + +test.describe("Onboarding wizard", () => { + test("completes full wizard flow", async ({ page }) => { + // Navigate to root — should auto-open onboarding when no companies exist + await page.goto("/"); + + // If the wizard didn't auto-open (company already exists), click the button + const wizardHeading = page.locator("h3", { hasText: "Name your company" }); + const newCompanyBtn = page.getByRole("button", { name: "New Company" }); + + // Wait for either the wizard or the start page + await expect( + wizardHeading.or(newCompanyBtn) + ).toBeVisible({ timeout: 15_000 }); + + if (await newCompanyBtn.isVisible()) { + await newCompanyBtn.click(); + } + + // ----------------------------------------------------------- + // Step 1: Name your company + // ----------------------------------------------------------- + await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); + await expect(page.locator("text=Step 1 of 4")).toBeVisible(); + + const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); + await companyNameInput.fill(COMPANY_NAME); + + // Click Next + const nextButton = page.getByRole("button", { name: "Next" }); + await nextButton.click(); + + // ----------------------------------------------------------- + // Step 2: Create your first agent + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Create your first agent" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 2 of 4")).toBeVisible(); + + // Agent name should default to "CEO" + const agentNameInput = page.locator('input[placeholder="CEO"]'); + await expect(agentNameInput).toHaveValue(AGENT_NAME); + + // Claude Code adapter should be selected by default + await expect( + page.locator("button", { hasText: "Claude Code" }).locator("..") + ).toBeVisible(); + + // Select the "Process" adapter to avoid needing a real CLI tool installed + await page.locator("button", { hasText: "Process" }).click(); + + // Fill in process adapter fields + const commandInput = page.locator('input[placeholder="e.g. node, python"]'); + await commandInput.fill("echo"); + const argsInput = page.locator( + 'input[placeholder="e.g. script.js, --flag"]' + ); + await argsInput.fill("hello"); + + // Click Next (process adapter skips environment test) + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 3: Give it something to do + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Give it something to do" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 3 of 4")).toBeVisible(); + + // Clear default title and set our test title + const taskTitleInput = page.locator( + 'input[placeholder="e.g. Research competitor pricing"]' + ); + await taskTitleInput.clear(); + await taskTitleInput.fill(TASK_TITLE); + + // Click Next + await page.getByRole("button", { name: "Next" }).click(); + + // ----------------------------------------------------------- + // Step 4: Ready to launch + // ----------------------------------------------------------- + await expect( + page.locator("h3", { hasText: "Ready to launch" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.locator("text=Step 4 of 4")).toBeVisible(); + + // Verify summary displays our created entities + await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); + await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); + await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); + + // Click "Open Issue" + await page.getByRole("button", { name: "Open Issue" }).click(); + + // Should navigate to the issue page + await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); + + // ----------------------------------------------------------- + // Verify via API that entities were created + // ----------------------------------------------------------- + const baseUrl = page.url().split("/").slice(0, 3).join("/"); + + // List companies and find ours + const companiesRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesRes.ok()).toBe(true); + const companies = await companiesRes.json(); + const company = companies.find( + (c: { name: string }) => c.name === COMPANY_NAME + ); + expect(company).toBeTruthy(); + + // List agents for our company + const agentsRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/agents` + ); + expect(agentsRes.ok()).toBe(true); + const agents = await agentsRes.json(); + const ceoAgent = agents.find( + (a: { name: string }) => a.name === AGENT_NAME + ); + expect(ceoAgent).toBeTruthy(); + expect(ceoAgent.role).toBe("ceo"); + expect(ceoAgent.adapterType).toBe("process"); + + // List issues for our company + const issuesRes = await page.request.get( + `${baseUrl}/api/companies/${company.id}/issues` + ); + expect(issuesRes.ok()).toBe(true); + const issues = await issuesRes.json(); + const task = issues.find( + (i: { title: string }) => i.title === TASK_TITLE + ); + expect(task).toBeTruthy(); + expect(task.assigneeAgentId).toBe(ceoAgent.id); + + if (!SKIP_LLM) { + // LLM-dependent: wait for the heartbeat to transition the issue + await expect(async () => { + const res = await page.request.get( + `${baseUrl}/api/issues/${task.id}` + ); + const issue = await res.json(); + expect(["in_progress", "done"]).toContain(issue.status); + }).toPass({ timeout: 120_000, intervals: [5_000] }); + } + }); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 00000000..5ae1b677 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3100); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 60_000, + retries: 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + // The webServer directive starts `paperclipai run` before tests. + // Expects `pnpm paperclipai` to be runnable from repo root. + webServer: { + command: `pnpm paperclipai run --yes`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: !!process.env.CI, + timeout: 120_000, + stdout: "pipe", + stderr: "pipe", + }, + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); From e6e41dba9daa215479ef67c14f17ac6987af1538 Mon Sep 17 00:00:00 2001 From: lockfile-bot Date: Mon, 9 Mar 2026 13:27:18 +0000 Subject: [PATCH 19/34] chore(lockfile): refresh pnpm-lock.yaml --- pnpm-lock.yaml | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..9536ff75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,9 +35,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -139,22 +136,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - packages/adapters/openclaw: - dependencies: - '@paperclipai/adapter-utils': - specifier: workspace:* - version: link:../../adapter-utils - picocolors: - specifier: ^1.1.1 - version: 1.1.1 - devDependencies: - '@types/node': - specifier: ^24.6.0 - version: 24.12.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - packages/adapters/openclaw-gateway: dependencies: '@paperclipai/adapter-utils': @@ -261,9 +242,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +357,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway From a7cfd9f24b865bd2a1d08a520de5075a3767e68f Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 08:49:42 -0500 Subject: [PATCH 20/34] chore: formalize release workflow --- .github/workflows/release.yml | 132 ++++++ doc/PUBLISHING.md | 257 ++++------- doc/RELEASING.md | 437 ++++++++++++++++++ package.json | 2 + scripts/create-github-release.sh | 86 ++++ scripts/release.sh | 714 ++++++++++++++++-------------- scripts/rollback-latest.sh | 111 +++++ skills/release-changelog/SKILL.md | 351 +++------------ skills/release/SKILL.md | 432 ++++++------------ 9 files changed, 1431 insertions(+), 1091 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 doc/RELEASING.md create mode 100755 scripts/create-github-release.sh create mode 100755 scripts/rollback-latest.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..492b02b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,132 @@ +name: Release + +on: + workflow_dispatch: + inputs: + channel: + description: Release channel + required: true + type: choice + default: canary + options: + - canary + - stable + bump: + description: Semantic version bump + required: true + type: choice + default: patch + options: + - patch + - minor + - major + dry_run: + description: Preview the release without publishing + required: true + type: boolean + default: true + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + verify: + if: github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Typecheck + run: pnpm -r typecheck + + - name: Run tests + run: pnpm test:run + + - name: Build + run: pnpm build + + publish: + if: github.ref == 'refs/heads/master' + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 45 + environment: npm-release + permissions: + contents: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Run release script + env: + GITHUB_ACTIONS: "true" + run: | + args=("${{ inputs.bump }}") + if [ "${{ inputs.channel }}" = "canary" ]; then + args+=("--canary") + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=("--dry-run") + fi + ./scripts/release.sh "${args[@]}" + + - name: Push stable release commit and tag + if: inputs.channel == 'stable' && !inputs.dry_run + run: git push origin HEAD:master --follow-tags + + - name: Create GitHub Release + if: inputs.channel == 'stable' && !inputs.dry_run + env: + GH_TOKEN: ${{ github.token }} + run: | + version="$(git tag --points-at HEAD | grep '^v' | head -1 | sed 's/^v//')" + if [ -z "$version" ]; then + echo "Error: no v* tag points at HEAD after stable release." >&2 + exit 1 + fi + ./scripts/create-github-release.sh "$version" diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 29ac7291..fad105d6 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -1,196 +1,119 @@ # Publishing to npm -This document covers how to build and publish the `paperclipai` CLI package to npm. +Low-level reference for how Paperclip packages are built for npm. -## Prerequisites +For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This document is only about packaging internals and the scripts that produce publishable artifacts. -- Node.js 20+ -- pnpm 9.15+ -- An npm account with publish access to the `paperclipai` package -- Logged in to npm: `npm login` +## Current Release Entry Points -## One-Command Publish +Use these scripts instead of older one-off publish commands: -The fastest way to publish — bumps version, builds, publishes, restores, commits, and tags in one shot: +- [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push -```bash -./scripts/bump-and-publish.sh patch # 0.1.1 → 0.1.2 -./scripts/bump-and-publish.sh minor # 0.1.1 → 0.2.0 -./scripts/bump-and-publish.sh major # 0.1.1 → 1.0.0 -./scripts/bump-and-publish.sh 2.0.0 # set explicit version -./scripts/bump-and-publish.sh patch --dry-run # everything except npm publish -``` +## Why the CLI needs special packaging -The script runs all 6 steps below in order. It requires a clean working tree and an active `npm login` session (unless `--dry-run`). After it finishes, push: +The CLI package, `paperclipai`, imports code from workspace packages such as: -```bash -git push && git push origin v -``` +- `@paperclipai/server` +- `@paperclipai/db` +- `@paperclipai/shared` +- adapter packages under `packages/adapters/` -## Manual Step-by-Step +Those workspace references use `workspace:*` during development. npm cannot install those references directly for end users, so the release build has to transform the CLI into a publishable standalone package. -If you prefer to run each step individually: +## `build-npm.sh` -### Quick Reference - -```bash -# Bump version -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 - -# Build -./scripts/build-npm.sh - -# Preview what will be published -cd cli && npm pack --dry-run - -# Publish -cd cli && npm publish --access public - -# Restore dev package.json -mv cli/package.dev.json cli/package.json -``` - -## Step-by-Step - -### 1. Bump the version - -```bash -./scripts/version-bump.sh -``` - -This updates the version in two places: - -- `cli/package.json` — the source of truth -- `cli/src/index.ts` — the Commander `.version()` call - -Examples: - -```bash -./scripts/version-bump.sh patch # 0.1.0 → 0.1.1 -./scripts/version-bump.sh minor # 0.1.0 → 0.2.0 -./scripts/version-bump.sh major # 0.1.0 → 1.0.0 -./scripts/version-bump.sh 1.2.3 # set explicit version -``` - -### 2. Build +Run: ```bash ./scripts/build-npm.sh ``` -The build script runs five steps: +This script does six things: -1. **Forbidden token check** — scans tracked files for tokens listed in `.git/hooks/forbidden-tokens.txt`. If the file is missing (e.g. on a contributor's machine), the check passes silently. The script never prints which tokens it's searching for. -2. **TypeScript type-check** — runs `pnpm -r typecheck` across all workspace packages. -3. **esbuild bundle** — bundles the CLI entry point (`cli/src/index.ts`) and all workspace package code (`@paperclipai/*`) into a single file at `cli/dist/index.js`. External npm dependencies (express, postgres, etc.) are kept as regular imports. -4. **Generate publishable package.json** — replaces `cli/package.json` with a version that has real npm dependency ranges instead of `workspace:*` references (see [package.dev.json](#packagedevjson) below). -5. **Summary** — prints the bundle size and next steps. +1. Runs the forbidden token check unless `--skip-checks` is supplied +2. Runs `pnpm -r typecheck` +3. Bundles the CLI entrypoint with esbuild into `cli/dist/index.js` +4. Verifies the bundled entrypoint with `node --check` +5. Rewrites `cli/package.json` into a publishable npm manifest and stores the dev copy as `cli/package.dev.json` +6. Copies the repo `README.md` into `cli/README.md` for npm package metadata -To skip the forbidden token check (e.g. in CI without the token list): +`build-npm.sh` is used by the release script so that npm users install a real package rather than unresolved workspace dependencies. + +## Publishable CLI layout + +During development, [`cli/package.json`](../cli/package.json) contains workspace references. + +During release preparation: + +- `cli/package.json` becomes a publishable manifest with external npm dependency ranges +- `cli/package.dev.json` stores the development manifest temporarily +- `cli/dist/index.js` contains the bundled CLI entrypoint +- `cli/README.md` is copied in for npm metadata + +After release finalization, the release script restores the development manifest and removes the temporary README copy. + +## Package discovery + +The release tooling scans the workspace for public packages under: + +- `packages/` +- `server/` +- `cli/` + +`ui/` remains ignored for npm publishing because it is private. + +This matters because all public packages are versioned and published together as one release unit. + +## Canary packaging model + +Canaries are published as semver prereleases such as: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` + +They are published under the npm dist-tag `canary`. + +This means: + +- `npx paperclipai@canary onboard` can install them explicitly +- `npx paperclipai onboard` continues to resolve `latest` +- the stable changelog can stay at `releases/v1.2.3.md` + +## Stable packaging model + +Stable releases publish normal semver versions such as `1.2.3` under the npm dist-tag `latest`. + +The stable publish flow also creates the local release commit and git tag. Pushing the commit/tag and creating the GitHub Release happen afterward as separate maintainer steps. + +## Rollback model + +Rollback does not unpublish packages. + +Instead, the maintainer should move the `latest` dist-tag back to the previous good stable version with: ```bash -./scripts/build-npm.sh --skip-checks +./scripts/rollback-latest.sh ``` -### 3. Preview (optional) +That keeps history intact while restoring the default install path quickly. -See what npm will publish: +## Notes for CI -```bash -cd cli && npm pack --dry-run -``` +The repo includes a manual GitHub Actions release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). -### 4. Publish +Recommended CI release setup: -```bash -cd cli && npm publish --access public -``` +- use npm trusted publishing via GitHub OIDC +- require approval through the `npm-release` environment +- run releases from `master` +- use canary first, then stable -### 5. Restore dev package.json +## Related Files -After publishing, restore the workspace-aware `package.json`: - -```bash -mv cli/package.dev.json cli/package.json -``` - -### 6. Commit and tag - -```bash -git add cli/package.json cli/src/index.ts -git commit -m "chore: bump version to X.Y.Z" -git tag vX.Y.Z -``` - -## package.dev.json - -During development, `cli/package.json` contains `workspace:*` references like: - -```json -{ - "dependencies": { - "@paperclipai/server": "workspace:*", - "@paperclipai/db": "workspace:*" - } -} -``` - -These tell pnpm to resolve those packages from the local monorepo. This is great for development but **npm doesn't understand `workspace:*`** — publishing with these references would cause install failures for users. - -The build script solves this with a two-file swap: - -1. **Before building:** `cli/package.json` has `workspace:*` refs (the dev version). -2. **During build (`build-npm.sh` step 4):** - - The dev `package.json` is copied to `package.dev.json` as a backup. - - `generate-npm-package-json.mjs` reads every workspace package's `package.json`, collects all their external npm dependencies, and writes a new `cli/package.json` with those real dependency ranges — no `workspace:*` refs. -3. **After publishing:** you restore the dev version with `mv package.dev.json package.json`. - -The generated publishable `package.json` looks like: - -```json -{ - "name": "paperclipai", - "version": "0.1.0", - "bin": { "paperclipai": "./dist/index.js" }, - "dependencies": { - "express": "^5.1.0", - "postgres": "^3.4.5", - "commander": "^13.1.0" - } -} -``` - -`package.dev.json` is listed in `.gitignore` — it only exists temporarily on disk during the build/publish cycle. - -## How the bundle works - -The CLI is a monorepo package that imports code from `@paperclipai/server`, `@paperclipai/db`, `@paperclipai/shared`, and several adapter packages. These workspace packages don't exist on npm. - -**esbuild** bundles all workspace TypeScript code into a single `dist/index.js` file (~250kb). External npm packages (express, postgres, zod, etc.) are left as normal `import` statements — they get installed by npm when a user runs `npx paperclipai onboard`. - -The esbuild configuration lives at `cli/esbuild.config.mjs`. It automatically reads every workspace package's `package.json` to determine which dependencies are external (real npm packages) vs. internal (workspace code to bundle). - -## Forbidden token enforcement - -The build process includes the same forbidden-token check used by the git pre-commit hook. This catches any accidentally committed tokens before they reach npm. - -- Token list: `.git/hooks/forbidden-tokens.txt` (one token per line, `#` comments supported) -- The file lives inside `.git/` and is never committed -- If the file is missing, the check passes — contributors without the list can still build -- The script never prints which tokens are being searched for -- Matches are printed so you know which files to fix, but not which token triggered it - -Run the check standalone: - -```bash -pnpm check:tokens -``` - -## npm scripts reference - -| Script | Command | Description | -|---|---|---| -| `bump-and-publish` | `pnpm bump-and-publish ` | One-command bump + build + publish + commit + tag | -| `build:npm` | `pnpm build:npm` | Full build (check + typecheck + bundle + package.json) | -| `version:bump` | `pnpm version:bump ` | Bump CLI version | -| `check:tokens` | `pnpm check:tokens` | Run forbidden token check only | +- [`scripts/build-npm.sh`](../scripts/build-npm.sh) +- [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) +- [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASING.md b/doc/RELEASING.md new file mode 100644 index 00000000..cab82cbe --- /dev/null +++ b/doc/RELEASING.md @@ -0,0 +1,437 @@ +# Releasing Paperclip + +Maintainer runbook for shipping a full Paperclip release across npm, GitHub, and the website-facing changelog surface. + +This document is intentionally practical: + +- TL;DR command sequences are at the top. +- Detailed checklists come next. +- Motivation, failure handling, and rollback playbooks follow after that. + +## Release Surfaces + +Every Paperclip release has four separate surfaces: + +1. **Verification** — the exact git SHA must pass typecheck, tests, and build. +2. **npm** — `paperclipai` and the public workspace packages are published. +3. **GitHub** — the stable release gets a git tag and a GitHub Release. +4. **Website / announcements** — the stable changelog is published externally and announced. + +Treat those as related but separate. npm can succeed while the GitHub Release is still pending. GitHub can be correct while the website changelog is stale. A maintainer release is done only when all four surfaces are handled. + +## TL;DR + +### Canary release + +Use this when you want an installable prerelease without changing `latest`. + +```bash +# 0. Start clean +git status --short + +# 1. Verify the candidate SHA +pnpm -r typecheck +pnpm test:run +pnpm build + +# 2. Draft or update the stable changelog +# releases/vX.Y.Z.md + +# 3. Preview the canary release +./scripts/release.sh patch --canary --dry-run + +# 4. Publish the canary +./scripts/release.sh patch --canary + +# 5. Smoke test what users will actually install +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh + +# Users install with: +npx paperclipai@canary onboard +``` + +Result: + +- npm gets a prerelease such as `1.2.3-canary.0` under dist-tag `canary` +- `latest` is unchanged +- no git tag is created +- no GitHub Release is created +- the working tree returns to clean after the script finishes + +### Stable release + +Use this only after the canary SHA is good enough to become the public default. + +```bash +# 0. Start from the vetted commit +git checkout master +git pull +git status --short + +# 1. Verify again on the exact release SHA +pnpm -r typecheck +pnpm test:run +pnpm build + +# 2. Confirm the stable changelog exists +ls releases/v*.md + +# 3. Preview the stable publish +./scripts/release.sh patch --dry-run + +# 4. Publish the stable release to npm and create the local release commit + tag +./scripts/release.sh patch + +# 5. Push the release commit and tag +git push origin HEAD:master --follow-tags + +# 6. Create or update the GitHub Release from the pushed tag +./scripts/create-github-release.sh X.Y.Z +``` + +Result: + +- npm gets stable `X.Y.Z` under dist-tag `latest` +- a local git commit and tag `vX.Y.Z` are created +- after push, GitHub gets the matching Release +- the website and announcement steps still need to be handled manually + +### Emergency rollback + +If `latest` is broken after publish, repoint it to the last known good stable version first, then work on the fix. + +```bash +# Preview +./scripts/rollback-latest.sh X.Y.Z --dry-run + +# Roll back latest for every public package +./scripts/rollback-latest.sh X.Y.Z +``` + +This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. + +### GitHub Actions release + +There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. + +Use it from the Actions tab: + +1. Choose `Release` +2. Choose `channel`: `canary` or `stable` +3. Choose `bump`: `patch`, `minor`, or `major` +4. Choose whether this is a `dry_run` +5. Run it from `master` + +The workflow: + +- reruns `typecheck`, `test:run`, and `build` +- gates publish behind the `npm-release` environment +- can publish canaries without touching `latest` +- can publish stable, push the release commit and tag, and create the GitHub Release + +## Release Checklist + +### Before any publish + +- [ ] The working tree is clean, including untracked files +- [ ] The target branch and SHA are the ones you actually want to release +- [ ] The required verification gate passed on that exact SHA +- [ ] The bump type is correct for the user-visible impact +- [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` +- [ ] You know which previous stable version you would roll back to if needed + +### Before a canary + +- [ ] You are intentionally testing something that should be installable before it becomes default +- [ ] You are comfortable with users installing it via `npx paperclipai@canary onboard` +- [ ] You understand that each canary is a new immutable npm version such as `1.2.3-canary.1` + +### Before a stable + +- [ ] The candidate has already passed smoke testing +- [ ] The changelog should be the stable version only, for example `v1.2.3` +- [ ] You are ready to push the release commit and tag immediately after npm publish +- [ ] You are ready to create the GitHub Release immediately after the push +- [ ] You have a post-release website / announcement plan + +### After a stable + +- [ ] `npm view paperclipai@latest version` matches the new stable version +- [ ] The git tag exists on GitHub +- [ ] The GitHub Release exists and uses `releases/vX.Y.Z.md` +- [ ] The website changelog is updated +- [ ] Any announcement copy matches the shipped release, not the canary + +## Verification Gate + +The repository standard is: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. + +## Versioning Policy + +### Stable versions + +Stable releases use normal semver: + +- `patch` for bug fixes +- `minor` for additive features, endpoints, and additive migrations +- `major` for destructive migrations, removed APIs, or other breaking behavior + +### Canary versions + +Canaries are semver prereleases of the **intended stable version**: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +That gives you three useful properties: + +1. Users can install the prerelease explicitly with `@canary` +2. `latest` stays safe +3. The stable changelog can remain just `v1.2.3` + +We do **not** create separate changelog files for canary versions. + +## Changelog Policy + +The maintainer changelog source of truth is: + +- `releases/vX.Y.Z.md` + +That file is for the eventual stable release. It should not include `-canary` in the filename or heading. + +Recommended structure: + +- `Breaking Changes` when needed +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed + +Package-level `CHANGELOG.md` files are generated as part of the release mechanics. They are not the main release narrative. + +## Detailed Workflow + +### 1. Decide the bump + +Review the range since the last stable tag: + +```bash +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges +git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ +git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ +git log "${LAST_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +``` + +Use the higher bump if there is any doubt. + +### 2. Write the stable changelog first + +Create or update: + +```bash +releases/vX.Y.Z.md +``` + +This is deliberate. The release notes should describe the stable story, not the canary mechanics. + +### 3. Publish one or more canaries + +Run: + +```bash +./scripts/release.sh --canary +``` + +What the script does: + +1. Verifies the working tree is clean +2. Computes the intended stable version from the last stable tag +3. Computes the next canary ordinal from npm +4. Versions the public packages to `X.Y.Z-canary.N` +5. Builds the workspace and publishable CLI +6. Publishes to npm under dist-tag `canary` +7. Cleans up the temporary versioning state so your branch returns to clean + +This means the script is safe to repeat as many times as needed while iterating: + +- `1.2.3-canary.0` +- `1.2.3-canary.1` +- `1.2.3-canary.2` + +The target stable release can still remain `1.2.3`. + +### 4. Smoke test the canary + +Run the actual install path in Docker: + +```bash +PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + +Minimum checks: + +- [ ] `npx paperclipai@canary onboard` installs +- [ ] onboarding completes without crashes +- [ ] the server boots +- [ ] the UI loads +- [ ] basic company creation and dashboard load work + +### 5. Publish stable from the vetted commit + +Once the candidate SHA is good, run the stable flow on that exact commit: + +```bash +./scripts/release.sh +``` + +What the script does: + +1. Verifies the working tree is clean +2. Versions the public packages to the stable semver +3. Builds the workspace and CLI publish bundle +4. Publishes to npm under `latest` +5. Restores temporary publish artifacts +6. Creates the local release commit and git tag + +What it does **not** do: + +- it does not push for you +- it does not update the website +- it does not announce the release for you + +### 6. Push the release and create the GitHub Release + +After a stable publish succeeds: + +```bash +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z +``` + +The GitHub release notes come from: + +- `releases/vX.Y.Z.md` + +### 7. Complete the external surfaces + +After GitHub is correct: + +- publish the changelog on the website +- write the announcement copy +- ensure public docs and install guidance point to the stable version + +## GitHub Actions and npm Trusted Publishing + +If you want GitHub to own the actual npm publish, use [`.github/workflows/release.yml`](../.github/workflows/release.yml) together with npm trusted publishing. + +Recommended setup: + +1. Configure the GitHub Actions workflow as a trusted publisher for **every public package** on npm +2. Use the `npm-release` GitHub environment with required reviewers +3. Run stable publishes from `master` only +4. Keep the workflow manual via `workflow_dispatch` + +Why this is the right shape: + +- no long-lived npm token needs to live in GitHub secrets +- reviewers can approve the publish step at the environment gate +- the workflow reruns verification on the release SHA before publish +- stable and canary use the same mechanics + +## Failure Playbooks + +### If the canary fails before publish + +Nothing shipped. Fix the code and rerun the canary workflow. + +### If the canary publishes but the smoke test fails + +Do **not** publish stable. + +Instead: + +1. Fix the issue +2. Publish another canary +3. Re-run smoke testing + +The canary version number will increase, but the stable target version can remain the same. + +### If the stable npm publish succeeds but push fails + +This is a partial release. npm is already live. + +Do this immediately: + +1. Fix the git issue +2. Push the release commit and tag from the same checkout +3. Create the GitHub Release + +Do **not** publish the same version again. + +### If the stable release is bad after `latest` moves + +Use the rollback script first: + +```bash +./scripts/rollback-latest.sh +``` + +Then: + +1. open an incident note or maintainer comment +2. fix forward on a new patch release +3. update the changelog / release notes if the user-facing guidance changed + +### If the GitHub Release is wrong + +Edit it by re-running: + +```bash +./scripts/create-github-release.sh X.Y.Z +``` + +This updates the release notes if the GitHub Release already exists. + +### If the website changelog is wrong + +Fix the website independently. Do not republish npm just to repair the website surface. + +## Rollback Strategy + +The default rollback strategy is **dist-tag rollback, then fix forward**. + +Why: + +- npm versions are immutable +- users need `npx paperclipai onboard` to recover quickly +- moving `latest` back is faster and safer than trying to delete history + +Rollback procedure: + +1. identify the last known good stable version +2. run `./scripts/rollback-latest.sh ` +3. verify `npm view paperclipai@latest version` +4. fix forward with a new stable release + +## Scripts Reference + +- [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push +- [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release +- [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI + +## Related Docs + +- [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals +- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow +- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/package.json b/package.json index e19fb785..737438ec 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:github": "./scripts/create-github-release.sh", + "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", "version-packages": "changeset version", "check:tokens": "node scripts/check-forbidden-tokens.mjs", diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh new file mode 100755 index 00000000..4d1d0789 --- /dev/null +++ b/scripts/create-github-release.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/create-github-release.sh [--dry-run] + +Examples: + ./scripts/create-github-release.sh 1.2.3 + ./scripts/create-github-release.sh 1.2.3 --dry-run + +Notes: + - Run this after pushing the release commit and tag. + - If the release already exists, this script updates its title and notes. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.3." >&2 + exit 1 +fi + +tag="v$version" +notes_file="$REPO_ROOT/releases/${tag}.md" + +if ! command -v gh >/dev/null 2>&1; then + echo "Error: gh CLI is required to create GitHub releases." >&2 + exit 1 +fi + +if [ ! -f "$notes_file" ]; then + echo "Error: release notes file not found at $notes_file." >&2 + exit 1 +fi + +if ! git -C "$REPO_ROOT" rev-parse "$tag" >/dev/null 2>&1; then + echo "Error: local git tag $tag does not exist." >&2 + exit 1 +fi + +if [ "$dry_run" = true ]; then + echo "[dry-run] gh release create $tag --title $tag --notes-file $notes_file" + exit 0 +fi + +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then + echo "Error: remote tag $tag was not found on origin. Push the release commit and tag first." >&2 + exit 1 +fi + +if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file "$notes_file" + echo "Updated GitHub Release $tag" +else + gh release create "$tag" --title "$tag" --notes-file "$notes_file" + echo "Created GitHub Release $tag" +fi diff --git a/scripts/release.sh b/scripts/release.sh index 3668d87c..4908912c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,420 +1,460 @@ #!/usr/bin/env bash set -euo pipefail -# release.sh — One-command version bump, build, and publish via Changesets. +# release.sh — Prepare and publish a Paperclip release. # -# Usage: -# ./scripts/release.sh patch # 0.2.0 → 0.2.1 -# ./scripts/release.sh minor # 0.2.0 → 0.3.0 -# ./scripts/release.sh major # 0.2.0 → 1.0.0 -# ./scripts/release.sh patch --dry-run # everything except npm publish -# ./scripts/release.sh patch --canary # publish under @canary tag, no commit/tag -# ./scripts/release.sh patch --canary --dry-run -# ./scripts/release.sh --promote 0.2.8 # promote canary to @latest + commit/tag -# ./scripts/release.sh --promote 0.2.8 --dry-run +# Stable release: +# ./scripts/release.sh patch +# ./scripts/release.sh minor --dry-run # -# Steps (normal): -# 1. Preflight checks (clean tree, npm login) -# 2. Auto-create a changeset for all public packages -# 3. Run changeset version (bumps versions, generates CHANGELOGs) -# 4. Build all packages -# 5. Build CLI bundle (esbuild) -# 6. Publish to npm via changeset publish (unless --dry-run) -# 7. Commit and tag +# Canary release: +# ./scripts/release.sh patch --canary +# ./scripts/release.sh minor --canary --dry-run # -# --canary: Steps 1-5 unchanged, Step 6 publishes with --tag canary, Step 7 skipped. -# --promote: Skips Steps 1-6, promotes canary to latest, then commits and tags. +# Canary releases publish prerelease versions such as 1.2.3-canary.0 under the +# npm dist-tag "canary". Stable releases publish 1.2.3 under "latest". REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" - -# ── Helper: create GitHub Release ──────────────────────────────────────────── -create_github_release() { - local version="$1" - local is_dry_run="$2" - local release_notes="$REPO_ROOT/releases/v${version}.md" - - if [ "$is_dry_run" = true ]; then - echo " [dry-run] gh release create v$version" - return - fi - - if ! command -v gh &>/dev/null; then - echo " ⚠ gh CLI not found — skipping GitHub Release" - return - fi - - local gh_args=(gh release create "v$version" --title "v$version") - if [ -f "$release_notes" ]; then - gh_args+=(--notes-file "$release_notes") - else - gh_args+=(--generate-notes) - fi - - if "${gh_args[@]}"; then - echo " ✓ Created GitHub Release v$version" - else - echo " ⚠ GitHub Release creation failed (non-fatal)" - fi -} - -# ── Parse args ──────────────────────────────────────────────────────────────── +TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" dry_run=false canary=false -promote=false -promote_version="" bump_type="" +cleanup_on_exit=false + +usage() { + cat <<'EOF' +Usage: + ./scripts/release.sh [--canary] [--dry-run] + +Examples: + ./scripts/release.sh patch + ./scripts/release.sh minor --dry-run + ./scripts/release.sh patch --canary + ./scripts/release.sh minor --canary --dry-run + +Notes: + - Canary publishes prerelease versions like 1.2.3-canary.0 under the npm + dist-tag "canary". + - Stable publishes 1.2.3 under the npm dist-tag "latest". + - Dry runs leave the working tree clean. +EOF +} + while [ $# -gt 0 ]; do case "$1" in --dry-run) dry_run=true ;; --canary) canary=true ;; + -h|--help) + usage + exit 0 + ;; --promote) - promote=true - shift - if [ $# -eq 0 ] || [[ "$1" == --* ]]; then - echo "Error: --promote requires a version argument (e.g. --promote 0.2.8)" + echo "Error: --promote was removed. Re-run a stable release from the vetted commit instead." + exit 1 + ;; + *) + if [ -n "$bump_type" ]; then + echo "Error: only one bump type may be provided." exit 1 fi - promote_version="$1" + bump_type="$1" ;; - *) bump_type="$1" ;; esac shift done -if [ "$promote" = true ] && [ "$canary" = true ]; then - echo "Error: --canary and --promote cannot be used together" +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage exit 1 fi -if [ "$promote" = false ]; then - if [ -z "$bump_type" ]; then - echo "Usage: $0 [--dry-run] [--canary]" - echo " $0 --promote [--dry-run]" - exit 1 - fi - - if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then - echo "Error: bump type must be patch, minor, or major (got '$bump_type')" - exit 1 - fi -fi - -# ── Promote mode (skips Steps 1-6) ─────────────────────────────────────────── - -if [ "$promote" = true ]; then - NEW_VERSION="$promote_version" - echo "" - echo "==> Promote mode: promoting v$NEW_VERSION from canary to latest..." - - # Get all publishable package names - PACKAGES=$(node -e " -const { readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} +info() { + echo "$@" } -console.log(names.join('\n')); -") - echo "" - echo " Promoting packages to @latest:" - while IFS= read -r pkg; do - if [ "$dry_run" = true ]; then - echo " [dry-run] npm dist-tag add ${pkg}@${NEW_VERSION} latest" - else - npm dist-tag add "${pkg}@${NEW_VERSION}" latest - echo " ✓ ${pkg}@${NEW_VERSION} → latest" - fi - done <<< "$PACKAGES" +fail() { + echo "Error: $*" >&2 + exit 1 +} - # Restore CLI dev package.json if present +restore_publish_artifacts() { if [ -f "$CLI_DIR/package.dev.json" ]; then mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" fi - # Remove the README copied for npm publishing - if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" - fi - - # Remove temporary build artifacts + rm -f "$CLI_DIR/README.md" rm -rf "$REPO_ROOT/server/ui-dist" + for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" done - - # Stage release files, commit, and tag - echo "" - echo " Committing and tagging v$NEW_VERSION..." - if [ "$dry_run" = true ]; then - echo " [dry-run] git add + commit + tag v$NEW_VERSION" - else - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" - fi - - create_github_release "$NEW_VERSION" "$dry_run" - - echo "" - if [ "$dry_run" = true ]; then - echo "Dry run complete for promote v$NEW_VERSION." - echo " - Would promote all packages to @latest" - echo " - Would commit and tag v$NEW_VERSION" - echo " - Would create GitHub Release" - else - echo "Promoted all packages to @latest at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@latest version" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - fi - exit 0 -fi - -# ── Step 1: Preflight checks ───────────────────────────────────────────────── - -echo "" -echo "==> Step 1/7: Preflight checks..." - -if [ "$dry_run" = false ]; then - if ! npm whoami &>/dev/null; then - echo "Error: Not logged in to npm. Run 'npm login' first." - exit 1 - fi - echo " ✓ Logged in to npm as $(npm whoami)" -fi - -if ! git -C "$REPO_ROOT" diff --quiet || ! git -C "$REPO_ROOT" diff --cached --quiet; then - echo "Error: Working tree has uncommitted changes. Commit or stash them first." - exit 1 -fi -echo " ✓ Working tree is clean" - -# ── Step 2: Auto-create changeset ──────────────────────────────────────────── - -echo "" -echo "==> Step 2/7: Creating changeset ($bump_type bump for all packages)..." - -# Get all publishable (non-private) package names -PACKAGES=$(node -e " -const { readdirSync, readFileSync } = require('fs'); -const { resolve } = require('path'); -const root = '$REPO_ROOT'; -const wsYaml = readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8'); -const dirs = ['packages/shared', 'packages/adapter-utils', 'packages/db', - 'packages/adapters/claude-local', 'packages/adapters/codex-local', 'packages/adapters/opencode-local', 'packages/adapters/openclaw-gateway', - 'server', 'cli']; -const names = []; -for (const d of dirs) { - try { - const pkg = JSON.parse(readFileSync(resolve(root, d, 'package.json'), 'utf8')); - if (!pkg.private) names.push(pkg.name); - } catch {} } -console.log(names.join('\n')); -") -# Write a changeset file -CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" +cleanup_release_state() { + restore_publish_artifacts + + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + git -C "$REPO_ROOT" restore --source=HEAD --staged --worktree . + rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + fi +} + +if [ "$cleanup_on_exit" = true ]; then + trap cleanup_release_state EXIT +fi + +set_cleanup_trap() { + cleanup_on_exit=true + trap cleanup_release_state EXIT +} + +require_clean_worktree() { + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + fail "working tree is not clean. Commit, stash, or remove changes before releasing." + fi +} + +require_npm_publish_auth() { + if [ "$dry_run" = true ]; then + return + fi + + if npm whoami >/dev/null 2>&1; then + info " ✓ Logged in to npm as $(npm whoami)" + return + fi + + if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + info " ✓ npm publish auth will be provided by GitHub Actions trusted publishing" + return + fi + + fail "npm publish auth is not available. Use 'npm login' locally or run from the GitHub release workflow." +} + +list_public_package_info() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); +const rows = []; + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private) { + rows.push([relDir, pkg.name]); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} + +rows.sort((a, b) => a[0].localeCompare(b[0])); + +for (const [dir, name] of rows) { + const key = `${dir}\t${name}`; + if (seen.has(key)) continue; + seen.add(key); + process.stdout.write(`${dir}\t${name}\n`); +} +NODE +} + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +replace_version_string() { + local from_version="$1" + local to_version="$2" + + node - "$REPO_ROOT" "$from_version" "$to_version" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const fromVersion = process.argv[3]; +const toVersion = process.argv[4]; + +const roots = ['packages', 'server', 'ui', 'cli']; +const targets = new Set(['package.json', 'CHANGELOG.md']); +const extraFiles = [path.join('cli', 'src', 'index.ts')]; + +function rewriteFile(filePath) { + if (!fs.existsSync(filePath)) return; + const current = fs.readFileSync(filePath, 'utf8'); + if (!current.includes(fromVersion)) return; + fs.writeFileSync(filePath, current.split(fromVersion).join(toVersion)); +} + +function walk(relDir) { + const absDir = path.join(root, relDir); + if (!fs.existsSync(absDir)) return; + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + continue; + } + + if (targets.has(entry.name)) { + rewriteFile(path.join(absDir, entry.name)); + } + } +} + +for (const rel of roots) { + walk(rel); +} + +for (const relFile of extraFiles) { + rewriteFile(path.join(root, relFile)); +} +NODE +} + +LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" +CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" +if [ -z "$CURRENT_STABLE_VERSION" ]; then + CURRENT_STABLE_VERSION="0.0.0" +fi + +TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" +TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION" + +if [ "$canary" = true ]; then + TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +fi + +PUBLIC_PACKAGE_INFO="$(list_public_package_info)" +PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" +PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" + +if [ -z "$PUBLIC_PACKAGE_INFO" ]; then + fail "no public packages were found in the workspace." +fi + +info "" +info "==> Release plan" +info " Last stable tag: ${LAST_STABLE_TAG:-}" +info " Current stable version: $CURRENT_STABLE_VERSION" +if [ "$canary" = true ]; then + info " Target stable version: $TARGET_STABLE_VERSION" + info " Canary version: $TARGET_PUBLISH_VERSION" +else + info " Stable version: $TARGET_STABLE_VERSION" +fi + +info "" +info "==> Step 1/7: Preflight checks..." +require_clean_worktree +info " ✓ Working tree is clean" +require_npm_publish_auth + +if [ "$dry_run" = true ] || [ "$canary" = true ]; then + set_cleanup_trap +fi + +info "" +info "==> Step 2/7: Creating release changeset..." { echo "---" - while IFS= read -r pkg; do - echo "\"$pkg\": $bump_type" - done <<< "$PACKAGES" + while IFS= read -r pkg_name; do + [ -z "$pkg_name" ] && continue + echo "\"$pkg_name\": $bump_type" + done <<< "$PUBLIC_PACKAGE_NAMES" echo "---" echo "" - echo "Version bump ($bump_type)" -} > "$CHANGESET_FILE" + if [ "$canary" = true ]; then + echo "Canary release preparation for $TARGET_STABLE_VERSION" + else + echo "Stable release preparation for $TARGET_STABLE_VERSION" + fi +} > "$TEMP_CHANGESET_FILE" +info " ✓ Created release changeset for $(printf '%s\n' "$PUBLIC_PACKAGE_NAMES" | sed '/^$/d' | wc -l | xargs) packages" -echo " ✓ Created changeset for $(echo "$PACKAGES" | wc -l | xargs) packages" - -# ── Step 3: Version packages ───────────────────────────────────────────────── - -echo "" -echo "==> Step 3/7: Running changeset version..." +info "" +info "==> Step 3/7: Versioning packages..." cd "$REPO_ROOT" +if [ "$canary" = true ]; then + npx changeset pre enter canary +fi npx changeset version -echo " ✓ Versions bumped and CHANGELOGs generated" -# Read the new version from the CLI package -NEW_VERSION=$(node -e "console.log(require('$CLI_DIR/package.json').version)") -echo " New version: $NEW_VERSION" - -# Update the version string in cli/src/index.ts -CURRENT_VERSION_IN_SRC=$(sed -n 's/.*\.version("\([^"]*\)".*/\1/p' "$CLI_DIR/src/index.ts" | head -1) -if [ -n "$CURRENT_VERSION_IN_SRC" ] && [ "$CURRENT_VERSION_IN_SRC" != "$NEW_VERSION" ]; then - sed -i '' "s/\.version(\"$CURRENT_VERSION_IN_SRC\")/\.version(\"$NEW_VERSION\")/" "$CLI_DIR/src/index.ts" - echo " ✓ Updated cli/src/index.ts version to $NEW_VERSION" +if [ "$canary" = true ]; then + BASE_CANARY_VERSION="${TARGET_STABLE_VERSION}-canary.0" + if [ "$TARGET_PUBLISH_VERSION" != "$BASE_CANARY_VERSION" ]; then + replace_version_string "$BASE_CANARY_VERSION" "$TARGET_PUBLISH_VERSION" + fi fi -# ── Step 4: Build packages ─────────────────────────────────────────────────── +VERSION_IN_CLI_PACKAGE="$(node -e "console.log(require('$CLI_DIR/package.json').version)")" +if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then + fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE." +fi +info " ✓ Versioned workspace to $TARGET_PUBLISH_VERSION" -echo "" -echo "==> Step 4/7: Building all packages..." +info "" +info "==> Step 4/7: Building workspace artifacts..." cd "$REPO_ROOT" - -# Build packages in dependency order (excluding CLI) -pnpm --filter @paperclipai/shared build -pnpm --filter @paperclipai/adapter-utils build -pnpm --filter @paperclipai/db build -pnpm --filter @paperclipai/adapter-claude-local build -pnpm --filter @paperclipai/adapter-codex-local build -pnpm --filter @paperclipai/adapter-opencode-local build -pnpm --filter @paperclipai/adapter-openclaw-gateway build -pnpm --filter @paperclipai/server build - -# Build UI and bundle into server package for static serving +pnpm build bash "$REPO_ROOT/scripts/prepare-server-ui-dist.sh" - -# Bundle skills into packages that need them (adapters + server) for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do rm -rf "$REPO_ROOT/$pkg_dir/skills" cp -r "$REPO_ROOT/skills" "$REPO_ROOT/$pkg_dir/skills" done -echo " ✓ All packages built (including UI + skills)" +info " ✓ Workspace build complete" -# ── Step 5: Build CLI bundle ───────────────────────────────────────────────── - -echo "" -echo "==> Step 5/7: Building CLI bundle..." -cd "$REPO_ROOT" +info "" +info "==> Step 5/7: Building publishable CLI bundle..." "$REPO_ROOT/scripts/build-npm.sh" --skip-checks -echo " ✓ CLI bundled" - -# ── Step 6: Publish ────────────────────────────────────────────────────────── +info " ✓ CLI bundle ready" +info "" if [ "$dry_run" = true ]; then - echo "" - if [ "$canary" = true ]; then - echo "==> Step 6/7: Skipping publish (--dry-run, --canary)" - else - echo "==> Step 6/7: Skipping publish (--dry-run)" - fi - echo "" - echo " Preview what would be published:" - for dir in packages/shared packages/adapter-utils packages/db \ - packages/adapters/claude-local packages/adapters/codex-local packages/adapters/opencode-local packages/adapters/openclaw-gateway \ - server cli; do - echo " --- $dir ---" - cd "$REPO_ROOT/$dir" + info "==> Step 6/7: Previewing publish payloads (--dry-run)..." + while IFS= read -r pkg_dir; do + [ -z "$pkg_dir" ] && continue + info " --- $pkg_dir ---" + cd "$REPO_ROOT/$pkg_dir" npm pack --dry-run 2>&1 | tail -3 - done + done <<< "$PUBLIC_PACKAGE_DIRS" cd "$REPO_ROOT" if [ "$canary" = true ]; then - echo "" - echo " [dry-run] Would publish with: npx changeset publish --tag canary" + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag canary" + else + info " [dry-run] Would publish ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi else - echo "" if [ "$canary" = true ]; then - echo "==> Step 6/7: Publishing to npm (canary)..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing canary to npm..." npx changeset publish --tag canary - echo " ✓ Published all packages under @canary tag" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else - echo "==> Step 6/7: Publishing to npm..." - cd "$REPO_ROOT" + info "==> Step 6/7: Publishing stable release to npm..." npx changeset publish - echo " ✓ Published all packages" + info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag latest" fi fi -# ── Step 7: Restore CLI dev package.json and commit ────────────────────────── - -echo "" -if [ "$canary" = true ]; then - echo "==> Step 7/7: Skipping commit and tag (canary mode — promote later)..." +info "" +if [ "$dry_run" = true ]; then + info "==> Step 7/7: Cleaning up dry-run state..." + info " ✓ Dry run leaves the working tree unchanged" +elif [ "$canary" = true ]; then + info "==> Step 7/7: Cleaning up canary state..." + info " ✓ Canary state will be discarded after publish" else - echo "==> Step 7/7: Restoring dev package.json, committing, and tagging..." -fi -cd "$REPO_ROOT" + info "==> Step 7/7: Finalizing stable release commit..." + restore_publish_artifacts -# Restore the dev package.json (build-npm.sh backs it up) -if [ -f "$CLI_DIR/package.dev.json" ]; then - mv "$CLI_DIR/package.dev.json" "$CLI_DIR/package.json" - echo " ✓ Restored workspace dependencies in cli/package.json" + git -C "$REPO_ROOT" add -u .changeset packages server cli + if [ -f "$REPO_ROOT/releases/v${TARGET_STABLE_VERSION}.md" ]; then + git -C "$REPO_ROOT" add "releases/v${TARGET_STABLE_VERSION}.md" + fi + + git -C "$REPO_ROOT" commit -m "chore: release v$TARGET_STABLE_VERSION" + git -C "$REPO_ROOT" tag "v$TARGET_STABLE_VERSION" + info " ✓ Created commit and tag v$TARGET_STABLE_VERSION" fi -# Remove the README copied for npm publishing -if [ -f "$CLI_DIR/README.md" ]; then - rm "$CLI_DIR/README.md" -fi - -# Remove temporary build artifacts before committing (these are only needed during publish) -rm -rf "$REPO_ROOT/server/ui-dist" -for pkg_dir in server packages/adapters/claude-local packages/adapters/codex-local; do - rm -rf "$REPO_ROOT/$pkg_dir/skills" -done - -if [ "$canary" = false ]; then - # Stage only release-related files (avoid sweeping unrelated changes with -A) - git add \ - .changeset/ \ - '**/CHANGELOG.md' \ - '**/package.json' \ - cli/src/index.ts - git commit -m "chore: release v$NEW_VERSION" - git tag "v$NEW_VERSION" - echo " ✓ Committed and tagged v$NEW_VERSION" -fi - -if [ "$canary" = false ]; then - create_github_release "$NEW_VERSION" "$dry_run" -fi - -# ── Done ────────────────────────────────────────────────────────────────────── - -echo "" -if [ "$canary" = true ]; then - if [ "$dry_run" = true ]; then - echo "Dry run complete for canary v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - No commit or tag (canary mode)" - echo "" - echo "To actually publish canary, run:" - echo " ./scripts/release.sh $bump_type --canary" +info "" +if [ "$dry_run" = true ]; then + if [ "$canary" = true ]; then + info "Dry run complete for canary ${TARGET_PUBLISH_VERSION}." else - echo "Published canary at v$NEW_VERSION" - echo "" - echo "Verify: npm view paperclipai@canary version" - echo "" - echo "To promote to latest:" - echo " ./scripts/release.sh --promote $NEW_VERSION" + info "Dry run complete for stable v${TARGET_STABLE_VERSION}." fi -elif [ "$dry_run" = true ]; then - echo "Dry run complete for v$NEW_VERSION." - echo " - Versions bumped, built, and previewed" - echo " - Dev package.json restored" - echo " - Commit and tag created (locally)" - echo " - Would create GitHub Release" - echo "" - echo "To actually publish, run:" - echo " ./scripts/release.sh $bump_type" +elif [ "$canary" = true ]; then + info "Published canary ${TARGET_PUBLISH_VERSION}." + info "Install with: npx paperclipai@canary onboard" + info "Stable version remains: $CURRENT_STABLE_VERSION" else - echo "Published all packages at v$NEW_VERSION" - echo "" - echo "To push:" - echo " git push && git push origin v$NEW_VERSION" - echo "" - echo "GitHub Release: https://github.com/cryppadotta/paperclip/releases/tag/v$NEW_VERSION" + info "Published stable v${TARGET_STABLE_VERSION}." + info "Next steps:" + info " git push origin HEAD:master --follow-tags" + info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" fi diff --git a/scripts/rollback-latest.sh b/scripts/rollback-latest.sh new file mode 100755 index 00000000..a00da984 --- /dev/null +++ b/scripts/rollback-latest.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +dry_run=false +version="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/rollback-latest.sh [--dry-run] + +Examples: + ./scripts/rollback-latest.sh 1.2.2 + ./scripts/rollback-latest.sh 1.2.2 --dry-run + +Notes: + - This repoints the npm dist-tag "latest" for every public package. + - It does not unpublish anything. +EOF +} + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry_run=true ;; + -h|--help) + usage + exit 0 + ;; + *) + if [ -n "$version" ]; then + echo "Error: only one version may be provided." >&2 + exit 1 + fi + version="$1" + ;; + esac + shift +done + +if [ -z "$version" ]; then + usage + exit 1 +fi + +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be a stable semver like 1.2.2." >&2 + exit 1 +fi + +if [ "$dry_run" = false ] && ! npm whoami >/dev/null 2>&1; then + echo "Error: npm publish rights are required. Run 'npm login' first." >&2 + exit 1 +fi + +list_public_package_names() { + node - "$REPO_ROOT" <<'NODE' +const fs = require('fs'); +const path = require('path'); + +const root = process.argv[2]; +const roots = ['packages', 'server', 'ui', 'cli']; +const seen = new Set(); + +function walk(relDir) { + const absDir = path.join(root, relDir); + const pkgPath = path.join(absDir, 'package.json'); + + if (fs.existsSync(pkgPath)) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (!pkg.private && !seen.has(pkg.name)) { + seen.add(pkg.name); + process.stdout.write(`${pkg.name}\n`); + } + return; + } + + if (!fs.existsSync(absDir)) { + return; + } + + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git') continue; + walk(path.join(relDir, entry.name)); + } +} + +for (const rel of roots) { + walk(rel); +} +NODE +} + +package_names="$(list_public_package_names)" + +if [ -z "$package_names" ]; then + echo "Error: no public packages were found in the workspace." >&2 + exit 1 +fi + +while IFS= read -r package_name; do + [ -z "$package_name" ] && continue + if [ "$dry_run" = true ]; then + echo "[dry-run] npm dist-tag add ${package_name}@${version} latest" + else + npm dist-tag add "${package_name}@${version}" latest + echo "Updated latest -> ${package_name}@${version}" + fi +done <<< "$package_names" diff --git a/skills/release-changelog/SKILL.md b/skills/release-changelog/SKILL.md index d28fa931..b70b97f5 100644 --- a/skills/release-changelog/SKILL.md +++ b/skills/release-changelog/SKILL.md @@ -1,363 +1,140 @@ --- name: release-changelog description: > - Generate user-facing release changelogs for Paperclip. Reads git history, - merged PRs, and changeset files since the last release tag. Detects breaking - changes, categorizes changes, and outputs structured markdown to - releases/v{version}.md. Use when preparing a release or when asked to - generate a changelog. + Generate the stable Paperclip release changelog at releases/v{version}.md by + reading commits, changesets, and merged PR context since the last stable tag. --- # Release Changelog Skill -Generate a user-facing changelog for a new Paperclip release. This skill reads -the commit history, changeset files, and merged PRs since the last release tag, -detects breaking changes, categorizes everything, and writes a structured -release notes file. +Generate the user-facing changelog for the **stable** Paperclip release. -**Output:** `releases/v{version}.md` in the repo root. -**Review required:** Always present the draft for human sign-off before -finalizing. Never auto-publish. +Output: ---- +- `releases/v{version}.md` + +Important rule: + +- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md` ## Step 0 — Idempotency Check -Before generating anything, check if a changelog already exists for this version: +Before generating anything, check whether the file already exists: ```bash ls releases/v{version}.md 2>/dev/null ``` -**If the file already exists:** +If it exists: -1. Read the existing changelog and present it to the reviewer. -2. Ask: "A changelog for v{version} already exists. Do you want to (a) keep it - as-is, (b) regenerate from scratch, or (c) update specific sections?" -3. If the reviewer says keep it → **stop here**. Do not overwrite. This skill is - done. -4. If the reviewer says regenerate → back up the existing file to - `releases/v{version}.md.prev`, then proceed from Step 1. -5. If the reviewer says update → read the existing file, proceed through Steps - 1-4 to gather fresh data, then merge changes into the existing file rather - than replacing it wholesale. Preserve any manual edits the reviewer previously - made. +1. read it first +2. present it to the reviewer +3. ask whether to keep it, regenerate it, or update specific sections +4. never overwrite it silently -**If the file does not exist:** Proceed normally from Step 1. +## Step 1 — Determine the Stable Range -**Critical rule:** This skill NEVER triggers a version bump. It only reads git -history and writes a markdown file. The `release.sh` script is the only thing -that bumps versions, and it is called separately by the `release` coordination -skill. Running this skill multiple times is always safe — worst case it -overwrites a draft changelog (with reviewer permission). - ---- - -## Step 1 — Determine the Release Range - -Find the last release tag and the planned version: +Find the last stable tag: ```bash -# Last release tag (most recent semver tag) -git tag --sort=-version:refname | head -1 -# e.g. v0.2.7 - -# All commits since that tag -git log v0.2.7..HEAD --oneline --no-merges +git tag --list 'v*' --sort=-version:refname | head -1 +git log v{last}..HEAD --oneline --no-merges ``` -If no tag exists yet, use the initial commit as the base. +The planned stable version comes from one of: -The new version number comes from one of: -- An explicit argument (e.g. "generate changelog for v0.3.0") -- The bump type (patch/minor/major) applied to the last tag -- The version already set in `cli/package.json` if `scripts/release.sh` has been run +- an explicit maintainer request +- the chosen bump type applied to the last stable tag +- the release plan already agreed in `doc/RELEASING.md` ---- +Do not derive the changelog version from a canary tag or prerelease suffix. -## Step 2 — Gather Raw Change Data +## Step 2 — Gather the Raw Inputs -Collect changes from three sources, in priority order: +Collect release data from: -### 2a. Git Commits +1. git commits since the last stable tag +2. `.changeset/*.md` files +3. merged PRs via `gh` when available + +Useful commands: ```bash git log v{last}..HEAD --oneline --no-merges -git log v{last}..HEAD --format="%H %s" --no-merges # full SHAs for file diffs -``` - -### 2b. Changeset Files - -Look for unconsumed changesets in `.changeset/`: - -```bash +git log v{last}..HEAD --format="%H %s" --no-merges ls .changeset/*.md | grep -v README.md -``` - -Each changeset file has YAML frontmatter with package names and bump types -(`patch`, `minor`, `major`), followed by a description. Parse these — the bump -type is a strong categorization signal, and the description may contain -user-facing summaries. - -### 2c. Merged PRs (when available) - -If GitHub access is available via `gh`: - -```bash gh pr list --state merged --search "merged:>={last-tag-date}" --json number,title,body,labels ``` -PR titles and bodies are often the best source of user-facing descriptions. -Prefer PR descriptions over raw commit messages when both are available. - ---- - ## Step 3 — Detect Breaking Changes -Scan for breaking changes using these signals. **Any match flags the release as -containing breaking changes**, which affects version bump requirements and -changelog structure. +Look for: -### 3a. Migration Files +- destructive migrations +- removed or changed API fields/endpoints +- renamed or removed config keys +- `major` changesets +- `BREAKING:` or `BREAKING CHANGE:` commit signals -Check for new migration files since the last tag: +Key commands: ```bash git diff --name-only v{last}..HEAD -- packages/db/src/migrations/ -``` - -- **New migration files exist** = DB migration required in upgrade. -- Inspect migration content: look for `DROP`, `ALTER ... DROP`, `RENAME` to - distinguish destructive vs. additive migrations. -- Additive-only migrations (new tables, new nullable columns, new indexes) are - safe but should still be mentioned. -- Destructive migrations (column drops, type changes, table drops) = breaking. - -### 3b. Schema Changes - -```bash git diff v{last}..HEAD -- packages/db/src/schema/ -``` - -Look for: -- Removed or renamed columns/tables -- Changed column types -- Removed default values or nullable constraints -- These indicate breaking DB changes even if no explicit migration file exists - -### 3c. API Route Changes - -```bash git diff v{last}..HEAD -- server/src/routes/ server/src/api/ +git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` -Look for: -- Removed endpoints -- Changed request/response shapes (removed fields, type changes) -- Changed authentication requirements +If the requested bump is lower than the minimum required bump, flag that before the release proceeds. -### 3d. Config Changes +## Step 4 — Categorize for Users -```bash -git diff v{last}..HEAD -- cli/src/config/ packages/*/src/*config* -``` +Use these stable changelog sections: -Look for renamed, removed, or restructured configuration keys. +- `Breaking Changes` +- `Highlights` +- `Improvements` +- `Fixes` +- `Upgrade Guide` when needed -### 3e. Changeset Severity +Exclude purely internal refactors, CI changes, and docs-only work unless they materially affect users. -Any `.changeset/*.md` file with a `major` bump = explicitly flagged breaking. +Guidelines: -### 3f. Commit Conventions +- group related commits into one user-facing entry +- write from the user perspective +- keep highlights short and concrete +- spell out upgrade actions for breaking changes -Scan commit messages for: -- `BREAKING:` or `BREAKING CHANGE:` prefix -- `!` after the type in conventional commits (e.g. `feat!:`, `fix!:`) +## Step 5 — Write the File -### Version Bump Rules - -| Condition | Minimum Bump | -|---|---| -| Destructive migration (DROP, RENAME) | `major` | -| Removed API endpoints or fields | `major` | -| Any `major` changeset or `BREAKING:` commit | `major` | -| New (additive) migration | `minor` | -| New features (`feat:` commits, `minor` changesets) | `minor` | -| Bug fixes only | `patch` | - -If the planned bump is lower than the minimum required, **warn the reviewer** -and recommend the correct bump level. - ---- - -## Step 4 — Categorize Changes - -Assign every meaningful change to one of these categories: - -| Category | What Goes Here | Shows in User Notes? | -|---|---|---| -| **Breaking Changes** | Anything requiring user action to upgrade | Yes (top, with warning) | -| **Highlights** | New user-visible features, major behavioral changes | Yes (with 1-2 sentence descriptions) | -| **Improvements** | Enhancements to existing features | Yes (bullet list) | -| **Fixes** | Bug fixes | Yes (bullet list) | -| **Internal** | Refactoring, deps, CI, tests, docs | No (dev changelog only) | - -### Categorization Heuristics - -Use these signals to auto-categorize. When signals conflict, prefer the -higher-visibility category and flag for human review. - -| Signal | Category | -|---|---| -| Commit touches migration files, schema changes | Breaking Change (if destructive) | -| Changeset marked `major` | Breaking Change | -| Commit message has `BREAKING:` or `!:` | Breaking Change | -| New UI components, new routes, new API endpoints | Highlight | -| Commit message starts with `feat:` or `add:` | Highlight or Improvement | -| Changeset marked `minor` | Highlight | -| Commit message starts with `fix:` or `bug:` | Fix | -| Changeset marked `patch` | Fix or Improvement | -| Commit message starts with `chore:`, `refactor:`, `ci:`, `test:`, `docs:` | Internal | -| PR has detailed body with user-facing description | Use PR body as the description | - -### Writing Good Descriptions - -- **Highlights** get 1-2 sentence descriptions explaining the user benefit. - Write from the user's perspective ("You can now..." not "Added a component that..."). -- **Improvements and Fixes** are concise bullet points. -- **Breaking Changes** get detailed descriptions including what changed, - why, and what the user needs to do. -- Group related commits into a single changelog entry. Five commits implementing - one feature = one Highlight entry, not five bullets. -- Omit purely internal changes from user-facing notes entirely. - ---- - -## Step 5 — Write the Changelog - -Output the changelog to `releases/v{version}.md` using this template: +Template: ```markdown # v{version} > Released: {YYYY-MM-DD} -{If breaking changes detected, include this section:} - ## Breaking Changes -> **Action required before upgrading.** Read the Upgrade Guide below. - -- **{Breaking change title}** — {What changed and why. What the user needs to do.} - ## Highlights -- **{Feature name}** — {1-2 sentence description of what it does and why it matters.} - ## Improvements -- {Concise description of improvement} - ## Fixes -- {Concise description of fix} - ---- - -{If breaking changes detected, include this section:} - ## Upgrade Guide - -### Before You Update - -1. **Back up your database.** - - SQLite: `cp paperclip.db paperclip.db.backup` - - Postgres: `pg_dump -Fc paperclip > paperclip-pre-{version}.dump` -2. **Note your current version:** `paperclip --version` - -### After Updating - -{Specific steps: run migrations, update configs, etc.} - -### Rolling Back - -If something goes wrong: -1. Restore your database backup -2. `npm install @paperclipai/server@{previous-version}` ``` -### Template Rules +Omit empty sections except `Highlights`, `Improvements`, and `Fixes`, which should usually exist. -- Omit any empty section entirely (don't show "## Fixes" with no bullets). -- The Breaking Changes section always comes first when present. -- The Upgrade Guide always comes last when present. -- Use `**bold**` for feature/change names, regular text for descriptions. -- Keep the entire changelog scannable — a busy user should get the gist from - headings and bold text alone. +## Step 6 — Review Before Release ---- +Before handing it off: -## Step 6 — Present for Review +1. confirm the heading is the stable version only +2. confirm there is no `-canary` language in the title or filename +3. confirm any breaking changes have an upgrade path +4. present the draft for human sign-off -After generating the draft: - -1. **Show the full changelog** to the reviewer (CTO or whoever triggered the release). -2. **Flag ambiguous items** — commits you weren't sure how to categorize, or - items that might be breaking but aren't clearly signaled. -3. **Flag version bump mismatches** — if the planned bump is lower than what - the changes warrant. -4. **Wait for approval** before considering the changelog final. - -If the reviewer requests edits, update `releases/v{version}.md` accordingly. - -Do not proceed to publishing, website updates, or social announcements. Those -are handled by the `release` coordination skill (separate from this one). - ---- - -## Directory Convention - -Release changelogs live in `releases/` at the repo root: - -``` -releases/ - v0.2.7.md - v0.3.0.md - ... -``` - -Each file is named `v{version}.md` matching the git tag. This directory is -committed to the repo and serves as the source of truth for release history. - -The `releases/` directory should be created with a `.gitkeep` if it doesn't -exist yet. - ---- - -## Quick Reference - -```bash -# Full workflow summary: - -# 1. Find last tag -LAST_TAG=$(git tag --sort=-version:refname | head -1) - -# 2. Commits since last tag -git log $LAST_TAG..HEAD --oneline --no-merges - -# 3. Files changed (for breaking change detection) -git diff --name-only $LAST_TAG..HEAD - -# 4. Migration changes specifically -git diff --name-only $LAST_TAG..HEAD -- packages/db/src/migrations/ - -# 5. Schema changes -git diff $LAST_TAG..HEAD -- packages/db/src/schema/ - -# 6. Unconsumed changesets -ls .changeset/*.md | grep -v README.md - -# 7. Merged PRs (if gh available) -gh pr list --state merged --search "merged:>=$(git log -1 --format=%aI $LAST_TAG)" \ - --json number,title,body,labels -``` +This skill never publishes anything. It only prepares the stable changelog artifact. diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 4c91fffd..65468704 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -1,402 +1,234 @@ --- name: release description: > - Coordinate a full Paperclip release across engineering, website publishing, - and social announcement. Use when CTO/CEO requests "do a release" or - "release vX.Y.Z". Runs pre-flight checks, generates changelog via - release-changelog, executes npm release, creates cross-project follow-up - tasks, and posts a release wrap-up. + Coordinate a full Paperclip release across engineering verification, npm, + GitHub, website publishing, and announcement follow-up. Use when leadership + asks to ship a release, not merely to discuss version bumps. --- # Release Coordination Skill -Run the full Paperclip release process as an organizational workflow, not just -an npm publish. +Run the full Paperclip release as a maintainer workflow, not just an npm publish. This skill coordinates: -- User-facing changelog generation (`release-changelog` skill) -- Canary publish to npm (`scripts/release.sh --canary`) -- Docker smoke test of the canary (`scripts/docker-onboard-smoke.sh`) -- Promotion to `latest` after canary is verified -- Website publishing task creation -- CMO announcement task creation -- Final release summary with links ---- +- stable changelog drafting via `release-changelog` +- prerelease canary publishing via `scripts/release.sh --canary` +- Docker smoke testing via `scripts/docker-onboard-smoke.sh` +- stable publishing via `scripts/release.sh` +- pushing the release commit and tag +- GitHub Release creation via `scripts/create-github-release.sh` +- website / announcement follow-up tasks ## Trigger Use this skill when leadership asks for: -- "do a release" -- "release {patch|minor|major}" -- "release vX.Y.Z" ---- +- "do a release" +- "ship the next patch/minor/major" +- "release vX.Y.Z" ## Preconditions Before proceeding, verify all of the following: 1. `skills/release-changelog/SKILL.md` exists and is usable. -2. The `release-changelog` dependency work is complete/reviewed before running this flow. -3. App repo working tree is clean. -4. There are commits since the last release tag. -5. You have release permissions (`npm whoami` succeeds for real publish). -6. If running via Paperclip, you have issue context for posting status updates. +2. The repo working tree is clean, including untracked files. +3. There are commits since the last stable tag. +4. The release SHA has passed the verification gate or is about to. +5. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +6. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. ---- - ## Inputs Collect these inputs up front: -- Release request source issue (if in Paperclip) -- Requested bump (`patch|minor|major`) or explicit version (`vX.Y.Z`) -- Whether this run is dry-run or live publish -- Company/project context for follow-up issue creation +- requested bump: `patch`, `minor`, or `major` +- whether this run is a dry run or live release +- whether the release is being run locally or from GitHub Actions +- release issue / company context for website and announcement follow-up ---- +## Step 0 — Release Model -## Step 0 — Idempotency Guards +Paperclip now uses this release model: -Each step in this skill is designed to be safely re-runnable. Before executing -any step, check whether it has already been completed: +1. Draft the **stable** changelog as `releases/vX.Y.Z.md` +2. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` +3. Smoke test the canary via Docker +4. Publish the stable version `X.Y.Z` +5. Push the release commit and tag +6. Create the GitHub Release +7. Complete website and announcement surfaces -| Step | How to Check | If Already Done | -|---|---|---| -| Changelog | `releases/v{version}.md` exists | Read it, ask reviewer to confirm or update. Do NOT regenerate without asking. | -| Canary publish | `npm view paperclipai@{version}` succeeds | Skip canary publish. Proceed to smoke test. | -| Smoke test | Manual or scripted verification | If canary already verified, proceed to promote. | -| Promote | `git tag v{version}` exists | Skip promotion entirely. A tag means the version is already promoted to latest. | -| Website task | Search Paperclip issues for "Publish release notes for v{version}" | Skip creation. Link the existing task. | -| CMO task | Search Paperclip issues for "release announcement tweet for v{version}" | Skip creation. Link the existing task. | +Critical consequence: -**The golden rule:** If a git tag `v{version}` already exists, the release is -fully promoted. Only post-publish tasks (website, CMO, wrap-up) should proceed. -If the version exists on npm but there's no git tag, the canary was published but -not yet promoted — resume from smoke test. +- Canaries do **not** use promote-by-dist-tag anymore. +- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`. -**Iterating on changelogs:** You can re-run this skill with an existing changelog -to refine it _before_ the npm publish step. The `release-changelog` skill has -its own idempotency check and will ask the reviewer what to do with an existing -file. This is the expected workflow for iterating on release notes. +## Step 1 — Decide the Stable Version ---- - -## Step 1 - Pre-flight and Version Decision - -Run pre-flight in the App repo root: +Use the last stable tag as the base: ```bash -LAST_TAG=$(git tag --sort=-version:refname | head -1) -git diff --quiet && git diff --cached --quiet -git log "${LAST_TAG}..HEAD" --oneline --no-merges | head -50 -``` - -Then detect minimum required bump: - -```bash -# migrations +LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) +git log "${LAST_TAG}..HEAD" --oneline --no-merges git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ - -# schema deltas git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ - -# breaking commit conventions git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` Bump policy: -- Destructive migration/API removal/major changeset/breaking commit -> `major` -- Additive migrations or clear new features -> at least `minor` -- Fixes-only -> `patch` -If requested bump is lower than required minimum, escalate bump and explain why. +- destructive migrations, removed APIs, breaking config changes -> `major` +- additive migrations or clearly user-visible features -> at least `minor` +- fixes only -> `patch` ---- +If the requested bump is too low, escalate it and explain why. -## Step 2 - Generate Changelog Draft +## Step 2 — Draft the Stable Changelog -First, check if `releases/v{version}.md` already exists. If it does, the -`release-changelog` skill will detect this and ask the reviewer whether to keep, -regenerate, or update it. **Do not silently overwrite an existing changelog.** +Invoke `release-changelog` and generate: -Invoke the `release-changelog` skill and produce: -- `releases/v{version}.md` -- Sections ordered as: Breaking Changes (if any), Highlights, Improvements, Fixes, Upgrade Guide (if any) +- `releases/vX.Y.Z.md` -Required behavior: -- Present the draft for human review. -- Flag ambiguous categorization items. -- Flag bump mismatches before publish. -- Do not publish until reviewer confirms. +Rules: ---- +- review the draft with a human before publish +- preserve manual edits if the file already exists +- keep the heading and filename stable-only, for example `v1.2.3` +- do not create a separate canary changelog file -## Step 3 — Publish Canary +## Step 3 — Verify the Release SHA -The canary is the gatekeeper: every release goes to npm as a canary first. The -`latest` tag is never touched until the canary passes smoke testing. - -**Idempotency check:** Before publishing, check if this version already exists -on npm: +Run the standard gate: ```bash -# Check if canary is already published -npm view paperclipai@{version} version 2>/dev/null && echo "ALREADY_PUBLISHED" || echo "NOT_PUBLISHED" - -# Also check git tag -git tag -l "v{version}" +pnpm -r typecheck +pnpm test:run +pnpm build ``` -- If a git tag exists → the release is already fully promoted. Skip to Step 6. -- If the version exists on npm but no git tag → canary was published but not yet - promoted. Skip to Step 4 (smoke test). -- If neither exists → proceed with canary publish. +If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. -### Publishing the canary +## Step 4 — Publish a Canary -Use `release.sh` with the `--canary` flag (see script changes below): +Run: ```bash -# Dry run first ./scripts/release.sh {patch|minor|major} --canary --dry-run - -# Publish canary (after dry-run review) ./scripts/release.sh {patch|minor|major} --canary ``` -This publishes all packages to npm with the `canary` dist-tag. The `latest` tag -is **not** updated. Users running `npx paperclipai onboard` still get the -previous stable version. +What this means: -After publish, verify the canary is accessible: +- npm receives `X.Y.Z-canary.N` under dist-tag `canary` +- `latest` remains unchanged +- no git tag is created +- the script cleans the working tree afterward + +After publish, verify: ```bash npm view paperclipai@canary version -# Should show the new version ``` -**How `--canary` works in release.sh:** -- Steps 1-5 are the same (preflight, changeset, version, build, CLI bundle) -- Step 6 uses `npx changeset publish --tag canary` instead of `npx changeset publish` -- Step 7 does NOT commit or tag — the commit and tag happen later in the promote - step, only after smoke testing passes +The user install path is: -**Script changes required:** Add `--canary` support to `scripts/release.sh`: -- Parse `--canary` flag alongside `--dry-run` -- When `--canary`: pass `--tag canary` to `changeset publish` -- When `--canary`: skip the git commit and tag step (Step 7) -- When NOT `--canary`: behavior is unchanged (backwards compatible) +```bash +npx paperclipai@canary onboard +``` ---- +## Step 5 — Smoke Test the Canary -## Step 4 — Smoke Test the Canary - -Run the canary in a clean Docker environment to verify `npx paperclipai onboard` -works end-to-end. - -### Automated smoke test - -Use the existing Docker smoke test infrastructure with the canary version: +Run: ```bash PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` -This builds a clean Ubuntu container, installs `paperclipai@canary` via npx, and -runs the onboarding flow. The UI is accessible at `http://localhost:3131`. +Confirm: -### What to verify +1. install succeeds +2. onboarding completes +3. server boots +4. UI loads +5. basic company/dashboard flow works -At minimum, confirm: +If smoke testing fails: -1. **Container starts** — no npm install errors, no missing dependencies -2. **Onboarding completes** — the wizard runs through without crashes -3. **Server boots** — UI is accessible at the expected port -4. **Basic operations** — can create a company, view the dashboard +- stop the stable release +- fix the issue +- publish another canary +- repeat the smoke test -For a more thorough check (stretch goal — can be automated later): +Each retry should create a higher canary ordinal, while the stable target version can stay the same. -5. **Browser automation** — script Playwright/Puppeteer to walk through onboard - in the Docker container's browser and verify key pages render +## Step 6 — Publish Stable -### If smoke test fails - -- Do NOT promote the canary. -- Fix the issue, publish a new canary (re-run Step 3 — idempotency guards allow - this since there's no git tag yet). -- Re-run the smoke test. - -### If smoke test passes - -Proceed to Step 5 (promote). - ---- - -## Step 5 — Promote Canary to Latest - -Once the canary passes smoke testing, promote it to `latest` so that -`npx paperclipai onboard` picks up the new version. - -### Promote on npm +Once the SHA is vetted, run: ```bash -# For each published package, move the dist-tag from canary to latest -npm dist-tag add paperclipai@{version} latest -npm dist-tag add @paperclipai/server@{version} latest -npm dist-tag add @paperclipai/cli@{version} latest -npm dist-tag add @paperclipai/shared@{version} latest -npm dist-tag add @paperclipai/db@{version} latest -npm dist-tag add @paperclipai/adapter-utils@{version} latest -npm dist-tag add @paperclipai/adapter-claude-local@{version} latest -npm dist-tag add @paperclipai/adapter-codex-local@{version} latest -npm dist-tag add @paperclipai/adapter-openclaw-gateway@{version} latest +./scripts/release.sh {patch|minor|major} --dry-run +./scripts/release.sh {patch|minor|major} ``` -**Script option:** Add `./scripts/release.sh --promote {version}` to automate -the dist-tag promotion for all packages. +Stable publish does this: -### Commit and tag +- publishes `X.Y.Z` to npm under `latest` +- creates the local release commit +- creates the local git tag `vX.Y.Z` -After promotion, finalize in git (this is what `release.sh` Step 7 normally -does, but was deferred during canary publish): +Stable publish does **not** push the release for you. + +## Step 7 — Push and Create GitHub Release + +After stable publish succeeds: ```bash -git add . -git commit -m "chore: release v{version}" -git tag "v{version}" +git push origin HEAD:master --follow-tags +./scripts/create-github-release.sh X.Y.Z ``` -### Verify promotion +Use the stable changelog file as the GitHub Release notes source. -```bash -npm view paperclipai@latest version -# Should now show the new version +## Step 8 — Finish the Other Surfaces -# Final sanity check -npx --yes paperclipai@latest --version -``` +Create or verify follow-up work for: ---- +- website changelog publishing +- launch post / social announcement +- any release summary in Paperclip issue context -## Step 6 - Create Cross-Project Follow-up Tasks - -**Idempotency check:** Before creating tasks, search for existing ones: - -``` -GET /api/companies/{companyId}/issues?q=release+notes+v{version} -GET /api/companies/{companyId}/issues?q=announcement+tweet+v{version} -``` - -If matching tasks already exist (check title contains the version), skip -creation and link the existing tasks instead. Do not create duplicates. - -Create at least two tasks in Paperclip (only if they don't already exist): - -1. Website task: publish changelog for `v{version}` -2. CMO task: draft announcement tweet for `v{version}` - -When creating tasks: -- Set `parentId` to the release issue id. -- Carry over `goalId` from the parent issue when present. -- Include `billingCode` for cross-team work when required by company policy. -- Mark website task `high` priority if release has breaking changes. - -Suggested payloads: - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{websiteProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Publish release notes for v{version}", - "priority": "medium", - "status": "todo", - "description": "Publish /changelog entry for v{version}. Include full markdown from releases/v{version}.md and prominent upgrade guide if breaking changes exist." -} -``` - -```json -POST /api/companies/{companyId}/issues -{ - "projectId": "{workspaceProjectId}", - "parentId": "{releaseIssueId}", - "goalId": "{goalId-or-null}", - "billingCode": "{billingCode-or-null}", - "title": "Draft release announcement tweet for v{version}", - "priority": "medium", - "status": "todo", - "description": "Draft launch tweet with top 1-2 highlights, version number, and changelog URL. If breaking changes exist, include an explicit upgrade-guide callout." -} -``` - ---- - -## Step 7 - Wrap Up the Release Issue - -Post a concise markdown update linking: -- Release issue -- Changelog file (`releases/v{version}.md`) -- npm package URL (both `@canary` and `@latest` after promotion) -- Canary smoke test result (pass/fail, what was tested) -- Website task -- CMO task -- Final changelog URL (once website publishes) -- Tweet URL (once published) - -Completion rules: -- Keep issue `in_progress` until canary is promoted AND website + social tasks - are done. -- Mark `done` only when all required artifacts are published and linked. -- If waiting on another team, keep open with clear owner and next action. - ---- - -## Release Flow Summary - -The full release lifecycle is now: - -``` -1. Generate changelog → releases/v{version}.md (review + iterate) -2. Publish canary → npm @canary dist-tag (latest untouched) -3. Smoke test canary → Docker clean install verification -4. Promote to latest → npm @latest dist-tag + git tag + commit -5. Create follow-up tasks → website changelog + CMO tweet -6. Wrap up → link everything, close issue -``` - -At any point you can re-enter the flow — idempotency guards detect which steps -are already done and skip them. The changelog can be iterated before or after -canary publish. The canary can be re-published if the smoke test reveals issues -(just fix + re-run Step 3). Only after smoke testing passes does `latest` get -updated. - ---- - -## Paperclip API Notes (When Running in Agent Context) - -Use: -- `GET /api/companies/{companyId}/projects` to resolve website/workspace project IDs. -- `POST /api/companies/{companyId}/issues` to create follow-up tasks. -- `PATCH /api/issues/{issueId}` with comments for release progress. - -For issue-modifying calls, include: -- `Authorization: Bearer $PAPERCLIP_API_KEY` -- `X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID` - ---- +These should reference the stable release, not the canary. ## Failure Handling -If blocked, update the release issue explicitly with: -- what failed -- exact blocker -- who must act next -- whether any release artifacts were partially published +If the canary is bad: -Never silently fail mid-release. +- publish another canary, do not ship stable + +If stable npm publish succeeds but push or GitHub release creation fails: + +- fix the git/GitHub issue immediately from the same checkout +- do not republish the same version + +If `latest` is bad after stable publish: + +```bash +./scripts/rollback-latest.sh +``` + +Then fix forward with a new patch release. + +## Output + +When the skill completes, provide: + +- stable version and, if relevant, the final canary version tested +- verification status +- npm status +- git tag / GitHub Release status +- website / announcement follow-up status +- rollback recommendation if anything is still partially complete From df94c98494c63e7b634821a9f7ca5afdeac62033 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:06:45 -0500 Subject: [PATCH 21/34] chore: add release preflight workflow --- doc/PUBLISHING.md | 1 + doc/RELEASING.md | 73 ++++++++++---- package.json | 1 + scripts/release-preflight.sh | 182 +++++++++++++++++++++++++++++++++++ scripts/release.sh | 9 ++ skills/release/SKILL.md | 15 ++- 6 files changed, 260 insertions(+), 21 deletions(-) create mode 100755 scripts/release-preflight.sh diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index fad105d6..9326fd5b 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -8,6 +8,7 @@ For the maintainer release workflow, use [doc/RELEASING.md](RELEASING.md). This Use these scripts instead of older one-off publish commands: +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) before any canary or stable release - [`scripts/release.sh`](../scripts/release.sh) for canary and stable npm publishes - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) to repoint `latest` during rollback - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) after a stable push diff --git a/doc/RELEASING.md b/doc/RELEASING.md index cab82cbe..e18a3e6e 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -26,24 +26,20 @@ Treat those as related but separate. npm can succeed while the GitHub Release is Use this when you want an installable prerelease without changing `latest`. ```bash -# 0. Start clean -git status --short +# 0. Preflight the canary candidate +./scripts/release-preflight.sh canary patch -# 1. Verify the candidate SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Draft or update the stable changelog for the intended stable version +VERSION=0.2.8 +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -# 2. Draft or update the stable changelog -# releases/vX.Y.Z.md - -# 3. Preview the canary release +# 2. Preview the canary release ./scripts/release.sh patch --canary --dry-run -# 4. Publish the canary +# 3. Publish the canary ./scripts/release.sh patch --canary -# 5. Smoke test what users will actually install +# 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh # Users install with: @@ -57,6 +53,7 @@ Result: - no git tag is created - no GitHub Release is created - the working tree returns to clean after the script finishes +- after stable `0.2.7`, a patch canary targets `0.2.8-canary.0`, never `0.2.7-canary.N` ### Stable release @@ -66,15 +63,13 @@ Use this only after the canary SHA is good enough to become the public default. # 0. Start from the vetted commit git checkout master git pull -git status --short -# 1. Verify again on the exact release SHA -pnpm -r typecheck -pnpm test:run -pnpm build +# 1. Preflight the stable candidate +./scripts/release-preflight.sh stable patch # 2. Confirm the stable changelog exists -ls releases/v*.md +VERSION=0.2.8 +ls "releases/v${VERSION}.md" # 3. Preview the stable publish ./scripts/release.sh patch --dry-run @@ -174,6 +169,15 @@ pnpm build This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. +For release work, prefer: + +```bash +./scripts/release-preflight.sh canary +./scripts/release-preflight.sh stable +``` + +That script runs the verification gate and prints the computed target versions before you publish anything. + ## Versioning Policy ### Stable versions @@ -200,6 +204,11 @@ That gives you three useful properties: We do **not** create separate changelog files for canary versions. +Concrete example: + +- if the latest stable release is `0.2.7`, a patch canary is `0.2.8-canary.0` +- `0.2.7-canary.0` is invalid, because `0.2.7` is already the shipped stable version + ## Changelog Policy The maintainer changelog source of truth is: @@ -222,7 +231,23 @@ Package-level `CHANGELOG.md` files are generated as part of the release mechanic ### 1. Decide the bump -Review the range since the last stable tag: +Run preflight first: + +```bash +./scripts/release-preflight.sh canary +# or +./scripts/release-preflight.sh stable +``` + +That command: + +- verifies the worktree is clean, including untracked files +- shows the last stable tag and computed next versions +- shows the commit range since the last stable tag +- highlights migration and breaking-change signals +- runs `pnpm -r typecheck`, `pnpm test:run`, and `pnpm build` + +If you want the raw inputs separately, review the range since the last stable tag: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -239,7 +264,8 @@ Use the higher bump if there is any doubt. Create or update: ```bash -releases/vX.Y.Z.md +VERSION=X.Y.Z +claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." ``` This is deliberate. The release notes should describe the stable story, not the canary mechanics. @@ -270,6 +296,12 @@ This means the script is safe to repeat as many times as needed while iterating: The target stable release can still remain `1.2.3`. +Guardrail: + +- the canary is always derived from the **next stable version** +- after stable `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the scripts refuse to publish `0.2.7-canary.N` once `0.2.7` is already the stable release + ### 4. Smoke test the canary Run the actual install path in Docker: @@ -426,6 +458,7 @@ Rollback procedure: ## Scripts Reference - [`scripts/release.sh`](../scripts/release.sh) — stable and canary npm publish flow +- [`scripts/release-preflight.sh`](../scripts/release-preflight.sh) — clean-tree, version-plan, and verification-gate preflight - [`scripts/create-github-release.sh`](../scripts/create-github-release.sh) — create or update the GitHub Release after push - [`scripts/rollback-latest.sh`](../scripts/rollback-latest.sh) — repoint `latest` to the last good stable release - [`scripts/docker-onboard-smoke.sh`](../scripts/docker-onboard-smoke.sh) — Docker smoke test for the installed CLI diff --git a/package.json b/package.json index 737438ec..68098ad8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", + "release:preflight": "./scripts/release-preflight.sh", "release:github": "./scripts/create-github-release.sh", "release:rollback": "./scripts/rollback-latest.sh", "changeset": "changeset", diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh new file mode 100755 index 00000000..575fbcc1 --- /dev/null +++ b/scripts/release-preflight.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +channel="" +bump_type="" + +usage() { + cat <<'EOF' +Usage: + ./scripts/release-preflight.sh + +Examples: + ./scripts/release-preflight.sh canary patch + ./scripts/release-preflight.sh stable minor + +What it does: + - verifies the git worktree is clean, including untracked files + - shows the last stable tag and the target version(s) + - shows commits since the last stable tag + - highlights migration/schema/breaking-change signals + - runs the verification gate: + pnpm -r typecheck + pnpm test:run + pnpm build +EOF +} + +if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then + usage + exit 0 +fi + +if [ $# -ne 2 ]; then + usage + exit 1 +fi + +channel="$1" +bump_type="$2" + +if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then + usage + exit 1 +fi + +if [[ ! "$bump_type" =~ ^(patch|minor|major)$ ]]; then + usage + exit 1 +fi + +compute_bumped_version() { + node - "$1" "$2" <<'NODE' +const current = process.argv[2]; +const bump = process.argv[3]; +const match = current.match(/^(\d+)\.(\d+)\.(\d+)$/); + +if (!match) { + throw new Error(`invalid semver version: ${current}`); +} + +let [major, minor, patch] = match.slice(1).map(Number); + +if (bump === 'patch') { + patch += 1; +} else if (bump === 'minor') { + minor += 1; + patch = 0; +} else if (bump === 'major') { + major += 1; + minor = 0; + patch = 0; +} else { + throw new Error(`unsupported bump type: ${bump}`); +} + +process.stdout.write(`${major}.${minor}.${patch}`); +NODE +} + +next_canary_version() { + local stable_version="$1" + local versions_json + + versions_json="$(npm view paperclipai versions --json 2>/dev/null || echo '[]')" + + node - "$stable_version" "$versions_json" <<'NODE' +const stable = process.argv[2]; +const versionsArg = process.argv[3]; + +let versions = []; +try { + const parsed = JSON.parse(versionsArg); + versions = Array.isArray(parsed) ? parsed : [parsed]; +} catch { + versions = []; +} + +const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`); +let max = -1; + +for (const version of versions) { + const match = version.match(pattern); + if (!match) continue; + max = Math.max(max, Number(match[1])); +} + +process.stdout.write(`${stable}-canary.${max + 1}`); +NODE +} + +LAST_STABLE_TAG="$(git -C "$REPO_ROOT" tag --list 'v*' --sort=-version:refname | head -1)" +CURRENT_STABLE_VERSION="${LAST_STABLE_TAG#v}" +if [ -z "$CURRENT_STABLE_VERSION" ]; then + CURRENT_STABLE_VERSION="0.0.0" +fi + +TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" +TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" + +if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then + echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 + exit 1 +fi + +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + echo "Error: next stable version matches the current stable version." >&2 + exit 1 +fi + +if [[ "$TARGET_CANARY_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + echo "Error: canary target was derived from the current stable version, which is not allowed." >&2 + exit 1 +fi + +echo "" +echo "==> Release preflight" +echo " Channel: $channel" +echo " Bump: $bump_type" +echo " Last stable tag: ${LAST_STABLE_TAG:-}" +echo " Current stable version: $CURRENT_STABLE_VERSION" +echo " Next stable version: $TARGET_STABLE_VERSION" +if [ "$channel" = "canary" ]; then + echo " Next canary version: $TARGET_CANARY_VERSION" + echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" +fi + +echo "" +echo "==> Working tree" +echo " ✓ Clean" + +echo "" +echo "==> Commits since last stable tag" +if [ -n "$LAST_STABLE_TAG" ]; then + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true +else + git -C "$REPO_ROOT" log --oneline --no-merges || true +fi + +echo "" +echo "==> Migration / breaking change signals" +if [ -n "$LAST_STABLE_TAG" ]; then + echo "-- migrations --" + git -C "$REPO_ROOT" diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true + echo "-- schema --" + git -C "$REPO_ROOT" diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true + echo "-- breaking commit messages --" + git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true +else + echo "No stable tag exists yet. Review the full current tree manually." +fi + +echo "" +echo "==> Verification gate" +cd "$REPO_ROOT" +pnpm -r typecheck +pnpm test:run +pnpm build + +echo "" +echo "Preflight passed for $channel release." diff --git a/scripts/release.sh b/scripts/release.sh index 4908912c..1c05e19c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -309,6 +309,14 @@ if [ "$canary" = true ]; then TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" fi +if [ "$TARGET_STABLE_VERSION" = "$CURRENT_STABLE_VERSION" ]; then + fail "next stable version matches the current stable version. Refusing to publish." +fi + +if [[ "$TARGET_PUBLISH_VERSION" == "${CURRENT_STABLE_VERSION}-canary."* ]]; then + fail "canary versions must be derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N." +fi + PUBLIC_PACKAGE_INFO="$(list_public_package_info)" PUBLIC_PACKAGE_NAMES="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)" PUBLIC_PACKAGE_DIRS="$(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f1)" @@ -324,6 +332,7 @@ info " Current stable version: $CURRENT_STABLE_VERSION" if [ "$canary" = true ]; then info " Target stable version: $TARGET_STABLE_VERSION" info " Canary version: $TARGET_PUBLISH_VERSION" + info " Guard: canary is derived from next stable version, not ${CURRENT_STABLE_VERSION}-canary.N" else info " Stable version: $TARGET_STABLE_VERSION" fi diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 65468704..088ed7ba 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -69,7 +69,15 @@ Critical consequence: ## Step 1 — Decide the Stable Version -Use the last stable tag as the base: +Run release preflight first: + +```bash +./scripts/release-preflight.sh canary {patch|minor|major} +# or +./scripts/release-preflight.sh stable {patch|minor|major} +``` + +Then use the last stable tag as the base: ```bash LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) @@ -128,6 +136,11 @@ What this means: - no git tag is created - the script cleans the working tree afterward +Guard: + +- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0` +- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable + After publish, verify: ```bash From e1ddcbb71f212275e2ca7de67ef08e48a9e79212 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:13:49 -0500 Subject: [PATCH 22/34] fix: disable git pagers in release preflight --- scripts/release-preflight.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 575fbcc1..fdba4ae1 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -2,6 +2,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +export GIT_PAGER=cat channel="" bump_type="" @@ -153,20 +154,20 @@ echo " ✓ Clean" echo "" echo "==> Commits since last stable tag" if [ -n "$LAST_STABLE_TAG" ]; then - git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true + git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --oneline --no-merges || true else - git -C "$REPO_ROOT" log --oneline --no-merges || true + git -C "$REPO_ROOT" --no-pager log --oneline --no-merges || true fi echo "" echo "==> Migration / breaking change signals" if [ -n "$LAST_STABLE_TAG" ]; then echo "-- migrations --" - git -C "$REPO_ROOT" diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true + git -C "$REPO_ROOT" --no-pager diff --name-only "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/migrations/ || true echo "-- schema --" - git -C "$REPO_ROOT" diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true + git -C "$REPO_ROOT" --no-pager diff "${LAST_STABLE_TAG}..HEAD" -- packages/db/src/schema/ || true echo "-- breaking commit messages --" - git -C "$REPO_ROOT" log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true + git -C "$REPO_ROOT" --no-pager log "${LAST_STABLE_TAG}..HEAD" --format="%s" | grep -E 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true else echo "No stable tag exists yet. Review the full current tree manually." fi From aa2b11d5288c225ff7b5da08036b31c8a5ee48e6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:21:56 -0500 Subject: [PATCH 23/34] feat: extend release preflight smoke options --- doc/RELEASING.md | 26 ++++++++ scripts/release-preflight.sh | 124 ++++++++++++++++++++++++++++++++--- 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index e18a3e6e..7b6b69e8 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -42,6 +42,9 @@ claude -p "Use the release-changelog skill to draft or update releases/v${VERSIO # 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +# Optional: have preflight run the onboarding smoke immediately afterward +./scripts/release-preflight.sh canary patch --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary + # Users install with: npx paperclipai@canary onboard ``` @@ -105,6 +108,21 @@ If `latest` is broken after publish, repoint it to the last known good stable ve This does **not** unpublish anything. It only moves the `latest` dist-tag back to the last good stable release. +### Standalone onboarding smoke + +You already have a script for isolated onboarding verification: + +```bash +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +This is the best existing fit when you want: + +- a standalone Paperclip data dir +- a dedicated host port +- an end-to-end `npx paperclipai ... onboard` check + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -310,6 +328,14 @@ Run the actual install path in Docker: PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` +If you want it tied directly to preflight, you can append: + +```bash +./scripts/release-preflight.sh canary --onboard-smoke +./scripts/release-preflight.sh canary --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary +./scripts/release-preflight.sh stable --onboard-smoke --onboard-host-port 3233 --onboard-data-dir ./data/release-preflight-stable +``` + Minimum checks: - [ ] `npx paperclipai@canary onboard` installs diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index fdba4ae1..4b68cbb3 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -6,15 +6,23 @@ export GIT_PAGER=cat channel="" bump_type="" +run_onboard_smoke=false +onboard_version="" +onboard_host_port="" +onboard_data_dir="" usage() { cat <<'EOF' Usage: - ./scripts/release-preflight.sh + ./scripts/release-preflight.sh [--onboard-smoke] + [--onboard-version ] + [--onboard-host-port ] + [--onboard-data-dir ] Examples: ./scripts/release-preflight.sh canary patch ./scripts/release-preflight.sh stable minor + ./scripts/release-preflight.sh canary minor --onboard-smoke --onboard-version canary --onboard-host-port 3232 What it does: - verifies the git worktree is clean, including untracked files @@ -25,22 +33,62 @@ What it does: pnpm -r typecheck pnpm test:run pnpm build + - optionally runs scripts/docker-onboard-smoke.sh afterward EOF } -if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then - usage - exit 0 -fi +while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --onboard-smoke) + run_onboard_smoke=true + ;; + --onboard-version) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-version requires a value." >&2 + exit 1 + fi + onboard_version="$1" + ;; + --onboard-host-port) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-host-port requires a value." >&2 + exit 1 + fi + onboard_host_port="$1" + ;; + --onboard-data-dir) + shift + if [ $# -eq 0 ]; then + echo "Error: --onboard-data-dir requires a value." >&2 + exit 1 + fi + onboard_data_dir="$1" + ;; + *) + if [ -z "$channel" ]; then + channel="$1" + elif [ -z "$bump_type" ]; then + bump_type="$1" + else + echo "Error: unexpected argument: $1" >&2 + exit 1 + fi + ;; + esac + shift +done -if [ $# -ne 2 ]; then +if [ -z "$channel" ] || [ -z "$bump_type" ]; then usage exit 1 fi -channel="$1" -bump_type="$2" - if [[ ! "$channel" =~ ^(canary|stable)$ ]]; then usage exit 1 @@ -120,6 +168,14 @@ fi TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" +if [ "$run_onboard_smoke" = true ] && [ -z "$onboard_version" ]; then + if [ "$channel" = "canary" ]; then + onboard_version="canary" + else + onboard_version="latest" + fi +fi + if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 exit 1 @@ -146,6 +202,16 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi +if [ "$run_onboard_smoke" = true ]; then + echo " Post-check: onboarding smoke enabled" + echo " Onboarding smoke version/tag: $onboard_version" + if [ -n "$onboard_host_port" ]; then + echo " Onboarding smoke host port: $onboard_host_port" + fi + if [ -n "$onboard_data_dir" ]; then + echo " Onboarding smoke data dir: $onboard_data_dir" + fi +fi echo "" echo "==> Working tree" @@ -179,5 +245,45 @@ pnpm -r typecheck pnpm test:run pnpm build +echo "" +if [ "$run_onboard_smoke" = true ]; then + echo "==> Optional onboarding smoke" + smoke_cmd=(env "PAPERCLIPAI_VERSION=$onboard_version") + if [ -n "$onboard_host_port" ]; then + smoke_cmd+=("HOST_PORT=$onboard_host_port") + fi + if [ -n "$onboard_data_dir" ]; then + smoke_cmd+=("DATA_DIR=$onboard_data_dir") + fi + smoke_cmd+=("$REPO_ROOT/scripts/docker-onboard-smoke.sh") + printf ' Running:' + for arg in "${smoke_cmd[@]}"; do + printf ' %q' "$arg" + done + printf '\n' + "${smoke_cmd[@]}" + echo "" +fi + +echo "==> Release preflight summary" +echo " Channel: $channel" +echo " Bump: $bump_type" +echo " Last stable tag: ${LAST_STABLE_TAG:-}" +echo " Current stable version: $CURRENT_STABLE_VERSION" +echo " Next stable version: $TARGET_STABLE_VERSION" +if [ "$channel" = "canary" ]; then + echo " Next canary version: $TARGET_CANARY_VERSION" + echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" +fi +if [ "$run_onboard_smoke" = true ]; then + echo " Onboarding smoke version/tag: $onboard_version" + if [ -n "$onboard_host_port" ]; then + echo " Onboarding smoke host port: $onboard_host_port" + fi + if [ -n "$onboard_data_dir" ]; then + echo " Onboarding smoke data dir: $onboard_data_dir" + fi +fi + echo "" echo "Preflight passed for $channel release." From 30ee59c3241d4a2ee1ec600dde5bf9c069750099 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:37:18 -0500 Subject: [PATCH 24/34] chore: simplify release preflight workflow --- doc/RELEASING.md | 24 ++++++++--- scripts/release-preflight.sh | 84 +----------------------------------- 2 files changed, 18 insertions(+), 90 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 7b6b69e8..bd7314ce 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -42,9 +42,6 @@ claude -p "Use the release-changelog skill to draft or update releases/v${VERSIO # 4. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh -# Optional: have preflight run the onboarding smoke immediately afterward -./scripts/release-preflight.sh canary patch --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary - # Users install with: npx paperclipai@canary onboard ``` @@ -123,6 +120,14 @@ This is the best existing fit when you want: - a dedicated host port - an end-to-end `npx paperclipai ... onboard` check +If you want to exercise onboarding from a fresh local checkout rather than npm, use: + +```bash +./scripts/clean-onboard-git.sh +``` + +That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing. + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -328,12 +333,17 @@ Run the actual install path in Docker: PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` -If you want it tied directly to preflight, you can append: +Useful isolated variants: ```bash -./scripts/release-preflight.sh canary --onboard-smoke -./scripts/release-preflight.sh canary --onboard-smoke --onboard-host-port 3232 --onboard-data-dir ./data/release-preflight-canary -./scripts/release-preflight.sh stable --onboard-smoke --onboard-host-port 3233 --onboard-data-dir ./data/release-preflight-stable +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +If you want to smoke onboarding from the current codebase rather than npm, run: + +```bash +./scripts/clean-onboard-git.sh ``` Minimum checks: diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 4b68cbb3..84faf5b2 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -6,23 +6,15 @@ export GIT_PAGER=cat channel="" bump_type="" -run_onboard_smoke=false -onboard_version="" -onboard_host_port="" -onboard_data_dir="" usage() { cat <<'EOF' Usage: - ./scripts/release-preflight.sh [--onboard-smoke] - [--onboard-version ] - [--onboard-host-port ] - [--onboard-data-dir ] + ./scripts/release-preflight.sh Examples: ./scripts/release-preflight.sh canary patch ./scripts/release-preflight.sh stable minor - ./scripts/release-preflight.sh canary minor --onboard-smoke --onboard-version canary --onboard-host-port 3232 What it does: - verifies the git worktree is clean, including untracked files @@ -33,7 +25,6 @@ What it does: pnpm -r typecheck pnpm test:run pnpm build - - optionally runs scripts/docker-onboard-smoke.sh afterward EOF } @@ -43,33 +34,6 @@ while [ $# -gt 0 ]; do usage exit 0 ;; - --onboard-smoke) - run_onboard_smoke=true - ;; - --onboard-version) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-version requires a value." >&2 - exit 1 - fi - onboard_version="$1" - ;; - --onboard-host-port) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-host-port requires a value." >&2 - exit 1 - fi - onboard_host_port="$1" - ;; - --onboard-data-dir) - shift - if [ $# -eq 0 ]; then - echo "Error: --onboard-data-dir requires a value." >&2 - exit 1 - fi - onboard_data_dir="$1" - ;; *) if [ -z "$channel" ]; then channel="$1" @@ -168,14 +132,6 @@ fi TARGET_STABLE_VERSION="$(compute_bumped_version "$CURRENT_STABLE_VERSION" "$bump_type")" TARGET_CANARY_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION")" -if [ "$run_onboard_smoke" = true ] && [ -z "$onboard_version" ]; then - if [ "$channel" = "canary" ]; then - onboard_version="canary" - else - onboard_version="latest" - fi -fi - if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then echo "Error: working tree is not clean. Commit, stash, or remove changes before releasing." >&2 exit 1 @@ -202,16 +158,6 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi -if [ "$run_onboard_smoke" = true ]; then - echo " Post-check: onboarding smoke enabled" - echo " Onboarding smoke version/tag: $onboard_version" - if [ -n "$onboard_host_port" ]; then - echo " Onboarding smoke host port: $onboard_host_port" - fi - if [ -n "$onboard_data_dir" ]; then - echo " Onboarding smoke data dir: $onboard_data_dir" - fi -fi echo "" echo "==> Working tree" @@ -246,25 +192,6 @@ pnpm test:run pnpm build echo "" -if [ "$run_onboard_smoke" = true ]; then - echo "==> Optional onboarding smoke" - smoke_cmd=(env "PAPERCLIPAI_VERSION=$onboard_version") - if [ -n "$onboard_host_port" ]; then - smoke_cmd+=("HOST_PORT=$onboard_host_port") - fi - if [ -n "$onboard_data_dir" ]; then - smoke_cmd+=("DATA_DIR=$onboard_data_dir") - fi - smoke_cmd+=("$REPO_ROOT/scripts/docker-onboard-smoke.sh") - printf ' Running:' - for arg in "${smoke_cmd[@]}"; do - printf ' %q' "$arg" - done - printf '\n' - "${smoke_cmd[@]}" - echo "" -fi - echo "==> Release preflight summary" echo " Channel: $channel" echo " Bump: $bump_type" @@ -275,15 +202,6 @@ if [ "$channel" = "canary" ]; then echo " Next canary version: $TARGET_CANARY_VERSION" echo " Guard: canaries are always derived from the next stable version, never ${CURRENT_STABLE_VERSION}-canary.N" fi -if [ "$run_onboard_smoke" = true ]; then - echo " Onboarding smoke version/tag: $onboard_version" - if [ -n "$onboard_host_port" ]; then - echo " Onboarding smoke host port: $onboard_host_port" - fi - if [ -n "$onboard_data_dir" ]; then - echo " Onboarding smoke data dir: $onboard_data_dir" - fi -fi echo "" echo "Preflight passed for $channel release." From 0781b7a15cd317aba2e2a74ccc0fdce5e90afdef Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:53:35 -0500 Subject: [PATCH 25/34] v0.3.0.md release changelog --- doc/RELEASING.md | 2 +- releases/v0.3.0.md | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 releases/v0.3.0.md diff --git a/doc/RELEASING.md b/doc/RELEASING.md index bd7314ce..8ffd80fd 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -31,7 +31,7 @@ Use this when you want an installable prerelease without changing `latest`. # 1. Draft or update the stable changelog for the intended stable version VERSION=0.2.8 -claude -p "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." # 2. Preview the canary release ./scripts/release.sh patch --canary --dry-run diff --git a/releases/v0.3.0.md b/releases/v0.3.0.md new file mode 100644 index 00000000..4e18ae6c --- /dev/null +++ b/releases/v0.3.0.md @@ -0,0 +1,47 @@ +# v0.3.0 + +> Released: 2026-03-09 + +## Highlights + +- **New adapters: Cursor, OpenCode, and Pi** — Paperclip now supports three additional local coding agents. Cursor and OpenCode integrate as first-class adapters with model discovery, run-log streaming, and skill injection. Pi adds a local RPC mode with cost tracking. All three appear in the onboarding wizard alongside Claude Code and Codex. +- **OpenClaw gateway adapter** — A new gateway-only OpenClaw flow replaces the legacy adapter. It uses strict SSE streaming, supports device-key pairing, and handles invite-based onboarding with join-token validation. +- **Inbox and unread semantics** — Issues now track per-user read state. Unread indicators appear in the inbox, dashboard, and browser tab (blue dot). The inbox badge includes join requests and approvals, and inbox ordering is alert-focused. +- **PWA support** — The UI ships as an installable Progressive Web App with a service worker and enhanced manifest. The service worker uses a network-first strategy to prevent stale content. +- **Agent creation wizard** — A new choice modal and full-page configuration flow make it easier to add agents. The sidebar AGENTS header now has a quick-add button. + +## Improvements + +- **Mermaid diagrams in markdown** — Fenced `mermaid` blocks render as diagrams in issue comments and descriptions. +- **Live run output** — Run detail pages stream output over WebSocket in real time, with coalesced deltas and deduplicated feed items. +- **Copy comment as Markdown** — Each comment header has a one-click copy-as-markdown button. +- **Retry failed runs** — Failed and timed-out runs now show a Retry button on the run detail page. +- **Project status clickable** — The status chip in the project properties pane is now clickable for quick updates. +- **Scroll-to-bottom button** — Issue detail and run pages show a floating scroll-to-bottom button when you scroll up. +- **Database backup CLI** — `paperclipai db:backup` lets you snapshot the database on demand, with optional automatic scheduling. +- **Disable sign-up** — A new `auth.disableSignUp` config option (and `AUTH_DISABLE_SIGNUP` env var) lets operators lock registration. +- **Deduplicated shortnames** — Agent and project shortnames are now auto-deduplicated on create and update instead of rejecting duplicates. +- **Human-readable role labels** — The agent list and properties pane show friendly role names. +- **Assignee picker sorting** — Recent selections appear first, then alphabetical. +- **Mobile layout polish** — Unified GitHub-style issue rows across issues, inbox, and dashboard. Improved popover scrolling, command palette centering, and property toggles on mobile. +- **Invite UX improvements** — Invite links auto-copy to clipboard, snippet-only flow in settings, 10-minute invite TTL, and clearer network-host guidance. +- **Permalink anchors on comments** — Each comment has a stable anchor link and a GET-by-ID API endpoint. +- **Docker deployment hardening** — Authenticated deployment mode by default, named data volume, `PAPERCLIP_PUBLIC_URL` and `PAPERCLIP_ALLOWED_HOSTNAMES` exposed in compose files, health-check DB wait, and Node 24 base image. +- **Updated model lists** — Added `claude-sonnet-4-6`, `claude-haiku-4-6`, and `gpt-5.4` to adapter model constants. +- **Playwright e2e tests** — New end-to-end test suite covering the onboarding wizard flow. + +## Fixes + +- **Secret redaction in run logs** — Env vars sourced from secrets are now redacted by provenance, with consistent `secretKeys` tracking. +- **SPA catch-all 500s** — The server serves cached `index.html` in the catch-all route and uses `root` in `sendFile`, preventing 500 errors on dotfile paths and SPA refreshes. +- **Unmatched API routes return 404 JSON** — Previously fell through to the SPA handler. +- **Agent wake logic** — Agents wake when issues move out of backlog, skip self-wake on own comments, and skip wakeup for backlog-status changes. Pending-approval agents are excluded from heartbeat timers. +- **Run log fd leak** — Fixed a file-descriptor leak in log append that caused `spawn EBADF` errors. +- **500 error logging** — Error logs now include the actual error message and request context instead of generic pino-http output. +- **Boolean env parsing** — `parseBooleanFromEnv` no longer silently treats common truthy values as false. +- **Onboarding env defaults** — `onboard` now correctly derives secrets from env vars and reports ignored exposure settings in `local_trusted` mode. +- **Windows path compatibility** — Migration paths use `fileURLToPath` for Windows-safe resolution. +- **Secure cookies on HTTP** — Disabled secure cookie flag for plain HTTP deployments to prevent auth failures. +- **URL encoding** — `buildUrl` splits path and query to prevent `%3F` encoding issues. +- **Auth trusted origins** — Effective trusted origins and allowed hostnames are now applied correctly in public mode. +- **UI stability** — Fixed blank screen when prompt templates are emptied, search URL sync causing re-renders, issue title overflow in inbox, and sidebar badge counts including approvals. From a47ea343ba99262456014082ec8a3a7e1b1774db Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 09:59:43 -0500 Subject: [PATCH 26/34] feat: add committed-ref onboarding smoke script --- doc/RELEASING.md | 11 +++++ scripts/clean-onboard-ref.sh | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 scripts/clean-onboard-ref.sh diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 8ffd80fd..9ca74d11 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -128,6 +128,16 @@ If you want to exercise onboarding from a fresh local checkout rather than npm, That is not a required release step every time, but it is a useful higher-confidence check when onboarding is the main risk area or when you need to verify what the current codebase does before publishing. +If you want to exercise onboarding from the current committed ref in your local repo, use: + +```bash +./scripts/clean-onboard-ref.sh +PAPERCLIP_PORT=3234 ./scripts/clean-onboard-ref.sh +./scripts/clean-onboard-ref.sh HEAD +``` + +This uses the current committed `HEAD` in a detached temp worktree. It does **not** include uncommitted local edits. + ### GitHub Actions release There is also a manual workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml). It is designed for npm trusted publishing via GitHub OIDC instead of long-lived npm tokens. @@ -344,6 +354,7 @@ If you want to smoke onboarding from the current codebase rather than npm, run: ```bash ./scripts/clean-onboard-git.sh +./scripts/clean-onboard-ref.sh ``` Minimum checks: diff --git a/scripts/clean-onboard-ref.sh b/scripts/clean-onboard-ref.sh new file mode 100755 index 00000000..a42c5ea4 --- /dev/null +++ b/scripts/clean-onboard-ref.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_REF="${1:-HEAD}" + +usage() { + cat <<'EOF' +Usage: + ./scripts/clean-onboard-ref.sh [git-ref] + +Examples: + ./scripts/clean-onboard-ref.sh + ./scripts/clean-onboard-ref.sh HEAD + ./scripts/clean-onboard-ref.sh v0.2.7 + +Environment overrides: + KEEP_TEMP=1 Keep the temp directory and detached worktree for debugging + PC_TEST_ROOT=/tmp/custom Base temp directory to use + PC_DATA=/tmp/data Paperclip data dir to use + PAPERCLIP_HOST=127.0.0.1 Host passed to the onboarded server + PAPERCLIP_PORT=3232 Port passed to the onboarded server + +Notes: + - Defaults to the current committed ref (HEAD), not uncommitted local edits. + - Creates an isolated temp HOME, npm cache, data dir, and detached git worktree. +EOF +} + +if [ $# -gt 1 ]; then + usage + exit 1 +fi + +if [ $# -eq 1 ] && [[ "$1" =~ ^(-h|--help)$ ]]; then + usage + exit 0 +fi + +TARGET_COMMIT="$(git -C "$REPO_ROOT" rev-parse --verify "${TARGET_REF}^{commit}")" + +export KEEP_TEMP="${KEEP_TEMP:-0}" +export PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d /tmp/paperclip-clean-ref.XXXXXX)}" +export PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}" +export PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}" +export PC_DATA="${PC_DATA:-$PC_TEST_ROOT/paperclip-data}" +export PC_REPO="${PC_REPO:-$PC_TEST_ROOT/repo}" +export PAPERCLIP_HOST="${PAPERCLIP_HOST:-127.0.0.1}" +export PAPERCLIP_PORT="${PAPERCLIP_PORT:-3100}" +export PAPERCLIP_OPEN_ON_LISTEN="${PAPERCLIP_OPEN_ON_LISTEN:-false}" + +cleanup() { + if [ "$KEEP_TEMP" = "1" ]; then + return + fi + + git -C "$REPO_ROOT" worktree remove --force "$PC_REPO" >/dev/null 2>&1 || true + rm -rf "$PC_TEST_ROOT" +} + +trap cleanup EXIT + +mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA" + +echo "TARGET_REF: $TARGET_REF" +echo "TARGET_COMMIT: $TARGET_COMMIT" +echo "PC_TEST_ROOT: $PC_TEST_ROOT" +echo "PC_HOME: $PC_HOME" +echo "PC_DATA: $PC_DATA" +echo "PC_REPO: $PC_REPO" +echo "PAPERCLIP_HOST: $PAPERCLIP_HOST" +echo "PAPERCLIP_PORT: $PAPERCLIP_PORT" + +git -C "$REPO_ROOT" worktree add --detach "$PC_REPO" "$TARGET_COMMIT" + +cd "$PC_REPO" +pnpm install + +env \ + HOME="$PC_HOME" \ + npm_config_cache="$PC_CACHE" \ + npm_config_userconfig="$PC_HOME/.npmrc" \ + HOST="$PAPERCLIP_HOST" \ + PORT="$PAPERCLIP_PORT" \ + PAPERCLIP_OPEN_ON_LISTEN="$PAPERCLIP_OPEN_ON_LISTEN" \ + pnpm paperclipai onboard --yes --data-dir "$PC_DATA" From 0a8b96cdb3cb79cfad98e2f0e9bc5b1c7b8c3f31 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:03:45 -0500 Subject: [PATCH 27/34] http clone --- scripts/clean-onboard-git.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/clean-onboard-git.sh b/scripts/clean-onboard-git.sh index a662b57f..789b764d 100755 --- a/scripts/clean-onboard-git.sh +++ b/scripts/clean-onboard-git.sh @@ -7,7 +7,7 @@ mkdir -p "$PC_HOME" "$PC_CACHE" "$PC_DATA" echo "PC_TEST_ROOT: $PC_TEST_ROOT" echo "PC_HOME: $PC_HOME" cd $PC_TEST_ROOT -git clone github.com:paperclipai/paperclip.git repo +git clone https://github.com/paperclipai/paperclip.git repo cd repo pnpm install env HOME="$PC_HOME" npm_config_cache="$PC_CACHE" npm_config_userconfig="$PC_HOME/.npmrc" \ From f5bf743745a00ebcb172c0e6862e1ca066cfd6a3 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:11:46 -0500 Subject: [PATCH 28/34] fix: support older git in release cleanup --- scripts/release.sh | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/release.sh b/scripts/release.sh index 1c05e19c..07b1bd12 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -99,9 +99,24 @@ cleanup_release_state() { rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" - if [ -n "$(git -C "$REPO_ROOT" status --porcelain)" ]; then - git -C "$REPO_ROOT" restore --source=HEAD --staged --worktree . - rm -f "$TEMP_CHANGESET_FILE" "$TEMP_PRE_FILE" + tracked_changes="$(git -C "$REPO_ROOT" diff --name-only; git -C "$REPO_ROOT" diff --cached --name-only)" + if [ -n "$tracked_changes" ]; then + printf '%s\n' "$tracked_changes" | sort -u | while IFS= read -r path; do + [ -z "$path" ] && continue + git -C "$REPO_ROOT" checkout -q HEAD -- "$path" || true + done + fi + + untracked_changes="$(git -C "$REPO_ROOT" ls-files --others --exclude-standard)" + if [ -n "$untracked_changes" ]; then + printf '%s\n' "$untracked_changes" | while IFS= read -r path; do + [ -z "$path" ] && continue + if [ -d "$REPO_ROOT/$path" ]; then + rm -rf "$REPO_ROOT/$path" + else + rm -f "$REPO_ROOT/$path" + fi + done fi } From 31c947bf7fb1ef6aa6517098b73dc8732518976d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:23:04 -0500 Subject: [PATCH 29/34] fix: publish canaries in changesets pre mode --- scripts/release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/release.sh b/scripts/release.sh index 07b1bd12..ab9257df 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -435,7 +435,7 @@ if [ "$dry_run" = true ]; then else if [ "$canary" = true ]; then info "==> Step 6/7: Publishing canary to npm..." - npx changeset publish --tag canary + npx changeset publish info " ✓ Published ${TARGET_PUBLISH_VERSION} under dist-tag canary" else info "==> Step 6/7: Publishing stable release to npm..." From 422f57b16001ed8842d860a9a1351d8f8984fc34 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:33:56 -0500 Subject: [PATCH 30/34] chore: use public-gh for manual release flow --- doc/RELEASING.md | 4 ++-- scripts/create-github-release.sh | 5 +++-- scripts/release.sh | 3 ++- skills/release/SKILL.md | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 9ca74d11..1751f466 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -78,7 +78,7 @@ ls "releases/v${VERSION}.md" ./scripts/release.sh patch # 5. Push the release commit and tag -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags # 6. Create or update the GitHub Release from the pushed tag ./scripts/create-github-release.sh X.Y.Z @@ -393,7 +393,7 @@ What it does **not** do: After a stable publish succeeds: ```bash -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` diff --git a/scripts/create-github-release.sh b/scripts/create-github-release.sh index 4d1d0789..1a12a783 100755 --- a/scripts/create-github-release.sh +++ b/scripts/create-github-release.sh @@ -2,6 +2,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" dry_run=false version="" @@ -72,8 +73,8 @@ if [ "$dry_run" = true ]; then exit 0 fi -if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then - echo "Error: remote tag $tag was not found on origin. Push the release commit and tag first." >&2 +if ! git -C "$REPO_ROOT" ls-remote --exit-code --tags "$PUBLISH_REMOTE" "refs/tags/$tag" >/dev/null 2>&1; then + echo "Error: remote tag $tag was not found on $PUBLISH_REMOTE. Push the release commit and tag first." >&2 exit 1 fi diff --git a/scripts/release.sh b/scripts/release.sh index ab9257df..f21acc60 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -18,6 +18,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CLI_DIR="$REPO_ROOT/cli" TEMP_CHANGESET_FILE="$REPO_ROOT/.changeset/release-bump.md" TEMP_PRE_FILE="$REPO_ROOT/.changeset/pre.json" +PUBLISH_REMOTE="${PUBLISH_REMOTE:-public-gh}" dry_run=false canary=false @@ -479,6 +480,6 @@ elif [ "$canary" = true ]; then else info "Published stable v${TARGET_STABLE_VERSION}." info "Next steps:" - info " git push origin HEAD:master --follow-tags" + info " git push ${PUBLISH_REMOTE} HEAD:master --follow-tags" info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION" fi diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 088ed7ba..866bdb22 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -200,7 +200,7 @@ Stable publish does **not** push the release for you. After stable publish succeeds: ```bash -git push origin HEAD:master --follow-tags +git push public-gh HEAD:master --follow-tags ./scripts/create-github-release.sh X.Y.Z ``` From 8d53800c1993115d73551783a4a07a92bedc8acf Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:35:32 -0500 Subject: [PATCH 31/34] chore: restore pnpm-lock.yaml from master --- pnpm-lock.yaml | 47 +++++++++-------------------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2885e8a..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) - '@playwright/test': - specifier: ^1.58.2 - version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +35,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,6 +261,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -376,6 +379,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1690,11 +1696,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4011,11 +4012,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4802,16 +4798,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7408,10 +7394,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9890,9 +9872,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -10944,14 +10923,6 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - points-on-curve@0.2.0: {} points-on-path@0.2.1: From 948080fee9bebc8a0daadfe3396c3b7e6e89928d Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:37:38 -0500 Subject: [PATCH 32/34] Revert "chore: restore pnpm-lock.yaml from master" This reverts commit 8d53800c1993115d73551783a4a07a92bedc8acf. --- pnpm-lock.yaml | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff4f3e35..f2885e8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -35,9 +38,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,9 +261,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -379,9 +376,6 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local - '@paperclipai/adapter-openclaw': - specifier: workspace:* - version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1696,6 +1690,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4012,6 +4011,11 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4798,6 +4802,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7394,6 +7408,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9872,6 +9890,9 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -10923,6 +10944,14 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + points-on-curve@0.2.0: {} points-on-path@0.2.1: From 7d8d6a5cafde72ca4284cba0f8709967a4d62f0b Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:38:18 -0500 Subject: [PATCH 33/34] chore: remove lockfile changes from release branch --- pnpm-lock.yaml | 47 +++++++++-------------------------------------- 1 file changed, 9 insertions(+), 38 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2885e8a..ff4f3e35 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.2.3) - '@playwright/test': - specifier: ^1.58.2 - version: 1.58.2 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -38,6 +35,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -261,6 +261,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -376,6 +379,9 @@ importers: '@paperclipai/adapter-cursor-local': specifier: workspace:* version: link:../packages/adapters/cursor-local + '@paperclipai/adapter-openclaw': + specifier: workspace:* + version: link:../packages/adapters/openclaw '@paperclipai/adapter-openclaw-gateway': specifier: workspace:* version: link:../packages/adapters/openclaw-gateway @@ -1690,11 +1696,6 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - '@radix-ui/colors@3.0.0': resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} @@ -4011,11 +4012,6 @@ packages: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4802,16 +4798,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -7408,10 +7394,6 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - '@radix-ui/colors@3.0.0': {} '@radix-ui/number@1.1.1': {} @@ -9890,9 +9872,6 @@ snapshots: jsonfile: 4.0.0 universalify: 0.1.2 - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -10944,14 +10923,6 @@ snapshots: mlly: 1.8.1 pathe: 2.0.3 - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - points-on-curve@0.2.0: {} points-on-path@0.2.1: From 632079ae3b55002bb2e20957b8ca746e2e06e2a8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 9 Mar 2026 10:43:04 -0500 Subject: [PATCH 34/34] chore: require frozen lockfile for releases --- .github/workflows/release.yml | 4 ++-- doc/RELEASING.md | 33 +++++++++++++++++++++------------ skills/release/SKILL.md | 7 +++++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 492b02b5..d456eb7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile - name: Typecheck run: pnpm -r typecheck @@ -95,7 +95,7 @@ jobs: cache: pnpm - name: Install dependencies - run: pnpm install --no-frozen-lockfile + run: pnpm install --frozen-lockfile - name: Configure git author run: | diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 1751f466..1f9b7fae 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -26,20 +26,23 @@ Treat those as related but separate. npm can succeed while the GitHub Release is Use this when you want an installable prerelease without changing `latest`. ```bash -# 0. Preflight the canary candidate +# 0. Confirm master already has the CI-owned lockfile refresh merged +# If package manifests changed recently, wait for the refresh-lockfile PR first. + +# 1. Preflight the canary candidate ./scripts/release-preflight.sh canary patch -# 1. Draft or update the stable changelog for the intended stable version +# 2. Draft or update the stable changelog for the intended stable version VERSION=0.2.8 claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." -# 2. Preview the canary release +# 3. Preview the canary release ./scripts/release.sh patch --canary --dry-run -# 3. Publish the canary +# 4. Publish the canary ./scripts/release.sh patch --canary -# 4. Smoke test what users will actually install +# 5. Smoke test what users will actually install PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh # Users install with: @@ -60,27 +63,30 @@ Result: Use this only after the canary SHA is good enough to become the public default. ```bash -# 0. Start from the vetted commit +# 0. Confirm master already has the CI-owned lockfile refresh merged +# If package manifests changed recently, wait for the refresh-lockfile PR first. + +# 1. Start from the vetted commit git checkout master git pull -# 1. Preflight the stable candidate +# 2. Preflight the stable candidate ./scripts/release-preflight.sh stable patch -# 2. Confirm the stable changelog exists +# 3. Confirm the stable changelog exists VERSION=0.2.8 ls "releases/v${VERSION}.md" -# 3. Preview the stable publish +# 4. Preview the stable publish ./scripts/release.sh patch --dry-run -# 4. Publish the stable release to npm and create the local release commit + tag +# 5. Publish the stable release to npm and create the local release commit + tag ./scripts/release.sh patch -# 5. Push the release commit and tag +# 6. Push the release commit and tag git push public-gh HEAD:master --follow-tags -# 6. Create or update the GitHub Release from the pushed tag +# 7. Create or update the GitHub Release from the pushed tag ./scripts/create-github-release.sh X.Y.Z ``` @@ -163,6 +169,7 @@ The workflow: - [ ] The working tree is clean, including untracked files - [ ] The target branch and SHA are the ones you actually want to release +- [ ] If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` - [ ] The required verification gate passed on that exact SHA - [ ] The bump type is correct for the user-visible impact - [ ] The stable changelog file exists or is ready to be written at `releases/vX.Y.Z.md` @@ -202,6 +209,8 @@ pnpm build This matches [`.github/workflows/pr-verify.yml`](../.github/workflows/pr-verify.yml). Run it before claiming a release candidate is ready. +The release workflow at [`.github/workflows/release.yml`](../.github/workflows/release.yml) installs with `pnpm install --frozen-lockfile`. That is intentional. Releases must use the exact dependency graph already committed on `master`; if manifests changed and the CI-owned lockfile refresh has not landed yet, the release should fail until that prerequisite is merged. + For release work, prefer: ```bash diff --git a/skills/release/SKILL.md b/skills/release/SKILL.md index 866bdb22..085c1bad 100644 --- a/skills/release/SKILL.md +++ b/skills/release/SKILL.md @@ -36,8 +36,9 @@ Before proceeding, verify all of the following: 2. The repo working tree is clean, including untracked files. 3. There are commits since the last stable tag. 4. The release SHA has passed the verification gate or is about to. -5. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. -6. If running through Paperclip, you have issue context for status updates and follow-up task creation. +5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. +6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +7. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. @@ -120,6 +121,8 @@ pnpm build If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. +The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping. + ## Step 4 — Publish a Canary Run: