Automate your changelog with GitHub Actions (and any other CI)

Most abandoned changelogs didn't die because someone decided to stop. They died because posting was a separate chore that lived outside the release pipeline β€” and anything outside the pipeline gets skipped the first busy week, then forever. The fix is wiring, not willpower: make the changelog step part of the same automation that ships the code. This guide shows how to do that with GitHub Actions (the pattern ports to any CI), and β€” just as important β€” where to draw the line so the automation doesn't publish garbage on your behalf.

Automate the plumbing, not the judgment

There are two jobs hiding in "automate the changelog":

  • Delivery β€” noticing that a release happened, assembling the raw material, getting an entry onto the page. Machines are perfect at this.
  • Writing β€” deciding what matters to readers and saying it in their words. Machines are terrible at this: a pipeline that publishes fix: null check in parser verbatim ships you a commit dump, not a changelog.

The pattern that keeps both jobs honest: CI creates a draft, a human publishes it. The machine never forgets; the human never transcribes. Everything below builds toward that split β€” fully automatic publishing has its place (internal streams, mechanical per-package logs), but for a customer-facing changelog, curation is the feature.

Pick the trigger: announce when it's live

The single most consequential choice is which event runs the changelog step. The rule from announcing features applies to pipelines too: the entry should appear when users can actually touch the change.

  • Tag push (on: push: tags). Right for libraries, CLIs, and anything where the tag is the release β€” the moment v2.4.0 exists, users can install it. The workflow below uses this.
  • Release published (on: release: types: [published]). Right if your team already writes GitHub Releases: the human curation step already happened in the release body, so mirroring it outward is safe. Note this fires on publish, not on draft β€” which is exactly the draft-then-publish split, using GitHub's UI as the editor.
  • End of the deploy job. Right for SaaS: a tag means nothing to your users until the deploy finishes. Put the changelog step after the production deploy succeeds β€” same workflow, last step β€” so you never announce something that rolled back. If you use staged rollouts or feature flags, post a scheduled or draft entry and flip it when the rollout completes.

What's almost never right: per-merge to main. Merges are your team's heartbeat, not your users' β€” a changelog that posts on every merge is a firehose nobody subscribes to twice (ship rhythm β‰  merge rhythm). Batch merges into releases, then announce the release.

The minimal workflow

One job, one curl. On every version tag, this drafts an entry whose body is the commit list since the previous tag β€” raw material waiting for an editor, not a public post:

# .github/workflows/changelog.yml
name: changelog
on:
  push:
    tags: ['v*']

jobs:
  draft-entry:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # full history, so the previous tag is reachable
      - name: Draft changelog entry
        env:
          WAKELOG_TOKEN: ${{ secrets.WAKELOG_TOKEN }}
        run: |
          PREV=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
          BODY=$(git log --no-merges --pretty='- %s' ${PREV:+$PREV..}HEAD)
          curl -fsS "https://YOUR-CHANGELOG-HOST/api/v1/my-app/posts" \
            -H "Authorization: Bearer $WAKELOG_TOKEN" \
            --data-urlencode "title=$GITHUB_REF_NAME" \
            --data-urlencode "body=$BODY" \
            --data-urlencode "draft=true"

Three details that save debugging time: fetch-depth: 0, because the default shallow checkout can't see the previous tag and your "commits since last release" silently becomes "the last commit"; --no-merges, because merge commits are plumbing; and draft=true, because this body is a reminder of what shipped, not prose anyone should read yet. The same step works in any CI β€” GitLab's version is a rules: - if: $CI_COMMIT_TAG job with $CI_COMMIT_TAG as the title; Jenkins, Buildkite, and a bare deploy.sh are the same curl.

Draft in CI, publish by hand

The two-step loop in practice: the tag lands, CI files a draft titled v2.4.0 with the commit list. Sometime in the next hour, a human opens it, deletes the chore: lines, renames it after the change instead of the version, rewrites three bullets in reader language β€” and publishes. Total human cost: five minutes, with zero chance of forgetting, because the draft is already sitting there.

Two refinements once the loop works:

  • Write the announcement before the release. For a launch you care about, draft the entry days early β€” by hand, in good prose β€” and let the pipeline (or a schedule) publish it. The CI draft then serves as a checklist to confirm nothing else snuck into the release.
  • Schedule for rollout lag. If a release takes hours to reach users (app-store review, staged rollout), publish at the time users get it, not when CI finishes β€” a scheduled entry posted by the pipeline with a publish time attached does this without a human awake at rollout-complete o'clock (store lag is the classic case).

Make re-runs safe

CI steps run more than once: flaky runners get retried, workflows get re-run from the UI, a force-pushed tag fires twice. If the changelog step isn't idempotent, every hiccup double-posts. Three defenses, use any one:

  • Dedupe at the receiving end. The best tools skip an incoming entry whose title already exists β€” then a re-run is a no-op by construction. (Wakelog's release-webhook and import paths work this way; a re-delivered webhook or re-run import can't double-post.)
  • Check before posting. One GET for the latest entries, grep for the tag name, skip if present. Ten lines, works against any API.
  • Draft-by-default as a backstop. If duplicates land as drafts, the human in the loop deletes one in passing β€” annoying, never public. This is the lazy option and it's genuinely fine at small scale.

Keep the deploy green

Decide explicitly: should a changelog failure fail the release? Almost always no β€” you don't want a 500 from a notification endpoint rolling back a good deploy. Put the changelog step in its own job (or mark it continue-on-error: true) so the release pipeline stays green on its own merits. But pair that with visibility: a step that fails silently for six weeks is how automated changelogs quietly die. A red job in the Actions list you actually look at is enough; a CI-failure notification to the team chat is better.

Secrets hygiene

  • Store the token as a CI secret (repository or organization secret in GitHub, masked variable in GitLab) β€” never in the workflow file, never in the repo. Anyone who can read the repo can read the workflow.
  • One token per pipeline. If the token only posts to the changelog, leaking it costs you spam cleanup, not your infrastructure. Don't reuse a deploy credential.
  • Rotate on any doubt. A token that appeared in a log line is burned; rotate it. (Curl with -fsS, as above, won't echo headers into logs.)
  • Fork PRs don't get secrets β€” GitHub withholds secrets from workflows triggered by pull requests from forks. Tag and release triggers come from maintainers, so the patterns in this guide are naturally safe; just don't move the posting step into a pull_request job.

Already using GitHub Releases? Skip the YAML

If your release ritual already ends in a published GitHub Release, you may not need a workflow at all: point a release webhook at your changelog tool and every published release becomes an entry automatically β€” curation happens where you already do it, in the release body (how Releases and changelogs fit together). GitHub's auto-generated release notes ("Generate release notes" button, or gh release create --generate-notes) make a decent starting draft β€” treat that text exactly like the commit list above: raw material to edit, not copy to publish.

Anti-patterns

  • Publishing the commit dump. Automation that posts git log output live is worse than no automation β€” it fills the page with noise that buries the entries that matter. Draft, then edit.
  • Post-per-merge. Twelve entries on a Tuesday teaches subscribers to unsubscribe. Announce releases, not merges.
  • The commit-back loop. Workflows that edit CHANGELOG.md and push the commit back can re-trigger themselves (and fight human edits). If you must commit back, gate the workflow against its own commits and use [skip ci] β€” or keep the changelog out of the repo entirely and post to it instead.
  • Announcing before it's live. A tag-triggered post for a SaaS that deploys hourly later, or a store build still in review β€” readers click, find nothing changed, trust the page a little less. Match the trigger to user-visible reality.
  • Hardcoded tokens. Covered above; listed again because it's the one that ends up in a security postmortem.

Where Wakelog fits

Wakelog is built for exactly this pipeline shape. Posting is one POST /api/v1/<project>/posts with a bearer token β€” no SDK, no OAuth dance β€” with draft=true for the draft-in-CI pattern and publish_at for rollout-timed publishing. The wakelog CLI wraps the same API for deploy scripts: wakelog post --from-git --draft turns "commits since the last tag" into a draft in one line. Prefer zero YAML? Point a GitHub, GitLab, or Gitea release webhook at your project and published releases become entries β€” signature-verified, title-deduped, so redeliveries never double-post. Publishing a draft is one click on the dashboard or one PATCH from anywhere.

Start your free changelog   Next: changelogs from git commits β†’

Related guides

Last updated 2026-07-27 Β· All guides