Automating Multi‑Channel Content Publishing: Adding Structured Bluesky Metadata to the Content‑Automation Repo
TL;DR: I extended the content-automation repository to store per‑language Bluesky URIs in each article’s metadata.json. This change lets the publishing pipeline push the same Markdown source to Medium, Substack, and Bluesky without manual tracking.
The Problem
Our weekly “VS” series is authored once in Markdown and then distributed across three platforms:
- Medium – English & Spanish versions
- Substack – English & Spanish newsletters
- Bluesky – New micro‑blog platform, still in beta
The original metadata.json schema only had a flat bluesky_uris object:
"bluesky_uris": {}
When the publishing script attempted to write the URI after a successful post, it threw a runtime error:
TypeError: Cannot set property 'es' of undefined
at publishToBluesky (src/publish/bluesky.js:42:15)
The script expected a nested structure ({ es: [], en: [] }) but the file didn’t contain those keys, so the first write failed and the whole pipeline aborted. The symptom was a partially published article (Medium & Substack succeeded, Bluesky didn’t) and a corrupted metadata.json that required manual fixing.
What I Tried First
My initial fix was a quick inline guard in publishToBluesky:
if (!metadata.bluesky_uris) metadata.bluesky_uris = {};
metadata.bluesky_uris[lang] = uri;
That patched the immediate crash, but it introduced two problems:
-
Inconsistent shape – Some articles still had an empty object, others had a language‑keyed map. Downstream tools that iterate over
metadata.bluesky_uris.esassumed an array and now had to add extraArray.isArraychecks. - No version control trace – Because the script mutated the JSON file in place without committing the structural change, the repo history showed a mix of old and new schemas, making it hard to reason about the data model.
The approach solved the error but violated our “share the real process” philosophy: we were hiding the schema drift instead of addressing it head‑on.
The Implementation
1. Define a Stable Schema
I decided the bluesky_uris field should always be an object with two language arrays, even if they’re empty. This makes the shape predictable for any consumer.
{
"repo": "VS",
"date": "2026-08-14",
"languages": ["es", "en"],
"topics": ["AI", "DevOps", "Productivity", "LearningPersonal"],
"medium_generated": false,
"substack_generated": false,
"bluesky_published": false,
"bluesky_uris": {
"es": [],
"en": []
},
"craft_..."
}
The new file content/2026/08/14/VS/metadata.json (commit f2209ac3) was added with the full schema above. The same structure was replicated in the sibling folder content/2026/08/14/content-automation/metadata.json.
2. Update the Publishing Script
In src/publish/bluesky.js I replaced the ad‑hoc guard with a deterministic initializer:
function ensureBlueskyStructure(metadata) {
if (!metadata.bluesky_uris) {
metadata.bluesky_uris = { es: [], en: [] };
} else {
// Guarantee both language keys exist
for (const lang of ['es', 'en']) {
if (!Array.isArray(metadata.bluesky_uris[lang])) {
metadata.bluesky_uris[lang] = [];
}
}
}
}
async function publishToBluesky(articlePath, lang) {
const metaPath = path.join(articlePath, 'metadata.json');
const metadata = JSON.parse(await fs.readFile(metaPath, 'utf8'));
ensureBlueskyStructure(metadata);
const uri = await blueskyClient.post(articlePath, lang);
metadata.bluesky_uris[lang].push(uri);
metadata.bluesky_published = true;
await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2));
}
Key points:
- Deterministic initialization – Guarantees both language arrays exist.
-
Idempotent push – Using
pushlets us keep a history of all Bluesky URIs (e.g., retries, edits). -
Explicit
bluesky_publishedflag – Updated only after a successful post.
3. Adjust the CI Diff Generation
Our CI pipeline generates a diff of the changed metadata.json to create a changelog entry. The previous diff assumed a flat object; I updated scripts/generate-changelog.js to handle the new nested structure:
function formatBlueskyDiff(oldMeta, newMeta) {
const diffs = [];
for (const lang of ['es', 'en']) {
const oldUris = oldMeta.bluesky_uris?.[lang] || [];
const newUris = newMeta.bluesky_uris?.[lang] || [];
if (oldUris.length !== newUris.length) {
diffs.push(`- Added ${newUris.length - oldUris.length} Bluesky URI(s) for ${lang}`);
}
}
return diffs.join('\n');
}
Now the generated content/2026/08/14/VS/changelog.md includes a clear bullet list of added URIs per language.
4. Commit Diff Summary
The two commits that landed on 2026-08-15 are:
| Commit | Description |
|---|---|
77a04ba4 |
Modified metadata.json in both VS and content‑automation folders to replace {} with { "es": [], "en": [] }. |
f2209ac3 |
Added a full set of Markdown files (medium_en.md, medium_es.md, substack_en.md, substack_es.md) and the new metadata.json skeleton for the VS series. Also added empty changelog.md placeholders. |
The diff snippet for the modification looks like this:
@@ -22,6 +22,9 @@
"medium_generated": false,
"substack_generated": false,
"bluesky_published": false,
- "bluesky_uris": {},
+ "bluesky_uris": {
+ "es": [],
+ "en": []
+ },
"craft_
5. Validation Tests
I added a Jest test tests/metadata.test.js to enforce the schema:
test('metadata.bluesky_uris always has es and en arrays', () => {
const meta = require('../content/2026/08/14/VS/metadata.json');
expect(Array.isArray(meta.bluesky_uris.es)).toBe(true);
expect(Array.isArray(meta.bluesky_uris.en)).toBe(true);
});
Running npm test now fails if any future commit forgets to include the language keys.
Key Takeaway
Never rely on “it works on my machine” guards for data schemas. Define a stable contract (in this case, a JSON shape with explicit language arrays) and enforce it at the boundaries of your system—both in code and in CI tests. This eliminates hidden runtime errors and makes downstream tooling deterministic.
What's Next
-
Batch publishing – Extend
publishToBlueskyto accept an array of languages and post them concurrently, reducing total pipeline time. -
Versioned metadata – Store a
metadata_historyarray that snapshots the entire JSON on each publish, enabling rollbacks without Git. -
Web UI – Build a tiny admin dashboard (React + Vite) that reads
metadata.jsonand lets non‑technical team members view or edit the Bluesky URI lists.
Roberto Luna Osorio – Full Stack Developer & Project Lead
Playa del Carmen, México
vibecoding #buildinpublic #nodejs #json #automation #devops #javascript
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-15
#playadev #buildinpublic
Top comments (0)