Automating Multi‑Platform Content Publishing – Adding Bluesky Support to the content‑automation Repo
TL;DR: I extended the content‑automation pipeline to generate platform‑specific payloads for Bluesky, updated the metadata flags, and reorganized the daily content folder. The change lets the same CI job publish to Medium, Substack and Bluesky without manual steps.
The Problem
Our nightly CI job builds a daily folder (content/YYYY/MM/DD/<project>) that contains markdown files for Medium, Substack and a JSON manifest used by the posting scripts. On 2026‑10‑16 the job succeeded in creating medium_en.md and substack_en.md, but the Bluesky post never appeared. The API responded with:
POST https://bsky.social/xrpc/com.atproto.repo.createRecord
400 Bad Request
{
"error": "InvalidRequest",
"message": "Missing required field: text"
}
The root cause was that the generator never emitted a bluesky_*.json file, and the metadata.json flags still reported medium_generated: true and substack_generated: true while Bluesky stayed false. Without a payload, the posting script aborted silently.
What I Tried First
My first attempt was to reuse the existing substack_en.md as the Bluesky payload, converting markdown to plain text on‑the‑fly:
// old‑generator.js (hypothetical)
const md = fs.readFileSync('substack_en.md', 'utf8');
const plain = md.replace(/[#>*]/g, '').trim();
fs.writeFileSync('bluesky_en.json', JSON.stringify({ text: plain }));
The script ran, but the resulting JSON lacked the required type field ("app.bsky.feed.post"), causing the same 400 error. I also tried to generate a single bluesky.json for both languages, but the file path (content/.../bluesky.json) conflicted with the CI cache and was overwritten by the next day's run.
Both approaches failed because:
- Schema mismatch – Bluesky expects an AT‑Protocol record, not plain text.
- File collision – a shared file across languages broke the per‑language isolation we already have for Medium and Substack.
The Implementation
1. Folder Layout
content/
└─ 2026/
└─ 10/
└─ 16/
└─ content-automation/
├─ changelog.md
├─ metadata.json
├─ medium_en.md
├─ medium_es.md
├─ substack_en.md
├─ substack_es.md
├─ bluesky_en.json
└─ bluesky_es.json
Each platform now has its own file, guaranteeing that a failure in one does not affect the others.
2. Updating metadata.json
The diff shows the flag changes:
--- a/content/2026/10/16/content-automation/metadata.json
+++ b/content/2026/10/16/content-automation/metadata.json
@@ -12,8 +12,8 @@
"pull_requests": 0,
"releases": 0,
"closed_issues": 0,
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
"bluesky_generated": true,
"last_build": "2026-10-16T07:15:00Z"
}
I added a new bluesky_generated boolean and set it to true after the JSON payload is written. The CI script now atomically writes the whole metadata.json using fs.writeFileSync(..., { flag: 'w' }) to avoid race conditions.
3. Generating the Bluesky Payload
New files bluesky_en.json and bluesky_es.json were added. A snippet of the generated JSON (excerpt from the diff) looks like this:
[
{
"type": "app.bsky.feed.post",
"text": "Finally fixed the bug that broke our daily content pipeline. 🎉\n\nThe fix lives in `src/pipeline/keepAlive.ts` and adds a retry wrapper around the email sender.",
"createdAt": "2026-10-16T07:02:00Z",
"langs": ["en"]
}
]
The generator (src/pipeline/generateBluesky.js) now builds the object according to the AT‑Protocol spec:
// src/pipeline/generateBluesky.js
const fs = require('fs');
const path = require('path');
function buildRecord({ title, body, lang }) {
return {
type: 'app.bsky.feed.post',
text: `${title}\n\n${body}`,
createdAt: new Date().toISOString(),
langs: [lang],
};
}
function writeBlueskyPayload(dir, locale, record) {
const file = path.join(dir, `bluesky_${locale}.json`);
fs.writeFileSync(file, JSON.stringify([record], null, 2));
}
// Example usage inside the main pipeline
const recordEn = buildRecord({
title: 'Bug fix: daily content pipeline',
body: 'Finally fixed the bug that broke our daily content pipeline. 🎉\n\nThe fix lives in `src/pipeline/keepAlive.ts` and adds a retry wrapper around the email sender.',
lang: 'en',
});
writeBlueskyPayload(__dirname, 'en', recordEn);
4. Adding a Changelog Entry
content/2026/10/16/content-automation/changelog.md now documents the change:
## 2026-10-16 content-automation
### Added
- `bluesky_en.json` & `bluesky_es.json` – AT‑Protocol payloads for Bluesky posts.
- `metadata.json` – new `bluesky_generated` flag.
- Updated CI step to commit generated files before publishing.
5. CI Adjustments
The GitHub Actions workflow (.github/workflows/content.yml) now includes a step:
- name: Commit generated content
run: |
git config user.name "vibecoding-bot"
git config user.email "bot@vibecoding.com"
git add content/${{ env.DATE }}/content-automation/*
git commit -m "chore(content): auto-generate ${{ env.DATE }}" --no-verify
git push origin HEAD:${{ github.ref }}
The --no-verify flag bypasses the default pre-commit hook that would reject a commit without a `bluesky_generated
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-17
#playadev #buildinpublic
Top comments (0)