Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 0 additions & 42 deletions .github/workflows/notify-new-post.yml

This file was deleted.

9 changes: 8 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ just dev # Start dev server with auto-open
# Build & Deploy
just build # Production build
just deploy # Deploy to Cloudflare Pages (staging by default)
just deploy live # Deploy to production
just deploy live # Deploy to production, and email subscribers about new posts
just prod # Build and preview production locally

# Code Quality
Expand Down Expand Up @@ -52,6 +52,13 @@ just fix # Auto-fix code formatting/lint issues

- Cloudflare Pages via Wrangler
- `just deploy` for staging, `just deploy live` for production
- Deploys are manual; nothing ships on merge to `main`

**`just deploy live` mails the subscriber list.** `scripts/notify-subscribers.ts` snapshots the slugs in `https://moq.dev/rss.xml` before the upload, then sends a Resend broadcast for every post in the freshly built `dist/rss.xml` that wasn't in that snapshot. Subject and body come from the feed's `title` and `description`. A deploy that adds no posts sends nothing.

This needs `RESEND_API_KEY` and `RESEND_SEGMENT_ID` in the shell. They are secrets, so they cannot live in the committed `.env.live`; use `.dev.vars` or your shell profile. Without them the deploy still succeeds and the script exits non-zero to say the announcement did not go out.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Explicitly load the documented .dev.vars file

When a deployer follows this instruction and stores the Resend values in .dev.vars, bun scripts/notify-subscribers.ts does not load that Wrangler-specific file: the script reads only process.env, and the recipe supplies no --env-file=.dev.vars option (bun --help exposes --env-file=<val> for loading nonstandard files). The production upload therefore succeeds before requireEnv throws, so the promised announcement is missed; either load the file explicitly or require the variables to be exported into the shell.

Useful? React with 👍 / 👎.


Broadcasts cannot be recalled, so the script refuses to guess: an unreachable or empty live feed, a missing snapshot, or a missing build all skip sending rather than risk mailing the back catalogue. Any local `.mdx` under `src/pages/blog/` ships on the next `just deploy live` and gets announced, drafts included.

## Development Tips

Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ build mode="live":
bun astro build --mode {{mode}}

# Deploy the site to Cloudflare Pages
# On `live`, any post that wasn't already on moq.dev gets mailed to subscribers.
deploy env="staging": (build env)
# Record what's live before we replace it, so we can tell what the deploy added.
bun scripts/notify-subscribers.ts snapshot --env {{env}}
bun wrangler deploy --env {{env}}
bun scripts/notify-subscribers.ts send --env {{env}}

dev:
bun i
Expand Down
261 changes: 196 additions & 65 deletions scripts/notify-subscribers.ts
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve notification state until all broadcasts succeed

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 send has no snapshot, while rerunning the deployment snapshots the already-updated live feed and detects no new posts; a failure midway through several posts similarly strands the unsent remainder. Retain or checkpoint the snapshot until every required broadcast has been sent successfully.

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track prior announcements independently of the live feed

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 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include nested blog posts in the feed comparison

When a post is placed in a nested route such as src/pages/blog/topic/post.mdx, Astro publishes the page and the previous workflow's src/pages/blog/**/*.mdx path detected it, but src/pages/rss.xml.js uses the non-recursive glob ./blog/*.{md,mdx}. Consequently the post is absent from dist/rss.xml, so this filter sees no addition and subscribers are never notified, despite the new deployment documentation promising that any .mdx under src/pages/blog/ is announced.

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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&amp;/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);
Expand Down
Loading