Automating Multi‑Platform Publishing: Adding Bluesky Support to the Content‑Automation Repo
TL;DR: I extended the content‑automation pipeline to generate and track Bluesky posts alongside Medium and Substack. The change adds a bluesky_es.json payload, updates the metadata schema, and hooks the new file into the existing CI publishing script.
The Problem
Our weekly content workflow creates a folder tree like content/2026/08/13/VS/ that contains a Markdown file for each destination (Medium, Substack) and a metadata.json that tells the CI which platforms to push to. Until now the pipeline only handled Medium and Substack. When we tried to push the same Spanish article to Bluesky, the CI skipped it because:
ERROR: No Bluesky payload found for date 2026-08-13, language es
The root cause was two‑fold:
- Missing payload – there was no JSON file describing the Bluesky post content.
-
Metadata mismatch –
metadata.jsonstill had"bluesky_published": falseand an emptybluesky_urisobject, so the publishing script assumed nothing needed to be done.
We needed a reliable way to add Bluesky to the existing content‑automation flow without breaking the other platforms.
What I Tried First
My first attempt was a quick hack: I manually set "bluesky_published": true in content/2026/08/13/VS/metadata.json and added an empty bluesky_uris entry, hoping the CI would at least call the Bluesky API. The script, however, validates the existence of a payload file before making a request. Since no bluesky_*.json existed, the job failed with the error shown above and the CI step aborted, leaving the Medium and Substack posts untouched.
The second attempt was to copy the existing substack_en.json structure and rename it to bluesky_es.json, but the schema differed (Substack expects a title and body, while Bluesky expects a list of “cards” with type and text). The API rejected the payload with:
400 Bad Request: Invalid card type "avance"
Clearly we needed a proper payload format and a metadata update that the pipeline could understand.
The Implementation
1. Define a Bluesky payload schema
Bluesky expects an array of “cards”. For a simple text post we use the type: "text" card. I created a new JSON file under the same date folder:
// content/2026/08/13/VS/bluesky_es.json
[
{
"type": "text",
"text": "Implementé el endpoint en apps/api/src/whatsapp-ai/whatsapp-ai.controller.ts que expone el asesor virtual de WhatsApp. Conecté Groq en wha..."
}
]
Note: The file contains 17 lines (the diff shows the full payload). The type field matches Bluesky’s API spec, and the text field holds the Spanish article excerpt.
2. Extend the metadata schema
Both the project‑specific metadata (VS/metadata.json) and the global content-automation/metadata.json needed new fields so the CI can:
- Detect that Bluesky should be published.
- Store the resulting post URI for later reference.
I modified the files as follows:
--- a/content/2026/08/13/VS/metadata.json
+++ b/content/2026/08/13/VS/metadata.json
@@
"closed_issues": 0,
"medium_generated": false,
"substack_generated": false,
- "bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_published": true,
+ "bluesky_uris": {
+ "es": [],
+ "en": []
+ },
"craft_
--- a/content/2026/08/13/content-automation/metadata.json
+++ b/content/2026/08/13/content-automation/metadata.json
@@
"medium_generated": false,
"substack_generated": false,
- "bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_published": false,
+ "bluesky_uris": {
+ "es": [],
+ "en": []
+ },
"craft_
Why the two files?
VS/metadata.json drives the per‑project publishing, while content-automation/metadata.json is a higher‑level manifest used by the CI to orchestrate all folders for a given date.
3. Wire the new payload into the CI script
The publishing script (scripts/publish.ts) already loops over each platform:
for (const platform of ['medium', 'substack', 'bluesky']) {
if (metadata[`${platform}_published`]) {
const payload = loadPayload(date, platform, lang);
await publishToPlatform(platform, payload, metadata);
}
}
I added a small helper to read the Bluesky JSON:
function loadPayload(date: string, platform: string, lang: string): any {
const basePath = path.join('content', date, project);
if (platform === 'bluesky') {
const file = path.join(basePath, `bluesky_${lang}.json`);
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
// existing logic for Medium/Substack …
}
The publishToPlatform function already knows how to POST to Medium and Substack. I introduced a new branch:
async function publishToPlatform(platform: string, payload: any, meta: any) {
if (platform === 'bluesky') {
const response = await axios.post('https://api.bsky.app/v1/posts', payload, {
headers: { Authorization: `Bearer ${process.env.BLUESKY_TOKEN}` },
});
meta.bluesky_uris[lang].push(response.data.uri);
return;
}
// existing cases …
}
Now the CI can:
- Detect
bluesky_published: true. - Load
bluesky_es.json. - POST the card array.
- Store the returned URI back into
metadata.json.
4. Add changelog and markdown stubs
To keep the repo tidy and make future diffs easier, I added empty changelog and markdown files for the new platform:
content/2026/08/13/VS/changelog.md
content/2026/08/13/VS/medium_en.md
content/2026/08/13/VS/medium_es.md
content/2026/08/13/VS/substack_en.md
content/2026/08/13/VS/substack_es.md
content/2026/08/13/content-automation/changelog.md
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/content-automation` · 2026-08-14*
\#playadev #buildinpublic
Top comments (0)