mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 21:21:03 +00:00
If two pre-releases were cut in a row, the second pre-release version would only consider the semantic changes since the previous pre-release, but base its new version of the latest tag. Example: - stable tag: `1.2.0` - latest tag: `1.3.0-rc.1` - 1 commit since with `fix:` tag The resulting releaser-pleaser tag would be: `1.2.1-rc.2`. It should be `1.3.0-rc.2` instead. This is now fixed by considering different commit ranges for versioning and changelog. For a stable version we want the list of changes since the stable tag. For a prerelease version we want the list of changes since the latest tag. To avoid repeating the same features over and over in a series of multiple pre-releases. This behaviour already existed and worked. For a stable version, we want to consider all changes since the stable tag. For a prerelease version, we also want to consider all changes since the stable tag. This was broken and only used the changes since the latest tag.
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package rp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/apricote/releaser-pleaser/internal/changelog"
|
|
"github.com/apricote/releaser-pleaser/internal/commitparser"
|
|
"github.com/apricote/releaser-pleaser/internal/forge"
|
|
"github.com/apricote/releaser-pleaser/internal/git"
|
|
"github.com/apricote/releaser-pleaser/internal/releasepr"
|
|
"github.com/apricote/releaser-pleaser/internal/updater"
|
|
"github.com/apricote/releaser-pleaser/internal/versioning"
|
|
)
|
|
|
|
const (
|
|
PullRequestBranchFormat = "releaser-pleaser--branches--%s"
|
|
)
|
|
|
|
type ReleaserPleaser struct {
|
|
forge forge.Forge
|
|
logger *slog.Logger
|
|
targetBranch string
|
|
commitParser commitparser.CommitParser
|
|
versioning versioning.Strategy
|
|
extraFiles []string
|
|
updaters []updater.NewUpdater
|
|
}
|
|
|
|
func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser {
|
|
return &ReleaserPleaser{
|
|
forge: forge,
|
|
logger: logger,
|
|
targetBranch: targetBranch,
|
|
commitParser: commitParser,
|
|
versioning: versioningStrategy,
|
|
extraFiles: extraFiles,
|
|
updaters: updaters,
|
|
}
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error {
|
|
// TODO: Wrap Error
|
|
|
|
return rp.forge.EnsureLabelsExist(ctx, releasepr.KnownLabels)
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) Run(ctx context.Context) error {
|
|
err := rp.runOnboarding(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to onboard repository: %w", err)
|
|
}
|
|
|
|
err = rp.runCreatePendingReleases(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create pending releases: %w", err)
|
|
}
|
|
|
|
err = rp.runReconcileReleasePR(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reconcile release pull request: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) runOnboarding(ctx context.Context) error {
|
|
err := rp.EnsureLabels(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to ensure all labels exist: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
|
|
logger := rp.logger.With("method", "runCreatePendingReleases")
|
|
|
|
logger.InfoContext(ctx, "checking for pending releases")
|
|
prs, err := rp.forge.PendingReleases(ctx, releasepr.LabelReleasePending)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(prs) == 0 {
|
|
logger.InfoContext(ctx, "No pending releases found")
|
|
return nil
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Found pending releases", "length", len(prs))
|
|
|
|
for _, pr := range prs {
|
|
err = rp.createPendingRelease(ctx, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
|
logger := rp.logger.With(
|
|
"method", "createPendingRelease",
|
|
"pr.id", pr.ID,
|
|
"pr.title", pr.Title)
|
|
|
|
if pr.ReleaseCommit == nil {
|
|
return fmt.Errorf("pull request is missing the merge commit")
|
|
}
|
|
|
|
logger.Info("Creating release", "commit.hash", pr.ReleaseCommit.Hash)
|
|
|
|
version, err := pr.Version()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
changelogText, err := pr.ChangelogText()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: Check if version should be marked latest
|
|
|
|
logger.DebugContext(ctx, "Creating release on forge")
|
|
err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelogText, rp.versioning.IsPrerelease(version), true)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create release on forge: %w", err)
|
|
}
|
|
logger.DebugContext(ctx, "created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
|
|
|
|
logger.DebugContext(ctx, "updating pr labels")
|
|
err = rp.forge.SetPullRequestLabels(ctx, pr, []releasepr.Label{releasepr.LabelReleasePending}, []releasepr.Label{releasepr.LabelReleaseTagged})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.DebugContext(ctx, "updated pr labels")
|
|
|
|
logger.InfoContext(ctx, "Created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
|
|
logger := rp.logger.With("method", "runReconcileReleasePR")
|
|
|
|
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
|
|
|
|
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var releaseOverrides releasepr.ReleaseOverrides
|
|
|
|
if pr != nil {
|
|
logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title)
|
|
logger.InfoContext(ctx, "found existing release pull request")
|
|
|
|
releaseOverrides, err = pr.GetOverrides()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
releases, err := rp.forge.LatestTags(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if releases.Latest != nil {
|
|
logger.InfoContext(ctx, "found latest tag", "tag.hash", releases.Latest.Hash, "tag.name", releases.Latest.Name)
|
|
if releases.Stable != nil && releases.Latest.Hash != releases.Stable.Hash {
|
|
logger.InfoContext(ctx, "found stable tag", "tag.hash", releases.Stable.Hash, "tag.name", releases.Stable.Name)
|
|
}
|
|
} else {
|
|
logger.InfoContext(ctx, "no latest tag found")
|
|
}
|
|
|
|
// For stable releases, we want to consider all changes since the last stable release for version and changelog.
|
|
// For prereleases, we want to consider all changes...
|
|
// - since the last stable release for the version
|
|
// - since the latest release (stable or prerelease) for the changelog
|
|
analyzedCommitsForVersioning, err := rp.analyzedCommitsSince(ctx, releases.Stable)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(analyzedCommitsForVersioning) == 0 {
|
|
if pr != nil {
|
|
logger.InfoContext(ctx, "closing existing pull requests, no commits available", "pr.id", pr.ID, "pr.title", pr.Title)
|
|
err = rp.forge.ClosePullRequest(ctx, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
logger.InfoContext(ctx, "No commits available for release")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
versionBump := versioning.BumpFromCommits(analyzedCommitsForVersioning)
|
|
// TODO: Set version in release pr
|
|
nextVersion, err := rp.versioning.NextVersion(releases, versionBump, releaseOverrides.NextVersionType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.InfoContext(ctx, "next version", "version", nextVersion)
|
|
|
|
analyzedCommitsForChangelog := analyzedCommitsForVersioning
|
|
if releaseOverrides.NextVersionType.IsPrerelease() && releases.Latest != releases.Stable {
|
|
analyzedCommitsForChangelog, err = rp.analyzedCommitsSince(ctx, releases.Latest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
|
|
repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to clone repository: %w", err)
|
|
}
|
|
|
|
if err = repo.DeleteBranch(ctx, rpBranch); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err = repo.Checkout(ctx, rpBranch); err != nil {
|
|
return err
|
|
}
|
|
|
|
changelogData := changelog.New(commitparser.ByType(analyzedCommitsForChangelog), nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
|
|
|
|
changelogEntry, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build changelog entry: %w", err)
|
|
}
|
|
|
|
// Info for updaters
|
|
info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
|
|
|
|
err = repo.UpdateFile(ctx, updater.ChangelogFile, true, updater.WithInfo(info, updater.Changelog))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update changelog file: %w", err)
|
|
}
|
|
|
|
for _, path := range rp.extraFiles {
|
|
// TODO: Check for missing files
|
|
err = repo.UpdateFile(ctx, path, false, updater.WithInfo(info, rp.updaters...))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to run file updater: %w", err)
|
|
}
|
|
}
|
|
|
|
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion)
|
|
releaseCommit, err := repo.Commit(ctx, releaseCommitMessage)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to commit changes: %w", err)
|
|
}
|
|
|
|
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message)
|
|
|
|
// Check if anything changed in comparison to the remote branch (if exists)
|
|
newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if newReleasePRChanges {
|
|
err = repo.ForcePush(ctx, rpBranch)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to push branch: %w", err)
|
|
}
|
|
|
|
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.Hash, "branch.name", rpBranch)
|
|
} else {
|
|
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
|
|
}
|
|
|
|
// We do not need the version title here. In the pull request the version is available from the title, and in the
|
|
// release on the Forge its usually in a heading somewhere above the text.
|
|
changelogEntryPullRequest, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{HideVersionTitle: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to build pull request changelog entry: %w", err)
|
|
}
|
|
|
|
// Open/Update PR
|
|
if pr == nil {
|
|
pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntryPullRequest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = rp.forge.CreatePullRequest(ctx, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID, "pr.url", rp.forge.PullRequestURL(pr.ID))
|
|
} else {
|
|
pr.SetTitle(rp.targetBranch, nextVersion)
|
|
|
|
overrides, err := pr.GetOverrides()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = pr.SetDescription(changelogEntryPullRequest, overrides)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = rp.forge.UpdatePullRequest(ctx, pr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID, "pr.url", rp.forge.PullRequestURL(pr.ID))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (rp *ReleaserPleaser) analyzedCommitsSince(ctx context.Context, since *git.Tag) ([]commitparser.AnalyzedCommit, error) {
|
|
logger := rp.logger.With("method", "analyzedCommitsSince", "tag.hash", since.Hash, "tag.name", since.Name)
|
|
|
|
commits, err := rp.forge.CommitsSince(ctx, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
commits, err = parsePRBodyForCommitOverrides(commits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Found releasable commits", "length", len(commits))
|
|
|
|
analyzedCommits, err := rp.commitParser.Analyze(commits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits))
|
|
|
|
return analyzedCommits, nil
|
|
}
|