Automating Multi‑Platform Content Publishing with JSON‑Driven Metadata (Bluesky Integration)
TL;DR: I added a JSON‑based publishing flag and URI tracking for Bluesky to the content‑automation repo, enabling the CI pipeline to publish articles to Bluesky without manual steps. This change required extending the metadata schema, generating language‑specific payload files, and updating the publishing script to read the new fields.
The Problem
Our content‑automation pipeline was able to generate Markdown for Medium and Substack, but it never pushed the same articles to Bluesky. The CI job would silently skip Bluesky because the metadata.json files lacked any indication that a post had been published, and there was no place to store the resulting Bluesky URIs. The symptom was a build log entry:
[info] Bluesky publishing skipped – metadata.bluesky_published is false
Without this data, we couldn’t verify which language version (EN/ES) was live, nor could we reference the post in future automations (e.g., cross‑posting or analytics).
What I Tried First
My first attempt was to add a simple boolean flag bluesky_published to the top‑level metadata.json and manually edit it after each successful post. I also tried to store the URIs in a flat object:
{
"bluesky_published": true,
"bluesky_uri": "at://did:plc:..."
}
This approach failed for two reasons:
-
Language granularity: Our repo publishes both Spanish and English versions of each article, but the flat
bluesky_uricould only hold a single value. -
Automation breakage: The CI script expected
bluesky_uristo be an object, so the type mismatch caused a runtime error when it attempted toObject.keys(metadata.bluesky_uris).
The job aborted with:
TypeError: Cannot read property 'es' of undefined
The Implementation
1. Extend the metadata schema
I restructured metadata.json to include a language‑keyed bluesky_uris object and initialized it as empty arrays for each language. The diff (commit fe00c2dc) looks like this:
@@ -16,6 +16,9 @@
"medium_generated": false,
"substack_generated": false,
"bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_uris": {
+ "es": [],
+ "en": []
+ },
"craft_
Now the CI can safely iterate over metadata.bluesky_uris[lang] regardless of whether any URIs have been added yet.
2. Add language‑specific Bluesky payloads
Each article needs a JSON payload that conforms to Bluesky’s app.bsky.feed.post schema. I created a new file under the craveview folder for the English version:
content/2026/08/18/craveview/bluesky_en.json
[
{
"type": "progress",
"text": "Finally added the duplicate‑tablet flag to the Dashboard. Updated src/app/(dashboard)/page.tsx, src/components/dashboard/duplicate-rooms"
}
]
The Spanish counterpart (bluesky_es.json) will follow the same structure. The array format allows us to batch multiple post fragments if needed.
3. Update the publishing script
The script scripts/publish_bluesky.js (new file) now reads the metadata, loops through each language, and posts the JSON payload:
const fs = require('fs');
const path = require('path');
const { BskyAgent } = require('@atproto/api');
async function publishArticle(articleDir) {
const metaPath = path.join(articleDir, 'metadata.json');
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
if (!meta.bluesky_published) {
console.log('Skipping Bluesky – not marked as published');
return;
}
const agent = new BskyAgent({ service: 'https://bsky.social' });
await agent.login({ identifier: process.env.BLUESKY_USER, password: process.env.BLUESKY_PASS });
for (const lang of meta.languages) {
const payloadPath = path.join(articleDir, `bluesky_${lang}.json`);
if (!fs.existsSync(payloadPath)) continue;
const posts = JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
for (const post of posts) {
const response = await agent.post({
text: post.text,
createdAt: new Date().toISOString(),
// additional fields like facets can be added here
});
meta.bluesky_uris[lang].push(response.uri);
console.log(`Published ${lang} post: ${response.uri}`);
}
}
// Write back the updated URIs
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
}
publishArticle(process.argv[2]).catch(console.error);
Key points:
- Language loop: Guarantees each locale is handled independently.
-
URI persistence: After each successful post, we push the returned URI into
metadata.bluesky_uris[lang]and rewrite the file. This makes the data source of truth for downstream steps (e.g., analytics dashboards). -
Idempotency: If
bluesky_uris[lang]already contains entries, the script will still attempt to post unless we add a guard. For now, we rely on the CI to run only once per commit.
4. Adjust CI workflow
In .github/workflows/content.yml I added a step after Medium/Substack generation:
- name: Publish to Bluesky
if: steps.metadata.outputs.bluesky_published == 'true'
run: node scripts/publish_bluesky.js ./content/${{ env.DATE }}/content-automation
env:
BLUESKY_USER: ${{ secrets.BLUESKY_USER }}
BLUESKY_PASS: ${{ secrets.BLUESKY_PASS }}
The metadata output is derived from a small action that reads metadata.json and exposes the bluesky_published flag.
5. Update the metadata for the current article
The second commit (a106942e) added a fresh metadata.json for the 2026‑08‑18 content‑automation article, with the new fields pre‑populated:
{
"repo": "content-automation",
"date": "2026-08-18",
"languages": ["es", "en"],
"topics": ["AI", "Productivity"],
"commits": 3,
"pull": "...",
"medium_generated": false,
"substack_generated": false,
"bluesky_published": true,
"bluesky_uris": {
"es": [],
"en": []
}
}
Setting bluesky_published to true triggers the new CI step, and the empty arrays are ready to receive URIs.
Key Takeaway
When adding a new publishing target to an automated content pipeline, always model the metadata to reflect language granularity and future extensibility. A nested object (bluesky_uris[lang]) avoids type mismatches and lets you store multiple URIs per locale, which is essential for multi‑language projects.
What's Next
- Idempotent publishing guard: Detect existing URIs and skip re‑posting to avoid duplicate content on Bluesky.
- Analytics hook: Push the stored URIs to a Grafana dashboard for real‑time engagement tracking.
- Error handling: Capture and log API failures (rate limits, auth errors) and retry with exponential backoff.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #json #automation #bluesky #nodejs #ci #content-publishing
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-08-19
#playadev #buildinpublic
Top comments (0)