Implementing a Multi‑Platform Content Automation Pipeline with JSON Metadata and GitHub Actions

TL;DR: I added a structured metadata.json and a small Node.js runner that reads platform‑specific markdown files, publishes them to Medium, Substack and Bluesky, and then flips the corresponding flags in the JSON. The change makes the whole “write once, publish everywhere” workflow deterministic and CI‑driven.

The Problem

Every week I have to push the same article to three different publishing platforms (Medium, Substack, Bluesky) and keep a changelog that lives in the repo. The manual copy‑paste looked like this:

$ cat content/2026/10/30/content-automation/medium_en.md # → copy → Medium UI $ cat content/2026/10/30/content-automation/substack_en.md # → copy → Substack UI $ cat content/2026/10/30/content-automation/bluesky_es.json # → copy → Bluesky API 

The symptoms were:

  • Missing changelog entries – after a failed publish the changelog.md entry stayed in the repo, causing duplicate posts.
  • Inconsistent state – I couldn’t tell at a glance which platform already received the article; the repo only stored a boolean flag in metadata.json, but it was never updated reliably.
  • Error‑prone copy‑paste – a stray newline or a missing front‑matter broke the API call and left me scrambling.

The error messages from the platform APIs were buried in terminal output, making debugging a nightmare.

What I Tried First

My first attempt was a bash script that concatenated the three markdown files and used curl to POST to each endpoint. I stored a tiny published.txt file with a list of dates to avoid duplicates.

#!/usr/bin/env bash DATE=$1 if grep -q "$DATE" published.txt; then echo "Already published" exit 0 fi # Medium curl -X POST https://api.medium.com/v1/... -d @content/$DATE/medium_en.md # Substack curl -X POST https://api.substack.com/v1/... -d @content/$DATE/substack_en.md # Bluesky curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \ -d @content/$DATE/bluesky_es.json echo "$DATE" >> published.txt 

What went wrong

  • The script stopped on the first failed curl call, leaving the other two platforms untouched.
  • published.txt got out of sync with the actual state in metadata.json.
  • Adding a new platform required editing the same script, violating DRY.

I needed a more robust, state‑driven approach.

The Implementation

1. Centralizing State in metadata.json

I introduced a JSON file that lives next to each content folder. It tracks whether the article has been generated for each platform and stores the URIs returned by the APIs.

// content/2026/10/30/content-automation/metadata.json { "date": "2026-10-30", "medium_generated": true, "substack_generated": true, "bluesky_published": true, "bluesky_uris": { "post": "at://did:plc:.../app.bsky.feed.post/3k5g..." }, "pull_requests": 0, "releases": 0, "closed_issues": 0 } 

Diff excerpt – the fields were added in commit 7b14c67b and later reinforced in 048876df:

- "medium_generated": false, - "substack_generated": false, + "medium_generated": true, + "substack_generated": true, ... - "bluesky_published": false, - "bluesky_uris": {}, + "bluesky_published": true, + "bluesky_uris": { ... } 

2. Node.js Runner (publish.js)

A small Node script now owns the entire pipeline. It reads metadata.json, decides which platforms still need publishing, calls the appropriate SDKs, and writes the updated JSON back to the repo.

// scripts/publish.js import fs from 'fs'; import path from 'path'; import { MediumClient } from './lib/medium.js'; import { SubstackClient } from './lib/substack.js'; import { BlueskyClient } from './lib/bluesky.js'; const CONTENT_ROOT = path.resolve('content', '2026', '10', '30', 'content-automation'); const metaPath = path.join(CONTENT_ROOT, 'metadata.json'); const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); async function main() { if (!meta.medium_generated) { const md = fs.readFileSync(path.join(CONTENT_ROOT, 'medium_en.md'), 'utf8'); const resp = await MediumClient.publish(md); meta.medium_generated = true; console.log('Medium →', resp.url); } if (!meta.substack_generated) { const md = fs.readFileSync(path.join(CONTENT_ROOT, 'substack_en.md'), 'utf8'); const resp = await SubstackClient.publish(md); meta.substack_generated = true; console.log('Substack →', resp.url); } if (!meta.bluesky_published) { const payload = JSON.parse( fs.readFileSync(path.join(CONTENT_ROOT, 'bluesky_es.json'), 'utf8') ); const resp = await BlueskyClient.publish(payload); meta.bluesky_published = true; meta.bluesky_uris = { post: resp.uri }; console.log('Bluesky →', resp.uri); } // Persist updated state fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2)); } main().catch(err => { console.error('Publish failed:', err); process.exit(1); }); 

Platform SDK Stubs

Each client wraps the HTTP call and normalizes the response:

// lib/bluesky.js import fetch from 'node-fetch'; export const BlueskyClient = { async publish(payload) { const res = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.BSKY_TOKEN}` }, body: JSON.stringify(payload) }); if (!res.ok) { const txt = await res.text(); throw new Error(`Bluesky API error ${res.status}: ${txt}`); } return await res.json(); // { uri: "...", cid: "..." } } }; 

3. GitHub Actions Integration

The runner is now part of a CI workflow that triggers on any change under content/**/metadata.json. The job fails fast if any platform returns an error, leaving the JSON untouched.

 yaml # .github/workflows/publish.yml name: Publish Content on: push: paths: - 'content/**/metadata.json' jobs: publish: --- *Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.* *Repo: `zaerohell/content-automation` · 2026-10-31* \#playadev #buildinpublic