Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/changelog-announce.yml
Original file line number Diff line number Diff line change
@@ -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 }}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions scripts/changelog-announce.test.ts
Original file line number Diff line number Diff line change
@@ -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 */}

<Update
label="Week of November 2, 2026"
description="Weekly digest"
tags={["Weekly update", "Highlights"]}
>

<Frame>
<DocsVideo title="v" src="https://static.heygen.ai/x/week.mp4" />
</Frame>

**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.

</Update>

<Update label="Week of October 26, 2026" description="older" tags={[]}>
**1. Old.** nope
</Update>
`;

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 <Update>/);
});
});
129 changes: 129 additions & 0 deletions scripts/changelog-announce.ts
Original file line number Diff line number Diff line change
@@ -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 <Update> 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("<Update");
const end = mdx.indexOf("</Update>", start);
if (start < 0 || end < 0) throw new Error("no <Update> 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(/<Frame>[\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(/\.$/, ""),

Check failure on line 52 in scripts/changelog-announce.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
);
const highlights = numbered.length
? numbered
: [...body.matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim()).slice(0, 5);

Check failure on line 56 in scripts/changelog-announce.ts

View workflow job for this annotation

GitHub Actions / Typecheck

Object is possibly 'undefined'.

return { label, video, summary, highlights };
}

export function slackPayload(d: Digest): Record<string, unknown> {
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<string, unknown> {
// 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<void> {
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<void> {
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);
});
}
Loading