feat: initial stamp implementation

- stamp add: create .stamp/*.md changeset files (interactive + --no-interactive)
- stamp status: show pending stamps and projected next versions (plain + --json)
- stamp version: consume stamps, bump semver, update CHANGELOGs, commit
- stamp publish: create git tags, GitHub/Gitea releases, upload artifacts
- stamp comment: post/update idempotent PR comments via GitHub/Gitea API
- Supports monorepos via stamp.toml [[projects]] blocks
- Changeset files support YAML (---) and TOML (+++) frontmatter
- Semver + pre-release support (alpha, beta, rc)
- Examples and workflow documentation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Thomas
2026-03-08 20:56:23 +01:00
commit 77462f5e8a
25 changed files with 2518 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
package changelog
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const header = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"
// Entry holds the data for one changelog entry.
type Entry struct {
Version string
Date time.Time
Description string
}
// Append prepends a new version entry to the CHANGELOG.md at the given path.
// If the file doesn't exist it will be created with a standard header.
func Append(path string, entry Entry) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating dir for %s: %w", path, err)
}
existing, err := os.ReadFile(path)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading %s: %w", path, err)
}
section := formatSection(entry)
var buf bytes.Buffer
if len(existing) == 0 {
buf.WriteString(header)
buf.WriteString(section)
} else {
content := string(existing)
// Insert after the header block if present, otherwise prepend.
insertIdx := findInsertPoint(content)
buf.WriteString(content[:insertIdx])
buf.WriteString(section)
buf.WriteString(content[insertIdx:])
}
return os.WriteFile(path, buf.Bytes(), 0644)
}
func formatSection(e Entry) string {
date := e.Date.Format("2006-01-02")
var buf bytes.Buffer
fmt.Fprintf(&buf, "## [%s] - %s\n\n", e.Version, date)
if strings.TrimSpace(e.Description) != "" {
buf.WriteString(strings.TrimSpace(e.Description))
buf.WriteString("\n")
}
buf.WriteString("\n")
return buf.String()
}
// findInsertPoint returns the index in content where new version sections should be inserted.
// It skips over the header (title + unreleased section if any), placing new entries at the top
// of the version list.
func findInsertPoint(content string) int {
lines := strings.Split(content, "\n")
for i, line := range lines {
// Find first ## [version] line (not ## [Unreleased])
if strings.HasPrefix(line, "## [") && !strings.HasPrefix(line, "## [Unreleased]") {
// Return position of this line
return positionOfLine(content, i)
}
}
// No existing version sections: append at end of file
return len(content)
}
func positionOfLine(content string, lineNum int) int {
pos := 0
for i, ch := range content {
if lineNum == 0 {
return i
}
if ch == '\n' {
lineNum--
}
_ = pos
}
return len(content)
}