ehsan.blog
~/blog/how-to-auto-generate-blog-posts-with-a-github-action — zsh
cat how-to-auto-generate-blog-posts-with-a-github-action.md

How to auto-generate and publish blog posts with a GitHub Action

·6 min read

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.

json
// 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:

js
// 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:

js
// 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 the ANTHROPIC_API_KEY environment variable automatically — you never hard-code the key.
  • system is the persona (who the model is writing as); the user message is the actual brief plus the writing rules.
  • output_config.format with a json_schema forces the reply to conform to OUTPUT_SCHEMA, whose required fields are title, slug, description, tags, and body_markdown. That’s what makes the output safe to consume programmatically.

The script also handles the model declining a topic:

js
// 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:

js
// 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:

js
// 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:

bash
ANTHROPIC_API_KEY=... DRY_RUN=true node scripts/generate-post.mjs

And 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:

yaml
# .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: write

permissions: 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:

yaml
# .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.mjs

Those 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:

yaml
# .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 push

The 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 up ANTHROPIC_API_KEY on its own.
  • Give the Action contents: write and guard the commit with git diff --quiet so idempotent runs stay clean.
  • Build in a DRY_RUN and a draft mode so you can trust the automation before you let it publish unattended.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.