From 87699c128fc4c8bcc8beda194712d4f1fd105bbc Mon Sep 17 00:00:00 2001 From: Tejas Makwana Date: Thu, 12 Nov 2026 22:26:54 +0530 Subject: [PATCH] feat(ci): post each new weekly digest to Slack and Discord When a new week lands in docs/weekly-updates.mdx on main, a workflow posts its summary, top highlights, video and notes link to Slack and Discord. Edits to a week that already went out don't repost. A missing webhook secret is skipped. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/changelog-announce.yml | 46 ++++++++ package.json | 2 +- scripts/changelog-announce.test.ts | 68 ++++++++++++ scripts/changelog-announce.ts | 129 +++++++++++++++++++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/changelog-announce.yml create mode 100644 scripts/changelog-announce.test.ts create mode 100644 scripts/changelog-announce.ts 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); + }); +}