DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

How I Auto-Publish a Blog Article Per Day With GitHub Actions and Claude

  • Daily blog automation runs free on GitHub Actions cron at 06:00 UTC

  • Topic queue file plus dedup check against the live blog index

  • Single Anthropic API call generates the full 1800-word draft

  • Shopify Admin API publishes the article with zero manual steps

I publish one blog article every single day and I never open a text editor to do it. A GitHub Actions cron job wakes up at 06:00 UTC, picks the next topic from a queue file, checks it against my live blog index so I never repeat myself, calls Claude for the draft, and pushes it straight to my store through the Shopify Admin API. Here is the entire workflow, copy-ready.

The Queue File and the Cron Trigger

Everything starts with a plain text file. I keep a file called topics.txt in the repo root. One topic per line. Each line is a title plus a short angle, separated by a pipe. It looks like this:


How I Auto-Publish a Blog Article Per Day|workflow walkthrough, queue file, dedup, API
Why I Switched My Image Pipeline to [Magnific](https://referral.magnific.com/mQMIvsh)|upscaling tests, before/after counts

Enter fullscreen mode Exit fullscreen mode

The queue is the single source of truth. When I get an idea I add a line. I currently have 60 lines sitting in there, which means two months of articles are already scheduled without me touching anything.

The trigger is a GitHub Actions workflow. The cron syntax is the part people get wrong, so here is the exact block:


on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

Enter fullscreen mode Exit fullscreen mode

The workflow_dispatch line matters more than it looks. It adds a manual "Run workflow" button in the Actions tab, so when I add a hot topic I can fire it off immediately instead of waiting for 06:00 UTC. The cron runs daily at 6 in the morning UTC, which is 7 or 8 in Berlin depending on the season. That timing means the article is live before I have my first coffee.

One warning about GitHub cron: it is not punctual. During busy periods on their infrastructure I have seen the job start up to 15 minutes late. For a blog that does not matter at all. If you need exact timing, you would move to a self-hosted runner, but for daily content the drift is irrelevant.

The free tier gives you 2000 Actions minutes per month on private repos and unlimited minutes on public ones. My whole job, from checkout to publish, runs in about 90 seconds. So 30 runs a month costs me roughly 45 minutes of my allowance. I am nowhere near the limit. The Anthropic API call is the only thing I actually pay for, and a single 1800-word draft runs me a few cents. If you want the full architecture behind how I run agents like this in production, the Claude Blueprint lays out the whole stack.

Dedup Against the Live Blog Index

The scariest failure mode in automated publishing is repeating yourself. Nothing kills trust faster than two near-identical articles sitting next to each other. So before I generate anything, the job pulls my live blog index and checks the chosen topic against it.

The logic is simple. I fetch the list of existing article handles and titles from the Shopify Admin API, then compare the next queue line against them. I use a fuzzy match, not an exact one, because "How I Auto-Publish a Blog" and "Auto-Publishing My Blog Daily" are the same article with different words.

Here is the shape of the check in Node:


const existing = await fetchAllArticleTitles();
const candidate = queue[0].split("|")[0];

const tooSimilar = existing.some(t =>
  similarity(t.toLowerCase(), candidate.toLowerCase()) > 0.72
);

if (tooSimilar) {
  console.log("Skipping duplicate:", candidate);
  queue.shift();           // drop it
  fs.writeFileSync("topics.txt", queue.join("\n"));
  process.exit(0);         // try again tomorrow
}

Enter fullscreen mode Exit fullscreen mode

The similarity function is a basic token-overlap score. I split both titles into words, count the shared ones, and divide by the total unique words. Anything above 0.72 gets flagged. I picked that number after running 40 of my old titles through it and checking which pairs it caught. At 0.6 it flagged unrelated articles. At 0.85 it missed obvious dupes. 0.72 was the sweet spot.

When a duplicate is found, I do not crash the job. I shift the line off the queue, save the file, and exit cleanly. The next day it grabs the following topic. This way a bad queue entry never blocks the whole pipeline.

I also dedup at the body level inside the prompt itself. I feed Claude the last 10 article titles and tell it explicitly not to overlap with them. That belt-and-suspenders approach has kept my index clean across more than 200 published pieces. I have had exactly two near-misses, both caught by the title check before generation ever ran.

If you want the deeper context on running this kind of guardrail safely, I treat the index fetch as read-only and cache it for the duration of the run so I never hammer the API with repeat calls.

The Anthropic API Call

This is the part everyone asks about. The generation is a single call to Claude with a structured system prompt. No agent loop, no tool use, no chaining. One request, one draft.

The system prompt is long. It defines the exact structure (TLDR div, four H2 sections, a Bottom Line), the word count target, the voice rules, the banned words, and the internal link requirements. The user message is short: just the topic line and the angle from the queue.


const res = await fetch("https://api.anthropic.com/v1/messages", {
  method: "POST",
  headers: {
    "x-api-key": process.env.CLAUDE_KEY,
    "anthropic-version": "2023-06-01",
    "content-type": "application/json"
  },
  body: JSON.stringify({
    model: "claude-sonnet-4-5",
    max_tokens: 4096,
    system: SYSTEM_PROMPT,
    messages: [{ role: "user", content: topicLine }]
  })
});

Enter fullscreen mode Exit fullscreen mode

The API key lives in GitHub Secrets, never in the repo. I reference it from the secrets store in the workflow and it lands in the environment at runtime. Rotating it is a two-minute job in the repo settings.

After the response comes back I run a validation pass in plain Node before anything touches Shopify. I count the words. If the draft is under my floor, I do not publish. I log the failure and exit, and the topic stays in the queue for tomorrow. I also strip any em dashes, normalize currency formatting, and scan for banned phrases. This post-processing layer catches the small things the model occasionally slips in despite the instructions.

Roughly 4 percent of my generations fail validation on the first pass, almost always on word count. Rather than retry in a loop (which burns tokens and can spiral), I just let the next day handle it. The queue is long enough that one skipped day never leaves me with empty content. I covered the reliability tradeoffs of single-call versus agent loops in more depth when I tested both approaches, and for content generation the single call wins on cost and predictability every time.

The model picks the internal links from a list I pass it, so every article ships with at least three outbound links to related pieces. That alone has done more for my time-on-site than any other single change.

Publishing Through the Shopify Admin API

Once the draft passes validation, the last step pushes it live. I use the Shopify Admin REST API to create the article inside a specific blog. You need three things: your store domain, a blog ID, and an Admin API access token with write_content scope.

Getting the blog ID trips people up. You query /admin/api/2024-01/blogs.json once, find the blog you want, and hardcode the ID. Mine never changes so there is no reason to look it up on every run.

The create call looks like this:


await fetch(`https://${SHOP}/admin/api/2024-01/blogs/${BLOG_ID}/articles.json`, {
  method: "POST",
  headers: {
    "X-Shopify-Access-Token": process.env.SHOP_ADMIN_KEY,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
    article: {
      title,
      body_html: markdownToHtml(draft),
      tags,
      published: true
    }
  })
});

Enter fullscreen mode Exit fullscreen mode

The published: true flag is what makes this fully hands-off. Set it to false if you want drafts to review first. I ran in draft mode for the first three weeks, reviewed every output by hand, and only flipped it to true once I trusted the validation layer. That trust period is worth doing. It is how you catch the edge cases your prompt missed.

I convert the markdown to HTML before sending because Shopify stores body_html, not markdown. A small markdown library handles that in two lines. I also inject my TLDR div as raw HTML at the top, which my theme styles into a summary box.

After a successful publish, I shift the used topic off the queue and commit the updated topics.txt back to the repo. That commit is what makes the system stateful across runs. The GitHub Actions token has contents: write permission so it can push that one-line change.

When I want to amplify the post, I drop the new URL into Buffer so it schedules across my social accounts without me opening any app. The whole chain, from cron tick to live article to scheduled social post, runs without a human in the loop.

Bottom Line

This workflow costs me a few cents a day in API calls and almost nothing in GitHub Actions minutes. The hard parts are not the API calls, they are the guardrails: the dedup check that keeps the index clean, the word-count validation that blocks thin drafts, and the queue file that turns one good brainstorm session into two months of content.

Start small. Build the queue file first. Run in draft mode and review every output by hand for two weeks before you ever set published: true. Tune your similarity threshold against your own old titles, not mine. Once you trust it, flip the switch and let it run.

If you want the full picture of how I wire Claude into production systems like this one, the Claude Blueprint walks through the whole setup end to end. Copy what fits, ignore what does not, and ship something tomorrow morning while you sleep.

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Top comments (0)