diff --git a/.github/workflows/notify-new-post.yml b/.github/workflows/notify-new-post.yml deleted file mode 100644 index ceb3061..0000000 --- a/.github/workflows/notify-new-post.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: notify-new-post - -on: - push: - branches: ["main"] - paths: ["src/pages/blog/**/*.mdx"] - -permissions: - contents: read - -jobs: - notify: - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 2 - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.4 - - - name: Find newly added blog posts - id: detect - env: - BEFORE_SHA: ${{ github.event.before }} - AFTER_SHA: ${{ github.sha }} - run: | - git diff --name-only --diff-filter=A "$BEFORE_SHA" "$AFTER_SHA" -- 'src/pages/blog/**/*.mdx' > new_posts.txt - if [ ! -s new_posts.txt ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - cat new_posts.txt - - - name: Send broadcast(s) - if: steps.detect.outputs.skip != 'true' - env: - RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} - RESEND_SEGMENT_ID: ${{ secrets.RESEND_SEGMENT_ID }} - run: bun scripts/notify-subscribers.ts diff --git a/CLAUDE.md b/CLAUDE.md index ded683e..8707c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -52,6 +52,15 @@ 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. + +Credentials come from 1Password, so no secret has to sit on disk. `op.env` maps `RESEND_API_KEY` to `op://Corp/Resend/credential` and `op run` resolves it for the duration of the command. That file is committed on purpose: it holds references, not values. Install and sign in once with `brew install 1password-cli && op signin`. + +Without the 1Password CLI the recipe falls back to `RESEND_API_KEY` and `RESEND_SEGMENT_ID` from the ambient environment, and if those are missing too the deploy still succeeds while the script exits non-zero to say the announcement did not go out. `just deploy staging` never announces and never touches 1Password. + +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 diff --git a/justfile b/justfile index 3d3211b..19e8359 100644 --- a/justfile +++ b/justfile @@ -41,8 +41,33 @@ 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}} + just _announce {{env}} + +# Mail subscribers about anything this deploy published. +# Credentials come from 1Password (see op.env) so no secret has to live on disk. +[private] +_announce env: + #!/usr/bin/env bash + set -euo pipefail + + # Staging never announces, so don't make it depend on 1Password. + if [ "{{env}}" != "live" ]; then + exec bun scripts/notify-subscribers.ts send --env {{env}} + fi + + # Fall back to the ambient environment rather than failing outright: the deploy + # has already happened by now, and the script reports a missing key itself. + if ! command -v op >/dev/null 2>&1; then + echo "[notify] 1Password CLI not found, falling back to the ambient environment." >&2 + exec bun scripts/notify-subscribers.ts send --env {{env}} + fi + + exec op run --env-file=op.env -- bun scripts/notify-subscribers.ts send --env {{env}} dev: bun i diff --git a/op.env b/op.env new file mode 100644 index 0000000..d6d8a85 --- /dev/null +++ b/op.env @@ -0,0 +1,14 @@ +# Secret references for `just deploy live`, resolved by `op run`. +# +# This file is committed on purpose: it holds pointers, not secrets. `op run` +# swaps each op:// reference for the real value at run time and masks it in the +# output. Nothing here is sensitive on its own. +# +# Requires the 1Password CLI, signed in to an account with the Corp vault: +# brew install 1password-cli && op signin + +RESEND_API_KEY=op://Corp/Resend/credential + +# The "Blog" segment. Not a secret, just an id, so keep it inline rather than +# minting a 1Password item for it. +RESEND_SEGMENT_ID=7bfda95d-9eb6-4f29-b1f8-1c2a748ce2ff diff --git a/scripts/notify-subscribers.ts b/scripts/notify-subscribers.ts index ff87421..0599180 100644 --- a/scripts/notify-subscribers.ts +++ b/scripts/notify-subscribers.ts @@ -1,90 +1,256 @@ #!/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. +// 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 { readFileSync } from "node:fs"; -import { basename } from "node:path"; +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 "; +const BUILT_FEED = "dist/rss.xml"; +const SNAPSHOT = join(tmpdir(), "moq-dev-published-slugs.json"); -const apiKey = requireEnv("RESEND_API_KEY"); -const segmentId = requireEnv("RESEND_SEGMENT_ID"); +// 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 newPostsList = readFileSync("new_posts.txt", "utf8").trim(); -if (!newPostsList) { - console.log("No new posts. Exiting."); - process.exit(0); +const FETCH_TIMEOUT_MS = 15000; + +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 --env "); + process.exit(2); +} - if (!create.ok) { - const err = await create.text(); - throw new Error(`Resend broadcast create failed (${create.status}): ${err}`); +// Record the slugs the live site serves right now, before the deploy replaces it. +async function snapshot() { + if (env !== "live") { + // Don't touch a pending live snapshot; a staging deploy is unrelated to it. + console.log(`[notify] env=${env}, skipping (only live announces).`); + return; } - const { id } = (await create.json()) as { id: string }; + // A surviving snapshot means the last deploy died partway through announcing. + // Its baseline predates those posts going live, so it is the only thing that + // still knows they are unannounced. Overwriting it with the current feed, + // which now contains them, would bury them permanently. + const pending = readSnapshot(); + if (pending) { + console.warn(`[notify] resuming the unfinished snapshot from a previous deploy (${pending.slugs.length} posts).`); + return; + } - const send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, { - signal: AbortSignal.timeout(15000), - method: "POST", - headers: { Authorization: `Bearer ${apiKey}` }, - }); + rmSync(SNAPSHOT, { force: true }); + + 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; + } - if (!send.ok) { - const err = await send.text(); - throw new Error(`Resend broadcast send failed (${send.status}): ${err}`); + // 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; } - console.log(`✓ Sent broadcast ${id} for "${title}"`); + writeFileSync(SNAPSHOT, JSON.stringify({ at: Date.now(), slugs })); + console.log(`[notify] ${slugs.length} post(s) already live.`); } -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) throw new Error(`Missing env var: ${name}`); - return v; +// Mail every post this deploy added. +async function send() { + if (env !== "live") return; + + // The snapshot is this run's to-do list, so it survives anything that leaves + // work outstanding and is removed only once there is nothing left to send. + const snap = readSnapshot(); + if (!snap) { + // Either nothing was recorded, or what was there is too old to trust: + // another deploy may have announced since, and reusing it would send twice. + rmSync(SNAPSHOT, { force: true }); + console.warn("[notify] no usable snapshot from this deploy, skipping the announcement."); + return; + } + + if (!existsSync(BUILT_FEED)) { + // Keep the snapshot: a rerun with a real build can still announce these. + console.warn(`[notify] ${BUILT_FEED} is missing, skipping the announcement.`); + return; + } + + const published = new Set(snap.slugs); + const added = parseFeed(readFileSync(BUILT_FEED, "utf8")).filter((p) => !published.has(p.slug)); + + if (added.length === 0) { + rmSync(SNAPSHOT, { force: true }); + console.log("[notify] no new posts."); + return; + } + + 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}`); + } + + // Checkpoint before the next one. If a later send dies, the retry sees this + // post as already published and mails only the remainder. `at` is carried + // over so checkpointing can't extend the staleness window indefinitely. + published.add(post.slug); + writeFileSync(SNAPSHOT, JSON.stringify({ at: snap.at, slugs: [...published] })); + + console.log(`✓ Sent broadcast ${id} for "${post.title}"`); + } + + // Everything landed, so there is nothing for a rerun to pick up. + rmSync(SNAPSHOT, { force: true }); } -function parseFrontmatter(source: string): Record { - const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---/); - if (!match) return {}; - const out: Record = {}; - 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, ""); +// The snapshot, or undefined if there isn't a usable one. Unreadable and expired +// files both read as absent; the caller decides whether to discard or replace. +function readSnapshot(): { at: number; slugs: string[] } | undefined { + if (!existsSync(SNAPSHOT)) return undefined; + + let snap: { at: number; slugs: string[] }; + try { + snap = JSON.parse(readFileSync(SNAPSHOT, "utf8")); + } catch { + return undefined; + } + + if (typeof snap?.at !== "number" || !Array.isArray(snap.slugs)) return undefined; + if (Date.now() - snap.at > SNAPSHOT_MAX_AGE_MS) return undefined; + + return snap; +} + +// 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(/([\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 out; + + 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}>(?:|([\\s\\S]*?))`)); + 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} (the deploy succeeded; the announcement did not go out)`); + return v; } -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);