collective

Browse / github.com/storytold/artcraft-services / patch

Show each Crafting App's latest release on the /apps pages

Show each Crafting App's latest release on the /apps pages The `/apps` pages pinned every app's version by hand, so they fell behind every release: getartcraft.com/apps/photocraft still offers 0.7.0, while 0.10.0 is out. Every other app was behind as well. ## What changes The pages now get versions from GitHub at render time instead of from hardcoded constants: - **`fetchCraftRelease(repo, assets)`** calls GitHub's `releases/latest` for each repo, which only returns published, non-pre-release builds. Next caches the result for an hour. - **`/apps` and `/apps/[slug]` regenerate hourly** (`revalidate = 3600`). A new release appears on the live deploy

Status
open
Score
0.00
Adopters
0 other operators
Author
tanish2k09 (imported from pull request #169, open)
Base
e57ee3d63ac478a24149384bf1473ef97aa067f8
Size
+136 −71 in 10 files
Review
pending
Shared
2026-10-28
Id
6a3da93f62409d772ae608a7

How it went

No other operator has reported using it yet.

Diff

raw · AGENTS.md frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx frontend/apps/artcraft-website-next/src/app/apps/page.tsx frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts

diff --git a/AGENTS.md b/AGENTS.md--- a/AGENTS.md+++ b/AGENTS.md@@ -67,6 +67,12 @@ filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS require deployment of the affected apps before the public pages change; do not report a local update as a deployed update. +The Crafting App pages (`/apps`, `/apps/<slug>`) are different: they read each+app's and the ArtCraft Launcher's latest GitHub release when they render and+regenerate hourly, so their versions are never edited by hand. To hide an app's+downloads or change its file list, see the header of+`frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts`.+ ## Code Style  - Rust with no minimum supported versiondiff --git a/frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx b/frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx--- a/frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx+++ b/frontend/apps/artcraft-website-next/src/app/apps/[slug]/page.tsx@@ -47,6 +47,10 @@ type Params = Promise<{ slug: string }>;  export const dynamicParams = false; +// Downloads follow each app's latest GitHub release; regenerate hourly to pick+// up new ones (CRAFT_RELEASE_REVALIDATE_SECONDS; route config must be a literal).+export const revalidate = 3600;+ export function generateStaticParams() {   return CRAFTING_APPS.map((app) => ({ slug: app.slug })); }@@ -82,12 +86,12 @@ export default async function CraftAppPage({ params }: { params: Params }) {   if (!app) notFound();    const name = craftAppName(app);-  const release = craftAppRelease(app);+  const release = await craftAppRelease(app);   // The hero's download is ArtCraft Launcher, which installs this app and keeps   // it updated; the app's own installers are in #get-it, under the launcher.   // An app with no release yet has nothing for the launcher to install.-  const launcherDownloads = release ? craftLauncherRecommended() : [];-  const allLauncherDownloads = craftLauncherDownloads();+  const launcherDownloads = release ? await craftLauncherRecommended() : [];+  const allLauncherDownloads = await craftLauncherDownloads();   const hasDownload = release !== null;   const [hero] = app.shots;   const [before, accent, after] = app.headline;diff --git a/frontend/apps/artcraft-website-next/src/app/apps/page.tsx b/frontend/apps/artcraft-website-next/src/app/apps/page.tsx--- a/frontend/apps/artcraft-website-next/src/app/apps/page.tsx+++ b/frontend/apps/artcraft-website-next/src/app/apps/page.tsx@@ -72,9 +72,13 @@ const PRINCIPLE_ICONS = [   <GlobeIcon key="web" aria-hidden className="h-5 w-5" />, ]; -export default function CraftingAppsPage() {-  const launcherDownloads = craftLauncherRecommended();-  const allLauncherDownloads = craftLauncherDownloads();+// Downloads follow each app's latest GitHub release; regenerate hourly to pick+// up new ones (CRAFT_RELEASE_REVALIDATE_SECONDS; route config must be a literal).+export const revalidate = 3600;++export default async function CraftingAppsPage() {+  const launcherDownloads = await craftLauncherRecommended();+  const allLauncherDownloads = await craftLauncherDownloads();   return (     <>       <RevealManager />@@ -126,7 +130,7 @@ export default function CraftingAppsPage() {             <LauncherDownloadButton               recommended={launcherDownloads}               allDownloads={allLauncherDownloads}-              fallbackHref={craftLauncherReleasePageUrl()}+              fallbackHref={await craftLauncherReleasePageUrl()}             />           )}           <DiscordButtondiff --git a/frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx b/frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx--- a/frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx+++ b/frontend/apps/artcraft-website-next/src/components/apps/app-card.tsx@@ -16,7 +16,7 @@ import { AppIcon, AppTab, AppWordmark, appThemeClass } from "./app-wordmark"; // straddling the screenshot's bottom edge, wordmark, pitch, and (unless // compact) status/platform chips. The whole cell is the link; a hairline in // the app's color draws across the top on hover.-export default function AppCard({+export default async function AppCard({   app,   index,   compact = false,@@ -26,7 +26,7 @@ export default function AppCard({   compact?: boolean; }) {   const [hero] = app.shots;-  const release = craftAppRelease(app);+  const release = await craftAppRelease(app);   return (     <Link       href={craftAppPath(app)}diff --git a/frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx b/frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx--- a/frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx+++ b/frontend/apps/artcraft-website-next/src/components/apps/app-downloads.tsx@@ -11,7 +11,6 @@ import { import { twMerge } from "tailwind-merge"; import { WindowsIcon } from "@/components/icons"; import { Button, type ButtonProps } from "@/components/ui";-import { craftLauncherDownloads } from "@/lib/craft-launcher"; import {   CRAFT_DESKTOP_PLATFORMS,   type CraftDesktopPlatform,@@ -178,7 +177,7 @@ export function LauncherDownloadButton({   className, }: {   recommended: CraftDownload[];-  allDownloads?: CraftDownload[];+  allDownloads: CraftDownload[];   fallbackHref: string;   size?: ButtonProps["size"];   variant?: ButtonProps["variant"];@@ -213,7 +212,7 @@ export function LauncherDownloadButton({   }, [menuOpen]);    const isLinux = detected?.platform === "Linux";-  const downloads = allDownloads ?? (isLinux ? craftLauncherDownloads() : []);+  const downloads = isLinux ? allDownloads : [];   const linuxDownloads = isLinux     ? downloads.filter(         (download) =>diff --git a/frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx b/frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx--- a/frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx+++ b/frontend/apps/artcraft-website-next/src/components/apps/app-sections.tsx@@ -9,7 +9,7 @@ import { SectionShell, SectionEyebrow } from "@/components/landing/section-shell import LazyVideo from "@/components/lazy-video"; import { Badge, Button, CopyButton } from "@/components/ui"; import { trackAttrs } from "@/lib/analytics";-import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases";+import { craftLauncherRelease } from "@/lib/craft-launcher"; import {   CRAFTING_APPS,   craftAppBuildCommand,@@ -157,11 +157,12 @@ export function AppGallery({ app, index }: { app: CraftApp; index: string }) { // away) above release details and build-from-source. Versions and files come // from crafting-app-releases.ts; a null release there drops the cards and // shows the Discord waitlist instead.-export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {+export async function AppGetIt({ app, index }: { app: CraftApp; index: string }) {   const name = craftAppName(app);   const command = craftAppBuildCommand(app);   const desktop = app.platforms.filter((platform) => platform !== "Web");-  const release = craftAppRelease(app);+  const release = await craftAppRelease(app);+  const launcherRelease = await craftLauncherRelease();   const downloads = release ? craftReleaseDownloads(app, release) : [];   const extras = downloads.filter((download) => download.group === "Other"); @@ -174,7 +175,7 @@ export function AppGetIt({ app, index }: { app: CraftApp; index: string }) {       />       {release && (         <>-          {CRAFT_LAUNCHER_RELEASE && (+          {launcherRelease && (             <>               <LauncherCallout app={app} />               <div data-reveal className="border-y border-line bg-bg p-6 md:p-10">diff --git a/frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx b/frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx--- a/frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx+++ b/frontend/apps/artcraft-website-next/src/components/apps/launcher-callout.tsx@@ -3,9 +3,9 @@ import {   CRAFT_LAUNCHER_NAME,   CRAFT_LAUNCHER_REPO,   craftLauncherDownloads,+  craftLauncherRelease,   craftLauncherReleasePageUrl, } from "@/lib/craft-launcher";-import { CRAFT_LAUNCHER_RELEASE } from "@/lib/crafting-app-releases"; import { craftAppName, type CraftApp, type CraftDownload } from "@/lib/crafting-apps"; import { LauncherDownloadButton } from "./app-downloads"; import { ColorAccent } from "./app-wordmark";@@ -18,16 +18,17 @@ import { ColorAccent } from "./app-wordmark"; const CALLOUT_POINTS = ["Every Crafting App", "One-click updates", "Verified downloads"];  /** The launcher's main download per platform, for the OS-aware buttons. */-export function craftLauncherRecommended(): CraftDownload[] {-  return craftLauncherDownloads().filter((download) => download.recommended);+export async function craftLauncherRecommended(): Promise<CraftDownload[]> {+  return (await craftLauncherDownloads()).filter((download) => download.recommended); }  // Leads an app's Get-it section, above that app's own installers: the pitch // beside its download panel, matching the release and build-from-source // cells below it. Renders nothing while the launcher has no release.-export function LauncherCallout({ app }: { app: CraftApp }) {-  const recommended = craftLauncherRecommended();-  if (!CRAFT_LAUNCHER_RELEASE || recommended.length === 0) return null;+export async function LauncherCallout({ app }: { app: CraftApp }) {+  const release = await craftLauncherRelease();+  const recommended = await craftLauncherRecommended();+  if (!release || recommended.length === 0) return null;   const name = craftAppName(app);   return (     <div@@ -61,12 +62,12 @@ export function LauncherCallout({ app }: { app: CraftApp }) {       </div>       <div className="flex flex-col justify-center bg-bg p-6 md:p-10">         <p className="hud-label text-faint">-          {CRAFT_LAUNCHER_NAME} · v{CRAFT_LAUNCHER_RELEASE.version}+          {CRAFT_LAUNCHER_NAME} · v{release.version}         </p>         <LauncherDownloadButton           recommended={recommended}-          allDownloads={craftLauncherDownloads()}-          fallbackHref={craftLauncherReleasePageUrl()}+          allDownloads={await craftLauncherDownloads()}+          fallbackHref={await craftLauncherReleasePageUrl()}           fullWidth           className="mt-4"         />diff --git a/frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts b/frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts--- a/frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts+++ b/frontend/apps/artcraft-website-next/src/lib/craft-launcher.ts@@ -1,4 +1,8 @@-import { CRAFT_LAUNCHER_RELEASE } from "./crafting-app-releases";+import {+  CRAFT_LAUNCHER_ASSETS,+  fetchCraftRelease,+  type CraftRelease,+} from "./crafting-app-releases"; import {   CRAFTING_APPS_GITHUB_ORG,   craftReleaseTag,@@ -10,22 +14,27 @@ import { // every Crafting App. The /apps hub's main button downloads it and every // /apps/<slug> Get-it section recommends it above that app's own installers. //-// Facts come from the craft-launcher README — keep them in sync. Version and-// files live in crafting-app-releases.ts (CRAFT_LAUNCHER_RELEASE); null there-// hides every launcher download.+// Facts come from the craft-launcher README — keep them in sync. The version+// is the repo's latest GitHub release; its files are CRAFT_LAUNCHER_ASSETS in+// crafting-app-releases.ts, and null there hides every launcher download.  export const CRAFT_LAUNCHER_NAME = "ArtCraft Launcher"; export const CRAFT_LAUNCHER_REPO = `${CRAFTING_APPS_GITHUB_ORG}/craft-launcher`; -/** Every launcher release file, or none while the release is null. */-export function craftLauncherDownloads(): CraftDownload[] {-  return CRAFT_LAUNCHER_RELEASE-    ? releaseDownloads(CRAFT_LAUNCHER_REPO, "artcraft-launcher", CRAFT_LAUNCHER_RELEASE)-    : [];+/** The launcher's latest GitHub release, or null while its downloads are hidden. */+export async function craftLauncherRelease(): Promise<CraftRelease | null> {+  return CRAFT_LAUNCHER_ASSETS ? fetchCraftRelease("craft-launcher", CRAFT_LAUNCHER_ASSETS) : null;+}++/** Every launcher release file, or none while the release is hidden. */+export async function craftLauncherDownloads(): Promise<CraftDownload[]> {+  const release = await craftLauncherRelease();+  return release ? releaseDownloads(CRAFT_LAUNCHER_REPO, "artcraft-launcher", release) : []; } -export function craftLauncherReleasePageUrl(): string {-  return CRAFT_LAUNCHER_RELEASE-    ? `${CRAFT_LAUNCHER_REPO}/releases/tag/${craftReleaseTag(CRAFT_LAUNCHER_RELEASE)}`+export async function craftLauncherReleasePageUrl(): Promise<string> {+  const release = await craftLauncherRelease();+  return release+    ? `${CRAFT_LAUNCHER_REPO}/releases/tag/${craftReleaseTag(release)}`     : `${CRAFT_LAUNCHER_REPO}/releases`; }diff --git a/frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts b/frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts--- a/frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts+++ b/frontend/apps/artcraft-website-next/src/lib/crafting-app-releases.ts@@ -3,13 +3,18 @@ import type { CraftAppSlug } from "./crafting-apps"; // GitHub releases for the Crafting Apps, which drive every download button on // the /apps pages. //-// To bump an app: change its version in CRAFT_APP_RELEASES (each app has its-// own, e.g. `craftRelease("0.4.0")`).-// To hide an app's downloads (Discord waitlist instead): set it to null.-// If a release renames, adds or drops files, edit CRAFT_RELEASE_ASSETS, or-// pass a custom list as craftRelease's second argument for a single app.+// Versions are never edited here: each page asks GitHub for the repo's latest+// published release when it renders (fetchCraftRelease) and caches the answer+// for CRAFT_RELEASE_REVALIDATE_SECONDS, so a new release shows up on the live+// deploy within the hour, without a commit or a redeploy. //-// Release tags are `v<version>` and live at github.com/storytold/<slug>.+// To hide an app's downloads (Discord waitlist instead): set it to null in+// CRAFT_APP_ASSETS.+// If a release renames or adds files, edit CRAFT_RELEASE_ASSETS, or give a+// single app its own list in CRAFT_APP_ASSETS. Catalog files missing from a+// release are left out, so a dropped file never becomes a dead link.+//+// Releases live at github.com/storytold/<slug>.  export type CraftDesktopPlatform = "macOS" | "Windows" | "Linux"; @@ -43,6 +48,8 @@ export type CraftRelease = {   /** Fills `{slug}` in filenames. Defaults to the app's slug; a release from before a rename keeps the old one. */   fileSlug?: string;   assets: readonly CraftReleaseAsset[];+  /** The release's actual file names; catalog assets not in it are left out. */+  files?: readonly string[]; };  // The file set every Crafting App publishes, in display order within each group.@@ -197,34 +204,64 @@ export const CRAFT_RELEASE_ASSETS: readonly CraftReleaseAsset[] = [   }, ]; -export function craftRelease(-  version: string,-  assets: readonly CraftReleaseAsset[] = CRAFT_RELEASE_ASSETS,-): CraftRelease {-  return { version, assets };+/** How long a fetched release is cached before the next request rechecks GitHub. */+export const CRAFT_RELEASE_REVALIDATE_SECONDS = 3600;++/**+ * The latest published release of github.com/storytold/<repo> (never a draft+ * or pre-release). Throws when GitHub can't answer: a build then fails and the+ * previous deploy stays live, and a background revalidation keeps serving the+ * last good page. Set GITHUB_TOKEN in Netlify to lift the anonymous rate limit.+ */+export async function fetchCraftRelease(+  repo: string,+  assets: readonly CraftReleaseAsset[],+): Promise<CraftRelease> {+  const headers: Record<string, string> = {+    Accept: "application/vnd.github+json",+    "User-Agent": "getartcraft.com",+    "X-GitHub-Api-Version": "2022-11-28",+  };+  if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;+  const res = await fetch(`https://api.github.com/repos/storytold/${repo}/releases/latest`, {+    headers,+    next: { revalidate: CRAFT_RELEASE_REVALIDATE_SECONDS },+  });+  if (!res.ok) throw new Error(`GitHub latest release of storytold/${repo}: HTTP ${res.status}`);+  const body = (await res.json()) as { tag_name?: unknown; assets?: { name?: unknown }[] };+  const tag = typeof body.tag_name === "string" ? body.tag_name : "";+  if (!/^v\d+\.\d+\.\d+/.test(tag)) {+    throw new Error(`GitHub latest release of storytold/${repo}: unexpected tag "${tag}"`);+  }+  return {+    version: tag.slice(1),+    tag,+    assets,+    files: (body.assets ?? []).flatMap((asset) =>+      typeof asset.name === "string" ? [asset.name] : [],+    ),+  }; }  // ArtCraft Launcher (github.com/storytold/craft-launcher) installs and updates // every app above, and the /apps pages recommend it first. Its releases ship // the same file set minus the macOS CLI, named artcraft-launcher-*.-export const CRAFT_LAUNCHER_RELEASE: CraftRelease | null = craftRelease(-  "0.5.1",-  CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-")),-);+export const CRAFT_LAUNCHER_ASSETS: readonly CraftReleaseAsset[] | null =+  CRAFT_RELEASE_ASSETS.filter((asset) => !asset.file.startsWith("{slug}-cli-"));  // Every app must be listed, so a new slug fails the type check until it is.-export const CRAFT_APP_RELEASES: Record<CraftAppSlug, CraftRelease | null> = {-  photocraft: craftRelease("0.7.0"),-  vectorcraft: craftRelease("0.9.0"),-  filmcraft: craftRelease("0.6.0"),-  lightcraft: craftRelease("0.6.0"),-  pdfcraft: craftRelease("0.6.0"),-  effectcraft: craftRelease("0.8.0"),-  designcraft: craftRelease("0.6.0"),-  figcraft: craftRelease("0.5.1"),-  wordcraft: craftRelease("0.5.0"),-  soundcraft: craftRelease("0.5.0"),-  deckcraft: craftRelease("0.5.0"),-  cadcraft: craftRelease("0.5.0"),-  gridcraft: craftRelease("0.5.0"),+export const CRAFT_APP_ASSETS: Record<CraftAppSlug, readonly CraftReleaseAsset[] | null> = {+  photocraft: CRAFT_RELEASE_ASSETS,+  vectorcraft: CRAFT_RELEASE_ASSETS,+  filmcraft: CRAFT_RELEASE_ASSETS,+  lightcraft: CRAFT_RELEASE_ASSETS,+  pdfcraft: CRAFT_RELEASE_ASSETS,+  effectcraft: CRAFT_RELEASE_ASSETS,+  designcraft: CRAFT_RELEASE_ASSETS,+  figcraft: CRAFT_RELEASE_ASSETS,+  wordcraft: CRAFT_RELEASE_ASSETS,+  soundcraft: CRAFT_RELEASE_ASSETS,+  deckcraft: CRAFT_RELEASE_ASSETS,+  cadcraft: CRAFT_RELEASE_ASSETS,+  gridcraft: CRAFT_RELEASE_ASSETS, };diff --git a/frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts b/frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts--- a/frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts+++ b/frontend/apps/artcraft-website-next/src/lib/crafting-apps.ts@@ -1,6 +1,7 @@ import type { Tip } from "./campaign-data"; import {-  CRAFT_APP_RELEASES,+  CRAFT_APP_ASSETS,+  fetchCraftRelease,   type CraftRelease,   type CraftReleaseAsset, } from "./crafting-app-releases";@@ -1128,8 +1129,10 @@ export function craftAppIconSourceUrl(app: CraftApp): string {   return `https://raw.githubusercontent.com/storytold/${app.slug}/main/assets/app-icon/${app.slug}-1024.png`; } -export function craftAppRelease(app: CraftApp): CraftRelease | null {-  return CRAFT_APP_RELEASES[app.slug];+/** The app's latest GitHub release, or null while its downloads are hidden. */+export async function craftAppRelease(app: CraftApp): Promise<CraftRelease | null> {+  const assets = CRAFT_APP_ASSETS[app.slug];+  return assets ? fetchCraftRelease(app.slug, assets) : null; }  export function craftReleaseTag(release: CraftRelease): string {@@ -1159,11 +1162,12 @@ export function releaseDownloads(   release: CraftRelease, ): CraftDownload[] {   const base = `${repo}/releases/download/${craftReleaseTag(release)}`;-  return release.assets.map(({ file, ...asset }) => {+  return release.assets.flatMap(({ file, ...asset }) => {     const fileName = file       .replaceAll("{slug}", release.fileSlug ?? fileSlug)       .replaceAll("{version}", release.version);-    return { ...asset, fileName, href: `${base}/${fileName}` };+    if (release.files && !release.files.includes(fileName)) return [];+    return [{ ...asset, fileName, href: `${base}/${fileName}` }];   }); }