DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing with a Node.js Scheduler

Automating Multi‑Platform Content Publishing with a Node.js Scheduler

TL;DR: I extended the content-automation repo to generate weekly newsletters, Dev.to articles, and platform‑specific markdown in a single CI run. The key was a tiny Node.js scheduler that reads a JSON manifest, writes files, and flips “generated” flags in metadata.json so downstream pipelines know what to publish.


The Problem

Our content pipeline had three independent manual steps:

  1. Write a weekly newsletter markdown file.
  2. Draft a Medium article.
  3. Publish a Dev.to post.

Each step required copying the same body copy into a different folder (weekly/, content-automation/medium_*, content-automation/substack_*) and then manually toggling flags in metadata.json.

During a production run on 2026‑08‑08 the CI job failed with a cryptic log line:

Error: Conn
Enter fullscreen mode Exit fullscreen mode

The truncated message was coming from the Prisma client that our automation script uses to fetch the latest draft from the CMS. Because the script never updated the metadata.json flags after a successful write, the next run tried to re‑process the same draft, hit a stale DB connection, and blew up.

In short: the automation was not idempotent, and the state tracking was brittle.

What I Tried First

My first attempt was to wrap the whole generation flow in a try / catch and, on any error, abort the job without touching the manifest. I added a quick if (fs.existsSync(filePath)) return; guard to each write operation.

// naive guard
if (fs.existsSync(targetPath)) {
  console.log(`${targetPath} already exists – skipping`);
  return;
}
Enter fullscreen mode Exit fullscreen mode

That prevented duplicate files, but it also silently skipped a legitimate update when we intentionally rewrote a newsletter (e.g., after a typo fix). Moreover, the guard didn’t address the stale Prisma connection, so the same Error: Conn kept surfacing in later runs.

The Implementation

1. Central Manifest (metadata.json)

The manifest now lives at content/2026/08/08/content-automation/metadata.json. I added explicit boolean fields for each platform and a timestamp for the last successful run.

{
  "pull_requests": 0,
  "releases": 0,
  "closed_issues": 0,
  "medium_generated": true,
  "substack_generated": true,
  "devto_generated": false,
  "last_run": "2026-08-08T23:45:00Z"
}
Enter fullscreen mode Exit fullscreen mode

The diff that shipped with the commit changed medium_generated and substack_generated from false to true (see the diff snippet below).

@@ -12,9 +12,14 @@
   "pull_requests": 0,
   "releases": 0,
   "closed_issues": 0,
-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
+  "devto_generated": false,
+  "last_run": "2026-08-08T23:45:00Z"
Enter fullscreen mode Exit fullscreen mode

2. Scheduler (src/scheduler.ts)

I introduced a tiny scheduler that runs three async jobs in series. Each job receives the manifest, decides whether it needs to run, writes the appropriate markdown, and updates the manifest atomically.

// src/scheduler.ts
import fs from 'fs/promises';
import path from 'path';
import { PrismaClient } from '@prisma/client';
import { Manifest } from './types';

const prisma = new PrismaClient();
const MANIFEST_PATH = path.resolve(__dirname, '../content/2026/08/08/content-automation/metadata.json');

async function loadManifest(): Promise<Manifest> {
  const raw = await fs.readFile(MANIFEST_PATH, 'utf-8');
  return JSON.parse(raw);
}

async function saveManifest(manifest: Manifest) {
  await fs.writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
}

/** Generic writer – ensures atomicity */
async function writeFileAtomic(target: string, content: string) {
  const tmp = `${target}.tmp`;
  await fs.writeFile(tmp, content);
  await fs.rename(tmp, target);
}

/** Job factories */
function createJob(
  name: keyof Manifest,
  srcFn: () => Promise<string>,
  destPath: string
) {
  return async (manifest: Manifest) => {
    if (manifest[name]) {
      console.log(`[${name}] already generated – skipping`);
      return manifest;
    }

    const content = await srcFn(); // fetch from DB / API
    await writeFileAtomic(destPath, content);
    manifest[name] = true;
    console.log(`[${name}] written to ${destPath}`);
    return manifest;
  };
}

/** Content fetchers – simplified */
async function fetchNewsletter(): Promise<string> {
  const draft = await prisma.draft.findFirst({ where: { platform: 'newsletter' } });
  if (!draft) throw new Error('No newsletter draft found');
  return draft.body;
}
async function fetchMedium(): Promise<string> {
  const draft = await prisma.draft.findFirst({ where: { platform: 'medium' } });
  if (!draft) throw new Error('No Medium draft found');
  return draft.body;
}
async function fetchDevTo(): Promise<string> {
  const draft = await prisma.draft.findFirst({ where: { platform: 'devto' } });
  if (!draft) throw new Error('No Dev.to draft found');
  return draft.body;
}

/** Main runner */
async function run() {
  let manifest = await loadManifest();

  const jobs = [
    createJob(
      'devto_generated',
      fetchDevTo,
      path.resolve(__dirname, '../content/2026/08/08/content-automation/devto.md')
    ),
    createJob(
      'medium_generated',
      fetchMedium,
      path.resolve(__dirname, '../content/2026/08/08/content-automation/medium_en.md')
    ),
    createJob(
      'substack_generated',
      fetchNewsletter,
      path.resolve(__dirname, '../content/2026/08/08/content-automation/substack_en.md')
    ),
  ];

  for (const job of jobs) {
    try {
      manifest = await job(manifest);
    } catch (e) {
      console.error('Job failed:', e);
      // abort early – we keep the manifest in its last good state
      break;
    }
  }

  manifest.last_run = new Date().toISOString();
  await saveManifest(manifest);
}

run().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Atomic writes (writeFileAtomic) prevent partially written markdown from being consumed by downstream pipelines.
  • Manifest gating (if (manifest[name])) makes the process idempotent while still allowing a manual reset (git commit -m "reset flags").
  • Error handling is now explicit; a failed DB fetch aborts the run, leaving the manifest untouched so the next CI attempt can retry safely.

3. New Content Files

The scheduler writes to the following paths, which match the diff‑added files:

File Purpose
content/weekly/2026-08-02_newsletter_en.md English newsletter (added by commit bb99c81c).

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-08-09

#playadev #buildinpublic

Top comments (0)