Automating Weekly Multi‑Platform Posts with a Python Generator and Docker

TL;DR: I built a tiny Python CLI that pulls data from metadata.json, renders Jinja2 templates, and spits out ready‑to‑publish Markdown and JSON files for Medium, Substack, and Bluesky. Packaging it in a Docker image lets the CI pipeline run the generator on every sprint without manual copy‑pasting.

The Problem

Every sprint I had to take the same weekly summary (changelog, release notes, bug fixes) and publish it on three different platforms:

  • Medium – requires a full‑length Markdown file with front‑matter.
  • Substack – a shorter Markdown version, also with front‑matter.
  • Bluesky – a JSON payload limited to 300 characters per post.

Doing this by hand meant:

  • Inconsistent wording – copy‑paste errors showed up in production.
  • Layout regressions – the mobile overflow‑x bug in tables.css was fixed in the codebase, but the documentation never mentioned it because I missed the step.
  • Wasted time – ~30 minutes per sprint just to re‑format the same content three times.

The CI pipeline already built Docker images for the app, but there was no automated step to generate the marketing copy.

What I Tried First

I started with a bash script that concatenated static snippets:

#!/usr/bin/env bash cat header.md content/2026/10/19/VS/changelog.md > out/medium.md 

It broke immediately:

./gen.sh: line 3: content/2026/10/19/VS/changelog.md: No such file or directory 

The script assumed a fixed folder layout and failed when a new week was added (2026/10/20). I also tried a Node.js script with fs.promises.readFile, but the JSON payload for Bluesky kept getting malformed (missing commas) and I couldn’t easily reuse the same data for the Markdown files.

Both approaches suffered from hard‑coded paths and no templating – any change required editing the script itself.

The Implementation

1. Directory layout

content-automation/ ├─ templates/ │ ├─ medium.md.j2 │ ├─ substack.md.j2 │ └─ bluesky.json.j2 ├─ src/ │ └─ generate_content.py ├─ Dockerfile.prod └─ content/ └─ 2026/10/19/VS/ ├─ changelog.md ├─ metadata.json ├─ medium_en.md (generated) ├─ substack_en.md (generated) └─ bluesky_en.json (generated) 

2. metadata.json – source of truth

{ "project": "VS", "date": "2026-10-19", "pull_requests": 6, "releases": 0, "closed_issues": 0, "medium_generated": true, "substack_generated": true, "bluesky_generated": false } 

The generator updates the boolean flags after each successful render (see src/generate_content.py).

3. Jinja2 templates

templates/medium.md.j2

--- title: "{{ project }} – Weekly Update ({{ date }})" tags: ["{{ project }}", "productivity"] published: true --- {% include "changelog.md" %} ## Highlights - Fixed the overflow‑x bug in `tables.css` that broke mobile layout. - Added `src/ai/inference_engine.py` (ONNX inference REST API). - Updated Dockerfile for production builds. 

templates/bluesky.json.j2

[ { "type": "progress", "text": "Finally fixed the overflow‑x bug in tables.css that was pushing the page sideways on mobile. The diff shows the new `max-width: 100%` rule." }, { "type": "feature", "text": "Deployed `src/ai/inference_engine.py` – a lightweight FastAPI service for ONNX model inference." } ] 

4. Core generator (src/generate_content.py)

 python #!/usr/bin/env python3 import json import pathlib import sys from jinja2 import Environment, FileSystemLoader, select_autoescape BASE = pathlib.Path(__file__).resolve().parents[1] CONTENT_ROOT = BASE / "content" / "2026" / "10" / "19" / "VS" def load_metadata() -> dict: meta_path = CONTENT_ROOT / "metadata.json" with meta_path.open() as f: return json.load(f) def render_template(tpl_name: str, dest: pathlib.Path, ctx: dict): env = Environment( loader=FileSystemLoader(str(BASE / "templates")), autoescape=select_autoescape(['html', 'xml']), trim_blocks=True, lstrip_blocks=True, ) template = env.get_template(tpl_name) rendered = template.render(**ctx) dest.write_text(rendered) print(f"✔️ Generated {dest.relative_to(BASE)}") def main(): meta = load_metadata() ctx = { "project": meta["project"], "date": meta["date"], "changelog_path": str(CONTENT_ROOT / "changelog.md"), } # Render Medium render_template( "medium.md.j2", CONTENT_ROOT / "medium_en.md", ctx, ) # Render Substack (reuse same template, just shorter) render_template( "substack.md.j2", CONTENT_ROOT / "substack_en.md", ctx, ) # Render Bluesky JSON render_template( "bluesky.json.j2", CONTENT_ROOT / "bluesky_en.json", ctx, ) # Update flags meta.update({ "medium_generated": True, "substack_generated": True, "bluesky_generated": True, }) with (CONTENT_ROOT / "metadata.json").open("w") as f: json.dump(meta, f, indent=2) print("✅ metadata.json flags updated") if __name__ == "__main__": try: main() except Exception as exc: --- *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-10-20* \#playadev #buildinpublic