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:
- Pull the latest content templates.
- Render them into
*.mdfiles for each platform. - Flip the
medium_generatedandsubstack_generatedflags incontent/…/metadata.json. - 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
}
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.
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));
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+'));
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.jsononce, and calls the platform SDKs.
Both scripts live under src/scripts/:
src/
└─ scripts/
├─ generate.ts
├─ publish.ts
└─ cron/
└─ contentCron.ts
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
}
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()}`);
}
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 ..."
}
]
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);
}
});
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
}
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
Top comments (0)