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.mdentry 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
curlcall, leaving the other two platforms untouched. -
published.txtgot out of sync with the actual state inmetadata.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
7b14c67band later reinforced in048876df:
- "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
Top comments (0)