DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Bluesky & Dev.to Publishing with Atomic JSON Metadata Updates

Automating Bluesky & Dev.to Publishing with Atomic JSON Metadata Updates

TL;DR: I rewrote the content‑automation pipeline so that publishing to Bluesky and Dev.to updates the metadata.json files atomically, preventing JSON corruption and race conditions. The change adds proper adapters, a safe write helper, and new JSON payloads for the 2026‑08‑11 post.


The Problem

Our weekly content‑automation script reads a metadata.json file to decide which platforms still need publishing. The file lives next to the content payloads, e.g.:

content/2026/08/10/VS/metadata.json
content/2026/08/10/content-automation/metadata.json
Enter fullscreen mode Exit fullscreen mode

During the 2026‑08‑11 run the script crashed with:

SyntaxError: Unexpected token } in JSON at position 213
    at JSON.parse (<anonymous>)
    at Object.readMetadata (/usr/src/app/utils/metadata.ts:12:15)
Enter fullscreen mode Exit fullscreen mode

The error happened only when two platform adapters (Bluesky and Dev.to) tried to write back the bluesky_published flag at the same time. The original implementation used sed‑style string replacement on the file, which broke the JSON structure when the two processes interleaved.

What I Tried First

My first fix was a quick Bash one‑liner inside publish.sh:

sed -i "s/\"bluesky_published\": false/\"bluesky_published\": true/" $METADATA_PATH
Enter fullscreen mode Exit fullscreen mode

It worked when I ran the script manually, but in CI the pipeline still threw the same SyntaxError. The root cause was that sed rewrote the file in‑place, so while the Bluesky adapter was still reading the file the Dev.to adapter started its own sed operation, corrupting the JSON (missing commas, duplicated braces). I also tried using jq to rewrite the file, but the same race condition persisted because both adapters invoked the command concurrently.

The Implementation

1. Introduce a Safe JSON Writer

Created utils/atomicJson.ts:

// utils/atomicJson.ts
import { promises as fs } from 'fs';
import * as path from 'path';
import * as os from 'os';

export async function writeJsonAtomic<T>(filePath: string, data: T): Promise<void> {
  const dir = path.dirname(filePath);
  const tmpPath = path.join(dir, `.tmp-${path.basename(filePath)}-${Date.now()}`);

  await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
  // Rename is atomic on POSIX filesystems
  await fs.rename(tmpPath, filePath);
}
Enter fullscreen mode Exit fullscreen mode

All adapters now import writeJsonAtomic instead of mutating the file directly.

2. Refactor Platform Adapters

Bluesky Adapter (adapters/blueskyAdapter.ts)

// adapters/blueskyAdapter.ts
import fetch from 'node-fetch';
import { writeJsonAtomic } from '../utils/atomicJson';
import type { Metadata } from '../types';

export async function publishToBluesky(
  contentPath: string,
  metaPath: string
): Promise<void> {
  const post = JSON.parse(await fs.readFile(contentPath, 'utf8'));

  const resp = await fetch('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.BLUESKY_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      repo: process.env.BLUESKY_HANDLE,
      collection: 'app.bsky.feed.post',
      record: {
        text: post[0].text,
        createdAt: new Date().toISOString(),
      },
    }),
  });

  if (!resp.ok) {
    const err = await resp.text();
    throw new Error(`Bluesky publish failed: ${resp.status} ${err}`);
  }

  const { uri, cid } = await resp.json();

  // Update metadata atomically
  const meta: Metadata = JSON.parse(await fs.readFile(metaPath, 'utf8'));
  meta.bluesky_published = true;
  meta.bluesky_uris = { uri, cid };
  await writeJsonAtomic(metaPath, meta);
}
Enter fullscreen mode Exit fullscreen mode

Dev.to Adapter (adapters/devtoAdapter.ts)

// adapters/devtoAdapter.ts
import fetch from 'node-fetch';
import { writeJsonAtomic } from '../utils/atomicJson';
import type { Metadata } from '../types';

export async function publishToDevTo(
  markdownPath: string,
  metaPath: string
): Promise<void> {
  const body = await fs.readFile(markdownPath, 'utf8');

  const resp = await fetch('https://dev.to/api/articles', {
    method: 'POST',
    headers: {
      'api-key': process.env.DEVTO_API_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ article: { body_markdown: body, published: true } }),
  });

  if (!resp.ok) {
    const err = await resp.text();
    throw new Error(`Dev.to publish failed: ${resp.status} ${err}`);
  }

  const { id } = await resp.json();

  // Update metadata atomically
  const meta: Metadata = JSON.parse(await fs.readFile(metaPath, 'utf8'));
  meta.devto_published = true;
  meta.devto_id = id;
  await writeJsonAtomic(metaPath, meta);
}
Enter fullscreen mode Exit fullscreen mode

Both adapters now read‑modify‑write the same metadata.json using the atomic helper, eliminating the race condition.

3. Add New Content Payloads

The commit added two JSON payload files that the Bluesky adapter consumes:

// content/2026/08/10/VS/bluesky_en.json
[
  {
    "type": "progress",
    "text": "Today I finally closed the six legal/technical gaps in Control de Obra. Updated apps/api/src/construction/construction.controller.ts and ..."
  }
]
Enter fullscreen mode Exit fullscreen mode

and the duplicate under content-automation (mirrored for the automation repo). No code changes were required; the adapters simply read the first element of the array.

4. Update Metadata Flags

Both content/2026/08/10/VS/metadata.json and content/2026/08/10/content-automation/metadata.json now contain:


json
{

---

*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-11*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)