DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Daily Bluesky Posts with a JSON‑Driven Content Pipeline

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:

  1. Human error – a typo or missing line would cause the post to be rejected by the API (Error: Invalid payload: missing "text").
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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."
  }
]
Enter fullscreen mode Exit fullscreen mode

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"
  }
]
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Environment‑driven directory – CI can set CONTENT_DIR to 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
Enter fullscreen mode Exit fullscreen mode

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 ajv into the loader to reject malformed JSON before the CLI runs.
  • Parallel posting with back‑off – Use p‑limit to 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)