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:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
bin/
|
||||
146
cmd/stamp/cmd_add.go
Normal file
146
cmd/stamp/cmd_add.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
)
|
||||
|
||||
func addCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "add",
|
||||
Usage: "create a new stamp (changeset) file",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "project",
|
||||
Usage: "project name to include (can be specified multiple times)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "bump",
|
||||
Usage: "bump type: major, minor, patch, premajor, preminor, prepatch, prerelease",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "message",
|
||||
Usage: "description of the change",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-interactive",
|
||||
Usage: "disable interactive prompts (requires --project, --bump, --message)",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "slug",
|
||||
Usage: "custom filename slug (default: auto-generated)",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
repoRoot, err := findRepoRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
|
||||
|
||||
noInteractive := cmd.Bool("no-interactive")
|
||||
|
||||
var projects []string
|
||||
var bumpType changeset.BumpType
|
||||
var message string
|
||||
|
||||
if noInteractive || cmd.IsSet("project") {
|
||||
projects = cmd.StringSlice("project")
|
||||
if len(projects) == 0 {
|
||||
return fmt.Errorf("--project is required in non-interactive mode")
|
||||
}
|
||||
bumpType = changeset.BumpType(cmd.String("bump"))
|
||||
if bumpType == "" {
|
||||
return fmt.Errorf("--bump is required in non-interactive mode")
|
||||
}
|
||||
message = cmd.String("message")
|
||||
if message == "" {
|
||||
return fmt.Errorf("--message is required in non-interactive mode")
|
||||
}
|
||||
} else {
|
||||
projects, bumpType, message, err = promptAdd(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate projects exist in config.
|
||||
for _, p := range projects {
|
||||
if cfg.FindProject(p) == nil {
|
||||
return fmt.Errorf("project %q not found in stamp.toml", p)
|
||||
}
|
||||
}
|
||||
|
||||
bumps := make(map[string]changeset.BumpType, len(projects))
|
||||
for _, p := range projects {
|
||||
bumps[p] = bumpType
|
||||
}
|
||||
|
||||
slug := cmd.String("slug")
|
||||
if slug == "" {
|
||||
slug = changeset.GenerateSlug()
|
||||
}
|
||||
|
||||
if err := changeset.Write(stampDir, slug, bumps, message); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Created .stamp/%s.md\n", slug)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// promptAdd runs an interactive prompt to collect add parameters.
|
||||
func promptAdd(cfg *config.Config) ([]string, changeset.BumpType, string, error) {
|
||||
projectNames := cfg.ProjectNames()
|
||||
|
||||
fmt.Println("Which projects are affected? (enter comma-separated names)")
|
||||
for _, name := range projectNames {
|
||||
fmt.Printf(" - %s\n", name)
|
||||
}
|
||||
fmt.Print("> ")
|
||||
|
||||
var input string
|
||||
if _, err := fmt.Scanln(&input); err != nil {
|
||||
return nil, "", "", fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
|
||||
var projects []string
|
||||
for _, p := range splitComma(input) {
|
||||
if cfg.FindProject(p) == nil {
|
||||
return nil, "", "", fmt.Errorf("project %q not found in stamp.toml", p)
|
||||
}
|
||||
projects = append(projects, p)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return nil, "", "", fmt.Errorf("at least one project must be specified")
|
||||
}
|
||||
|
||||
fmt.Println("Bump type? (major, minor, patch, premajor, preminor, prepatch, prerelease)")
|
||||
fmt.Print("> ")
|
||||
var bumpStr string
|
||||
if _, err := fmt.Scanln(&bumpStr); err != nil {
|
||||
return nil, "", "", fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
bumpType := changeset.BumpType(bumpStr)
|
||||
|
||||
fmt.Println("Description of the change:")
|
||||
fmt.Print("> ")
|
||||
var message string
|
||||
if _, err := fmt.Scanln(&message); err != nil {
|
||||
return nil, "", "", fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
|
||||
return projects, bumpType, message, nil
|
||||
}
|
||||
108
cmd/stamp/cmd_comment.go
Normal file
108
cmd/stamp/cmd_comment.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
"github.com/thokra/stamp/internal/semver"
|
||||
ghClient "github.com/thokra/stamp/internal/github"
|
||||
giteaClient "github.com/thokra/stamp/internal/gitea"
|
||||
)
|
||||
|
||||
func commentCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "comment",
|
||||
Usage: "post or update a PR comment summarising pending stamps",
|
||||
Flags: []cli.Flag{
|
||||
&cli.IntFlag{
|
||||
Name: "pr",
|
||||
Usage: "pull request number",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "repo",
|
||||
Usage: "repository slug owner/repo (defaults to STAMP_REPO or GITHUB_REPOSITORY env vars)",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
repoRoot, err := findRepoRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
|
||||
|
||||
changesets, err := changeset.ReadAll(stampDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
body := buildCommentBody(cfg, changesets)
|
||||
prNumber := int(cmd.Int("pr"))
|
||||
|
||||
repoSlug := cmd.String("repo")
|
||||
if repoSlug == "" {
|
||||
repoSlug = os.Getenv("STAMP_REPO")
|
||||
}
|
||||
if repoSlug == "" {
|
||||
repoSlug = os.Getenv("GITHUB_REPOSITORY")
|
||||
}
|
||||
if repoSlug == "" {
|
||||
return fmt.Errorf("--repo or STAMP_REPO / GITHUB_REPOSITORY env var must be set")
|
||||
}
|
||||
|
||||
if os.Getenv("GITEA_BASE_URL") != "" {
|
||||
client, err := giteaClient.NewClient(repoSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.UpsertPRComment(prNumber, body)
|
||||
}
|
||||
|
||||
client, err := ghClient.NewClient(repoSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return client.UpsertPRComment(ctx, prNumber, body)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCommentBody(cfg *config.Config, changesets []*changeset.Changeset) string {
|
||||
if len(changesets) == 0 {
|
||||
return "## ⚠️ No stamps found\n\n" +
|
||||
"This PR does not include a stamp file. If this change affects a versioned project, " +
|
||||
"please add a stamp by running:\n\n" +
|
||||
"```sh\nstamp add\n```\n\n" +
|
||||
"or creating a `.stamp/<name>.md` file manually."
|
||||
}
|
||||
|
||||
projectBumps := semver.ProjectBumps(changesets)
|
||||
body := fmt.Sprintf("## ✅ Stamps detected (%d)\n\n", len(changesets))
|
||||
body += "| Project | Current | Next | Bump |\n"
|
||||
body += "|---------|---------|------|------|\n"
|
||||
|
||||
for _, project := range cfg.Projects {
|
||||
bumps := projectBumps[project.Name]
|
||||
if len(bumps) == 0 {
|
||||
continue
|
||||
}
|
||||
highest := semver.HighestBump(bumps)
|
||||
next, err := semver.Bump(project.Version, highest, "")
|
||||
if err != nil {
|
||||
next = "?"
|
||||
}
|
||||
body += fmt.Sprintf("| %s | %s | %s | %s |\n", project.Name, project.Version, next, highest)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
73
cmd/stamp/cmd_publish.go
Normal file
73
cmd/stamp/cmd_publish.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
"github.com/thokra/stamp/internal/publish"
|
||||
)
|
||||
|
||||
func publishCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "publish",
|
||||
Usage: "create git tags, releases, and upload artifacts",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "dry-run",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "print what would be done without executing",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "project",
|
||||
Usage: "only publish a specific project",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
repoRoot, err := findRepoRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
repoSlug := os.Getenv("STAMP_REPO")
|
||||
opts := publish.Options{
|
||||
DryRun: cmd.Bool("dry-run"),
|
||||
RepoSlug: repoSlug,
|
||||
RepoRoot: repoRoot,
|
||||
}
|
||||
|
||||
filterProject := cmd.String("project")
|
||||
published := 0
|
||||
|
||||
for i := range cfg.Projects {
|
||||
project := &cfg.Projects[i]
|
||||
if filterProject != "" && project.Name != filterProject {
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("Publishing %s@%s...\n", project.Name, project.Version)
|
||||
if err := publish.Publish(cfg, project, opts); err != nil {
|
||||
return fmt.Errorf("publishing %s: %w", project.Name, err)
|
||||
}
|
||||
published++
|
||||
}
|
||||
|
||||
if published == 0 {
|
||||
if filterProject != "" {
|
||||
return fmt.Errorf("project %q not found in stamp.toml", filterProject)
|
||||
}
|
||||
fmt.Println("No projects to publish.")
|
||||
} else {
|
||||
fmt.Printf("\n✓ Published %d project(s).\n", published)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
119
cmd/stamp/cmd_status.go
Normal file
119
cmd/stamp/cmd_status.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
"github.com/thokra/stamp/internal/semver"
|
||||
)
|
||||
|
||||
// statusResult is the machine-readable form of stamp status.
|
||||
type statusResult struct {
|
||||
Pending int `json:"pending"`
|
||||
Projects []projectStatus `json:"projects"`
|
||||
}
|
||||
|
||||
type projectStatus struct {
|
||||
Name string `json:"name"`
|
||||
Current string `json:"current"`
|
||||
Next string `json:"next"`
|
||||
Bump string `json:"bump"`
|
||||
Changesets int `json:"changesets"`
|
||||
NoBump bool `json:"no_bump"`
|
||||
}
|
||||
|
||||
func statusCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "status",
|
||||
Usage: "show pending stamps and projected next versions",
|
||||
Flags: []cli.Flag{
|
||||
&cli.BoolFlag{
|
||||
Name: "json",
|
||||
Usage: "output as JSON",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
repoRoot, err := findRepoRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
|
||||
|
||||
changesets, err := changeset.ReadAll(stampDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
projectBumps := semver.ProjectBumps(changesets)
|
||||
|
||||
result := statusResult{
|
||||
Pending: len(changesets),
|
||||
}
|
||||
|
||||
for _, project := range cfg.Projects {
|
||||
bumps := projectBumps[project.Name]
|
||||
ps := projectStatus{
|
||||
Name: project.Name,
|
||||
Current: project.Version,
|
||||
Changesets: len(bumps),
|
||||
NoBump: len(bumps) == 0,
|
||||
}
|
||||
|
||||
if len(bumps) > 0 {
|
||||
highest := semver.HighestBump(bumps)
|
||||
ps.Bump = string(highest)
|
||||
next, err := semver.Bump(project.Version, highest, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("computing next version for %s: %w", project.Name, err)
|
||||
}
|
||||
ps.Next = next
|
||||
} else {
|
||||
ps.Next = project.Version
|
||||
}
|
||||
|
||||
result.Projects = append(result.Projects, ps)
|
||||
}
|
||||
|
||||
if cmd.Bool("json") {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(result)
|
||||
}
|
||||
|
||||
return printStatus(result)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func printStatus(result statusResult) error {
|
||||
if result.Pending == 0 {
|
||||
fmt.Println("⚠ No pending stamps found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("📦 Pending stamps: %d\n\n", result.Pending)
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, " PROJECT\tCURRENT\tNEXT\tBUMP\tSTAMPS")
|
||||
fmt.Fprintln(w, " -------\t-------\t----\t----\t------")
|
||||
for _, p := range result.Projects {
|
||||
if p.NoBump {
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t—\t0\n", p.Name, p.Current, p.Current)
|
||||
} else {
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%d\n", p.Name, p.Current, p.Next, p.Bump, p.Changesets)
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
138
cmd/stamp/cmd_version.go
Normal file
138
cmd/stamp/cmd_version.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/thokra/stamp/internal/changeset"
|
||||
"github.com/thokra/stamp/internal/changelog"
|
||||
"github.com/thokra/stamp/internal/config"
|
||||
"github.com/thokra/stamp/internal/git"
|
||||
"github.com/thokra/stamp/internal/semver"
|
||||
)
|
||||
|
||||
func versionCmd() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "version",
|
||||
Usage: "consume stamps, bump versions, and update changelogs",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "snapshot",
|
||||
Usage: "pre-release identifier (e.g. alpha, beta, rc) for snapshot releases",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "no-commit",
|
||||
Usage: "skip the git commit after versioning",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, cmd *cli.Command) error {
|
||||
repoRoot, err := findRepoRoot(".")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(repoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
|
||||
|
||||
changesets, err := changeset.ReadAll(stampDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if len(changesets) == 0 {
|
||||
fmt.Println("No pending stamps found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
preID := cmd.String("snapshot")
|
||||
projectBumps := semver.ProjectBumps(changesets)
|
||||
now := time.Now()
|
||||
|
||||
var changedFiles []string
|
||||
var bumped []string
|
||||
|
||||
for i := range cfg.Projects {
|
||||
project := &cfg.Projects[i]
|
||||
bumps := projectBumps[project.Name]
|
||||
if len(bumps) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
highest := semver.HighestBump(bumps)
|
||||
if preID != "" {
|
||||
highest = changeset.BumpPreRelease
|
||||
}
|
||||
|
||||
nextVer, err := semver.Bump(project.Version, highest, preID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bumping %s: %w", project.Name, err)
|
||||
}
|
||||
|
||||
// Build changelog body from all matching changesets.
|
||||
var body string
|
||||
for _, cs := range changesets {
|
||||
if _, ok := cs.Bumps[project.Name]; ok {
|
||||
if cs.Description != "" {
|
||||
body += "- " + cs.Description + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clPath := project.ChangelogPath(repoRoot)
|
||||
if err := changelog.Append(clPath, changelog.Entry{
|
||||
Version: nextVer,
|
||||
Date: now,
|
||||
Description: body,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("updating changelog for %s: %w", project.Name, err)
|
||||
}
|
||||
changedFiles = append(changedFiles, clPath)
|
||||
|
||||
fmt.Printf(" %s: %s → %s\n", project.Name, project.Version, nextVer)
|
||||
project.Version = nextVer
|
||||
bumped = append(bumped, project.Name)
|
||||
}
|
||||
|
||||
if len(bumped) == 0 {
|
||||
fmt.Println("No projects were bumped.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update stamp.toml with new versions.
|
||||
cfgPath := filepath.Join(repoRoot, config.ConfigFileName)
|
||||
if err := config.Save(repoRoot, cfg); err != nil {
|
||||
return fmt.Errorf("saving stamp.toml: %w", err)
|
||||
}
|
||||
changedFiles = append(changedFiles, cfgPath)
|
||||
|
||||
// Delete consumed stamp files.
|
||||
if err := changeset.DeleteAll(stampDir); err != nil {
|
||||
return fmt.Errorf("deleting stamp files: %w", err)
|
||||
}
|
||||
changedFiles = append(changedFiles, stampDir)
|
||||
|
||||
if cmd.Bool("no-commit") {
|
||||
fmt.Println("\nVersioning complete (no commit).")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stage and commit.
|
||||
if err := git.Add(repoRoot, changedFiles...); err != nil {
|
||||
return fmt.Errorf("staging files: %w", err)
|
||||
}
|
||||
|
||||
commitMsg := fmt.Sprintf("chore: version packages\n\nBumped: %v", bumped)
|
||||
if err := git.Commit(repoRoot, commitMsg); err != nil {
|
||||
return fmt.Errorf("committing: %w", err)
|
||||
}
|
||||
fmt.Println("\n✓ Committed version bump.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
31
cmd/stamp/helpers.go
Normal file
31
cmd/stamp/helpers.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// findRepoRoot walks up from dir to find the git repository root.
|
||||
func findRepoRoot(dir string) (string, error) {
|
||||
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not find git repository root: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// splitComma splits a comma-separated string and trims each part.
|
||||
func splitComma(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
29
cmd/stamp/main.go
Normal file
29
cmd/stamp/main.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := &cli.Command{
|
||||
Name: "stamp",
|
||||
Usage: "language-agnostic changesets-style versioning and changelog tool",
|
||||
Version: "0.1.0",
|
||||
Commands: []*cli.Command{
|
||||
addCmd(),
|
||||
statusCmd(),
|
||||
versionCmd(),
|
||||
publishCmd(),
|
||||
commentCmd(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := app.Run(context.Background(), os.Args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
229
docs/workflow.md
Normal file
229
docs/workflow.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# stamp — Workflow Guide
|
||||
|
||||
`stamp` is a language-agnostic, changesets-style versioning and changelog tool.
|
||||
It works with any project type and supports monorepos.
|
||||
|
||||
## Concepts
|
||||
|
||||
| Term | Description |
|
||||
|------|-------------|
|
||||
| **Stamp file** | A `.stamp/*.md` file describing a change and which projects it affects |
|
||||
| **Bump type** | How to increment a version: `major`, `minor`, `patch`, or pre-release variants |
|
||||
| `stamp version` | Consumes stamp files → bumps versions → updates changelogs |
|
||||
| `stamp publish` | Creates git tags, releases, and uploads artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install stamp
|
||||
|
||||
Via [Mise](https://mise.jdx.dev/):
|
||||
|
||||
```toml
|
||||
# mise.toml
|
||||
[tools]
|
||||
"go:github.com/thokra/stamp/cmd/stamp" = "latest"
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```sh
|
||||
go install github.com/thokra/stamp/cmd/stamp@latest
|
||||
```
|
||||
|
||||
### 2. Create stamp.toml
|
||||
|
||||
At the root of your repository:
|
||||
|
||||
```toml
|
||||
[[projects]]
|
||||
name = "my-app"
|
||||
path = "."
|
||||
version = "0.1.0"
|
||||
|
||||
[projects.publish]
|
||||
tags = true
|
||||
releases = true
|
||||
artifacts = ["dist/my-app-*"]
|
||||
```
|
||||
|
||||
See [`examples/stamp.toml`](../examples/stamp.toml) for a fully annotated example.
|
||||
|
||||
---
|
||||
|
||||
## Day-to-day Workflow
|
||||
|
||||
### Adding a stamp to your PR
|
||||
|
||||
When making a change that should be released, add a stamp file:
|
||||
|
||||
```sh
|
||||
# Interactive
|
||||
stamp add
|
||||
|
||||
# Non-interactive (useful in scripts or with AI agents)
|
||||
stamp add --project=my-app --bump=minor --message="Added X feature" --no-interactive
|
||||
```
|
||||
|
||||
This creates a `.stamp/<random-slug>.md` file, e.g.:
|
||||
|
||||
```markdown
|
||||
---
|
||||
bumps:
|
||||
my-app: minor
|
||||
---
|
||||
|
||||
Added X feature
|
||||
```
|
||||
|
||||
Commit and push the stamp file alongside your code changes.
|
||||
|
||||
### Checking pending stamps
|
||||
|
||||
```sh
|
||||
stamp status
|
||||
```
|
||||
|
||||
```
|
||||
📦 Pending stamps: 2
|
||||
|
||||
PROJECT CURRENT NEXT BUMP STAMPS
|
||||
------- ------- ---- ---- ------
|
||||
my-app 1.2.3 1.3.0 minor 2
|
||||
my-lib 0.1.0 0.1.0 — 0
|
||||
```
|
||||
|
||||
### Releasing
|
||||
|
||||
On your release branch (e.g. after merging PRs):
|
||||
|
||||
```sh
|
||||
# 1. Bump versions and update changelogs
|
||||
stamp version
|
||||
|
||||
# 2. Publish tags, releases, and artifacts
|
||||
STAMP_REPO=owner/repo stamp publish
|
||||
```
|
||||
|
||||
For a pre-release snapshot:
|
||||
|
||||
```sh
|
||||
stamp version --snapshot=alpha
|
||||
stamp publish
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PR Commenting (CI)
|
||||
|
||||
Use `stamp comment` in your CI pipeline to automatically comment on PRs:
|
||||
|
||||
- ⚠️ **No stamp file?** → warns the author and explains how to add one
|
||||
- ✅ **Stamps found?** → shows a table of affected projects and next versions
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/stamp-check.yml
|
||||
name: stamp check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install stamp
|
||||
run: go install github.com/thokra/stamp/cmd/stamp@latest
|
||||
|
||||
- name: Comment on PR
|
||||
run: stamp comment --pr=${{ github.event.pull_request.number }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STAMP_REPO: ${{ github.repository }}
|
||||
```
|
||||
|
||||
### Gitea Actions
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/stamp-check.yml
|
||||
name: stamp check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install stamp
|
||||
run: go install github.com/thokra/stamp/cmd/stamp@latest
|
||||
|
||||
- name: Comment on PR
|
||||
run: stamp comment --pr=${{ gitea.event.pull_request.number }}
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITEA_BASE_URL: ${{ gitea.server_url }}
|
||||
STAMP_REPO: ${{ gitea.repository }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stamp File Format
|
||||
|
||||
Stamp files live in `.stamp/` and use Markdown with either YAML or TOML frontmatter.
|
||||
|
||||
### YAML frontmatter (default)
|
||||
|
||||
```markdown
|
||||
---
|
||||
bumps:
|
||||
my-app: minor
|
||||
my-lib: patch
|
||||
---
|
||||
|
||||
Short description of the change used as the changelog entry.
|
||||
|
||||
- Optional bullet points
|
||||
- for more detail
|
||||
```
|
||||
|
||||
### TOML frontmatter
|
||||
|
||||
```markdown
|
||||
+++
|
||||
[bumps]
|
||||
my-app = "minor"
|
||||
+++
|
||||
|
||||
Short description.
|
||||
```
|
||||
|
||||
### Valid bump types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `major` | Breaking change (1.x.x → 2.0.0) |
|
||||
| `minor` | New feature (1.2.x → 1.3.0) |
|
||||
| `patch` | Bug fix (1.2.3 → 1.2.4) |
|
||||
| `premajor` | Pre-release major (→ 2.0.0-alpha.0) |
|
||||
| `preminor` | Pre-release minor (→ 1.3.0-beta.0) |
|
||||
| `prepatch` | Pre-release patch (→ 1.2.4-rc.0) |
|
||||
| `prerelease` | Increment pre-release (1.2.4-rc.0 → 1.2.4-rc.1) |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `STAMP_REPO` | Repository slug `owner/repo` used by publish and comment |
|
||||
| `GITHUB_TOKEN` | GitHub personal access token for releases and PR comments |
|
||||
| `GITEA_TOKEN` | Gitea access token |
|
||||
| `GITEA_BASE_URL` | Gitea instance URL (e.g. `https://gitea.example.com`) — also used to detect Gitea mode |
|
||||
46
examples/stamp.toml
Normal file
46
examples/stamp.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
# stamp.toml — annotated example configuration
|
||||
|
||||
# Global settings (all optional)
|
||||
[config]
|
||||
# The base branch used by `stamp comment` to detect what's changed in a PR.
|
||||
base_branch = "main"
|
||||
|
||||
# Directory where stamp files (.md changesets) are stored.
|
||||
# Default: .stamp
|
||||
changeset_dir = ".stamp"
|
||||
|
||||
# --- Projects ---
|
||||
# Define one [[projects]] block per versioned component.
|
||||
# For single-project repos, define just one block with name = "" (or any name).
|
||||
|
||||
[[projects]]
|
||||
name = "my-app"
|
||||
path = "apps/my-app" # path relative to repo root
|
||||
version = "1.2.3" # current version — updated by `stamp version`
|
||||
changelog = "CHANGELOG.md" # relative to path (default: CHANGELOG.md)
|
||||
|
||||
[projects.publish]
|
||||
# Create and push a git tag for this project on `stamp publish`.
|
||||
# Tag format: <name>@v<version> e.g. my-app@v1.2.3
|
||||
tags = true
|
||||
|
||||
# Create a GitHub/Gitea release with the changelog section as the release body.
|
||||
releases = true
|
||||
|
||||
# Glob patterns (relative to repo root) for files to upload as release artifacts.
|
||||
artifacts = [
|
||||
"apps/my-app/dist/my-app-linux-amd64",
|
||||
"apps/my-app/dist/my-app-darwin-arm64",
|
||||
"apps/my-app/dist/my-app-windows-amd64.exe",
|
||||
]
|
||||
|
||||
[[projects]]
|
||||
name = "my-lib"
|
||||
path = "libs/my-lib"
|
||||
version = "0.1.0"
|
||||
|
||||
[projects.publish]
|
||||
tags = true
|
||||
releases = true
|
||||
# No artifacts for a library.
|
||||
artifacts = []
|
||||
22
go.mod
Normal file
22
go.mod
Normal file
@@ -0,0 +1,22 @@
|
||||
module github.com/thokra/stamp
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.23.2
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/Masterminds/semver/v3 v3.4.0
|
||||
github.com/google/go-github/v70 v70.0.0
|
||||
github.com/urfave/cli/v3 v3.7.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
52
go.sum
Normal file
52
go.sum
Normal file
@@ -0,0 +1,52 @@
|
||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
|
||||
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v70 v70.0.0 h1:/tqCp5KPrcvqCc7vIvYyFYTiCGrYvaWoYMGHSQbo55o=
|
||||
github.com/google/go-github/v70 v70.0.0/go.mod h1:xBUZgo8MI3lUL/hwxl3hlceJW1U8MVnXP3zUyI+rhQY=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=
|
||||
github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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