feat: initial stamp implementation

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

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

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
bin/

146
cmd/stamp/cmd_add.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"context"
"fmt"
"path/filepath"
"github.com/urfave/cli/v3"
"github.com/thokra/stamp/internal/changeset"
"github.com/thokra/stamp/internal/config"
)
func addCmd() *cli.Command {
return &cli.Command{
Name: "add",
Usage: "create a new stamp (changeset) file",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "project",
Usage: "project name to include (can be specified multiple times)",
},
&cli.StringFlag{
Name: "bump",
Usage: "bump type: major, minor, patch, premajor, preminor, prepatch, prerelease",
},
&cli.StringFlag{
Name: "message",
Usage: "description of the change",
},
&cli.BoolFlag{
Name: "no-interactive",
Usage: "disable interactive prompts (requires --project, --bump, --message)",
},
&cli.StringFlag{
Name: "slug",
Usage: "custom filename slug (default: auto-generated)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
noInteractive := cmd.Bool("no-interactive")
var projects []string
var bumpType changeset.BumpType
var message string
if noInteractive || cmd.IsSet("project") {
projects = cmd.StringSlice("project")
if len(projects) == 0 {
return fmt.Errorf("--project is required in non-interactive mode")
}
bumpType = changeset.BumpType(cmd.String("bump"))
if bumpType == "" {
return fmt.Errorf("--bump is required in non-interactive mode")
}
message = cmd.String("message")
if message == "" {
return fmt.Errorf("--message is required in non-interactive mode")
}
} else {
projects, bumpType, message, err = promptAdd(cfg)
if err != nil {
return err
}
}
// Validate projects exist in config.
for _, p := range projects {
if cfg.FindProject(p) == nil {
return fmt.Errorf("project %q not found in stamp.toml", p)
}
}
bumps := make(map[string]changeset.BumpType, len(projects))
for _, p := range projects {
bumps[p] = bumpType
}
slug := cmd.String("slug")
if slug == "" {
slug = changeset.GenerateSlug()
}
if err := changeset.Write(stampDir, slug, bumps, message); err != nil {
return err
}
fmt.Printf("✓ Created .stamp/%s.md\n", slug)
return nil
},
}
}
// promptAdd runs an interactive prompt to collect add parameters.
func promptAdd(cfg *config.Config) ([]string, changeset.BumpType, string, error) {
projectNames := cfg.ProjectNames()
fmt.Println("Which projects are affected? (enter comma-separated names)")
for _, name := range projectNames {
fmt.Printf(" - %s\n", name)
}
fmt.Print("> ")
var input string
if _, err := fmt.Scanln(&input); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
}
var projects []string
for _, p := range splitComma(input) {
if cfg.FindProject(p) == nil {
return nil, "", "", fmt.Errorf("project %q not found in stamp.toml", p)
}
projects = append(projects, p)
}
if len(projects) == 0 {
return nil, "", "", fmt.Errorf("at least one project must be specified")
}
fmt.Println("Bump type? (major, minor, patch, premajor, preminor, prepatch, prerelease)")
fmt.Print("> ")
var bumpStr string
if _, err := fmt.Scanln(&bumpStr); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
}
bumpType := changeset.BumpType(bumpStr)
fmt.Println("Description of the change:")
fmt.Print("> ")
var message string
if _, err := fmt.Scanln(&message); err != nil {
return nil, "", "", fmt.Errorf("reading input: %w", err)
}
return projects, bumpType, message, nil
}

108
cmd/stamp/cmd_comment.go Normal file
View File

@@ -0,0 +1,108 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v3"
"github.com/thokra/stamp/internal/changeset"
"github.com/thokra/stamp/internal/config"
"github.com/thokra/stamp/internal/semver"
ghClient "github.com/thokra/stamp/internal/github"
giteaClient "github.com/thokra/stamp/internal/gitea"
)
func commentCmd() *cli.Command {
return &cli.Command{
Name: "comment",
Usage: "post or update a PR comment summarising pending stamps",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "pr",
Usage: "pull request number",
Required: true,
},
&cli.StringFlag{
Name: "repo",
Usage: "repository slug owner/repo (defaults to STAMP_REPO or GITHUB_REPOSITORY env vars)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
changesets, err := changeset.ReadAll(stampDir)
if err != nil && !os.IsNotExist(err) {
return err
}
body := buildCommentBody(cfg, changesets)
prNumber := int(cmd.Int("pr"))
repoSlug := cmd.String("repo")
if repoSlug == "" {
repoSlug = os.Getenv("STAMP_REPO")
}
if repoSlug == "" {
repoSlug = os.Getenv("GITHUB_REPOSITORY")
}
if repoSlug == "" {
return fmt.Errorf("--repo or STAMP_REPO / GITHUB_REPOSITORY env var must be set")
}
if os.Getenv("GITEA_BASE_URL") != "" {
client, err := giteaClient.NewClient(repoSlug)
if err != nil {
return err
}
return client.UpsertPRComment(prNumber, body)
}
client, err := ghClient.NewClient(repoSlug)
if err != nil {
return err
}
return client.UpsertPRComment(ctx, prNumber, body)
},
}
}
func buildCommentBody(cfg *config.Config, changesets []*changeset.Changeset) string {
if len(changesets) == 0 {
return "## ⚠️ No stamps found\n\n" +
"This PR does not include a stamp file. If this change affects a versioned project, " +
"please add a stamp by running:\n\n" +
"```sh\nstamp add\n```\n\n" +
"or creating a `.stamp/<name>.md` file manually."
}
projectBumps := semver.ProjectBumps(changesets)
body := fmt.Sprintf("## ✅ Stamps detected (%d)\n\n", len(changesets))
body += "| Project | Current | Next | Bump |\n"
body += "|---------|---------|------|------|\n"
for _, project := range cfg.Projects {
bumps := projectBumps[project.Name]
if len(bumps) == 0 {
continue
}
highest := semver.HighestBump(bumps)
next, err := semver.Bump(project.Version, highest, "")
if err != nil {
next = "?"
}
body += fmt.Sprintf("| %s | %s | %s | %s |\n", project.Name, project.Version, next, highest)
}
return body
}

73
cmd/stamp/cmd_publish.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
"github.com/thokra/stamp/internal/config"
"github.com/thokra/stamp/internal/publish"
)
func publishCmd() *cli.Command {
return &cli.Command{
Name: "publish",
Usage: "create git tags, releases, and upload artifacts",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "dry-run",
Aliases: []string{"n"},
Usage: "print what would be done without executing",
},
&cli.StringFlag{
Name: "project",
Usage: "only publish a specific project",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
repoSlug := os.Getenv("STAMP_REPO")
opts := publish.Options{
DryRun: cmd.Bool("dry-run"),
RepoSlug: repoSlug,
RepoRoot: repoRoot,
}
filterProject := cmd.String("project")
published := 0
for i := range cfg.Projects {
project := &cfg.Projects[i]
if filterProject != "" && project.Name != filterProject {
continue
}
fmt.Printf("Publishing %s@%s...\n", project.Name, project.Version)
if err := publish.Publish(cfg, project, opts); err != nil {
return fmt.Errorf("publishing %s: %w", project.Name, err)
}
published++
}
if published == 0 {
if filterProject != "" {
return fmt.Errorf("project %q not found in stamp.toml", filterProject)
}
fmt.Println("No projects to publish.")
} else {
fmt.Printf("\n✓ Published %d project(s).\n", published)
}
return nil
},
}
}

119
cmd/stamp/cmd_status.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"text/tabwriter"
"github.com/urfave/cli/v3"
"github.com/thokra/stamp/internal/changeset"
"github.com/thokra/stamp/internal/config"
"github.com/thokra/stamp/internal/semver"
)
// statusResult is the machine-readable form of stamp status.
type statusResult struct {
Pending int `json:"pending"`
Projects []projectStatus `json:"projects"`
}
type projectStatus struct {
Name string `json:"name"`
Current string `json:"current"`
Next string `json:"next"`
Bump string `json:"bump"`
Changesets int `json:"changesets"`
NoBump bool `json:"no_bump"`
}
func statusCmd() *cli.Command {
return &cli.Command{
Name: "status",
Usage: "show pending stamps and projected next versions",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "json",
Usage: "output as JSON",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
changesets, err := changeset.ReadAll(stampDir)
if err != nil && !os.IsNotExist(err) {
return err
}
projectBumps := semver.ProjectBumps(changesets)
result := statusResult{
Pending: len(changesets),
}
for _, project := range cfg.Projects {
bumps := projectBumps[project.Name]
ps := projectStatus{
Name: project.Name,
Current: project.Version,
Changesets: len(bumps),
NoBump: len(bumps) == 0,
}
if len(bumps) > 0 {
highest := semver.HighestBump(bumps)
ps.Bump = string(highest)
next, err := semver.Bump(project.Version, highest, "")
if err != nil {
return fmt.Errorf("computing next version for %s: %w", project.Name, err)
}
ps.Next = next
} else {
ps.Next = project.Version
}
result.Projects = append(result.Projects, ps)
}
if cmd.Bool("json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
return printStatus(result)
},
}
}
func printStatus(result statusResult) error {
if result.Pending == 0 {
fmt.Println("⚠ No pending stamps found.")
return nil
}
fmt.Printf("📦 Pending stamps: %d\n\n", result.Pending)
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, " PROJECT\tCURRENT\tNEXT\tBUMP\tSTAMPS")
fmt.Fprintln(w, " -------\t-------\t----\t----\t------")
for _, p := range result.Projects {
if p.NoBump {
fmt.Fprintf(w, " %s\t%s\t%s\t—\t0\n", p.Name, p.Current, p.Current)
} else {
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%d\n", p.Name, p.Current, p.Next, p.Bump, p.Changesets)
}
}
return w.Flush()
}

138
cmd/stamp/cmd_version.go Normal file
View File

@@ -0,0 +1,138 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/urfave/cli/v3"
"github.com/thokra/stamp/internal/changeset"
"github.com/thokra/stamp/internal/changelog"
"github.com/thokra/stamp/internal/config"
"github.com/thokra/stamp/internal/git"
"github.com/thokra/stamp/internal/semver"
)
func versionCmd() *cli.Command {
return &cli.Command{
Name: "version",
Usage: "consume stamps, bump versions, and update changelogs",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "snapshot",
Usage: "pre-release identifier (e.g. alpha, beta, rc) for snapshot releases",
},
&cli.BoolFlag{
Name: "no-commit",
Usage: "skip the git commit after versioning",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
repoRoot, err := findRepoRoot(".")
if err != nil {
return err
}
cfg, err := config.Load(repoRoot)
if err != nil {
return err
}
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
changesets, err := changeset.ReadAll(stampDir)
if err != nil && !os.IsNotExist(err) {
return err
}
if len(changesets) == 0 {
fmt.Println("No pending stamps found.")
return nil
}
preID := cmd.String("snapshot")
projectBumps := semver.ProjectBumps(changesets)
now := time.Now()
var changedFiles []string
var bumped []string
for i := range cfg.Projects {
project := &cfg.Projects[i]
bumps := projectBumps[project.Name]
if len(bumps) == 0 {
continue
}
highest := semver.HighestBump(bumps)
if preID != "" {
highest = changeset.BumpPreRelease
}
nextVer, err := semver.Bump(project.Version, highest, preID)
if err != nil {
return fmt.Errorf("bumping %s: %w", project.Name, err)
}
// Build changelog body from all matching changesets.
var body string
for _, cs := range changesets {
if _, ok := cs.Bumps[project.Name]; ok {
if cs.Description != "" {
body += "- " + cs.Description + "\n"
}
}
}
clPath := project.ChangelogPath(repoRoot)
if err := changelog.Append(clPath, changelog.Entry{
Version: nextVer,
Date: now,
Description: body,
}); err != nil {
return fmt.Errorf("updating changelog for %s: %w", project.Name, err)
}
changedFiles = append(changedFiles, clPath)
fmt.Printf(" %s: %s → %s\n", project.Name, project.Version, nextVer)
project.Version = nextVer
bumped = append(bumped, project.Name)
}
if len(bumped) == 0 {
fmt.Println("No projects were bumped.")
return nil
}
// Update stamp.toml with new versions.
cfgPath := filepath.Join(repoRoot, config.ConfigFileName)
if err := config.Save(repoRoot, cfg); err != nil {
return fmt.Errorf("saving stamp.toml: %w", err)
}
changedFiles = append(changedFiles, cfgPath)
// Delete consumed stamp files.
if err := changeset.DeleteAll(stampDir); err != nil {
return fmt.Errorf("deleting stamp files: %w", err)
}
changedFiles = append(changedFiles, stampDir)
if cmd.Bool("no-commit") {
fmt.Println("\nVersioning complete (no commit).")
return nil
}
// Stage and commit.
if err := git.Add(repoRoot, changedFiles...); err != nil {
return fmt.Errorf("staging files: %w", err)
}
commitMsg := fmt.Sprintf("chore: version packages\n\nBumped: %v", bumped)
if err := git.Commit(repoRoot, commitMsg); err != nil {
return fmt.Errorf("committing: %w", err)
}
fmt.Println("\n✓ Committed version bump.")
return nil
},
}
}

31
cmd/stamp/helpers.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"fmt"
"os/exec"
"strings"
)
// findRepoRoot walks up from dir to find the git repository root.
func findRepoRoot(dir string) (string, error) {
cmd := exec.Command("git", "rev-parse", "--show-toplevel")
cmd.Dir = dir
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("could not find git repository root: %w", err)
}
return strings.TrimSpace(string(out)), nil
}
// splitComma splits a comma-separated string and trims each part.
func splitComma(s string) []string {
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
result = append(result, p)
}
}
return result
}

29
cmd/stamp/main.go Normal file
View File

@@ -0,0 +1,29 @@
package main
import (
"context"
"fmt"
"os"
"github.com/urfave/cli/v3"
)
func main() {
app := &cli.Command{
Name: "stamp",
Usage: "language-agnostic changesets-style versioning and changelog tool",
Version: "0.1.0",
Commands: []*cli.Command{
addCmd(),
statusCmd(),
versionCmd(),
publishCmd(),
commentCmd(),
},
}
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

229
docs/workflow.md Normal file
View 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
View 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
View 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
View 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=

View 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)
}

View 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)
}
}

View 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",
}

View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
}

View 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"]))
}
}

14
mise.toml Normal file
View File

@@ -0,0 +1,14 @@
[tools]
go = "1.26.1"
[tasks.build]
run = "go build -o bin/stamp ./cmd/stamp"
[tasks.test]
run = "go test ./..."
[tasks.lint]
run = "golangci-lint run"
[tasks.install]
run = "go install ./cmd/stamp"