How to auto-generate and publish blog posts with a GitHub Action
I wanted this blog to keep publishing without me babysitting it. So I built a small autopilot: a backlog of topics, a script that turns the next topic into a finished MDX post via the Claude API, and a weekly GitHub Action that runs the script, commits the result, and lets Vercel deploy it. Three moving parts, and none of them are complicated. Here’s how each one works and how they fit together.
A quick note: I keep the workflow file itself commented out in the repo, so it only runs when I deliberately enable it. Everything below is the exact pipeline — uncomment the workflow and add the secrets to switch it on.
flowchart LR A[content-queue.json] --> B[generate-post.mjs] B --> C[Claude API] C --> B B --> D[write .mdx + mark topic used] D --> E[GitHub Action commits & pushes] E --> F[Vercel deploy]
Part 1: the content queue
The topic backlog is plain JSON. Each entry is a topic with a used flag and some
suggested tags. That’s the whole schema.
// blog/content-queue.json
{
"topics": [
{
"used": false,
"topic": "Why I stopped reaching for useEffect — deriving state and using refs correctly in React 19",
"tags": ["react", "frontend", "typescript"]
},
{
"used": false,
"topic": "Static-first for SEO: how prerendering with SSG beats client-only React",
"tags": ["ssg", "seo", "frontend"]
}
]
}The generator picks the first entry with "used": false, writes about it, then
flips that flag to true so it’s never reused. Editing this file is how I steer
what gets written. And when every topic is used up, the script asks the model to
propose a fresh one instead of failing — more on that below.
Part 2: the generator script
The core is scripts/generate-post.mjs, a plain Node ES module. It reads the
queue and finds the next unused topic:
// scripts/generate-post.mjs
function loadQueue() {
if (!fs.existsSync(QUEUE_PATH)) return { topics: [] }
return JSON.parse(fs.readFileSync(QUEUE_PATH, "utf8"))
}
function pickTopic(queue) {
const next = queue.topics?.find((t) => t.used === false)
return next ?? null // null → let the model propose one
}If pickTopic returns null (empty queue), the brief tells the model to invent a
topic in my domain. Either way, the script also reads the titles of every existing
post so it can instruct the model not to duplicate them.
Calling the Claude API with a JSON schema
The script uses the official @anthropic-ai/sdk. Rather than parsing free-form
Markdown out of a chat reply, it asks the model to return structured JSON that
matches a schema (the full walkthrough is in getting structured JSON output from the Claude API) — title, slug, description, tags, and the post body:
// scripts/generate-post.mjs
import Anthropic from "@anthropic-ai/sdk"
const client = new Anthropic() // reads ANTHROPIC_API_KEY from env
const message = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 16000,
thinking: { type: "adaptive" },
output_config: {
effort: "high",
format: { type: "json_schema", schema: OUTPUT_SCHEMA },
},
system: PERSONA,
messages: [{ role: "user", content: `${brief}\n\n${RULES}${suggestedTags}${avoid}` }],
})A few things worth calling out for beginners:
new Anthropic()with no arguments picks up theANTHROPIC_API_KEYenvironment variable automatically — you never hard-code the key.systemis the persona (who the model is writing as); theusermessage is the actual brief plus the writing rules.output_config.formatwith ajson_schemaforces the reply to conform toOUTPUT_SCHEMA, whose required fields aretitle,slug,description,tags, andbody_markdown. That’s what makes the output safe to consume programmatically.
The script also handles the model declining a topic:
// scripts/generate-post.mjs
if (message.stop_reason === "refusal") {
console.error("Model refused to generate this topic. Skipping.")
process.exit(1)
}Turning the response into a file
The JSON comes back inside a text block, which the script parses and then assembles into a real MDX file — frontmatter first, body after. It also builds a safe, unique slug so two posts never collide on a filename:
// scripts/generate-post.mjs
const date = new Date().toISOString().slice(0, 10)
const frontmatter = [
"---",
`title: ${JSON.stringify(post.title)}`,
`description: ${JSON.stringify(post.description)}`,
`date: ${date}`,
`tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
`draft: ${AUTO_PUBLISH ? "false" : "true"}`,
"---",
"",
].join("\n")
const fileBody = frontmatter + post.body_markdown.trim() + "\n"
fs.writeFileSync(path.join(POSTS_DIR, `${slug}.mdx`), fileBody, "utf8")Notice draft: an AUTO_PUBLISH env var of "false" writes the post as a draft
instead of publishing it live. Then the script marks the topic used and writes the
queue back to disk:
// scripts/generate-post.mjs
if (queued) {
queued.used = true
fs.writeFileSync(QUEUE_PATH, JSON.stringify(queue, null, 2) + "\n", "utf8")
}There are two escape hatches built in. DRY_RUN=true prints the post and writes
nothing — no file, no queue change, no email — which is how you test locally:
ANTHROPIC_API_KEY=... DRY_RUN=true node scripts/generate-post.mjsAnd an optional Resend integration emails a “published” notification, but only if
RESEND_API_KEY and a recipient are set; otherwise it quietly skips. The email is
a nice-to-have, not a dependency.
Part 3: the scheduled GitHub Action
The last piece runs the script on a schedule and commits whatever it produces. The workflow triggers weekly, or manually with a checkbox for publishing as a draft:
# .github/workflows/auto-post.yml
on:
schedule:
- cron: "0 9 * * 1" # Mondays 09:00 UTC
workflow_dispatch:
inputs:
auto_publish:
description: "Publish live (false = save as draft)"
type: boolean
default: true
permissions:
contents: writepermissions: contents: write is what lets the Action push a commit back to the
repo. The job checks out the code, installs Bun, and runs the script with the
secrets wired in as environment variables:
# .github/workflows/auto-post.yml
- name: Generate post
id: gen
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
AUTO_PUBLISH: ${{ github.event_name == 'workflow_dispatch' && inputs.auto_publish == false && 'false' || 'true' }}
run: node scripts/generate-post.mjsThose secrets.* values live in the repo’s Settings under Secrets and variables →
Actions, so the API key is never in the code. The final step commits and pushes —
but only if the script actually changed something:
# .github/workflows/auto-post.yml
- name: Commit & push
run: |
if git diff --quiet; then
echo "No changes to commit."
exit 0
fi
git config user.name "blog-autopilot[bot]"
git config user.email "blog-autopilot@users.noreply.github.com"
git add blog/src/content/posts blog/content-queue.json
git commit -m "post: ${{ steps.gen.outputs.title || 'new automated post' }}"
git pushThe git diff --quiet guard means a no-op run doesn’t create an empty commit. The
commit message pulls the post title from the step’s outputs (the script appends
slug, title, and url to GITHUB_OUTPUT). Once the push lands, Vercel sees
the new commit and redeploys the blog — the post is live with no human in the loop. That deploy is hardened with the security and cache headers in the project’s vercel.json.
What to remember
- Keep the workflow in three separable parts: a data queue, a generator, a scheduler. Each is easy to test and change on its own.
- Ask the model for structured JSON against a schema — never scrape prose out of a chat reply when a script has to consume it.
- Store the API key as a GitHub Actions secret and read it from
env; the SDK picks upANTHROPIC_API_KEYon its own. - Give the Action
contents: writeand guard the commit withgit diff --quietso idempotent runs stay clean. - Build in a
DRY_RUNand a draft mode so you can trust the automation before you let it publish unattended.