Release
This commit is contained in:
@@ -82,3 +82,36 @@ func TagExists(dir, tag string) (bool, error) {
|
||||
}
|
||||
return strings.TrimSpace(out) == tag, nil
|
||||
}
|
||||
|
||||
// BranchExists returns true if the given local branch exists.
|
||||
func BranchExists(dir, branch string) (bool, error) {
|
||||
out, err := Run(dir, "branch", "--list", branch)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
// CheckoutBranch checks out an existing branch.
|
||||
func CheckoutBranch(dir, branch string) error {
|
||||
_, err := Run(dir, "checkout", branch)
|
||||
return err
|
||||
}
|
||||
|
||||
// CheckoutNewBranch creates and checks out a new branch from the given base.
|
||||
func CheckoutNewBranch(dir, branch, base string) error {
|
||||
_, err := Run(dir, "checkout", "-B", branch, base)
|
||||
return err
|
||||
}
|
||||
|
||||
// PushBranch force-pushes a branch to origin.
|
||||
func PushBranch(dir, branch string) error {
|
||||
_, err := Run(dir, "push", "--force-with-lease", "origin", branch)
|
||||
return err
|
||||
}
|
||||
|
||||
// FetchOrigin fetches from origin.
|
||||
func FetchOrigin(dir string) error {
|
||||
_, err := Run(dir, "fetch", "origin")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
giteaSDK "code.gitea.io/sdk/gitea"
|
||||
)
|
||||
|
||||
const prMarker = "<!-- stamp-release-pr -->"
|
||||
|
||||
// Client wraps the Gitea API.
|
||||
type Client struct {
|
||||
client *giteaSDK.Client
|
||||
@@ -74,6 +76,51 @@ func (c *Client) UploadAsset(releaseID int64, filePath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertPR creates or updates a stamp release PR on the given base branch.
|
||||
// It identifies the existing PR by scanning open PRs for the hidden marker in the body.
|
||||
func (c *Client) UpsertPR(head, base, title, body string) (int64, string, error) {
|
||||
markedBody := prMarker + "\n" + body
|
||||
|
||||
// Search open PRs for an existing stamp release PR (Gitea SDK does not support
|
||||
// filtering by head branch, so we filter client-side).
|
||||
prs, _, err := c.client.ListRepoPullRequests(c.owner, c.repo, giteaSDK.ListPullRequestsOptions{
|
||||
State: giteaSDK.StateOpen,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("listing pull requests: %w", err)
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
if pr.Head != nil && pr.Head.Name != head {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(pr.Body, prMarker) {
|
||||
// Update existing PR.
|
||||
updated, _, err := c.client.EditPullRequest(c.owner, c.repo, pr.Index,
|
||||
giteaSDK.EditPullRequestOption{
|
||||
Title: title,
|
||||
Body: &markedBody,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("updating pull request #%d: %w", pr.Index, err)
|
||||
}
|
||||
return updated.Index, updated.HTMLURL, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new PR.
|
||||
pr, _, err := c.client.CreatePullRequest(c.owner, c.repo, giteaSDK.CreatePullRequestOption{
|
||||
Head: head,
|
||||
Base: base,
|
||||
Title: title,
|
||||
Body: markedBody,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("creating pull request: %w", err)
|
||||
}
|
||||
return pr.Index, pr.HTMLURL, nil
|
||||
}
|
||||
|
||||
// UpsertPRComment posts or updates a stamped PR comment.
|
||||
func (c *Client) UpsertPRComment(prNumber int, body string) error {
|
||||
markedBody := commentMarker + "\n" + body
|
||||
|
||||
@@ -94,3 +94,49 @@ func (c *Client) UpsertPRComment(ctx context.Context, prNumber int, body string)
|
||||
&gh.IssueComment{Body: gh.Ptr(markedBody)})
|
||||
return err
|
||||
}
|
||||
|
||||
const prMarker = "<!-- stamp-release-pr -->"
|
||||
|
||||
// UpsertPR creates or updates a release PR from head into base.
|
||||
// It identifies a previously-created PR by the hidden marker in the body.
|
||||
// Returns the PR number and the URL.
|
||||
func (c *Client) UpsertPR(ctx context.Context, head, base, title, body string) (int, string, error) {
|
||||
markedBody := prMarker + "\n" + body
|
||||
|
||||
// Search open PRs from this head branch.
|
||||
prs, _, err := c.client.PullRequests.List(ctx, c.owner, c.repo, &gh.PullRequestListOptions{
|
||||
State: "open",
|
||||
Head: c.owner + ":" + head,
|
||||
Base: base,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("listing pull requests: %w", err)
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
if strings.Contains(pr.GetBody(), prMarker) {
|
||||
// Update existing PR.
|
||||
updated, _, err := c.client.PullRequests.Edit(ctx, c.owner, c.repo, pr.GetNumber(),
|
||||
&gh.PullRequest{
|
||||
Title: gh.Ptr(title),
|
||||
Body: gh.Ptr(markedBody),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("updating pull request: %w", err)
|
||||
}
|
||||
return updated.GetNumber(), updated.GetHTMLURL(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create new PR.
|
||||
pr, _, err := c.client.PullRequests.Create(ctx, c.owner, c.repo, &gh.NewPullRequest{
|
||||
Title: gh.Ptr(title),
|
||||
Head: gh.Ptr(head),
|
||||
Base: gh.Ptr(base),
|
||||
Body: gh.Ptr(markedBody),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("creating pull request: %w", err)
|
||||
}
|
||||
return pr.GetNumber(), pr.GetHTMLURL(), nil
|
||||
}
|
||||
|
||||
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") != ""
|
||||
}
|
||||
@@ -9,6 +9,25 @@ import (
|
||||
"git.thokra.dev/thokra/stamp/internal/changeset"
|
||||
)
|
||||
|
||||
// PreReleaseBump converts a regular bump type to its pre-release equivalent,
|
||||
// taking into account whether the current version is already a pre-release.
|
||||
// If it is, the bump is always BumpPreRelease (just increment the pre number).
|
||||
// Otherwise the highest regular bump is promoted to its pre-release variant.
|
||||
func PreReleaseBump(currentVersion string, highest changeset.BumpType) changeset.BumpType {
|
||||
v, err := goSemver.NewVersion(currentVersion)
|
||||
if err == nil && v.Prerelease() != "" {
|
||||
return changeset.BumpPreRelease
|
||||
}
|
||||
switch highest {
|
||||
case changeset.BumpMajor:
|
||||
return changeset.BumpPreMajor
|
||||
case changeset.BumpMinor:
|
||||
return changeset.BumpPreMinor
|
||||
default:
|
||||
return changeset.BumpPrePatch
|
||||
}
|
||||
}
|
||||
|
||||
// CurrentVersion parses a version string and returns the semver object.
|
||||
// It is exported so callers can inspect pre-release state without re-importing the semver library.
|
||||
func CurrentVersion(version string) (*goSemver.Version, error) {
|
||||
|
||||
Reference in New Issue
Block a user