2026-07-07 00:07:53 +02:00
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
gogit "github.com/go-git/go-git/v5"
"github.com/spf13/cobra"
"git.k3nny.fr/releaser/internal/branch"
2026-07-07 11:18:27 +02:00
"git.k3nny.fr/releaser/internal/changelog"
2026-07-07 00:07:53 +02:00
"git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config"
2026-07-11 00:15:09 +02:00
"git.k3nny.fr/releaser/internal/ghclient"
2026-07-07 00:07:53 +02:00
"git.k3nny.fr/releaser/internal/gitutil"
2026-07-11 00:15:09 +02:00
"git.k3nny.fr/releaser/internal/glclient"
2026-07-11 17:59:13 +02:00
"git.k3nny.fr/releaser/internal/gradle"
2026-07-07 00:07:53 +02:00
"git.k3nny.fr/releaser/internal/maven"
2026-07-12 00:22:49 +02:00
"git.k3nny.fr/releaser/internal/pyproject"
2026-07-11 16:59:28 +02:00
"git.k3nny.fr/releaser/internal/node"
2026-07-07 00:07:53 +02:00
"git.k3nny.fr/releaser/internal/notes"
2026-07-12 13:55:57 +02:00
"git.k3nny.fr/releaser/internal/notify"
2026-07-07 00:07:53 +02:00
semver "git.k3nny.fr/releaser/internal/version"
)
2026-07-07 11:18:27 +02:00
const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.fr/releaser
# All fields are optional. Uncomment and adjust what you need.
# CLI flags always take precedence over values set here.
git:
2026-07-07 11:58:29 +02:00
# Prefix prepended to every version tag (default: no prefix).
2026-07-07 11:18:27 +02:00
# tag_prefix: "v"
# Regex that identifies release branches. Must contain exactly two capture
# groups: group 1 = major version, group 2 = minor version.
# branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$"
# Template for the version-bump commit message.
# {version} is replaced with the full tag name (e.g. "v1.2.3").
# commit_message: "chore(release): {version} [skip ci]"
# Override the git commit author. When omitted, releaser reads user.name
# and user.email from the repository's git config.
# author_name: ""
# author_email: ""
2026-07-11 00:15:09 +02:00
# Limit which commit types trigger a release (default: fix, feat, breaking).
# releasable_types:
# - fix
# - feat
# - breaking
2026-07-11 16:59:28 +02:00
# Configure which version component each commit type bumps.
# Valid values: "patch" (default) or "minor".
# bump_rules:
# breaking: "minor" # bump minor version instead of patch on breaking changes
# feat: "patch"
# fix: "patch"
2026-07-07 11:18:27 +02:00
maven:
2026-07-11 16:59:28 +02:00
# Single pom.xml path, relative to the repository root.
2026-07-07 11:18:27 +02:00
# pom_path: "pom.xml"
2026-07-11 16:59:28 +02:00
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
# pom_paths:
# - "pom.xml"
# - "module-a/pom.xml"
# - "module-b/pom.xml"
node:
# Single package.json path (node processing is opt-in — no default).
# package_json: "package.json"
# Multiple package.json paths for monorepos (overrides package_json).
# package_jsons:
# - "package.json"
# - "packages/frontend/package.json"
# - "packages/backend/package.json"
2026-07-11 17:59:13 +02:00
gradle:
# Single build.gradle or build.gradle.kts path (opt-in — no default).
# Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported.
# build_file: "build.gradle"
# Multiple build files for multi-module projects (overrides build_file).
# build_files:
# - "build.gradle"
# - "module-a/build.gradle"
# - "module-b/build.gradle"
2026-07-12 00:22:49 +02:00
python:
# Single pyproject.toml path (opt-in — no default).
# Reads [project].version (PEP 621) first, then [tool.poetry].version.
# pyproject_toml: "pyproject.toml"
# Multiple pyproject.toml paths for monorepos (overrides pyproject_toml).
# pyproject_tomls:
# - "pyproject.toml"
# - "packages/cli/pyproject.toml"
# - "packages/lib/pyproject.toml"
2026-07-07 11:18:27 +02:00
gitlab:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com"
# Personal or CI access token with api scope.
# Falls back to the GITLAB_TOKEN environment variable.
# Tip: never commit a real token here — use the environment variable instead.
# token: ""
# Numeric project ID or "namespace/project" path.
# Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables.
# project: ""
2026-07-11 00:15:09 +02:00
github:
# GitHub personal access token with repo scope.
# Falls back to the GITHUB_TOKEN environment variable.
# token: ""
# Repository in "owner/repo" format.
# repo: ""
2026-07-12 13:55:57 +02:00
notify:
# Every field below is opt-in. A target is only used when its required
# fields are set (via this file, or the matching environment variable).
# Notification failures never fail the release — they're logged as warnings.
# Slack incoming webhook URL. Falls back to SLACK_WEBHOOK_URL.
# slack_webhook_url: ""
# Microsoft Teams incoming webhook URL. Falls back to TEAMS_WEBHOOK_URL.
# teams_webhook_url: ""
# Google Chat incoming webhook URL. Falls back to GOOGLE_CHAT_WEBHOOK_URL.
# google_chat_webhook_url: ""
# Telegram bot token and chat ID — both required. Fall back to
# TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.
# telegram_bot_token: ""
# telegram_chat_id: ""
# Generic webhook URL — POSTed a {"version": ..., "notes": ...} JSON body.
# Falls back to RELEASER_WEBHOOK_URL.
# webhook_url: ""
2026-07-07 11:18:27 +02:00
`
2026-07-07 00:07:53 +02:00
var (
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
errNothingToRelease = errors . New ( "nothing to release" )
)
// exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os . Exit
2026-07-11 16:59:28 +02:00
// injectable function variables for testing error paths.
var (
absPath = filepath . Abs
gitAllCommits = gitutil . AllCommits
gitCommitsSince = gitutil . CommitsSince
gitCommitFiles = gitutil . CommitFiles
)
2026-07-11 00:15:09 +02:00
// releasePublisher is implemented by both glclient and ghclient.
type releasePublisher interface {
CreateRelease ( ctx context . Context , tagName , body string ) error
}
// buildPublisher selects and returns the active release publisher based on config.
// GitHub takes precedence over GitLab when both are configured.
// Returns (nil, nil) when no provider is configured — caller should skip release creation.
func buildPublisher ( cfg config . Config ) ( releasePublisher , error ) {
if cfg . GitHub . Token != "" && cfg . GitHub . Repo != "" {
return ghclient . New ( cfg . GitHub . Token , cfg . GitHub . Repo ), nil
}
if cfg . GitLab . URL != "" && cfg . GitLab . Project != "" {
if cfg . GitLab . Token == "" {
return nil , fmt . Errorf ( "GITLAB_TOKEN not set — required for release creation" )
}
return glclient . New ( cfg . GitLab . URL , cfg . GitLab . Token , cfg . GitLab . Project ), nil
}
return nil , nil
}
2026-07-07 00:07:53 +02:00
func newRootCmd () * cobra . Command {
var (
2026-07-11 00:15:09 +02:00
init_ bool
verbose bool
dryRun bool
noPush bool
noRelease bool
noCommit bool
tagOnly bool
branchOverride string
repoPath string
pomOverride string
2026-07-12 00:22:49 +02:00
gradleOverride string
pyprojectOverride string
changelogFile string
2026-07-11 00:15:09 +02:00
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
releaseEnvFile string
2026-07-07 00:07:53 +02:00
)
root := & cobra . Command {
Use : "releaser" ,
Short : "GitFlow release automation for Conventional Commits" ,
Version : version ,
SilenceUsage : true ,
RunE : func ( cmd * cobra . Command , args [] string ) error {
tagPrefixSet = cmd . Flags (). Changed ( "tag-prefix" )
patternSet = cmd . Flags (). Changed ( "branch-pattern" )
return run ( options {
2026-07-07 11:18:27 +02:00
init : init_ ,
2026-07-07 11:35:22 +02:00
verbose : verbose ,
2026-07-07 00:07:53 +02:00
repoPath : repoPath ,
branchOverride : branchOverride ,
pomOverride : pomOverride ,
2026-07-12 00:22:49 +02:00
gradleOverride : gradleOverride ,
pyprojectOverride : pyprojectOverride ,
changelogFile : changelogFile ,
2026-07-07 00:07:53 +02:00
tagPrefixFlag : tagPrefixFlag ,
tagPrefixSet : tagPrefixSet ,
patternFlag : patternFlag ,
patternSet : patternSet ,
dryRun : dryRun ,
noPush : noPush ,
2026-07-07 00:52:11 +02:00
noRelease : noRelease ,
2026-07-07 00:07:53 +02:00
noCommit : noCommit ,
tagOnly : tagOnly ,
2026-07-11 00:15:09 +02:00
releaseEnvFile : releaseEnvFile ,
2026-07-07 00:07:53 +02:00
})
},
}
2026-07-07 11:18:27 +02:00
root . Flags (). BoolVar ( & init_ , "init" , false , "create a default .releaser.yml in the repository and exit" )
2026-07-07 11:35:22 +02:00
root . Flags (). BoolVar ( & verbose , "verbose" , false , "print configuration sources, commit list, and version decision" )
2026-07-07 00:07:53 +02:00
root . Flags (). BoolVar ( & dryRun , "dry-run" , false , "print next version without making changes" )
2026-07-11 00:15:09 +02:00
root . Flags (). BoolVar ( & noPush , "no-push" , false , "create commit and tag locally without pushing or creating a release" )
root . Flags (). BoolVar ( & noRelease , "no-release" , false , "push commit and tag but skip creating the release" )
2026-07-07 11:18:27 +02:00
root . Flags (). BoolVar ( & noCommit , "no-commit" , false , "update files but do not commit, tag, or push" )
root . Flags (). BoolVar ( & tagOnly , "tag-only" , false , "tag HEAD without updating files (assumes version was already committed)" )
2026-07-07 00:07:53 +02:00
root . Flags (). StringVar ( & branchOverride , "branch" , "" , "override branch name detection (required in detached HEAD)" )
root . Flags (). StringVar ( & repoPath , "repo" , "." , "path to git repository" )
root . Flags (). StringVar ( & pomOverride , "pom" , "" , "override maven.pom_path from config" )
2026-07-11 17:59:13 +02:00
root . Flags (). StringVar ( & gradleOverride , "gradle" , "" , "override gradle.build_file from config" )
2026-07-12 00:22:49 +02:00
root . Flags (). StringVar ( & pyprojectOverride , "pyproject" , "" , "override python.pyproject_toml from config" )
2026-07-07 11:18:27 +02:00
root . Flags (). StringVar ( & changelogFile , "changelog-file" , "CHANGELOG.md" , "path to changelog file relative to repo root" )
2026-07-07 00:07:53 +02:00
root . Flags (). StringVar ( & tagPrefixFlag , "tag-prefix" , "" , "override git.tag_prefix from config" )
root . Flags (). StringVar ( & patternFlag , "branch-pattern" , "" , "override git.branch_pattern from config" )
2026-07-11 00:15:09 +02:00
root . Flags (). StringVar ( & releaseEnvFile , "release-env-file" , "release.env" , "write NEXT_VERSION dotenv to this path (relative to repo root; empty to disable)" )
2026-07-07 00:07:53 +02:00
return root
}
func main () {
if err := newRootCmd (). Execute (); err != nil {
if errors . Is ( err , errNothingToRelease ) {
exitFn ( 2 )
return
}
exitFn ( 1 )
}
}
type options struct {
2026-07-11 17:59:13 +02:00
init bool
verbose bool
repoPath string
branchOverride string
pomOverride string
2026-07-12 00:22:49 +02:00
gradleOverride string
pyprojectOverride string
changelogFile string
tagPrefixFlag string
2026-07-11 17:59:13 +02:00
tagPrefixSet bool
patternFlag string
patternSet bool
dryRun bool
noPush bool
noRelease bool
noCommit bool
tagOnly bool
releaseEnvFile string
2026-07-07 00:07:53 +02:00
}
2026-07-12 13:55:57 +02:00
// maskedIfSet reports whether a secret-like config value is set, without
// printing the value itself.
func maskedIfSet ( v string ) string {
if v != "" {
return "(set)"
}
return "(not set)"
}
2026-07-07 11:35:22 +02:00
func printVerboseConfig ( cfg config . Config , src config . Sources ) {
2026-07-07 11:46:42 +02:00
logSection ( "configuration" )
2026-07-07 11:35:22 +02:00
rows := [] struct { key , val string }{
{ "git.tag_prefix" , cfg . Git . TagPrefix },
{ "git.branch_pattern" , cfg . Git . BranchPattern },
{ "git.commit_message" , cfg . Git . CommitMessage },
{ "git.author_name" , cfg . Git . AuthorName },
{ "git.author_email" , cfg . Git . AuthorEmail },
2026-07-11 00:15:09 +02:00
{ "git.releasable_types" , func () string {
if len ( cfg . Git . ReleasableTypes ) == 0 {
return "(all)"
}
return strings . Join ( cfg . Git . ReleasableTypes , ", " )
}()},
2026-07-11 16:59:28 +02:00
{ "git.bump_rules.breaking" , func () string {
if cfg . Git . BumpRules . Breaking == "" {
return "patch"
}
return cfg . Git . BumpRules . Breaking
}()},
{ "git.bump_rules.feat" , func () string {
if cfg . Git . BumpRules . Feat == "" {
return "patch"
}
return cfg . Git . BumpRules . Feat
}()},
{ "git.bump_rules.fix" , func () string {
if cfg . Git . BumpRules . Fix == "" {
return "patch"
}
return cfg . Git . BumpRules . Fix
}()},
{ "maven.pom_paths" , strings . Join ( cfg . Maven . EffectivePomPaths (), ", " )},
{ "node.paths" , func () string {
paths := cfg . Node . EffectivePaths ()
if len ( paths ) == 0 {
return "(not configured)"
}
return strings . Join ( paths , ", " )
}()},
2026-07-11 17:59:13 +02:00
{ "gradle.paths" , func () string {
paths := cfg . Gradle . EffectiveBuildFiles ()
if len ( paths ) == 0 {
return "(not configured)"
}
return strings . Join ( paths , ", " )
}()},
2026-07-12 00:22:49 +02:00
{ "python.paths" , func () string {
paths := cfg . Python . EffectivePaths ()
if len ( paths ) == 0 {
return "(not configured)"
}
return strings . Join ( paths , ", " )
}()},
2026-07-07 11:35:22 +02:00
{ "gitlab.url" , cfg . GitLab . URL },
2026-07-12 13:55:57 +02:00
{ "gitlab.token" , maskedIfSet ( cfg . GitLab . Token )},
2026-07-07 11:35:22 +02:00
{ "gitlab.project" , cfg . GitLab . Project },
2026-07-12 13:55:57 +02:00
{ "github.token" , maskedIfSet ( cfg . GitHub . Token )},
2026-07-11 00:15:09 +02:00
{ "github.repo" , cfg . GitHub . Repo },
2026-07-12 13:55:57 +02:00
{ "notify.slack_webhook_url" , maskedIfSet ( cfg . Notify . SlackWebhookURL )},
{ "notify.teams_webhook_url" , maskedIfSet ( cfg . Notify . TeamsWebhookURL )},
{ "notify.google_chat_webhook_url" , maskedIfSet ( cfg . Notify . GoogleChatWebhookURL )},
{ "notify.telegram_bot_token" , maskedIfSet ( cfg . Notify . TelegramBotToken )},
{ "notify.telegram_chat_id" , maskedIfSet ( cfg . Notify . TelegramChatID )},
{ "notify.webhook_url" , maskedIfSet ( cfg . Notify . WebhookURL )},
2026-07-07 11:35:22 +02:00
}
for _ , r := range rows {
source := src [ r . key ]
if source == "" {
source = "default"
}
2026-07-07 11:46:42 +02:00
val := r . val
if val == "" {
val = paint ( ansiDim , "(empty)" )
}
fmt . Fprintf ( os . Stderr , " %-25s = %-45s %s\n" , r . key , val , fmtSource ( source ))
2026-07-07 11:35:22 +02:00
}
}
2026-07-07 11:18:27 +02:00
func initConfig ( absRepo string ) error {
path := filepath . Join ( absRepo , ".releaser.yml" )
if _ , err := os . Stat ( path ); err == nil {
return fmt . Errorf ( ".releaser.yml already exists in %s — delete it first if you want to reset" , absRepo )
}
if err := os . WriteFile ( path , [] byte ( defaultConfigTemplate ), 0644 ); err != nil {
return fmt . Errorf ( "write .releaser.yml: %w" , err )
}
fmt . Printf ( "created %s\n" , path )
return nil
}
2026-07-11 16:59:28 +02:00
func parseBumpRules ( rules config . BumpRulesConfig ) map [ commits . Type ] semver . BumpLevel {
m := map [ commits . Type ] semver . BumpLevel {}
if rules . Breaking == "minor" {
m [ commits . TypeBreaking ] = semver . BumpMinor
}
if rules . Feat == "minor" {
m [ commits . TypeFeat ] = semver . BumpMinor
}
if rules . Fix == "minor" {
m [ commits . TypeFix ] = semver . BumpMinor
}
return m
}
2026-07-07 00:07:53 +02:00
func run ( o options ) error {
2026-07-07 11:53:22 +02:00
logHeader ( version )
2026-07-07 00:07:53 +02:00
// --- Config ---
2026-07-11 16:59:28 +02:00
absRepo , err := absPath ( o . repoPath )
2026-07-07 00:07:53 +02:00
if err != nil {
return fmt . Errorf ( "resolve repo path: %w" , err )
}
2026-07-07 11:18:27 +02:00
if o . init {
2026-07-07 11:46:42 +02:00
if o . verbose {
logStep ( "creating .releaser.yml in %s" , absRepo )
}
2026-07-07 11:18:27 +02:00
return initConfig ( absRepo )
}
2026-07-11 00:15:09 +02:00
cfg , src , err := config . LoadWithSources ( absRepo )
2026-07-07 00:07:53 +02:00
if err != nil {
return err
}
2026-07-07 11:35:22 +02:00
cfg . ApplyEnvWithSources ( src )
2026-07-07 00:07:53 +02:00
// CLI flags take precedence over config file and env vars
if o . tagPrefixSet {
cfg . Git . TagPrefix = o . tagPrefixFlag
2026-07-11 00:15:09 +02:00
src [ "git.tag_prefix" ] = "flag: --tag-prefix"
2026-07-07 00:07:53 +02:00
}
if o . pomOverride != "" {
cfg . Maven . PomPath = o . pomOverride
2026-07-11 16:59:28 +02:00
cfg . Maven . PomPaths = nil
src [ "maven.pom_paths" ] = "flag: --pom"
2026-07-07 00:07:53 +02:00
}
2026-07-11 17:59:13 +02:00
if o . gradleOverride != "" {
cfg . Gradle . BuildFile = o . gradleOverride
cfg . Gradle . BuildFiles = nil
src [ "gradle.build_files" ] = "flag: --gradle"
}
2026-07-12 00:22:49 +02:00
if o . pyprojectOverride != "" {
cfg . Python . PyprojectTOML = o . pyprojectOverride
cfg . Python . PyprojectTOMLs = nil
src [ "python.pyproject_tomls" ] = "flag: --pyproject"
}
2026-07-07 00:07:53 +02:00
if o . patternSet {
cfg . Git . BranchPattern = o . patternFlag
2026-07-11 00:15:09 +02:00
src [ "git.branch_pattern" ] = "flag: --branch-pattern"
2026-07-07 11:35:22 +02:00
}
if o . verbose {
printVerboseConfig ( cfg , src )
2026-07-07 00:07:53 +02:00
}
// --- Git ---
repo , err := gogit . PlainOpenWithOptions ( absRepo , & gogit . PlainOpenOptions { DetectDotGit : true })
if err != nil {
return fmt . Errorf ( "open repository: %w" , err )
}
branchName := o . branchOverride
if branchName == "" {
branchName , err = gitutil . CurrentBranch ( repo )
if err != nil {
return err
}
}
info , err := branch . Parse ( branchName , cfg . Git . BranchPattern )
if err != nil {
return err
}
info . TagPrefix = cfg . Git . TagPrefix
2026-07-07 11:46:42 +02:00
if o . verbose {
logSection ( "branch" )
fmt . Fprintf ( os . Stderr , " %s → major=%d, minor=%d %s\n" ,
paint ( ansiBold , branchName ), info . Major , info . Minor ,
paint ( ansiDim , "(pinned by branch)" ))
}
2026-07-07 11:35:22 +02:00
2026-07-07 00:07:53 +02:00
// --- Dirty check (before any changes) ---
// Skipped in --no-commit mode: the user intentionally has changes in flight.
if ! o . dryRun && ! o . noCommit {
clean , err := gitutil . IsWorkingTreeClean ( repo )
if err != nil {
return fmt . Errorf ( "check working tree: %w" , err )
}
if ! clean {
return fmt . Errorf ( "working tree has uncommitted changes — commit or stash them before releasing" )
}
}
// --- Tag discovery ---
lastTag , currentPatch , err := gitutil . LatestTag ( repo , info )
if err != nil {
return fmt . Errorf ( "find latest tag: %w" , err )
}
// --- Commit range ---
var messages [] string
if lastTag == "" {
2026-07-11 16:59:28 +02:00
messages , err = gitAllCommits ( repo )
2026-07-07 00:07:53 +02:00
} else {
2026-07-11 16:59:28 +02:00
messages , err = gitCommitsSince ( repo , lastTag )
2026-07-07 00:07:53 +02:00
}
if err != nil {
return fmt . Errorf ( "read commits: %w" , err )
}
2026-07-07 11:46:42 +02:00
if ! o . verbose {
if lastTag == "" {
logStep ( "no previous tag — scanning all %d commit(s)" , len ( messages ))
} else {
logStep ( "last tag: %s (%d commit(s) to analyze)" , lastTag , len ( messages ))
}
}
2026-07-07 00:07:53 +02:00
// --- Version calculation ---
types := make ([] commits . Type , len ( messages ))
for i , msg := range messages {
types [ i ] = commits . Parse ( msg )
}
2026-07-07 11:35:22 +02:00
if o . verbose {
2026-07-07 11:46:42 +02:00
logSection ( fmt . Sprintf ( "commits (%d)" , len ( messages )))
if lastTag != "" {
fmt . Fprintf ( os . Stderr , " since: %s (patch=%d)\n" , paint ( ansiCyan , lastTag ), currentPatch )
}
2026-07-07 11:35:22 +02:00
for i , msg := range messages {
first := strings . SplitN ( strings . TrimSpace ( msg ), "\n" , 2 )[ 0 ]
2026-07-07 11:46:42 +02:00
if len ( first ) > 70 {
first = first [: 67 ] + "..."
}
2026-07-07 11:35:22 +02:00
t := types [ i ]
2026-07-07 11:46:42 +02:00
typeLabel := fmt . Sprintf ( "%-9s" , t . String ())
if t == commits . TypeNone {
fmt . Fprintf ( os . Stderr , " %s\n" , paint ( ansiDim , typeLabel + first ))
} else {
var col string
switch t {
case commits . TypeBreaking :
col = ansiRed + ansiBold
case commits . TypeFeat :
col = ansiCyan
default : // fix
col = ansiGreen
}
fmt . Fprintf ( os . Stderr , " %s %s %s\n" ,
paint ( col , typeLabel ), first , paint ( ansiDim , "→ patch bump" ))
2026-07-07 11:35:22 +02:00
}
}
}
2026-07-11 00:15:09 +02:00
releasable := commits . ReleasableSet ( cfg . Git . ReleasableTypes )
2026-07-11 16:59:28 +02:00
nextVersion , ok := semver . Next ( info . Major , info . Minor , currentPatch , types , releasable , parseBumpRules ( cfg . Git . BumpRules ))
2026-07-07 00:07:53 +02:00
if ! ok {
2026-07-07 11:46:42 +02:00
logWarn ( "no releasable commits found" )
2026-07-07 00:07:53 +02:00
return errNothingToRelease
}
nextTag := info . TagName ( nextVersion )
2026-07-07 11:35:22 +02:00
if o . verbose {
highestType := commits . TypeNone
for _ , t := range types {
if t > highestType {
highestType = t
}
}
2026-07-07 11:46:42 +02:00
logSection ( "version" )
fmt . Fprintf ( os . Stderr , " highest type: %s → next: %s (tag: %s)\n" ,
paint ( ansiCyan , highestType . String ()),
paint ( ansiBold , nextVersion ),
paint ( ansiBold + ansiCyan , nextTag ))
2026-07-07 11:35:22 +02:00
}
2026-07-07 00:07:53 +02:00
fmt . Printf ( "next version: %s (tag: %s)\n" , nextVersion , nextTag )
if o . dryRun {
2026-07-07 11:46:42 +02:00
logStep ( "dry-run: no changes made" )
2026-07-07 00:07:53 +02:00
return nil
}
2026-07-07 13:54:38 +02:00
// --- release.env (GitLab CI dotenv artifact) ---
2026-07-11 00:15:09 +02:00
if o . releaseEnvFile != "" {
releaseEnvPath := filepath . Join ( absRepo , o . releaseEnvFile )
if err := os . WriteFile ( releaseEnvPath , [] byte ( "NEXT_VERSION=" + nextTag + "\n" ), 0644 ); err != nil {
return fmt . Errorf ( "write %s: %w" , o . releaseEnvFile , err )
}
logDone ( "%s: NEXT_VERSION=%s" , o . releaseEnvFile , nextTag )
2026-07-07 13:54:38 +02:00
}
2026-07-07 11:18:27 +02:00
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
if ! o . tagOnly {
var filesToCommit [] string
2026-07-07 00:33:57 +02:00
2026-07-11 16:59:28 +02:00
// pom.xml (supports multi-module via pom_paths)
anyPom := false
for _ , relPomPath := range cfg . Maven . EffectivePomPaths () {
pomPath := filepath . Join ( absRepo , relPomPath )
_ , statErr := os . Stat ( pomPath )
hasPom := ! errors . Is ( statErr , os . ErrNotExist )
if statErr != nil && hasPom {
return fmt . Errorf ( "check pom path: %w" , statErr )
}
if hasPom {
anyPom = true
currentPomVersion , err := maven . ReadVersion ( pomPath )
if err != nil {
return fmt . Errorf ( "read pom version: %w" , err )
}
if err := maven . WriteVersion ( pomPath , currentPomVersion , nextVersion ); err != nil {
return fmt . Errorf ( "update pom version: %w" , err )
}
logDone ( "%s: %s → %s" , relPomPath , currentPomVersion , nextVersion )
filesToCommit = append ( filesToCommit , relPomPath )
}
}
if ! anyPom {
logWarn ( "no pom.xml — skipping version bump" )
2026-07-07 11:18:27 +02:00
}
2026-07-11 16:59:28 +02:00
// package.json (opt-in via node.package_json / node.package_jsons)
for _ , relPkgPath := range cfg . Node . EffectivePaths () {
pkgPath := filepath . Join ( absRepo , relPkgPath )
currentNodeVersion , err := node . ReadVersion ( pkgPath )
2026-07-07 11:18:27 +02:00
if err != nil {
2026-07-11 16:59:28 +02:00
return fmt . Errorf ( "read package.json version: %w" , err )
2026-07-07 11:18:27 +02:00
}
2026-07-11 16:59:28 +02:00
if err := node . WriteVersion ( pkgPath , currentNodeVersion , nextVersion ); err != nil {
return fmt . Errorf ( "update package.json version: %w" , err )
2026-07-07 11:18:27 +02:00
}
2026-07-11 16:59:28 +02:00
logDone ( "%s: %s → %s" , relPkgPath , currentNodeVersion , nextVersion )
filesToCommit = append ( filesToCommit , relPkgPath )
2026-07-07 00:07:53 +02:00
}
2026-07-11 17:59:13 +02:00
// build.gradle / build.gradle.kts (opt-in via gradle.build_file / gradle.build_files)
for _ , relGradlePath := range cfg . Gradle . EffectiveBuildFiles () {
gradlePath := filepath . Join ( absRepo , relGradlePath )
currentGradleVersion , err := gradle . ReadVersion ( gradlePath )
if err != nil {
return fmt . Errorf ( "read gradle version: %w" , err )
}
if err := gradle . WriteVersion ( gradlePath , currentGradleVersion , nextVersion ); err != nil {
return fmt . Errorf ( "update gradle version: %w" , err )
}
logDone ( "%s: %s → %s" , relGradlePath , currentGradleVersion , nextVersion )
filesToCommit = append ( filesToCommit , relGradlePath )
}
2026-07-12 00:22:49 +02:00
// pyproject.toml (opt-in via python.pyproject_toml / python.pyproject_tomls)
for _ , relPyprojectPath := range cfg . Python . EffectivePaths () {
pyprojectPath := filepath . Join ( absRepo , relPyprojectPath )
currentPyVersion , err := pyproject . ReadVersion ( pyprojectPath )
if err != nil {
return fmt . Errorf ( "read pyproject version: %w" , err )
}
if err := pyproject . WriteVersion ( pyprojectPath , currentPyVersion , nextVersion ); err != nil {
return fmt . Errorf ( "update pyproject version: %w" , err )
}
logDone ( "%s: %s → %s" , relPyprojectPath , currentPyVersion , nextVersion )
filesToCommit = append ( filesToCommit , relPyprojectPath )
}
2026-07-07 11:18:27 +02:00
// CHANGELOG.md
changelogAbsPath := filepath . Join ( absRepo , o . changelogFile )
if err := changelog . Update ( changelogAbsPath , nextTag , nextVersion , messages ); err != nil {
return fmt . Errorf ( "update changelog: %w" , err )
2026-07-07 00:07:53 +02:00
}
2026-07-07 11:46:42 +02:00
logDone ( "%s updated" , o . changelogFile )
2026-07-07 11:18:27 +02:00
filesToCommit = append ( filesToCommit , o . changelogFile )
2026-07-07 00:07:53 +02:00
if o . noCommit {
2026-07-07 11:18:27 +02:00
fmt . Printf ( "files updated to %s — commit manually then re-run with --tag-only\n" , nextVersion )
2026-07-07 00:07:53 +02:00
return nil
}
// --- Git commit ---
authorName , authorEmail := gitutil . AuthorFromConfig ( repo )
if cfg . Git . AuthorName != "" {
authorName = cfg . Git . AuthorName
}
if cfg . Git . AuthorEmail != "" {
authorEmail = cfg . Git . AuthorEmail
}
commitMsg := strings . ReplaceAll ( cfg . Git . CommitMessage , "{version}" , nextTag )
2026-07-11 16:59:28 +02:00
if _ , err := gitCommitFiles ( repo , filesToCommit , commitMsg , authorName , authorEmail ); err != nil {
2026-07-07 11:18:27 +02:00
return fmt . Errorf ( "commit: %w" , err )
2026-07-07 00:07:53 +02:00
}
2026-07-07 11:46:42 +02:00
logDone ( "committed: %s" , commitMsg )
2026-07-07 00:07:53 +02:00
}
// --- Git tag ---
if err := gitutil . CreateTag ( repo , nextTag ); err != nil {
return fmt . Errorf ( "create tag: %w" , err )
}
2026-07-07 11:46:42 +02:00
logDone ( "tag: %s" , nextTag )
2026-07-07 00:07:53 +02:00
if o . noPush {
fmt . Printf ( "released %s locally — push manually with: git push && git push --tags\n" , nextTag )
return nil
}
// --- Push ---
2026-07-07 11:46:42 +02:00
logStep ( "pushing commit and tag..." )
2026-07-07 00:07:53 +02:00
if err := gitutil . Push ( repo , branchName , nextTag , cfg . GitLab . Token ); err != nil {
return fmt . Errorf ( "push: %w" , err )
}
2026-07-07 11:46:42 +02:00
logDone ( "pushed" )
2026-07-07 00:07:53 +02:00
2026-07-12 13:55:57 +02:00
releaseNotes := notes . Generate ( nextTag , messages )
2026-07-07 00:52:11 +02:00
if o . noRelease {
2026-07-12 13:55:57 +02:00
notifyRelease ( cfg , nextTag , releaseNotes )
2026-07-07 00:52:11 +02:00
fmt . Printf ( "released %s\n" , nextTag )
return nil
}
2026-07-11 00:15:09 +02:00
// --- Release creation ---
publisher , err := buildPublisher ( cfg )
if err != nil {
return err
}
if publisher == nil {
logWarn ( "no release provider configured — skipping release creation" )
2026-07-12 13:55:57 +02:00
} else {
if err := publisher . CreateRelease ( context . Background (), nextTag , releaseNotes ); err != nil {
return fmt . Errorf ( "create release: %w" , err )
}
logDone ( "release created: %s" , nextTag )
2026-07-07 00:07:53 +02:00
}
2026-07-12 13:55:57 +02:00
notifyRelease ( cfg , nextTag , releaseNotes )
2026-07-07 00:07:53 +02:00
fmt . Printf ( "released %s\n" , nextTag )
return nil
}
2026-07-12 13:55:57 +02:00
// notifyRelease sends best-effort release notifications to every configured
// target. Failures are logged as warnings, not errors — the release itself
// already succeeded by the time this runs.
func notifyRelease ( cfg config . Config , tagName , releaseNotes string ) {
notifyCfg := notify . Config {
SlackWebhookURL : cfg . Notify . SlackWebhookURL ,
TeamsWebhookURL : cfg . Notify . TeamsWebhookURL ,
GoogleChatWebhookURL : cfg . Notify . GoogleChatWebhookURL ,
TelegramBotToken : cfg . Notify . TelegramBotToken ,
TelegramChatID : cfg . Notify . TelegramChatID ,
WebhookURL : cfg . Notify . WebhookURL ,
}
for _ , err := range notify . SendAll ( context . Background (), notifyCfg , notify . Message { Version : tagName , Notes : releaseNotes }) {
logWarn ( "notification failed: %v" , err )
}
}