Files
coder/scripts/releaser/v2/notes.go
T
Garrett Delfosse bfbacd64f4 refactor: consolidate release tooling into a single releaser command (#27034)
## 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.
2026-07-07 11:13:50 -04:00

161 lines
4.5 KiB
Go

package v2
import (
"fmt"
"regexp"
"strconv"
"strings"
"golang.org/x/xerrors"
)
// generateReleaseNotes produces markdown release notes for the given
// version range by examining the commit log and PR metadata.
func generateReleaseNotes(exec CommandExecutor, newVersion, previousVersion version) (string, error) {
// Build commit range. If the new tag doesn't exist locally yet,
// fall back to ..HEAD.
newTag := newVersion.String()
commitRange := fmt.Sprintf("%s...%s", previousVersion.String(), newTag)
if err := gitRun(exec, "rev-parse", "--verify", newTag); err != nil {
commitRange = fmt.Sprintf("%s..HEAD", previousVersion.String())
}
commits, err := commitLog(exec, commitRange)
if err != nil {
return "", xerrors.Errorf("commit log: %w", err)
}
// Extract PR numbers from commit titles and fetch metadata.
prMeta := ghBuildPullRequestMap(exec, extractPRNumbers(commits))
// Section definitions in display order.
type section struct {
key string
title string
}
sections := []section{
{"breaking", "BREAKING CHANGES"},
{"security", "Security"},
{"feat", "Features"},
{"fix", "Bug fixes"},
{"docs", "Documentation"},
{"refactor", "Code refactoring"},
{"perf", "Performance"},
{"test", "Tests"},
{"build", "Build"},
{"ci", "CI"},
{"chore", "Chores"},
{"revert", "Reverts"},
{"other", "Other changes"},
{"experimental", "Experimental"},
}
// Categorize commits into sections.
buckets := make(map[string][]commitEntry)
for _, c := range commits {
// Skip dependabot commits.
if isDependabot(c.Title) {
continue
}
var labels []string
for _, prNum := range parsePRNumbers(c.Title) {
if meta, ok := prMeta[prNum]; ok {
labels = append(labels, meta.Labels...)
}
}
cat := categorizeCommit(c.Title, labels)
buckets[cat] = append(buckets[cat], c)
}
var b strings.Builder
// RC note based on version.
if newVersion.IsRC() {
_, _ = b.WriteString("> [!NOTE]\n")
_, _ = b.WriteString("> This is a **release candidate** build of Coder. Release candidate builds are not intended for production use. Learn more about our [Release Schedule](https://coder.com/docs/install/releases).\n\n")
}
_, _ = b.WriteString("## Changelog\n\n")
for _, sec := range sections {
entries, ok := buckets[sec.key]
if !ok || len(entries) == 0 {
continue
}
_, _ = fmt.Fprintf(&b, "### %s\n\n", sec.title)
for _, e := range entries {
title := humanizeTitle(e.Title)
if prNums := parsePRNumbers(e.Title); len(prNums) > 0 {
// Strip the trailing PR reference from the title since
// we add it as a link.
title = stripPRRef(title)
_, _ = fmt.Fprintf(&b, "- %s (#%d)\n", title, prNums[0])
} else {
_, _ = fmt.Fprintf(&b, "- %s\n", title)
}
}
_, _ = b.WriteString("\n")
}
// Compare link.
_, _ = fmt.Fprintf(&b, "Compare: [`%s...%s`](https://github.com/%s/%s/compare/%s...%s)\n\n",
previousVersion.String(), newVersion.String(),
owner, repo,
previousVersion.String(), newVersion.String())
// Container image.
_, _ = b.WriteString("## Container image\n\n")
_, _ = fmt.Fprintf(&b, "- `docker pull ghcr.io/%s/%s:%s`\n\n", owner, repo, newVersion.String())
// Install/upgrade links.
_, _ = b.WriteString("## Install/upgrade\n\n")
_, _ = b.WriteString("Refer to our docs to [install](https://coder.com/docs/install) or [upgrade](https://coder.com/docs/admin/upgrade) Coder, or use a release asset below.\n")
return b.String(), nil
}
// isDependabot returns true if the commit title looks like it came
// from dependabot.
func isDependabot(title string) bool {
lower := strings.ToLower(title)
return strings.Contains(lower, "dependabot") ||
strings.HasPrefix(lower, "chore(deps):")
}
// prNumRe matches GitHub's "(#NNN)" PR reference convention.
var prNumRe = regexp.MustCompile(`\(#(\d+)\)`)
// parsePRNumbers extracts all PR numbers from a commit title.
func parsePRNumbers(title string) []int {
var nums []int
for _, m := range prNumRe.FindAllStringSubmatch(title, -1) {
num, _ := strconv.Atoi(m[1])
nums = append(nums, num)
}
return nums
}
// extractPRNumbers collects all unique PR numbers from a list of commits.
func extractPRNumbers(commits []commitEntry) []int {
seen := make(map[int]bool)
var nums []int
for _, c := range commits {
for _, num := range parsePRNumbers(c.Title) {
if !seen[num] {
seen[num] = true
nums = append(nums, num)
}
}
}
return nums
}
// stripPRRef removes a trailing (#NNN) from a title.
func stripPRRef(title string) string {
if idx := strings.LastIndex(title, "(#"); idx >= 0 {
return strings.TrimSpace(title[:idx])
}
return title
}