Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,9 @@ make fmt
Releases are automated via GoReleaser. Pushing a version tag triggers GitHub Actions to build binaries and update the Homebrew formula.

```bash
# Preview release notes from commits since the nearest semver tag
td release-notes --version v0.2.0 --date 2026-04-29

# Create and push an annotated tag (triggers automated release)
make release VERSION=v0.2.0

Expand Down Expand Up @@ -422,6 +425,7 @@ Analytics are stored locally and help identify workflow patterns. Disable with `
| Undo last action | `td undo` |
| New named session | `td session --new "feature-work"` |
| Live dashboard | `td monitor` |
| Draft release notes | `td release-notes --version vX.Y.Z --date YYYY-MM-DD` |

### Boards

Expand Down
88 changes: 88 additions & 0 deletions cmd/release_notes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package cmd

import (
"fmt"
"os"
"strings"
"time"

tdgit "github.com/marcus/td/internal/git"
"github.com/marcus/td/internal/releasenotes"
"github.com/spf13/cobra"
)

var releaseNotesCmd = &cobra.Command{
Use: "release-notes",
Short: "Draft markdown release notes from git commits",
GroupID: "system",
Args: cobra.NoArgs,
Example: ` td release-notes
td release-notes --from v0.4.0 --to HEAD
td release-notes --version v0.5.0 --date 2026-04-29`,
RunE: func(cmd *cobra.Command, args []string) error {
from, _ := cmd.Flags().GetString("from")
to, _ := cmd.Flags().GetString("to")
version, _ := cmd.Flags().GetString("version")
date, _ := cmd.Flags().GetString("date")

if strings.TrimSpace(to) == "" {
return fmt.Errorf("--to cannot be empty")
}
if date != "" {
if version == "" {
return fmt.Errorf("--date requires --version")
}
if _, err := time.Parse("2006-01-02", date); err != nil {
return fmt.Errorf("--date must use YYYY-MM-DD format")
}
}

repoDir := getBaseDir()
if repoDir == "" {
var err error
repoDir, err = os.Getwd()
if err != nil {
return err
}
}

if _, err := tdgit.ResolveRef(repoDir, to); err != nil {
return err
}

if strings.TrimSpace(from) == "" {
tag, err := tdgit.NearestSemverTag(repoDir, to)
if err != nil {
return fmt.Errorf("%w; pass --from <ref> to choose a release-note range explicitly", err)
}
from = tag
} else if _, err := tdgit.ResolveRef(repoDir, from); err != nil {
return err
}

commits, err := tdgit.ListCommits(repoDir, from, to)
if err != nil {
return fmt.Errorf("failed to list commits for %s..%s: %w", from, to, err)
}
if len(commits) == 0 {
return fmt.Errorf("no commits found in %s..%s", from, to)
}

if err := releasenotes.Render(cmd.OutOrStdout(), commits, releasenotes.Options{
Version: version,
Date: date,
}); err != nil {
return fmt.Errorf("%w in %s..%s", err, from, to)
}

return nil
},
}

func init() {
releaseNotesCmd.Flags().String("from", "", "start ref for the release-note range (defaults to nearest reachable semver tag)")
releaseNotesCmd.Flags().String("to", "HEAD", "end ref for the release-note range")
releaseNotesCmd.Flags().String("version", "", "version heading to render, such as v0.5.0")
releaseNotesCmd.Flags().String("date", "", "release date heading in YYYY-MM-DD format (requires --version)")
rootCmd.AddCommand(releaseNotesCmd)
}
187 changes: 187 additions & 0 deletions cmd/release_notes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package cmd

import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestReleaseNotesDefaultRangeSelection(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")

out, err := runReleaseNotesCommand(t, repo, nil)
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "### Features\n- Add release notes") {
t.Fatalf("expected feature markdown, got:\n%s", out)
}
}

func TestReleaseNotesExplicitRange(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")
gitCmd(t, repo, "tag", "v1.1.0")
commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair later bug")

out, err := runReleaseNotesCommand(t, repo, map[string]string{
"from": "v1.0.0",
"to": "v1.1.0",
})
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}
if !strings.Contains(out, "Add release notes") {
t.Fatalf("expected first range commit, got:\n%s", out)
}
if strings.Contains(out, "Repair later bug") {
t.Fatalf("did not expect commit after --to ref, got:\n%s", out)
}
}

func TestReleaseNotesValidationFailures(t *testing.T) {
repo := initReleaseNotesRepo(t)
commitReleaseNoteFile(t, repo, "feature.txt", "feat: add release notes")

tests := []struct {
name string
flags map[string]string
wantErr string
}{
{
name: "date requires version",
flags: map[string]string{"date": "2026-04-29"},
wantErr: "--date requires --version",
},
{
name: "date format",
flags: map[string]string{"version": "v1.0.0", "date": "04-29-2026"},
wantErr: "--date must use YYYY-MM-DD format",
},
{
name: "missing default tag",
flags: nil,
wantErr: "pass --from <ref>",
},
{
name: "invalid from ref",
flags: map[string]string{"from": "missing-ref"},
wantErr: "invalid git ref",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := runReleaseNotesCommand(t, repo, tt.flags)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}

func TestReleaseNotesStdoutMarkdownWithHeading(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "fix.txt", "fix: repair output")

out, err := runReleaseNotesCommand(t, repo, map[string]string{
"version": "v1.1.0",
"date": "2026-04-29",
})
if err != nil {
t.Fatalf("release-notes failed: %v", err)
}

if !strings.HasPrefix(out, "## v1.1.0 - 2026-04-29\n\n") {
t.Fatalf("expected version/date heading, got:\n%s", out)
}
if !strings.Contains(out, "### Bug Fixes\n- Repair output") {
t.Fatalf("expected bug fix section, got:\n%s", out)
}
}

func TestReleaseNotesNoRelevantCommits(t *testing.T) {
repo := initReleaseNotesRepo(t)
gitCmd(t, repo, "tag", "v1.0.0")
commitReleaseNoteFile(t, repo, "merge.txt", "fixup! feat: add hidden note")

_, err := runReleaseNotesCommand(t, repo, nil)
if err == nil {
t.Fatal("expected no relevant commits error")
}
if !strings.Contains(err.Error(), "no release-note-worthy commits") {
t.Fatalf("unexpected error: %v", err)
}
}

func runReleaseNotesCommand(t *testing.T, repo string, flags map[string]string) (string, error) {
t.Helper()
saveAndRestoreCommandFlags(t, releaseNotesCmd, "from", "to", "version", "date")

oldBaseDirOverride := baseDirOverride
baseDirOverride = &repo
t.Cleanup(func() {
baseDirOverride = oldBaseDirOverride
})

var out bytes.Buffer
releaseNotesCmd.SetOut(&out)
t.Cleanup(func() {
releaseNotesCmd.SetOut(nil)
})

for name, value := range flags {
if err := releaseNotesCmd.Flags().Set(name, value); err != nil {
t.Fatalf("set flag %s=%s: %v", name, value, err)
}
}

err := releaseNotesCmd.RunE(releaseNotesCmd, nil)
return out.String(), err
}

func initReleaseNotesRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
gitCmd(t, dir, "init")
gitCmd(t, dir, "config", "user.email", "test@example.com")
gitCmd(t, dir, "config", "user.name", "Test User")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# test\n"), 0644); err != nil {
t.Fatalf("write README: %v", err)
}
gitCmd(t, dir, "add", ".")
gitCmd(t, dir, "commit", "-m", "Initial commit")
return dir
}

func commitReleaseNoteFile(t *testing.T, repo, name, subject string) {
t.Helper()
path := filepath.Join(repo, name)
content := fmt.Sprintf("%s\n", subject)
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
gitCmd(t, repo, "add", ".")
gitCmd(t, repo, "commit", "-m", subject)
}

func gitCmd(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, output)
}
}
33 changes: 19 additions & 14 deletions docs/guides/releasing-new-version.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,26 @@ Check current version:
git tag -l | sort -V | tail -1
```

### 2. Update CHANGELOG.md
### 2. Draft and Review Release Notes

Add entry at the top of `CHANGELOG.md`:
Preview deterministic markdown from the local commit range. By default,
`td release-notes` uses commits from the nearest reachable semver tag through
`HEAD`.

```markdown
## [vX.Y.Z] - YYYY-MM-DD

### Features
- New feature description
```bash
td release-notes --version vX.Y.Z --date YYYY-MM-DD
```

### Bug Fixes
- Fix description
For an explicit range:

### Documentation
- Doc change description
```bash
td release-notes --from vX.Y.W --to HEAD --version vX.Y.Z --date YYYY-MM-DD
```

Review the output, adjust wording as needed, then add the entry at the top of
`CHANGELOG.md`. The command intentionally uses local git history only; it does
not call GitHub or generate AI-authored prose.

Commit the changelog:
```bash
git add CHANGELOG.md
Expand Down Expand Up @@ -134,8 +137,9 @@ Replace `X.Y.Z` with actual version:
git status
go test ./...

# Update changelog
# (Edit CHANGELOG.md, add entry at top)
# Draft release notes and update changelog
td release-notes --version vX.Y.Z --date YYYY-MM-DD
# Review output, edit CHANGELOG.md, add entry at top
git add CHANGELOG.md
git commit -m "docs: Update changelog for vX.Y.Z"

Expand All @@ -154,7 +158,8 @@ brew upgrade td && td version

- [ ] Tests pass (`go test ./...`)
- [ ] Working tree clean
- [ ] CHANGELOG.md updated with new version entry
- [ ] Release notes previewed with `td release-notes`
- [ ] CHANGELOG.md updated with reviewed release-note entry
- [ ] Changelog committed to git
- [ ] Version number follows semver
- [ ] Commits pushed to main
Expand Down
Loading
Loading