From 724c2e62a0c95e1bd4f02cf94648ba735012d01e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 12:09:17 +0000 Subject: [PATCH] Fix dev.to 422 and publish workflow picking the wrong draft - publish.yml: pick the draft the merged PR actually added/changed instead of `ls -t` (all files share one mtime on a fresh checkout, so an arbitrary draft was published). Add workflow_dispatch with a draft_path input for manual re-runs. Skip the commit step when there is nothing to publish. - devto_publish.py: fail early with a clear message when a draft has no frontmatter title; surface dev.to's error body on 4xx. - blogmind-medium-post.md: move title/subtitle into YAML frontmatter. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wd36GnTiTuBTpbteZ4P5QC --- .github/workflows/publish.yml | 37 +++++++++++++++++++++++--- content/drafts/blogmind-medium-post.md | 9 ++++--- src/publishing/devto_publish.py | 13 +++++++-- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 95e38cc..0fd0391 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 @@ -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: @@ -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 @@ -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" diff --git a/content/drafts/blogmind-medium-post.md b/content/drafts/blogmind-medium-post.md index a53cc9c..32f240c 100644 --- a/content/drafts/blogmind-medium-post.md +++ b/content/drafts/blogmind-medium-post.md @@ -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. diff --git a/src/publishing/devto_publish.py b/src/publishing/devto_publish.py index ec3dd6d..89a0670 100644 --- a/src/publishing/devto_publish.py +++ b/src/publishing/devto_publish.py @@ -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"], @@ -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