1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
name: Changelog
# CHANGELOG.md is generated from the commit history by
# scripts/generate-changelog.ts. It cannot be hand-maintained without drifting
# from the tags, so the only job here is to keep the committed copy equal to
# what the generator produces.
#
# Why a pull request and not a direct commit to main:
# - Generated content still deserves a reviewable diff. `git log` is the
# source of truth, so the interesting question is not "is this text right"
# but "did the generator mis-group something", and a PR is where that gets
# noticed.
# - A direct push to main is not available if the branch is protected, and
# adding a bypass to work around that would be worse than a PR.
# - peter-evans/create-pull-request pushes a throwaway branch and force-pushes
# *that* branch (never main), which is the safe way to keep a generated file
# current. This is the same mechanism .github/workflows/discovery.yml
# already uses for the generated Discovery index, so there is one pattern in
# this repository rather than two.
# - Consequence: main is never force-pushed, and no job here needs
# --force-with-lease at all.
#
# The script is pure and idempotent: the same history always renders the same
# bytes, so a run that finds no change opens no PR and this workflow stays
# quiet.
on:
workflow_dispatch:
inputs:
dry_run:
description: "Generate and show the diff, but do not open a pull request"
type: boolean
default: false
verify:
description: "Also run the release-only sync check (used by the release watcher)"
type: boolean
default: false
push:
branches:
release:
types:
permissions:
contents: read
concurrency:
# Static, NOT keyed on the ref: every trigger pushes the SAME throwaway
# branch (`chore/changelog-regenerate`), so a push-to-main run and a release
# run must serialise. Keying on `github.ref` put them in two different
# groups and let them force-push the same branch concurrently.
group: changelog
# Serialised, not cancelled: a release-triggered regeneration must finish
# even if another push lands behind it. The job is ~2 minutes, so queueing
# is cheaper than losing the run.
cancel-in-progress: false
jobs:
regenerate:
name: regenerate changelog
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
permissions:
# Push the regeneration branch; never main.
contents: write
pull-requests: write
# gh workflow run below — waking the merge poller.
actions: write
steps:
- uses: actions/checkout@v7
timeout-minutes: 5
with:
# The generator diffs consecutive tags, so it needs the full history
# and every tag. A shallow clone would silently render "no changes"
# for older releases.
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@v7
timeout-minutes: 5
with:
# Matches the Node version the other script-running workflow uses.
node-version: "26"
- name: Generate CHANGELOG.md
id: gen
timeout-minutes: 5
run: |
set -euo pipefail
# pipefail so a non-zero exit from the generator (for example the
# refuse-to-overwrite-a-hand-written-changelog guard) fails this step
# instead of being swallowed by `tee`.
node scripts/generate-changelog.ts | tee changelog-gen.log
# The script prints machine-readable `key=value` lines; reuse them
# instead of re-deriving the newest tag in shell.
TAG=$(grep -m1 '^changelog:newest-tag=' changelog-gen.log | cut -d= -f2- || true)
echo "newest_tag=${TAG:-unreleased}" >> "$GITHUB_OUTPUT"
# Heredoc form: a single-line `echo` cannot carry a multi-line value.
{
echo 'summary<<CHANGELOG_GEN_EOF'
cat changelog-gen.log
echo 'CHANGELOG_GEN_EOF'
} >> "$GITHUB_OUTPUT"
# `git status --porcelain` rather than `git diff --quiet`, so a
# first-time generated file (untracked) counts as a change too.
# Both outputs are named: the generator writes CHANGELOG.md *and*
# site/src/data/changelog.json, and the changelog page renders the
# latter. Detecting only the Markdown file meant a run that moved
# the JSON alone opened no PR and left the site stale.
if [ -z "$(git status --porcelain -- CHANGELOG.md site/src/data/changelog.json)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "CHANGELOG.md and site/src/data/changelog.json are in sync."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Changelog output is out of date:"
git --no-pager diff --stat -- CHANGELOG.md site/src/data/changelog.json
fi
- name: Summarise
if: always()
run: |
{
echo "### Changelog generator"
echo
echo '```'
cat changelog-gen.log 2>/dev/null || echo "(generator produced no log)"
echo '```'
if [ "${{ steps.gen.outputs.changed }}" = "true" ]; then
echo
echo "#### Diff"
echo
echo '```diff'
git --no-pager diff -- CHANGELOG.md
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Report (dry run)
if: steps.gen.outputs.changed == 'true' && inputs.dry_run == true
run: |
echo "::notice title=Dry run::CHANGELOG.md is out of date but no pull"
echo "::notice::request was opened, because dry_run was set."
git --no-pager diff -- CHANGELOG.md
exit 1
- name: Open a pull request
id: pr
if: steps.gen.outputs.changed == 'true' && inputs.dry_run != true
uses: peter-evans/create-pull-request@v8
timeout-minutes: 5
with:
author: "debanjanbasu <10209115+debanjanbasu@users.noreply.github.com>"
committer: "debanjanbasu <10209115+debanjanbasu@users.noreply.github.com>"
branch: chore/changelog-regenerate
title: "docs(changelog): regenerate for ${{ steps.gen.outputs.newest_tag }}"
commit-message: "docs(changelog): regenerate for ${{ steps.gen.outputs.newest_tag }}"
# Same triage label every other generated-artifact automation PR
# carries (stats/demo/discovery/benchmark).
labels: dependencies
add-paths: |
CHANGELOG.md
site/src/data/changelog.json
body: |
Automated regeneration of `CHANGELOG.md` by
`.github/workflows/changelog.yml`, run after
`${{ github.event_name }}`.
The file is derived entirely from the git tags and the commits
between them, so this PR is not asking for a wording review — it is
asking for a sanity check that commits landed in the right section.
A commit that is missing from the changelog entirely means its
subject did not parse as a Conventional Commit and it was filed
under **Other**.
Re-running the workflow without new commits produces no diff and
opens no PR.
<details>
<summary>Generator output</summary>
```
${{ steps.gen.outputs.summary }}
```
</details>
- name: Wake the merge poller
# CI on a token-authored PR waits in action_required until the poller
# approves it (GitHub's 2026-06 bot-event gate). Waking the poller now
# instead of waiting for the next cron tick removes the measured 5-7
# minutes of approval latency before any CI compute starts. The poller
# is idempotent, and the cron remains the safety net.
if: steps.pr.outputs.pull-request-number != ''
timeout-minutes: 2
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh workflow run automation-merge.yml --ref main || echo "::warning::poller dispatch failed"
verify:
name: verify changelog is in sync
# Release-only, on purpose. On a `main` push the generator legitimately
# wants to add the new commits to `## [Unreleased]`, so a sync check there
# would fail on every push and train everyone to ignore it. A published
# release is the moment where "the changelog must describe this version" is
# a real invariant.
#
# The `release` event cannot be relied on alone: releases are created with
# GITHUB_TOKEN, and a token-authored release starts no workflows, so this
# job would never run. release-binaries.yml's watcher therefore dispatches
# this workflow with `verify=true`, which is the second arm below. The
# `release` arm is kept for the day a release is published by a human.
#
# Report-only on the section check, deliberately: the generator emits one
# heading per TAG, so the commit a tag points at cannot contain its own
# section — the tag did not exist when that commit was made. The old
# check grepped the tagged tree and therefore failed on every release,
# forever (v0.11.0 and v0.11.1 both went red this way). What is checked
# here instead is main's CHANGELOG.md, and a miss warns: the regeneration
# PR normally merges minutes after the release, so lagging is the expected
# state at release time. The hard, grace-windowed gate is the daily
# verify-delivery check (scripts/verify-delivery.ts), which fails once the
# release leaves the window with the section still missing.
if: (github.event_name == 'release' && github.event.action == 'published') || inputs.verify == true
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
permissions:
# Read-only: this job verifies, it never writes.
contents: read
steps:
- uses: actions/checkout@v7
timeout-minutes: 5
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/setup-node@v7
timeout-minutes: 5
with:
node-version: "26"
- name: Report on the released version's changelog section
timeout-minutes: 5
run: |
set -euo pipefail
# Two checks with different strictness, because they answer
# different questions:
#
# 1. REPORT: does main's CHANGELOG.md carry a section for the version
# being released? main, not the checked-out tree — on the
# `release` event the checkout is the tag, and a tag's commit can
# never contain its own section (see the job comment above). A
# miss warns here; verify-delivery.yml fails on it once the
# release leaves its grace window, which is the enforcement.
VERSION="$(grep -m1 '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1/')"
# A failed fetch falls back to HEAD rather than failing a report:
# HEAD is the best available snapshot, and the enforcement lives in
# verify-delivery.yml.
ref=HEAD
git fetch --no-tags origin main && ref=origin/main || true
# No grep -q: it exits at the first match, git show takes SIGPIPE on
# the still-open pipe, and pipefail would read that as "no match".
if git show "$ref:CHANGELOG.md" | grep -E "^## \[?v?${VERSION}\]?" > /dev/null; then
echo "CHANGELOG.md on $ref documents ${VERSION}."
else
echo "::warning title=CHANGELOG.md has no section for ${VERSION} yet::The section is generated from the tag, so it can only land after the regeneration PR (chore/changelog-regenerate) merges — a snapshot taken minutes after a release is expected to lag. No action needed unless it is still missing tomorrow: verify-delivery.yml fails once the release leaves the 12h grace window."
fi
#
# 2. ADVISORY: the generated file matching the moving HEAD exactly.
# On a repo that regenerates on every push this is true only in
# quiet windows — a bot merge minutes later re-dilutes it — so a
# mismatch is a warning here, not a failure. The regeneration PR
# is the mechanism that converges it; failing the release verify
# on a race nobody can win would just train everyone to ignore it.
if node scripts/generate-changelog.ts --check; then
echo "CHANGELOG.md is byte-identical to the generator output."
else
echo "::warning title=CHANGELOG.md is one regeneration behind::A push landed after the last regeneration; the changelog workflow's PR will converge it. No action needed unless it persists."
fi