Skip to content

Commit d2e92f3

Browse files
authored
feat(link-backport-prs): verify sub-issue release matches backport target (#196)
A title-prefix match alone cannot catch a sub-issue attached to the wrong release: the [X.Y] title can say one line while the attached release says another, and the backport linked silently. The TPM validation flow needs the release to agree with the backport label, branch, and title. After a successful title match, read the Releases attached to the sub-issue via Linear GraphQL and derive each release's X.Y line from its version field, falling back to the leading version in the release name ("0.33.5 - Security Only" parses to 0.33). No release attached or a line mismatch emits a remedy warning in the DEVOPS-1139 style; a match stays silent. Linking proceeds unchanged in every outcome and a failed releases query degrades to a single warning, so the step stays advisory and never fails the backport job. Claude-Session: https://claude.ai/code/session_01DCPTEDZcxEN1zbD6ixxcFy
1 parent c40c1db commit d2e92f3

4 files changed

Lines changed: 241 additions & 14 deletions

File tree

.github/actions/link-backport-prs/README.md

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ When a merged source PR carries `backport-to-<branch>` labels, the [sorenlouv ba
88

99
1. Resolves the source PR's Linear issue (the parent) via Linear's `attachmentsForURL` reverse lookup, falling back to a `TEAM-123` identifier parsed from the branch name or body.
1010
2. Finds the sub-issue whose title carries the release-line prefix for that target, e.g. `[0.34] Copy of ENGCP-906` for a backport to `v0.34` (a leading `v`, as in `[v0.34]`, is also accepted).
11-
3. Appends `Fixes <sub-issue-id>` to that backport PR's body, unless it already references the issue.
11+
3. Verifies the release attached to the matched sub-issue agrees with the backport target line, warning on a missing or mismatched release (see below). Linking proceeds either way.
12+
4. Appends `Fixes <sub-issue-id>` to that backport PR's body, unless it already references the issue.
1213

1314
The match is by title prefix, not milestone: the `[X.Y] Copy of ...` sub-issues created for a backport family do not reliably carry a patch milestone, so the title is the dependable key.
1415

16+
The release check exists because a title match alone cannot catch a sub-issue attached to the wrong release. After a title match, the action reads the Releases attached to the sub-issue via Linear and derives each release's `X.Y` line from its version field, falling back to the leading version in the release name (`0.33.5 - Security Only` parses to `0.33`). No release attached, or no attached release on the target's line, produces a remedy warning. A matching release stays silent. If the releases query itself fails, verification degrades to a single warning and linking continues.
17+
1518
It is advisory and idempotent: it never fails the backport job (every problem is a warning and it exits 0) and re-runs do not add duplicate `Fixes` lines.
1619

17-
Every skip that a human can fix is loud. When a source PR carries backport labels but linking hits a dead end, the action emits a GitHub `::warning::` annotation and a job-summary line naming the remedy. This covers an empty `linear-token` (fix the repository secret), an unresolved parent Linear issue (attach the PR to its issue), a release line with no matching `[X.Y]` sub-issue (create or rename the sub-issue), and a backport PR that sorenlouv never opened (backport it manually after the conflict). A source PR with no backport labels stays a plain notice, since there is nothing to fix. The step always publishes a `linked-count` output, 0 when it skips.
20+
Every skip that a human can fix is loud. When a source PR carries backport labels but linking hits a dead end, the action emits a GitHub `::warning::` annotation and a job-summary line naming the remedy. This covers an empty `linear-token` (fix the repository secret), an unresolved parent Linear issue (attach the PR to its issue), a release line with no matching `[X.Y]` sub-issue (create or rename the sub-issue), a matched sub-issue with no release attached (attach the line's In Progress release), a matched sub-issue whose release is on a different line (fix the release attachment or the sub-issue title), and a backport PR that sorenlouv never opened (backport it manually after the conflict). A source PR with no backport labels stays a plain notice, since there is nothing to fix. The step always publishes a `linked-count` output, 0 when it skips.
1821

1922
## Usage
2023

@@ -47,15 +50,15 @@ The `github-token` must be the same PAT that created the backport PRs (a PAT, no
4750

4851
<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->
4952

50-
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
51-
|--------------|--------|----------|------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
52-
| dry-run | string | false | `"false"` | Log intended edits without applying them |
53-
| github-token | string | true | | GitHub token with permission to read <br>and edit pull requests (must be the same PAT that created the backport PRs) |
54-
| label-prefix | string | false | `"backport-to-"` | Prefix of the backport labels on <br>the source PR |
55-
| linear-token | string | false | | Linear API token for resolving the <br>issue family. Optional: the step always <br>exits 0, so callers can adopt <br>the backport workflow before a token <br>is wired up. When it is <br>empty but the source PR carries <br>backport labels, the step emits a <br>warning naming the missing secret instead <br>of silently doing nothing. |
56-
| repo-name | string | true | | The name of the repository |
57-
| repo-owner | string | true | | The owner of the repository |
58-
| source-pr | string | true | | The merged source pull request number <br>that was backported |
53+
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
54+
|--------------|--------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
55+
| dry-run | string | false | `"false"` | Log intended edits without applying them |
56+
| github-token | string | true | | GitHub token with permission to read <br>and edit pull requests (must be the same PAT that created the backport PRs) |
57+
| label-prefix | string | false | `"backport-to-"` | Prefix of the backport labels on <br>the source PR |
58+
| linear-token | string | false | | Linear API token for resolving the <br>issue family and verifying release attachments. <br>Optional: the step always exits 0, <br>so callers can adopt the backport <br>workflow before a token is wired <br>up. When it is empty but <br>the source PR carries backport labels, <br>the step emits a warning naming <br>the missing secret instead of silently <br>doing nothing. |
59+
| repo-name | string | true | | The name of the repository |
60+
| repo-owner | string | true | | The owner of the repository |
61+
| source-pr | string | true | | The merged source pull request number <br>that was backported |
5962

6063
<!-- AUTO-DOC-INPUT:END -->
6164

@@ -81,4 +84,4 @@ Run the unit tests:
8184
make test-link-backport-prs
8285
```
8386

84-
The tests cover the pure matching logic (release-line extraction from a target branch, title-prefix matching for `[0.34]` and `[v0.34]`, sub-issue selection within an issue family, idempotency of the `Fixes` line, and identifier extraction fallback), plus the remedy-warning rendering and the `linked-count` / job-summary writers.
87+
The tests cover the pure matching logic (release-line extraction from a target branch, title-prefix matching for `[0.34]` and `[v0.34]`, sub-issue selection within an issue family, idempotency of the `Fixes` line, and identifier extraction fallback), the release verification (line derivation from the version field with name fallback, and the missing / mismatched / matching outcomes), plus the remedy-warning rendering and the `linked-count` / job-summary writers.

.github/actions/link-backport-prs/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: 'Link Backport PRs to Linear'
2-
description: 'Links sorenlouv-created backport PRs to the matching Linear sub-issue by adding "Fixes <id>" to the backport PR body'
2+
description: 'Links sorenlouv-created backport PRs to the matching Linear sub-issue by adding "Fixes <id>" to the backport PR body, and verifies the sub-issue carries a release on the backport target line'
33

44
inputs:
55
source-pr:
@@ -15,7 +15,7 @@ inputs:
1515
description: 'GitHub token with permission to read and edit pull requests (must be the same PAT that created the backport PRs)'
1616
required: true
1717
linear-token:
18-
description: 'Linear API token for resolving the issue family. Optional: the step always exits 0, so callers can adopt the backport workflow before a token is wired up. When it is empty but the source PR carries backport labels, the step emits a warning naming the missing secret instead of silently doing nothing.'
18+
description: 'Linear API token for resolving the issue family and verifying release attachments. Optional: the step always exits 0, so callers can adopt the backport workflow before a token is wired up. When it is empty but the source PR carries backport labels, the step emits a warning naming the missing secret instead of silently doing nothing.'
1919
required: false
2020
default: ''
2121
label-prefix:

.github/actions/link-backport-prs/src/main.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
// prefix (for example "[0.34] Copy of ENGCP-906"), and appends "Fixes <id>" to
88
// that backport PR's body so the sub-issue is closed when the backport merges.
99
//
10+
// After a title match it also verifies the release attached to the sub-issue:
11+
// a missing release or a release on a different line than the backport target
12+
// produces a remedy warning, because a title match alone cannot catch a
13+
// sub-issue attached to the wrong release. Verification never blocks linking.
14+
//
1015
// It is advisory: any failure is logged as a warning and the process still
1116
// exits 0 so it never blocks the backport workflow.
1217
package main
@@ -51,6 +56,13 @@ type issueFamily struct {
5156
Parent *issueWithChildren `json:"parent"`
5257
}
5358

59+
// releaseRef is a minimal Linear release attached to an issue. Version is
60+
// nullable in Linear's schema, so it decodes to "" when unset.
61+
type releaseRef struct {
62+
Name string `json:"name"`
63+
Version string `json:"version"`
64+
}
65+
5466
func main() {
5567
// Workflow commands (::notice::, ::warning::) are only parsed when they
5668
// start the line, so drop log's default date/time prefix; otherwise the
@@ -143,6 +155,11 @@ func run() error {
143155
continue
144156
}
145157

158+
// A title match alone cannot catch a sub-issue attached to the wrong
159+
// release, so verify the attachment. Advisory in every outcome: the
160+
// Fixes-line linking below proceeds unchanged.
161+
verifyRelease(linearToken, sub, version, target)
162+
146163
headBranch := backportHeadBranch(target, *sourcePR)
147164
bp, err := findBackportPR(ctx, gh, *repoOwner, *repoName, headBranch, target)
148165
if err != nil {
@@ -254,6 +271,73 @@ func familyCandidates(f issueFamily) []issueRef {
254271
return append(out, f.Children.Nodes...)
255272
}
256273

274+
var leadingVersionRe = regexp.MustCompile(`^\s*v?(\d+)\.(\d+)`)
275+
276+
// lineFromVersionString extracts the X.Y release line from the leading version
277+
// in a string. "0.33.5 - Security Only" -> "0.33", "v1.2.3" -> "1.2",
278+
// "abc123" -> "".
279+
func lineFromVersionString(s string) string {
280+
m := leadingVersionRe.FindStringSubmatch(s)
281+
if len(m) < 3 {
282+
return ""
283+
}
284+
return m[1] + "." + m[2]
285+
}
286+
287+
// releaseLine derives a release's X.Y line: from the version field when it is
288+
// set and parseable, else from the leading version in the release name. Empty
289+
// when neither carries a version (e.g. a commit-hash version and a free-form
290+
// name).
291+
func releaseLine(r releaseRef) string {
292+
if line := lineFromVersionString(r.Version); line != "" {
293+
return line
294+
}
295+
return lineFromVersionString(r.Name)
296+
}
297+
298+
// verifyReleaseAttachment checks that a matched sub-issue carries a release on
299+
// the backport target's line. It returns nil when any attached release matches
300+
// the line, and a remedyWarning naming the fix otherwise. Pure so the three
301+
// outcomes are testable without HTTP.
302+
func verifyReleaseAttachment(sub issueRef, releases []releaseRef, line, target string) *remedyWarning {
303+
if len(releases) == 0 {
304+
return &remedyWarning{
305+
problem: fmt.Sprintf("sub-issue %s matched backport target %s by title but has no release attached", sub.Identifier, target),
306+
remedy: fmt.Sprintf("attach the %s line's In Progress release to %s", line, sub.Identifier),
307+
}
308+
}
309+
var attached []string
310+
for _, r := range releases {
311+
rl := releaseLine(r)
312+
if rl == line {
313+
return nil
314+
}
315+
if rl == "" {
316+
rl = fmt.Sprintf("%q (no parseable version)", r.Name)
317+
}
318+
attached = append(attached, rl)
319+
}
320+
return &remedyWarning{
321+
problem: fmt.Sprintf("sub-issue %s matched backport target %s by title but its attached release is on line %s, not %s", sub.Identifier, target, strings.Join(attached, ", "), line),
322+
remedy: fmt.Sprintf("attach the %s release to %s, or fix the sub-issue title if the release attachment is the correct one", line, sub.Identifier),
323+
}
324+
}
325+
326+
// verifyRelease fetches the releases attached to a matched sub-issue and emits
327+
// a remedy warning when none is on the backport target's line. When the
328+
// releases query itself fails (API shape drift, permissions), it degrades to a
329+
// single plain warning; linking always proceeds.
330+
func verifyRelease(token string, sub issueRef, line, target string) {
331+
releases, err := getIssueReleases(token, sub.ID)
332+
if err != nil {
333+
warnf("could not verify the release attached to %s (linking continues): %v", sub.Identifier, err)
334+
return
335+
}
336+
if w := verifyReleaseAttachment(sub, releases, line, target); w != nil {
337+
w.emit()
338+
}
339+
}
340+
257341
var fixesRe = regexp.MustCompile(`(?i)\b(fix(es|ed)?|close[sd]?|resolve[sd]?)\s+#?`)
258342

259343
// bodyReferencesIssue reports whether a PR body already closes/fixes/resolves
@@ -396,6 +480,34 @@ func getFamilyByID(token, id string) (*issueFamily, error) {
396480
return &f, nil
397481
}
398482

483+
// getIssueReleases returns the releases attached to a Linear issue.
484+
func getIssueReleases(token, id string) ([]releaseRef, error) {
485+
const q = `query($id: String!) {
486+
issue(id: $id) {
487+
releases { nodes { name version } }
488+
}
489+
}`
490+
var resp struct {
491+
Data struct {
492+
Issue struct {
493+
Releases struct {
494+
Nodes []releaseRef `json:"nodes"`
495+
} `json:"releases"`
496+
} `json:"issue"`
497+
} `json:"data"`
498+
Errors []struct {
499+
Message string `json:"message"`
500+
} `json:"errors"`
501+
}
502+
if err := linearGraphQL(token, q, map[string]any{"id": id}, &resp); err != nil {
503+
return nil, err
504+
}
505+
if len(resp.Errors) > 0 {
506+
return nil, fmt.Errorf("linear: %s", resp.Errors[0].Message)
507+
}
508+
return resp.Data.Issue.Releases.Nodes, nil
509+
}
510+
399511
func linearGraphQL(token, query string, variables map[string]any, out any) error {
400512
payload, err := json.Marshal(map[string]any{"query": query, "variables": variables})
401513
if err != nil {

0 commit comments

Comments
 (0)