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