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:
91
internal/changelog/changelog.go
Normal file
91
internal/changelog/changelog.go
Normal 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)
|
||||
}
|
||||
107
internal/changelog/changelog_test.go
Normal file
107
internal/changelog/changelog_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package changelog_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/thokra/stamp/internal/changelog"
|
||||
)
|
||||
|
||||
var testDate = time.Date(2026, 3, 8, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAppend_NewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
Description: "Initial release",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "## [1.0.0] - 2026-03-08") {
|
||||
t.Errorf("expected version header, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "Initial release") {
|
||||
t.Errorf("expected description in changelog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_ExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
// Write first version.
|
||||
_ = changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
Description: "First release",
|
||||
})
|
||||
|
||||
// Append second version.
|
||||
_ = changelog.Append(path, changelog.Entry{
|
||||
Version: "1.1.0",
|
||||
Date: testDate.Add(24 * time.Hour),
|
||||
Description: "Second release",
|
||||
})
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "## [1.0.0]") {
|
||||
t.Error("expected 1.0.0 section")
|
||||
}
|
||||
if !strings.Contains(content, "## [1.1.0]") {
|
||||
t.Error("expected 1.1.0 section")
|
||||
}
|
||||
|
||||
// New version should appear before old.
|
||||
idx110 := strings.Index(content, "## [1.1.0]")
|
||||
idx100 := strings.Index(content, "## [1.0.0]")
|
||||
if idx110 > idx100 {
|
||||
t.Error("expected 1.1.0 to appear before 1.0.0 (newest first)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_EmptyDescription(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "2.0.0",
|
||||
Date: testDate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(data), "## [2.0.0]") {
|
||||
t.Error("expected version header even with empty description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppend_CreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sub", "dir", "CHANGELOG.md")
|
||||
|
||||
err := changelog.Append(path, changelog.Entry{
|
||||
Version: "1.0.0",
|
||||
Date: testDate,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("expected file to be created: %v", err)
|
||||
}
|
||||
}
|
||||
223
internal/changeset/changeset.go
Normal file
223
internal/changeset/changeset.go
Normal 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",
|
||||
}
|
||||
137
internal/changeset/changeset_test.go
Normal file
137
internal/changeset/changeset_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
148
internal/config/config.go
Normal file
148
internal/config/config.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
const DefaultChangesetDir = ".stamp"
|
||||
const ConfigFileName = "stamp.toml"
|
||||
|
||||
// Config is the root configuration parsed from stamp.toml.
|
||||
type Config struct {
|
||||
Config GlobalConfig `toml:"config"`
|
||||
Projects []Project `toml:"projects"`
|
||||
}
|
||||
|
||||
// GlobalConfig holds repo-level settings.
|
||||
type GlobalConfig struct {
|
||||
BaseBranch string `toml:"base_branch,omitempty"`
|
||||
ChangesetDir string `toml:"changeset_dir,omitempty"`
|
||||
}
|
||||
|
||||
// Project represents a single project in the monorepo.
|
||||
type Project struct {
|
||||
Name string `toml:"name"`
|
||||
Path string `toml:"path"`
|
||||
Version string `toml:"version"`
|
||||
Changelog string `toml:"changelog,omitempty"`
|
||||
Publish PublishConfig `toml:"publish,omitempty"`
|
||||
}
|
||||
|
||||
// PublishConfig controls what happens during stamp publish.
|
||||
type PublishConfig struct {
|
||||
Tags *bool `toml:"tags"`
|
||||
Releases *bool `toml:"releases"`
|
||||
Artifacts []string `toml:"artifacts"`
|
||||
}
|
||||
|
||||
// ChangesetDir returns the configured changeset directory, defaulting to .stamp.
|
||||
func (c *Config) ChangesetDir() string {
|
||||
if c.Config.ChangesetDir != "" {
|
||||
return c.Config.ChangesetDir
|
||||
}
|
||||
return DefaultChangesetDir
|
||||
}
|
||||
|
||||
// BaseBranch returns the configured base branch, defaulting to "main".
|
||||
func (c *Config) BaseBranch() string {
|
||||
if c.Config.BaseBranch != "" {
|
||||
return c.Config.BaseBranch
|
||||
}
|
||||
return "main"
|
||||
}
|
||||
|
||||
// FindProject returns the project with the given name, or nil if not found.
|
||||
func (c *Config) FindProject(name string) *Project {
|
||||
for i := range c.Projects {
|
||||
if c.Projects[i].Name == name {
|
||||
return &c.Projects[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectNames returns all project names.
|
||||
func (c *Config) ProjectNames() []string {
|
||||
names := make([]string, len(c.Projects))
|
||||
for i, p := range c.Projects {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ChangelogPath returns the path to CHANGELOG.md for a project, relative to repoRoot.
|
||||
func (p *Project) ChangelogPath(repoRoot string) string {
|
||||
changelog := p.Changelog
|
||||
if changelog == "" {
|
||||
changelog = "CHANGELOG.md"
|
||||
}
|
||||
return filepath.Join(repoRoot, p.Path, changelog)
|
||||
}
|
||||
|
||||
// PublishTags returns true unless explicitly disabled.
|
||||
func (p *PublishConfig) PublishTags() bool {
|
||||
return p.Tags == nil || *p.Tags
|
||||
}
|
||||
|
||||
// PublishReleases returns true unless explicitly disabled.
|
||||
func (p *PublishConfig) PublishReleases() bool {
|
||||
return p.Releases == nil || *p.Releases
|
||||
}
|
||||
|
||||
// Load reads and validates stamp.toml from the given directory.
|
||||
func Load(dir string) (*Config, error) {
|
||||
path := filepath.Join(dir, ConfigFileName)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
if err := validate(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid %s: %w", path, err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config back to stamp.toml in the given directory.
|
||||
func Save(dir string, cfg *Config) error {
|
||||
path := filepath.Join(dir, ConfigFileName)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
enc := toml.NewEncoder(f)
|
||||
return enc.Encode(cfg)
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if len(cfg.Projects) == 0 {
|
||||
return fmt.Errorf("at least one [[projects]] entry is required")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, p := range cfg.Projects {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("projects[%d]: name is required", i)
|
||||
}
|
||||
if seen[p.Name] {
|
||||
return fmt.Errorf("projects[%d]: duplicate project name %q", i, p.Name)
|
||||
}
|
||||
seen[p.Name] = true
|
||||
if p.Version == "" {
|
||||
return fmt.Errorf("project %q: version is required", p.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
132
internal/config/config_test.go
Normal file
132
internal/config/config_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
)
|
||||
|
||||
const validTOML = `
|
||||
[config]
|
||||
base_branch = "main"
|
||||
changeset_dir = ".stamp"
|
||||
|
||||
[[projects]]
|
||||
name = "my-app"
|
||||
path = "apps/my-app"
|
||||
version = "1.2.3"
|
||||
|
||||
[[projects]]
|
||||
name = "my-lib"
|
||||
path = "libs/my-lib"
|
||||
version = "0.1.0"
|
||||
`
|
||||
|
||||
func TestLoad_Valid(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Projects) != 2 {
|
||||
t.Fatalf("expected 2 projects, got %d", len(cfg.Projects))
|
||||
}
|
||||
if cfg.BaseBranch() != "main" {
|
||||
t.Errorf("expected base_branch=main, got %s", cfg.BaseBranch())
|
||||
}
|
||||
if cfg.ChangesetDir() != ".stamp" {
|
||||
t.Errorf("expected changeset_dir=.stamp, got %s", cfg.ChangesetDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_Defaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
minimal := "[[projects]]\nname = \"app\"\npath = \".\"\nversion = \"1.0.0\"\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(minimal), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if cfg.BaseBranch() != "main" {
|
||||
t.Errorf("default base_branch should be main, got %s", cfg.BaseBranch())
|
||||
}
|
||||
if cfg.ChangesetDir() != ".stamp" {
|
||||
t.Errorf("default changeset_dir should be .stamp, got %s", cfg.ChangesetDir())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingFile(t *testing.T) {
|
||||
_, err := config.Load(t.TempDir())
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing stamp.toml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_NoProjects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte("[config]\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := config.Load(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for config with no projects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_DuplicateProjectName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dup := `
|
||||
[[projects]]
|
||||
name = "app"
|
||||
path = "."
|
||||
version = "1.0.0"
|
||||
|
||||
[[projects]]
|
||||
name = "app"
|
||||
path = "other"
|
||||
version = "2.0.0"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(dup), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := config.Load(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate project name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindProject(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "stamp.toml"), []byte(validTOML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, _ := config.Load(dir)
|
||||
|
||||
if p := cfg.FindProject("my-app"); p == nil {
|
||||
t.Error("expected to find my-app")
|
||||
}
|
||||
if p := cfg.FindProject("nonexistent"); p != nil {
|
||||
t.Error("expected nil for nonexistent project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishDefaults(t *testing.T) {
|
||||
var pc config.PublishConfig
|
||||
if !pc.PublishTags() {
|
||||
t.Error("tags should default to true")
|
||||
}
|
||||
if !pc.PublishReleases() {
|
||||
t.Error("releases should default to true")
|
||||
}
|
||||
}
|
||||
84
internal/git/git.go
Normal file
84
internal/git/git.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Run executes a git command in the given directory and returns combined output.
|
||||
func Run(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// RepoRoot returns the root of the git repository.
|
||||
func RepoRoot(dir string) (string, error) {
|
||||
return Run(dir, "rev-parse", "--show-toplevel")
|
||||
}
|
||||
|
||||
// Add stages the given paths.
|
||||
func Add(dir string, paths ...string) error {
|
||||
args := append([]string{"add", "--"}, paths...)
|
||||
_, err := Run(dir, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit creates a commit with the given message.
|
||||
func Commit(dir, message string) error {
|
||||
_, err := Run(dir, "commit", "-m", message)
|
||||
return err
|
||||
}
|
||||
|
||||
// Tag creates an annotated tag.
|
||||
func Tag(dir, name, message string) error {
|
||||
_, err := Run(dir, "tag", "-a", name, "-m", message)
|
||||
return err
|
||||
}
|
||||
|
||||
// TagLightweight creates a lightweight tag.
|
||||
func TagLightweight(dir, name string) error {
|
||||
_, err := Run(dir, "tag", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// Push pushes the current branch and all tags to origin.
|
||||
func Push(dir string) error {
|
||||
_, err := Run(dir, "push", "--follow-tags")
|
||||
return err
|
||||
}
|
||||
|
||||
// PushTag pushes a specific tag to origin.
|
||||
func PushTag(dir, tag string) error {
|
||||
_, err := Run(dir, "push", "origin", tag)
|
||||
return err
|
||||
}
|
||||
|
||||
// CurrentBranch returns the current branch name.
|
||||
func CurrentBranch(dir string) (string, error) {
|
||||
return Run(dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
}
|
||||
|
||||
// TagName returns the conventional tag name for a project version.
|
||||
// For a single-project repo (projectName == "") it returns "v<version>".
|
||||
// For monorepos it returns "<projectName>@v<version>".
|
||||
func TagName(projectName, version string) string {
|
||||
if projectName == "" {
|
||||
return "v" + version
|
||||
}
|
||||
return projectName + "@v" + version
|
||||
}
|
||||
|
||||
// TagExists returns true if the tag already exists locally.
|
||||
func TagExists(dir, tag string) (bool, error) {
|
||||
out, err := Run(dir, "tag", "-l", tag)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) == tag, nil
|
||||
}
|
||||
98
internal/gitea/gitea.go
Normal file
98
internal/gitea/gitea.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
giteaSDK "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
// Client wraps the Gitea API.
|
||||
type Client struct {
|
||||
client *giteaSDK.Client
|
||||
owner string
|
||||
repo string
|
||||
}
|
||||
|
||||
const commentMarker = "<!-- stamp-pr-comment -->"
|
||||
|
||||
// NewClient creates a Gitea client from GITEA_TOKEN and GITEA_BASE_URL environment variables.
|
||||
// The repoSlug must be in "owner/repo" format.
|
||||
func NewClient(repoSlug string) (*Client, error) {
|
||||
token := os.Getenv("GITEA_TOKEN")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("GITEA_TOKEN environment variable is not set")
|
||||
}
|
||||
baseURL := os.Getenv("GITEA_BASE_URL")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("GITEA_BASE_URL environment variable is not set")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(repoSlug, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid repo slug %q: expected owner/repo", repoSlug)
|
||||
}
|
||||
|
||||
client, err := giteaSDK.NewClient(baseURL, giteaSDK.SetToken(token))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating Gitea client: %w", err)
|
||||
}
|
||||
return &Client{client: client, owner: parts[0], repo: parts[1]}, nil
|
||||
}
|
||||
|
||||
// CreateRelease creates a Gitea release for the given tag.
|
||||
func (c *Client) CreateRelease(tag, name, body string, draft, prerelease bool) (int64, error) {
|
||||
rel, _, err := c.client.CreateRelease(c.owner, c.repo, giteaSDK.CreateReleaseOption{
|
||||
TagName: tag,
|
||||
Title: name,
|
||||
Note: body,
|
||||
IsDraft: draft,
|
||||
IsPrerelease: prerelease,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating Gitea release %s: %w", tag, err)
|
||||
}
|
||||
return rel.ID, nil
|
||||
}
|
||||
|
||||
// UploadAsset uploads a file to an existing Gitea release.
|
||||
func (c *Client) UploadAsset(releaseID int64, filePath string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
name := strings.Split(filePath, "/")
|
||||
fileName := name[len(name)-1]
|
||||
|
||||
_, _, err = c.client.CreateReleaseAttachment(c.owner, c.repo, releaseID, f, fileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading %s to release %d: %w", fileName, releaseID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertPRComment posts or updates a stamped PR comment.
|
||||
func (c *Client) UpsertPRComment(prNumber int, body string) error {
|
||||
markedBody := commentMarker + "\n" + body
|
||||
|
||||
comments, _, err := c.client.ListIssueComments(c.owner, c.repo, int64(prNumber),
|
||||
giteaSDK.ListIssueCommentOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing PR comments: %w", err)
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if strings.Contains(comment.Body, commentMarker) {
|
||||
_, _, err = c.client.EditIssueComment(c.owner, c.repo, comment.ID,
|
||||
giteaSDK.EditIssueCommentOption{Body: markedBody})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _, err = c.client.CreateIssueComment(c.owner, c.repo, int64(prNumber),
|
||||
giteaSDK.CreateIssueCommentOption{Body: markedBody})
|
||||
return err
|
||||
}
|
||||
96
internal/github/github.go
Normal file
96
internal/github/github.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
gh "github.com/google/go-github/v70/github"
|
||||
)
|
||||
|
||||
// Client wraps the GitHub REST API.
|
||||
type Client struct {
|
||||
client *gh.Client
|
||||
owner string
|
||||
repo string
|
||||
}
|
||||
|
||||
// NewClient creates a GitHub client from the GITHUB_TOKEN environment variable.
|
||||
// The repoSlug must be in "owner/repo" format.
|
||||
func NewClient(repoSlug string) (*Client, error) {
|
||||
token := os.Getenv("GITHUB_TOKEN")
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("GITHUB_TOKEN environment variable is not set")
|
||||
}
|
||||
parts := strings.SplitN(repoSlug, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("invalid repo slug %q: expected owner/repo", repoSlug)
|
||||
}
|
||||
return &Client{
|
||||
client: gh.NewClient(nil).WithAuthToken(token),
|
||||
owner: parts[0],
|
||||
repo: parts[1],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateRelease creates a GitHub release for the given tag.
|
||||
func (c *Client) CreateRelease(ctx context.Context, tag, name, body string, draft, prerelease bool) (int64, error) {
|
||||
rel, _, err := c.client.Repositories.CreateRelease(ctx, c.owner, c.repo, &gh.RepositoryRelease{
|
||||
TagName: gh.Ptr(tag),
|
||||
Name: gh.Ptr(name),
|
||||
Body: gh.Ptr(body),
|
||||
Draft: gh.Ptr(draft),
|
||||
Prerelease: gh.Ptr(prerelease),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("creating GitHub release %s: %w", tag, err)
|
||||
}
|
||||
return rel.GetID(), nil
|
||||
}
|
||||
|
||||
// UploadAsset uploads a file to an existing release.
|
||||
func (c *Client) UploadAsset(ctx context.Context, releaseID int64, filePath string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", filePath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
name := strings.Split(filePath, "/")
|
||||
fileName := name[len(name)-1]
|
||||
|
||||
_, _, err = c.client.Repositories.UploadReleaseAsset(ctx, c.owner, c.repo, releaseID,
|
||||
&gh.UploadOptions{Name: fileName}, f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("uploading %s to release %d: %w", fileName, releaseID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const commentMarker = "<!-- stamp-pr-comment -->"
|
||||
|
||||
// UpsertPRComment posts or updates a stamped PR comment (identified by a hidden marker).
|
||||
func (c *Client) UpsertPRComment(ctx context.Context, prNumber int, body string) error {
|
||||
markedBody := commentMarker + "\n" + body
|
||||
|
||||
// List existing comments to find ours.
|
||||
comments, _, err := c.client.Issues.ListComments(ctx, c.owner, c.repo, prNumber, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing PR comments: %w", err)
|
||||
}
|
||||
|
||||
for _, comment := range comments {
|
||||
if strings.Contains(comment.GetBody(), commentMarker) {
|
||||
// Update existing comment.
|
||||
_, _, err = c.client.Issues.EditComment(ctx, c.owner, c.repo, comment.GetID(),
|
||||
&gh.IssueComment{Body: gh.Ptr(markedBody)})
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create new comment.
|
||||
_, _, err = c.client.Issues.CreateComment(ctx, c.owner, c.repo, prNumber,
|
||||
&gh.IssueComment{Body: gh.Ptr(markedBody)})
|
||||
return err
|
||||
}
|
||||
141
internal/publish/publish.go
Normal file
141
internal/publish/publish.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package publish
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
"github.com/thokra/stamp/internal/git"
|
||||
ghClient "github.com/thokra/stamp/internal/github"
|
||||
giteaClient "github.com/thokra/stamp/internal/gitea"
|
||||
)
|
||||
|
||||
// Options controls publish behaviour.
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
RepoSlug string // "owner/repo" — required for releases
|
||||
RepoRoot string
|
||||
}
|
||||
|
||||
// Publish creates tags, releases, and uploads artifacts for a project.
|
||||
func Publish(cfg *config.Config, project *config.Project, opts Options) error {
|
||||
tag := git.TagName(project.Name, project.Version)
|
||||
|
||||
// --- Git tag ---
|
||||
if project.Publish.PublishTags() {
|
||||
if opts.DryRun {
|
||||
fmt.Printf("[dry-run] would create git tag %s\n", tag)
|
||||
} else {
|
||||
exists, err := git.TagExists(opts.RepoRoot, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
fmt.Printf("tag %s already exists, skipping\n", tag)
|
||||
} else {
|
||||
if err := git.Tag(opts.RepoRoot, tag, fmt.Sprintf("release %s %s", project.Name, project.Version)); err != nil {
|
||||
return fmt.Errorf("creating tag %s: %w", tag, err)
|
||||
}
|
||||
if err := git.PushTag(opts.RepoRoot, tag); err != nil {
|
||||
return fmt.Errorf("pushing tag %s: %w", tag, err)
|
||||
}
|
||||
fmt.Printf("created and pushed tag %s\n", tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !project.Publish.PublishReleases() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if opts.RepoSlug == "" {
|
||||
return fmt.Errorf("STAMP_REPO (owner/repo) must be set to publish releases")
|
||||
}
|
||||
|
||||
// --- Collect artifact paths ---
|
||||
var artifacts []string
|
||||
for _, pattern := range project.Publish.Artifacts {
|
||||
matches, err := filepath.Glob(filepath.Join(opts.RepoRoot, pattern))
|
||||
if err != nil {
|
||||
return fmt.Errorf("glob %q: %w", pattern, err)
|
||||
}
|
||||
artifacts = append(artifacts, matches...)
|
||||
}
|
||||
|
||||
releaseTitle := fmt.Sprintf("%s v%s", project.Name, project.Version)
|
||||
releaseBody := fmt.Sprintf("Release of %s version %s", project.Name, project.Version)
|
||||
|
||||
// Detect host: GitHub vs Gitea
|
||||
if isGitea() {
|
||||
if err := publishGitea(opts, tag, releaseTitle, releaseBody, artifacts); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := publishGitHub(opts, tag, releaseTitle, releaseBody, artifacts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishGitHub(opts Options, tag, title, body string, artifacts []string) error {
|
||||
client, err := ghClient.NewClient(opts.RepoSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
if opts.DryRun {
|
||||
fmt.Printf("[dry-run] would create GitHub release %s with %d artifact(s)\n", tag, len(artifacts))
|
||||
return nil
|
||||
}
|
||||
|
||||
releaseID, err := client.CreateRelease(ctx, tag, title, body, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created GitHub release %s (id=%d)\n", tag, releaseID)
|
||||
|
||||
for _, a := range artifacts {
|
||||
if err := client.UploadAsset(ctx, releaseID, a); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("uploaded artifact: %s\n", a)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func publishGitea(opts Options, tag, title, body string, artifacts []string) error {
|
||||
client, err := giteaClient.NewClient(opts.RepoSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
fmt.Printf("[dry-run] would create Gitea release %s with %d artifact(s)\n", tag, len(artifacts))
|
||||
return nil
|
||||
}
|
||||
|
||||
releaseID, err := client.CreateRelease(tag, title, body, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created Gitea release %s (id=%d)\n", tag, releaseID)
|
||||
|
||||
for _, a := range artifacts {
|
||||
if err := client.UploadAsset(releaseID, a); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("uploaded artifact: %s\n", a)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isGitea returns true when Gitea environment variables are configured.
|
||||
func isGitea() bool {
|
||||
return os.Getenv("GITEA_BASE_URL") != ""
|
||||
}
|
||||
123
internal/semver/semver.go
Normal file
123
internal/semver/semver.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package semver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
goSemver "github.com/Masterminds/semver/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
)
|
||||
|
||||
// Bump computes the next version given the current version string and a bump type.
|
||||
// preID is the pre-release identifier (e.g. "alpha", "beta", "rc") used for pre-* bumps.
|
||||
func Bump(current string, bump changeset.BumpType, preID string) (string, error) {
|
||||
v, err := goSemver.NewVersion(current)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid version %q: %w", current, err)
|
||||
}
|
||||
|
||||
if preID == "" {
|
||||
preID = "0"
|
||||
}
|
||||
|
||||
switch bump {
|
||||
case changeset.BumpMajor:
|
||||
next := v.IncMajor()
|
||||
return next.Original(), nil
|
||||
|
||||
case changeset.BumpMinor:
|
||||
next := v.IncMinor()
|
||||
return next.Original(), nil
|
||||
|
||||
case changeset.BumpPatch:
|
||||
next := v.IncPatch()
|
||||
return next.Original(), nil
|
||||
|
||||
case changeset.BumpPreMajor:
|
||||
next := v.IncMajor()
|
||||
pre, err := goSemver.NewVersion(fmt.Sprintf("%d.%d.%d-%s.0", next.Major(), next.Minor(), next.Patch(), preID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pre.Original(), nil
|
||||
|
||||
case changeset.BumpPreMinor:
|
||||
next := v.IncMinor()
|
||||
pre, err := goSemver.NewVersion(fmt.Sprintf("%d.%d.%d-%s.0", next.Major(), next.Minor(), next.Patch(), preID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pre.Original(), nil
|
||||
|
||||
case changeset.BumpPrePatch:
|
||||
next := v.IncPatch()
|
||||
pre, err := goSemver.NewVersion(fmt.Sprintf("%d.%d.%d-%s.0", next.Major(), next.Minor(), next.Patch(), preID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pre.Original(), nil
|
||||
|
||||
case changeset.BumpPreRelease:
|
||||
// If already a pre-release with matching identifier, increment the pre-release number.
|
||||
// Otherwise start a fresh pre-release on current patch.
|
||||
preStr := v.Prerelease()
|
||||
if preStr != "" && strings.HasPrefix(preStr, preID+".") {
|
||||
parts := strings.SplitN(preStr, ".", 2)
|
||||
var num int
|
||||
fmt.Sscanf(parts[1], "%d", &num)
|
||||
next, err := goSemver.NewVersion(fmt.Sprintf("%d.%d.%d-%s.%d",
|
||||
v.Major(), v.Minor(), v.Patch(), preID, num+1))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return next.Original(), nil
|
||||
}
|
||||
// Start a new pre-release on next patch.
|
||||
patch := v.IncPatch()
|
||||
next, err := goSemver.NewVersion(fmt.Sprintf("%d.%d.%d-%s.0",
|
||||
patch.Major(), patch.Minor(), patch.Patch(), preID))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return next.Original(), nil
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown bump type %q", bump)
|
||||
}
|
||||
}
|
||||
|
||||
// HighestBump returns the highest-priority bump type from a set of bumps.
|
||||
// Priority: major > minor > patch > premajor > preminor > prepatch > prerelease.
|
||||
func HighestBump(bumps []changeset.BumpType) changeset.BumpType {
|
||||
priority := map[changeset.BumpType]int{
|
||||
changeset.BumpMajor: 6,
|
||||
changeset.BumpMinor: 5,
|
||||
changeset.BumpPatch: 4,
|
||||
changeset.BumpPreMajor: 3,
|
||||
changeset.BumpPreMinor: 2,
|
||||
changeset.BumpPrePatch: 1,
|
||||
changeset.BumpPreRelease: 0,
|
||||
}
|
||||
|
||||
best := changeset.BumpPreRelease
|
||||
bestP := -1
|
||||
for _, b := range bumps {
|
||||
if p, ok := priority[b]; ok && p > bestP {
|
||||
best = b
|
||||
bestP = p
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ProjectBumps aggregates bump types per project from a list of changesets.
|
||||
func ProjectBumps(changesets []*changeset.Changeset) map[string][]changeset.BumpType {
|
||||
result := map[string][]changeset.BumpType{}
|
||||
for _, cs := range changesets {
|
||||
for project, bump := range cs.Bumps {
|
||||
result[project] = append(result[project], bump)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
130
internal/semver/semver_test.go
Normal file
130
internal/semver/semver_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package semver_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
"github.com/thokra/stamp/internal/semver"
|
||||
)
|
||||
|
||||
func TestBump_Major(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpMajor, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2.0.0" {
|
||||
t.Errorf("expected 2.0.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_Minor(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpMinor, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.3.0" {
|
||||
t.Errorf("expected 1.3.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_Patch(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpPatch, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.2.4" {
|
||||
t.Errorf("expected 1.2.4, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_PreMajor(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpPreMajor, "alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2.0.0-alpha.0" {
|
||||
t.Errorf("expected 2.0.0-alpha.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_PreMinor(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpPreMinor, "beta")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.3.0-beta.0" {
|
||||
t.Errorf("expected 1.3.0-beta.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_PrePatch(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpPrePatch, "rc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.2.4-rc.0" {
|
||||
t.Errorf("expected 1.2.4-rc.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_PreRelease_Increment(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.4-rc.0", changeset.BumpPreRelease, "rc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.2.4-rc.1" {
|
||||
t.Errorf("expected 1.2.4-rc.1, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_PreRelease_NewFromStable(t *testing.T) {
|
||||
got, err := semver.Bump("1.2.3", changeset.BumpPreRelease, "alpha")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "1.2.4-alpha.0" {
|
||||
t.Errorf("expected 1.2.4-alpha.0, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBump_InvalidVersion(t *testing.T) {
|
||||
_, err := semver.Bump("not-a-version", changeset.BumpPatch, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighestBump(t *testing.T) {
|
||||
tests := []struct {
|
||||
bumps []changeset.BumpType
|
||||
expected changeset.BumpType
|
||||
}{
|
||||
{[]changeset.BumpType{changeset.BumpPatch, changeset.BumpMinor}, changeset.BumpMinor},
|
||||
{[]changeset.BumpType{changeset.BumpMinor, changeset.BumpMajor}, changeset.BumpMajor},
|
||||
{[]changeset.BumpType{changeset.BumpPreRelease}, changeset.BumpPreRelease},
|
||||
{[]changeset.BumpType{changeset.BumpPreRelease, changeset.BumpPatch}, changeset.BumpPatch},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := semver.HighestBump(tt.bumps)
|
||||
if got != tt.expected {
|
||||
t.Errorf("HighestBump(%v) = %s, want %s", tt.bumps, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectBumps(t *testing.T) {
|
||||
changesets := []*changeset.Changeset{
|
||||
{Slug: "a", Bumps: map[string]changeset.BumpType{"app": changeset.BumpMinor, "lib": changeset.BumpPatch}},
|
||||
{Slug: "b", Bumps: map[string]changeset.BumpType{"app": changeset.BumpPatch}},
|
||||
}
|
||||
|
||||
bumps := semver.ProjectBumps(changesets)
|
||||
|
||||
if len(bumps["app"]) != 2 {
|
||||
t.Errorf("expected 2 bumps for app, got %d", len(bumps["app"]))
|
||||
}
|
||||
if len(bumps["lib"]) != 1 {
|
||||
t.Errorf("expected 1 bump for lib, got %d", len(bumps["lib"]))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user