- 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>
224 lines
5.9 KiB
Go
224 lines
5.9 KiB
Go
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",
|
|
}
|