DEV Community

Roberto Luna
Roberto Luna

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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": { ... }
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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: "..." }
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)