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
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)
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
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);
}
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);
}
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);
}
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 ..."
}
]
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
Top comments (0)