@anishhs/ledger
v1.0.0
Published
AI-powered release notes generator — analyses git history and diffs to produce audience-tailored notes. Works locally and in any CI.
Maintainers
Readme
AI-powered release notes, generated from your git history — locally or in any CI.
ledger analyses your commits and the actual code diffs, reduces them into a focused
context, and asks the AI provider of your choice to write release notes tailored to a
specific audience (engineering, business, or QA). Bring your own API key; no vendor lock-in.
npx @anishhs/ledger generate --since-last-tag[!TIP] ~3.5 MB install — 16× smaller than the beta. The public beta (
1.0.0-beta.1) bundled a vendor SDK for every provider and weighed ~58 MB. Stable1.0.0is SDK-free: every provider talks to its API over plainfetch, dropping the install to ~3.5 MB. Sinceledgeris meant to be run withnpx, that means far faster cold starts and much less bandwidth per run — which is why it's worth calling out.
Why
Most changelog tools just reformat commit messages. ledger reads the diff too, so the
notes describe what actually changed — not just what someone typed in a commit subject.
- Provider-agnostic (BYOK): OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Bedrock.
- CI-first, local-friendly: the same command works on your laptop and inside GitHub Actions, GitLab CI, Jenkins, or any runner — auto-detecting the environment and range.
- Robust: per-request timeouts, automatic retry with backoff (honouring a provider's
Retry-Afterhint), and a documented exit-code contract so pipelines behave predictably. - Cheap to preview:
--dry-runassembles the context and estimates tokens without spending anything.
How it works
For a commit range, ledger:
- Collects the commits and their actual code diffs with
git. - Reduces them into a focused context, capping per-file diff size (
maxDiffLines) so large PRs stay within token budgets. - Prompts your chosen AI provider — one non-streaming request — to write notes for your chosen audience (engineering / business / QA).
- Writes the result as Markdown (default) or JSON, to stdout or a file.
Nothing leaves your machine except that single prompt to the provider you configured
(bring your own key). ledger stores nothing and calls no other service.
Install
Requirements: Node ≥ 20, git on PATH, and an API key for your chosen provider
(none for local Ollama).
No install needed — run it with npx:
npx @anishhs/ledger generate --since-last-tagOr install globally:
npm install -g @anishhs/ledger
ledger generate --since-last-tagQuickstart
# 1. Scaffold a config file
ledger init
# 2. Set the API key for your chosen provider
export OPENAI_API_KEY=sk-...
# 3. Generate notes for everything since the last git tag
ledger generate --since-last-tag > RELEASE_NOTES.mdUsage
ledger generate [options]Selecting a range
| Flag | Description |
| --- | --- |
| --since-last-tag | From the last git tag to HEAD (default when no range is given) |
| --from <ref> | Start from a tag, branch, or SHA |
| --to <ref> | End ref (default: HEAD) |
| --last <n> | Include the last N commits (clamped to available history — asking for more than exist just includes everything back to the first commit) |
In CI, if you pass no range flag, ledger derives one automatically:
a tag build uses previous tag → this tag; a PR/MR build uses base branch → HEAD.
Output & behaviour
| Flag | Description |
| --- | --- |
| --audience <mode> | engineering (default), business, or qa |
| --output <format> | markdown (default) or json |
| -o, --output-file <path> | Write notes to a file. Optional — omit it and notes go to stdout. When set, notes are not echoed to stdout (add --stdout if you want both) |
| --stdout | Also echo the notes to stdout when writing to --output-file |
| --append | Append to --output-file instead of overwriting (newest at the bottom) |
| --prepend | Prepend to --output-file (newest on top, inserted below a leading # title) — ideal for a CHANGELOG.md |
| --max-tokens <n> | Max tokens for the AI response (default 4096) |
| --timeout <ms> | Per-request timeout (default 60000) |
| --quiet | Suppress progress on stderr (errors still shown) |
| --dry-run | Assemble context + estimate tokens, without calling the AI |
| --fail-on-empty | Exit non-zero when there are no changes in the range |
| --no-summary | Don't write to the CI step summary even when one is detected |
| --provider <name> / --model <name> | Override the configured provider/model |
| --config <path> | Path to a config file |
Without --output-file, notes are written to stdout and all progress/logging goes to
stderr, so ledger generate > NOTES.md is always clean. With --output-file, the file is
the output and stdout stays quiet unless you add --stdout.
JSON output (for programmatic use)
--output json emits a structured object instead of Markdown — handy when another tool consumes
the notes (posting a PR comment, feeding a release dashboard, etc.). content holds the AI-written
body; the rest is metadata ledger computed:
{
"title": "Release Notes",
"date": "2026-07-07",
"range": "v1.2.0..HEAD",
"audience": "engineering",
"commits": 12,
"filesChanged": 34,
"content": "# Release Notes\n\n## New Features\n...", // the AI-generated markdown body
"repo": "owner/repo", // present in CI when detected
"ref": "refs/tags/v1.3.0", // present in CI when detected
"runUrl": "https://..." // present in CI when detected
}# e.g. pull just the body out with jq
ledger generate --since-last-tag --output json | jq -r .contentConfiguration
Run ledger init to scaffold ledger.config.yaml — one small YAML file that holds all of
ledger's settings (provider, model, token limits, …). Commit it to your repo. It's the single
source of truth used both on your laptop and in CI, so you never have to repeat provider/model flags
anywhere. Everything in it is optional except provider.
provider: openai # openai | anthropic | gemini | openrouter | ollama | bedrock | openai-compatible
model: gpt-4o # optional — a sensible default is used per provider (required for openai-compatible)
# audience: engineering # default audience when --audience isn't passed
# maxDiffLines: 100 # cap per-file diff lines sent to the AI (reduces tokens on big PRs)
# maxTokens: 4096 # max tokens for the AI's response (default 4096)
# timeout: 60000 # per-request timeout (ms)
# maxRetries: 3 # retry attempts on transient provider errors (429/5xx/network)Only the API key stays out of the file — keep it in an environment variable (locally) or a secret (in CI). See Providers & keys for the variable each provider reads.
Accepted config file names
ledger looks in the current directory for the first of these that exists (so .yaml wins over
.yml if you have both), or point at any path with --config <path>:
| File | Format |
| --- | --- |
| ledger.config.yaml | YAML (what ledger init creates) |
| ledger.config.yml | YAML |
| .ledger.yaml | YAML (dotfile) |
| .ledger.yml | YAML (dotfile) |
| ledger.config.json | JSON |
.yaml and .yml are treated identically — pick whichever your repo prefers. A JSON config uses the
same keys:
{
"provider": "openai",
"model": "gpt-4o",
"maxTokens": 4096
}# Or keep it anywhere and pass the path explicitly:
ledger generate --since-last-tag --config config/ledger.jsonPrecedence (highest first): LEDGER_PROVIDER / LEDGER_MODEL env vars → CLI flags →
config file → built-in defaults. So the committed file sets your defaults, and a flag lets you
override one run without editing it. (An empty env var — e.g. an unset vars.LEDGER_MODEL in
CI — is ignored, so it falls back to the config file rather than blanking the value.)
Updating a CHANGELOG in place
--output-file overwrites by default. Use --prepend to drop the new notes at the top of an
existing changelog (below its # title), or --append to add them at the bottom:
ledger generate --since-last-tag -o CHANGELOG.md --prependA fresh file is created if it doesn't exist yet. (--append/--prepend need --output-file and
markdown output.)
Providers & keys
| Provider | Env var | Notes |
| --- | --- | --- |
| OpenAI | OPENAI_API_KEY | |
| Anthropic | ANTHROPIC_API_KEY | |
| Gemini | GEMINI_API_KEY | |
| OpenRouter | OPENROUTER_API_KEY | |
| Ollama | — | local; set OLLAMA_BASE_URL to override http://localhost:11434/v1 |
| Bedrock | BEDROCK_API_KEY or AWS IAM (AWS_REGION, AWS_ACCESS_KEY_ID, …) | AWS_REGION defaults to us-east-1. Reasoning models (e.g. openai.gpt-oss-*) are supported. |
| OpenAI-compatible | LEDGER_API_KEY (or your own via apiKeyEnv) | Any other service — set baseURL. See below. |
Use any OpenAI-compatible provider
Not locked to the six above. Most inference services — Groq, Together, Fireworks, DeepSeek,
Mistral, xAI, Perplexity, Azure OpenAI, or a self-hosted vLLM / LiteLLM / LocalAI server —
expose the OpenAI Chat Completions API. Point ledger at any of them with the openai-compatible
provider: give it a base URL, a model, and (usually) an API key.
export LEDGER_API_KEY=gsk_...
ledger generate --since-last-tag \
--provider openai-compatible \
--base-url https://api.groq.com/openai/v1 \
--model llama-3.3-70b-versatileOr in ledger.config.yaml:
provider: openai-compatible
baseURL: https://api.groq.com/openai/v1 # or LEDGER_BASE_URL / --base-url
model: llama-3.3-70b-versatile
# apiKeyEnv: GROQ_API_KEY # optional — read the key from a different env var (default LEDGER_API_KEY)
# headers: # optional — extra request headers
# X-Title: my-appThe key is read from LEDGER_API_KEY unless you name another var via apiKeyEnv. A purely local
server that needs no key works without one.
Use in CI
ledger auto-detects the runner (GitHub Actions, GitLab CI, Jenkins, CircleCI, Buildkite,
or a generic CI) and, when you pass no range flag, derives one: a tag build uses
previous tag → this tag; a PR/MR build uses base branch → HEAD. On GitHub it also
appends the notes to the run's step summary.
Two requirements on every runner: full git history + tags (CI often shallow-clones — see below) and a provider API key from your secret store.
The recommended setup: commit ledger.config.yaml, and let CI just run ledger generate.
Because your provider and model live in that file (see Configuration), the CI job
carries no ledger settings at all — it only supplies the API key as a secret. Switching providers or
models later is a one-line edit to the committed file, with nothing to change across your pipelines.
The snippets below assume a ledger.config.yaml is checked in.
GitHub Actions (composite action)
The action is optional — it's just a convenience wrapper around
npx @anishhs/ledger. If you prefer not to depend on it, skip straight to the plainnpxstep below — it does exactly the same thing. Everythingledgerdoes (CI detection, range derivation, step summary) lives in the CLI, not the action.
name: Release notes
on:
push:
tags: ['v*']
jobs:
notes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags so the range can be derived
- uses: anishhs-gh/ledger@v1
with:
api-key: ${{ secrets.OPENAI_API_KEY }} # the only secret; provider/model come from ledger.config.yaml
output-file: RELEASE_NOTES.mdPin the action to the floating
anishhs-gh/ledger@v1tag (or a specific release, e.g.anishhs-gh/[email protected]) to control when you pick up updates.
Action inputs — all optional; anything you omit is read from the committed ledger.config.yaml.
Set them here only to override the file for this workflow:
| Input | Default | Description |
| --- | --- | --- |
| api-key | — | Provider key; mapped to the correct *_API_KEY var, or LEDGER_API_KEY for openai-compatible (pass a secret) |
| provider | from config | openai | anthropic | gemini | openrouter | ollama | bedrock | openai-compatible |
| model | from config | Model name |
| base-url | from config | OpenAI-compatible API base URL (for openai-compatible) |
| audience | engineering | engineering | business | qa |
| output | markdown | markdown | json |
| output-file | — | Write notes to this file |
| args | — | Extra raw flags, e.g. --since-last-tag --fail-on-empty |
| package | @anishhs/ledger | npm spec to run (pin a version if you like, e.g. @anishhs/[email protected]) |
Output: file — the path written (equals output-file).
Prefer raw npx (no action dependency)? That works too — still just the key plus the committed config:
- run: npx @anishhs/ledger generate -o RELEASE_NOTES.md
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}Attach the notes to a GitHub Release
The most common end-to-end flow: on a version tag, generate the notes and publish them as the
release body. Generation is best-effort — if the AI call fails, fall back to GitHub's
auto-generated notes so a release is never blocked. (This is exactly what ledger's own
publish.yml does.)
name: Release
on:
push:
tags: ['v*']
permissions:
contents: write # required to create the release
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history + tags
- name: Generate notes (best-effort)
id: notes
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # provider/model from ledger.config.yaml
run: |
set -uo pipefail
file="$RUNNER_TEMP/NOTES.md"
if npx --yes @anishhs/ledger generate --since-last-tag -o "$file" --quiet && [ -s "$file" ]; then
echo "file=$file" >> "$GITHUB_OUTPUT"
else
echo "::warning::ledger failed — falling back to GitHub auto-generated notes."
fi
- name: Create the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ -n "${{ steps.notes.outputs.file }}" ]; then
gh release create "${{ github.ref_name }}" --notes-file "${{ steps.notes.outputs.file }}"
else
gh release create "${{ github.ref_name }}" --generate-notes
fiThe two keys to the fallback: set -uo pipefail (not -e) so a failed generation doesn't
abort the step, and the && [ -s "$file" ] check so an empty file also triggers the fallback.
GitLab CI
release-notes:
image: node:20
rules:
- if: $CI_COMMIT_TAG
variables:
GIT_DEPTH: "0" # full history + tags
script:
- npx --yes @anishhs/ledger generate -o RELEASE_NOTES.md # provider/model from ledger.config.yaml
artifacts:
paths: [RELEASE_NOTES.md]Set OPENAI_API_KEY (or your provider's key) as a masked CI/CD variable.
Jenkins
environment { OPENAI_API_KEY = credentials('openai-api-key') }
steps {
sh 'npx --yes @anishhs/ledger generate -o RELEASE_NOTES.md' // provider/model from ledger.config.yaml
}Any other runner
git fetch --tags --unshallow || true # ensure history + tags
export OPENAI_API_KEY=... # or your provider's key
npx @anishhs/ledger generate --since-last-tag -o RELEASE_NOTES.md # config from ledger.config.yamlCopy-paste templates for each platform live in examples/ — each reads its
settings from a committed ledger.config.yaml.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Success (also empty range, unless --fail-on-empty) |
| 1 | Runtime/provider error |
| 2 | Usage or configuration error (missing/unknown provider, bad ref, auth failure) |
| 3 | No changes found and --fail-on-empty was set |
Troubleshooting
| Symptom | Cause & fix |
| --- | --- |
| Empty notes / "no changes", or a git "unknown revision" error on --from / --since-last-tag | CI did a shallow clone, so tags and history aren't present. Fetch them: actions/checkout@v4 with fetch-depth: 0, GitLab GIT_DEPTH: "0", or git fetch --tags --unshallow. |
| Authentication failed … (exit 2) | The provider's key env var isn't set, or is wrong for the selected provider. Check the Providers & keys table — e.g. anthropic reads ANTHROPIC_API_KEY, not OPENAI_API_KEY. |
| Rate limited … (exit 1) | ledger already retries with backoff and honours the provider's Retry-After. If it still exhausts, lower run frequency, raise maxRetries, or switch to a higher-tier key. |
| Notes look truncated / empty response | The model hit the token cap — raise --max-tokens (models with reasoning on by default, like Claude Sonnet 5, spend part of the budget thinking). |
| Request too large / HTTP 413 | The prompt plus the reserved --max-tokens output exceeded the provider's per-request or per-minute token limit — common on free tiers (e.g. Groq's 12k tokens/minute). Narrow the range, lower --max-tokens, set a smaller maxDiffLines, or use a higher-tier key / larger-limit provider. Run --dry-run first — it now prints the total tokens requested (prompt + reserved output). |
| openai-compatible errors about a missing baseURL/model | That provider needs both a baseURL and a model — set them in ledger.config.yaml, via LEDGER_BASE_URL / --base-url, or --model. |
| Model-not-found (404 / invalid model) | The model string must be exactly what your provider expects (e.g. Bedrock model IDs like openai.gpt-oss-20b-1:0). Check the provider's model list. |
Tip: run with --dry-run first — it assembles the context and prints the token estimate without
calling the AI (and without spending anything), so you can confirm the range and size are right.
Development
npm install
npm run typecheck
npm run lint
npm test # vitest
npm run build # tsup → dist/cli.jsContributing
Bug reports and focused PRs are welcome. See CONTRIBUTING.md for the dev setup and the gitflow branch/PR workflow.
Changelog
See CHANGELOG.md.
Author
Anish Shekh · Website · GitHub · LinkedIn
License
MIT © Anish Shekh
