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,223 @@
package changeset
import (
"bytes"
"fmt"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// BumpType represents a semver bump type.
type BumpType string
const (
BumpMajor BumpType = "major"
BumpMinor BumpType = "minor"
BumpPatch BumpType = "patch"
BumpPreMajor BumpType = "premajor"
BumpPreMinor BumpType = "preminor"
BumpPrePatch BumpType = "prepatch"
BumpPreRelease BumpType = "prerelease"
)
// ValidBumps is the set of valid bump type strings.
var ValidBumps = []BumpType{
BumpMajor, BumpMinor, BumpPatch,
BumpPreMajor, BumpPreMinor, BumpPrePatch, BumpPreRelease,
}
// Changeset represents a single .stamp/*.md file.
type Changeset struct {
// Slug is the filename without extension.
Slug string
// Bumps maps project name to bump type.
Bumps map[string]BumpType
// Description is the markdown body of the changeset.
Description string
}
// frontmatterYAML is the shape parsed from YAML frontmatter.
type frontmatterYAML struct {
Bumps map[string]string `yaml:"bumps"`
}
// frontmatterTOML is the shape parsed from TOML frontmatter.
type frontmatterTOML struct {
Bumps map[string]string `toml:"bumps"`
}
// Parse reads a .stamp/*.md file and returns a Changeset.
func Parse(path string) (*Changeset, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
return ParseBytes(filepath.Base(strings.TrimSuffix(path, ".md")), data)
}
// ParseBytes parses changeset content from raw bytes.
func ParseBytes(slug string, data []byte) (*Changeset, error) {
content := strings.TrimSpace(string(data))
var (
rawBumps map[string]string
body string
err error
)
switch {
case strings.HasPrefix(content, "---"):
rawBumps, body, err = parseYAMLFrontmatter(content)
case strings.HasPrefix(content, "+++"):
rawBumps, body, err = parseTOMLFrontmatter(content)
default:
return nil, fmt.Errorf("changeset %q: missing frontmatter (expected --- or +++)", slug)
}
if err != nil {
return nil, fmt.Errorf("changeset %q: %w", slug, err)
}
bumps := make(map[string]BumpType, len(rawBumps))
for project, raw := range rawBumps {
bt := BumpType(strings.ToLower(raw))
if !isValidBump(bt) {
return nil, fmt.Errorf("changeset %q: invalid bump type %q for project %q", slug, raw, project)
}
bumps[project] = bt
}
return &Changeset{
Slug: slug,
Bumps: bumps,
Description: strings.TrimSpace(body),
}, nil
}
func parseYAMLFrontmatter(content string) (map[string]string, string, error) {
// Strip opening ---
rest := strings.TrimPrefix(content, "---")
idx := strings.Index(rest, "\n---")
if idx < 0 {
return nil, "", fmt.Errorf("unterminated YAML frontmatter")
}
fm := rest[:idx]
body := strings.TrimSpace(rest[idx+4:]) // skip closing ---\n
var parsed frontmatterYAML
if err := yaml.Unmarshal([]byte(fm), &parsed); err != nil {
return nil, "", fmt.Errorf("invalid YAML frontmatter: %w", err)
}
return parsed.Bumps, body, nil
}
func parseTOMLFrontmatter(content string) (map[string]string, string, error) {
rest := strings.TrimPrefix(content, "+++")
idx := strings.Index(rest, "\n+++")
if idx < 0 {
return nil, "", fmt.Errorf("unterminated TOML frontmatter")
}
fm := rest[:idx]
body := strings.TrimSpace(rest[idx+4:])
var parsed frontmatterTOML
if err := toml.Unmarshal([]byte(fm), &parsed); err != nil {
return nil, "", fmt.Errorf("invalid TOML frontmatter: %w", err)
}
return parsed.Bumps, body, nil
}
// Write creates a new .stamp/<slug>.md file in the given directory.
func Write(dir, slug string, bumps map[string]BumpType, description string) error {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("creating %s: %w", dir, err)
}
path := filepath.Join(dir, slug+".md")
content, err := Format(bumps, description)
if err != nil {
return err
}
return os.WriteFile(path, content, 0644)
}
// Format renders a changeset to YAML-frontmatter markdown bytes.
func Format(bumps map[string]BumpType, description string) ([]byte, error) {
var buf bytes.Buffer
buf.WriteString("---\nbumps:\n")
for project, bump := range bumps {
fmt.Fprintf(&buf, " %s: %s\n", project, bump)
}
buf.WriteString("---\n\n")
buf.WriteString(description)
buf.WriteString("\n")
return buf.Bytes(), nil
}
// ReadAll reads all .stamp/*.md files from the given directory.
func ReadAll(dir string) ([]*Changeset, error) {
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, err
}
var result []*Changeset
for _, entry := range entries {
cs, err := Parse(entry)
if err != nil {
return nil, err
}
result = append(result, cs)
}
return result, nil
}
// DeleteAll removes all .stamp/*.md files from the given directory.
func DeleteAll(dir string) error {
entries, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return err
}
for _, entry := range entries {
if err := os.Remove(entry); err != nil {
return fmt.Errorf("removing %s: %w", entry, err)
}
}
return nil
}
// GenerateSlug returns a random adjective-noun slug, e.g. "brave-river".
func GenerateSlug() string {
r := rand.New(rand.NewSource(time.Now().UnixNano()))
adj := adjectives[r.Intn(len(adjectives))]
noun := nouns[r.Intn(len(nouns))]
return fmt.Sprintf("%s-%s", adj, noun)
}
func isValidBump(bt BumpType) bool {
for _, v := range ValidBumps {
if bt == v {
return true
}
}
return false
}
var adjectives = []string{
"brave", "calm", "clean", "dark", "eager", "fast", "fresh",
"gentle", "happy", "kind", "light", "loud", "neat", "quiet",
"rapid", "sharp", "silent", "smart", "soft", "sweet", "swift",
"warm", "wide", "wild", "young",
}
var nouns = []string{
"bird", "cloud", "dawn", "dew", "dust", "fire", "flame",
"flash", "frost", "gale", "hill", "lake", "leaf", "moon",
"rain", "river", "rock", "seed", "shadow", "sky", "snow",
"star", "storm", "stream", "tide", "tree", "wave", "wind",
}

View File

@@ -0,0 +1,137 @@
package changeset_test
import (
"os"
"path/filepath"
"testing"
"github.com/thokra/stamp/internal/changeset"
)
func TestParseYAMLFrontmatter(t *testing.T) {
content := `---
bumps:
my-app: minor
my-lib: patch
---
Added a cool feature.
- Detail one
`
cs, err := changeset.ParseBytes("add-feature", []byte(content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cs.Slug != "add-feature" {
t.Errorf("expected slug=add-feature, got %s", cs.Slug)
}
if cs.Bumps["my-app"] != changeset.BumpMinor {
t.Errorf("expected my-app=minor, got %s", cs.Bumps["my-app"])
}
if cs.Bumps["my-lib"] != changeset.BumpPatch {
t.Errorf("expected my-lib=patch, got %s", cs.Bumps["my-lib"])
}
if cs.Description == "" {
t.Error("expected non-empty description")
}
}
func TestParseTOMLFrontmatter(t *testing.T) {
content := `+++
[bumps]
my-app = "major"
+++
Breaking change.
`
cs, err := changeset.ParseBytes("breaking", []byte(content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cs.Bumps["my-app"] != changeset.BumpMajor {
t.Errorf("expected my-app=major, got %s", cs.Bumps["my-app"])
}
}
func TestParse_InvalidBumpType(t *testing.T) {
content := "---\nbumps:\n my-app: invalid\n---\ndesc\n"
_, err := changeset.ParseBytes("test", []byte(content))
if err == nil {
t.Fatal("expected error for invalid bump type")
}
}
func TestParse_MissingFrontmatter(t *testing.T) {
_, err := changeset.ParseBytes("test", []byte("just some text"))
if err == nil {
t.Fatal("expected error for missing frontmatter")
}
}
func TestParse_UnterminatedYAML(t *testing.T) {
content := "---\nbumps:\n my-app: minor\n"
_, err := changeset.ParseBytes("test", []byte(content))
if err == nil {
t.Fatal("expected error for unterminated YAML frontmatter")
}
}
func TestWriteAndReadAll(t *testing.T) {
dir := t.TempDir()
bumps := map[string]changeset.BumpType{
"my-app": changeset.BumpMinor,
}
if err := changeset.Write(dir, "my-slug", bumps, "A change description"); err != nil {
t.Fatalf("Write failed: %v", err)
}
path := filepath.Join(dir, "my-slug.md")
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected file to exist: %v", err)
}
all, err := changeset.ReadAll(dir)
if err != nil {
t.Fatalf("ReadAll failed: %v", err)
}
if len(all) != 1 {
t.Fatalf("expected 1 changeset, got %d", len(all))
}
if all[0].Bumps["my-app"] != changeset.BumpMinor {
t.Errorf("expected my-app=minor")
}
}
func TestDeleteAll(t *testing.T) {
dir := t.TempDir()
bumps := map[string]changeset.BumpType{"x": changeset.BumpPatch}
_ = changeset.Write(dir, "to-delete", bumps, "bye")
if err := changeset.DeleteAll(dir); err != nil {
t.Fatalf("DeleteAll failed: %v", err)
}
all, _ := changeset.ReadAll(dir)
if len(all) != 0 {
t.Errorf("expected 0 changesets after delete, got %d", len(all))
}
}
func TestGenerateSlug(t *testing.T) {
slugs := map[string]bool{}
for i := 0; i < 20; i++ {
s := changeset.GenerateSlug()
if s == "" {
t.Error("expected non-empty slug")
}
slugs[s] = true
}
// With 25 adjectives × 28 nouns = 700 combos, expect most to be unique across 20 runs.
if len(slugs) < 5 {
t.Errorf("expected more unique slugs, got %d unique from 20 runs", len(slugs))
}
}