A CHANGELOG generator for repositories that use a gitflow branching model —
a long-running develop branch plus maintenance/support branches (e.g.
support/1.x) — where each release tag lives on its own line of history.
Generic changelog generators place issues and pull requests by comparing dates (when did this close relative to when that tag was cut?), which is wrong the moment a repo has more than one active branch: an issue whose fix hasn't shipped on a given branch yet — or shipped on a different branch entirely — gets silently attributed to whatever tag happens to be newest when it closes. This tool places every entry by asking git which tag actually contains its commit, walking real ancestry instead of trusting dates or platform-reported associations.
Driver (platform-specific) → Placement (shared, git-only) → Renderer (shared, format-only)
- Driver (
src/drivers/github.tsfor v1) walks the GitHub REST API and produces a platform-agnostic list of entries:{ number, kind, category, title, login, bot, sha }. - Placement (
src/placement.ts) is pure local git: for each entry, it finds the earliest tag (chronologically) whose history contains the entry's commit. If no tag contains it but it's still an ancestor of the branch being processed, it goes underUnreleased. If it's not reachable from this branch's line of history at all — e.g. a PR merged todevelopas part of an unrelated release — it's dropped from this branch's log entirely. - Renderer (
src/render/default.ts) turns the placed structure into markdown, matching the existing CHANGELOG format byte-for-byte.
This design keeps the door open for other platforms (GitLab, etc.) later — they'd only need to implement the driver contract; placement and rendering are already platform-agnostic.
merge-report
is a second consumer of the Driver's cached, event-sourced entries — it
skips Placement and Renderer entirely (it isn't about which release
something shipped in) and instead cross-references entry labels against
git cherry candidates.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required — ancestry checks need full history, not a shallow clone
- uses: actions/cache@v4
with:
path: .gitflow-changelog-cache.json
key: gitflow-changelog-v1-${{ github.repository }} # stable key, not hash-based
- uses: aklivity/gitflow-changelog@v0
with:
ref: support/1.xSee action.yml for the full list of inputs.
npx gitflow-changelog --owner aklivity --repo zilla --token "$GITHUB_TOKEN" --ref support/1.xsrc/cli.ts and src/action.ts share the same core (src/run.ts) — the CLI
is not a wrapper around the Action, and vice versa; both are thin entrypoints
over the same logic.
Settings that describe what your changelog looks like — which tags get
their own section, how issues/PRs are categorized, which renderer to use —
live in a YAML file committed to the consuming repo (path via the
config-path input, default .gitflow-changelog.yml), not as action inputs
repeated at every call site:
tag-pattern: '^[0-9]+\.[0-9]+\.[0-9]+$' # exclude alpha/beta/rc pre-releases
enhancement-labels: [enhancement]
bug-labels: [bug]
exclude-labels: [duplicate, invalid, wontfix]
format: defaultThis is a policy that doesn't vary by branch or by which step (prepare vs.
finalize) is running, so one file avoids the same setting drifting out of
sync across every copy of a release workflow. Settings that genuinely do
vary per call site — ref, token, git-dir, cache-path, output-path
— stay as action inputs. overrides-path also follows the config-path
convention: auto-loaded from .gitflow-changelog-hash-overrides.yml if
present, no input needed.
The matching action inputs (tag-pattern, enhancement-labels,
bug-labels, exclude-labels, format) still exist as one-off overrides
and take precedence over the file when set; if neither the input nor the
file sets a value, each falls back to a built-in default (shown in the
example above).
Both a PR's merge_commit_sha and an issue's closing commit are immutable
once recorded (gitflow forbids rebasing release branches), so this tool
caches aggressively instead of refetching all history every run. It walks
the repo-wide GET /repos/{owner}/{repo}/issues/events feed — which covers
both issues and pull requests, since every PR is an issue under the hood —
and keeps only the highest event id processed as a watermark. Each run jumps
to the last page and walks backward only until it reaches the cached
watermark.
The CHANGELOG is always fully re-rendered from the entire accumulated cache, never incrementally patched — so a label change or rename processed today correctly updates every section that entry appears in, old releases included.
Pair cache-path with actions/cache using a stable key (not
hash-based, e.g. gitflow-changelog-v1-${{ github.repository }}) so the
cache is found and updated on every run rather than only on exact matches.
The same applies to upstream-cache-dir, used only when .gitflow-changelog.yml
declares an upstream source: placing an upstream repo's entries under the
right release needs its full git history, and git fetch on an existing
clone only transfers what changed since last time — a full clone's cost is
paid once, not on every run, as long as the directory is cached across runs
the same way. Use a key that's stable across branches too (the upstream
repo's history is the same regardless of which of your branches is
generating a changelog).
A recorded merge_commit_sha or closing commit_id can point at a commit
that no longer exists in the repository at all — distinct from "a valid
commit that just isn't reachable from this branch," which is a normal,
correct drop. This happens when history is rewritten on another branch after
the fact (e.g. a PR merged to a feature branch that was later rebased or
squash-merged).
Resolution order, highest precedence first:
-
Checked-in override — a YAML file in the consuming repo, keyed by the broken commit sha itself (not by PR/issue number): an issue auto-closed by a merged PR has its sha backfilled from that PR's own commit, so a PR and the issue(s) it closes always carry the identical recorded sha — one entry here fixes both, and any other entry that happens to share the same broken sha, with no need to enumerate every affected PR/issue number by hand. Auto-loaded from
.gitflow-changelog-hash-overrides.ymlif present — no workflow changes needed; override the path via theoverrides-pathinput only if you want a different filename. When resolving anupstreamdependency's own history for fold-in, that upstream's own.gitflow-changelog-hash-overrides.yml(from its clone) is what's read — not the consuming repo's, and not configurable viaoverrides-path(which only applies to the consuming repo's own resolution):hash-overrides: 54ab5fa6ace003e9a559f83d4a94ef847a733bbd: eb43dc7e4b78b1095f56767c004cd443a423bbd0
-
As recorded, if the commit resolves locally.
-
Heuristic auto-detection — search local commit messages for a reference to the same PR/issue number. If exactly one reachable candidate is found, it's used, with a visible warning naming the substitution.
-
Squash-merge discovery (PR entries only) — covers a PR merged into a long-lived feature branch (e.g.
feature/grpc-kafka) that was itself later squash-merged, which flattens the PR's own commit away entirely. Fetches the PR's base ref, then searches GitHub for whichever PR squash-merged that base ref into a real branch — trying the branch currently being processed first (the feature branch may have been squashed directly into it), then falling back to the repo's actual default branch if that misses (the feature branch may instead have been squashed into develop, with the branch being processed only inheriting the result via ancestry — confirmed against real aklivity/zilla history:feature/support-catalog-handler-validatewas squashed intodevelopvia #1606, yet is also correctly resolved when generatingsupport/1.x's changelog). Once the right squash-merge PR is found, checks whether its number is referenced in local commit messages (reusing the same history scan from tier 3 — no extra git operation, and no risk of a false positive: that scan only ever contains commits reachable from the branch being processed, so a squash-merge that never reached this branch correctly yields zero candidates). An issue closed by a PR resolved this way is fixed for free in the same run, since it shares that PR's exact broken sha. Discovered base refs and squash-merge PR numbers are cached permanently (both are immutable once merged), so this only costs GitHub API calls once per affected PR/branch, not on every run.If GitHub signals rate-limit exhaustion (a 403 with
X-RateLimit-Remaining: 0, a secondary/abuse-detection 403 withRetry-After, or a 429) partway through, this tier stops attempting further lookups for the rest of the run rather than silently reporting every remaining entry as unresolvable — a single clear warning is emitted instead. Anything already discovered before the limit was hit stays in the persisted cache, so the next run resumes from there instead of starting over. An ordinary 403 (e.g. a token missing a required scope) or 404 is unaffected and still resolves to "not found," same as before. -
Flagged unresolved — zero or multiple ambiguous candidates at any tier: the entry is dropped and a warning names the PR/issue and the unresolvable SHA, so a human can add an explicit override.
The GitHub driver's PR discovery (walkNewEvents in src/drivers/github.ts)
walks a repo-wide, paginated /issues/events feed and persists what it's
seen across runs via a cached watermark, so each run only re-fetches what's
new since the last one instead of the repo's entire history. This happened
for real and shipped a wrong release: aklivity/zilla-plus#1073 and #1075 both
squash-merged into support/1.x before the 1.4.3 release, both are correctly
contained by the 1.4.3 tag (confirmed directly with git tag --contains),
yet neither ever showed up in the fetched entries or any warning when 1.4.3
was cut — CHANGELOG.md shipped silently missing both, on an all-green
workflow.
Root cause and fix. The walk finds the newest page once (via the Link
header on an initial request) and then walks backward from there toward page
1, stopping at the first page with nothing newer than the cached watermark —
an optimization that avoids re-walking a large repo's full history every run,
since new events are always clustered near the end. On a live, active repo,
that "newest page" figure can change during the walk itself: if any new
event lands between the initial request and a later one in the same walk,
the true page count grows, page boundaries shift, and whatever falls in the
newly-revealed range is silently never fetched at any page number at all —
exactly what happened to #1073/#1075. The fix: every page fetched during the
walk re-checks its own Link header against the highest page number seen so
far, and if it's grown, the newly-revealed pages are walked first (before the
original walk is allowed to continue or stop) — covering drift discovered at
any point mid-walk, not only drift already present when the walk started.
Covered directly in test/github-driver.test.ts, including a dedicated
regression test that reproduces the #1073/#1075 shape (a page number
appearing only after the walk that would have missed it has already begun)
and asserts every event is still returned exactly once.
Backstop for anything this doesn't anticipate. Fixing the one known
mechanism doesn't rule out every possible way a driver could someday drop an
entry, so every run also cross-checks its own output against an independent
ground truth: git log itself, over the exact range this run is about to
render (the previous release tag to ref, or the full history on the very
first release). Any commit whose subject matches GitHub's own "this commit
is PR #NNN" conventions — a squash-merge's trailing (#NNN), or a real merge
commit's Merge pull request #NNN from ... — must have a corresponding
number somewhere in the entries the driver fetched (resolved or not;
excluded-label filtering isn't distinguished here, so a legitimately
excluded PR that matches one of these subject shapes will also be flagged —
an accepted false positive, since the alternative is another silent gap).
Anything git log says merged that the driver never saw is a completeness
issue, and unlike every other warning in this tool, it fails the run
(core.setFailed in the Action, a non-zero exit code from the CLI) — a
changelog known to be wrong is worse than a failed build, since a failed
build can't be missed. The file is still written (a changelog missing a
couple of entries is more useful than none), but the run itself must not
look green. If this ever fires, don't just re-run it: it means something
other than the pagination-drift case above caused the gap — figure out
what, and consider filing an issue against this repo with the details.
gitflow-changelog answers "which release did this fix ship in." A related,
separate question: did a fix that landed on one branch actually make it to
every other branch that needs it? A bug fix on support/1.x that never
reaches develop becomes a regression for a customer upgrading past
1.x — they had the fix, then lost it. The same risk exists between peer
maintenance branches (support/1.x and support/2.x both need a fix that
only landed on one of them). This isn't a "backport" (mainline → older
branch) — it's the reverse, or a sideways case between peers — hence
merge-report: did this change actually merge across the branches that
needed it, regardless of direction.
merge-report doesn't take a source/target pair — it discovers the whole
branch topology itself and sweeps it in one pass. The mainline branch
(develop by default) and every branch matching a support-branch pattern
(support/(\d+)\.x by default) are found by listing what actually exists
right now, then each one's sources — the branches it should have every
fix from — are derived purely from naming/version convention:
- the mainline branch's sources are every support branch that exists
support/N.x's sources are everysupport/M.xthat exists withM < N- the lowest surviving support branch has no sources — it's trivially clean by definition, and the report says so explicitly rather than omitting it
This is what makes the report self-adjusting: cutting support/3.x from
develop doesn't require a check-in anywhere — the next run picks it up as
a new target with support/1.x and support/2.x as its sources
automatically, and develop's own sweep gains a third source the same way.
It's also what makes a scheduled run and a manual workflow_dispatch run
produce identical results: neither one requires the caller to know or
supply the current branch topology, since there's nothing to supply.
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history — patch-content comparison needs it
# actions/checkout's default fetch refspec only brings full history for the
# one ref it checks out, even at fetch-depth 0 — every other branch needs an
# explicit fetch, or discovery below finds nothing to sweep.
- run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'
- uses: actions/cache@v4
with:
path: .gitflow-changelog-cache.json
key: gitflow-changelog-v1-${{ github.repository }} # same cache the changelog action uses
- uses: aklivity/gitflow-changelog/merge-report@v0Run this on a schedule (weekly, say) plus workflow_dispatch, not on every
push or PR merge — at the exact moment a fix lands on some branch it is
definitionally not yet on anything downstream of it, so a merge-triggered
run would only ever report a guaranteed, contentless "not yet." The
scheduled sweep is where real signal — something that's been outstanding
for a while — shows up. GitHub's schedule trigger always runs the copy of
the workflow file on the repo's default branch, with no branch-selection
equivalent to what workflow_dispatch offers — so this only works as one
workflow living on the mainline branch, not as a workflow duplicated across
every branch expecting to infer "itself" as the target. git cherry
doesn't need any branch checked out, only present locally, which is exactly
what the fetch step above provides — no per-branch checkout required to
sweep the whole topology in a single job.
See merge-report/action.yml for the full
list of inputs, including target/sources (narrows the sweep to one
branch, for on-demand debugging — leave unset for the default full sweep)
and fail-on-outstanding-after-days (default 14): the report itself always
lists everything, unfiltered, oldest-first, one section per branch; this
input only controls whether the run exits non-zero, which is what actually
surfaces the finding to a human via GitHub's default scheduled-workflow-
failure notification — a job summary alone isn't pushed to anyone.
# full sweep — no branch args needed, topology is discovered
npx gitflow-changelog merge-report --owner aklivity --repo zilla-plus --token "$GITHUB_TOKEN"
# narrowed to one target, for on-demand debugging
npx gitflow-changelog merge-report --owner aklivity --repo zilla-plus --token "$GITHUB_TOKEN" \
--target support/2.x --sources support/1.xComparing branches by ancestry (git log target..source) doesn't work: it
flags every cherry-picked or independently re-landed commit as "missing"
purely because its SHA differs, which is the normal shape of a real
forward-port. merge-report uses git cherry -v target source instead —
patch-id comparison — so a commit re-applied under a new SHA on source is
correctly recognized as already present on target.
What's left after that goes through five more filters, cheapest first, each one only running if nothing cheaper already resolved the candidate:
-
exclude-labels— the same list read from.gitflow-changelog.ymlfor changelog categorization. A candidate commit whose originating PR/issue carries one of these labels (e.g.dependencies) is dropped the same way it's excluded from the changelog — no second API sweep, just a second consumer of the event-sourced label state the Driver already fetches and caches. -
ports-trailer(on by default) — aPorts: <sha>line in a target-branch commit's body, asserting it carries forward that specific source-branch commit. This is the escape hatch for a port whose content is a deliberate subset or superset of the original — e.g. the original bundled an unrelated version bump the target branch's own version already covers — which changes both the patch-id and the subject enough that neither of the other checks can recognize the pairing on their own. Unlike subject-match below, a trailer match is a human assertion, not a heuristic: it's trusted outright, no file-overlap confirmation required. The trailer value may be a full or abbreviated sha (a prefix match against the candidate's full sha, same conventiongit log <prefix>itself accepts). Scanned once per target branch, so per-candidate this is a plain in-memory check — cheap enough to run this early. -
exclude-paths— a candidate is dropped only if every changed path matches one of these globs; one file outside the set still shows up. Defaults to.github/**,CHANGELOG.md,.gitflow-changelog*.yml— safe across any consuming repo, since CI config, generated changelog content, and this tool's own config files are never customer-facing. Deliberately not the existing feature/noise classification used for the upstream fold-in feature (classification.ts'sDEFAULT_CLASSIFICATION_PATTERNS) — that's tuned for "does this belong in the customer changelog," which treatsexamples//docs/as noise. For merge-report a docs-only gap is exactly the kind of thing that has to stay visible; reusing that default would silently hide it. -
exclude-message-patterns— regexes tested against the full commit subject. No built-in default: scoped deliberately narrow, to a repo's own literal, deterministically machine-generated commit messages (e.g. a release workflow's fixed"Prepare release "template), never a general human/bot commit-message convention — commit-message conventions aren't reliable enough to filter on (see subject-match below for why this matters), but a fixed template a workflow emits verbatim every time is a different, much more reliable kind of signal. -
Subject-normalization matching (
subject-match, on by default) — the dominant real-world false-positive shape isn't release noise, it's a backport PR renumbered on the target branch: the same fix, cherry-picked with a new PR number and just enough incidental diff drift (unrelated codebase divergence between the branches) to change the patch-id. Strip every trailing(#NNN)group and a trailing(backport...)annotation from the subject, and check whether the target branch already has a commit with the identical normalized subject. If it does, additionally require the two commits' changed-file sets to overlap (Jaccard) by at leastsubject-match-min-overlap(default0.3) before trusting the match — title collisions are rare but real, and requiring file overlap on top costs nothing in the cases that matter, since a real match's files stay far more stable than its line content across independent codebase drift. Below the threshold, the candidate is not auto-resolved; it still shows up, same as anything else needing a human call. -
.gitflow-changelog-merge-ignore.yml— a checked-in, sha-keyed list for whatever survives all of the above, each with a required human- written reason:merge-ignore: a18bdc1c3db328f6f66f53ac84e1eec4f360ce38: "branch-scoped SNAPSHOT reset, not applicable to develop"
Keyed by sha rather than a commit-message convention (
build(deps):, "backport of #NNNN") — message conventions aren't consistent enough to filter on reliably; a human's explicit, reviewed judgment is. In practice, once the four filters above are in place, this list stays small and stops growing on every new backport — confirmed againstaklivity/zilla-plus's real history: 86 rawgit cherrycandidates reduced to 9 (8 genuine one-off residue, 1 real gap) with zero ignore-list entries needed for the recurring renumbered-backport shape.
None of this is cached — deliberately consistent with git cherry/
commitDate, which are also recomputed fresh every run. Every check above
is local git (no GitHub API cost), and "found a subject-match" vs. "no
match yet" isn't safe to cache without the same watermark discipline the
events cache uses (a real match can appear on a later run once a backport
actually lands) — not worth the complexity until an actual repo's scale
makes it slow.
When you open a PR that ports a fix from one branch to another, follow one of these so merge-report resolves it automatically instead of the gap lingering as a false positive that needs a manual ignore-list entry:
- Full port (the port carries the same change as the original, nothing
added or dropped): keep the original commit's exact
type(scope): descriptionsubject. Only the trailing(#NNN)PR-number reference should differ — subject-normalization already strips that. Don't reword the description or change the conventional-commit scope, even if it reads awkwardly on the target branch — a differing scope (e.g.docs(examples):vs.docs(some-feature):) is exactly the kind of thing that defeats subject-match. - Partial or reworded port (e.g. the original bundled a version bump or
other change that doesn't apply on the target branch): no title
convention can bridge a genuine content difference, since both the
patch-id and the subject will legitimately differ. Add a
Ports: <original-sha>line to the commit body instead — merge-report trusts it outright, no human follow-up needed. Confirmed working end-to-end onaklivity/zilla-plus: PR #1054 ported #1043's routed-API-list change todevelopas a deliberately partial diff (some entries already existed there independently); its commit body carriesPorts: 51d29db0f37695d8b479214dfeeab0f2c838a909, and merge-report resolved the gap on the very next scheduled run, with no ignore-list entry required.
- A label applied without generating a discrete GitHub event (rare, e.g. certain repository-transfer/import paths) is invisible to this tool, since categorization is driven entirely by event-sourced label state.
- A GitHub username change has no corresponding issue event, so a cached
logincan go stale — cosmetic (wrong link text), not a placement error. - An issue closed manually (no linked commit or merged pull request) has no
commit to place it against and is correctly dropped, same as an
unreachable PR. An issue closed via a merged PR's closing keyword
(
Fixes #N,Closes #N,Resolves #N, case-insensitive) is placed using that PR's merge commit instead — the repo-wide/issues/eventsfeed's ownclosedevent only carries acommit_idfor direct-commit closes, not ones closed via a linked PR. - The completeness check (above) only recognizes GitHub's own squash-merge and merge-commit subject conventions — a repo using a different merge style (e.g. plain fast-forward merges with no distinguishing subject) gets no coverage from it, silently. It's a safety net for the common case, not a guarantee.
npm install
npm run typecheck
npm test
npm run build # bundles src/cli.ts -> dist/cli.js, src/action.ts -> dist/index.js,
# and src/merge-report-action.ts -> merge-report/index.jsdist/ and merge-report/index.js are gitignored and never committed on
develop/main — GitHub Actions does not install dependencies for
JavaScript actions at run time, so a real, working action still needs its
compiled entrypoint to exist somewhere, but that somewhere is a release tag,
not the development branch (see "Releasing" below). Don't build-and-commit
these locally; npm run build is for local verification only.
Releases are cut via the Release workflow
(workflow_dispatch, with a version input like 0.1.0). It bumps
package.json, builds dist/, and commits both into a single release
commit — but that commit is never pushed onto the branch it was dispatched
from. Instead, only two tags are pushed to point at it: the exact vX.Y.Z
and a moving major-version tag (v0 until a stable v1) that consumers
reference via uses: aklivity/gitflow-changelog@v0, matching the convention
used by actions/checkout, actions/setup-node, etc. develop/main
themselves never see a version bump or a dist/ commit — only the tags do.
Because the release commit isn't part of develop's own history, each
release's commit is only reachable via its tag, not via git log develop;
that's expected, not a bug — the whole point is keeping dist/ and version
bumps out of normal development history entirely.