Every GitHub Release becomes a blog post, automatically
We ship often at Presence Wave, and for a long time that meant every release either got a hand-written announcement (if someone remembered, and had ten minutes) or it quietly shipped with nothing public to show for it. Neither option scales. So we wired our release process directly into our own blog: publish a GitHub Release, and a blog post appears a few seconds later - no copy-pasting, no separate CMS login, no second place to keep in sync.
The mechanism is small enough to explain in one sitting: a GitHub Actions workflow listens for `release: published`, turns the release's title and notes into a request, and POSTs it to the same Blog API our customers use to publish their own posts programmatically. We're not special-cased - we're just another caller of a public endpoint.
The trigger
# .github/workflows/publish-blog-on-release.yml (trimmed)on: release: types: [published]
jobs: publish-blog-post: if: github.event.release.prerelease == false runs-on: ubuntu-latest
Two things worth calling out. `types: [published]` means drafts and edits don't fire this - only the moment a release actually goes public. And `if: github.event.release.prerelease == false` means beta/RC tags don't spam the blog; we can cut a dozen release candidates without a dozen announcements, and only the real release publishes a post.
Release notes in, blog post out
The job itself is a single step: read the release's title and body off the GitHub event, build a request, call the API.
# .github/workflows/publish-blog-on-release.yml (trimmed)RELEASE_TITLE: ${{ github.event.release.name || github.event.release.tag_name }}RELEASE_BODY: ${{ github.event.release.body }}
BODY="${RELEASE_BODY:-Read the full release notes on GitHub.}"CATEGORY="Engineering"TAGS_JSON='["engineering", "release", "feature", "updates"]'SET_PUBLISH="true"
PAYLOAD=$(jq -n \ --arg title "$RELEASE_TITLE" \ --arg content "$BODY" \ --arg category "$CATEGORY" \ --argjson tags "$TAGS_JSON" \ --argjson setPublish "$SET_PUBLISH" \ '{ title: $title, markdownContent: $content, setPublish: $setPublish, category: $category, tags: $tags }')
curl -s -o response.json -w "%{http_code}" \ -X POST "https://app.presencewave.com/external/v1/blogs/${PW_BLOG_ID}/posts" \ -H "Authorization: Bearer ${PW_API_TOKEN}" \ -H "Content-Type: application/json" \ -d "$PAYLOAD"```
The release title becomes the post title. The release notes - written in plain Markdown, same as any other GitHub release - become the post body; the API converts and sanitizes that Markdown into HTML on our side, so the workflow never touches HTML at all. `PW_API_TOKEN` and `PW_BLOG_ID` are repo secrets: a tenant API token scoped to our own blog, the same kind any customer generates from their own tenant settings to automate their own publishing. There's nothing in this pipeline that isn't also available to anyone building their own integration against this API.
An escape hatch for the one-off release
Most releases are happy with the defaults above - "Engineering" category, the standard tag set, published immediately. Some aren't. A major feature announcement wants a different category; a release that goes out before the write-up is finished wants to land as a draft instead of going live. Rather than add input parameters to the workflow (which nobody remembers to use from the GitHub UI when cutting a release), we let the release notes carry their own metadata:
<!-- blog-metacategory: Product Announcementtags: feature, product, announcementpublish: false-->
The rest of the release notes go here, written normally...
It's an HTML comment, so GitHub hides it when it renders the release notes - nobody reading the release on GitHub ever sees it. The workflow greps it out of the body before building the request, uses whatever fields are present to override the defaults, and strips the block itself so it never ends up in the published post. All three fields - `category`, `tags`, `publish` - are independently optional; set only the one you need and leave the rest on the defaults.
We used exactly this for our last release, to get it onto the blog under "Product Announcement" instead of the usual "Engineering" bucket.
What broke the first time we actually used the override
The override block had been sitting in the workflow for a while, unused, before we wrote a release that actually exercised it. The first time we did, the workflow failed with `Process completed with exit code 1` and no other output at all - not even the `curl` request had been attempted.
The bug was in how the override's fields were parsed:
META_PUBLISH=$(printf '%s\n' "$META_BLOCK" | grep -i '^publish:' | head -1 | sed 's/^[Pp]ublish:[[:space:]]*//')
Our override set `category` and `tags` but left `publish` out, since we wanted the default (publish immediately). `grep` finding no match exits `1`. Under `set -euo pipefail`, that `1` propagates through the pipeline as the whole command's exit status - and a plain variable assignment that fails is exactly the kind of thing `set -e` kills a script for, instantly, with no explanation. Three optional fields, three near-identical `grep` lines, and the one we happened to omit was the one that took the whole job down before it ever reached the API call.
The fix was a one-line change to each line, wrapping the `grep` in `{ grep ... || true; }` so "no match" means "empty," not "fatal":
META_PUBLISH=$(printf '%s\n' "$META_BLOCK" | { grep -i '^publish:' || true; } | head -1 | sed 's/^[Pp]ublish:[[:space:]]*//')
It's a small, easy-to-miss interaction - `grep`'s exit code is meaningful (no match vs. a real error), and `pipefail` plus `set -e` doesn't know the difference between the two. Worth checking for in any CI script that greps for something genuinely optional.
Try it yourself
The endpoint this workflow calls isn't internal-only - it's the same Blog API available to any PresenceWave tenant with an API token:
If you're already generating release notes as part of your own pipeline, pointing them at a blog API instead of leaving them to live only on GitHub is a small amount of wiring for a release process that announces itself.