DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Channel Content Publishing with a Single Repo‑Based Generator

Automating Multi‑Channel Content Publishing with a Single Repo‑Based Generator

TL;DR: I refactored the content-automation repo to generate platform‑specific markdown/JSON files in one CI step, fixing broken metadata flags and eliminating per‑run credential churn. The change now lets our GitHub Action push to Medium, Substack, and Bluesky reliably from a single source of truth.


The Problem

Our weekly workflow required three separate scripts to produce content for Medium, Substack, and Bluesky. Each script duplicated the same front‑matter extraction logic, and the metadata.json flag that tells the CI whether a platform has already been generated was getting out of sync. The symptom was a CI run that reported:

Error: metadata.json "medium_generated" is false but the Medium file already exists.
Enter fullscreen mode Exit fullscreen mode

Additionally, Issue #74 revealed that the tenant credential used by the API was being created and revoked on every start‑up, causing a burst of 401 Unauthorized errors from the internal auth service.


What I Tried First

My first attempt was to keep the three scripts isolated and add a small wrapper that would call them sequentially:

#!/bin/bash
node scripts/generate-medium.js
node scripts/generate-substack.js
node scripts/generate-bluesky.js
Enter fullscreen mode Exit fullscreen mode

I also tried to patch metadata.json after each script:

// pseudo‑code inside generate-medium.js
fs.writeFileSync('content/metadata.json', JSON.stringify({
  ...metadata,
  medium_generated: true
}))
Enter fullscreen mode Exit fullscreen mode

Both approaches failed:

  • The wrapper aborted after the first script because the metadata.json write caused a race condition – the file was locked when the next script attempted to read it.
  • Credential churn persisted because each script re‑initialized the API client, triggering the creation/revocation cycle described in #74.

The Implementation

1. Centralize Generation in a Single Node module

I introduced src/generator.js that exports a generateAll() function. It reads the source markdown once, builds a unified content model, and writes platform‑specific files. This eliminates duplicated parsing logic.

// src/generator.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';

interface Content {
  title: string;
  body: string;
  tags: string[];
  date: string;
}

export function generateAll(sourcePath: string) {
  const raw = fs.readFileSync(sourcePath, 'utf-8');
  const { data, content } = matter(raw);
  const model: Content = {
    title: data.title as string,
    body: content,
    tags: data.tags as string[],
    date: data.date as string,
  };

  // Medium (markdown)
  const mediumPath = path.join('content', '2026', '10', '08', 'content-automation', 'medium_en.md');
  fs.writeFileSync(mediumPath, buildMedium(model));

  // Substack (markdown)
  const substackPath = path.join('content', '2026', '10', '08', 'content-automation', 'substack_en.md');
  fs.writeFileSync(substackPath, buildSubstack(model));

  // Bluesky (JSON)
  const blueskyPath = path.join('content', '2026', '10', '08', 'content-automation', 'bluesky_en.json');
  fs.writeFileSync(blueskyPath, JSON.stringify(buildBluesky(model), null, 2));
}
Enter fullscreen mode Exit fullscreen mode

The helper builders (buildMedium, buildSubstack, buildBluesky) live in the same file and share the same model instance, guaranteeing consistency.

2. Update metadata.json atomically

Instead of writing the file after each platform, I now compute the new flags in memory and write once at the end of generateAll():

// src/generator.ts (continued)
function persistMetadata() {
  const metaPath = path.join('content', '2026', '10', '08', 'content-automation', 'metadata.json');
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
  const updated = {
    ...meta,
    medium_generated: true,
    substack_generated: true,
    bluesky_generated: true,
    updated_at: new Date().toISOString(),
  };
  fs.writeFileSync(metaPath, JSON.stringify(updated, null, 2));
}
Enter fullscreen mode Exit fullscreen mode

Calling persistMetadata() after all writes removes the race condition and guarantees the CI step sees a single source of truth.

3. Fix credential churn (Issue #74)

The API client now lazily creates a tenant credential once per CI run and reuses it across all platform uploads. I moved the credential logic to src/tenant.ts:

// src/tenant.ts
import { getAuthClient } from './auth';

let tenantToken: string | null = null;

export async function getTenantToken() {
  if (tenantToken) return tenantToken;
  const client = getAuthClient();
  const { token } = await client.createTenantCredential();
  tenantToken = token; // cache for the whole run
  return tenantToken;
}
Enter fullscreen mode Exit fullscreen mode

All upload modules (src/uploadMedium.ts, etc.) now import getTenantToken() instead of creating their own client. The token is revoked only at the end of the workflow via a dedicated cleanup step:

# .github/workflows/content-publish.yml
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Install deps
        run: npm ci
      - name: Generate content
        run: node -r ts-node/register src/generator.ts
      - name: Publish to platforms
        run: node -r ts-node/register src/publishAll.ts
      - name: Cleanup tenant
        if: always()
        run: node -r ts-node/register src/cleanupTenant.ts
Enter fullscreen mode Exit fullscreen mode

4. Diff Summary of the Commit

Below are the relevant diff excerpts that landed in commit 568cb420 and commit 71938bfc.

+## [2026-10-08] content-automation
+
+### Added
+- `content/2026/10/07/VS/changelog.md` – nuevo registro de cambios para VS (16 líneas).
+- `content/2026/10/07/VS/medium_en.md` – art
Enter fullscreen mode Exit fullscreen mode
+**When the “publish” button felt like a hostage‑taking negotiation**
+
+I’ve been a full‑stack dev for a while, but the first time I tried to automate a daily newsletter, a Medium p
Enter fullscreen mode Exit fullscreen mode
-  "medium_generated": false,
-  "substack_generated": false,
+  "medium_generated": true,
+  "substack_generated": true,
Enter fullscreen mode Exit fullscreen mode
+[
+  {
+    "type": "progress",
+    "text": "Finally fixed the Sentry margin bug – I edited .github/workflows/bluesky-daily.yml to ignore cancelled runs and remove the weekly check‑
Enter fullscreen mode Exit fullscreen mode

These changes introduced the new markdown files (medium_en.md, substack_en.md) and updated metadata.json flags in a single atomic write.

5. GitHub Action Adjustments

The workflow file /.github/workflows/content-publish.yml now runs a single job called generate-and-publish. I added a step to skip cancelled runs for Bluesky to avoid the “Sentry margin bug” mentioned in the JSON payload.

- name: Skip cancelled runs (Bluesky)
  if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'cancelled'
  run: echo "Skipping Bluesky publish for cancelled run"
Enter fullscreen mode Exit fullscreen mode

Key Takeaway

When multiple outputs share a common source, centralize the generation logic and write shared state (like metadata flags) once, atomically. This eliminates race conditions, keeps platform outputs in sync, and reduces credential churn by reusing a single token per CI run


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

#playadev #buildinpublic

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to