diff --git a/apps/sim/app/(landing)/actions/github.ts b/apps/sim/app/(landing)/actions/github.ts index 38aea8538b..efd28a2124 100644 --- a/apps/sim/app/(landing)/actions/github.ts +++ b/apps/sim/app/(landing)/actions/github.ts @@ -1,6 +1,6 @@ import { createLogger } from '@/lib/logs/console/logger' -const DEFAULT_STARS = '15k' +const DEFAULT_STARS = '15.4k' const logger = createLogger('GitHubStars') diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index ca82c6f7ab..5d194c40ac 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -214,6 +214,12 @@ export default function Footer({ fullWidth = false }: FooterProps) { > Enterprise + + Changelog + /g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export async function GET() { + try { + const res = await fetch('https://api.github.com/repos/simstudioai/sim/releases', { + headers: { Accept: 'application/vnd.github+json' }, + next: { revalidate }, + }) + const releases: Release[] = await res.json() + const items = (releases || []) + .filter((r) => !r.prerelease) + .map( + (r) => ` + + ${escapeXml(r.name || r.tag_name)} + ${r.html_url} + ${r.html_url} + ${new Date(r.published_at).toUTCString()} + + + ` + ) + .join('') + + const xml = ` + + + Sim Changelog + https://sim.dev/changelog + Latest changes, fixes and updates in Sim. + en-us + ${items} + + ` + + return new NextResponse(xml, { + status: 200, + headers: { + 'Content-Type': 'application/rss+xml; charset=utf-8', + 'Cache-Control': `public, s-maxage=${revalidate}, stale-while-revalidate=${revalidate}`, + }, + }) + } catch { + return new NextResponse('Service Unavailable', { status: 503 }) + } +} diff --git a/apps/sim/app/changelog/components/changelog-content.tsx b/apps/sim/app/changelog/components/changelog-content.tsx new file mode 100644 index 0000000000..6fea9d5581 --- /dev/null +++ b/apps/sim/app/changelog/components/changelog-content.tsx @@ -0,0 +1,105 @@ +import { BookOpen, Github, Rss } from 'lucide-react' +import Link from 'next/link' +import { inter } from '@/app/fonts/inter' +import { soehne } from '@/app/fonts/soehne/soehne' +import ChangelogList from './timeline-list' + +export interface ChangelogEntry { + tag: string + title: string + content: string + date: string + url: string + contributors?: string[] +} + +function extractMentions(body: string): string[] { + const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? [] + const uniq = Array.from(new Set(matches.map((m) => m.slice(1)))) + return uniq +} + +export default async function ChangelogContent() { + let entries: ChangelogEntry[] = [] + + try { + const res = await fetch( + 'https://api.github.com/repos/simstudioai/sim/releases?per_page=10&page=1', + { + headers: { Accept: 'application/vnd.github+json' }, + next: { revalidate: 3600 }, + } + ) + const releases: any[] = await res.json() + entries = (releases || []) + .filter((r) => !r.prerelease) + .map((r) => ({ + tag: r.tag_name, + title: r.name || r.tag_name, + content: String(r.body || ''), + date: r.published_at, + url: r.html_url, + contributors: extractMentions(String(r.body || '')), + })) + } catch (err) { + entries = [] + } + + return ( +
+
+ {/* Left intro panel */} +
+
+
+ +
+

+ Changelog +

+

+ Stay up-to-date with the latest features, improvements, and bug fixes in Sim. All + changes are documented here with detailed release notes. +

+
+ +
+ + + View on GitHub + + + + Documentation + + + + RSS Feed + +
+
+
+ + {/* Right timeline */} +
+
+ +
+
+
+
+ ) +} diff --git a/apps/sim/app/changelog/components/timeline-list.tsx b/apps/sim/app/changelog/components/timeline-list.tsx new file mode 100644 index 0000000000..ecb306c56f --- /dev/null +++ b/apps/sim/app/changelog/components/timeline-list.tsx @@ -0,0 +1,224 @@ +'use client' + +import React from 'react' +import ReactMarkdown from 'react-markdown' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { inter } from '@/app/fonts/inter' +import { soehne } from '@/app/fonts/soehne/soehne' +import type { ChangelogEntry } from './changelog-content' + +type Props = { initialEntries: ChangelogEntry[] } + +function sanitizeContent(body: string): string { + return body.replace(/ /g, '') +} + +function stripContributors(body: string): string { + let output = body + output = output.replace( + /(^|\n)#{1,6}\s*Contributors\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi, + '\n' + ) + output = output.replace( + /(^|\n)\s*(?:\*\*|__)?\s*Contributors\s*(?:\*\*|__)?\s*:?\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi, + '\n' + ) + output = output.replace( + /(^|\n)[-*+]\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g, + '\n' + ) + output = output.replace( + /(^|\n)\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g, + '\n' + ) + return output +} + +function isContributorsLabel(nodeChildren: React.ReactNode): boolean { + return /^\s*contributors\s*:?\s*$/i.test(String(nodeChildren)) +} + +function stripPrReferences(body: string): string { + return body.replace(/\s*\(\s*\[#\d+\]\([^)]*\)\s*\)/g, '').replace(/\s*\(\s*#\d+\s*\)/g, '') +} + +function cleanMarkdown(body: string): string { + const sanitized = sanitizeContent(body) + const withoutContribs = stripContributors(sanitized) + const withoutPrs = stripPrReferences(withoutContribs) + return withoutPrs +} + +function extractMentions(body: string): string[] { + const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? [] + return Array.from(new Set(matches.map((m) => m.slice(1)))) +} + +export default function ChangelogList({ initialEntries }: Props) { + const [entries, setEntries] = React.useState(initialEntries) + const [page, setPage] = React.useState(1) + const [loading, setLoading] = React.useState(false) + const [done, setDone] = React.useState(false) + + const loadMore = async () => { + if (loading || done) return + setLoading(true) + try { + const nextPage = page + 1 + const res = await fetch( + `https://api.github.com/repos/simstudioai/sim/releases?per_page=10&page=${nextPage}`, + { headers: { Accept: 'application/vnd.github+json' } } + ) + const releases: any[] = await res.json() + const mapped: ChangelogEntry[] = (releases || []) + .filter((r) => !r.prerelease) + .map((r) => ({ + tag: r.tag_name, + title: r.name || r.tag_name, + content: sanitizeContent(String(r.body || '')), + date: r.published_at, + url: r.html_url, + contributors: extractMentions(String(r.body || '')), + })) + + if (mapped.length === 0) { + setDone(true) + } else { + setEntries((prev) => [...prev, ...mapped]) + setPage(nextPage) + } + } catch { + setDone(true) + } finally { + setLoading(false) + } + } + + return ( +
+ {entries.map((entry) => ( +
+
+
+ {entry.tag} +
+
+ {new Date(entry.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + })} +
+
+ +
+ + isContributorsLabel(children) ? null : ( +

+ {children} +

+ ), + h3: ({ children, ...props }) => + isContributorsLabel(children) ? null : ( +

+ {children} +

+ ), + ul: ({ children, ...props }) => ( +
    + {children} +
+ ), + li: ({ children, ...props }) => { + const text = String(children) + if (/^\s*contributors\s*:?\s*$/i.test(text)) return null + return ( +
  • + {children} +
  • + ) + }, + p: ({ children, ...props }) => + /^\s*contributors\s*:?\s*$/i.test(String(children)) ? null : ( +

    + {children} +

    + ), + strong: ({ children, ...props }) => ( + + {children} + + ), + code: ({ children, ...props }) => ( + + {children} + + ), + img: () => null, + a: ({ className, ...props }: any) => ( + + ), + }} + > + {cleanMarkdown(entry.content)} +
    +
    + + {entry.contributors && entry.contributors.length > 0 && ( +
    + {entry.contributors.slice(0, 5).map((contributor) => ( + + + {contributor.slice(0, 2).toUpperCase()} + + ))} + {entry.contributors.length > 5 && ( +
    + +{entry.contributors.length - 5} +
    + )} +
    + )} +
    + ))} + + {!done && ( +
    + +
    + )} +
    + ) +} diff --git a/apps/sim/app/changelog/layout.tsx b/apps/sim/app/changelog/layout.tsx new file mode 100644 index 0000000000..57508fe7a7 --- /dev/null +++ b/apps/sim/app/changelog/layout.tsx @@ -0,0 +1,10 @@ +import Nav from '@/app/(landing)/components/nav/nav' + +export default function ChangelogLayout({ children }: { children: React.ReactNode }) { + return ( +
    +
    + ) +} diff --git a/apps/sim/app/changelog/page.tsx b/apps/sim/app/changelog/page.tsx new file mode 100644 index 0000000000..39dbfeaa79 --- /dev/null +++ b/apps/sim/app/changelog/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import ChangelogContent from './components/changelog-content' + +export const metadata: Metadata = { + title: 'Changelog', + description: 'Stay up-to-date with the latest features, improvements, and bug fixes in Sim.', + openGraph: { + title: 'Changelog', + description: 'Stay up-to-date with the latest features, improvements, and bug fixes in Sim.', + type: 'website', + }, +} + +export default function ChangelogPage() { + return +} diff --git a/apps/sim/app/conditional-theme-provider.tsx b/apps/sim/app/conditional-theme-provider.tsx index 332b094ec1..fedddf40f2 100644 --- a/apps/sim/app/conditional-theme-provider.tsx +++ b/apps/sim/app/conditional-theme-provider.tsx @@ -7,7 +7,7 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes' export function ConditionalThemeProvider({ children, ...props }: ThemeProviderProps) { const pathname = usePathname() - // Force light mode for landing page (root path and /homepage), auth verify, invite, and legal pages + // Force light mode for certain pages const forcedTheme = pathname === '/' || pathname === '/homepage' || @@ -16,7 +16,8 @@ export function ConditionalThemeProvider({ children, ...props }: ThemeProviderPr pathname.startsWith('/terms') || pathname.startsWith('/privacy') || pathname.startsWith('/invite') || - pathname.startsWith('/verify') + pathname.startsWith('/verify') || + pathname.startsWith('/changelog') ? 'light' : undefined diff --git a/apps/sim/components/ui/avatar.tsx b/apps/sim/components/ui/avatar.tsx index 38b7a5e985..6ecb201311 100644 --- a/apps/sim/components/ui/avatar.tsx +++ b/apps/sim/components/ui/avatar.tsx @@ -2,8 +2,26 @@ import * as React from 'react' import * as AvatarPrimitive from '@radix-ui/react-avatar' +import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' +const avatarStatusVariants = cva( + 'flex items-center rounded-full size-2 border-2 border-background', + { + variants: { + variant: { + online: 'bg-green-600', + offline: 'bg-zinc-600 dark:bg-zinc-300', + busy: 'bg-yellow-600', + away: 'bg-blue-600', + }, + }, + defaultVariants: { + variant: 'online', + }, + } +) + const Avatar = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -22,7 +40,7 @@ const AvatarImage = React.forwardRef< >(({ className, ...props }, ref) => ( )) @@ -35,7 +53,7 @@ const AvatarFallback = React.forwardRef< ) { + return ( +
    + ) +} + +function AvatarStatus({ + className, + variant, + ...props +}: React.HTMLAttributes & VariantProps) { + return ( +
    + ) +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarIndicator, AvatarStatus, avatarStatusVariants } diff --git a/apps/sim/lib/security/csp.ts b/apps/sim/lib/security/csp.ts index 42d14e9ce1..a4d117b03a 100644 --- a/apps/sim/lib/security/csp.ts +++ b/apps/sim/lib/security/csp.ts @@ -62,6 +62,7 @@ export const buildTimeCSPDirectives: CSPDirectives = { 'https://*.public.blob.vercel-storage.com', 'https://*.s3.amazonaws.com', 'https://s3.amazonaws.com', + 'https://github.com/*', ...(env.S3_BUCKET_NAME && env.AWS_REGION ? [`https://${env.S3_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com`] : []), @@ -73,6 +74,7 @@ export const buildTimeCSPDirectives: CSPDirectives = { : []), 'https://*.amazonaws.com', 'https://*.blob.core.windows.net', + 'https://github.com/*', ...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL), ...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_FAVICON_URL), ], @@ -107,6 +109,8 @@ export const buildTimeCSPDirectives: CSPDirectives = { 'https://*.vercel.app', 'wss://*.vercel.app', 'https://pro.ip-api.com', + 'https://api.github.com', + 'https://github.com/*', ...getHostnameFromUrl(env.NEXT_PUBLIC_BRAND_LOGO_URL), ...getHostnameFromUrl(env.NEXT_PUBLIC_PRIVACY_URL), ...getHostnameFromUrl(env.NEXT_PUBLIC_TERMS_URL), @@ -169,7 +173,7 @@ export function generateRuntimeCSP(): string { img-src 'self' data: blob: https://*.googleusercontent.com https://*.google.com https://*.atlassian.com https://cdn.discordapp.com https://*.githubusercontent.com https://*.public.blob.vercel-storage.com ${brandLogoDomain} ${brandFaviconDomain}; media-src 'self' blob:; font-src 'self' https://fonts.gstatic.com; - connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://*.up.railway.app wss://*.up.railway.app https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://*.vercel-insights.com https://vitals.vercel-insights.com https://*.atlassian.com https://*.supabase.co https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app wss://*.vercel.app https://pro.ip-api.com ${dynamicDomainsStr}; + connect-src 'self' ${appUrl} ${ollamaUrl} ${socketUrl} ${socketWsUrl} https://*.up.railway.app wss://*.up.railway.app https://api.browser-use.com https://api.exa.ai https://api.firecrawl.dev https://*.googleapis.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.blob.core.windows.net https://api.github.com https://github.com/* https://*.vercel-insights.com https://vitals.vercel-insights.com https://*.atlassian.com https://*.supabase.co https://vercel.live https://*.vercel.live https://vercel.com https://*.vercel.app wss://*.vercel.app https://pro.ip-api.com ${dynamicDomainsStr}; frame-src https://drive.google.com https://docs.google.com https://*.google.com; frame-ancestors 'self'; form-action 'self'; diff --git a/bun.lock b/bun.lock index f6d32d8e77..da40564970 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", "@next/env": "15.4.1", + "@octokit/rest": "^21.0.0", "@types/bcryptjs": "3.0.0", "drizzle-kit": "^0.31.4", "husky": "9.1.7", @@ -744,6 +745,30 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@11.6.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@5.3.1", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@13.5.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw=="], + + "@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="], + + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/rest": ["@octokit/rest@21.1.1", "", { "dependencies": { "@octokit/core": "^6.1.4", "@octokit/plugin-paginate-rest": "^11.4.2", "@octokit/plugin-request-log": "^5.3.1", "@octokit/plugin-rest-endpoint-methods": "^13.3.0" } }, "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], @@ -1672,6 +1697,8 @@ "bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="], + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + "better-auth": ["better-auth@1.2.9", "", { "dependencies": { "@better-auth/utils": "0.2.5", "@better-fetch/fetch": "^1.1.18", "@noble/ciphers": "^0.6.0", "@noble/hashes": "^1.6.1", "@simplewebauthn/browser": "^13.0.0", "@simplewebauthn/server": "^13.0.0", "better-call": "^1.0.8", "defu": "^6.1.4", "jose": "^5.9.6", "kysely": "^0.28.2", "nanostores": "^0.11.3", "zod": "^3.24.1" } }, "sha512-WLqBXDzuaCQetQctLGC5oTfGmL32zUvxnM4Y+LZkhwseMaZWq5EKI+c/ZATgz2YkFt7726q659PF8CfB9P1VuA=="], "better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="], @@ -2090,6 +2117,8 @@ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -3346,6 +3375,8 @@ "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "unplugin": ["unplugin@1.0.1", "", { "dependencies": { "acorn": "^8.8.1", "chokidar": "^3.5.3", "webpack-sources": "^3.2.3", "webpack-virtual-modules": "^0.5.0" } }, "sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA=="], @@ -3524,6 +3555,10 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + "@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@0.25.0", "", {}, "sha512-V3N+MDBiv0TUlorbgiSqk6CvcP876CYUk/41Tg6s8OIyvniTwprE6vPvFQayuABiVkGlHOxv1Mlvp0w4qNdnVg=="], "@opentelemetry/exporter-collector/@opentelemetry/resources": ["@opentelemetry/resources@0.25.0", "", { "dependencies": { "@opentelemetry/core": "0.25.0", "@opentelemetry/semantic-conventions": "0.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.2" } }, "sha512-O46u53vDBlxCML8O9dIjsRcCC2VT5ri1upwhp02ITobgJ16aVD/iScCo1lPl/x2E7yq9uwzMINENiiYZRFb6XA=="], @@ -4218,6 +4253,10 @@ "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + "@opentelemetry/exporter-collector/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@0.25.0", "", {}, "sha512-V3N+MDBiv0TUlorbgiSqk6CvcP876CYUk/41Tg6s8OIyvniTwprE6vPvFQayuABiVkGlHOxv1Mlvp0w4qNdnVg=="], "@opentelemetry/instrumentation-amqplib/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], diff --git a/package.json b/package.json index 3e4b55fdd8..80ba33ce51 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "check": "bunx biome check --files-ignore-unknown=true", "prepare": "bun husky", "prebuild": "bun run lint:check", - "type-check": "turbo run type-check" + "type-check": "turbo run type-check", + "release": "bun run scripts/create-single-release.ts" }, "overrides": { "react": "19.1.0", @@ -49,6 +50,7 @@ }, "devDependencies": { "@biomejs/biome": "2.0.0-beta.5", + "@octokit/rest": "^21.0.0", "@next/env": "15.4.1", "@types/bcryptjs": "3.0.0", "drizzle-kit": "^0.31.4", diff --git a/scripts/create-single-release.ts b/scripts/create-single-release.ts new file mode 100755 index 0000000000..3e6d90c56f --- /dev/null +++ b/scripts/create-single-release.ts @@ -0,0 +1,385 @@ +#!/usr/bin/env bun + +import { execSync } from 'node:child_process' +import { Octokit } from '@octokit/rest' + +const GITHUB_TOKEN = process.env.GH_PAT +const REPO_OWNER = 'simstudioai' +const REPO_NAME = 'sim' + +if (!GITHUB_TOKEN) { + console.error('❌ GH_PAT environment variable is required') + process.exit(1) +} + +const targetVersion = process.argv[2] +if (!targetVersion) { + console.error('❌ Version argument is required') + console.error('Usage: bun run scripts/create-single-release.ts v0.3.XX') + process.exit(1) +} + +const octokit = new Octokit({ + auth: GITHUB_TOKEN, +}) + +interface VersionCommit { + hash: string + version: string + title: string + date: string + author: string +} + +interface CommitDetail { + hash: string + message: string + author: string + githubUsername: string + prNumber?: string +} + +function execCommand(command: string): string { + try { + return execSync(command, { encoding: 'utf8' }).trim() + } catch (error) { + console.error(`❌ Command failed: ${command}`) + throw error + } +} + +function findVersionCommit(version: string): VersionCommit | null { + console.log(`πŸ” Finding commit for version ${version}...`) + + const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main') + const lines = gitLog.split('\n').filter((line) => line.trim()) + + for (const line of lines) { + const [hash, message, date, author] = line.split('|') + + const versionMatch = message.match(/^(v\d+\.\d+\.?\d*):\s*(.+)$/) + if (versionMatch && versionMatch[1] === version) { + return { + hash, + version, + title: versionMatch[2], + date: new Date(date).toISOString(), + author, + } + } + } + + return null +} + +function findPreviousVersionCommit(currentVersion: string): VersionCommit | null { + console.log(`πŸ” Finding previous version before ${currentVersion}...`) + + const gitLog = execCommand('git log --oneline --format="%H|%s|%ai|%an" main') + const lines = gitLog.split('\n').filter((line) => line.trim()) + + let foundCurrent = false + + for (const line of lines) { + const [hash, message, date, author] = line.split('|') + + const versionMatch = message.match(/^(v\d+\.\d+\.?\d*):\s*(.+)$/) + if (versionMatch) { + if (versionMatch[1] === currentVersion) { + foundCurrent = true + continue + } + + if (foundCurrent) { + return { + hash, + version: versionMatch[1], + title: versionMatch[2], + date: new Date(date).toISOString(), + author, + } + } + } + } + + return null +} + +async function fetchGitHubCommitDetails( + commitHashes: string[] +): Promise> { + console.log(`πŸ” Fetching GitHub commit details for ${commitHashes.length} commits...`) + const commitMap = new Map() + + for (let i = 0; i < commitHashes.length; i++) { + const hash = commitHashes[i] + + try { + const { data: commit } = await octokit.rest.repos.getCommit({ + owner: REPO_OWNER, + repo: REPO_NAME, + ref: hash, + }) + + const prMatch = commit.commit.message.match(/\(#(\d+)\)/) + const prNumber = prMatch ? prMatch[1] : undefined + + const githubUsername = commit.author?.login || commit.committer?.login || 'unknown' + + let cleanMessage = commit.commit.message.split('\n')[0] // First line only + if (prNumber) { + cleanMessage = cleanMessage.replace(/\s*\(#\d+\)\s*$/, '') + } + + commitMap.set(hash, { + hash, + message: cleanMessage, + author: commit.commit.author?.name || 'Unknown', + githubUsername, + prNumber, + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + } catch (error: any) { + console.warn(`⚠️ Could not fetch commit ${hash.substring(0, 7)}: ${error?.message || error}`) + + try { + const gitData = execCommand(`git log --format="%s|%an" -1 ${hash}`).split('|') + let message = gitData[0] || 'Unknown commit' + + const prMatch = message.match(/\(#(\d+)\)/) + const prNumber = prMatch ? prMatch[1] : undefined + + if (prNumber) { + message = message.replace(/\s*\(#\d+\)\s*$/, '') + } + + commitMap.set(hash, { + hash, + message, + author: gitData[1] || 'Unknown', + githubUsername: 'unknown', + prNumber, + }) + } catch (fallbackError) { + console.error(`❌ Failed to get fallback data for ${hash.substring(0, 7)}`) + } + } + } + + return commitMap +} + +async function getCommitsBetweenVersions( + currentCommit: VersionCommit, + previousCommit?: VersionCommit +): Promise { + try { + let range: string + + if (previousCommit) { + range = `${previousCommit.hash}..${currentCommit.hash}` + console.log( + `πŸ” Getting commits between ${previousCommit.version} and ${currentCommit.version}` + ) + } else { + range = `${currentCommit.hash}~10..${currentCommit.hash}` + console.log(`πŸ” Getting commits before first version ${currentCommit.version}`) + } + + const gitLog = execCommand(`git log --oneline --format="%H|%s" ${range}`) + + if (!gitLog.trim()) { + console.log(`⚠️ No commits found in range ${range}`) + return [] + } + + const commitEntries = gitLog.split('\n').filter((line) => line.trim()) + + const nonVersionCommits = commitEntries.filter((line) => { + const [hash, message] = line.split('|') + const isVersionCommit = message.match(/^v\d+\.\d+/) + if (isVersionCommit) { + console.log(`⏭️ Skipping version commit: ${message.substring(0, 50)}...`) + return false + } + return true + }) + + console.log(`πŸ“‹ After filtering version commits: ${nonVersionCommits.length} commits`) + + if (nonVersionCommits.length === 0) { + return [] + } + + const commitHashes = nonVersionCommits.map((line) => line.split('|')[0]) + + const commitMap = await fetchGitHubCommitDetails(commitHashes) + + return commitHashes.map((hash) => commitMap.get(hash)!).filter(Boolean) + } catch (error) { + console.error(`❌ Error getting commits between versions:`, error) + return [] + } +} + +function categorizeCommit(message: string): 'features' | 'fixes' | 'improvements' | 'other' { + const msgLower = message.toLowerCase() + + if ( + msgLower.includes('feat') || + msgLower.includes('add') || + msgLower.includes('implement') || + msgLower.includes('new ') + ) { + return 'features' + } + + if (msgLower.includes('fix') || msgLower.includes('bug') || msgLower.includes('error')) { + return 'fixes' + } + + if ( + msgLower.includes('improve') || + msgLower.includes('enhance') || + msgLower.includes('update') || + msgLower.includes('upgrade') || + msgLower.includes('optimization') + ) { + return 'improvements' + } + + return 'other' +} + +async function generateReleaseBody( + versionCommit: VersionCommit, + previousCommit?: VersionCommit +): Promise { + console.log(`πŸ“ Generating release body for ${versionCommit.version}...`) + + const commits = await getCommitsBetweenVersions(versionCommit, previousCommit) + + if (commits.length === 0) { + console.log(`⚠️ No commits found, using simple format`) + return `${versionCommit.title} + +[View changes on GitHub](https://github.com/${REPO_OWNER}/${REPO_NAME}/compare/${previousCommit?.version || 'v1.0.0'}...${versionCommit.version})` + } + + console.log(`πŸ“‹ Processing ${commits.length} commits for categorization`) + + const features = commits.filter((c) => categorizeCommit(c.message) === 'features') + const fixes = commits.filter((c) => categorizeCommit(c.message) === 'fixes') + const improvements = commits.filter((c) => categorizeCommit(c.message) === 'improvements') + const others = commits.filter((c) => categorizeCommit(c.message) === 'other') + + console.log( + `πŸ“Š Categories: ${features.length} features, ${improvements.length} improvements, ${fixes.length} fixes, ${others.length} other` + ) + + let body = '' + + if (features.length > 0) { + body += '## Features\n\n' + for (const commit of features) { + const prLink = commit.prNumber ? ` (#${commit.prNumber})` : '' + body += `- ${commit.message}${prLink}\n` + } + body += '\n' + } + + if (improvements.length > 0) { + body += '## Improvements\n\n' + for (const commit of improvements) { + const prLink = commit.prNumber ? ` (#${commit.prNumber})` : '' + body += `- ${commit.message}${prLink}\n` + } + body += '\n' + } + + if (fixes.length > 0) { + body += '## Bug Fixes\n\n' + for (const commit of fixes) { + const prLink = commit.prNumber ? ` (#${commit.prNumber})` : '' + body += `- ${commit.message}${prLink}\n` + } + body += '\n' + } + + if (others.length > 0) { + body += '## Other Changes\n\n' + for (const commit of others) { + const prLink = commit.prNumber ? ` (#${commit.prNumber})` : '' + body += `- ${commit.message}${prLink}\n` + } + body += '\n' + } + + const uniqueContributors = new Set() + commits.forEach((commit) => { + if (commit.githubUsername && commit.githubUsername !== 'unknown') { + uniqueContributors.add(commit.githubUsername) + } + }) + + if (uniqueContributors.size > 0) { + body += '## Contributors\n\n' + for (const contributor of Array.from(uniqueContributors).sort()) { + body += `- @${contributor}\n` + } + body += '\n' + } + + body += `[View changes on GitHub](https://github.com/${REPO_OWNER}/${REPO_NAME}/compare/${previousCommit?.version || 'v1.0.0'}...${versionCommit.version})` + + return body.trim() +} + +async function main() { + try { + console.log(`πŸš€ Creating single release for ${targetVersion}...`) + + const versionCommit = findVersionCommit(targetVersion) + if (!versionCommit) { + console.error(`❌ No commit found for version ${targetVersion}`) + process.exit(1) + } + + console.log( + `βœ… Found version commit: ${versionCommit.hash.substring(0, 7)} - ${versionCommit.title}` + ) + + const previousCommit = findPreviousVersionCommit(targetVersion) + if (previousCommit) { + console.log(`βœ… Found previous version: ${previousCommit.version}`) + } else { + console.log(`ℹ️ No previous version found (this might be the first release)`) + } + + const releaseBody = await generateReleaseBody(versionCommit, previousCommit || undefined) + + console.log(`πŸš€ Creating GitHub release for ${targetVersion}...`) + + await octokit.rest.repos.createRelease({ + owner: REPO_OWNER, + repo: REPO_NAME, + tag_name: targetVersion, + name: targetVersion, + body: releaseBody, + draft: false, + prerelease: false, + target_commitish: versionCommit.hash, + }) + + console.log(`βœ… Successfully created release: ${targetVersion}`) + console.log( + `πŸ”— View release: https://github.com/${REPO_OWNER}/${REPO_NAME}/releases/tag/${targetVersion}` + ) + } catch (error) { + console.error('❌ Script failed:', error) + process.exit(1) + } +} + +main()