I wanted a blogging workflow that was fast, mobile-friendly, and didn’t require me to clone a repo or open a code editor. The solution: write posts as GitHub Issues in a private repo, and let a GitHub Action publish them to a public Jekyll site.

The Setup

The system uses two repositories:

  • A private repo where I write posts as issues
  • A public repo (and.github.io) that serves the Jekyll blog via GitHub Pages

A GitHub Actions workflow watches for the publish label. When I add it to an issue, the workflow converts the issue into a Jekyll post, builds the site, and pushes it to the public repo. Removing the label or closing the issue takes the post down.

Labels double as categories — any label except publish becomes a post category.

The Image Problem

This worked immediately for text, but images broke. When you upload an image to a GitHub issue, it gets a URL like:

https://github.com/user-attachments/assets/f4742aba-1db3-4caf-ad23-05040e7b6144

In a private repo, these URLs are private. They can’t be accessed by anonymous visitors to the public blog. So the workflow needs to download the images and commit them to the public repo.

The obvious approach — curl with the GITHUB_TOKEN — doesn’t work. The automatic GITHUB_TOKEN in GitHub Actions cannot download user-uploaded attachment assets. Neither can fine-grained PATs scoped to the repo. They all return “Not Found”.

The Fix

The solution uses the GitHub REST API itself to get around this limitation:

  1. Call the REST API to fetch the issue with Accept: application/vnd.github.full+json. This returns the issue body as rendered HTML in the body_html field.

  2. The rendered HTML contains signed CDN URLs. Instead of the original user-attachments URL, the HTML has URLs like:

    https://private-user-images.githubusercontent.com/24557/546715488-f4742aba-....png?jwt=eyJ...
    

    These signed URLs include a JWT token and are downloadable without any authentication.

  3. Match by UUID. The original URL and the signed URL both contain the same UUID (f4742aba-1db3-4caf-ad23-05040e7b6144), so the workflow maps one to the other, downloads from the signed URL, and replaces the original URL in the markdown with a local path.

The key permission requirement: the workflow needs issues: read on the GITHUB_TOKEN for the API call. This isn’t granted by default when you add an explicit permissions block — and if you add permissions at all, you also need to explicitly include contents: read for the checkout step, since the block replaces all defaults.

What the Workflow Does

When an issue gets the publish label:

  1. Checks out both the private and public repos
  2. Fetches the issue’s rendered HTML via the API
  3. Extracts user-attachments URLs from the markdown body
  4. Finds matching signed CDN URLs in the rendered HTML (by UUID)
  5. Downloads each image, detects its actual MIME type, and saves it with the correct extension
  6. Verifies each download is a real image (not an error page)
  7. Replaces the GitHub URLs with local paths in the post body
  8. Writes the Jekyll post file with front matter
  9. Builds the Jekyll site
  10. Commits and pushes to the public repo (with git pull --rebase to handle concurrent runs)

When the label is removed or the issue is closed, the post and its images are deleted.

The Mobile Workflow

This is really what makes it worthwhile. From my phone:

  1. Open GitHub (app or browser)
  2. Create a new issue
  3. Write the post in markdown, drag in images
  4. Add the publish label
  5. Submit

The post is live in about a minute. No laptop needed, no git commands, no deploy scripts. Just write and publish.