Skip to content

Commit 7ef8201

Browse files
authored
chore: backport release action (#26143)
<!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. -->
1 parent 3159068 commit 7ef8201

14 files changed

Lines changed: 2333 additions & 3 deletions

File tree

scripts/release-action/calculate.go

Lines changed: 442 additions & 0 deletions
Large diffs are not rendered by default.

scripts/release-action/calculate_test.go

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.

scripts/release-action/commit.go

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package main
2+
3+
import (
4+
"regexp"
5+
"sort"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// commitEntry represents a single non-merge commit.
11+
type commitEntry struct {
12+
SHA string
13+
FullSHA string
14+
Title string
15+
Timestamp int64
16+
}
17+
18+
// cherryPickPRRe matches cherry-pick bot titles like
19+
// "chore: foo bar (cherry-pick #42) (#43)".
20+
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
21+
22+
// humanizedAreas maps conventional commit scopes to human-readable area
23+
// names. Order matters: more specific prefixes must come first so that
24+
// the first partial match wins.
25+
var humanizedAreas = []struct {
26+
Prefix string
27+
Area string
28+
}{
29+
{"agent/agentssh", "Agent SSH"},
30+
{"coderd/database", "Database"},
31+
{"enterprise/audit", "Auditing"},
32+
{"enterprise/cli", "CLI"},
33+
{"enterprise/coderd", "Server"},
34+
{"enterprise/dbcrypt", "Database"},
35+
{"enterprise/derpmesh", "Networking"},
36+
{"enterprise/provisionerd", "Provisioner"},
37+
{"enterprise/tailnet", "Networking"},
38+
{"enterprise/wsproxy", "Workspace Proxy"},
39+
{"agent", "Agent"},
40+
{"cli", "CLI"},
41+
{"coderd", "Server"},
42+
{"codersdk", "SDK"},
43+
{"docs", "Documentation"},
44+
{"enterprise", "Enterprise"},
45+
{"examples", "Examples"},
46+
{"helm", "Helm"},
47+
{"install.sh", "Installer"},
48+
{"provisionersdk", "SDK"},
49+
{"provisionerd", "Provisioner"},
50+
{"provisioner", "Provisioner"},
51+
{"pty", "CLI"},
52+
{"scaletest", "Scale Testing"},
53+
{"site", "Dashboard"},
54+
{"support", "Support"},
55+
{"tailnet", "Networking"},
56+
}
57+
58+
// commitLog returns non-merge commits in the given range, filtering
59+
// out left-side commits (already in the base) and deduplicating
60+
// cherry-picks using git's --cherry-mark.
61+
func commitLog(commitRange string) ([]commitEntry, error) {
62+
// Use --left-right --cherry-mark to identify equivalent
63+
// (cherry-picked) commits and left-side-only commits.
64+
out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
65+
"--pretty=format:%m %ct %h %H %s", commitRange)
66+
if err != nil {
67+
return nil, err
68+
}
69+
if out == "" {
70+
return nil, nil
71+
}
72+
73+
// Collect cherry-pick equivalent commits (marked with '=') so
74+
// we can skip duplicates. We keep only the right-side version.
75+
seen := make(map[string]bool)
76+
77+
var entries []commitEntry
78+
for _, line := range strings.Split(out, "\n") {
79+
line = strings.TrimSpace(line)
80+
if line == "" {
81+
continue
82+
}
83+
// Format: %m %ct %h %H %s
84+
// mark timestamp shortSHA fullSHA title...
85+
parts := strings.SplitN(line, " ", 5)
86+
if len(parts) < 5 {
87+
continue
88+
}
89+
mark := parts[0]
90+
ts, _ := strconv.ParseInt(parts[1], 10, 64)
91+
shortSHA := parts[2]
92+
fullSHA := parts[3]
93+
title := parts[4]
94+
95+
// Skip left-side commits (already in the old version).
96+
if mark == "<" {
97+
continue
98+
}
99+
// Skip cherry-pick equivalents that we've already seen
100+
// (marked '=' by --cherry-mark).
101+
if mark == "=" {
102+
if seen[title] {
103+
continue
104+
}
105+
seen[title] = true
106+
}
107+
108+
// Normalize cherry-pick bot titles:
109+
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
110+
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
111+
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
112+
}
113+
114+
entries = append(entries, commitEntry{
115+
SHA: shortSHA,
116+
FullSHA: fullSHA,
117+
Title: title,
118+
Timestamp: ts,
119+
})
120+
}
121+
122+
// Sort by conventional commit prefix, then by timestamp
123+
// (matching the bash script's sort -k3,3 -k1,1n).
124+
sort.SliceStable(entries, func(i, j int) bool {
125+
pi := commitSortPrefix(entries[i].Title)
126+
pj := commitSortPrefix(entries[j].Title)
127+
if pi != pj {
128+
return pi < pj
129+
}
130+
return entries[i].Timestamp < entries[j].Timestamp
131+
})
132+
133+
return entries, nil
134+
}
135+
136+
// commitSortPrefix extracts the first word of a title for sorting.
137+
func commitSortPrefix(title string) string {
138+
idx := strings.IndexAny(title, " (:")
139+
if idx < 0 {
140+
return title
141+
}
142+
return title[:idx]
143+
}
144+
145+
// conventionalPrefixRe extracts prefix, scope, and rest from a
146+
// conventional commit title. Does NOT match breaking "!" suffix;
147+
// those titles are left as-is (matching bash behavior).
148+
var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
149+
150+
// humanizeTitle converts a conventional commit title to a
151+
// human-readable form, e.g. "feat(site): add bar" -> "Dashboard: Add bar".
152+
func humanizeTitle(title string) string {
153+
m := conventionalPrefixRe.FindStringSubmatch(title)
154+
if m == nil {
155+
return title
156+
}
157+
scope := m[3] // may be empty
158+
rest := m[4]
159+
if rest == "" {
160+
return title
161+
}
162+
// Capitalize the first letter of the rest.
163+
rest = strings.ToUpper(rest[:1]) + rest[1:]
164+
165+
if scope == "" {
166+
return rest
167+
}
168+
169+
// Look up scope in humanizedAreas (first partial match wins).
170+
for _, ha := range humanizedAreas {
171+
if strings.HasPrefix(scope, ha.Prefix) {
172+
return ha.Area + ": " + rest
173+
}
174+
}
175+
// Scope not found in map; return as-is.
176+
return title
177+
}
178+
179+
// breakingCommitRe matches conventional commit "!:" breaking changes.
180+
var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
181+
182+
// categorizeCommit determines the release note section for a commit.
183+
// The priority order matches the bash script: breaking title first,
184+
// then labels (breaking, security, experimental), then prefix.
185+
func categorizeCommit(title string, labels []string) string {
186+
// Check breaking title first (matches bash behavior).
187+
if breakingCommitRe.MatchString(title) {
188+
return "breaking"
189+
}
190+
191+
// Label-based categorization.
192+
for _, l := range labels {
193+
if l == "release/breaking" {
194+
return "breaking"
195+
}
196+
if l == "security" {
197+
return "security"
198+
}
199+
if l == "release/experimental" {
200+
return "experimental"
201+
}
202+
}
203+
204+
// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
205+
prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
206+
m := prefixRe.FindStringSubmatch(title)
207+
if m == nil {
208+
return "other"
209+
}
210+
211+
validPrefixes := []string{
212+
"feat", "fix", "docs", "refactor", "perf",
213+
"test", "build", "ci", "chore", "revert",
214+
}
215+
for _, p := range validPrefixes {
216+
if m[1] == p {
217+
return p
218+
}
219+
}
220+
return "other"
221+
}

0 commit comments

Comments
 (0)