Blogging from GitHub Issues: How I built a private-to-public publishing pipeline
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:
-
Call the REST API to fetch the issue with
Accept: application/vnd.github.full+json. This returns the issue body as rendered HTML in thebody_htmlfield. -
The rendered HTML contains signed CDN URLs. Instead of the original
user-attachmentsURL, 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.
-
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:
- Checks out both the private and public repos
- Fetches the issue’s rendered HTML via the API
- Extracts
user-attachmentsURLs from the markdown body - Finds matching signed CDN URLs in the rendered HTML (by UUID)
- Downloads each image, detects its actual MIME type, and saves it with the correct extension
- Verifies each download is a real image (not an error page)
- Replaces the GitHub URLs with local paths in the post body
- Writes the Jekyll post file with front matter
- Builds the Jekyll site
- Commits and pushes to the public repo (with
git pull --rebaseto 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:
- Open GitHub (app or browser)
- Create a new issue
- Write the post in markdown, drag in images
- Add the
publishlabel - Submit
The post is live in about a minute. No laptop needed, no git commands, no deploy scripts. Just write and publish.