Publishing blog posts from my phone with Claude and GitHub Actions



I got tired of the friction between having a post idea and actually publishing it. On my phone especially — I'd have a thought, open my laptop, forget half of it, and give up. So I built a pipeline that goes from idea to open PR without touching a code editor.
The whole thing is three files and a GitHub label.
How it works
- Open the GitHub mobile app → Issues → New Issue → pick the "New Blog Post" template
- Fill in the form (or paste output from Claude)
- Submit — a GitHub Action fires, writes the post file with correct frontmatter, creates a branch, and opens a PR
- Vercel auto-deploys a preview from the PR branch
- Review it, merge it, done
Folder structure
.github/
├── ISSUE_TEMPLATE/
│ └── new-post.yml # The structured issue form
├── PULL_REQUEST_TEMPLATE.md # PR checklist
└── workflows/
└── new-post.yml # The action that does the work
_posts/
└── your-post-slug.md # Where posts live
The issue form
This is what surfaces as a structured form in the GitHub app. The labels field is the key — it auto-applies new-post on submit, which is what triggers the action.
.github/ISSUE_TEMPLATE/new-post.yml:
name: New Blog Post
description: Draft a new blog post — a PR will be opened automatically.
labels: ["new-post"]
body:
- type: input
id: title
attributes:
label: Title
placeholder: "My Awesome Post"
validations:
required: true
- type: input
id: slug
attributes:
label: Slug
description: URL slug and filename — kebab-case, no spaces (e.g. my-awesome-post)
placeholder: "my-awesome-post"
validations:
required: true
- type: textarea
id: excerpt
attributes:
label: Excerpt
description: 1–2 sentence summary shown on the homepage.
placeholder: "A brief summary of what this post is about."
validations:
required: true
- type: input
id: date
attributes:
label: Date
description: Publication date in YYYY-MM-DD format. Leave blank to use today's date.
placeholder: "2026-02-28"
validations:
required: false
- type: input
id: cover_image
attributes:
label: Cover Image URL
description: URL for the cover and OG image. Leave blank to use the default site image.
placeholder: "https://example.com/image.jpg"
validations:
required: false
- type: textarea
id: content
attributes:
label: Content
description: Full post body in Markdown.
placeholder: |
## Introduction
Your post content here...
validations:
required: true
One thing worth noting: the new-post label has to exist in the repo before this works. GitHub silently ignores labels in issue templates if they haven't been created yet. Create it once via the Labels page or with the gh CLI:
gh label create "new-post" --color "0075ca" --description "New blog post draft"
The action
This is where all the work happens. When the new-post label fires, it parses the structured issue body, validates the fields, writes the markdown file with correct frontmatter, creates a branch, and opens a PR. If anything goes wrong it comments on the issue with a human-readable error instead of silently dying.
.github/workflows/new-post.yml:
name: New Blog Post
on:
issues:
types: [labeled]
jobs:
create-post:
if: github.event.label.name == 'new-post'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
steps:
- uses: actions/checkout@v4
- name: Parse and validate issue fields
env:
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 << 'PYEOF'
import re, os, sys, uuid
body = os.environ['ISSUE_BODY']
issue_number = os.environ['ISSUE_NUMBER']
def parse_section(body, header):
pattern = rf'### {re.escape(header)}\s*\n\n(.*?)(?=\n### |\Z)'
match = re.search(pattern, body, re.DOTALL)
return match.group(1).strip() if match else ''
def fail(message):
import subprocess
subprocess.run([
'gh', 'issue', 'comment', issue_number,
'--body', f'**New Post action failed:** {message}\n\nMake sure this issue was created using the New Blog Post template.'
])
print(f'ERROR: {message}', file=sys.stderr)
sys.exit(1)
title = parse_section(body, 'Title')
slug = parse_section(body, 'Slug')
excerpt = parse_section(body, 'Excerpt')
date = parse_section(body, 'Date')
cover_image = parse_section(body, 'Cover Image URL')
content = parse_section(body, 'Content')
missing = [name for name, val in [('Title', title), ('Slug', slug), ('Excerpt', excerpt), ('Content', content)] if not val]
if missing:
fail(f'Missing required fields: {", ".join(missing)}. This issue does not appear to use the New Blog Post template.')
from datetime import date as dt
if not date or date == '_No response_':
date = dt.today().strftime('%Y-%m-%d')
else:
m = re.match(r'^(\d{4})-(\d{2})-(\d{2})$', date)
if not m or not (1 <= int(m.group(2)) <= 12) or not (1 <= int(m.group(3)) <= 31):
fail(f'Date `{date}` is not a valid YYYY-MM-DD date.')
slug = re.sub(r'[^a-z0-9-]', '-', slug.lower()).strip('-')
if not slug:
fail('Slug is empty after sanitization. Use kebab-case (e.g. my-post-title).')
if not cover_image or cover_image == '_No response_':
cover_image = '/assets/images/og-josh-miami.jpg'
if content == '_No response_':
fail('Content field is empty. Please provide post content.')
excerpt_delim = f'EXCERPT_{uuid.uuid4().hex}'
content_delim = f'CONTENT_{uuid.uuid4().hex}'
with open(os.environ['GITHUB_ENV'], 'a') as f:
f.write(f'POST_SLUG={slug}\n')
f.write(f'POST_TITLE={title}\n')
f.write(f'POST_DATE={date}\n')
f.write(f'POST_COVER={cover_image}\n')
f.write(f'POST_EXCERPT<<{excerpt_delim}\n{excerpt}\n{excerpt_delim}\n')
f.write(f'POST_CONTENT<<{content_delim}\n{content}\n{content_delim}\n')
PYEOF
- name: Check slug is not already taken
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git ls-files --error-unmatch "_posts/${POST_SLUG}.md" 2>/dev/null; then
gh issue comment "$ISSUE_NUMBER" \
--body "**New Post action failed:** \`_posts/${POST_SLUG}.md\` already exists. Choose a different slug."
exit 1
fi
- name: Write post file
run: |
python3 << 'PYEOF'
import os
slug = os.environ['POST_SLUG']
title = os.environ['POST_TITLE']
excerpt = os.environ['POST_EXCERPT']
date = os.environ['POST_DATE']
cover_image = os.environ['POST_COVER']
content = os.environ['POST_CONTENT']
def yaml_str(s):
return s.replace('\\', '\\\\').replace('"', '\\"')
post = (
'---\n'
f'title: "{yaml_str(title)}"\n'
f'excerpt: "{yaml_str(excerpt)}"\n'
f'coverImage: "{cover_image}"\n'
f'date: "{date}T00:00:00"\n'
'author:\n'
' name: Josh Echeverri\n'
' picture: "/assets/memoji/laptop.png"\n'
'ogImage:\n'
f' url: "{cover_image}"\n'
'---\n\n'
+ content + '\n'
)
with open(f'_posts/{slug}.md', 'w') as f:
f.write(post)
PYEOF
- name: Create branch, commit, and open PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
BRANCH="feat/posts/${POST_SLUG}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git push origin --delete "$BRANCH" 2>/dev/null || true
git checkout -b "$BRANCH"
git add "_posts/${POST_SLUG}.md"
git commit -m "feat(posts): add ${POST_TITLE}"
git push origin "$BRANCH"
PR_BODY=$(printf 'Opened via issue #%s. Review, tweak if needed, then merge to publish.\n\nCloses #%s' "$ISSUE_NUMBER" "$ISSUE_NUMBER")
PR_URL=$(gh api repos/${{ github.repository }}/pulls \
--method POST \
--field title="feat(posts): ${POST_TITLE}" \
--field body="$PR_BODY" \
--field head="$BRANCH" \
--field base="main" \
--jq '.html_url')
gh issue comment "$ISSUE_NUMBER" \
--body "PR opened: $PR_URL — review and merge to publish."
A few things worth calling out:
UUID delimiters for multiline env vars. GitHub Actions passes multiline values between steps using a VAR<<DELIMITER syntax. Fixed delimiters like EOF get silently truncated if post content contains that exact string on its own line. uuid.uuid4().hex makes that collision essentially impossible.
yaml_str escaping at write time, not export time. Early versions escaped quotes before writing to GITHUB_ENV, which meant commit messages and PR titles showed \" for any title with a quote. Export raw, escape only when writing YAML.
REST API for PR creation. gh pr create uses GraphQL and hits Resource not accessible by integration with GITHUB_TOKEN in some repo configurations even with pull-requests: write set. gh api repos/.../pulls --method POST uses REST and doesn't have this problem.
The Claude skill
On desktop with the GitHub connector, I describe a post idea and Claude writes the file and opens the PR directly. On mobile, the same skill falls back to outputting each issue form field in its own code block — one tap to copy each into the form.
The skill is a zip with a single SKILL.md:
new-blog-post.zip
└── new-blog-post/
└── SKILL.md
---
name: new-blog-post
description: Generate a new josh.miami blog post. If GitHub tools are available, creates the branch, writes the file, and opens the PR directly. Otherwise outputs copy-paste-ready fields for the GitHub issue form.
---
Generate and publish a new blog post for josh.miami (GitHub repo: `josheche/josh-miami-ts`).
## If GitHub tools are available
1. Derive a kebab-case slug from the title
2. Create branch `feat/posts/{slug}` off `main`
3. Write `_posts/{slug}.md` with correct frontmatter
4. Commit: `feat(posts): add {title}`
5. Open a PR to `main`, share the link
## If GitHub tools are NOT available
Output each field in its own code block for copy-pasting into the issue form: Title, Slug, Excerpt, Date, Cover Image URL, Content.
## Writing style
Conversational, direct, opinionated, first-person. Software engineer in Miami.
Use `##` for headers, no H1. Keep it tight.
One gotcha
You need to allow GitHub Actions to create pull requests. Either flip it in Settings → Actions → General, or:
gh api -X PUT repos/{owner}/{repo}/actions/permissions/workflow \
--field default_workflow_permissions=write \
--field can_approve_pull_request_reviews=true
After that: have an idea, open an issue, merge the PR.
