Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ jobs:
VERSION="${{ inputs.version }}"
MAJOR="v$(echo "$VERSION" | cut -d. -f1)"

# dist/ is gitignored on the branch itself — force-add it into this
# one release commit, which is reachable only via the tags below,
# never pushed onto the branch. develop/main stay dist-free forever.
# dist/ and merge-report/index.js are gitignored on the branch
# itself — force-add them into this one release commit, which is
# reachable only via the tags below, never pushed onto the branch.
# develop/main stay build-artifact-free forever.
git add package.json package-lock.json
git add -f dist
git add -f merge-report/index.js
git commit -m "chore(release): v${VERSION}"
git tag -a "v${VERSION}" -m "v${VERSION}"
git tag -f "$MAJOR" HEAD
Expand Down
8 changes: 5 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ build/
.DS_Store
coverage/

# dist/index.js and dist/cli.js are built and committed only by the Release
# workflow, into a release-only commit that a version tag points at — never
# on the develop/main branch itself. Do not build-and-commit these locally.
# dist/index.js, dist/cli.js, and merge-report/index.js are built and
# committed only by the Release workflow, into a release-only commit that a
# version tag points at — never on the develop/main branch itself. Do not
# build-and-commit these locally.
dist/
merge-report/index.js
140 changes: 133 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ 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`](#merge-report-did-a-fix-propagate-everywhere-it-needs-to)
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.

## Usage

### As a GitHub Action
Expand Down Expand Up @@ -198,6 +204,125 @@ Resolution order, highest precedence first:
tier: the entry is dropped and a warning names the PR/issue and the
unresolvable SHA, so a human can add an explicit override.

## merge-report: did a fix propagate everywhere it needs to?

`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.

### Branch topology, discovered automatically

`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 every `support/M.x` that exists with `M < 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.

### As a GitHub Action

```yaml
- 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@v1
```

Run 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`](./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.

### As a CLI

```bash
# 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.x
```

### How it detects a gap

Comparing 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 still needs two more filters:

- **`exclude-labels`** — the same list read from `.gitflow-changelog.yml`
for 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.
- **`.gitflow-changelog-merge-ignore.yml`** — a checked-in, sha-keyed list
for the residual cases the label sweep can't resolve, each with a required
human-written reason:

```yaml
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.

## Known limitations

- A label applied without generating a discrete GitHub event (rare, e.g.
Expand All @@ -219,15 +344,16 @@ Resolution order, highest precedence first:
npm install
npm run typecheck
npm test
npm run build # bundles src/cli.ts -> dist/cli.js and src/action.ts -> dist/index.js
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.js
```

`dist/` is 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 `dist/index.js` to exist somewhere, but
that somewhere is a release tag, not the development branch (see
"Releasing" below). Don't build-and-commit `dist/` locally; `npm run build`
is for local verification only.
`dist/` 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.

## Releasing

Expand Down
113 changes: 113 additions & 0 deletions merge-report/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
name: gitflow-changelog merge-report
description: >-
Sweeps a repo's gitflow branch topology (a mainline branch plus every
support/* maintenance branch) for commits that landed on one branch but
have no equivalent patch content on another that should have it — by
comparing actual patch content, not merge-commit ancestry.
author: Aklivity
branding:
icon: git-merge
color: purple

inputs:
owner:
description: Repository owner.
required: false
default: ${{ github.repository_owner }}
repo:
description: Repository name.
required: false
default: ${{ github.event.repository.name }}
token:
description: GitHub token used to read issues, pull requests, and their events.
required: false
default: ${{ github.token }}
git-dir:
description: >-
Path to a full clone of the repository, with every relevant branch
fetched — not just the checked-out one (requires fetch-depth 0 plus
an explicit fetch of every branch; actions/checkout's default fetch
refspec only brings full history for the one ref it checks out, even
at fetch-depth 0). Defaults to the current working directory.
required: false
cache-path:
description: >-
Path to the incremental events cache file. Shared with the
gitflow-changelog action — pair with actions/cache using the same
stable key so a warm changelog cache means this action makes no new
API calls of its own.
required: false
default: .gitflow-changelog-cache.json
merge-ignore-path:
description: >-
Path to a YAML file (in the consuming repo) listing commit shas that
are known not to need propagating, keyed by sha with a required
human-written reason. Auto-loaded from this default path if present —
no workflow changes needed to start using it.
required: false
default: .gitflow-changelog-merge-ignore.yml
config-path:
description: >-
Path to the same .gitflow-changelog.yml the changelog command reads.
exclude-labels is shared with it (a PR/issue labeled e.g.
"dependencies" is skipped the same way it's excluded from the
changelog); mainline-branch and support-branch-pattern are read from
it too, even though they're merge-report-only, since they're repo-
wide policy in the same sense tag-pattern is.
required: false
default: .gitflow-changelog.yml
exclude-labels:
description: >-
Comma-separated labels whose PR/issue, if matched to a candidate
commit, drops it from the report. Overrides exclude-labels in
config-path; defaults to "duplicate,invalid,wontfix" if set in
neither place.
required: false
mainline-branch:
description: >-
The repo's mainline gitflow branch. Every support branch is one of
its sources. Overrides mainline-branch in config-path; defaults to
"develop" if set in neither place.
required: false
support-branch-pattern:
description: >-
Regular expression matching a maintenance branch name, with the
version as the first capture group (compared numerically, so
support/10.x correctly sorts after support/2.x). Overrides
support-branch-pattern in config-path; defaults to
"^support/(\d+)\.x$" if set in neither place.
required: false
target:
description: >-
Narrows the sweep to a single branch instead of the full topology —
for on-demand debugging. Leave unset for the default, parameter-free
full sweep, which is what makes a scheduled run and a manual
workflow_dispatch run produce identical results.
required: false
sources:
description: >-
Comma-separated branches to check `target` against, replacing its
auto-computed sources entirely. Only meaningful alongside `target`.
required: false
output-path:
description: Path to write the generated report to.
required: false
default: merge-report.md
fail-on-outstanding-after-days:
description: >-
Fail the run if any entry in the report has been outstanding longer
than this many days. The report itself always lists everything,
unfiltered, sorted oldest-first, one section per branch (including a
branch with nothing outstanding) — this only controls whether the
run exits non-zero (and therefore triggers GitHub's default
scheduled-workflow-failure notification).
required: false
default: '14'

outputs:
merge-report-path:
description: Path to the generated merge report.

runs:
using: node20
main: index.js
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
"gitflow-changelog": "./dist/cli.js"
},
"scripts": {
"build": "npm run build:cli && npm run build:action",
"build": "npm run build:cli && npm run build:action && npm run build:merge-report-action",
"build:cli": "esbuild src/cli.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=dist/cli.js",
"build:action": "esbuild src/action.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=dist/index.js",
"build:merge-report-action": "esbuild src/merge-report-action.ts --bundle --platform=node --target=node20 --format=esm --banner:js=\"import { createRequire } from 'module'; const require = createRequire(import.meta.url);\" --outfile=merge-report/index.js",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
Expand Down
10 changes: 10 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@
import { parseArgs } from 'node:util';
import { writeFile } from 'node:fs/promises';
import { toRunOptions } from './config.js';
import { runMergeReportCli } from './merge-report-cli.js';
import { run } from './run.js';

async function main(): Promise<void> {
// `merge-report` is a sibling subcommand, not a flag — anything else
// (including no positional arg at all) keeps today's flat-flags
// changelog behavior unchanged, so existing callers see no difference.
if (process.argv[2] === 'merge-report')
{
await runMergeReportCli(process.argv.slice(3));
return;
}

const { values } = parseArgs({
options: {
owner: { type: 'string' },
Expand Down
20 changes: 20 additions & 0 deletions src/drivers/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,26 @@ function categorize(labels: string[], options: DriverOptions): Category {
return 'issue';
}

// Same event-sourced label state entriesFromCache reads, but answering a
// different question: not "how should this entry be categorized" (which
// only ever runs against this repo's own enhancement/bug/exclude labels),
// but "which commits, wherever they came from, are labeled in a way that
// says they don't need to go anywhere else" — merge-report's use case,
// where the label check has to run before any category is assigned and
// doesn't care about enhancement vs. bug. Keyed by sha, not issue/PR
// number, since that's what a `git cherry` candidate is identified by.
export function excludedShas(cache: CacheFile, excludeLabels: string[]): Set<string> {
const shas = new Set<string>();
for (const entry of Object.values(cache.entries))
{
if (entry.sha && entry.labels.some((label) => excludeLabels.includes(label)))
{
shas.add(entry.sha);
}
}
return shas;
}

export function entriesFromCache(cache: CacheFile, options: DriverOptions): Entry[] {
const entries: Entry[] = [];
for (const [number, cacheEntry] of Object.entries(cache.entries))
Expand Down
Loading
Loading