From 77462f5e8ad6698459545649610779537888fe20 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sun, 8 Mar 2026 20:56:23 +0100 Subject: [PATCH] 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> --- .gitignore | 1 + cmd/stamp/cmd_add.go | 146 +++++++++++++++++ cmd/stamp/cmd_comment.go | 108 +++++++++++++ cmd/stamp/cmd_publish.go | 73 +++++++++ cmd/stamp/cmd_status.go | 119 ++++++++++++++ cmd/stamp/cmd_version.go | 138 ++++++++++++++++ cmd/stamp/helpers.go | 31 ++++ cmd/stamp/main.go | 29 ++++ docs/workflow.md | 229 +++++++++++++++++++++++++++ examples/stamp.toml | 46 ++++++ go.mod | 22 +++ go.sum | 52 ++++++ internal/changelog/changelog.go | 91 +++++++++++ internal/changelog/changelog_test.go | 107 +++++++++++++ internal/changeset/changeset.go | 223 ++++++++++++++++++++++++++ internal/changeset/changeset_test.go | 137 ++++++++++++++++ internal/config/config.go | 148 +++++++++++++++++ internal/config/config_test.go | 132 +++++++++++++++ internal/git/git.go | 84 ++++++++++ internal/gitea/gitea.go | 98 ++++++++++++ internal/github/github.go | 96 +++++++++++ internal/publish/publish.go | 141 +++++++++++++++++ internal/semver/semver.go | 123 ++++++++++++++ internal/semver/semver_test.go | 130 +++++++++++++++ mise.toml | 14 ++ 25 files changed, 2518 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/stamp/cmd_add.go create mode 100644 cmd/stamp/cmd_comment.go create mode 100644 cmd/stamp/cmd_publish.go create mode 100644 cmd/stamp/cmd_status.go create mode 100644 cmd/stamp/cmd_version.go create mode 100644 cmd/stamp/helpers.go create mode 100644 cmd/stamp/main.go create mode 100644 docs/workflow.md create mode 100644 examples/stamp.toml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/changelog/changelog.go create mode 100644 internal/changelog/changelog_test.go create mode 100644 internal/changeset/changeset.go create mode 100644 internal/changeset/changeset_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/git/git.go create mode 100644 internal/gitea/gitea.go create mode 100644 internal/github/github.go create mode 100644 internal/publish/publish.go create mode 100644 internal/semver/semver.go create mode 100644 internal/semver/semver_test.go create mode 100644 mise.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e660fd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/cmd/stamp/cmd_add.go b/cmd/stamp/cmd_add.go new file mode 100644 index 0000000..ed8f083 --- /dev/null +++ b/cmd/stamp/cmd_add.go @@ -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 +} diff --git a/cmd/stamp/cmd_comment.go b/cmd/stamp/cmd_comment.go new file mode 100644 index 0000000..10e39e5 --- /dev/null +++ b/cmd/stamp/cmd_comment.go @@ -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/.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 +} diff --git a/cmd/stamp/cmd_publish.go b/cmd/stamp/cmd_publish.go new file mode 100644 index 0000000..ffe4eb4 --- /dev/null +++ b/cmd/stamp/cmd_publish.go @@ -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 + }, + } +} diff --git a/cmd/stamp/cmd_status.go b/cmd/stamp/cmd_status.go new file mode 100644 index 0000000..b92a21b --- /dev/null +++ b/cmd/stamp/cmd_status.go @@ -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() +} diff --git a/cmd/stamp/cmd_version.go b/cmd/stamp/cmd_version.go new file mode 100644 index 0000000..20d1a28 --- /dev/null +++ b/cmd/stamp/cmd_version.go @@ -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 + }, + } +} diff --git a/cmd/stamp/helpers.go b/cmd/stamp/helpers.go new file mode 100644 index 0000000..9911047 --- /dev/null +++ b/cmd/stamp/helpers.go @@ -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 +} diff --git a/cmd/stamp/main.go b/cmd/stamp/main.go new file mode 100644 index 0000000..c67e829 --- /dev/null +++ b/cmd/stamp/main.go @@ -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) + } +} diff --git a/docs/workflow.md b/docs/workflow.md new file mode 100644 index 0000000..8773f36 --- /dev/null +++ b/docs/workflow.md @@ -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/.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 | diff --git a/examples/stamp.toml b/examples/stamp.toml new file mode 100644 index 0000000..4e44441 --- /dev/null +++ b/examples/stamp.toml @@ -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: @v 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 = [] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3aee18b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e83cd96 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..c4a0440 --- /dev/null +++ b/internal/changelog/changelog.go @@ -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) +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..404c60c --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -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) + } +} diff --git a/internal/changeset/changeset.go b/internal/changeset/changeset.go new file mode 100644 index 0000000..a1a699a --- /dev/null +++ b/internal/changeset/changeset.go @@ -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/.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", +} diff --git a/internal/changeset/changeset_test.go b/internal/changeset/changeset_test.go new file mode 100644 index 0000000..c1f23f9 --- /dev/null +++ b/internal/changeset/changeset_test.go @@ -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)) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b0cf3e2 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3574d72 --- /dev/null +++ b/internal/config/config_test.go @@ -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") + } +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..85c34b2 --- /dev/null +++ b/internal/git/git.go @@ -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". +// For monorepos it returns "@v". +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 +} diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..5cbb87c --- /dev/null +++ b/internal/gitea/gitea.go @@ -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 = "" + +// 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 +} diff --git a/internal/github/github.go b/internal/github/github.go new file mode 100644 index 0000000..1c2f994 --- /dev/null +++ b/internal/github/github.go @@ -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 = "" + +// 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 +} diff --git a/internal/publish/publish.go b/internal/publish/publish.go new file mode 100644 index 0000000..0e4a4c5 --- /dev/null +++ b/internal/publish/publish.go @@ -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") != "" +} diff --git a/internal/semver/semver.go b/internal/semver/semver.go new file mode 100644 index 0000000..966c4f3 --- /dev/null +++ b/internal/semver/semver.go @@ -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 +} diff --git a/internal/semver/semver_test.go b/internal/semver/semver_test.go new file mode 100644 index 0000000..572edc6 --- /dev/null +++ b/internal/semver/semver_test.go @@ -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"])) + } +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..552073d --- /dev/null +++ b/mise.toml @@ -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"