DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a JSON‑Driven Cron Pipeline

Automating Multi‑Platform Content Publishing with a JSON‑Driven Cron Pipeline

TL;DR: I refactored the content-automation repo to generate daily markdown assets, update metadata.json, and push to Medium, Substack, and Bluesky in a single cron job. The change fixes a race condition that left JSON flags out of sync and adds a reusable publish.ts module.

The Problem

Our weekly pipeline was supposed to:

  1. Pull the latest content templates.
  2. Render them into *.md files for each platform.
  3. Flip the medium_generated and substack_generated flags in content/…/metadata.json.
  4. Call the platform‑specific SDKs (Medium API, Substack webhook, Bluesky AT‑proto client).

In practice the pipeline would sometimes finish the markdown generation but crash before the JSON update. The result was a repository state like:

// content/2026/08/22/content-automation/metadata.json (before fix)
{
  "pull_requests": 0,
  "releases": 0,
  "closed_issues": 0,
  "medium_generated": false,
  "substack_generated": false,
  "bluesky_generated": false
}
Enter fullscreen mode Exit fullscreen mode

When the job later succeeded, the markdown files existed but the flags stayed false. Downstream jobs that read the flags (e.g., the GitHub Action that triggers the deployment) would skip the already‑generated content, causing duplicate posts or missing releases. The symptom on the CI logs was:

ERROR: Content generation succeeded but metadata.json unchanged.
Aborting publish step to avoid duplicate posts.
Enter fullscreen mode Exit fullscreen mode

What I Tried First

My first attempt was a quick patch inside the existing generateContent.ts script:

// src/scripts/generateContent.ts (first attempt)
await writeFile(mdPath, rendered);
await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
Enter fullscreen mode Exit fullscreen mode

I assumed that writing the metadata file right after the markdown would be atomic. However, the script runs inside a Docker container that mounts the repo as a volume. The writeFile call would sometimes be buffered and the container would exit before the filesystem flushed, leaving the file unchanged on the host. The CI logs showed “no changes detected” even though the script reported success.

I also tried adding a fsync call:

await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
await fs.promises.fsync(await fs.promises.open(metadataPath, 'r+'));
Enter fullscreen mode Exit fullscreen mode

That reduced the race but still didn’t guarantee consistency when the container crashed (e.g., out‑of‑memory) after the markdown write but before the fsync.

The Implementation

1. Separate Concerns – generate.ts vs publish.ts

I split the pipeline into two distinct steps:

  • generate.ts – only creates the markdown files and returns a plain‑JS object describing what was generated.
  • publish.ts – consumes that object, updates metadata.json once, and calls the platform SDKs.

Both scripts live under src/scripts/:

src/
 └─ scripts/
     ├─ generate.ts
     ├─ publish.ts
     └─ cron/
         └─ contentCron.ts
Enter fullscreen mode Exit fullscreen mode

2. Atomic JSON Update with a Temp File

Instead of writing directly to metadata.json, publish.ts writes to a temporary file and then renames it. fs.rename is atomic on POSIX filesystems, guaranteeing that the consumer sees either the old or the new version, never a half‑written file.

// src/scripts/publish.ts
import { writeFile, rename } from 'fs/promises';
import path from 'path';

export async function updateMetadata(
  metadataPath: string,
  updates: Partial<Metadata>
) {
  const current = JSON.parse(await readFile(metadataPath, 'utf8'));
  const merged = { ...current, ...updates };
  const tmpPath = `${metadataPath}.tmp`;

  await writeFile(tmpPath, JSON.stringify(merged, null, 2));
  await rename(tmpPath, metadataPath); // atomic swap
}
Enter fullscreen mode Exit fullscreen mode

3. Centralized Platform Clients

I created thin wrappers for each platform so that publish.ts can call them in a loop. This makes it trivial to add a new destination later.

// src/scripts/clients/medium.ts
import fetch from 'node-fetch';
export async function postToMedium(md: string, token: string) {
  const res = await fetch('https://api.medium.com/v1/articles', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ contentFormat: 'markdown', content: md, title: 'Weekly Build‑in‑Public' }),
  });
  if (!res.ok) throw new Error(`Medium post failed: ${await res.text()}`);
}
Enter fullscreen mode Exit fullscreen mode

Similar wrappers exist for substack.ts and bluesky.ts. The Bluesky client now reads the newly added bluesky_es.json payload:

// content/2026/08/22/content-automation/bluesky_es.json
[
  {
    "type": "avance",
    "text": "Finalmente solucioné el ..."
  }
]
Enter fullscreen mode Exit fullscreen mode

4. Cron Orchestration

The contentCron.ts file is the only entry point that CI runs:

// src/cron/contentCron.ts
import { generate } from '../scripts/generate';
import { publish } from '../scripts/publish';
import { schedule } from 'node-cron';

schedule('0 6 * * *', async () => {
  try {
    const generated = await generate(); // returns { mdPaths: [...], platform: 'es' }
    await publish(generated);
    console.log('✅ Content pipeline completed');
  } catch (err) {
    console.error('❌ Content pipeline failed', err);
    process.exit(1);
  }
});
Enter fullscreen mode Exit fullscreen mode

The cron runs at 06:00 UTC every day, ensuring a deterministic order: generate → publish → metadata update.

5. Updated metadata.json Diff

After the refactor the file looks like this (excerpt from the diff):

@@ -14,8 +14,8 @@
   "pull_requests": 0,
   "releases": 0,
   "closed_issues": 0,
-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
   "bluesky_generated": true
 }
Enter fullscreen mode Exit fullscreen mode

The flags now reliably reflect the actual state of the repo because they are set after every successful publish call.

6. Tests & CI Guard

I added a Jest test that simulates a crash after markdown generation and asserts that metadata.json remains unchanged, proving the atomic swap works:


ts
test('metadata not updated on crash', async () => {
  const crashSimulator = jest.spyOn(publish, '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-08-23*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)