Compare commits

...

13 Commits

Author SHA1 Message Date
123cd38a18 Add fatal missing-version handling with opt-out flag
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-01 13:11:33 +03:00
f9865168c9 Fix entrypoint
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-24 14:22:01 +03:00
08c1a170dc Bump version
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-24 12:42:18 +03:00
56848c73fd Fix issues 2025-12-24 12:01:22 +03:00
dc05c68ee2 Add build metadata injection 2025-12-24 11:34:57 +03:00
e7a65571c8 Align build tooling 2025-12-24 11:34:56 +03:00
14ff6977ab Add cache directory to gitignore 2025-12-24 11:00:57 +03:00
3628a7898b Add tests for bumpversion logic 2025-12-24 10:38:58 +03:00
04298cad4e Commit only versioned files 2025-09-16 15:35:47 +03:00
8b30e53a69 [skip ci] Bump version: 0.1.1 → 0.1.2 2025-04-04 01:20:11 +03:00
c11139e078 Right tag message 2025-04-04 01:17:34 +03:00
ce98e2f482 Save config file before commit 2025-04-04 01:08:12 +03:00
93917649e6 Fix messages 2025-04-04 01:07:53 +03:00
11 changed files with 574 additions and 93 deletions

View File

@@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 0.1.1 current_version = 1.0.0
commit = True commit = True
tag = True tag = True
tag_name = {new_version} tag_name = {new_version}

View File

@@ -15,7 +15,7 @@ steps:
path: /var/run/docker.sock path: /var/run/docker.sock
settings: settings:
dockerfile: Dockerfile dockerfile: Dockerfile
tags: 0.1.1 tags: 1.0.0
force_tag: true force_tag: true
registry: registry.halfakop.ru registry: registry.halfakop.ru
repo: registry.halfakop.ru/golang/bumpversion repo: registry.halfakop.ru/golang/bumpversion

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
*.run *.run
.env .env
.cache

View File

@@ -1,14 +1,17 @@
FROM golang:1.24.1-alpine AS builder FROM golang:1.24-alpine AS builder
ARG SOURCE_VERSION
ARG SOURCE_COMMIT
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bumpversion ./src RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags "-X main.AppVersion=${SOURCE_VERSION} -X main.AppCommit=${SOURCE_COMMIT}" \
-o bumpversion ./src
FROM gcr.io/distroless/static FROM gcr.io/distroless/static
WORKDIR / WORKDIR /
COPY --from=builder /app/bumpversion . COPY --from=builder /app/bumpversion /bumpversion
ENTRYPOINT ["/bumpversion"] ENTRYPOINT ["/bumpversion"]
CMD ["--version"] CMD ["--version"]

View File

@@ -3,10 +3,13 @@ PACKAGE := bumpversion
SHELL := /bin/bash SHELL := /bin/bash
REGISTRY := registry.halfakop.ru REGISTRY := registry.halfakop.ru
REPOSITORY := $(NAMESPACE)/$(PACKAGE) REPOSITORY := $(NAMESPACE)/$(PACKAGE)
PLATFORM ?= --platform=linux/amd64
GOCACHE ?= $(CURDIR)/.cache/go-build
SOURCE_VERSION ?= $(shell cat VERSION) SOURCE_VERSION ?= $(shell cat VERSION)
SOURCE_COMMIT ?= $(shell git rev-parse --short=8 HEAD) SOURCE_COMMIT ?= $(shell git rev-parse --short=8 HEAD)
GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD | sed s,feature/,,g) GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD | sed s,feature/,,g)
LDFLAGS := -X main.AppVersion=$(SOURCE_VERSION) -X main.AppCommit=$(SOURCE_COMMIT)
IMAGE_NAME_TAGGED = $(REPOSITORY):$(SOURCE_VERSION) IMAGE_NAME_TAGGED = $(REPOSITORY):$(SOURCE_VERSION)
EXEC=$(PACKAGE).run EXEC=$(PACKAGE).run
@@ -14,34 +17,62 @@ EXEC=$(PACKAGE).run
all: help all: help
help: help:
@echo "app - build the application" @printf "\nMain make targets:\n"
@echo "tests - run tests" @printf " make app - Download deps, fix, build app (binary: %s)\n" "$(EXEC)"
@echo "run - run application locally" @printf " make test - Run unit tests\n"
@echo "clean - clean build environment" @printf " make test-integration - Run tests that require Postgres (testcontainers)\n"
@printf " make run - Build then run locally\n"
@printf " make clean - Clean build artifacts\n"
@printf " make release - Clean, build image, login, push\n"
@printf "\nVariables:\n"
@printf " NAMESPACE=%s\n" "$(NAMESPACE)"
@printf " PACKAGE=%s\n" "$(PACKAGE)"
@printf " IMAGE_NAME_TAGGED=%s\n" "$(IMAGE_NAME_TAGGED)"
@printf " EXEC=%s\n\n" "$(EXEC)"
download:
@echo "Download dependencies"
@GOCACHE=$(GOCACHE) go mod download
fix: fix:
@go fix ./... @echo "Fix code"
@GOCACHE=$(GOCACHE) go fix ./...
app: fix app: download fix
@go build -o ./${EXEC} ./src @echo "Build application"
@GOCACHE=$(GOCACHE) go build -ldflags "$(LDFLAGS)" -o ./${EXEC} ./src
tests: build tests: app
@go test ./... @echo "Run tests"
@GOCACHE=$(GOCACHE) go test ./...
test:
@echo "Run unit tests"
@GOCACHE=$(GOCACHE) go test -count=1 ./...
test-integration:
@echo "Run integration tests (requires Docker for Postgres)"
@GOCACHE=$(GOCACHE) go test -tags=integration -count=1 ./...
run: run:
@echo "Run application"
@./${EXEC} @./${EXEC}
clean: clean:
@rm -rf ./${EXEC}% @echo "Clean build environment"
@rm -rf ./${EXEC}% $(GOCACHE)
release: title clean build login push release: clean build login push
build: build:
docker build --compress \ DOCKER_BUILDKIT=0 \
docker build $(PLATFORM) --progress=plain --compress \
-t $(IMAGE_NAME_TAGGED) \ -t $(IMAGE_NAME_TAGGED) \
-t $(REGISTRY)/$(IMAGE_NAME_TAGGED) \ -t $(REGISTRY)/$(IMAGE_NAME_TAGGED) \
--build-arg SOURCE_VERSION=$(SOURCE_VERSION) \ --build-arg SOURCE_VERSION=$(SOURCE_VERSION) \
--build-arg SOURCE_COMMIT=$(SOURCE_COMMIT) \ --build-arg SOURCE_COMMIT=$(SOURCE_COMMIT) \
--build-arg GOPROXY=$(GOPROXY) \
--build-arg GONOSUMDB=$(GONOSUMDB) \
${DOCKER_OPTS} \ ${DOCKER_OPTS} \
-f Dockerfile . -f Dockerfile .
@@ -54,4 +85,4 @@ login:
push: push:
docker push $(REGISTRY)/$(IMAGE_NAME_TAGGED) docker push $(REGISTRY)/$(IMAGE_NAME_TAGGED)
.PHONY: tests release build login push .PHONY: tests test test-integration release build login push download fix app clean run help

View File

@@ -1,4 +1,4 @@
# BumpVersion v0.1.1 # BumpVersion v1.0.0
[![Build Status](https://drone.halfakop.ru/api/badges/rad/bumpversion/status.svg)](https://drone.halfakop.ru/rad/bumpversion) [![Build Status](https://drone.halfakop.ru/api/badges/rad/bumpversion/status.svg)](https://drone.halfakop.ru/rad/bumpversion)
@@ -6,11 +6,18 @@
## Разработчику ## Разработчику
```bash
export PATH=$PATH:/usr/local/go/bin export PATH=$PATH:/usr/local/go/bin
go mod init src go mod init src
go mod tidy go mod tidy
go build go build
go run . go run .
```
или
```bash
make
```
## Девопсу ## Девопсу

View File

@@ -1 +1 @@
0.1.1 1.0.0

33
src/build_info.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import "runtime/debug"
func init() {
if AppCommit == "" || AppCommit == "unknown" {
if commit := resolveCommitFromBuildInfo(); commit != "" {
AppCommit = commit
}
}
}
func resolveCommitFromBuildInfo() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return ""
}
for _, setting := range info.Settings {
if setting.Key == "vcs.revision" && setting.Value != "" {
return shortCommit(setting.Value)
}
}
return ""
}
func shortCommit(commit string) string {
if len(commit) >= 8 {
return commit[:8]
}
return commit
}

View File

@@ -9,49 +9,126 @@ import (
git "github.com/go-git/go-git/v5" git "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
) )
// getenvDefault возвращает значение из окружения по ключу, или заданное
// по умолчанию
func getenvDefault(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// normalizePath возвращает нормализованный путь
func normalizePath(p string) string {
p = strings.TrimSpace(p)
p = strings.TrimPrefix(p, "./")
return p
}
func pushRefSpec(headRef *plumbing.Reference) (config.RefSpec, error) {
if headRef == nil || !headRef.Name().IsBranch() {
return "", fmt.Errorf("cannot determine branch to push from HEAD")
}
branch := headRef.Name().String()
return config.RefSpec(branch + ":" + branch), nil
}
func signatureFromEnv() *object.Signature {
return &object.Signature{
Name: getenvDefault("GIT_USERNAME", "bumpversion"),
Email: getenvDefault("GIT_EMAIL", "bumpversion@deploy"),
When: time.Now().UTC(),
}
}
// gitCommit выполняет коммит с внесёнными изменениями // gitCommit выполняет коммит с внесёнными изменениями
func gitCommit(bc *BumpConfig, newVersion string) { func gitCommit(bc *BumpConfig, newVersion string, configPath string) {
// Открываем локальный репозиторий (предполагается, что он существует в папке ".") // Открываем локальный репозиторий (предполагается, что он существует в папке ".")
repo, err := git.PlainOpen(".") repo, err := git.PlainOpen(".")
if err != nil { if err != nil {
log.Fatalf("Ошибка открытия репозитория: %v", err) log.Fatalf("Repository open error: %v", err)
} }
// Получаем рабочее дерево // получаем рабочее дерево
worktree, err := repo.Worktree() worktree, err := repo.Worktree()
if err != nil { if err != nil {
log.Fatalf("Ошибка получения рабочей директории: %v", err) log.Fatalf("Work directory open error: %v", err)
} }
// Добавляем все изменения в индекс (или конкретные файлы, если нужно) // создаём список файлов, которые должны быть включены в коммит
_, err = worktree.Add(".") targets := make([]string, 0, len(bc.FilePaths)+1)
targets = append(targets, bc.FilePaths...)
targets = append(targets, configPath)
// проверяем на наличие изменений
status, err := worktree.Status()
if err != nil { if err != nil {
log.Fatalf("Ошибка добавления изменений: %v", err) log.Fatalf("Status error: %v", err)
} }
// Формируем сообщение коммита changed := false
commitMsg := strings.ReplaceAll(bc.Message, "{current_version}", bc.CurrentVersion) for _, p := range targets {
commitMsg = strings.ReplaceAll(commitMsg, "{new_version}", newVersion) if p == "" {
commit, err := worktree.Commit(commitMsg, &git.CommitOptions{ continue
Author: &object.Signature{ }
Name: os.Getenv("GIT_USERNAME"), p = normalizePath(p)
Email: os.Getenv("GIT_EMAIL"),
When: time.Now(), // пропускаем отсутствующие файлы тихо
}, if _, err := os.Stat(p); err != nil {
if os.IsNotExist(err) {
log.Printf("Skip missing file: %s", p)
continue
}
log.Fatalf("Stat error for %s: %v", p, err)
}
st, ok := status[p]
if !ok || st == nil {
// nothing to stage for this file
continue
}
if st.Worktree != git.Unmodified {
if _, err := worktree.Add(p); err != nil {
log.Fatalf("Add %s error: %v", p, err)
}
changed = true
continue
}
}
if !changed {
log.Printf("No changes detected in configured files; skipping commit.")
return
}
// формируем сообщение коммита
commitMsg := strings.
NewReplacer(
"{current_version}", bc.CurrentVersion,
"{new_version}", newVersion,
).Replace(bc.Message)
author := signatureFromEnv()
hash, err := worktree.Commit(commitMsg, &git.CommitOptions{
Author: author,
Committer: author,
}) })
if err != nil { if err != nil {
log.Fatalf("Ошибка выполнения коммита: %v", err) log.Fatalf("Commit error: %v", err)
} }
// Получаем объект коммита (по его хэшу) // Получаем объект коммита (по его хэшу)
commitObj, err := repo.CommitObject(commit) commitObj, err := repo.CommitObject(hash)
if err != nil { if err != nil {
log.Fatalf("Ошибка получения объекта коммита: %v", err) log.Fatalf("Commit object error: %v", err)
} }
fmt.Printf("Коммит выполнен: %s\n", commitObj.Hash) log.Printf("Committed as %s\n", commitObj.Hash)
} }
// gitTag ставит тэг на текущий коммит // gitTag ставит тэг на текущий коммит
@@ -59,52 +136,60 @@ func gitTag(bc *BumpConfig, newVersion string) {
// Открываем локальный репозиторий (предполагается, что он существует в папке ".") // Открываем локальный репозиторий (предполагается, что он существует в папке ".")
repo, err := git.PlainOpen(".") repo, err := git.PlainOpen(".")
if err != nil { if err != nil {
log.Fatalf("Ошибка открытия репозитория: %v", err) log.Fatalf("Repository open error: %v", err)
} }
// Получаем текущий HEAD (он должен совпадать с только что созданным коммитом) // Получаем текущий HEAD (он должен совпадать с только что созданным коммитом)
headRef, err := repo.Head() headRef, err := repo.Head()
if err != nil { if err != nil {
log.Fatalf("Ошибка получения HEAD: %v", err) log.Fatalf("HEAD open error: %v", err)
} }
fmt.Printf("Текущий HEAD: %s\n", headRef.Hash()) log.Printf("Current HEAD is %s\n", headRef.Hash())
// Создаем тег на текущем коммите (HEAD) // Создаем тег на текущем коммите (HEAD)
commitMsg := strings.ReplaceAll(bc.Message, "{current_version}", bc.CurrentVersion)
commitMsg = strings.ReplaceAll(commitMsg, "{new_version}", newVersion)
tagName := strings.ReplaceAll(bc.TagName, "{new_version}", newVersion) tagName := strings.ReplaceAll(bc.TagName, "{new_version}", newVersion)
_, err = repo.CreateTag(tagName, headRef.Hash(), &git.CreateTagOptions{ _, err = repo.CreateTag(tagName, headRef.Hash(), &git.CreateTagOptions{
Tagger: &object.Signature{ Tagger: signatureFromEnv(),
Name: os.Getenv("GIT_USERNAME"), Message: commitMsg,
Email: os.Getenv("GIT_EMAIL"),
When: time.Now(),
},
Message: "Тег создан с помощью go-git",
}) })
if err != nil { if err != nil {
log.Fatalf("Ошибка создания тега: %v", err) log.Fatalf("Tag creation error: %v", err)
} }
fmt.Printf("Тег '%s' создан на коммите %s\n", tagName, headRef.Hash()) log.Printf("Tag '%s' is created on commit %s\n", tagName, headRef.Hash())
} }
func gitPush(bc *BumpConfig, newVersion string) { func gitPush(bc *BumpConfig, newVersion string) {
// Открываем локальный репозиторий (предполагается, что он существует в папке ".") // Открываем локальный репозиторий (предполагается, что он существует в папке ".")
repo, err := git.PlainOpen(".") repo, err := git.PlainOpen(".")
if err != nil { if err != nil {
log.Fatalf("Ошибка открытия репозитория: %v", err) log.Fatalf("Repository open error: %v", err)
} }
tagName := strings.ReplaceAll(bc.TagName, "{new_version}", newVersion) tagName := strings.ReplaceAll(bc.TagName, "{new_version}", newVersion)
headRef, err := repo.Head()
if err != nil {
log.Fatalf("HEAD open error: %v", err)
}
branchSpec, err := pushRefSpec(headRef)
if err != nil {
log.Fatalf("Push branch detection error: %v", err)
}
// (Опционально) Выполняем push на удаленный репозиторий // (Опционально) Выполняем push на удаленный репозиторий
tagSpec := config.RefSpec("refs/tags/" + tagName + ":refs/tags/" + tagName) tagSpec := config.RefSpec("refs/tags/" + tagName + ":refs/tags/" + tagName)
err = repo.Push(&git.PushOptions{ err = repo.Push(&git.PushOptions{
RemoteName: "origin", RemoteName: "origin",
RefSpecs: []config.RefSpec{ RefSpecs: []config.RefSpec{
"refs/heads/master:refs/heads/master", branchSpec,
tagSpec, tagSpec,
}, },
}) })
if err != nil { if err != nil {
log.Fatalf("Ошибка пуша: %v", err) log.Fatalf("Push error: %v", err)
} }
fmt.Println("Изменения успешно отправлены") log.Println("Changes pushed successfully")
} }

View File

@@ -27,7 +27,7 @@ type BumpConfig struct {
func getBumpConfig(cfg_name string) (*BumpConfig, error) { func getBumpConfig(cfg_name string) (*BumpConfig, error) {
cfg, err := ini.Load(cfg_name) cfg, err := ini.Load(cfg_name)
if err != nil { if err != nil {
log.Fatalf("Error loading config: %v", err) return nil, fmt.Errorf("error loading config: %w", err)
} }
sec, err := cfg.GetSection("bumpversion") sec, err := cfg.GetSection("bumpversion")
@@ -70,6 +70,7 @@ func bumpVersion(bc *BumpConfig, part string) (string, error) {
// Создадим карту групп по именам // Создадим карту групп по именам
groupNames := re.SubexpNames() groupNames := re.SubexpNames()
var major, minor, patch int var major, minor, patch int
var hasMajor, hasMinor, hasPatch bool
for i, name := range groupNames { for i, name := range groupNames {
switch name { switch name {
case "major": case "major":
@@ -77,18 +78,24 @@ func bumpVersion(bc *BumpConfig, part string) (string, error) {
if err != nil { if err != nil {
return "", err return "", err
} }
hasMajor = true
case "minor": case "minor":
minor, err = strconv.Atoi(matches[i]) minor, err = strconv.Atoi(matches[i])
if err != nil { if err != nil {
return "", err return "", err
} }
hasMinor = true
case "patch": case "patch":
patch, err = strconv.Atoi(matches[i]) patch, err = strconv.Atoi(matches[i])
if err != nil { if err != nil {
return "", err return "", err
} }
hasPatch = true
} }
} }
if !hasMajor || !hasMinor || !hasPatch {
return "", fmt.Errorf("parse pattern must contain major, minor, and patch groups")
}
switch part { switch part {
case "major": case "major":
major++ major++
@@ -109,8 +116,8 @@ func bumpVersion(bc *BumpConfig, part string) (string, error) {
return newVersion, nil return newVersion, nil
} }
// updateFiles обновляет версию в файле // updateFiles обновляет версию в файле; при fatalIfMissing=true возвращает ошибку, если строка не найдена.
func updateFiles(filePaths []string, oldVersion, newVersion string) { func updateFiles(filePaths []string, oldVersion, newVersion string, fatalIfMissing bool) error {
for _, path := range filePaths { for _, path := range filePaths {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
@@ -118,12 +125,20 @@ func updateFiles(filePaths []string, oldVersion, newVersion string) {
continue continue
} }
newData := strings.ReplaceAll(string(data), oldVersion, newVersion) newData := strings.ReplaceAll(string(data), oldVersion, newVersion)
if newData == string(data) {
log.Printf("Version %s not found in %s; skip update", oldVersion, path)
if fatalIfMissing {
return fmt.Errorf("version %s not found in %s", oldVersion, path)
}
continue
}
if err := os.WriteFile(path, []byte(newData), 0644); err != nil { if err := os.WriteFile(path, []byte(newData), 0644); err != nil {
log.Printf("Unable to write file %s: %v", path, err) log.Printf("Unable to write file %s: %v", path, err)
} else { continue
}
log.Printf("Updated file: %s", path) log.Printf("Updated file: %s", path)
} }
} return nil
} }
// updateConfigFile обновляет исходный конфигурационный файл // updateConfigFile обновляет исходный конфигурационный файл
@@ -143,53 +158,58 @@ func updateConfigFile(configPath string, newVersion string) error {
// resolveFlag проверяет два логических флага: positive и negative. // resolveFlag проверяет два логических флага: positive и negative.
// Если оба заданы как true, вызывается ошибка; если задан negative, возвращается false; // Если оба заданы как true, вызывается ошибка; если задан negative, возвращается false;
// если задан positive, возвращается true; иначе возвращается defaultValue. // если задан positive, возвращается true; иначе возвращается defaultValue.
func resolveFlag(positive, negative *bool, defaultValue bool) bool { func resolveFlag(positive, negative *bool, defaultValue bool) (bool, error) {
if *positive && *negative { if *positive && *negative {
// Если оба флага заданы, это противоречивое состояние. return false, fmt.Errorf("conflicting flags: both positive and negative are set")
// Здесь можно завершить программу с ошибкой или выбрать приоритет.
// Например, завершим выполнение:
panic("conflicting flags: both positive and negative are set")
} }
if *negative { if *negative {
return false return false, nil
} }
if *positive { if *positive {
return true return true, nil
} }
return defaultValue return defaultValue, nil
} }
// Версия приложения // Версия приложения
const ( const AppName = "BumpVersion"
AppName = "BumpVersion"
AppVersion = "0.1.1" var (
AppVersion = "1.0.0"
AppCommit = "unknown"
) )
func versionString() string {
if AppCommit == "" || AppCommit == "unknown" {
return AppVersion
}
return fmt.Sprintf("%s (%s)", AppVersion, AppCommit)
}
func main() { func main() {
const cfg_name = ".bumpversion.cfg" const cfg_name = ".bumpversion.cfg"
args := os.Args[1:] args := os.Args[1:]
// Проверяем аргументы командной строки // Проверяем аргументы командной строки
if len(args) > 0 && strings.ToLower(args[0]) == "--version" { if len(args) > 0 && strings.ToLower(args[0]) == "--version" {
fmt.Printf("%s version %s\n", AppName, AppVersion) fmt.Printf("%s version %s\n", AppName, versionString())
return return
} }
// Печатаем название и версию при старте // Печатаем название и версию при старте
fmt.Printf("Starting %s version %s\n", AppName, AppVersion) fmt.Printf("Starting %s version %s\n", AppName, versionString())
bc, err := getBumpConfig(cfg_name) bc, err := getBumpConfig(cfg_name)
if err != nil { if err != nil {
log.Fatalf("Error reading bumpversion configuration: %v", err) log.Fatalf("Error reading bumpversion configuration: %v", err)
} }
fmt.Printf("Current version: %s\n", bc.CurrentVersion)
// Парсинг аргументов командной строки // Парсинг аргументов командной строки
part := flag.String("part", "patch", "Part of the version to bump (major/minor/patch)") part := flag.String("part", "patch", "Part of the version to bump (major/minor/patch)")
commit := flag.Bool("commit", false, "Create a commit") commit := flag.Bool("commit", false, "Create a commit")
noCommit := flag.Bool("no-commit", false, "Do not create a commit") noCommit := flag.Bool("no-commit", false, "Do not create a commit")
tag := flag.Bool("tag", false, "Add a git tag") tag := flag.Bool("tag", false, "Add a git tag")
noTag := flag.Bool("no-tag", false, "Do not add a git tag") noTag := flag.Bool("no-tag", false, "Do not add a git tag")
noFatal := flag.Bool("no-fatal", false, "Do not fail if a configured file does not contain the current version")
push := flag.Bool("push", false, "Force push to repository") push := flag.Bool("push", false, "Force push to repository")
flag.Parse() flag.Parse()
@@ -200,21 +220,37 @@ func main() {
} }
// Разрешаем флаги: // Разрешаем флаги:
shouldCommit := resolveFlag(commit, noCommit, bc.Commit) shouldCommit, err := resolveFlag(commit, noCommit, bc.Commit)
shouldTag := resolveFlag(tag, noTag, bc.Tag) if err != nil {
log.Fatalf("Error resolving commit flags: %v", err)
}
shouldTag, err := resolveFlag(tag, noTag, bc.Tag)
if err != nil {
log.Fatalf("Error resolving tag flags: %v", err)
}
newVersion, err := bumpVersion(bc, *part) newVersion, err := bumpVersion(bc, *part)
if err != nil { if err != nil {
log.Fatalf("Error bumping version: %v", err) log.Fatalf("Error bumping version: %v", err)
} }
fmt.Printf("Current version: %s\n", bc.CurrentVersion)
fmt.Printf("New version: %s\n", newVersion) fmt.Printf("New version: %s\n", newVersion)
// Обновляем файлы, указанные в конфигурации // Обновляем файлы, указанные в конфигурации
updateFiles(bc.FilePaths, bc.CurrentVersion, newVersion) if err := updateFiles(bc.FilePaths, bc.CurrentVersion, newVersion, !*noFatal); err != nil {
log.Fatalf("Error updating files: %v", err)
}
// Обновляем конфигурационный файл
if err := updateConfigFile(cfg_name, newVersion); err != nil {
log.Printf("Error updating config file: %v", err)
} else {
log.Printf("Config file %s updated to version %s", cfg_name, newVersion)
}
// Выполняем git commit и tag, если требуется // Выполняем git commit и tag, если требуется
if shouldCommit { if shouldCommit {
gitCommit(bc, newVersion) gitCommit(bc, newVersion, cfg_name)
} }
// Выполняем git commit и tag, если требуется // Выполняем git commit и tag, если требуется
@@ -225,11 +261,4 @@ func main() {
if *push { if *push {
gitPush(bc, newVersion) gitPush(bc, newVersion)
} }
// Обновляем конфигурационный файл
if err := updateConfigFile(cfg_name, newVersion); err != nil {
log.Printf("Error updating config file: %v", err)
} else {
log.Printf("Config file %s updated to version %s", cfg_name, newVersion)
}
} }

292
src/main_test.go Normal file
View File

@@ -0,0 +1,292 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
)
func TestGetBumpConfig(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, ".bumpversion.cfg")
cfg := `
[bumpversion]
current_version = 1.2.3
commit = true
tag = true
tag_name = v{new_version}
parse = ^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$
serialize = {major}.{minor}.{patch}
message = Release {new_version}
[bumpversion:file:VERSION]
[bumpversion:file:README.md]
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := getBumpConfig(cfgPath)
if err != nil {
t.Fatalf("getBumpConfig returned error: %v", err)
}
if got.CurrentVersion != "1.2.3" {
t.Fatalf("CurrentVersion = %q, want 1.2.3", got.CurrentVersion)
}
if !got.Commit || !got.Tag {
t.Fatalf("expected commit and tag to be true, got commit=%v tag=%v", got.Commit, got.Tag)
}
if got.TagName != "v{new_version}" {
t.Fatalf("TagName = %q, want v{new_version}", got.TagName)
}
if strings.TrimSpace(got.Serialize) != "{major}.{minor}.{patch}" {
t.Fatalf("Serialize = %q, want {major}.{minor}.{patch}", got.Serialize)
}
if got.Parse != `^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$` {
t.Fatalf("Parse = %q, want regex string", got.Parse)
}
wantPaths := map[string]bool{
"VERSION": true,
"README.md": true,
}
for _, p := range got.FilePaths {
delete(wantPaths, p)
}
if len(wantPaths) != 0 {
t.Fatalf("FilePaths missing entries: %v", reflect.ValueOf(wantPaths).MapKeys())
}
}
func TestUpdateConfigFile(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, ".bumpversion.cfg")
cfg := `
[bumpversion]
current_version = 0.0.1
commit = false
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
if err := updateConfigFile(cfgPath, "0.0.2"); err != nil {
t.Fatalf("updateConfigFile returned error: %v", err)
}
updated, err := getBumpConfig(cfgPath)
if err != nil {
t.Fatalf("getBumpConfig returned error: %v", err)
}
if updated.CurrentVersion != "0.0.2" {
t.Fatalf("CurrentVersion = %q, want 0.0.2", updated.CurrentVersion)
}
}
func TestBumpVersion(t *testing.T) {
bc := &BumpConfig{
CurrentVersion: "1.2.3",
Parse: `^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$`,
Serialize: "{major}.{minor}.{patch}",
}
tests := []struct {
name string
part string
want string
wantErr bool
}{
{name: "major", part: "major", want: "2.0.0"},
{name: "minor", part: "minor", want: "1.3.0"},
{name: "patch", part: "patch", want: "1.2.4"},
{name: "unknown", part: "build", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := bumpVersion(bc, tt.part)
if (err != nil) != tt.wantErr {
t.Fatalf("bumpVersion error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && got != tt.want {
t.Fatalf("bumpVersion = %q, want %q", got, tt.want)
}
})
}
}
func TestBumpVersionInvalidCurrent(t *testing.T) {
bc := &BumpConfig{
CurrentVersion: "1.2",
Parse: `^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$`,
Serialize: "{major}.{minor}.{patch}",
}
if _, err := bumpVersion(bc, "patch"); err == nil {
t.Fatalf("expected error for invalid current_version")
}
}
func TestBumpVersionMissingGroups(t *testing.T) {
bc := &BumpConfig{
CurrentVersion: "1.2.3",
Parse: `^(?P<major>\d+)\.(?P<minor>\d+)$`,
Serialize: "{major}.{minor}.{patch}",
}
if _, err := bumpVersion(bc, "patch"); err == nil {
t.Fatalf("expected error when parse pattern misses patch group")
}
}
func TestGetBumpConfigMissingFile(t *testing.T) {
tmpDir := t.TempDir()
if _, err := getBumpConfig(filepath.Join(tmpDir, "missing.cfg")); err == nil {
t.Fatalf("expected error for missing config file")
}
}
func TestSignatureFromEnvDefaults(t *testing.T) {
prevUser := os.Getenv("GIT_USERNAME")
prevEmail := os.Getenv("GIT_EMAIL")
t.Cleanup(func() {
_ = os.Setenv("GIT_USERNAME", prevUser)
_ = os.Setenv("GIT_EMAIL", prevEmail)
})
_ = os.Unsetenv("GIT_USERNAME")
_ = os.Unsetenv("GIT_EMAIL")
sig := signatureFromEnv()
if sig.Name != "bumpversion" || sig.Email != "bumpversion@deploy" {
t.Fatalf("signatureFromEnv defaults = %s %s, want bumpversion bumpversion@deploy", sig.Name, sig.Email)
}
}
func TestPushRefSpec(t *testing.T) {
head := plumbing.NewHashReference(plumbing.NewBranchReferenceName("main"), plumbing.ZeroHash)
spec, err := pushRefSpec(head)
if err != nil {
t.Fatalf("pushRefSpec returned error: %v", err)
}
want := config.RefSpec("refs/heads/main:refs/heads/main")
if spec != want {
t.Fatalf("pushRefSpec = %q, want %q", spec, want)
}
_, err = pushRefSpec(plumbing.NewHashReference(plumbing.HEAD, plumbing.ZeroHash))
if err == nil {
t.Fatalf("expected error for non-branch HEAD")
}
}
func TestUpdateFiles(t *testing.T) {
tmpDir := t.TempDir()
oldV := "1.2.3"
newV := "1.2.4"
filePaths := []string{
filepath.Join(tmpDir, "VERSION"),
filepath.Join(tmpDir, "README.md"),
}
for _, p := range filePaths {
contents := "project version " + oldV + "\n"
if err := os.WriteFile(p, []byte(contents), 0o644); err != nil {
t.Fatalf("write %s: %v", p, err)
}
}
if err := updateFiles(filePaths, oldV, newV, true); err != nil {
t.Fatalf("updateFiles returned error: %v", err)
}
for _, p := range filePaths {
data, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
if !strings.Contains(string(data), newV) || strings.Contains(string(data), oldV) {
t.Fatalf("%s not updated correctly: %s", p, string(data))
}
}
}
func TestUpdateFilesMissingVersionFatal(t *testing.T) {
tmpDir := t.TempDir()
oldV := "1.2.3"
newV := "1.2.4"
target := filepath.Join(tmpDir, "README.md")
if err := os.WriteFile(target, []byte("no version here\n"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := updateFiles([]string{target}, oldV, newV, true); err == nil {
t.Fatalf("expected error when version is missing")
}
if err := updateFiles([]string{target}, oldV, newV, false); err != nil {
t.Fatalf("did not expect error when no-fatal is set: %v", err)
}
}
func TestResolveFlag(t *testing.T) {
boolPtr := func(b bool) *bool { return &b }
tests := []struct {
name string
positive *bool
negative *bool
defaultValue bool
want bool
wantErr bool
}{
{
name: "positive wins",
positive: boolPtr(true),
negative: boolPtr(false),
defaultValue: false,
want: true,
},
{
name: "negative wins",
positive: boolPtr(false),
negative: boolPtr(true),
defaultValue: true,
want: false,
},
{
name: "default used",
positive: boolPtr(false),
negative: boolPtr(false),
defaultValue: true,
want: true,
},
{
name: "error on conflict",
positive: boolPtr(true),
negative: boolPtr(true),
defaultValue: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveFlag(tt.positive, tt.negative, tt.defaultValue)
if (err != nil) != tt.wantErr {
t.Fatalf("resolveFlag error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
if got != tt.want {
t.Fatalf("resolveFlag = %v, want %v", got, tt.want)
}
})
}
}