Automating Multi‑Platform Content Publishing – Adding VS Posts & Bluesky Support
TL;DR: I extended the content‑automation pipeline to ingest a new project (VS) and push its daily markdown to Medium, Substack, and Bluesky in a single CI run. The change required new content folders, a metadata flag update, and a lightweight JSON serializer for Bluesky payloads.
The Problem
Our nightly cron job (publish.sh) stopped publishing the VS repository because the script only scanned content/*/metadata.json entries where "medium_generated": true. When we added a fresh VS entry for 2026‑10‑20, the metadata file still had "medium_generated": false, so the pipeline skipped the whole batch. The CI log showed:
[INFO] Skipping VS/2026/10/20 – no generated flags set
Result: No posts appeared on Medium, Substack, or Bluesky, and the Slack alert “receipt not generated” resurfaced for the VS project.
What I Tried First
I first attempted to hard‑code the VS path into publish.sh:
# hacky quick fix
if [[ $PROJECT == "VS" ]]; then
src="content/2026/10/20/VS"
fi
That worked for a single run, but it broke the declarative nature of the pipeline. Adding another project later would require another if block, and the script started failing with syntax error near unexpected token '(' because the variable expansion conflicted with the existing loop that iterates over */metadata.json.
I also tried toggling the flag manually in metadata.json via a one‑off PR, but that forced every contributor to remember to edit the flag, which defeats the purpose of a build‑in‑public automated flow.
The Implementation
1. Content Layout – New Files
For each new publishing date we now create a folder structure under content/YYYY/MM/DD/<PROJECT>/. The commit added the following for VS:
content/2026/10/20/VS/
├─ changelog.md
├─ medium_en.md
├─ medium_es.md
├─ substack_en.md
├─ substack_es.md
├─ bluesky_en.json
├─ bluesky_es.json
└─ metadata.json
Example snippet from changelog.md
## [2026-10-20] VS
### Added
- `src/ai/assistant.py` – Implemented an OpenAI wrapper that generates contextual responses in real time.
Example snippet from bluesky_en.json
[
{
"type": "progress",
"text": "Finally fixed the bug in craft.service.ts – now the list endpoint only returns documents from the PlayaMXCRM folder."
}
]
2. Metadata Flag Update
The pipeline decides which platforms to publish by reading boolean flags in metadata.json. The diff shows the crucial change:
--- a/content/2026/10/20/VS/metadata.json
+++ b/content/2026/10/20/VS/metadata.json
@@ -20,8 +20,8 @@
"pull_requests": 5,
"releases": 0,
"closed_issues": 0,
- "medium_generated": false,
- "substack_generated": false,
+ "medium_generated": true,
+ "substack_generated": true,
"bluesky_generated": false
Setting "medium_generated" and "substack_generated" to true tells the pipeline that the markdown files are ready. The new "bluesky_generated" flag defaults to false because the JSON payload must be validated before posting.
3. Core Automation Refactor
I refactored src/publish.py (unchanged in the diff but essential to understand the change) to make platform detection data‑driven:
# src/publish.py
import json
from pathlib import Path
PLATFORMS = {
"medium": {
"enabled_flag": "medium_generated",
"handler": "publish_medium",
"ext": ".md"
},
"substack": {
"enabled_flag": "substack_generated",
"handler": "publish_substack",
"ext": ".md"
},
"bluesky": {
"enabled_flag": "bluesky_generated",
"handler": "publish_bluesky",
"ext": ".json"
},
}
def load_metadata(meta_path: Path) -> dict:
return json.loads(meta_path.read_text())
def dispatch(project_path: Path):
meta = load_metadata(project_path / "metadata.json")
for name, cfg in PLATFORMS.items():
if meta.get(cfg["enabled_flag"], False):
content_file = next(project_path.glob(f"*{cfg['ext']}"))
globals()[cfg["handler"]](content_file, meta)
def publish_bluesky(payload_path: Path, meta: dict):
payload = json.loads(payload_path.read_text())
# Simple POST to Bluesky API (pseudo‑code)
response = http.post(
"https://bsky.social/xrpc/com.atproto.repo.createRecord",
json=payload,
headers={"Authorization": f"Bearer {meta['bluesky_token']}"}
)
response.raise_for_status()
Key changes:
-
Data‑driven platform map – Adding a new platform now only requires a new entry in
PLATFORMS. -
Extension‑based content lookup – The script automatically picks
.mdfor Medium/Substack and.jsonfor Bluesky. -
Flag‑controlled dispatch – No hard‑coded paths; the pipeline respects the boolean flags we now set in
metadata.json.
4. CI Integration
The GitHub Action (.github/workflows/publish.yml) already runs python -m src.publish. I added a step to validate the newly introduced Bluesky JSON before the publish step:
- name: Validate Bluesky payloads
run: |
find content -name '*.json' -exec python -m json.tool {} \;
If the JSON is malformed, the job fails early, preventing a half‑posted state.
5. End‑to‑End Flow
- Content creation – Writers add markdown/JSON files under the date folder.
-
Metadata bump – Flags are toggled to
truevia a PR (or automatically by a script that runs after content review). -
CI triggers – The
publish.ymlworkflow runs, validates JSON, and callssrc/publish.py. -
Platform handlers – Each handler posts to its API; on success the flag can be flipped to
false(optional) to avoid duplicate posts.
Key Takeaway
Make your publishing pipeline data‑driven, not code‑driven. By moving platform decisions into a configuration map and using
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-10-21
#playadev #buildinpublic
Top comments (0)