diff --git a/.github/workflows/changelog-announce.yml b/.github/workflows/changelog-announce.yml
new file mode 100644
index 00000000000..5feb11c2fc0
--- /dev/null
+++ b/.github/workflows/changelog-announce.yml
@@ -0,0 +1,46 @@
+name: Announce weekly digest
+
+# Posts the newest docs/weekly-updates.mdx entry to Slack and Discord when a NEW week lands on main.
+# Edits to an already-announced week don't repost. Needs repo secrets SLACK_WEBHOOK_URL and/or
+# DISCORD_WEBHOOK_URL; a missing one is skipped.
+
+on:
+ push:
+ branches: [main]
+ paths: [docs/weekly-updates.mdx]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: changelog-announce
+ cancel-in-progress: false
+
+jobs:
+ announce:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 2
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+ - name: Is this a new week?
+ id: week
+ run: |
+ now=$(grep -m1 -o 'label="[^"]*"' docs/weekly-updates.mdx || true)
+ before=$(git show HEAD^:docs/weekly-updates.mdx 2>/dev/null | grep -m1 -o 'label="[^"]*"' || true)
+ echo "label=$now" >> "$GITHUB_OUTPUT"
+ if [ "$now" != "$before" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ echo "new=true" >> "$GITHUB_OUTPUT"
+ fi
+ - name: Post
+ if: steps.week.outputs.new == 'true'
+ run: npx --yes tsx@4 scripts/changelog-announce.ts --post
+ env:
+ SLACK_WEBHOOK_URL: ${{ secrets.CHANGELOG_SLACK_WEBHOOK_URL }}
+ DISCORD_WEBHOOK_URL: ${{ secrets.CHANGELOG_DISCORD_WEBHOOK_URL }}
diff --git a/package.json b/package.json
index 95ecd58e722..95c0b08c07d 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
- "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/ci-cache-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/registry-cdn-pins.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/motion-blur-nightly-report.test.mjs scripts/ci/base-file.test.mjs scripts/ci/release-only-diff.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs registry/examples/path-traced-3d/source/path-traced-3d.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
+ "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-cli-lazy-deps.test.mjs scripts/file-url-paths.test.mjs scripts/check-studio-copy-dashes.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/clawhub-unlist-retired.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/ci-cache-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/check-edit-accuracy-filter.test.mjs scripts/edit-accuracy-retry.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/changelog-announce.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/registry-cdn-pins.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/rewrite-relative-import-extensions.test.mjs scripts/lint-skills.test.mjs scripts/check-core-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/lefthook-order.test.mjs scripts/inline-gsap.test.mjs scripts/ci/flake-gate.test.mjs scripts/ci/motion-blur-nightly-report.test.mjs scripts/ci/base-file.test.mjs scripts/ci/release-only-diff.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-theme-fallbacks.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs registry/components/filmed-screen/filmed-screen.test.mjs registry/components/echo-smear/echo-smear.test.mjs registry/examples/path-traced-3d/source/path-traced-3d.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
diff --git a/scripts/changelog-announce.test.ts b/scripts/changelog-announce.test.ts
new file mode 100644
index 00000000000..cfc1357f359
--- /dev/null
+++ b/scripts/changelog-announce.test.ts
@@ -0,0 +1,68 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { discordPayload, parseLatestDigest, slackPayload } from "./changelog-announce.ts";
+
+const PAGE = `---
+title: "Weekly updates"
+---
+
+{/* marker */}
+
+
+
+
+
+
+
+**20 releases this week (v0.9.6 to v0.9.26).** The five you'll notice are below.
+
+## The 5 you'll feel
+
+**1. Preview = render.** Same film at any worker count. [#7410](https://github.com/x/pull/7410)
+
+**2. Motion blur uses the filter tier by default.** Detail.
+
+
+
+
+**1. Old.** nope
+
+`;
+
+describe("changelog announce", () => {
+ it("should read the newest entry only", () => {
+ const d = parseLatestDigest(PAGE);
+ assert.equal(d.label, "Week of November 2, 2026");
+ assert.equal(d.video, "https://static.heygen.ai/x/week.mp4");
+ assert.equal(
+ d.summary,
+ "20 releases this week (v0.9.6 to v0.9.26). The five you'll notice are below.",
+ );
+ assert.deepEqual(d.highlights, [
+ "Preview = render",
+ "Motion blur uses the filter tier by default",
+ ]);
+ });
+
+ it("should fall back to section headings for older digests", () => {
+ const old = PAGE.replace(/\*\*\d\. /g, "**").replace(
+ "## The 5 you'll feel",
+ "## Renders\n\ntext\n\n## Cloud",
+ );
+ assert.deepEqual(parseLatestDigest(old).highlights.slice(0, 2), ["Renders", "Cloud"]);
+ });
+
+ it("should put the video link where each app plays it", () => {
+ const d = parseLatestDigest(PAGE);
+ assert.match(JSON.stringify(slackPayload(d)), /week\.mp4\|Watch the video/);
+ assert.equal(discordPayload(d).content, "https://static.heygen.ai/x/week.mp4");
+ });
+
+ it("should throw when the page has no entry", () => {
+ assert.throws(() => parseLatestDigest("# nothing"), /no /);
+ });
+});
diff --git a/scripts/changelog-announce.ts b/scripts/changelog-announce.ts
new file mode 100644
index 00000000000..fa485008247
--- /dev/null
+++ b/scripts/changelog-announce.ts
@@ -0,0 +1,129 @@
+#!/usr/bin/env tsx
+/**
+ * Announce the newest weekly digest in Slack and Discord.
+ *
+ * tsx scripts/changelog-announce.ts # print both payloads (dry run)
+ * tsx scripts/changelog-announce.ts --post # post to SLACK_WEBHOOK_URL / DISCORD_WEBHOOK_URL
+ *
+ * Reads the first in docs/weekly-updates.mdx: its label, the embedded video, the bold
+ * summary line and the "The 5 you'll feel" headlines. A webhook that is not set is skipped.
+ */
+import { readFileSync } from "fs";
+import { join } from "path";
+import { pathToFileURL } from "url";
+
+const ROOT = join(import.meta.dirname, "..");
+const PAGE_URL = "https://hyperframes.heygen.com/weekly-updates";
+
+export interface Digest {
+ label: string;
+ video: string | null;
+ summary: string;
+ highlights: string[];
+}
+
+const stripMd = (s: string): string =>
+ s
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/\*\*|__|\*/g, "")
+ .trim();
+
+export function parseLatestDigest(mdx: string): Digest {
+ const start = mdx.indexOf("", start);
+ if (start < 0 || end < 0) throw new Error("no entry found in weekly-updates.mdx");
+ const entry = mdx.slice(start, end);
+
+ const label = /label="([^"]+)"/.exec(entry)?.[1] ?? "This week";
+ const video = /src="(https:[^"]+\.mp4)"/.exec(entry)?.[1] ?? null;
+
+ // body = everything after the attribute block and the video frame
+ const body = entry
+ .slice(entry.indexOf(">", entry.indexOf("tags=")) + 1)
+ .replace(/[\s\S]*?<\/Frame>/g, "");
+ const paragraphs = body
+ .split(/\n\s*\n/)
+ .map((p) => p.trim())
+ .filter((p) => p && !p.startsWith("<") && !p.startsWith("#") && !p.startsWith("{/*"));
+ const summary = stripMd(paragraphs[0] ?? "");
+
+ // "**1. Headline.** detail" lines under the top-5 heading; fall back to the old "## Section" headings
+ const numbered = [...body.matchAll(/^\*\*\d+\.\s*(.+?)\*\*/gm)].map((m) =>
+ stripMd(m[1]).replace(/\.$/, ""),
+ );
+ const highlights = numbered.length
+ ? numbered
+ : [...body.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, 5);
+
+ return { label, video, summary, highlights };
+}
+
+export function slackPayload(d: Digest): Record {
+ const lines = d.highlights.map((h) => `• ${h}`).join("\n");
+ const links = [d.video ? `<${d.video}|Watch the video>` : null, `<${PAGE_URL}|Read the notes>`]
+ .filter(Boolean)
+ .join(" · ");
+ return {
+ text: `HyperFrames: ${d.label}`,
+ blocks: [
+ {
+ type: "header",
+ text: { type: "plain_text", text: `HyperFrames · ${d.label}` },
+ },
+ { type: "section", text: { type: "mrkdwn", text: d.summary } },
+ ...(lines ? [{ type: "section", text: { type: "mrkdwn", text: lines } }] : []),
+ { type: "context", elements: [{ type: "mrkdwn", text: links }] },
+ ],
+ };
+}
+
+export function discordPayload(d: Digest): Record {
+ // Discord turns a bare .mp4 link in `content` into an inline player.
+ const list = d.highlights.map((h) => `- ${h}`).join("\n");
+ return {
+ content: d.video ?? undefined,
+ embeds: [
+ {
+ title: `HyperFrames · ${d.label}`,
+ url: PAGE_URL,
+ description: [d.summary, list].filter(Boolean).join("\n\n").slice(0, 4000),
+ color: 0x3ce6ac,
+ },
+ ],
+ };
+}
+
+async function post(name: string, url: string | undefined, payload: unknown): Promise {
+ if (!url) {
+ console.log(`${name}: no webhook set, skipped`);
+ return;
+ }
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (!res.ok) throw new Error(`${name}: ${res.status} ${await res.text()}`);
+ console.log(`${name}: posted`);
+}
+
+async function main(argv: string[]): Promise {
+ const file =
+ argv.find((a) => a.startsWith("--file="))?.slice(7) ?? join(ROOT, "docs/weekly-updates.mdx");
+ const digest = parseLatestDigest(readFileSync(file, "utf8"));
+ const slack = slackPayload(digest);
+ const discord = discordPayload(digest);
+ if (!argv.includes("--post")) {
+ console.log(JSON.stringify({ digest, slack, discord }, null, 2));
+ return;
+ }
+ await post("slack", process.env.SLACK_WEBHOOK_URL, slack);
+ await post("discord", process.env.DISCORD_WEBHOOK_URL, discord);
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+ main(process.argv.slice(2)).catch((err: unknown) => {
+ console.error(err instanceof Error ? err.message : err);
+ process.exit(1);
+ });
+}