mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## What Consolidates the two separate release programs into a single command at `scripts/releaser`: - `scripts/releaser/v1/` — the former interactive releaser (package `v1`). - `scripts/releaser/v2/` — the former `scripts/release-action` CI tool (package `v2`). - `scripts/releaser/main.go` — new entrypoint. Runs the **v2** tooling by default and the **v1** interactive wizard with `--legacy`. ## CLI shape Three documented subcommands, each backed by v2 `prepare-release` with the release type baked in: - `releaser rc` — tag a release candidate - `releaser branch` — cut a new release branch and tag its first RC - `releaser release` — tag a stable release or patch The former release-action verbs (`calculate-version`, `prepare-release`, `generate-notes`, `publish`) are retained as **hidden** top-level commands with identical flags and stdout, so `tag-and-release.yaml` migrates with a path-only change (`scripts/release-action` -> `scripts/releaser`). `--legacy` runs the v1 wizard and is mutually exclusive with the subcommands. `scripts/release.sh` now launches `releaser --legacy`. All file moves are rename-detected by git, so the per-file diff is just the package declaration. ## Testing - `go build ./scripts/...`, `go vet ./scripts/releaser/...`, `go test ./scripts/releaser/...` - `golangci-lint run ./scripts/releaser/...`, `make lint/emdash`, `shellcheck`, `actionlint` - Smoke: `releaser --help` shows only rc/branch/release; hidden verbs still run; `releaser rc --ref main --dry-run` emits the same JSON contract; `--legacy rc` errors cleanly. <details> <summary>Implementation plan</summary> # Plan: Consolidate release tooling into a single `scripts/releaser` command ## Goal Merge the two separate release programs into one binary at `scripts/releaser`: - `scripts/releaser/v1/` — the current interactive releaser (package `v1`). - `scripts/releaser/v2/` — the current CI `scripts/release-action` (package `v2`). - `scripts/releaser/main.go` — new entrypoint (package `main`). - Uses v2 by default, v1 with `--legacy`. - Exposes 3 subcommands: `rc`, `branch` (cut release branch), `release`. ## Design decision (Option A, chosen) The workflow needs `prepare-release`, `generate-notes`, and `publish` invokable separately (a build happens between prepare and publish). The latter two are version-driven and type-agnostic, so they do not map cleanly onto `rc`/`branch`/`release`. - Visible subcommands `rc`, `branch`, `release` run v2 `prepare-release` with the type baked in and print the same JSON. - Hidden verbs `calculate-version`, `prepare-release`, `generate-notes`, `publish` keep byte-identical flags/stdout, so the workflow change is path-only. Lowest risk; honors "3 subcommands" from a UX perspective. ## `--legacy` semantics - `releaser --legacy` runs the v1 interactive wizard (preserves today's behavior; the wizard auto-detects RC vs release from the branch). - `--legacy` is mutually exclusive with the subcommands (clear error if combined), because v1 auto-detects type and cannot cut a branch. ## Work items 1. Create `v1` and `v2` packages via `git mv`, renaming `package main`. Move the `owner`/`repo` consts into each package. Add `v1.Run(inv, dryRun)` (old wizard `main()` body) and v2 command builders (`CICommands`, `TypeCommand`) so internals stay unexported. 2. New `scripts/releaser/main.go`: top-level `releaser` with `--legacy`, the 3 subcommands, and the hidden compat verbs; delegates to `v1.Run` for legacy. 3. Update references: `tag-and-release.yaml` (3 command paths + header comment) and `scripts/release.sh` (`--legacy`). 4. Verify: build, vet, test, `go run` smoke tests, fmt, lint. 5. Open a single PR from a feature branch. ## Risks / notes - stdout contract for rc/branch/release and the hidden verbs must stay identical (workflow parses stdout); logs go to stderr. - Patch releases from pre-existing `release/X.Y` branches run those branches' own (old) workflow + `scripts/release-action`, so they stay self-consistent. New releases cut from branches containing this change get the new workflow + `scripts/releaser`. No forwarding stub needed since code and workflow ship together. </details> --- This PR was created by Coder Agents on behalf of @f0ssel.
229 lines
6.0 KiB
Go
229 lines
6.0 KiB
Go
package v1
|
|
|
|
import (
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// commitEntry represents a single non-merge commit.
|
|
type commitEntry struct {
|
|
SHA string
|
|
FullSHA string
|
|
Title string
|
|
PRCount int // 0 if no PR number found
|
|
Timestamp int64
|
|
}
|
|
|
|
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
|
|
|
|
// cherryPickPRRe matches cherry-pick bot titles like
|
|
// "chore: foo bar (cherry-pick #42) (#43)".
|
|
var cherryPickPRRe = regexp.MustCompile(`\(cherry-pick #(\d+)\)\s*\(#\d+\)$`)
|
|
|
|
// commitLog returns non-merge commits in the given range, filtering
|
|
// out left-side commits (already in the base) and deduplicating
|
|
// cherry-picks using git's --cherry-mark.
|
|
func commitLog(commitRange string) ([]commitEntry, error) {
|
|
// Use --left-right --cherry-mark to identify equivalent
|
|
// (cherry-picked) commits and left-side-only commits.
|
|
out, err := gitOutput("log", "--no-merges", "--left-right", "--cherry-mark",
|
|
"--pretty=format:%m %ct %h %H %s", commitRange)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
// Collect cherry-pick equivalent commits (marked with '=') so
|
|
// we can skip duplicates. We keep only the right-side version.
|
|
seen := make(map[string]bool)
|
|
|
|
var entries []commitEntry
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Format: %m %ct %h %H %s
|
|
// mark timestamp shortSHA fullSHA title...
|
|
parts := strings.SplitN(line, " ", 5)
|
|
if len(parts) < 5 {
|
|
continue
|
|
}
|
|
mark := parts[0]
|
|
ts, _ := strconv.ParseInt(parts[1], 10, 64)
|
|
shortSHA := parts[2]
|
|
fullSHA := parts[3]
|
|
title := parts[4]
|
|
|
|
// Skip left-side commits (already in the old version).
|
|
if mark == "<" {
|
|
continue
|
|
}
|
|
// Skip cherry-pick equivalents that we've already seen
|
|
// (marked '=' by --cherry-mark).
|
|
if mark == "=" {
|
|
if seen[title] {
|
|
continue
|
|
}
|
|
seen[title] = true
|
|
}
|
|
|
|
// Normalize cherry-pick bot titles:
|
|
// "chore: foo (cherry-pick #42) (#43)" → "chore: foo (#42)"
|
|
if m := cherryPickPRRe.FindStringSubmatch(title); m != nil {
|
|
title = title[:cherryPickPRRe.FindStringIndex(title)[0]] + "(#" + m[1] + ")"
|
|
}
|
|
|
|
e := commitEntry{
|
|
SHA: shortSHA,
|
|
FullSHA: fullSHA,
|
|
Title: title,
|
|
Timestamp: ts,
|
|
}
|
|
if m := prNumRe.FindStringSubmatch(e.Title); m != nil {
|
|
e.PRCount, _ = strconv.Atoi(m[1])
|
|
}
|
|
entries = append(entries, e)
|
|
}
|
|
|
|
// Sort by conventional commit prefix, then by timestamp
|
|
// (matching the bash script's sort -k3,3 -k1,1n).
|
|
sort.SliceStable(entries, func(i, j int) bool {
|
|
pi := commitSortPrefix(entries[i].Title)
|
|
pj := commitSortPrefix(entries[j].Title)
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
return entries[i].Timestamp < entries[j].Timestamp
|
|
})
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// commitSortPrefix extracts the first word of a title for sorting.
|
|
func commitSortPrefix(title string) string {
|
|
idx := strings.IndexAny(title, " (:")
|
|
if idx < 0 {
|
|
return title
|
|
}
|
|
return title[:idx]
|
|
}
|
|
|
|
// humanizedAreas maps conventional commit scopes to human-readable area
|
|
// names. Order matters: more specific prefixes must come first so that
|
|
// the first partial match wins.
|
|
var humanizedAreas = []struct {
|
|
Prefix string
|
|
Area string
|
|
}{
|
|
{"agent/agentssh", "Agent SSH"},
|
|
{"coderd/database", "Database"},
|
|
{"enterprise/audit", "Auditing"},
|
|
{"enterprise/cli", "CLI"},
|
|
{"enterprise/coderd", "Server"},
|
|
{"enterprise/dbcrypt", "Database"},
|
|
{"enterprise/derpmesh", "Networking"},
|
|
{"enterprise/provisionerd", "Provisioner"},
|
|
{"enterprise/tailnet", "Networking"},
|
|
{"enterprise/wsproxy", "Workspace Proxy"},
|
|
{"agent", "Agent"},
|
|
{"cli", "CLI"},
|
|
{"coderd", "Server"},
|
|
{"codersdk", "SDK"},
|
|
{"docs", "Documentation"},
|
|
{"enterprise", "Enterprise"},
|
|
{"examples", "Examples"},
|
|
{"helm", "Helm"},
|
|
{"install.sh", "Installer"},
|
|
{"provisionersdk", "SDK"},
|
|
{"provisionerd", "Provisioner"},
|
|
{"provisioner", "Provisioner"},
|
|
{"pty", "CLI"},
|
|
{"scaletest", "Scale Testing"},
|
|
{"site", "Dashboard"},
|
|
{"support", "Support"},
|
|
{"tailnet", "Networking"},
|
|
}
|
|
|
|
// conventionalPrefixRe extracts prefix, scope, and rest from a
|
|
// conventional commit title. Does NOT match breaking "!" suffix —
|
|
// those titles are left as-is (matching bash behavior).
|
|
var conventionalPrefixRe = regexp.MustCompile(`^([a-z]+)(\((.+)\))?:\s*(.*)$`)
|
|
|
|
// humanizeTitle converts a conventional commit title to a
|
|
// human-readable form, e.g. "feat(site): add bar" → "Dashboard: Add bar".
|
|
func humanizeTitle(title string) string {
|
|
m := conventionalPrefixRe.FindStringSubmatch(title)
|
|
if m == nil {
|
|
return title
|
|
}
|
|
scope := m[3] // may be empty
|
|
rest := m[4]
|
|
if rest == "" {
|
|
return title
|
|
}
|
|
// Capitalize the first letter of the rest.
|
|
rest = strings.ToUpper(rest[:1]) + rest[1:]
|
|
|
|
if scope == "" {
|
|
return rest
|
|
}
|
|
|
|
// Look up scope in humanizedAreas (first partial match wins).
|
|
for _, ha := range humanizedAreas {
|
|
if strings.HasPrefix(scope, ha.Prefix) {
|
|
return ha.Area + ": " + rest
|
|
}
|
|
}
|
|
// Scope not found in map — return as-is.
|
|
return title
|
|
}
|
|
|
|
// breakingCommitRe matches conventional commit "!:" breaking changes.
|
|
var breakingCommitRe = regexp.MustCompile(`^[a-zA-Z]+(\(.+\))?!:`)
|
|
|
|
// categorizeCommit determines the release note section for a commit.
|
|
// The priority order matches the bash script: breaking title first,
|
|
// then labels (breaking, security, experimental), then prefix.
|
|
func categorizeCommit(title string, labels []string) string {
|
|
// Check breaking title first (matches bash behavior).
|
|
if breakingCommitRe.MatchString(title) {
|
|
return "breaking"
|
|
}
|
|
|
|
// Label-based categorization.
|
|
for _, l := range labels {
|
|
if l == "release/breaking" {
|
|
return "breaking"
|
|
}
|
|
if l == "security" {
|
|
return "security"
|
|
}
|
|
if l == "release/experimental" {
|
|
return "experimental"
|
|
}
|
|
}
|
|
|
|
// Extract the conventional commit prefix (e.g. "feat", "fix(scope)").
|
|
prefixRe := regexp.MustCompile(`^([a-z]+)(\(.+\))?[!]?:`)
|
|
m := prefixRe.FindStringSubmatch(title)
|
|
if m == nil {
|
|
return "other"
|
|
}
|
|
|
|
validPrefixes := []string{
|
|
"feat", "fix", "docs", "refactor", "perf",
|
|
"test", "build", "ci", "chore", "revert",
|
|
}
|
|
for _, p := range validPrefixes {
|
|
if m[1] == p {
|
|
return p
|
|
}
|
|
}
|
|
return "other"
|
|
}
|