DEV Community

Roberto Luna
Roberto Luna

Posted on

Automating Multi‑Platform Content Publishing – Adding VS Posts & Bluesky Support

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

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

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

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

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

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

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

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 .md for Medium/Substack and .json for 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 {} \;
Enter fullscreen mode Exit fullscreen mode

If the JSON is malformed, the job fails early, preventing a half‑posted state.

5. End‑to‑End Flow

  1. Content creation – Writers add markdown/JSON files under the date folder.
  2. Metadata bump – Flags are toggled to true via a PR (or automatically by a script that runs after content review).
  3. CI triggers – The publish.yml workflow runs, validates JSON, and calls src/publish.py.
  4. 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)