Skip to content

Commit cbb16d0

Browse files
committed
Write a backported change up with the pull request it came from
The Backports section explains what `-x` buys — that the changelog reaches the pull request the change was written and reviewed in — but not what the entry ends up looking like, so the annotation naming the backport was rediscovered while preparing 4.1.3, and got it wrong first. `gem:changelog` now prints the form rather than leaving it to be remembered. It already had both halves: `changelog_origins` resolves the `-x` trailer to where the change was written, and the `fallbacks` path already looked the cherry-pick's own pull request up, calling it in passing "at least the backport that brought it here". Both ends are now asked for together — one query rather than two, since the second was only ever a retry — and the cherry-pick's becomes the entry's second link instead of being discarded. A backport also stops appearing beside the entries it carried. Its merge commit is in the history too and has no `-x` trailer to resolve, so it read as an ordinary commit of its own pull request: dispatching this on the 4.1.3 cycle listed #3076 as a change of its own, above the entry it annotates. A backport is how a change arrived rather than a change, so the ones that annotate something are dropped from the list. On the development line there are no origins, so entries keep the single link they have today. A backport carrying several changes at once falls out of the same handling: each entry names its own origin and shares the one backport link, which is what 4.0.3 should have looked like. docs/release.md loses the procedure it carried for assembling the changelog by hand, which existed because the task could not run from a Claude Code on the web session. `changelog.yml` runs it on a runner now, so the section says to dispatch that instead — and the traps it documented, all of them about hand-rolling the match, go with it. What replaces them is the one thing the workflow adds: the ref picks the copy of the task as well as the history, so a release branch needs the file on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QAgPhpLrU9Z4bENCR9RUub
1 parent f8afa26 commit cbb16d0

2 files changed

Lines changed: 78 additions & 78 deletions

File tree

Rakefile

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -603,30 +603,41 @@ end
603603
#
604604
def changelog_pull_requests(commits, skip_labels: CHANGELOG_SKIP_LABELS)
605605
origins = changelog_origins(commits)
606-
found = changelog_associated_pull_requests(commits.map { |commit| origins[commit] || commit })
607606

608-
# An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
609-
# default branch without a pull request -- falls back to the commit in this history, which is
610-
# at least the backport that brought it here.
611-
fallbacks = commits.select { |commit| origins[commit] && found.fetch(origins[commit], []).empty? }
612-
found.update(changelog_associated_pull_requests(fallbacks)) unless fallbacks.empty?
607+
# Both ends of each commit, in one query: the origin a cherry-pick records, which is where the
608+
# change was written and reviewed, and the commit as it sits in this history, whose pull request
609+
# is the backport that brought it here. On the development line there are no origins and the
610+
# second end is the only one.
611+
found = changelog_associated_pull_requests(commits + origins.values)
613612

614613
pull_requests = {}
615614
skipped = {}
616615

617616
commits.each do |commit|
618-
prs = found.fetch(origins[commit] || commit, [])
619-
prs = found.fetch(commit, []) if prs.empty?
617+
carriers = found.fetch(commit, [])
618+
prs = origins[commit] ? found.fetch(origins[commit], []) : []
619+
620+
# An origin that leads nowhere -- a commit cherry-picked from a fork, or one that went to the
621+
# default branch without a pull request -- falls back to the commit in this history, which is
622+
# at least the backport that brought it here. It is the entry itself then, not an annotation.
623+
backports = prs.empty? ? [] : carriers
624+
prs = carriers if prs.empty?
620625

621626
prs.each do |pr|
622-
if (pr[:labels] & skip_labels).empty?
623-
pull_requests[pr[:number]] ||= pr
624-
else
625-
skipped[pr[:number]] ||= pr
626-
end
627+
bucket = (pr[:labels] & skip_labels).empty? ? pull_requests : skipped
628+
entry = (bucket[pr[:number]] ||= pr.merge(backports: []))
629+
entry[:backports] |= backports.map { |backport| backport.slice(:number, :url) }
627630
end
628631
end
629632

633+
# A backport is how a change reached this line, not a change of its own, so it belongs in the
634+
# entries it carried rather than beside them. It can still arrive as one: the merge commit of the
635+
# backport is in the history too, and carries no `-x` trailer to resolve, so it looks like an
636+
# ordinary commit of its own pull request. Drop the ones that annotate something.
637+
carried = (pull_requests.each_value.to_a + skipped.each_value.to_a).flat_map { |pr| pr[:backports] }
638+
carried = carried.map { |backport| backport[:number] }.uniq
639+
carried.each { |number| pull_requests.delete(number) }
640+
630641
[pull_requests.values, skipped.values]
631642
end
632643

@@ -675,6 +686,19 @@ def warn_skipped_pull_requests(skipped, skip_labels)
675686
$stderr.puts " (⏭️ Skipped #{skipped.size} pull request(s) labeled #{skip_labels.map { |label| "`#{label}`" }.join(" or ")}: #{numbers.join(", ")})"
676687
end
677688

689+
# The links of one changelog entry: the pull request the change was written in, and on a release
690+
# branch the backport that carried it onto the line.
691+
#
692+
# The second link is what keeps a backported entry from reading as a mistake. Its first link is a
693+
# pull request against the development line, so the same link is listed again when that line ships,
694+
# and nothing else tells the two occurrences apart.
695+
#
696+
def changelog_links(pr)
697+
links = ["[##{pr[:number]}](#{pr[:url]})"]
698+
links.concat(pr[:backports].to_a.map { |backport| "Backported in [##{backport[:number]}](#{backport[:url]})" })
699+
links.join(", ")
700+
end
701+
678702
# Prints the changelog template listing the pull requests merged between `from` and `HEAD`.
679703
#
680704
# The changelog goes to STDOUT and everything else goes to STDERR, so that the output can be
@@ -696,7 +720,7 @@ def print_changelog(from, paths: [], skip_labels: CHANGELOG_SKIP_LABELS)
696720
else
697721
$stderr.puts
698722
pull_requests.each do |pr|
699-
puts "* #{pr[:title]} ([##{pr[:number]}](#{pr[:url]}))"
723+
puts "* #{pr[:title]} (#{changelog_links(pr)})"
700724
end
701725
$stdout.flush
702726
end

docs/release.md

Lines changed: 40 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,8 @@ Sort the list into the sections below. `rake gem:changelog:json` prints the same
7878
the changed files, labels, and body of each, which is what the sorting is based on.
7979

8080
Both tasks reach GitHub through `gh`, which a Claude Code on the web session cannot do. See
81-
[Assembling the changelog without `gh`](#assembling-the-changelog-without-gh) for how the same list
82-
is produced there.
81+
[Assembling the changelog without `gh`](#assembling-the-changelog-without-gh), which runs them on a
82+
runner instead.
8383

8484
```markdown
8585
## X.Y.Z (YYYY-MM-DD)
@@ -199,6 +199,11 @@ opposite places:
199199
cherry-picked from `master` — see [Backports](#backports). The `aaa-` prefix carries no meaning
200200
beyond sorting the release branches to the top of the branch list.
201201

202+
The branch carries its own release tooling, since that is read from the ref rather than from
203+
`master`: the `gem:` tasks, and `changelog.yml` for the changelog. Branching from `master`
204+
brings both along; what needs watching is a later change to either, which reaches this line
205+
only if it is cherry-picked here too.
206+
202207
2. **Bump `master`** to `X.(Y+1).0.dev`, in a pull request with `Gemfile.lock` regenerated and
203208
labeled `skip-changelog` like the release pull request itself. `4.1` was started exactly this
204209
way: `aaa-4.0.x` was branched at the commit before `Start 4.1 development`, which set
@@ -231,80 +236,51 @@ it, the only pull request a backported commit is associated with is the one that
231236
backport, which says nothing about the change and is the same for every commit it brought over —
232237
that is why the 4.0.3 changelog credits its three entries to the same pull request.
233238

234-
## Assembling the changelog without `gh`
235-
236-
A release can be prepared from a Claude Code on the web session, with one exception:
237-
`gem:changelog` and `gem:changelog:json` cannot run there. Both go through `gh`, and in such a
238-
session `api.github.com` is blocked at the agent proxy for anything the shell does. `gh` is not
239-
installed, installing it does not help, and rewriting the tasks against REST or Net::HTTP would be
240-
blocked the same way — the refusal is keyed on the session rather than on the client:
239+
The entry names that pull request and adds the one that carried the backport:
241240

242-
```console
243-
$ curl -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/ruby/rbs
244-
{"message":"GitHub access is not enabled for this session. ..."} # HTTP 403
241+
```markdown
242+
* {title} ([#{original}](https://github.com/ruby/rbs/pull/{original}), Backported in [#{backport}](https://github.com/ruby/rbs/pull/{backport}))
245243
```
246244

247-
Nothing else in the release is affected. `gem:check_release` and `gem:tag` read git and the working
248-
tree, and `gem:gh_release` runs on a runner, where `gh` and `github.token` both work.
249-
250-
What the session does have is the GitHub MCP server, which reaches the API through its own
251-
credentials. The changelog is assembled with its tools, in the three steps the rake task takes.
245+
`gem:changelog` prints this form on its own, from the same `-x` trailer: the origin it resolves is
246+
the first link, and the pull request of the cherry-pick in front of it is the second. On the
247+
development line nothing is a cherry-pick, so entries there keep the plain single link.
252248

253-
**1. Where the changelog starts.** The rule is `changelog_base`: a prerelease starts from the
254-
latest tag, a release proper skips the prerelease tags. Tags are not fetched by default.
249+
The second link is what keeps the entry from reading as a mistake. The original pull request is
250+
against `master`, so it is listed again when the development line ships — and with nothing to tell
251+
the two apart, the same link under two version headings looks like a change written into the wrong
252+
section. #1923 is the pair to look at: plain under 3.6.0.pre.1, annotated under 3.5.2, which
253+
backported it.
255254

256-
```console
257-
$ git fetch origin --tags
258-
$ git describe --tags --match 'v*' --abbrev=0 --exclude 'v*.pre*' --exclude 'v*.dev*'
259-
v4.1.1
260-
```
261-
262-
Drop the two `--exclude` flags for a prerelease, which starts at the latest tag whatever it is.
255+
## Assembling the changelog without `gh`
263256

264-
**2. The commits.**
257+
`gem:changelog` and `gem:changelog:json` reach GitHub through `gh`, so they cannot run from a
258+
Claude Code on the web session. `api.github.com` refuses anything the shell does there, and the
259+
refusal is keyed on the session rather than on the client, so installing `gh` does not help:
265260

266261
```console
267-
$ git log --format=%H v4.1.1..HEAD
262+
$ curl -s -o /dev/null -w '%{http_code}' https://api.github.com/repos/ruby/rbs
263+
403
268264
```
269265

270-
**3. The pull requests they came from.** List the merged pull requests with `list_pull_requests`
271-
(`base: master`, `state: closed`, `sort: updated`, `direction: desc`, and `fields: number, title,
272-
labels, merged_at, head`), paging back until `merged_at` predates the base tag, and keep the ones
273-
whose `head.sha` appears in the commit list from step 2. That intersection is what the task's
274-
GraphQL `associatedPullRequests` query answers, reached from the other side.
266+
Nothing else about the release is affected. `gem:check_release` and `gem:tag` read git and the
267+
working tree, `gem:gh_release` runs on a runner, and git itself reaches github.com normally —
268+
clone, fetch and push all work.
275269

276-
Then drop the pull requests labeled `skip-changelog` and format the rest newest first, which is the
277-
order of step 2:
270+
So the task is run where it does work. Dispatch
271+
[`changelog.yml`](../.github/workflows/changelog.yml), which runs it on a runner, and read the list
272+
from the run summary, the log, or the `changelog` artifact.
278273

279-
```markdown
280-
* {title} ([#{number}](https://github.com/ruby/rbs/pull/{number}))
281-
```
282-
283-
Sorting them into sections needs the changed files, which `gem:changelog:json` would have supplied:
284-
`pull_request_read` with `get_files` per pull request, or `get` for the body.
285-
286-
Four things about that matching, the first of which is a trap:
287-
288-
- **Do not read the numbers from `Merge pull request #N` commit subjects.** It looks like it works
289-
on this repository, and it silently loses pull requests. Applying the path filter the task uses
290-
(`git log --full-history --simplify-merges -- . ':(exclude)rust'`) drops the merge commits while
291-
keeping the commits they merged, so on the 4.1.2 cycle five of the eight numbers disappeared with
292-
them. A squashed or rebased pull request never writes that subject at all. Matching `head.sha`
293-
has neither failure mode.
294-
- `head.sha` is in the history because this repository merges pull requests with merge commits. A
295-
squashed or rebased one would need its `merge_commit_sha`, which the listing does not carry.
296-
- The listing reports `merged: false` for pull requests that are merged — the field is not
297-
populated by that endpoint. Read `merged_at` instead.
298-
- On a release branch the commits are cherry-picks, so resolve the `(cherry picked from commit
299-
<sha>)` trailer first and match the recorded origin, as `changelog_origins` does. Matching the
300-
cherry-pick itself attributes every backport to the pull request that carried it.
301-
302-
Pull requests confined to `rust/` are left out too, which the task does by filtering the commits in
303-
step 2 with `-- . ':(exclude)rust'`. Leave step 2 unfiltered here and drop those pull requests by
304-
their `get_files` instead. The filter decides which commits are listed, and a pull request is found
305-
by one specific commit — its head — so a pull request whose last commit happens to touch only
306-
`rust/` would lose that head and disappear even though the rest of it belongs in the changelog.
307-
Matching against every commit and filtering afterwards cannot go wrong that way.
274+
| Input | Value |
275+
| --- | --- |
276+
| The ref selector | The branch the changelog is for: `aaa-X.Y.x` for a patch release, `master` otherwise |
277+
| `version` | Where the changelog starts, when that should not follow `RBS::VERSION`. It names the release before the one being written, so `4.1.2` produces the 4.1.3 changelog |
278+
| `format` | `list` for the template, `json` for the pull request details the sections are sorted from |
279+
280+
The ref is not incidental the way it is for `release-gems.yml`: it picks the history being
281+
described *and* the copy of the task that describes it. So a release branch needs `changelog.yml`
282+
on it, the same way it needs the release tasks — dispatching on a ref without the file fails with
283+
`Workflow does not have 'workflow_dispatch' trigger`, since the trigger is read from the ref.
308284

309285
## Notes
310286

0 commit comments

Comments
 (0)