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.csswas 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
Top comments (0)