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.
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
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
}))
Both approaches failed:
- The wrapper aborted after the first script because the
metadata.jsonwrite 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));
}
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));
}
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;
}
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
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
+**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
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
+[
+ {
+ "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‑
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"
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)
tr.ee/dev-to