feat: initial stamp implementation

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

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

146
cmd/stamp/cmd_add.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}