-
Notifications
You must be signed in to change notification settings - Fork 7
Announce new posts on deploy instead of on merge #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,90 +1,221 @@ | ||
| #!/usr/bin/env bun | ||
| // Sends a Resend broadcast for each newly added blog post listed in new_posts.txt | ||
| // (paths relative to repo root, one per line). Invoked by the GitHub Action | ||
| // .github/workflows/notify-new-post.yml on push to main. | ||
|
|
||
| import { readFileSync } from "node:fs"; | ||
| import { basename } from "node:path"; | ||
| // Sends a Resend broadcast for each blog post that goes live in a deploy. | ||
| // | ||
| // Invoked by `just deploy live` in two phases, around the wrangler upload: | ||
| // | ||
| // snapshot - records which posts the live site already serves | ||
| // send - mails every post in the new build that the snapshot didn't have | ||
| // | ||
| // Deploys are manual, so there is no push event to diff against. Both sides of | ||
| // the diff are RSS feeds instead: the live one for what subscribers have already | ||
| // been told about, and the freshly built dist/rss.xml for what this deploy puts | ||
| // up. Reading the build rather than src/pages/blog keeps the mail honest, since | ||
| // it announces what actually shipped and reuses the exact title and description | ||
| // the feed carries. | ||
| // | ||
| // Only the `live` env notifies; staging is a no-op. Anything that leaves us | ||
| // unsure which posts are new (unreachable feed, missing snapshot, missing build) | ||
| // skips sending rather than guessing, because a broadcast cannot be recalled. | ||
|
|
||
| import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
|
|
||
| const SITE = "https://moq.dev"; | ||
| const FROM = "Media over QUIC <blog@moq.dev>"; | ||
| const BUILT_FEED = "dist/rss.xml"; | ||
| const SNAPSHOT = join(tmpdir(), "moq-dev-published-slugs.json"); | ||
|
|
||
| // A snapshot only describes the feed at the moment it was taken. Past this, assume | ||
| // the site moved on under us and refuse to treat it as "what was already live". | ||
| const SNAPSHOT_MAX_AGE_MS = 60 * 60 * 1000; | ||
|
|
||
| const apiKey = requireEnv("RESEND_API_KEY"); | ||
| const segmentId = requireEnv("RESEND_SEGMENT_ID"); | ||
| const FETCH_TIMEOUT_MS = 15000; | ||
|
|
||
| const newPostsList = readFileSync("new_posts.txt", "utf8").trim(); | ||
| if (!newPostsList) { | ||
| console.log("No new posts. Exiting."); | ||
| process.exit(0); | ||
| interface Post { | ||
| slug: string; | ||
| title: string; | ||
| description: string; | ||
| url: string; | ||
| } | ||
|
|
||
| const paths = newPostsList.split("\n").filter(Boolean); | ||
| console.log(`Found ${paths.length} new post(s): ${paths.join(", ")}`); | ||
|
|
||
| for (const path of paths) { | ||
| const rawSlug = basename(path, ".mdx"); | ||
| const slug = encodeURIComponent(rawSlug); | ||
| const fm = parseFrontmatter(readFileSync(path, "utf8")); | ||
| const title = fm.title ?? rawSlug; | ||
| const description = fm.description ?? ""; | ||
| const url = `${SITE}/blog/${slug}`; | ||
|
|
||
| console.log(`Creating broadcast for "${title}" → ${url}`); | ||
|
|
||
| const create = await fetch("https://api.resend.com/broadcasts", { | ||
| signal: AbortSignal.timeout(15000), | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| segment_id: segmentId, | ||
| from: FROM, | ||
| subject: title, | ||
| html: renderHtml({ title, description, url }), | ||
| }), | ||
| }); | ||
| const [phase, ...rest] = process.argv.slice(2); | ||
| const env = flag(rest, "--env") ?? "staging"; | ||
|
|
||
| if (phase === "snapshot") { | ||
| await snapshot(); | ||
| } else if (phase === "send") { | ||
| await send(); | ||
| } else { | ||
| console.error("Usage: notify-subscribers.ts <snapshot|send> --env <name>"); | ||
| process.exit(2); | ||
| } | ||
|
|
||
| // Record the slugs the live site serves right now, before the deploy replaces it. | ||
| async function snapshot() { | ||
| // Leave nothing behind for `send` to misread as this deploy's baseline. | ||
| rmSync(SNAPSHOT, { force: true }); | ||
|
|
||
| if (!create.ok) { | ||
| const err = await create.text(); | ||
| throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); | ||
| if (env !== "live") { | ||
| console.log(`[notify] env=${env}, skipping (only live announces).`); | ||
| return; | ||
| } | ||
|
|
||
| const { id } = (await create.json()) as { id: string }; | ||
| let slugs: string[]; | ||
| try { | ||
| const res = await fetch(`${SITE}/rss.xml`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); | ||
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | ||
| slugs = parseFeed(await res.text()).map((p) => p.slug); | ||
| } catch (err) { | ||
| // Deploying is still the right thing to do; we just can't safely say what's new. | ||
| console.warn(`[notify] could not read ${SITE}/rss.xml: ${err instanceof Error ? err.message : err}`); | ||
| console.warn("[notify] no announcement will be sent for this deploy."); | ||
| return; | ||
| } | ||
|
|
||
| const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { | ||
| signal: AbortSignal.timeout(15000), | ||
| method: "POST", | ||
| headers: { Authorization: `Bearer ${apiKey}` }, | ||
| }); | ||
| // An empty feed is far more likely to be a broken site than a blog with no posts, | ||
| // and treating it as "nothing is live" would mail the entire back catalogue. | ||
| if (slugs.length === 0) { | ||
| console.warn("[notify] live feed listed no posts, refusing to treat that as an empty blog."); | ||
| console.warn("[notify] no announcement will be sent for this deploy."); | ||
| return; | ||
| } | ||
|
|
||
| writeFileSync(SNAPSHOT, JSON.stringify({ at: Date.now(), slugs })); | ||
| console.log(`[notify] ${slugs.length} post(s) already live.`); | ||
| } | ||
|
|
||
| // Mail every post this deploy added. | ||
| async function send() { | ||
| if (env !== "live") return; | ||
|
|
||
| if (!existsSync(SNAPSHOT)) { | ||
| console.warn("[notify] no snapshot from this deploy, skipping the announcement."); | ||
| return; | ||
| } | ||
|
|
||
| const { at, slugs } = JSON.parse(readFileSync(SNAPSHOT, "utf8")) as { at: number; slugs: string[] }; | ||
|
|
||
| // Consume it either way: a snapshot must never outlive the deploy that took it. | ||
| rmSync(SNAPSHOT, { force: true }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If a Resend request times out, returns a non-2xx response, or credentials are missing, this deletes the only record of which posts require announcements before the failure occurs. A second Useful? React with 👍 / 👎. |
||
|
|
||
| if (Date.now() - at > SNAPSHOT_MAX_AGE_MS) { | ||
| console.warn("[notify] snapshot is stale, skipping the announcement."); | ||
| return; | ||
| } | ||
|
|
||
| if (!existsSync(BUILT_FEED)) { | ||
| console.warn(`[notify] ${BUILT_FEED} is missing, skipping the announcement.`); | ||
| return; | ||
| } | ||
|
|
||
| const published = new Set(slugs); | ||
| const added = parseFeed(readFileSync(BUILT_FEED, "utf8")).filter((p) => !published.has(p.slug)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a live release temporarily omits a previously announced post—for example during a production rollback or accidental removal—the snapshot loses that slug even though subscribers have already received it. Restoring the normal release then makes this filter classify the post as new and sends a duplicate broadcast. The current feed is not a durable notification ledger, so the comparison needs persistent announcement history or another way to distinguish restored posts. Useful? React with 👍 / 👎. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a post is placed in a nested route such as Useful? React with 👍 / 👎. |
||
|
|
||
| if (!send.ok) { | ||
| const err = await send.text(); | ||
| throw new Error(`Resend broadcast send failed (${send.status}): ${err}`); | ||
| if (added.length === 0) { | ||
| console.log("[notify] no new posts."); | ||
| return; | ||
| } | ||
|
|
||
| console.log(`✓ Sent broadcast ${id} for "${title}"`); | ||
| console.log(`[notify] announcing ${added.length} new post(s): ${added.map((p) => p.slug).join(", ")}`); | ||
|
|
||
| // Deploy already succeeded, so surface a missing key loudly instead of failing quietly. | ||
| const apiKey = requireEnv("RESEND_API_KEY"); | ||
| const segmentId = requireEnv("RESEND_SEGMENT_ID"); | ||
|
|
||
| for (const post of added) { | ||
| console.log(`Creating broadcast for "${post.title}" → ${post.url}`); | ||
|
|
||
| const create = await fetch("https://api.resend.com/broadcasts", { | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| "Content-Type": "application/json", | ||
| }, | ||
| body: JSON.stringify({ | ||
| segment_id: segmentId, | ||
| from: FROM, | ||
| subject: post.title, | ||
| html: renderHtml(post), | ||
| }), | ||
| }); | ||
|
|
||
| if (!create.ok) { | ||
| const err = await create.text(); | ||
| throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); | ||
| } | ||
|
|
||
| const { id } = (await create.json()) as { id: string }; | ||
|
|
||
| const sent = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| method: "POST", | ||
| headers: { Authorization: `Bearer ${apiKey}` }, | ||
| }); | ||
|
|
||
| if (!sent.ok) { | ||
| const err = await sent.text(); | ||
| throw new Error(`Resend broadcast send failed (${sent.status}): ${err}`); | ||
| } | ||
|
|
||
| console.log(`✓ Sent broadcast ${id} for "${post.title}"`); | ||
| } | ||
| } | ||
|
|
||
| // Pull the blog items out of an RSS feed. Both the live feed and the built one are | ||
| // generated by src/pages/rss.xml.js, so the same shape parses either. | ||
| function parseFeed(xml: string): Post[] { | ||
| const posts: Post[] = []; | ||
|
|
||
| for (const item of xml.matchAll(/<item>([\s\S]*?)<\/item>/g)) { | ||
| const body = item[1]; | ||
| const url = tag(body, "link"); | ||
| if (!url) continue; | ||
|
|
||
| const slug = url.match(/\/blog\/([^/]+)\/?$/)?.[1]; | ||
| if (!slug) continue; | ||
|
|
||
| posts.push({ | ||
| slug: decodeURIComponent(slug), | ||
| title: unescapeXml(tag(body, "title") ?? slug), | ||
| description: unescapeXml(tag(body, "description") ?? ""), | ||
| url, | ||
| }); | ||
| } | ||
|
|
||
| return posts; | ||
| } | ||
|
|
||
| function tag(xml: string, name: string): string | undefined { | ||
| // CDATA is not emitted today, but @astrojs/rss switches to it whenever a value | ||
| // contains markup, so handle both rather than silently dropping such a post. | ||
| const match = xml.match(new RegExp(`<${name}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))</${name}>`)); | ||
| return match ? (match[1] ?? match[2]) : undefined; | ||
| } | ||
|
|
||
| function unescapeXml(s: string): string { | ||
| return s | ||
| .replace(/</g, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, "'") | ||
| .replace(/'/g, "'") | ||
| .replace(/&/g, "&"); | ||
| } | ||
|
|
||
| function flag(argv: string[], name: string): string | undefined { | ||
| const i = argv.indexOf(name); | ||
| return i === -1 ? undefined : argv[i + 1]; | ||
| } | ||
|
|
||
| function requireEnv(name: string): string { | ||
| const v = process.env[name]; | ||
| if (!v) throw new Error(`Missing env var: ${name}`); | ||
| if (!v) throw new Error(`Missing env var: ${name} (the deploy succeeded; the announcement did not go out)`); | ||
| return v; | ||
| } | ||
|
|
||
| function parseFrontmatter(source: string): Record<string, string> { | ||
| const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/); | ||
| if (!match) return {}; | ||
| const out: Record<string, string> = {}; | ||
| for (const line of match[1].split(/\r?\n/)) { | ||
| const m = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/); | ||
| if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ""); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| function renderHtml({ title, description, url }: { title: string; description: string; url: string }): string { | ||
| function renderHtml({ title, description, url }: Post): string { | ||
| const safeTitle = escapeHtml(title); | ||
| const safeDescription = escapeHtml(description); | ||
| const safeUrl = escapeHtml(url); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.dev.varsfileWhen a deployer follows this instruction and stores the Resend values in
.dev.vars,bun scripts/notify-subscribers.tsdoes not load that Wrangler-specific file: the script reads onlyprocess.env, and the recipe supplies no--env-file=.dev.varsoption (bun --helpexposes--env-file=<val>for loading nonstandard files). The production upload therefore succeeds beforerequireEnvthrows, so the promised announcement is missed; either load the file explicitly or require the variables to be exported into the shell.Useful? React with 👍 / 👎.