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 version
diff --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()}
             />
           )}
           <DiscordButton
diff --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}` }];
   });
 }
 
