Skip to content

Commit ef43c65

Browse files
docs(release): describe a late backport label the way the audit reads it
The README and the package doc both said that a kind/backport label added after the release line moved on "resolves to the newer line". That is what backport.yaml does -- it runs again when the label is added and resolves its targets at that moment -- but not what the audit does. The audit reads every label against the lines as they stood when the PR merged, because nothing it lists records when a label was added. A PR merged on 2026-07-01 and labelled after v1.6.0 published is audited as a release-1.5 candidate while the bot opens its backport against release-1.6. Say that, and what it leads to: the audit expects the change on the merge-time line and reports it MISSING there unless it arrived another way, and it lists the bot's backport under UNLABELLED on the newer line. Nothing pinned the audit's side of this. Move the label-to-line choice out of audit() into candidateLabel, unchanged, and test it against the real timeline: a July PR's kind/backport means release-1.5 and never release-1.6, whenever the label arrived. Assisted-by: LLM Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent 56f2034 commit ef43c65

3 files changed

Lines changed: 79 additions & 13 deletions

File tree

‎cmd/backport-audit/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,10 @@ A `duplicates` record, where `label` is the request the original was audited und
198198

199199
## Limits
200200

201-
A `MISSING` verdict is a prompt to check, not proof of absence. A hand-backport that was squash-merged, reworded, and referenced no original PR is invisible to every evidence layer and reads as `MISSING`; confirm at diff level before redoing the work. A `kind/backport` label added long after merge, once the release line has moved on, resolves to the newer line. And the audit reports *that* something is missing, never *why* — a run of `MISSING` entries clustered in time usually means the bot itself was failing during that window, which is worth checking before cherry-picking them one by one.
201+
A `MISSING` verdict is a prompt to check, not proof of absence. A hand-backport that was squash-merged, reworded, and referenced no original PR is invisible to every evidence layer and reads as `MISSING`; confirm at diff level before redoing the work. And the audit reports *that* something is missing, never *why* — a run of `MISSING` entries clustered in time usually means the bot itself was failing during that window, which is worth checking before cherry-picking them one by one.
202202

203203
The audit reads labels as they stand when it runs. A label added afterwards is seen only by the next run, and until then its PR is either absent or, if someone already backported it, listed as `UNLABELLED` rather than audited, so run it right before the cut rather than once at the start of the day.
204204

205+
A label is always read against the release lines as they stood when the PR merged, however much later the label was added, because nothing the audit lists records when that happened. A PR merged on 2026-07-01 and labelled `kind/backport` a month later, after `v1.6.0` published, is audited as a `release-1.5` candidate. [`backport.yaml`](../../.github/workflows/backport.yaml) runs again when the label is added and resolves its targets at that moment, so it opens the backport against `release-1.6`. When the line has moved on between the merge and the label, the two therefore disagree: the audit expects the change on the merge-time line and reports it `MISSING` there unless it arrived some other way, and lists the bot's backport under `UNLABELLED` on the newer one. `TestCandidateLabel` pins the audit's side of this.
206+
205207
A backport PR that names its original neither by the bot's head branch nor in a `Backport of` phrase links to nothing, so it is invisible to both cross-checks: it is not listed as `UNLABELLED` and does not count towards a `DUPLICATE`. For a labelled original the branch history can still prove the change landed, through an identical commit subject or an `-x` cherry-pick reference. For an unlabelled one those are not read at all, so a backport of an unlabelled PR that only a cherry-pick trailer points to, or nothing does, appears nowhere in the report.

‎cmd/backport-audit/main.go‎

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,11 @@ limitations under the License.
5252
//
5353
// Known limits: a hand-backport that is squash-merged, rewords its subject and
5454
// references no original PR is unprovable from either side and reads as
55-
// MISSING; a `kind/backport` label added after the release line moved on
56-
// resolves to the newer line; and a backport PR that names its original
57-
// neither by the bot's head branch nor in a "Backport of" phrase is invisible
58-
// to the unlabelled and duplicate checks.
55+
// MISSING; a backport label added after the release line moved on is still
56+
// read against the line current at merge, while the bot targets the newer
57+
// one, so the audit expects the change where the bot did not put it; and a
58+
// backport PR that names its original neither by the bot's head branch nor in
59+
// a "Backport of" phrase is invisible to the unlabelled and duplicate checks.
5960
package main
6061

6162
import (
@@ -1013,6 +1014,25 @@ func prCommitRange(mergeCommit string) (string, bool) {
10131014
return mergeCommit + "^.." + mergeCommit, true
10141015
}
10151016

1017+
// candidateLabel is the backport request under which pr is a candidate for the
1018+
// line want, or "" when it is not one.
1019+
//
1020+
// The request is read against the lines as they stood when the PR merged, even
1021+
// for a label added later: nothing the audit lists records when a label was
1022+
// added. backport.yaml, which runs again when a label is added, resolves its
1023+
// targets at that moment instead, so the two disagree for a label added after
1024+
// the line moved on.
1025+
func candidateLabel(pr *mainPR, opened []lineOpen, want line) string {
1026+
current, previous := targetsAt(opened, pr.MergedAt)
1027+
switch {
1028+
case pr.labels[labelCurrent] && current != nil && *current == want:
1029+
return labelCurrent
1030+
case pr.labels[labelPrevious] && previous != nil && *previous == want:
1031+
return labelPrevious
1032+
}
1033+
return ""
1034+
}
1035+
10161036
func (cfg *config) audit(branch string, cands map[int]*mainPR, opened []lineOpen) (*branchReport, error) {
10171037
hist, err := newBranchHistory(cfg.remote, branch)
10181038
if err != nil {
@@ -1038,14 +1058,8 @@ func (cfg *config) audit(branch string, cands map[int]*mainPR, opened []lineOpen
10381058
audited := map[int]string{}
10391059
for _, n := range numbers {
10401060
pr := cands[n]
1041-
current, previous := targetsAt(opened, pr.MergedAt)
1042-
label := ""
1043-
switch {
1044-
case pr.labels[labelCurrent] && current != nil && *current == want:
1045-
label = labelCurrent
1046-
case pr.labels[labelPrevious] && previous != nil && *previous == want:
1047-
label = labelPrevious
1048-
default:
1061+
label := candidateLabel(pr, opened, want)
1062+
if label == "" {
10491063
continue
10501064
}
10511065
audited[n] = label

‎cmd/backport-audit/main_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,56 @@ func TestTargetsAt(t *testing.T) {
112112
}
113113
}
114114

115+
// A label is read against the lines at merge time however late it was added:
116+
// the audit has no label timestamp. A PR merged on 2026-07-01 and labelled
117+
// after v1.6.0 published is still a release-1.5 candidate, even though
118+
// backport.yaml, running at label time, would have targeted release-1.6.
119+
func TestCandidateLabel(t *testing.T) {
120+
live := liveTimeline(t)
121+
pr := func(merged string, labels ...string) *mainPR {
122+
p := &mainPR{MergedAt: ts(t, merged), labels: map[string]bool{}}
123+
for _, l := range labels {
124+
p.labels[l] = true
125+
}
126+
return p
127+
}
128+
cases := []struct {
129+
name string
130+
pr *mainPR
131+
want map[line]string
132+
}{
133+
{
134+
name: "merged in the 1.5 era: kind/backport means release-1.5 for good",
135+
pr: pr("2026-07-01T00:00:00Z", labelCurrent),
136+
want: map[line]string{{1, 4}: "", {1, 5}: labelCurrent, {1, 6}: ""},
137+
},
138+
{
139+
name: "merged in the 1.5 era: kind/backport-previous means release-1.4 for good",
140+
pr: pr("2026-07-01T00:00:00Z", labelPrevious),
141+
want: map[line]string{{1, 4}: labelPrevious, {1, 5}: "", {1, 6}: ""},
142+
},
143+
{
144+
name: "merged in the 1.6 era, both requests",
145+
pr: pr("2026-09-01T00:00:00Z", labelCurrent, labelPrevious),
146+
want: map[line]string{{1, 4}: "", {1, 5}: labelPrevious, {1, 6}: labelCurrent},
147+
},
148+
{
149+
name: "no backport label: a candidate nowhere",
150+
pr: pr("2026-09-01T00:00:00Z"),
151+
want: map[line]string{{1, 4}: "", {1, 5}: "", {1, 6}: ""},
152+
},
153+
}
154+
for _, tc := range cases {
155+
t.Run(tc.name, func(t *testing.T) {
156+
for want, label := range tc.want {
157+
if got := candidateLabel(tc.pr, live, want); got != label {
158+
t.Errorf("%s: candidateLabel = %q, want %q", want.branch(), got, label)
159+
}
160+
}
161+
})
162+
}
163+
}
164+
115165
// backport.yaml sorts its branch list numerically descending, so the rank is by
116166
// version and never by when a line opened. The two disagree as soon as a freeze
117167
// overlaps the previous line's stabilisation, and 1.10-versus-1.9 is where a

0 commit comments

Comments
 (0)