Automated Multi‑Platform Publishing Pipeline – How I Fixed the Content‑Automation Breakage
TL;DR: A stale metadata.json flag and a missing Bluesky payload stopped our weekly auto‑publish. By synchronizing the generation flags, adding the missing bluesky_en.json, and tightening the commit flow, the pipeline now pushes to Medium, Substack and Bluesky in a single CI run.
The Problem
Our content-automation repo drives the weekly “Build‑in‑Public” posts that go out to Medium, Substack, Bluesky and a private Slack channel. On 2026‑10‑22 the CI job that runs npm run generate started failing with:
Error: Unable to locate content/2026/10/21/content-automation/bluesky_en.json
at src/publish/bluesky.js:45:13
The generator printed “Skipping Bluesky publish – no payload found” and the metadata.json still reported "bluesky_published": false. Meanwhile, Medium and Substack posts were being created but the metadata.json flags medium_generated and substack_generated remained false, causing downstream scripts to think the week’s content hadn’t been published at all. The result was a half‑finished release that required manual intervention.
What I Tried First
My first instinct was to patch the CI script to ignore the missing Bluesky file:
# CI step – naive fallback
if [ -f "$payload_path" ]; then
node src/publish/bluesky.js "$payload_path"
else
echo "Bluesky payload missing – continuing"
fi
That let the job finish, but the metadata.json still reported bluesky_published: false, so the “publish‑status” dashboard kept flagging the week as incomplete. I also tried flipping the flags manually in metadata.json before the CI run:
{
"medium_generated": true,
"substack_generated": true,
"bluesky_published": true
}
That solved the dashboard noise but broke the idempotency guarantee – a subsequent run would think the content was already published and skip regeneration entirely. Neither approach addressed the root cause: the generator never produced the Bluesky JSON payload, and the metadata flags were out of sync with the actual file system state.
The Implementation
1. Generate the missing Bluesky payload
The generator lives in src/generator/bluesky.js. I added a deterministic output path and ensured the file is always written, even if the content is empty:
// src/generator/bluesky.js
const fs = require('fs');
const path = require('path');
function buildBlueskyPayload(date, article) {
const payload = {
type: 'progress',
text: article.summary,
tags: article.tags,
date,
};
const outDir = path.join('content', date.slice(0, 10), 'content-automation');
const outFile = path.join(outDir, 'bluesky_en.json');
// Ensure directory exists
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(outFile, JSON.stringify([payload], null, 2));
return outFile;
}
module.exports = { buildBlueskyPayload };
Running the generator now creates content/2026/10/22/content-automation/bluesky_en.json automatically. The new file appears in the repo with the exact diff you can see in the commit:
+[
+ {
+ "type": "progress",
+ "text": "Finally fixed the bug in content/2026/10/21/content-automation/bluesky_en.json that was preventing the auto‑generated 2026‑10‑22 post from being published on Bluesky.",
+ "tags": ["buildinpublic","automation"],
+ "date": "2026-10-22"
+ }
+]
2. Keep metadata.json in lockstep
Instead of manually editing metadata.json, I introduced a small helper src/utils/metadata.js that atomically updates the JSON after each successful publish step:
// src/utils/metadata.js
const fs = require('fs');
const metaPath = 'content/2026/10/22/content-automation/metadata.json';
function loadMeta() {
return JSON.parse(fs.readFileSync(metaPath, 'utf8'));
}
function updateMeta(patch) {
const meta = loadMeta();
const updated = { ...meta, ...patch };
// Write atomically to avoid race conditions
fs.writeFileSync(metaPath, JSON.stringify(updated, null, 2));
}
module.exports = { loadMeta, updateMeta };
Each publisher now calls updateMeta on success:
// src/publish/bluesky.js
const { updateMeta } = require('../utils/metadata');
...
if (publishSuccess) {
updateMeta({ bluesky_published: true, bluesky_uris: { [date]: uri } });
}
The same pattern was applied to Medium and Substack publishers, resulting in a clean metadata.json after the run:
@@ -12,8 +12,14 @@
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
"bluesky_published": true,
- "bluesky_uris": {},
+ "bluesky_uris": {
+ "2026-10-22": "https://bsky.app/profile/roberto.luna/post/3kz6..."
+ },
+ "last_run": "2026-10-23T04:12:00Z"
}
3. Tighten the CI pipeline
The CI job now runs three sequential steps:
-
Generate –
npm run generate(creates markdown files andbluesky_en.json). -
Publish – Calls each publisher (
node src/publish/medium.js, etc.) and relies on the atomic metadata updates. -
Validate – A new script
npm run verifyreadsmetadata.jsonand exits with a non‑zero code if any flag isfalse.
# .github/workflows/publish.yml
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install
run: npm ci
- name: Generate content
run: npm run generate
- name: Publish to platforms
run: |
node src/publish/medium.js
node src/publish/substack.js
node src/publish/bluesky.js
- name: Verify publish status
run: npm run verify
If the verification step fails, the workflow aborts and alerts the team, preventing silent failures like the one we experienced.
Key Takeaway
Never let a state flag become the source of truth without a single source of truth for the underlying artifact. By generating the payload first, then atomically updating metadata.json after each successful publish, you guarantee that the flag truly reflects reality and you avoid “phantom‑published” weeks.
What's Next
I’m adding a schema‑validation step for each payload (using ajv), so malformed JSON will fail fast before any API call. Additionally, I plan
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-23
#playadev #buildinpublic
Top comments (0)