Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
40 changes: 40 additions & 0 deletions .github/workflows/release-checks.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Cumulus release checks

on:
pull_request:
push:
branches:
- main

concurrency:
group: release-checks-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out the reviewed commit
uses: actions/checkout@v4

- name: Use Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm

- name: Install exact dependencies
run: npm ci

- name: Install Chromium for public browser checks
run: npx playwright install --with-deps chromium

- name: Run the complete public release checks
run: npm run verify

- name: Run desktop and mobile browser checks
run: npm run test:e2e
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This repository defines the public application and its public-safe integration c
- `main` is the authorized production branch. The pre-redesign Git state remains preserved on an archive branch, and the two design branches remain reviewable.
- Resend Contacts, a dedicated Segment, and an opt-out-by-default Topic are the notification preference store and delivery boundary. Keys and provider-account identifiers stay in the deployment control plane, not this repository.
- Production notification publishing intentionally fails closed until `NOTIFICATION_POSTAL_ADDRESS` and `RESEND_WEBHOOK_SECRET` are configured and a controlled sign-in, receipt, unsubscribe, and suppression lifecycle is verified.
- Post content lives in the validated `src/content/posts.json` catalog. A private remote publisher may update that single public-safe file through a reviewed GitHub pull request; publisher credentials and draft state never belong in this repository.

See [Vercel cutover](docs/vercel-cutover.md) for the gate sequence and [private overlay](docs/private-overlay.md) for the public/private split.

Expand Down Expand Up @@ -97,6 +98,8 @@ The production workflow is:
6. Verify the exact production deployment, bundled font delivery, direct-route refreshes, static metadata, sitemap, 404 behavior, and domain alias.
7. Verify sign-in and one controlled Resend lifecycle only after the truthful postal address and webhook signing secret are present.

The optional private remote publisher keeps daily publishing off the operator's computer. It shows a live deterministic preview while the owner types; one Publish action then turns the supplied text into the existing JSON schema, creates a review branch, waits for checks and the exact Vercel preview commit, merges only that reviewed commit, verifies the matching production deployment and public route, and calls the notification endpoint in dry-run mode before any live send. Author-supplied HTTPS links are preserved and validated; automation cannot invent a source URL. Its Sites source, D1 publication history, owner identity, and GitHub/Vercel credentials are private operational material. The publisher does not send the post text to an AI service.

Do not create a replacement Vercel project merely to deploy this branch. Retaining the same external Vercel project and Git integration is what preserves its project-level settings and domain association. Re-verify the existing project and domain immediately before cutover because a repository commit cannot prove current provider state.

## Product behavior
Expand All @@ -109,6 +112,8 @@ The public experience is intentionally narrow:
- a preference/unsubscribe path that does not require a content account;
- a privileged, server-side publication notification trigger.

Published and draft entries are loaded from `src/content/posts.json`. The application validates slugs, dates, approved project/category pairs, Editorial entries, tags, dither variants, related links, body structure, and exactly one featured post. Reading time remains calculated in application code. New entries use `recent`; content automation cannot replace the featured post.

Consent, idempotency, unsubscribe behavior, and operational delivery rules are specified in [notifications](docs/notifications.md).
Reader-facing notification data boundaries and the manual correction/deletion contact are published at `/privacy`.

Expand Down
2 changes: 2 additions & 0 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ The Segment and Topic IDs are private deployment configuration. Cumulus checks b

`POST /api/notifications/publish` requires `NOTIFICATION_PUBLISH_SECRET`. It builds a deterministic Broadcast name and idempotency key from the immutable post slug, checks for an existing exact-content Broadcast, and refuses content conflicts. Resend Broadcasts supply the recipient-specific unsubscribe URL; the HTML presents it as a clear footer button and the plain-text part includes the same action. Both HTML and text include the truthful configured postal address.

An external publisher must not call this endpoint until the reviewed commit is on `main`, Vercel reports a successful Production deployment for that exact commit, and the public `/logs/{slug}` route passes a smoke check. It must call dry-run mode first. A failed preview, merge, deployment, public-route check, or dry run blocks delivery. A later retry uses the same slug, so the endpoint's existing idempotency protection prevents a second broadcast.

The authenticated Resend webhook accepts only bounced, complained, and suppressed events. It verifies the Svix signature over the raw body, normalizes unique recipients, and opts the matching Cumulus Topic out. Unsupported events are acknowledged and ignored; provider failures return a retryable error.

## Environment contract
Expand Down
3 changes: 3 additions & 0 deletions docs/private-overlay.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ Keep the following directly in Vercel, Resend, an approved secret manager, or a
- Vercel project/team identifiers, deployment URLs, tokens, environment values, domain evidence, and rollback records;
- truthful postal address and independent publication/session signing secrets;
- real recipient addresses, support requests, and lifecycle evidence.
- private publisher source and deployment access, Sites D1 drafts and workflow state, owner allowlists, repository-scoped GitHub write credentials, Vercel monitoring credentials, and publisher audit evidence.

The remote publisher may write only the public-safe `src/content/posts.json` file through a reviewed pull request. Its runtime secrets must remain in the private Sites environment. The public repository may contain the content schema, validation, remote CI, and documentation, but not the publisher's credentials, private deployment URL, drafts, or provider identifiers.

`NEXT_PUBLIC_SITE_URL` is deliberately browser-visible. Every notification credential is server-only. Synthetic examples such as `reader@example.com` are safe; sanitized-looking copies of live data are not. Outlook is outside the Cumulus system and remains untouched.
6 changes: 6 additions & 0 deletions docs/public-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Use this checklist for Cumulus 0.0.8 before pushing either design branch, publis
- [ ] `RESEND_API_KEY`, `RESEND_WEBHOOK_SECRET`, Resend Segment/Topic IDs, optional `GITHUB_ACCESS_TOKEN`, `NOTIFICATION_POSTAL_ADDRESS`, `NOTIFICATION_PUBLISH_SECRET`, and `NOTIFICATION_UNSUBSCRIBE_SECRET` remain outside public files and browser bundles.
- [ ] If configured, `GITHUB_ACCESS_TOKEN` belongs to a separate least-privilege viewer identity with no private-repository access; otherwise the endpoint uses only the fixed public profile. In both cases, the endpoint matches the public profile.
- [ ] Test fixtures use synthetic identities.
- [ ] No private publisher source, draft, owner email, Sites URL, or publisher provider credential is tracked.

## 3. Brand and third-party material

Expand Down Expand Up @@ -47,6 +48,8 @@ npm run test:e2e
- [ ] Any skipped or environment-dependent test is named in the handoff and is not reported as passed.
- [ ] Responsive behavior was checked at 320 px, 390 px, tablet, desktop, and wide desktop widths.
- [ ] Keyboard, visible focus, screen-reader status, and reduced-motion paths were checked.
- [ ] The JSON post catalog accepts variable post totals, short Editorial posts, and valid approved dither variants while rejecting duplicate slugs and unsafe source links.
- [ ] Routes, archive search, homepage sections, metadata, canonical URLs, sitemap, table of contents, and related-post fallback include a synthetic new post.

## 5. Notification behavior

Expand All @@ -72,6 +75,9 @@ npm run test:e2e
- [ ] Main replacement or merge has separate explicit approval.
- [ ] Production promotion and live domain cutover have separate explicit approval.
- [ ] The prior production deployment remains available for rollback until post-cutover checks pass.
- [ ] If the private remote publisher is used, its reviewed commit matches the successful preview commit and `main` has not changed since preview creation.
- [ ] The matching Production deployment and `/logs/{slug}` were verified before a notification dry run.
- [ ] Live publisher notifications remain disabled until a synthetic preview and controlled subscriber lifecycle pass.

## 7. Public handoff

Expand Down
6 changes: 6 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { defineConfig, devices } from "@playwright/test";

const localChromiumExecutable =
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;

export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
Expand All @@ -10,6 +13,9 @@ export default defineConfig({
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:4173",
launchOptions: localChromiumExecutable
? { executablePath: localChromiumExecutable }
: undefined,
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
Expand Down
3 changes: 2 additions & 1 deletion scripts/generate-static-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { URL } from "node:url";

import { FOCUSED_POSTS as publishedPosts } from "../src/content/focused-posts.ts";
import posts from "../src/content/posts.json" with { type: "json" };

const DIST = new URL("../dist/", import.meta.url);
const ORIGIN = "https://cumulush.com";
const template = await readFile(new URL("index.html", DIST), "utf8");
const publishedPosts = posts.filter((post) => post.status === "published");

function escapeHtml(value) {
return value
Expand Down
Loading
Loading