DEV Community

Roberto Luna
Roberto Luna

Posted on

Automated Multi‑Platform Publishing Pipeline – How I Fixed the Content‑Automation Breakage

Automated Multi‑Platform Publishing Pipeline – How I Fixed the Content‑Automation Breakage

TL;DR: A stale metadata.json flag and a missing Bluesky payload stopped our weekly auto‑publish. By synchronizing the generation flags, adding the missing bluesky_en.json, and tightening the commit flow, the pipeline now pushes to Medium, Substack and Bluesky in a single CI run.


The Problem

Our content-automation repo drives the weekly “Build‑in‑Public” posts that go out to Medium, Substack, Bluesky and a private Slack channel. On 2026‑10‑22 the CI job that runs npm run generate started failing with:

Error: Unable to locate content/2026/10/21/content-automation/bluesky_en.json
    at src/publish/bluesky.js:45:13
Enter fullscreen mode Exit fullscreen mode

The generator printed “Skipping Bluesky publish – no payload found” and the metadata.json still reported "bluesky_published": false. Meanwhile, Medium and Substack posts were being created but the metadata.json flags medium_generated and substack_generated remained false, causing downstream scripts to think the week’s content hadn’t been published at all. The result was a half‑finished release that required manual intervention.


What I Tried First

My first instinct was to patch the CI script to ignore the missing Bluesky file:

# CI step – naive fallback
if [ -f "$payload_path" ]; then
  node src/publish/bluesky.js "$payload_path"
else
  echo "Bluesky payload missing – continuing"
fi
Enter fullscreen mode Exit fullscreen mode

That let the job finish, but the metadata.json still reported bluesky_published: false, so the “publish‑status” dashboard kept flagging the week as incomplete. I also tried flipping the flags manually in metadata.json before the CI run:

{
  "medium_generated": true,
  "substack_generated": true,
  "bluesky_published": true
}
Enter fullscreen mode Exit fullscreen mode

That solved the dashboard noise but broke the idempotency guarantee – a subsequent run would think the content was already published and skip regeneration entirely. Neither approach addressed the root cause: the generator never produced the Bluesky JSON payload, and the metadata flags were out of sync with the actual file system state.


The Implementation

1. Generate the missing Bluesky payload

The generator lives in src/generator/bluesky.js. I added a deterministic output path and ensured the file is always written, even if the content is empty:

// src/generator/bluesky.js
const fs = require('fs');
const path = require('path');

function buildBlueskyPayload(date, article) {
  const payload = {
    type: 'progress',
    text: article.summary,
    tags: article.tags,
    date,
  };
  const outDir = path.join('content', date.slice(0, 10), 'content-automation');
  const outFile = path.join(outDir, 'bluesky_en.json');

  // Ensure directory exists
  fs.mkdirSync(outDir, { recursive: true });
  fs.writeFileSync(outFile, JSON.stringify([payload], null, 2));
  return outFile;
}
module.exports = { buildBlueskyPayload };
Enter fullscreen mode Exit fullscreen mode

Running the generator now creates content/2026/10/22/content-automation/bluesky_en.json automatically. The new file appears in the repo with the exact diff you can see in the commit:

+[
+  {
+    "type": "progress",
+    "text": "Finally fixed the bug in content/2026/10/21/content-automation/bluesky_en.json that was preventing the auto‑generated 2026‑10‑22 post from being published on Bluesky.",
+    "tags": ["buildinpublic","automation"],
+    "date": "2026-10-22"
+  }
+]
Enter fullscreen mode Exit fullscreen mode

2. Keep metadata.json in lockstep

Instead of manually editing metadata.json, I introduced a small helper src/utils/metadata.js that atomically updates the JSON after each successful publish step:

// src/utils/metadata.js
const fs = require('fs');
const metaPath = 'content/2026/10/22/content-automation/metadata.json';

function loadMeta() {
  return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
}

function updateMeta(patch) {
  const meta = loadMeta();
  const updated = { ...meta, ...patch };
  // Write atomically to avoid race conditions
  fs.writeFileSync(metaPath, JSON.stringify(updated, null, 2));
}

module.exports = { loadMeta, updateMeta };
Enter fullscreen mode Exit fullscreen mode

Each publisher now calls updateMeta on success:

// src/publish/bluesky.js
const { updateMeta } = require('../utils/metadata');
...
if (publishSuccess) {
  updateMeta({ bluesky_published: true, bluesky_uris: { [date]: uri } });
}
Enter fullscreen mode Exit fullscreen mode

The same pattern was applied to Medium and Substack publishers, resulting in a clean metadata.json after the run:

@@ -12,8 +12,14 @@
   "pull_requests": 0,
   "releases": 0,
   "closed_issues": 0,
-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
   "bluesky_published": true,
-  "bluesky_uris": {},
+  "bluesky_uris": {
+    "2026-10-22": "https://bsky.app/profile/roberto.luna/post/3kz6..."
+  },
+  "last_run": "2026-10-23T04:12:00Z"
 }
Enter fullscreen mode Exit fullscreen mode

3. Tighten the CI pipeline

The CI job now runs three sequential steps:

  1. Generate – npm run generate (creates markdown files and bluesky_en.json).
  2. Publish – Calls each publisher (node src/publish/medium.js, etc.) and relies on the atomic metadata updates.
  3. Validate – A new script npm run verify reads metadata.json and exits with a non‑zero code if any flag is false.
# .github/workflows/publish.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install
        run: npm ci
      - name: Generate content
        run: npm run generate
      - name: Publish to platforms
        run: |
          node src/publish/medium.js
          node src/publish/substack.js
          node src/publish/bluesky.js
      - name: Verify publish status
        run: npm run verify
Enter fullscreen mode Exit fullscreen mode

If the verification step fails, the workflow aborts and alerts the team, preventing silent failures like the one we experienced.


Key Takeaway

Never let a state flag become the source of truth without a single source of truth for the underlying artifact. By generating the payload first, then atomically updating metadata.json after each successful publish, you guarantee that the flag truly reflects reality and you avoid “phantom‑published” weeks.


What's Next

I’m adding a schema‑validation step for each payload (using ajv), so malformed JSON will fail fast before any API call. Additionally, I plan


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-23

#playadev #buildinpublic

Top comments (0)