DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Channel Blog Publishing with a Content‑Automation Repo

Automating Multi‑Channel Blog Publishing with a Content‑Automation Repo

TL;DR: I rewrote the content‑automation pipeline to generate, format, and push daily posts to Medium, Substack, and Bluesky in a single CI step. The change lives in a handful of Markdown, JSON, and metadata files, and it eliminates manual copy‑paste errors while keeping each platform’s payload valid.


The Problem

Every morning I had three tickets in the backlog:

  1. Publish the day’s article on Medium (English & Spanish).
  2. Send the same copy to Substack newsletters.
  3. Post a concise update to Bluesky (English & Spanish).

The manual workflow was:

$ cat content/2026/10/05/content-automation/medium_en.md
# … (copy‑paste) …
Enter fullscreen mode Exit fullscreen mode

I kept opening each file, adjusting front‑matter, fixing line breaks, and then manually uploading via each platform’s UI. The biggest pain point was the payload mismatch for Bluesky – the JSON schema expects a type field (progress/avance) and a text field limited to 300 characters. One typo in the generated JSON caused the API to reject the request with:

Error: payload validation failed – field "text" exceeds max length
Enter fullscreen mode Exit fullscreen mode

The error surfaced only after I had already spent ~30 minutes preparing the Medium draft, so I was forced to roll back changes and re‑edit everything. I needed a deterministic, repeatable process that would:

  • Pull the same source Markdown once.
  • Render platform‑specific formats (MD → HTML for Medium, plain‑text for Bluesky, newsletter template for Substack).
  • Validate each payload before committing.

What I Tried First

My first attempt was a shell script (scripts/publish.sh) that concatenated the Markdown files and called the three platform CLIs:

#!/usr/bin/env bash
mdcat content/2026/10/05/content-automation/medium_en.md | \
  medium-cli publish --title "Weekly Review"
Enter fullscreen mode Exit fullscreen mode

I duplicated the same logic for Substack and Bluesky, using jq to build the JSON for Bluesky. The script worked for a day, but it quickly broke:

  • Hard‑coded paths – each new date required manual updates.
  • No schema validation – the Bluesky JSON was sent raw, so the length error kept slipping through.
  • No CI integration – I had to run the script locally, which defeated the “build in public” automation goal.

The failure manifested as a broken JSON payload (missing commas) and a mismatched metadata.json flag (medium_generated: false) that downstream jobs relied on.


The Implementation

1. Repository Layout

content/
└─ 2026/
   └─ 10/
      └─ 06/
         └─ content-automation/
            ├─ changelog.md
            ├─ medium_en.md
            ├─ medium_es.md
            ├─ substack_en.md
            ├─ substack_es.md
            ├─ bluesky_en.json
            ├─ bluesky_es.json
            └─ metadata.json
Enter fullscreen mode Exit fullscreen mode

All files are now generated automatically by a single Node.js script (scripts/generate-content.js). The script reads a template (templates/post.hbs) and injects the day’s data (date, title, tags). It then writes the platform‑specific files and updates metadata.json.

2. Core Generator (scripts/generate-content.js)

// scripts/generate-content.js
const fs = require('fs');
const path = require('path');
const Handlebars = require('handlebars');

// Load shared data
const today = new Date().toISOString().slice(0, 10);
const data = {
  date: today,
  title: 'Weekly Review – Content Automation',
  tags: ['vibecoding', 'buildinpublic', 'automation'],
  summary: 'AI‑driven drafts, a ClickUp fix, and multi‑channel publishing.'
};

// Helper to write a file only if content changed
function writeIfChanged(filePath, content) {
  if (fs.existsSync(filePath) && fs.readFileSync(filePath, 'utf8') === content) return;
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, content);
}

// 1️⃣ Generate Medium Markdown (EN & ES)
['en', 'es'].forEach(lang => {
  const tmpl = fs.readFileSync(`templates/medium_${lang}.hbs`, 'utf8');
  const compiled = Handlebars.compile(tmpl);
  const md = compiled(data);
  writeIfChanged(`content/2026/10/06/content-automation/medium_${lang}.md`, md);
});

// 2️⃣ Generate Substack newsletters
['en', 'es'].forEach(lang => {
  const tmpl = fs.readFileSync(`templates/substack_${lang}.hbs`, 'utf8');
  const compiled = Handlebars.compile(tmpl);
  const md = compiled(data);
  writeIfChanged(`content/2026/10/06/content-automation/substack_${lang}.md`, md);
});

// 3️⃣ Generate Bluesky JSON payloads
['en', 'es'].forEach(lang => {
  const payload = {
    type: lang === 'en' ? 'progress' : 'avance',
    text: `${data.title} – ${data.summary}`.slice(0, 300) // enforce limit
  };
  writeIfChanged(
    `content/2026/10/06/content-automation/bluesky_${lang}.json`,
    JSON.stringify([payload], null, 2)
  );
});

// 4️⃣ Update metadata flags
const metaPath = 'content/2026/10/06/content-automation/metadata.json';
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
meta.medium_generated = true;
meta.substack_generated = true;
meta.bluesky_generated = true;
writeIfChanged(metaPath, JSON.stringify(meta, null, 2));

console.log('✅ Content generation complete');
Enter fullscreen mode Exit fullscreen mode

Key points

  • Handlebars templates keep the prose DRY. Only the language‑specific strings differ.
  • writeIfChanged prevents unnecessary Git diffs, which keeps the CI cache happy.
  • The Bluesky payload is built with a hard limit (slice(0, 300)) to guarantee schema compliance before the API call.

3. CI Integration

The new GitHub Actions workflow (.github/workflows/content.yml) runs on a schedule (cron: '0 6 * * *') and on any push to main:

name: Content Automation

on:
  schedule:
    - cron: '0 6 * * *'   # 6 AM UTC daily
  push:
    paths:
      - 'content/**'
      - 'scripts/**'
      - 'templates/**'

jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      - run: npm ci
      - run: node scripts/generate-content.js
      - name: Commit changes
        uses: stefanzweifel/git-auto-commit-action@v4
        with:
          commit_message: "chore(content): auto-generate $(date +%Y-%m-%d) [skip ci]"
          file_pattern: "content/**"
Enter fullscreen mode Exit fullscreen mode

The workflow auto‑commits the generated files, which is exactly the three commits we see in the diff:

  • chore(content): auto-generate 2026-10-07
  • chore(bluesky): posts 2026-10-07
  • chore(devto): article 2026-10-07

Notice how the metadata.json flags are now set to true after generation:

-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
Enter fullscreen mode Exit fullscreen mode

4


Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.

Repo: zaerohell/content-automation · 2026-10-07

#playadev #buildinpublic

Top comments (0)