Files
coder/scripts/release-action/notes.go
T
Garrett Delfosse ff7e0bc193 feat: add dry-run flag via CommandExecutor interface (#26422)
## Summary

Adds a `--dry-run` capability to the `release-action` Go tool and
exposes it through a **new** manual workflow, `tag-and-release.yaml`,
without disturbing the existing `release.yaml` pipeline.

PR #25162 had rewritten `release.yaml` in place to be driven by
`scripts/release-action`, which changed its `workflow_dispatch` inputs
from `release_channel`/`release_notes`/`dry_run` to
`release_type`/`commit_sha`. That broke `scripts/releaser`, which
dispatches `release.yaml` with the original inputs. This PR restores
`release.yaml` and moves the Go-driven pipeline to its own workflow.

## Workflow layout after this PR

| Workflow | Trigger | Driven by | Purpose |
|---|---|---|---|
| `release.yaml` | `scripts/releaser` (`gh workflow run`) | legacy
inline shell | Existing pipeline, restored to pre-#25162 state |
| `tag-and-release.yaml` | Manual (Actions UI) |
`scripts/release-action` Go tool | New pipeline with `prepare-release` +
`dry_run` |

`release.yaml` is restored byte-for-byte to its pre-#25162 version, so
its inputs match what `scripts/releaser` sends again.

## `release-action` design

### CommandExecutor interface

Abstracts CLI command execution behind read-only and mutating methods:

| Method | Purpose | Dry-run behavior |
|---|---|---|
| `RunOutput` | Read-only, capture stdout | Executes normally |
| `Run` | Read-only, exit code only | Executes normally |
| `RunMutation` | Changes remote state, no output | **Prints command,
skips execution** |
| `RunMutationStdout` | Changes remote state, streaming I/O | **Prints
command, skips execution** |

Two implementations: `realExecutor` (executes via `os/exec`) and
`dryRunExecutor` (delegates read-only calls, prints mutating calls).

### `prepare-release` subcommand

Composes `calculateNextVersion` with idempotent tag and branch
creation+push, emitting the same JSON as `calculate-version`. Matching
existing refs are skipped; mismatched refs error.

### `tag-and-release.yaml` `dry_run` input

When enabled: `prepare-release` runs with `--dry-run` (version
calculated, plan printed, nothing pushed), notes are generated for
inspection, and the build+publish job is skipped via an `if` guard
(cascading to homebrew/winget/docs).

## Mutating commands covered by `--dry-run`

| Command | Call site |
|---|---|
| `git tag -a <version> ...` | `createAndPushTag` |
| `git push origin refs/tags/...` | `createAndPushTag` |
| `git push origin <sha>:refs/heads/...` | `createAndPushBranch` |
| `gh release create ...` | `publishRelease` |

`git fetch --tags --force origin` is intentionally not a mutation; it
only updates local remote-tracking refs and must run for accurate
version calculation.

## Changes

- **New**: `scripts/release-action/cmdexec.go`, `prepare.go` (+ tests)
- **Refactored**: `git.go`, `github.go`, `calculate.go`, `notes.go`,
`commit.go`, `publish.go` to thread `CommandExecutor`; added `gitMutate`
- **Updated**: `main.go` adds `--dry-run` flag and `prepare-release`
subcommand
- **New**: `.github/workflows/tag-and-release.yaml` (manual, Go-driven,
with `dry_run`)
- **Reverted**: `.github/workflows/release.yaml` to its pre-#25162 state

> [!NOTE]
> Generated by Coder Agents on behalf of @f0ssel
2026-07-01 16:20:00 -04:00

161 lines
4.5 KiB
Go

package main
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
}