Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ on:
pull_request:
types: [closed]
branches: [main]
# Manual re-run for a specific draft (e.g. after a failed publish).
workflow_dispatch:
inputs:
draft_path:
description: "Draft to publish, e.g. content/drafts/2026-11-03-....md"
required: true

permissions:
contents: write
Expand All @@ -12,12 +18,15 @@ jobs:
publish:
# Only run if the PR was actually merged (not just closed) and it was a
# blog-draft PR opened by the generation workflow.
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'blog-draft')
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'blog-draft'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main
fetch-depth: 0

- uses: actions/setup-python@v5
with:
Expand All @@ -28,10 +37,31 @@ jobs:

- name: Find the merged draft file
id: find_draft
env:
INPUT_DRAFT: ${{ github.event.inputs.draft_path }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
run: |
FILE=$(ls -t content/drafts/*.md 2>/dev/null | head -n 1)
if [ -n "$INPUT_DRAFT" ]; then
FILE="$INPUT_DRAFT"
else
# Use the draft this PR actually added/changed. `ls -t` is not safe
# here: a fresh checkout gives every file the same mtime, so it
# picked an arbitrary draft.
FILES=$(git diff --name-only --diff-filter=AM "$BASE_SHA" "$MERGE_SHA" -- 'content/drafts/*.md')
COUNT=$(printf '%s\n' "$FILES" | grep -c . || true)
if [ "$COUNT" -gt 1 ]; then
echo "::error::PR touched $COUNT drafts; expected one:"
echo "$FILES"
exit 1
fi
FILE="$FILES"
fi
if [ -z "$FILE" ]; then
echo "No .md file found in content/drafts/ -- nothing to publish."
echo "No draft in this PR -- nothing to publish."
elif [ ! -f "$FILE" ]; then
echo "::error::$FILE not found in content/drafts/"
exit 1
else
echo "Found draft: $FILE"
fi
Expand All @@ -48,6 +78,7 @@ jobs:
SITE_BASE_URL: ${{ vars.SITE_BASE_URL }}

- name: Commit moved file (drafts -> published)
if: steps.find_draft.outputs.file != ''
run: |
git config user.name "blog-automation-bot"
git config user.email "actions@users.noreply.github.com"
Expand Down
9 changes: 5 additions & 4 deletions content/drafts/blogmind-medium-post.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
## Title: I Built an AI Agent That Writes and Publishes My Blog — Here's the Real Architecture (and Every Bug I Hit)

### Subtitle: Sourcing, drafting, critique, human review, and multi-platform publishing — fully automated, running on a 2-day cadence, with the production failures nobody's blog post about "AI content automation" ever shows you.

---
title: I Built an AI Agent That Writes and Publishes My Blog — Here's the Real Architecture (and Every Bug I Hit)
subtitle: Sourcing, drafting, critique, human review, and multi-platform publishing — fully automated, running on a 2-day cadence, with the production failures nobody's blog post about "AI content automation" ever shows you.
status: approved
---


Most posts about "AI content automation" show you a single prompt and a screenshot. That's not automation — that's a demo. Real automation means a pipeline that runs unattended for weeks, survives API changes, recovers from partial failures, and doesn't quietly publish garbage while you're not watching.

Expand Down
13 changes: 11 additions & 2 deletions src/publishing/devto_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,18 @@ def publish_to_devto(markdown_path: str, canonical_url: str | None = None) -> di
with open(markdown_path) as f:
post = frontmatter.load(f)

title = (post.get("title") or "").strip()
if not title:
raise ValueError(
f"{markdown_path} has no 'title' in its YAML frontmatter — dev.to "
"rejects untitled articles with a 422."
)

settings = get_settings()["publishing"]["devto"]

payload = {
"article": {
"title": post.get("title"),
"title": title,
"published": settings["publish_status"] == "public",
"body_markdown": post.content,
"tags": settings["tags"],
Expand All @@ -51,7 +58,9 @@ def publish_to_devto(markdown_path: str, canonical_url: str | None = None) -> di
json=payload,
timeout=30,
)
resp.raise_for_status()
if not resp.ok:
# dev.to explains 4xx errors in the body — surface it instead of a bare status.
raise RuntimeError(f"[devto] {resp.status_code} from dev.to: {resp.text}")
data = resp.json()
print(f"[devto] Published: {data.get('url')}")
return data
Expand Down