Automating Daily Bluesky Posts with a JSON‑Driven Content Pipeline
TL;DR: I added a set of JSON files and a lightweight loader to the content‑automation repo so our CI can generate and publish daily Bluesky posts automatically. The change centralizes multilingual copy, makes the publishing script data‑driven, and removes the manual copy‑paste step that was breaking our release flow.
The Problem
Our weekly release process includes a short status update on Bluesky. The copy lives in a markdown file that we edit manually, then copy‑paste into the Bluesky CLI. Two issues kept surfacing:
-
Human error – a typo or missing line would cause the post to be rejected by the API (
Error: Invalid payload: missing "text"). - No versioning – we had no way to track which text was used for a given date, making it impossible to audit or rollback a post.
The symptom was a failed CI job that stopped the whole pipeline with the error above, and we were forced to roll back the entire release just to fix a missing word.
What I Tried First
My first attempt was to add a tiny shell script that reads a bluesky.md file and pipes it into the CLI:
cat content/2026/08/16/bluesky.md | npx bluesky-cli post
That worked locally, but the script crashed in CI because the file path was hard‑coded and the runner didn’t have the bluesky-cli binary installed. I also quickly realized that the same script would need to support English and Spanish versions, so the hard‑coded approach would explode as we added more languages.
The Implementation
1. Data‑driven content files
Instead of markdown, I switched to a JSON structure that can hold multiple languages and post types (progress, announcement, etc.). Each day gets its own folder under content/YYYY/MM/DD/VS/. For the 2026‑08‑16 release we added:
content/2026/08/16/VS/bluesky_en.json
content/2026/08/16/VS/bluesky_es.json
content/2026/08/16/VS/metadata.json
Example bluesky_en.json
[
{
"type": "progress",
"text": "Finally pushed a real change: coverage for the access‑control module jumped from 0 % to 49 tests (apps/api/src/__tests__/access-control."
}
]
Example bluesky_es.json
[
{
"type": "avance",
"text": "Finalmente resolví el bug que impedía validar tokens en apps/api/src/access-control/access-control.controller.ts. Además, añadí pruebas en"
}
]
The metadata.json file provides context for the automation script:
{
"repo": "VS",
"date": "2026-08-16",
"languages": ["es", "en"],
"topics": ["Productivity", "Docker", "Networking"],
"commits": 1
}
All files are added in a single commit (6319ff7f) with the [skip ci] flag because the content itself does not require a test run.
2. Loader module (src/contentLoader.ts)
I created a tiny Node module that walks the content tree, reads the JSON files, and returns a plain object ready for the publishing step.
// src/contentLoader.ts
import { promises as fs } from 'fs';
import path from 'path';
export interface Post {
type: string;
text: string;
}
export interface DayContent {
date: string;
repo: string;
posts: Record<string, Post[]>; // language => posts
}
export async function loadDayContent(dir: string): Promise<DayContent> {
const metaPath = path.join(dir, 'metadata.json');
const meta = JSON.parse(await fs.readFile(metaPath, 'utf‑8'));
const posts: Record<string, Post[]> = {};
for (const lang of meta.languages) {
const file = path.join(dir, `bluesky_${lang}.json`);
const raw = await fs.readFile(file, 'utf‑8');
posts[lang] = JSON.parse(raw);
}
return {
date: meta.date,
repo: meta.repo,
posts,
};
}
The loader is deliberately language‑agnostic; adding a new locale only requires a new JSON file and updating metadata.json.
3. Publishing script (scripts/publishBluesky.ts)
The CI now runs this script after the build step:
// scripts/publishBluesky.ts
import { loadDayContent } from '../src/contentLoader';
import { exec } from 'child_process';
import util from 'util';
const execAsync = util.promisify(exec);
async function main() {
const dayDir = process.env.CONTENT_DIR || 'content/2026/08/16/VS';
const content = await loadDayContent(dayDir);
for (const [lang, posts] of Object.entries(content.posts)) {
for (const post of posts) {
const cmd = `npx bluesky-cli post --text "${post.text}" --lang ${lang}`;
try {
const { stdout } = await execAsync(cmd);
console.log(`[${lang}] Posted: ${stdout.trim()}`);
} catch (err: any) {
console.error(`[${lang}] Failed: ${err.stderr}`);
process.exit(1);
}
}
}
}
main().catch(err => {
console.error('Unexpected error', err);
process.exit(1);
});
Key points:
-
Environment‑driven directory – CI can set
CONTENT_DIRto point to any date folder, making the script reusable for future releases. -
Error handling – we surface the exact CLI error (
stderr) so a missing field in the JSON immediately fails the job with a clear message. - Parallelism – For now we run posts sequentially to respect rate limits; scaling to parallel is a future improvement.
4. CI integration (.github/workflows/publish.yml)
name: Publish Bluesky
on:
push:
paths:
- 'content/**/bluesky_*.json'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install deps
run: npm ci
- name: Publish Bluesky posts
env:
CONTENT_DIR: ${{ github.workspace }}/content/${{ github.event.head_commit.timestamp | date('YYYY/MM/DD') }}/VS
run: npx ts-node scripts/publishBluesky.ts
The workflow triggers only when a bluesky_*.json file changes, keeping the CI lightweight.
Key Takeaway
Data‑driven content pipelines eliminate manual copy‑paste errors and give you versioned, language‑agnostic assets that CI can consume directly. By storing copy in JSON and loading it with a tiny Node module, we turned a fragile markdown‑to‑CLI step into a repeatable, testable process.
What's Next
-
Schema validation – Hook
ajvinto the loader to reject malformed JSON before the CLI runs. -
Parallel posting with back‑off – Use
p‑limitto post multiple languages concurrently while respecting Bluesky’s rate limits. - Automated diff reporting – Generate a PR comment that shows exactly which text changed between releases, giving stakeholders a clear audit trail.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #nodejs #typescript #json #automation #ci #bluesky
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-17
#playadev #buildinpublic
Top comments (0)