Hey, it's me, josh - as a memoji

josh.miami

Publishing blog posts from my phone with Claude and GitHub Actions

Josh Echeverri
Josh Echeverri

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

  1. Open the GitHub mobile app → Issues → New Issue → pick the "New Blog Post" template
  2. Fill in the form (or paste output from Claude)
  3. Submit — a GitHub Action fires, writes the post file with correct frontmatter, creates a branch, and opens a PR
  4. Vercel auto-deploys a preview from the PR branch
  5. 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.