Release
This commit is contained in:
278
internal/releasepr/releasepr.go
Normal file
278
internal/releasepr/releasepr.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package releasepr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.thokra.dev/thokra/stamp/internal/changelog"
|
||||
"git.thokra.dev/thokra/stamp/internal/changeset"
|
||||
"git.thokra.dev/thokra/stamp/internal/config"
|
||||
"git.thokra.dev/thokra/stamp/internal/git"
|
||||
giteaClient "git.thokra.dev/thokra/stamp/internal/gitea"
|
||||
ghClient "git.thokra.dev/thokra/stamp/internal/github"
|
||||
"git.thokra.dev/thokra/stamp/internal/semver"
|
||||
)
|
||||
|
||||
const DefaultReleaseBranch = "stamp/release"
|
||||
|
||||
// Options controls release-pr behaviour.
|
||||
type Options struct {
|
||||
// RepoRoot is the absolute path to the repository root.
|
||||
RepoRoot string
|
||||
// RepoSlug is "owner/repo", required for API calls.
|
||||
RepoSlug string
|
||||
// BaseBranch is the branch the PR targets (e.g. "main").
|
||||
BaseBranch string
|
||||
// ReleaseBranch is the branch stamp commits to (default: "stamp/release").
|
||||
ReleaseBranch string
|
||||
// SnapshotID is an optional pre-release identifier forwarded to stamp version logic.
|
||||
SnapshotID string
|
||||
// DryRun prints what would happen without pushing or opening a PR.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// BumpSummary holds the computed version change for one project.
|
||||
type BumpSummary struct {
|
||||
Name string
|
||||
Current string
|
||||
Next string
|
||||
Bump string
|
||||
}
|
||||
|
||||
// Run is the main entry point for `stamp release-pr`.
|
||||
// It versions all pending stamps, commits to the release branch, pushes, and
|
||||
// opens or updates a pull request against the base branch.
|
||||
func Run(ctx context.Context, opts Options) error {
|
||||
cfg, err := config.Load(opts.RepoRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stampDir := filepath.Join(opts.RepoRoot, cfg.ChangesetDir())
|
||||
|
||||
changesets, err := changeset.ReadAll(stampDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading stamp files: %w", err)
|
||||
}
|
||||
if len(changesets) == 0 {
|
||||
fmt.Println("No pending stamps found — nothing to do.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compute version bumps for the PR body before mutating anything.
|
||||
summaries, err := computeSummaries(cfg, changesets, opts.SnapshotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
fmt.Println("[dry-run] Would create/update release PR with:")
|
||||
for _, s := range summaries {
|
||||
fmt.Printf(" %s: %s → %s (%s)\n", s.Name, s.Current, s.Next, s.Bump)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch so we have an up-to-date origin/<base>.
|
||||
fmt.Println("Fetching from origin...")
|
||||
if err := git.FetchOrigin(opts.RepoRoot); err != nil {
|
||||
return fmt.Errorf("fetching origin: %w", err)
|
||||
}
|
||||
|
||||
// Create or reset the release branch from origin/<base> so it is always a
|
||||
// clean, fast-forwardable branch on top of the latest base.
|
||||
base := "origin/" + opts.BaseBranch
|
||||
fmt.Printf("Resetting %s from %s...\n", opts.ReleaseBranch, base)
|
||||
if err := git.CheckoutNewBranch(opts.RepoRoot, opts.ReleaseBranch, base); err != nil {
|
||||
return fmt.Errorf("checking out release branch: %w", err)
|
||||
}
|
||||
|
||||
// Apply version changes (same logic as `stamp version --no-commit`).
|
||||
changedFiles, err := applyVersions(opts.RepoRoot, cfg, changesets, opts.SnapshotID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Stage and commit.
|
||||
if err := git.Add(opts.RepoRoot, changedFiles...); err != nil {
|
||||
return fmt.Errorf("staging files: %w", err)
|
||||
}
|
||||
commitMsg := buildCommitMessage(summaries)
|
||||
if err := git.Commit(opts.RepoRoot, commitMsg); err != nil {
|
||||
return fmt.Errorf("committing version bump: %w", err)
|
||||
}
|
||||
|
||||
// Push the release branch.
|
||||
fmt.Printf("Pushing %s...\n", opts.ReleaseBranch)
|
||||
if err := git.PushBranch(opts.RepoRoot, opts.ReleaseBranch); err != nil {
|
||||
return fmt.Errorf("pushing release branch: %w", err)
|
||||
}
|
||||
|
||||
// Open or update the PR.
|
||||
title := buildPRTitle(summaries)
|
||||
body := buildPRBody(summaries)
|
||||
|
||||
prNumber, prURL, err := upsertPR(ctx, opts, title, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Release PR #%d ready: %s\n", prNumber, prURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeSummaries calculates the next version for every project that has pending stamps.
|
||||
func computeSummaries(cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]BumpSummary, error) {
|
||||
projectBumps := semver.ProjectBumps(changesets)
|
||||
var summaries []BumpSummary
|
||||
|
||||
for _, project := range cfg.Projects {
|
||||
bumps := projectBumps[project.Name]
|
||||
if len(bumps) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
highest := semver.HighestBump(bumps)
|
||||
preID := snapshotID
|
||||
if preID == "" {
|
||||
preID = project.PreTag
|
||||
}
|
||||
if preID != "" {
|
||||
highest = semver.PreReleaseBump(project.Version, highest)
|
||||
}
|
||||
|
||||
next, err := semver.Bump(project.Version, highest, preID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing next version for %s: %w", project.Name, err)
|
||||
}
|
||||
|
||||
summaries = append(summaries, BumpSummary{
|
||||
Name: project.Name,
|
||||
Current: project.Version,
|
||||
Next: next,
|
||||
Bump: string(highest),
|
||||
})
|
||||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// applyVersions runs the version-bump logic and returns the list of files that were changed.
|
||||
// It mutates cfg.Projects in place (same as cmd_version.go).
|
||||
func applyVersions(repoRoot string, cfg *config.Config, changesets []*changeset.Changeset, snapshotID string) ([]string, error) {
|
||||
projectBumps := semver.ProjectBumps(changesets)
|
||||
now := time.Now()
|
||||
var changedFiles []string
|
||||
|
||||
for i := range cfg.Projects {
|
||||
project := &cfg.Projects[i]
|
||||
bumps := projectBumps[project.Name]
|
||||
if len(bumps) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
highest := semver.HighestBump(bumps)
|
||||
preID := snapshotID
|
||||
if preID == "" {
|
||||
preID = project.PreTag
|
||||
}
|
||||
if preID != "" {
|
||||
highest = semver.PreReleaseBump(project.Version, highest)
|
||||
}
|
||||
|
||||
nextVer, err := semver.Bump(project.Version, highest, preID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bumping %s: %w", project.Name, err)
|
||||
}
|
||||
|
||||
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 nil, 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
|
||||
}
|
||||
|
||||
// Persist updated versions to stamp.toml.
|
||||
if err := config.Save(repoRoot, cfg); err != nil {
|
||||
return nil, fmt.Errorf("saving stamp.toml: %w", err)
|
||||
}
|
||||
changedFiles = append(changedFiles, config.ConfigPath(repoRoot))
|
||||
|
||||
// Delete consumed stamp files.
|
||||
stampDir := filepath.Join(repoRoot, cfg.ChangesetDir())
|
||||
if err := changeset.DeleteAll(stampDir); err != nil {
|
||||
return nil, fmt.Errorf("deleting stamp files: %w", err)
|
||||
}
|
||||
changedFiles = append(changedFiles, stampDir)
|
||||
|
||||
return changedFiles, nil
|
||||
}
|
||||
|
||||
func buildCommitMessage(summaries []BumpSummary) string {
|
||||
msg := "chore: version packages\n\n"
|
||||
for _, s := range summaries {
|
||||
msg += fmt.Sprintf("- %s: %s → %s\n", s.Name, s.Current, s.Next)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func buildPRTitle(summaries []BumpSummary) string {
|
||||
if len(summaries) == 1 {
|
||||
s := summaries[0]
|
||||
return fmt.Sprintf("chore: release %s v%s", s.Name, s.Next)
|
||||
}
|
||||
return fmt.Sprintf("chore: release %d packages", len(summaries))
|
||||
}
|
||||
|
||||
func buildPRBody(summaries []BumpSummary) string {
|
||||
body := "## 📦 Pending releases\n\n"
|
||||
body += "This PR was created by `stamp release-pr`. Merge it to apply the version bumps and trigger publishing.\n\n"
|
||||
body += "| Project | Current | Next | Bump |\n"
|
||||
body += "|---------|---------|------|------|\n"
|
||||
for _, s := range summaries {
|
||||
body += fmt.Sprintf("| %s | %s | %s | `%s` |\n", s.Name, s.Current, s.Next, s.Bump)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func upsertPR(ctx context.Context, opts Options, title, body string) (int, string, error) {
|
||||
if isGitea() {
|
||||
client, err := giteaClient.NewClient(opts.RepoSlug)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
num, url, err := client.UpsertPR(opts.ReleaseBranch, opts.BaseBranch, title, body)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return int(num), url, nil
|
||||
}
|
||||
|
||||
client, err := ghClient.NewClient(opts.RepoSlug)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return client.UpsertPR(ctx, opts.ReleaseBranch, opts.BaseBranch, title, body)
|
||||
}
|
||||
|
||||
func isGitea() bool {
|
||||
return os.Getenv("GITEA_BASE_URL") != ""
|
||||
}
|
||||
Reference in New Issue
Block a user