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
42 changes: 42 additions & 0 deletions .github/workflows/notify-new-post.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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
Comment on lines +17 to +23
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/notify-new-post.yml"
echo "== $FILE =="
if [ -f "$FILE" ]; then
  nl -ba "$FILE" | sed -n '1,120p'
else
  echo "Missing: $FILE"
  exit 1
fi

echo "== Extract uses lines =="
rg -n --hidden --no-ignore -S "uses:\s+([^@]+)@(.+)" .github/workflows/notify-new-post.yml || true

Repository: moq-dev/moq.dev

Length of output: 164


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE=".github/workflows/notify-new-post.yml"
echo "== $FILE =="

if [ -f "$FILE" ]; then
  awk '{printf "%6d\t%s\n", NR, $0}' "$FILE" | sed -n '1,160p'
else
  echo "Missing: $FILE"
  exit 1
fi

echo "== Extract uses lines (uses: owner/repo@ref) =="
rg -n --hidden --no-ignore -S --no-mmap "uses:\s*[^#\n]+" "$FILE" || true

echo "== Extract checkout persist-credentials config if present =="
rg -n --hidden --no-ignore -S "persist-credentials" "$FILE" || true

Repository: moq-dev/moq.dev

Length of output: 1623


🌐 Web query:

actions/checkout v4 tag commit sha

💡 Result:

In GitHub Actions, the actions/checkout@v4 tag is a mutable reference that points to the latest commit within the v4 release branch of the actions/checkout repository [1][2]. Because tags and branches in GitHub repositories are mutable, they can be updated by repository maintainers to point to different commit SHAs [2][3]. For security-critical workflows, it is a recommended best practice to pin actions to a specific, immutable full commit SHA rather than a tag [2][3]. This prevents supply chain attacks where an attacker might compromise a repository and repoint a tag to a malicious commit [2][3][4]. To identify the specific commit SHA for a version: 1. Visit the official actions/checkout repository on GitHub [1]. 2. Navigate to the "Tags" or "Releases" section [1]. 3. Select the desired version (e.g., v4.0.0) to view the specific commit SHA it points to [1][5]. 4. Alternatively, you can check the build logs of a workflow that uses the tag; the "Setup Job" section will display the exact SHA of the action that was downloaded [1]. For example, the initial release of v4.0.0 corresponds to commit SHA 3df4ab1 [6]. Using a full SHA in your workflow configuration looks like this: - uses: actions/checkout@3df4ab1 Note that while tags like v4 are convenient, they do not provide the same security guarantees as pinning to a full commit SHA [2][3].

Citations:


🌐 Web query:

oven-sh/setup-bun v2 tag commit sha

💡 Result:

The oven-sh/setup-bun GitHub Action uses mutable Git tags (such as v2) to point to the latest release within that major version series [1][2]. Because these tags are updated by the repository maintainers to point to new commits as releases are published, they do not correspond to a single, permanent commit SHA [3][4][5]. For example, the v2 tag has been updated multiple times since its introduction, moving from the initial v2.0.0 release to subsequent minor and patch releases like v2.0.2, v2.1.0, and v2.2.0 [3][4][5]. To ensure security and reproducibility in your GitHub Actions workflows, it is strongly recommended to pin the action to a specific, immutable commit SHA rather than a mutable tag [6][7]. You can find the specific commit SHA for any release by viewing the release page on GitHub [3][4][5] or by inspecting the repository's commit history [8]. Example of pinning to a commit SHA: - uses: oven-sh/setup-bun@0c5077e

Citations:


🌐 Web query:

https://github.com/actions/checkout/releases/tag/v4 commit SHA

💡 Result:

For https://github.com/actions/checkout/releases/tag/v4, the tag v4 points to commit SHA 2d7d9f7ff5b310f983d059b68785b3c74d8b8edd.[1][2] In GitHub’s repo UI, navigating to the release tag “v4” corresponds to the “tree/v4” view (tag) and the underlying commit shown there is 2d7d9f7ff5b310f983d059b68785b3c74d8b8edd.[2][3]

Citations:


🌐 Web query:

https://github.com/oven-sh/setup-bun/releases/tag/v2 commit SHA

💡 Result:

The GitHub release URL you provided points to tag v2 (major tag), not a specific release version. For the major tag v2, GitHub does not expose a single immutable “commit SHA” from the release page alone. What we can confirm from the repo: - The release PR for v2.0 is #80 (“release: v2.0:tada:”) [1], and the compare page shows the v2 changes include commit ef00e4a: “release: v2.0:tada: (#80)” [2]. If you meant a specific minor/patch release under v2, tell me which tag (e.g., v2.0.2, v2.1.0, v2.2.0). Those pages are specific releases and will correspond to a particular commit SHA.

Citations:


🌐 Web query:

oven-sh/setup-bun tree/v2 commit SHA

💡 Result:

The GitHub Actions action version “oven-sh/setup-bun@v2” is a moving target (a tag), so the commit SHA depends on which exact tag commit the tag pointed to when you resolved it. One concrete, current example: the release tag “v2.2.0” corresponds to commit SHA 0c5077e51419868618aeaa5fe8019c62421857d6 in the oven-sh/setup-bun repository [1]. If you specifically meant “tree/v2” (a branch/path view) rather than the “@v2” action tag, that’s not directly a SHA; you’d need the exact underlying ref resolution from GitHub (branch/tag).

Citations:


🌐 Web query:

oven-sh/setup-bun releases tag v2 commit sha

💡 Result:

The request “oven-sh/setup-bun releases tag v2 commit sha” can be answered as follows: the oven-sh/setup-bun tag “v2” corresponds to the git commit SHA used for the v2.0.0 release. I was able to find concrete release entries for v2.0.2 and v2.1.0 (and the general releases list), but the search results returned do not include the v2 (i.e., v2.0.0) release’s commit SHA itself. For example, release tags shown include v2.0.2 and v2.1.0, but not the commit SHA mapping for “v2”. [1][2] If you want, tell me whether you mean: 1) tag name “v2” as used by GitHub Releases (often pointing to v2.0.0), or 2) “all v2.x release tags”, and I can re-search specifically for the commit SHA(s) behind those tag(s).

Citations:


Harden workflow: pin actions and disable checkout credential persistence

  • .github/workflows/notify-new-post.yml uses actions/checkout@v4; pin it to actions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8edd and add persist-credentials: false under with (currently missing).
  • .github/workflows/notify-new-post.yml uses oven-sh/setup-bun@v2; pin this to an immutable commit SHA instead of the moving v2 tag.
🧰 Tools
🪛 zizmor (1.25.2)

[warning] 17-19: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 17-17: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[error] 21-21: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/notify-new-post.yml around lines 17 - 23, Update the
workflow to pin external actions to immutable SHAs and disable checkout
credential persistence: replace the floating actions/checkout@v4 reference with
actions/checkout@2d7d9f7ff5b310f983d059b68785b3c74d8b8edd and add
persist-credentials: false under its with block, and replace
oven-sh/setup-bun@v2 with the specific commit SHA for that action (pin the
oven-sh/setup-bun action to an immutable commit) while keeping the existing
bun-version setting unchanged.


- 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
117 changes: 117 additions & 0 deletions scripts/notify-subscribers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/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";

const SITE = "https://moq.dev";
const FROM = "Media over QUIC <blog@moq.dev>";

const apiKey = requireEnv("RESEND_API_KEY");
const segmentId = requireEnv("RESEND_SEGMENT_ID");

const newPostsList = readFileSync("new_posts.txt", "utf8").trim();
if (!newPostsList) {
console.log("No new posts. Exiting.");
process.exit(0);
}

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

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 send = await fetch(`https://api.resend.com/broadcasts/${id}/send`, {
signal: AbortSignal.timeout(15000),
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
});
Comment thread
kixelated marked this conversation as resolved.

if (!send.ok) {
const err = await send.text();
throw new Error(`Resend broadcast send failed (${send.status}): ${err}`);
}

console.log(`✓ Sent broadcast ${id} for "${title}"`);
}

function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env var: ${name}`);
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 {
const safeTitle = escapeHtml(title);
const safeDescription = escapeHtml(description);
const safeUrl = escapeHtml(url);
return `<!doctype html>
<html><body style="font-family: -apple-system, system-ui, sans-serif; line-height: 1.5; color: #1f2937;">
<h1 style="margin: 0 0 16px;">${safeTitle}</h1>
${safeDescription ? `<p style="font-size: 16px; color: #4b5563;">${safeDescription}</p>` : ""}
<p style="margin: 24px 0;">
<a href="${safeUrl}" style="display: inline-block; background: #2563eb; color: #fff; text-decoration: none; padding: 10px 20px; border-radius: 6px;">Read it on moq.dev →</a>
</p>
<p style="font-size: 13px; color: #6b7280;">Or open it directly: <a href="${safeUrl}">${safeUrl}</a></p>
</body></html>`;
}

function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}
23 changes: 23 additions & 0 deletions src/components/subscribe.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
import Subscribe from "@/components/subscribe.tsx";
---

<aside class="not-prose mt-12 border-t border-slate-700 pt-8">
<h3 class="mb-4 text-xl font-semibold text-slate-100">Subscribe for new stuff</h3>
<Subscribe client:load />
<div class="mt-4 flex items-center gap-2 text-sm text-slate-400">
<span>or</span>
<a
href="/rss.xml"
class="inline-flex items-center gap-1.5 text-slate-300 hover:text-white"
title="RSS Feed"
>
<svg width="18" height="18" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="RSS Feed">
<circle cx="6" cy="26" r="2" fill="currentColor" />
<path d="M6 16 A10 10 0 0 1 16 26" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M6 8 A18 18 0 0 1 24 26" fill="none" stroke="#00C02D" stroke-width="2" stroke-linecap="round" />
</svg>
<span class="font-medium">RSS Feed</span>
</a>
</div>
</aside>
62 changes: 62 additions & 0 deletions src/components/subscribe.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createSignal } from "solid-js";

type State = "idle" | "submitting" | "success" | "error";

export default function Subscribe() {
const [email, setEmail] = createSignal("");
const [state, setState] = createSignal<State>("idle");
const [error, setError] = createSignal("");

const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault();
setState("submitting");
setError("");

try {
const res = await fetch("/api/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email() }),
});

if (res.ok) {
setState("success");
} else {
const body = (await res.json().catch(() => ({}))) as { error?: string };
setError(body.error ?? "Something went wrong. Try again?");
setState("error");
}
} catch {
setError("Couldn't reach the server. Try again?");
setState("error");
}
};
Comment on lines +10 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the component error-display pattern from src/components/fail.tsx.

This component handles errors inline, but this path requires the shared component-level error handling pattern for consistency in interactive UI components.

As per coding guidelines, "Use component-level error handling with the src/components/fail.tsx pattern for error display".

Also applies to: 59-59

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/subscribe.tsx` around lines 10 - 33, Replace the inline
error-display in handleSubmit with the shared component-level error handling
pattern used in src/components/fail.tsx: import and render the Fail component
(or use the same error state shape/API it expects) instead of calling setError
directly for fetch errors and catch blocks, and update handleSubmit to set only
the error value/state the Fail component consumes while keeping
setState("error")/("success") logic intact; specifically update the handleSubmit
function, its usage of setError and error state, and add the Fail component
import so the UI uses the centralized fail component for all error rendering.


return (
<>
{state() === "success" ? (
<p class="text-sm text-green-400">Thanks! You'll get an email when a new post goes up.</p>
) : (
<form onSubmit={handleSubmit} class="flex flex-col gap-2 sm:flex-row">
<input
type="email"
required
placeholder="you@example.com"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
disabled={state() === "submitting"}
class="flex-1 rounded border border-slate-700 bg-slate-800 px-3 py-2 text-slate-100 placeholder-slate-500 focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={state() === "submitting"}
class="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-500 disabled:bg-slate-700"
>
{state() === "submitting" ? "Subscribing…" : "Subscribe"}
</button>
</form>
)}
{state() === "error" && <p class="mt-2 text-sm text-red-400">{error()}</p>}
</>
);
}
2 changes: 2 additions & 0 deletions src/layouts/global.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import "./global.css";
// Imported after global.css so the theme's .hljs-* color rules land later in
// the cascade and beat the prose plugin's .markdown :where(pre code) { color: inherit }.
import "highlight.js/styles/atom-one-dark.css";
import Subscribe from "@/components/subscribe.astro";

// NOTE: This is magically used as the type for Astro.props
interface Props {
Expand Down Expand Up @@ -95,6 +96,7 @@ const ogImage = new URL(frontmatter?.cover ?? "/layout/icon.png", siteUrl).toStr
)
}
<slot />
{frontmatter?.date && <Subscribe />}
</article>
</div>
</body>
Expand Down
78 changes: 78 additions & 0 deletions worker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Cloudflare Worker entry. Static asset requests fall through to the ASSETS
// binding (Workers-with-Static-Assets). Only /api/* is handled here.

interface Env {
ASSETS: { fetch: (request: Request) => Promise<Response> };
RESEND_API_KEY: string;
RESEND_SEGMENT_ID: string;
}

const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/api/subscribe") {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
return handleSubscribe(request, env);
}

return env.ASSETS.fetch(request);
},
};

async function handleSubscribe(request: Request, env: Env): Promise<Response> {
let email: unknown;
try {
const body = (await request.json()) as { email?: unknown };
email = body.email;
} catch {
return json({ error: "invalid body" }, 400);
}

if (typeof email !== "string" || !EMAIL_RE.test(email)) {
return json({ error: "invalid email" }, 400);
}

let res: Response;
try {
res = await fetch("https://api.resend.com/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
unsubscribed: false,
segments: [env.RESEND_SEGMENT_ID],
}),
});
} catch (err) {
console.error(`Resend POST /contacts → fetch threw: ${err}`);
return json({ error: "subscribe failed" }, 502);
}

// Treat any non-5xx as success so we don't leak whether an address is
// already on the list (Resend returns 4xx for duplicates). Log 4xx for
// debugging since a misconfigured segment ID would silently break sends.
// Only log status + request id, not the body (which may contain the email).
if (!res.ok) {
console.error(`Resend POST /contacts → ${res.status} (request-id: ${res.headers.get("x-request-id") ?? "n/a"})`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (res.status >= 500) {
return json({ error: "subscribe failed" }, 502);
}

return json({ ok: true }, 200);
}

function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
7 changes: 5 additions & 2 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
"compatibility_date": "2025-08-13",
"account_id": "dd618f5dbd5da77b8296f1613c301f5c",

"main": "worker/index.ts",

"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
"not_found_handling": "404-page",
"binding": "ASSETS"
},

// Environment-specific configurations
"env": {
"staging": {
"name": "moq-dev-staging",
"route": {
"pattern": "new.moq.dev",
"pattern": "moq.wtf",
"custom_domain": true
}
},
Expand Down
Loading