Changelog generation¶
CHANGELOG.md is generator-owned. Manual edits are a review-blocker;
every change is overwritten on the next run. Maintain cliff.toml to
influence output, not the file directly.
Tool¶
git-cliff generates CHANGELOG.md from
conventional commits between release tags. Per the nix-run-pinned
invariant, git-cliff is invoked only via the flake-pinned package — not
an unpinned nix run nixpkgs#git-cliff. CI uses:
The same command works locally for manual rebuilds.
When the job runs¶
The changelog job runs as part of release-on-bump.yml, triggered on
every push to main that changes linpeas-pin.json — provided the
release job succeeded and the pin's release tag does not already
exist (tag-exists == 'false'). It also runs on workflow_dispatch
when force-republish: true is passed, which overrides the tag-exists
gate; a backfill-tag dispatch skips it (see Recovery below). A changelog
failure does not
block image publication — the job depends only on release
(needs: [release]), so it runs in parallel with the image and manifest
jobs and a transient cliff error cannot prevent the OCI image from
shipping.
App identity¶
The changelog commit is pushed using the same linpeas-flake-bumper
GitHub App identity that update-linpeas.yml's push-and-merge job
uses: vars.BUMP_APP_CLIENT_ID + secrets.BUMP_APP_PRIVATE_KEY. No new secret
surface is introduced.
cliff.toml load-bearing rules¶
Six settings in cliff.toml must not be changed without understanding
their effect:
tag_pattern¶
This regex must exactly match the canonical pin-shape regex enforced
across the codebase. Drift causes git-cliff to see no tags and generate
an empty changelog. scripts/check-cliff-tag-pattern.sh enforces
parity as the required cliff-tag-pattern CI job. The cross-layer
parity set it joins is listed in
architecture/auto-update.md,
generated from the tree.
docs: update changelog skip rule¶
The changelog commit itself carries the subject
docs: update changelog for <VERSION>, and the skip rule is anchored to
the ^docs: update changelog prefix so both that commit and a manual
regeneration are suppressed. Without this skip, each run would include the previous
run's commit in the next release's entry — a self-reference loop that
inflates the changelog with administrative noise.
PR-merge entry filter¶
{ message = "^Merge ", skip = true },
{ message = "^feat.*\\[#[0-9]+\\]", group = "Features" },
# … one rule per type, each requiring the [#N] link …
{ message = ".*", skip = true },
protect-main forces every change through a pull request, so each
landed change is a merge commit whose subject is the PR title plus
(#N) — rewritten to a [#N] link by the preprocessor below. Every
group rule requires that link, so only the merge commits are kept; the
trailing catch-all skips the branch commits the merge brought in.
Without this, both the merge commit (linked) and its branch commits
(unlinked) would render, producing duplicate entries for every PR. The
leading ^Merge rule skips any commit that uses GitHub's default
Merge pull request … subject. Breaking changes are recognized by the
! type suffix, per the repo's commit convention.
PR-number preprocessor¶
{ pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://github.com/rvenutolo/linPEAS-flake/pull/${1}))" },
Git-cliff runs the preprocessors before any commit parser, so the
[#N] link the group rules require is already in place. Commit subjects
that include (#NNN) — the format GitHub inserts into merge-commit
subjects — are rewritten to a clickable [#NNN](…) link in the
rendered changelog. This preprocessor is the sole source of PR
links: rendering a second link (e.g. a body-template pr_number block)
would double-link every entry as ([#N](…)) ([#N](…)).
scorecard-count preprocessor¶
Rewrites one mislabeled historical commit subject so the rendered
changelog states the allowlist size that was in force when that commit
landed — deliberately not the current count, since the changelog records
what shipped. The
link-duplication guard below asserts on every
PR that no 15-check allowlist survives regeneration, so removing this
preprocessor fails the required changelog-links job; the replacement
value itself is not pinned by any check.
spelling preprocessor¶
Corrects one merged commit subject whose "unparsable" carries an extra
letter. Merge subjects are frozen in signed history and this one renders
into CHANGELOG.md, so render time is the only place the word can be
fixed. _typos.toml does not exclude CHANGELOG.md and typos is a
required check, so removing this preprocessor turns that check red on
the next regeneration. The pattern uses a character class rather than
spelling the misspelled word because the same spell-check scans
cliff.toml itself.
Link-duplication guard¶
scripts/check-changelog-links.sh regenerates the changelog from the
flake-pinned .#git-cliff into a temp file and asserts two invariants
on the output: zero duplicate identical adjacent PR links (a commit
citing two distinct PRs is legitimate and passes), and that the
scorecard-count preprocessor still applies (no 15-check allowlist
survives). CHANGELOG.md is generator-owned and excluded from treefmt
and markdownlint, so this check is the only guard against a malformed
regeneration. It runs offline — git-cliff parses the PR number from the
(#N) subject suffix, so no token is needed — as the required
changelog-links CI job on every PR.
Exit 1 means an assertion failed. Exit 2 means the check could not run —
git-cliff failed, nix is absent, or cliff.toml is missing — so there was
no output to assert on. Chase the generator or its config, not the link
template.
Freshness guard¶
scripts/check-changelog-fresh.sh regenerates the changelog from the
flake-pinned .#git-cliff and compares its released sections (from
the first ## [<tag>] header onward) against the committed CHANGELOG.md.
A release that ships without its changelog update landing — or a manual edit
to a released section — is caught rather than accruing silently.
Only released sections are compared: the ## Unreleased section
legitimately changes with every merged commit, so diffing it would force a
changelog regen on every PR. When it exits 1, regenerate and commit:
Exit 2 means something else entirely: the check could not run, so freshness
was never evaluated. Either the generator itself failed, or an input it
reads — CHANGELOG.md, cliff.toml, nix on PATH — was absent. Read the
error on stderr and fix the generator, its config, or the environment —
regenerating is the wrong move, because the committed changelog was never
the subject of the failure. Both changelog checks separate the two codes
for exactly this reason.
Release-window exclusion¶
The release tag is created before the changelog commit that describes it, so
between the two there is a window in which the regenerated changelog carries a
released section that no committed file could yet contain. Comparing it there
reports staleness nothing can fix: main goes red, and because
changelog-links is a required check, every open PR based before the changelog
commit is blocked on it.
A release tag is therefore compared only when its commit is an ancestor of the
most recent commit that touched CHANGELOG.md — only when the changelog was
written at a point where that tag already existed. Newer tags are excluded from
both sides of the comparison. The condition is history-relative rather than
time-based: a wall-clock grace period would either mask a genuinely dropped
changelog or still flake, depending on how it was tuned.
This accepts one blind spot: only tags predating the last CHANGELOG.md commit
are covered. A release whose changelog never lands stays uncovered until some
later changelog commit exists, and two releases stacking up inside the window
would exclude both. That is tolerable because a dropped changelog job fails
loudly through the release-on-bump notify path — this check is the backstop,
not the primary signal.
The exclusion depends on an ordering property of release-on-bump.yml, and
changing that workflow can silently weaken this guard. The release job creates
the tag on the triggering commit (gh release create --target), and the
changelog job later cuts its branch from main. The tag is therefore always an
ancestor of the changelog commit, so on the changelog pull request's own merge
ref the tag is compared rather than excluded — the exclusion suppresses the
window for unrelated pull requests while never disabling the check on the one
pull request that has to land the section.
Reorder those two steps — tag after the changelog commit, or cut the changelog
branch from the tag instead of from main — and the tag stops being an
ancestor. The exclusion would then swallow the very case this guard exists to
catch, on every release, with no test going red. Treat that ordering as part of
this guard's contract rather than an incidental detail of the release workflow.
It runs offline (like the link guard) as part of the required
changelog-links CI job, which checks out with fetch-depth: 0 so every
release tag is visible to git-cliff.
End-to-end sequence¶
The changelog job in release-on-bump.yml follows this logical
sequence (its harden-runner egress step comes first, as in every job):
- Check out the repo with
fetch-depth: 0(full history required for git-cliff to walk all tags), then install Nix via./.github/actions/setup-nix. - Mint a short-lived App installation token using
vars.BUMP_APP_CLIENT_ID+secrets.BUMP_APP_PRIVATE_KEY. - Re-assert the canonical pin shape on
VERSIONat the credential boundary, failing the job on a malformed value. - Run
nix shell .#git-cliff --command git-cliff --config cliff.toml --output CHANGELOG.md. - Detect whether
CHANGELOG.mdchanged (a no-op day — identical pin — produces no diff). - If changed, render the latest section with
git-cliff --latest --strip headerinto.release-notes.mdfor the release body. - If changed, create a
chore/changelog-${VERSION}branch frommain— reusing and force-rebasing it if an open one for that version already exists — and commitCHANGELOG.mdvia RESTPUT /contentsas the App identity (GitHub web-flow-signs the commit). - Open a PR against
mainand enable auto-merge (gh pr merge --auto --merge --delete-branch), then block until the PR merges: the wait is bounded, and a closed PR or a stall fails the job sonotifyfiles an issue. The PR lands once theprotect-mainrequired status checks pass. - If changed, patch the just-published release's body: build
.release-body.mdfrom.release-notes.mdplus the upstream tracking footer and apply it withgh release edit "${VERSION}" --notes-file .release-body.md.
The PR detour is mandatory: protect-main has bypass_actors == []
and a pull_request rule, so direct PUT /contents to branch=main
returns HTTP 409 "Changes must be made through a pull request". The
flow mirrors update-linpeas.yml push-and-merge, with one addition:
the changelog job blocks until the PR merges.
Recovery procedures¶
Missed entry¶
A missed changelog entry (changelog job failed or was skipped) can be
recovered by triggering release-on-bump.yml via workflow_dispatch
with force-republish: true. The input is required: the release tag
already exists on every recovery path, so the job's
tag-exists == 'false' || force-republish gate skips it on a bare
dispatch. With the input set, the job re-runs git-cliff over the full
tag history and commits the corrected file — unless a
chore/changelog-<version> PR for that version was already closed
unmerged, in which case the commit step fails by design (see
auto-merge-decline-gate);
an open changelog branch is reused and force-rebased instead. (A
merged changelog PR means the entry already landed, so regeneration
produces no diff and the commit step is skipped before that gate.)
File lost entirely¶
If CHANGELOG.md is deleted or corrupted, rebuild it locally:
Commit the result with subject docs: update changelog so the skip
rule suppresses it from future changelog runs. Then push and verify.
Backfill dispatch¶
A backfill-tag dispatch republishes sidecars and SBOMs for a release
whose tag already exists, so the tag-exists gate skips the changelog job
by design — the entry for that tag is already in the file. There is
nothing to recover; use force-republish: true only when the entry is
genuinely missing.