Skip to content

Announce new posts on deploy instead of on merge - #117

Merged
kixelated merged 3 commits into
mainfrom
notify-on-deploy
Jul 31, 2026
Merged

Announce new posts on deploy instead of on merge#117
kixelated merged 3 commits into
mainfrom
notify-on-deploy

Conversation

@kixelated

@kixelated kixelated commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Deploys are manual now, so a push to main no longer means a post is live. notify-new-post.yml was mailing subscribers a link to a page that would not exist until someone ran just deploy live. Deleted it.
  • just deploy live now does the announcing, in two phases around the wrangler upload.
  • The Resend API key comes from 1Password, so no live credential has to sit in a shell profile or .dev.vars.
  • Root cause of the mismatch: the old trigger inferred "published" from a git event. With manual deploys, git state and site state are decoupled, so the trigger has to move to the thing that actually publishes.

How it decides what's new

There is no push event to diff against, so both sides of the diff are RSS feeds:

  1. snapshot (before upload) records the slugs in the live https://moq.dev/rss.xml.
  2. send (after upload) mails every post in the freshly built dist/rss.xml that was not in that snapshot.

Reading dist/rss.xml rather than src/pages/blog/ means we announce what actually shipped, and the subject and body reuse the exact title and description the feed carries. A deploy that adds no posts sends nothing, so routine deploys are silent.

Credentials

op.env maps RESEND_API_KEY to op://Corp/Resend/credential, and op run resolves it for the length of the command. The file is committed on purpose: it holds a reference and the non-secret segment id, never a value.

Degrades rather than breaks. just deploy staging never announces and never touches 1Password. If the op CLI is absent the recipe warns and falls back to RESEND_API_KEY / RESEND_SEGMENT_ID from the environment, and if those are missing too the deploy still succeeds while the script exits non-zero to report that the announcement did not go out.

Failing closed

A broadcast cannot be recalled, so every uncertain case skips sending rather than guessing:

  • Live feed unreachable or non-200
  • Live feed lists zero posts (far more likely a broken site than an empty blog, and treating it as "nothing is live" would mail the entire back catalogue)
  • No snapshot from this deploy, or one older than an hour
  • dist/rss.xml missing

The snapshot is consumed on read, so it can never outlive the deploy that took it. If wrangler deploy fails, just stops before send runs.

Test plan

Exercised every branch locally. No broadcast was ever created: the one credential-bearing run used a deliberately invalid key, which Resend rejects at the create call.

Detection and safety:

  • snapshot --env staging - skips, clears any stale snapshot
  • send --env staging - silent, exit 0
  • send --env live with no snapshot - skips, exit 0
  • snapshot --env live - read the real feed, 23 posts already live
  • send --env live against a build containing one unpublished post - detected exactly update-01, logged Creating broadcast for "Update 01" -> https://moq.dev/blog/update-01/
  • Same, credentials stripped - aborts at requireEnv before any network call, exit 1
  • Re-running send after a send - snapshot consumed, skips

1Password wiring:

  • op run --env-file=op.env resolves both variables (36-char key, correct segment id)
  • just _announce staging - exits 0 without invoking op
  • just _announce live with a current snapshot - ran under op run, reported "no new posts", sent nothing
  • just _announce live with op removed from PATH - warned about the missing CLI and fell back to the ambient environment

bun run check and just build clean.

Not run: just deploy itself, since that would deploy the site.

Note

Any local .mdx under src/pages/blog/ ships on the next just deploy live and gets announced, drafts included.

(written by Opus 5)

🤖 Generated with Claude Code

https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb

Deploys are manual now, so a push to main no longer means the post is
live. notify-new-post.yml was mailing subscribers a link to a page that
would not exist until someone ran `just deploy live`.

Move the notification into that deploy. There is no push event to diff
against, so both sides of the diff are RSS feeds: snapshot the live
moq.dev/rss.xml before wrangler uploads, then mail every post in the
freshly built dist/rss.xml that was not in the snapshot. Reading the
build rather than src/pages/blog means we announce what actually
shipped, with the title and description straight from the feed.

Sending is unrecallable, so every uncertain case skips instead of
guessing: unreachable feed, empty feed, missing snapshot, stale
snapshot, or missing build. Staging never announces.

Requires RESEND_API_KEY and RESEND_SEGMENT_ID in the shell, since
.env.live is committed and cannot hold secrets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@kixelated, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4cf1bcdf-d269-4675-b76b-8ebdff5355ad

📥 Commits

Reviewing files that changed from the base of the PR and between 05c2204 and e172d3f.

📒 Files selected for processing (5)
  • .github/workflows/notify-new-post.yml
  • CLAUDE.md
  • justfile
  • op.env
  • scripts/notify-subscribers.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 582bd6f371

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/notify-subscribers.ts Outdated
Comment on lines +99 to +100
// 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 👍 / 👎.

Comment thread CLAUDE.md Outdated

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

Comment thread scripts/notify-subscribers.ts Outdated
Comment on lines +112 to +113
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 👍 / 👎.

kixelated and others added 2 commits July 31, 2026 16:27
Deploying had to be preceded by exporting RESEND_API_KEY, which meant a
live API key sitting in a shell profile or .dev.vars. Reference it from
1Password instead and let `op run` supply it for the length of the
command.

op.env is committed deliberately: it maps the variable to an op:// URI
and carries the (non-secret) segment id, so it holds pointers rather
than values.

Staging never announces, so it does not depend on 1Password at all, and
a missing CLI falls back to the ambient environment rather than failing
a deploy that has already gone out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb
The snapshot was deleted the moment it was read, before any Resend call.
A failed send therefore destroyed the only record that a post still
needed announcing: rerunning `send` found no snapshot, and rerunning the
deploy re-snapshotted the already-updated live feed, so the post looked
like it had always been there. It could never be announced.

Treat the snapshot as the run's to-do list instead. It survives a missing
build, a missing credential, and a failed broadcast, and is removed only
once there is nothing left to send. Each successful broadcast checkpoints
it so a retry mails only the remainder, carrying the original timestamp
so checkpointing cannot extend the staleness window.

The snapshot phase now resumes an unfinished snapshot rather than
overwriting it, since the current feed already contains the posts it
still owes. Staging leaves it alone entirely.

Reading it is also total now: unparseable and expired files both read as
absent rather than throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QnCscaFDaj6TaBahqxXGqb
@kixelated
kixelated merged commit 9fc4be3 into main Jul 31, 2026
2 checks passed
@kixelated
kixelated deleted the notify-on-deploy branch July 31, 2026 23:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e172d3f7b6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

const published = new Set(snap.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 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant