feat(releaser): initial release v0.4.0
ci / vet, staticcheck, test, build (push) Failing after 3m26s
release / Build and publish release (push) Successful in 4m28s

Complete GitFlow release automation tool for Conventional Commits workflows:

- Core pipeline: branch parsing, tag discovery, commit analysis, version bump
- Maven pom.xml read/write, git commit/tag, HTTPS push with token auth
- GitLab release creation via API with auto-generated release notes
- Configurable via .releaser.yml (tag_prefix, branch_pattern, commit_message, pom_path, gitlab)
- CLI flags: --dry-run, --no-push, --no-commit, --tag-only, --branch, --pom, --tag-prefix, --branch-pattern
- Dockerfile (multi-stage Alpine), .releaser.gitlab-ci.yml reusable template
- Gitea CI (vet + staticcheck + test + build) and release (5-platform cross-compilation) workflows
- Taskfile with build/test/cov/lint/fuzz/ci/docker tasks
- 96% test coverage with real in-memory git repos and fuzz tests for all parsers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:07:53 +02:00
commit f0723e706a
30 changed files with 3959 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
name: ci
on:
push:
branches:
- '**'
pull_request:
jobs:
ci:
name: vet, staticcheck, test, build
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install tools
run: apk add --no-cache git
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
run: |
git clone --depth 1 --branch "$REF" \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
- name: vet
run: go vet ./...
- name: staticcheck
run: go tool staticcheck ./...
- name: test
run: go test ./...
- name: build
run: go build ./cmd/...
+110
View File
@@ -0,0 +1,110 @@
name: release
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and publish release
runs-on: ubuntu-latest
container:
image: golang:1.26-alpine
steps:
- name: Install tools
run: apk add --no-cache curl git jq
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
REF: ${{ github.ref_name }}
run: |
git clone --depth 1 --branch "$REF" \
"$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" .
- name: Build Linux (amd64)
env:
GOOS: linux
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o releaser-${{ github.ref_name }}-linux-amd64 \
./cmd/...
- name: Build Linux (arm64)
env:
GOOS: linux
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o releaser-${{ github.ref_name }}-linux-arm64 \
./cmd/...
- name: Build macOS (amd64)
env:
GOOS: darwin
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o releaser-${{ github.ref_name }}-darwin-amd64 \
./cmd/...
- name: Build macOS (arm64)
env:
GOOS: darwin
GOARCH: arm64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o releaser-${{ github.ref_name }}-darwin-arm64 \
./cmd/...
- name: Build Windows (amd64)
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: "0"
run: |
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o releaser-${{ github.ref_name }}-windows-amd64.exe \
./cmd/...
- name: Create release and upload assets
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
API_URL: ${{ github.api_url }}
REPO: ${{ github.repository }}
run: |
release_id=$(curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
"$API_URL/repos/$REPO/releases" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"draft\":false,\"prerelease\":false}" \
| jq -r .id)
for file in \
releaser-${TAG}-linux-amd64 \
releaser-${TAG}-linux-arm64 \
releaser-${TAG}-darwin-amd64 \
releaser-${TAG}-darwin-arm64 \
releaser-${TAG}-windows-amd64.exe; do
curl -sf -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/octet-stream" \
"$API_URL/repos/$REPO/releases/$release_id/assets?name=$file" \
--data-binary "@$file"
echo "uploaded: $file"
done
+8
View File
@@ -0,0 +1,8 @@
# build output
/bin/
releaser-*
# test coverage
coverage.out
coverage.html
+49
View File
@@ -0,0 +1,49 @@
# releaser — GitLab CI component template
#
# Usage in your project's .gitlab-ci.yml:
#
# include:
# - project: 'your-group/releaser'
# ref: main
# file: '.releaser.gitlab-ci.yml'
#
# stages:
# - build
# - test
# - release
#
# release:
# extends: .releaser:release
#
# Required CI variable (set at project or group level):
# RELEASE_TOKEN — a GitLab project/group access token with scopes:
# api + write_repository
# (do NOT use CI_JOB_TOKEN — it cannot push tags back)
.releaser:release:
stage: release
image: ${RELEASER_IMAGE:-registry.example.com/releaser:latest}
variables:
# Override in your job to change defaults
RELEASER_EXTRA_ARGS: "" # e.g. "--no-push" or "--tag-prefix ''"
GIT_DEPTH: 0 # full clone — required to find ancestor tags
rules:
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
before_script:
# Configure git to push back using the release token over HTTPS.
# CI_JOB_TOKEN cannot push tags, so a dedicated access token is required.
- git remote set-url origin
"https://oauth2:${RELEASE_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git"
- git config user.email "${GITLAB_USER_EMAIL:-ci@releaser}"
- git config user.name "${GITLAB_USER_NAME:-Releaser CI}"
script:
- releaser
--branch "$CI_COMMIT_BRANCH"
$RELEASER_EXTRA_ARGS
environment:
name: release/$CI_COMMIT_BRANCH
+37
View File
@@ -0,0 +1,37 @@
# .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:
# Prefix prepended to every version tag.
# 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: ""
maven:
# Path to pom.xml, relative to the repository root.
# pom_path: "pom.xml"
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: ""
+37
View File
@@ -0,0 +1,37 @@
# Changelog
All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.4.0] - 2026-07-07
### Added
- **Branch parser** — extracts `major.minor` from `release/X.Y` branch names using a configurable regex pattern
- **Git tag discovery** — finds the latest `vX.Y.*` tag that is a reachable ancestor of HEAD (ignores tags on other version lines or unrelated branches)
- **Conventional Commits parser** — non-strict mode: case-insensitive type, optional scope, flexible whitespace; unrecognised messages are silently skipped
- **Commit range walker** — scans from last matching tag to HEAD; falls back to full history on first release in a branch
- **Version calculator** — increments patch; returns nothing when there are no releasable commits (exit code 2 so CI can distinguish "nothing to do" from errors)
- **`--dry-run` flag** — prints next version without making any changes
- **`--branch` flag** — overrides automatic branch detection for detached-HEAD CI environments
- **`--no-push` flag** — commits and tags locally without pushing or creating a GitLab release
- **`--no-commit` flag** — updates `pom.xml` and stops; re-run with `--tag-only` after manual review
- **`--tag-only` flag** — tags HEAD without touching `pom.xml` (pair with `--no-commit` flow)
- **Dirty working-tree check** — aborts before any changes if tracked files are modified or staged
- **`.releaser.yml` config** — optional YAML loaded from repo root; missing file is not an error; all fields have sensible defaults
- **Configurable tag prefix** — `git.tag_prefix` in config or `--tag-prefix` flag (default: `v`)
- **Configurable branch pattern** — `git.branch_pattern` in config or `--branch-pattern` flag (default: `^(?:.*/)?release/(\d+)\.(\d+)$`)
- **Configurable commit message** — `git.commit_message` with `{version}` placeholder (default: `chore(release): {version} [skip ci]`)
- **Configurable commit author** — `git.author_name` / `git.author_email`; falls back to repo git config
- **Configurable `pom_path`** — `maven.pom_path` in config or `--pom` flag (default: `pom.xml`)
- **`pom.xml` reader / writer** — in-place version update; correctly ignores `<parent>` and dependency version elements
- **GitLab release creation** — minimal HTTP client (no SDK); auto-generates release notes grouped by Breaking Changes / Features / Bug Fixes
- **HTTPS push with token auth** — `oauth2` + `GITLAB_TOKEN`
- **`GITLAB_TOKEN`, `CI_SERVER_URL`, `CI_PROJECT_ID`, `CI_PROJECT_PATH`** env var support for GitLab CI environments
- **`Dockerfile`** — multi-stage build, Alpine final image with `ca-certificates`
- **`.releaser.gitlab-ci.yml`** — reusable GitLab CI job template (`extends: .releaser:release`) with `GIT_DEPTH: 0` and HTTPS push pre-wired
- **`.releaser.yml` template** — annotated example of every config option with its default value
- **Gitea CI workflow** — runs `vet`, `staticcheck`, `test`, `build` on every push
- **Gitea release workflow** — cross-compiles five platform binaries (linux/amd64+arm64, darwin/amd64+arm64, windows/amd64) and publishes them as release assets on tag push
- **Taskfile** — `build`, `run`, `test`, `cov`, `cov:text`, `lint`, `fmt`, `tidy`, `fuzz`, `fuzz:all`, `ci`, `clean`, `docker:build`, `docker:run`
- **96% test coverage** — integration tests use real in-memory git repositories; fuzz tests cover all parser entry-points
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.26-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /releaser ./cmd
# ---
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /releaser /usr/local/bin/releaser
ENTRYPOINT ["releaser"]
+112
View File
@@ -0,0 +1,112 @@
# releaser
![release](https://img.shields.io/badge/release-v0.4.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
## Problem
Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration.
`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab tag+release creation.
## How it works
```
release/1.2 branch
└─ last tag: v1.2.3 (or none → start at v1.2.0)
└─ commits since tag → Conventional Commits analysis
└─ next version: v1.2.4
```
1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2``1.2`)
2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (the minor is owned by the branch)
5. **Release** — updates `pom.xml`, commits, tags, creates GitLab release
## Version bump rules
| Commit type | Bump | Notes |
|------------------|---------|--------------------------------------------|
| `fix:` | patch | |
| `feat:` | patch | minor is pinned to branch |
| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary |
| `chore:`, `docs:`, etc. | none | |
| unparseable msg | none | non-strict mode: silently ignored |
## Usage
```bash
# Simulate next version (no side effects)
releaser --dry-run
# Full release: bump pom.xml, commit, tag, push, GitLab release
releaser
# Commit and tag locally — skip push and GitLab release
releaser --no-push
# Update pom.xml but stop before committing (review first)
releaser --no-commit
# … then commit manually and re-run:
releaser --tag-only
# Explicitly target a branch (useful in detached HEAD CI)
releaser --branch release/1.2
# Target a specific pom.xml
releaser --pom path/to/pom.xml
# Override tag prefix from CLI (empty = no prefix)
releaser --tag-prefix ""
# Override branch pattern (e.g. also match hotfix/ branches)
releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
```
## Configuration
`releaser` reads `.releaser.yml` from the repository root. All fields are optional — missing values fall back to the defaults shown below.
```yaml
git:
tag_prefix: "v" # set to "" for tags without prefix
branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor
commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name
author_email: "" # defaults to git config user.email
maven:
pom_path: "pom.xml" # relative to repo root
gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # env GITLAB_TOKEN (never commit this)
project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH
```
### Environment variables
GitLab-related fields are automatically read from the CI environment if not set in the config file:
| Variable | Used for |
|-------------------|-----------------------------------|
| `GITLAB_TOKEN` | API auth + HTTPS push auth |
| `CI_SERVER_URL` | GitLab instance URL |
| `CI_PROJECT_ID` | Project identifier (numeric) |
| `CI_PROJECT_PATH` | Project identifier (fallback) |
## CI integration (GitLab CI example)
```yaml
release:
stage: release
image: registry.example.com/releaser:latest
rules:
- if: $CI_COMMIT_BRANCH =~ /^release\/.+$/
variables:
GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope
script:
- releaser
```
+76
View File
@@ -0,0 +1,76 @@
# Roadmap
## v0.1 — Core logic (MVP) ✅
- [x] Branch name parser (`release/X.Y` → major, minor)
- [x] Git tag discovery (latest `vX.Y.*` tag reachable from HEAD — ancestor check, not global latest)
- [x] Conventional Commits parser (non-strict mode: case-insensitive, optional scope, flexible whitespace)
- [x] Commit range walker (last tag → HEAD, or full history on first release)
- [x] Version bump calculator — returns plain `X.Y.Z` (prefix kept separate)
- [x] `--dry-run` flag: print next version and exit
- [x] `--branch` flag: override branch detection (detached HEAD in CI)
- [x] Exit code 2 when no releasable commits
## v0.2 — Config + Maven + local git ops ✅
- [x] `.releaser.yml` config file (loaded from repo root, merged over defaults, missing file is not an error)
- [x] Configurable tag prefix (`tag_prefix: "v"` or `tag_prefix: ""` for no prefix)
- [x] Configurable commit message template (`{version}` placeholder)
- [x] Configurable `pom_path` (default `pom.xml`)
- [x] `--pom` CLI flag: override pom path
- [x] `--tag-prefix` CLI flag: override tag prefix
- [x] `pom.xml` version reader (ignores `<parent>` and dependency versions)
- [x] `pom.xml` version writer (in-place, handles `-SNAPSHOT` → release)
- [x] Git commit of `pom.xml` changes (author resolved from git config, overridable in `.releaser.yml`)
- [x] Git tag creation (lightweight, on the release commit)
## v0.3 — GitLab integration ✅
- [x] GitLab release creation via API (minimal HTTP client, no external dependency)
- [x] Release notes body auto-generated from commit log (grouped by Breaking Changes / Features / Bug Fixes)
- [x] `GITLAB_TOKEN` env var support
- [x] `CI_SERVER_URL` / `CI_PROJECT_ID` / `CI_PROJECT_PATH` env var support (GitLab CI native)
- [x] `--no-push` flag: commit+tag locally without pushing or creating GitLab release
- [x] Git push via HTTPS token auth (`oauth2` + `GITLAB_TOKEN`)
- [x] GitLab section in `.releaser.yml`:
```yaml
gitlab:
url: "https://gitlab.example.com"
token: "" # prefer env GITLAB_TOKEN
project: "" # prefer env CI_PROJECT_ID
```
## v0.4 — Hardening & CI template ✅
- [x] Configurable branch pattern (`git.branch_pattern` in `.releaser.yml`, default `release/X.Y`, supports regex e.g. `(?:release|hotfix)/X.Y`)
- [x] `--branch-pattern` CLI flag to override
- [x] Dirty working tree check — aborts if tracked files are modified or staged (untracked files ignored)
- [x] `--no-commit` flag: update pom.xml then stop — commit manually and re-run with `--tag-only`
- [x] `--tag-only` flag: skip pom.xml update, tag HEAD and push
- [x] `Dockerfile` — multi-stage build, alpine final image with `ca-certificates`
- [x] `.releaser.gitlab-ci.yml` — reusable CI job template with `GIT_DEPTH: 0` and HTTPS push setup
- [x] `.releaser.yml` template — annotated example of every config option with defaults
- [x] Taskfile — `build`, `run`, `test`, `cov`, `cov:text`, `lint`, `fmt`, `tidy`, `fuzz`, `fuzz:all`, `ci`, `clean`, `docker:build`, `docker:run`
- [x] Gitea CI workflow (vet, staticcheck, test, build)
- [x] Gitea release workflow (5-platform cross-compilation, release asset upload)
- [x] 96% test coverage with real in-memory git repos and fuzz tests for all parsers
## v0.5 — Changelog
- [ ] `CHANGELOG.md` generation / append (grouped by commit type)
- [ ] `--changelog-file` flag
## v1.0 — Production ready
- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos)
- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64)
- [ ] Documentation site
## Future / backlog
- GitHub release support (parity with GitLab)
- Multi-module Maven support (multiple `pom.xml` paths)
- Gradle support (`build.gradle` / `build.gradle.kts`)
- `package.json` version bump support (Node.js projects)
- Slack / Teams notification on release
- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch)
+107
View File
@@ -0,0 +1,107 @@
version: "3"
vars:
BIN: ./bin/releaser
PKG: ./...
FUZZ_TIME: 30s
tasks:
default:
desc: List available tasks
silent: true
cmds:
- task --list
build:
desc: Build the binary to ./bin/releaser
cmds:
- mkdir -p bin
- CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{.BIN}} ./cmd
sources:
- "**/*.go"
- go.mod
generates:
- "{{.BIN}}"
run:
desc: Build and run releaser (pass args with -- e.g. task run -- --dry-run)
deps: [build]
cmds:
- "{{.BIN}} {{.CLI_ARGS}}"
test:
desc: Run all tests (seed corpus only, no fuzzing)
cmds:
- go test {{.PKG}}
cov:
desc: Run tests with coverage and open HTML report
cmds:
- go test -coverprofile=coverage.out {{.PKG}}
- go tool cover -html=coverage.out -o coverage.html
- echo "Coverage report written to coverage.html"
cov:text:
desc: Run tests with per-function coverage in terminal
cmds:
- go test -coverprofile=coverage.out {{.PKG}}
- go tool cover -func=coverage.out
lint:
desc: Run go vet
cmds:
- go vet {{.PKG}}
fmt:
desc: Format all Go source files
cmds:
- gofmt -w .
tidy:
desc: Tidy go.mod and go.sum
cmds:
- go mod tidy
fuzz:
desc: "Fuzz a target for FUZZ_TIME (default 30s). Usage: task fuzz PKG=./internal/commits TARGET=FuzzParse"
vars:
PKG: '{{.PKG | default "./internal/commits"}}'
TARGET: '{{.TARGET | default "FuzzParse"}}'
cmds:
- go test -fuzz={{.TARGET}} -fuzztime={{.FUZZ_TIME}} {{.PKG}}
fuzz:all:
desc: Run all fuzz seed corpora (same as test but explicit)
cmds:
- go test -run='^Fuzz' {{.PKG}}
ci:
desc: Full CI pipeline — tidy check, vet, test
cmds:
- task: tidy
- |
if git rev-parse --git-dir >/dev/null 2>&1 && ! git diff --quiet go.mod go.sum; then
echo "go.mod/go.sum not tidy — run: task tidy" >&2
exit 1
fi
- task: lint
- task: test
clean:
desc: Remove build artifacts and coverage files
cmds:
- rm -rf bin/ coverage.out coverage.html
docker:build:
desc: Build the Docker image
vars:
TAG: '{{.TAG | default "releaser:dev"}}'
cmds:
- docker build -t {{.TAG}} .
docker:run:
desc: Run the Docker image (pass args with -- )
vars:
TAG: '{{.TAG | default "releaser:dev"}}'
cmds:
- docker run --rm {{.TAG}} {{.CLI_ARGS}}
+278
View File
@@ -0,0 +1,278 @@
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"
"git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version"
)
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
func newRootCmd() *cobra.Command {
var (
dryRun bool
noPush bool
noCommit bool
tagOnly bool
branchOverride string
repoPath string
pomOverride string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
)
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{
repoPath: repoPath,
branchOverride: branchOverride,
pomOverride: pomOverride,
tagPrefixFlag: tagPrefixFlag,
tagPrefixSet: tagPrefixSet,
patternFlag: patternFlag,
patternSet: patternSet,
dryRun: dryRun,
noPush: noPush,
noCommit: noCommit,
tagOnly: tagOnly,
})
},
}
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release")
root.Flags().BoolVar(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push")
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)")
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")
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
return root
}
func main() {
if err := newRootCmd().Execute(); err != nil {
if errors.Is(err, errNothingToRelease) {
exitFn(2)
return
}
exitFn(1)
}
}
type options struct {
repoPath string
branchOverride string
pomOverride string
tagPrefixFlag string
tagPrefixSet bool
patternFlag string
patternSet bool
dryRun bool
noPush bool
noCommit bool
tagOnly bool
}
func run(o options) error {
// --- Config ---
absRepo, err := filepath.Abs(o.repoPath)
if err != nil {
return fmt.Errorf("resolve repo path: %w", err)
}
cfg, err := config.Load(absRepo)
if err != nil {
return err
}
cfg.ApplyEnv()
// CLI flags take precedence over config file and env vars
if o.tagPrefixSet {
cfg.Git.TagPrefix = o.tagPrefixFlag
}
if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride
}
if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag
}
// --- 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
// --- 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 == "" {
fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits")
messages, err = gitutil.AllCommits(repo)
} else {
fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag)
messages, err = gitutil.CommitsSince(repo, lastTag)
}
if err != nil {
return fmt.Errorf("read commits: %w", err)
}
fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages))
// --- Version calculation ---
types := make([]commits.Type, len(messages))
for i, msg := range messages {
types[i] = commits.Parse(msg)
}
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
if !ok {
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
return errNothingToRelease
}
nextTag := info.TagName(nextVersion)
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
if o.dryRun {
fmt.Fprintln(os.Stderr, "dry-run: no changes made")
return nil
}
// --- pom.xml (skipped with --tag-only) ---
if !o.tagOnly {
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
currentPomVersion, err := maven.ReadVersion(pomPath)
if err != nil {
return fmt.Errorf("read pom version: %w", err)
}
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion)
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
}
if o.noCommit {
fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
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)
if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil {
return fmt.Errorf("commit pom.xml: %w", err)
}
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg)
}
// --- Git tag ---
if err := gitutil.CreateTag(repo, nextTag); err != nil {
return fmt.Errorf("create tag: %w", err)
}
fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag)
if o.noPush {
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
return nil
}
// --- Push ---
fmt.Fprintln(os.Stderr, "info: pushing commit and tag...")
if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil {
return fmt.Errorf("push: %w", err)
}
fmt.Fprintln(os.Stderr, "info: pushed")
// --- GitLab release ---
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation")
fmt.Printf("released %s\n", nextTag)
return nil
}
if cfg.GitLab.Token == "" {
return fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
}
releaseNotes := notes.Generate(nextTag, messages)
gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project)
if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create GitLab release: %w", err)
}
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag)
fmt.Printf("released %s\n", nextTag)
return nil
}
+541
View File
@@ -0,0 +1,541 @@
package main
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
gogit "github.com/go-git/go-git/v5"
gitcfg "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"
)
// ── helpers ──────────────────────────────────────────────────────────────────
func testSig() *object.Signature {
return &object.Signature{Name: "CI", Email: "ci@example.com", When: time.Now()}
}
// setupRepo creates a temporary git repo with pom.xml committed.
func setupRepo(t *testing.T) (repo *gogit.Repository, dir string) {
t.Helper()
dir = t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
writePom(t, dir, "1.2.3")
commitAll(t, repo, dir, "chore: init")
return repo, dir
}
// setupRepoWithRemote adds a bare remote so Push can succeed.
func setupRepoWithRemote(t *testing.T) (repo *gogit.Repository, dir string) {
t.Helper()
repo, dir = setupRepo(t)
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
if _, err := repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
return repo, dir
}
func writePom(t *testing.T, dir, version string) {
t.Helper()
pom := `<?xml version="1.0"?>
<project>
<version>` + version + `</version>
</project>`
if err := os.WriteFile(filepath.Join(dir, "pom.xml"), []byte(pom), 0644); err != nil {
t.Fatal(err)
}
}
func commitAll(t *testing.T, repo *gogit.Repository, dir, msg string) plumbing.Hash {
t.Helper()
w, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
// stage everything
if _, err := w.Add("."); err != nil {
t.Fatal(err)
}
h, err := w.Commit(msg, &gogit.CommitOptions{Author: testSig()})
if err != nil {
t.Fatalf("commit %q: %v", msg, err)
}
return h
}
func addFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// ── newRootCmd integration tests ──────────────────────────────────────────────
func execCmd(t *testing.T, args ...string) error {
t.Helper()
cmd := newRootCmd()
cmd.SetArgs(args)
return cmd.Execute()
}
func TestRunDryRun(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("dry-run: unexpected error: %v", err)
}
}
func TestRunNothingToRelease(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "chore")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("chore: update deps", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if !errors.Is(err, errNothingToRelease) {
t.Fatalf("expected errNothingToRelease, got %v", err)
}
}
func TestRunNotAGitRepo(t *testing.T) {
dir := t.TempDir()
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for non-git directory")
}
}
func TestRunBadBranchName(t *testing.T) {
_, dir := setupRepo(t)
err := execCmd(t, "--dry-run", "--branch", "main", "--repo", dir)
if err == nil {
t.Fatal("expected error: 'main' doesn't match release pattern")
}
}
func TestRunDetachedHead(t *testing.T) {
repo, dir := setupRepo(t)
// Detach HEAD
head, _ := repo.Head()
repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, head.Hash()))
err := execCmd(t, "--dry-run", "--repo", dir) // no --branch
if err == nil {
t.Fatal("expected error for detached HEAD without --branch")
}
}
func TestRunBadConfig(t *testing.T) {
_, dir := setupRepo(t)
if err := os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("{[invalid"), 0644); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for invalid .releaser.yml")
}
}
func TestRunDirtyTree(t *testing.T) {
_, dir := setupRepo(t)
// Modify a tracked file without staging
os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("<project><version>dirty</version></project>"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error for dirty working tree")
}
}
func TestRunFlagOverrides(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "src.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("src.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
// --tag-prefix "" --branch-pattern with defaults via flags
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir,
"--tag-prefix", "rel-", "--branch-pattern", `^(?:.*/)?release/(\d+)\.(\d+)$`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunPomOverride(t *testing.T) {
_, dir := setupRepo(t)
// Write a second pom at a custom path
os.MkdirAll(filepath.Join(dir, "sub"), 0755)
writePom(t, filepath.Join(dir, "sub"), "1.2.3")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("sub/pom.xml")
w.Commit("chore: add sub pom", &gogit.CommitOptions{Author: testSig()})
addFile(t, dir, "x.go", "// fix")
w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir, "--pom", "sub/pom.xml")
if err != nil {
t.Fatalf("--pom override: unexpected error: %v", err)
}
}
func TestRunMissingPom(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
// Use --pom to point to a non-existent file; keeps the working tree clean.
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml")
if err == nil {
t.Fatal("expected error for missing pom.xml")
}
}
func TestRunPomNoVersion(t *testing.T) {
_, dir := setupRepo(t)
// Write a pom with no <version> tag as an untracked file (tree stays clean)
os.WriteFile(filepath.Join(dir, "no-version.xml"), []byte("<project></project>"), 0644)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "no-version.xml")
if err == nil {
t.Fatal("expected error when pom has no <version>")
}
}
func TestRunPomReadOnly(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Make pom.xml read-only: ReadVersion succeeds (readable), WriteVersion fails (not writable)
os.Chmod(filepath.Join(dir, "pom.xml"), 0444)
defer os.Chmod(filepath.Join(dir, "pom.xml"), 0644)
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when pom.xml is read-only")
}
}
func TestRunAuthorFromConfig(t *testing.T) {
_, dir := setupRepo(t)
// Write .releaser.yml as untracked so the working tree stays clean
cfg := "git:\n author_name: ReleaserBot\n author_email: bot@example.com\n"
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte(cfg), 0644)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("run with author_name/author_email config: %v", err)
}
}
func TestRunNoPush(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-push: unexpected error: %v", err)
}
// Verify pom.xml was updated
data, _ := os.ReadFile(filepath.Join(dir, "pom.xml"))
if string(data) == "" {
t.Error("pom.xml should have been updated")
}
}
func TestRunNoCommit(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-commit", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--no-commit: unexpected error: %v", err)
}
}
func TestRunTagOnly(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("--tag-only --no-push: unexpected error: %v", err)
}
}
func TestRunDuplicateTag(t *testing.T) {
_, dir := setupRepo(t)
repo, _ := gogit.PlainOpen(dir)
// Add a fix commit so there are releasable changes
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Pre-create a v1.2.0 ref pointing to a garbage hash.
// LatestTag skips it (resolveTagToCommit fails for garbage hash),
// so run() calculates "v1.2.0" as the first-ever version — then
// CreateTag("v1.2.0") fails because the ref already exists.
fakeRef := plumbing.NewHashReference(
plumbing.NewTagReferenceName("v1.2.0"),
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error: v1.2.0 ref already exists")
}
}
func TestRunPushFails(t *testing.T) {
_, dir := setupRepo(t)
repo, _ := gogit.PlainOpen(dir)
// Remote exists but points to a non-existent directory → push fails
repo.CreateRemote(&gitcfg.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/path/to/bare/repo"},
})
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected push error for invalid remote URL")
}
}
func TestRunGitLabError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Tag already has a release"}`))
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w2, _ := repo.Worktree()
w2.Add("x.go")
w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("CI_SERVER_URL", srv.URL)
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when GitLab API returns 422")
}
}
func TestRunWithPreviousTag(t *testing.T) {
repo, dir := setupRepo(t)
// Tag the initial commit as v1.2.0 (simulates a prior release)
initialHead, _ := repo.Head()
repo.CreateTag("v1.2.0", initialHead.Hash(), nil)
// Fix commit after the tag — run() will use CommitsSince, not AllCommits
addFile(t, dir, "x.go", "// fix")
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("run with previous tag: %v", err)
}
}
func TestRunSkipGitLab(t *testing.T) {
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// No GITLAB_TOKEN / URL set → GitLab release is skipped
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("expected skip-gitlab success, got: %v", err)
}
}
func TestRunMissingToken(t *testing.T) {
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err == nil {
t.Fatal("expected error when GITLAB_TOKEN is not set but GitLab URL is configured")
}
}
func TestRunWithGitLab(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
}))
defer srv.Close()
_, dir := setupRepoWithRemote(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w2, _ := repo.Worktree()
w2.Add("x.go")
w2.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
t.Setenv("CI_SERVER_URL", srv.URL)
t.Setenv("CI_PROJECT_ID", "42")
t.Setenv("GITLAB_TOKEN", "test-token")
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
if err != nil {
t.Fatalf("full release with mock GitLab: unexpected error: %v", err)
}
}
// ── main() tests via exitFn ───────────────────────────────────────────────────
func TestMainSuccess(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "// fix")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
old := os.Args
os.Args = []string{"releaser", "--dry-run", "--branch", "release/1.2", "--repo", dir}
defer func() { os.Args = old }()
called := false
oldFn := exitFn
exitFn = func(code int) { called = true }
defer func() { exitFn = oldFn }()
main()
if called {
t.Error("exitFn should not have been called on success")
}
}
func TestMainNothingToRelease(t *testing.T) {
_, dir := setupRepo(t)
addFile(t, dir, "x.go", "chore")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("chore: bump deps", &gogit.CommitOptions{Author: testSig()})
old := os.Args
os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", dir}
defer func() { os.Args = old }()
var gotCode int
oldFn := exitFn
exitFn = func(code int) { gotCode = code }
defer func() { exitFn = oldFn }()
main()
if gotCode != 2 {
t.Errorf("expected exit code 2 for nothing-to-release, got %d", gotCode)
}
}
func TestMainError(t *testing.T) {
old := os.Args
os.Args = []string{"releaser", "--branch", "release/1.2", "--repo", t.TempDir()} // not a git repo
defer func() { os.Args = old }()
var gotCode int
oldFn := exitFn
exitFn = func(code int) { gotCode = code }
defer func() { exitFn = oldFn }()
main()
if gotCode != 1 {
t.Errorf("expected exit code 1 for general error, got %d", gotCode)
}
}
+42
View File
@@ -0,0 +1,42 @@
module git.k3nny.fr/releaser
go 1.26.4
require (
github.com/go-git/go-git/v5 v5.19.1
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
honnef.co/go/tools v0.7.0 // indirect
)
tool honnef.co/go/tools/cmd/staticcheck
+127
View File
@@ -0,0 +1,127 @@
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U=
golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
+63
View File
@@ -0,0 +1,63 @@
package branch
import (
"fmt"
"regexp"
"strconv"
)
// DefaultBranchPattern matches "release/X.Y" or "origin/release/X.Y".
// Group 1 = major, group 2 = minor.
const DefaultBranchPattern = `^(?:.*/)?release/(\d+)\.(\d+)$`
// Info holds the major.minor version pinned by the branch name, plus the tag prefix from config.
type Info struct {
Major int
Minor int
TagPrefix string // set from config after Parse (default "v")
}
// Parse extracts major and minor from a branch name using the provided regex pattern.
// The pattern must contain exactly two capture groups: group 1 = major, group 2 = minor.
// TagPrefix is left at its zero value — the caller sets it from config.
func Parse(name, pattern string) (Info, error) {
re, err := regexp.Compile(pattern)
if err != nil {
return Info{}, fmt.Errorf("invalid branch pattern %q: %w", pattern, err)
}
m := re.FindStringSubmatch(name)
if len(m) < 3 {
return Info{}, fmt.Errorf("branch %q does not match pattern %q", name, pattern)
}
major, err := strconv.Atoi(m[1])
if err != nil {
return Info{}, fmt.Errorf("invalid major version in branch %q: %w", name, err)
}
minor, err := strconv.Atoi(m[2])
if err != nil {
return Info{}, fmt.Errorf("invalid minor version in branch %q: %w", name, err)
}
return Info{Major: major, Minor: minor}, nil
}
// MatchesTag checks whether a tag (e.g. "v1.2.3" or "1.2.3") belongs to this branch's version range.
// Uses TagPrefix to match the expected format. Returns the patch number and true on match.
func (i Info) MatchesTag(tag string) (patch int, ok bool) {
prefix := fmt.Sprintf("%s%d.%d.", i.TagPrefix, i.Major, i.Minor)
if len(tag) <= len(prefix) || tag[:len(prefix)] != prefix {
return 0, false
}
p, err := strconv.Atoi(tag[len(prefix):])
if err != nil {
return 0, false
}
return p, true
}
// TagName formats a version string (e.g. "1.2.4") into a full tag name (e.g. "v1.2.4" or "1.2.4").
func (i Info) TagName(version string) string {
return i.TagPrefix + version
}
+136
View File
@@ -0,0 +1,136 @@
package branch
import (
"strings"
"testing"
)
// FuzzParse verifies the parser never panics, and that successful parses produce
// non-negative major/minor values.
func FuzzParse(f *testing.F) {
seeds := []string{
"release/1.2",
"hotfix/10.3",
"origin/release/0.0",
"main",
"release/",
"release/1.2.3",
"",
"\x00",
"release/999999.999999",
strings.Repeat("release/", 100) + "1.2",
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, name string) {
info, err := Parse(name, DefaultBranchPattern)
if err != nil {
return // invalid input is expected — just must not panic
}
if info.Major < 0 || info.Minor < 0 {
t.Errorf("Parse(%q) returned negative major/minor: %+v", name, info)
}
})
}
func TestParse(t *testing.T) {
cases := []struct {
input string
pattern string
major int
minor int
err bool
}{
// default pattern
{"release/1.2", DefaultBranchPattern, 1, 2, false},
{"release/10.3", DefaultBranchPattern, 10, 3, false},
{"origin/release/1.2", DefaultBranchPattern, 1, 2, false},
{"refs/heads/release/1.2", DefaultBranchPattern, 1, 2, false},
{"main", DefaultBranchPattern, 0, 0, true},
{"release/1", DefaultBranchPattern, 0, 0, true},
{"release/1.2.3", DefaultBranchPattern, 0, 0, true},
{"feature/1.2", DefaultBranchPattern, 0, 0, true},
// custom pattern — hotfix + release
{"hotfix/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
{"release/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 2, 3, false},
{"feature/2.3", `^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$`, 0, 0, true},
// invalid regex
{"release/1.2", `(unclosed`, 0, 0, true},
// valid regex but capture groups match non-digits → Atoi error
{"release/abc.1", `^release/(\w+)\.(\d+)$`, 0, 0, true}, // major non-numeric
{"release/1.abc", `^release/(\d+)\.(\w+)$`, 0, 0, true}, // minor non-numeric
}
for _, c := range cases {
got, err := Parse(c.input, c.pattern)
if c.err {
if err == nil {
t.Errorf("Parse(%q, %q): expected error, got none", c.input, c.pattern)
}
continue
}
if err != nil {
t.Errorf("Parse(%q, %q): unexpected error: %v", c.input, c.pattern, err)
continue
}
if got.Major != c.major || got.Minor != c.minor {
t.Errorf("Parse(%q) = {%d, %d}, want {%d, %d}", c.input, got.Major, got.Minor, c.major, c.minor)
}
}
}
func TestMatchesTag(t *testing.T) {
cases := []struct {
prefix string
tag string
patch int
ok bool
}{
{"v", "v1.2.0", 0, true},
{"v", "v1.2.5", 5, true},
{"v", "v1.2.99", 99, true},
{"v", "v1.3.0", 0, false},
{"v", "v2.2.0", 0, false},
{"v", "1.2.0", 0, false},
{"v", "v1.2.", 0, false},
{"v", "v1.2.x", 0, false},
{"", "1.2.0", 0, true},
{"", "1.2.7", 7, true},
{"", "v1.2.0", 0, false},
}
for _, c := range cases {
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
patch, ok := info.MatchesTag(c.tag)
if ok != c.ok {
t.Errorf("[prefix=%q] MatchesTag(%q): ok=%v, want %v", c.prefix, c.tag, ok, c.ok)
continue
}
if ok && patch != c.patch {
t.Errorf("[prefix=%q] MatchesTag(%q): patch=%d, want %d", c.prefix, c.tag, patch, c.patch)
}
}
}
func TestTagName(t *testing.T) {
cases := []struct {
prefix string
version string
want string
}{
{"v", "1.2.4", "v1.2.4"},
{"", "1.2.4", "1.2.4"},
{"release-", "1.2.4", "release-1.2.4"},
}
for _, c := range cases {
info := Info{Major: 1, Minor: 2, TagPrefix: c.prefix}
if got := info.TagName(c.version); got != c.want {
t.Errorf("TagName(%q) = %q, want %q", c.version, got, c.want)
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package commits
import (
"regexp"
"strings"
)
// Type represents the semantic weight of a commit for versioning purposes.
type Type int
const (
TypeNone Type = iota // no version bump
TypeFix // fix: → patch bump
TypeFeat // feat: → patch bump (minor is pinned to branch)
TypeBreaking // feat!: or BREAKING CHANGE → patch bump
)
// headerRe matches conventional commit headers in non-strict mode:
// case-insensitive, optional scope, optional breaking marker, flexible whitespace around colon.
var headerRe = regexp.MustCompile(`(?i)^(\w+)(?:\([^)]*\))?(!)?\s*:\s*\S`)
// Parse extracts the commit type from a commit message.
// Unparseable messages return TypeNone — never an error.
func Parse(message string) Type {
msg := strings.TrimSpace(message)
// BREAKING CHANGE anywhere in the body/footer takes priority (spec §10).
if strings.Contains(msg, "BREAKING CHANGE") {
return TypeBreaking
}
first := strings.SplitN(msg, "\n", 2)[0]
m := headerRe.FindStringSubmatch(first)
if m == nil {
return TypeNone
}
if m[2] == "!" {
return TypeBreaking
}
switch strings.ToLower(m[1]) {
case "feat":
return TypeFeat
case "fix":
return TypeFix
default:
return TypeNone
}
}
func (t Type) String() string {
switch t {
case TypeFix:
return "fix"
case TypeFeat:
return "feat"
case TypeBreaking:
return "breaking"
default:
return "none"
}
}
+102
View File
@@ -0,0 +1,102 @@
package commits
import (
"strings"
"testing"
)
// FuzzParse verifies that the parser never panics and always returns a valid Type.
func FuzzParse(f *testing.F) {
// Seed corpus: representative conventional commit messages
seeds := []string{
"feat: add OAuth2 login",
"fix: correct null pointer",
"feat!: remove legacy API",
"feat(auth): add login\n\nBREAKING CHANGE: old endpoint removed",
"chore: update deps",
"Feat:missing space",
"FIX(scope)!: mixed case breaking",
"",
"\x00",
"BREAKING CHANGE: bare footer",
"not a conventional commit at all",
"feat: \n\n body only",
strings.Repeat("a", 4096),
}
for _, s := range seeds {
f.Add(s)
}
f.Fuzz(func(t *testing.T, msg string) {
result := Parse(msg)
if result < TypeNone || result > TypeBreaking {
t.Errorf("Parse(%q) returned out-of-range type %d", msg, result)
}
})
}
func TestTypeString(t *testing.T) {
cases := []struct {
t Type
want string
}{
{TypeNone, "none"},
{TypeFix, "fix"},
{TypeFeat, "feat"},
{TypeBreaking, "breaking"},
{Type(99), "none"}, // unknown → default
}
for _, c := range cases {
if got := c.t.String(); got != c.want {
t.Errorf("Type(%d).String() = %q, want %q", c.t, got, c.want)
}
}
}
func TestParse(t *testing.T) {
cases := []struct {
msg string
want Type
}{
// standard cases
{"feat: add login page", TypeFeat},
{"fix: correct null pointer", TypeFix},
{"chore: update deps", TypeNone},
{"docs: update README", TypeNone},
// non-strict: case-insensitive
{"Feat: add login page", TypeFeat},
{"FIX: correct null pointer", TypeFix},
{"FEAT: add login", TypeFeat},
// non-strict: missing space after colon
{"feat:add login", TypeFeat},
{"fix:correct null pointer", TypeFix},
// with scope
{"feat(auth): add OAuth2", TypeFeat},
{"fix(db): prevent deadlock", TypeFix},
// breaking change via !
{"feat!: remove legacy API", TypeBreaking},
{"fix!: change response format", TypeBreaking},
{"feat(api)!: rename endpoint", TypeBreaking},
// breaking change via footer
{"feat: new API\n\nBREAKING CHANGE: old API removed", TypeBreaking},
{"refactor: cleanup\n\nBREAKING CHANGE: interface changed", TypeBreaking},
// unparseable — silently ignored
{"WIP", TypeNone},
{"Merge branch 'main' into release/1.2", TypeNone},
{"", TypeNone},
{"fix a bug without colon", TypeNone},
}
for _, c := range cases {
got := Parse(c.msg)
if got != c.want {
t.Errorf("Parse(%q) = %s, want %s", c.msg, got, c.want)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"git.k3nny.fr/releaser/internal/branch"
)
const filename = ".releaser.yml"
type Config struct {
Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"`
GitLab GitLabConfig `yaml:"gitlab"`
}
type GitConfig struct {
TagPrefix string `yaml:"tag_prefix"`
BranchPattern string `yaml:"branch_pattern"`
CommitMessage string `yaml:"commit_message"`
AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"`
}
type MavenConfig struct {
PomPath string `yaml:"pom_path"`
}
type GitLabConfig struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
Project string `yaml:"project"`
}
func defaults() Config {
return Config{
Git: GitConfig{
TagPrefix: "v",
BranchPattern: branch.DefaultBranchPattern,
CommitMessage: "chore(release): {version} [skip ci]",
},
Maven: MavenConfig{
PomPath: "pom.xml",
},
}
}
// Load reads .releaser.yml from dir and merges it over the defaults.
// Missing file is not an error — defaults are returned as-is.
func Load(dir string) (Config, error) {
cfg := defaults()
data, err := os.ReadFile(filepath.Join(dir, filename))
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
if err != nil {
return cfg, fmt.Errorf("read %s: %w", filename, err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", filename, err)
}
return cfg, nil
}
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables.
// Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() {
if c.GitLab.Token == "" {
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
}
if c.GitLab.URL == "" {
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
c.GitLab.URL = os.Getenv("CI_SERVER_URL")
}
if c.GitLab.Project == "" {
// Prefer numeric ID; fall back to namespace/project path
if id := os.Getenv("CI_PROJECT_ID"); id != "" {
c.GitLab.Project = id
} else {
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH")
}
}
}
+144
View File
@@ -0,0 +1,144 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaults(t *testing.T) {
cfg, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "v" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
}
if cfg.Maven.PomPath != "pom.xml" {
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
}
}
func TestLoadOverride(t *testing.T) {
dir := t.TempDir()
content := `
git:
tag_prefix: ""
commit_message: "release: {version}"
maven:
pom_path: "services/api/pom.xml"
`
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
}
if cfg.Git.CommitMessage != "release: {version}" {
t.Errorf("CommitMessage = %q", cfg.Git.CommitMessage)
}
if cfg.Maven.PomPath != "services/api/pom.xml" {
t.Errorf("PomPath = %q", cfg.Maven.PomPath)
}
}
func TestLoadReadError(t *testing.T) {
dir := t.TempDir()
// Create a directory named .releaser.yml so os.ReadFile fails (is a directory)
if err := os.Mkdir(filepath.Join(dir, filename), 0755); err != nil {
t.Fatal(err)
}
_, err := Load(dir)
if err == nil {
t.Error("expected error when .releaser.yml is a directory")
}
}
func TestLoadInvalidYAML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, filename), []byte("{[invalid yaml"), 0644); err != nil {
t.Fatal(err)
}
_, err := Load(dir)
if err == nil {
t.Error("expected error for invalid YAML")
}
}
func TestApplyEnv(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "mytoken")
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
t.Setenv("CI_PROJECT_ID", "42")
cfg := defaults()
cfg.ApplyEnv()
if cfg.GitLab.Token != "mytoken" {
t.Errorf("Token = %q, want mytoken", cfg.GitLab.Token)
}
if cfg.GitLab.URL != "https://gitlab.example.com" {
t.Errorf("URL = %q", cfg.GitLab.URL)
}
if cfg.GitLab.Project != "42" {
t.Errorf("Project = %q, want 42", cfg.GitLab.Project)
}
}
func TestApplyEnvDoesNotOverwrite(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "env-token")
t.Setenv("CI_SERVER_URL", "https://env.example.com")
t.Setenv("CI_PROJECT_ID", "99")
cfg := defaults()
cfg.GitLab.Token = "config-token"
cfg.GitLab.URL = "https://config.example.com"
cfg.GitLab.Project = "config-project"
cfg.ApplyEnv()
if cfg.GitLab.Token != "config-token" {
t.Errorf("Token overwritten: got %q", cfg.GitLab.Token)
}
if cfg.GitLab.URL != "https://config.example.com" {
t.Errorf("URL overwritten: got %q", cfg.GitLab.URL)
}
if cfg.GitLab.Project != "config-project" {
t.Errorf("Project overwritten: got %q", cfg.GitLab.Project)
}
}
func TestApplyEnvProjectPathFallback(t *testing.T) {
t.Setenv("CI_PROJECT_ID", "")
t.Setenv("CI_PROJECT_PATH", "mygroup/myapp")
cfg := defaults()
cfg.ApplyEnv()
if cfg.GitLab.Project != "mygroup/myapp" {
t.Errorf("Project = %q, want mygroup/myapp (from CI_PROJECT_PATH)", cfg.GitLab.Project)
}
}
func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default
content := "git:\n tag_prefix: \"\"\n"
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
t.Fatal(err)
}
cfg, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
}
if cfg.Git.CommitMessage != defaults().Git.CommitMessage {
t.Errorf("CommitMessage = %q, want default", cfg.Git.CommitMessage)
}
}
+280
View File
@@ -0,0 +1,280 @@
package gitutil
import (
"errors"
"fmt"
"sort"
"time"
gogit "github.com/go-git/go-git/v5"
gitconfig "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"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/storer"
"git.k3nny.fr/releaser/internal/branch"
)
// IsWorkingTreeClean returns true when there are no staged or modified tracked files.
// Untracked files are ignored so that unreleased work-in-progress does not block a release.
func IsWorkingTreeClean(repo *gogit.Repository) (bool, error) {
w, err := repo.Worktree()
if err != nil {
return false, err
}
status, err := w.Status()
if err != nil {
return false, err
}
for _, s := range status {
if s.Staging != gogit.Unmodified && s.Staging != gogit.Untracked {
return false, nil
}
if s.Worktree != gogit.Unmodified && s.Worktree != gogit.Untracked {
return false, nil
}
}
return true, nil
}
// CurrentBranch returns the short branch name from HEAD.
// Returns an error when HEAD is detached (common in CI pipelines).
func CurrentBranch(repo *gogit.Repository) (string, error) {
head, err := repo.Head()
if err != nil {
return "", fmt.Errorf("read HEAD: %w", err)
}
if !head.Name().IsBranch() {
return "", fmt.Errorf("HEAD is detached — use --branch to specify the release branch")
}
return head.Name().Short(), nil
}
type tagCandidate struct {
name string
patch int
}
// LatestTag returns the highest-patch tag matching the branch's version range that is
// reachable from HEAD. Returns ("", -1, nil) when no matching ancestor tag exists.
func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
head, err := repo.Head()
if err != nil {
return "", -1, err
}
headCommit, err := repo.CommitObject(head.Hash())
if err != nil {
return "", -1, err
}
refs, err := repo.Tags()
if err != nil {
return "", -1, err
}
var candidates []tagCandidate
err = refs.ForEach(func(ref *plumbing.Reference) error {
name := ref.Name().Short()
patch, ok := info.MatchesTag(name)
if !ok {
return nil
}
commitHash, err := resolveTagToCommit(repo, ref)
if err != nil {
return nil // silently skip malformed tags
}
tagCommit, err := repo.CommitObject(commitHash)
if err != nil {
return nil
}
if tagCommit.Hash == headCommit.Hash {
candidates = append(candidates, tagCandidate{name, patch})
return nil
}
anc, err := tagCommit.IsAncestor(headCommit)
if err != nil || !anc {
return nil
}
candidates = append(candidates, tagCandidate{name, patch})
return nil
})
if err != nil {
return "", -1, err
}
if len(candidates) == 0 {
return "", -1, nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].patch > candidates[j].patch
})
best := candidates[0]
return best.name, best.patch, nil
}
// CommitsSince returns commit messages from HEAD down to (but not including) the tagged commit.
func CommitsSince(repo *gogit.Repository, tagName string) ([]string, error) {
ref, err := repo.Tag(tagName)
if err != nil {
return nil, fmt.Errorf("resolve tag %q: %w", tagName, err)
}
tagHash, err := resolveTagToCommit(repo, ref)
if err != nil {
return nil, fmt.Errorf("resolve tag commit: %w", err)
}
head, err := repo.Head()
if err != nil {
return nil, err
}
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
if err != nil {
return nil, err
}
var messages []string
err = iter.ForEach(func(c *object.Commit) error {
if c.Hash == tagHash {
return storer.ErrStop
}
messages = append(messages, c.Message)
return nil
})
return messages, err
}
// AllCommits returns all commit messages reachable from HEAD.
func AllCommits(repo *gogit.Repository) ([]string, error) {
head, err := repo.Head()
if err != nil {
return nil, err
}
iter, err := repo.Log(&gogit.LogOptions{From: head.Hash()})
if err != nil {
return nil, err
}
var messages []string
err = iter.ForEach(func(c *object.Commit) error {
messages = append(messages, c.Message)
return nil
})
return messages, err
}
// AuthorFromConfig reads user.name and user.email from the repository's git config.
// Local config overrides global; returns empty strings if neither is set.
func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
cfg, err := repo.ConfigScoped(gitconfig.GlobalScope)
if err == nil {
name = cfg.User.Name
email = cfg.User.Email
}
// Local config overrides global
local, err := repo.Config()
if err == nil {
if local.User.Name != "" {
name = local.User.Name
}
if local.User.Email != "" {
email = local.User.Email
}
}
return
}
// CommitFile stages filePath (relative to worktree root) and creates a commit.
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
w, err := repo.Worktree()
if err != nil {
return plumbing.ZeroHash, err
}
if _, err := w.Add(filePath); err != nil {
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err)
}
hash, err := w.Commit(message, &gogit.CommitOptions{
Author: &object.Signature{
Name: authorName,
Email: authorEmail,
When: time.Now(),
},
})
if err != nil {
return plumbing.ZeroHash, fmt.Errorf("git commit: %w", err)
}
return hash, nil
}
// CreateTag creates a lightweight tag on HEAD.
func CreateTag(repo *gogit.Repository, tagName string) error {
head, err := repo.Head()
if err != nil {
return err
}
if _, err := repo.CreateTag(tagName, head.Hash(), nil); err != nil {
return fmt.Errorf("create tag %s: %w", tagName, err)
}
return nil
}
// Push pushes the given branch and tag to the "origin" remote.
// If token is non-empty, HTTPS basic auth (oauth2/token) is used.
// Passing an empty token lets go-git use the system credential helper or SSH agent.
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin")
if err != nil {
return fmt.Errorf("remote origin not found: %w", err)
}
opts := &gogit.PushOptions{
RefSpecs: []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
},
}
if token != "" {
opts.Auth = &githttp.BasicAuth{
Username: "oauth2",
Password: token,
}
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
return fmt.Errorf("git push: %w", err)
}
return nil
}
// resolveTagToCommit follows tag objects until it reaches a commit.
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
hash := ref.Hash()
for {
obj, err := repo.Object(plumbing.AnyObject, hash)
if err != nil {
return plumbing.ZeroHash, err
}
switch o := obj.(type) {
case *object.Commit:
return o.Hash, nil
case *object.Tag:
hash = o.Target
default:
return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
}
}
}
+701
View File
@@ -0,0 +1,701 @@
package gitutil
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
gogit "github.com/go-git/go-git/v5"
gitconfig "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"
"git.k3nny.fr/releaser/internal/branch"
)
// ── helpers ──────────────────────────────────────────────────────────────────
func newTestRepo(t *testing.T) (*gogit.Repository, string) {
t.Helper()
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, false)
if err != nil {
t.Fatalf("init repo: %v", err)
}
return repo, dir
}
func testSig() *object.Signature {
return &object.Signature{Name: "Test", Email: "test@example.com", When: time.Now()}
}
// addCommit writes content to test.txt, stages it, and creates a commit.
func addCommit(t *testing.T, repo *gogit.Repository, dir, message, content string) plumbing.Hash {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "test.txt"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
w, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
if _, err := w.Add("test.txt"); err != nil {
t.Fatal(err)
}
hash, err := w.Commit(message, &gogit.CommitOptions{Author: testSig()})
if err != nil {
t.Fatalf("commit %q: %v", message, err)
}
return hash
}
func addTag(t *testing.T, repo *gogit.Repository, name string) {
t.Helper()
head, err := repo.Head()
if err != nil {
t.Fatal(err)
}
if _, err := repo.CreateTag(name, head.Hash(), nil); err != nil {
t.Fatalf("create lightweight tag %s: %v", name, err)
}
}
func addAnnotatedTag(t *testing.T, repo *gogit.Repository, name string) {
t.Helper()
head, err := repo.Head()
if err != nil {
t.Fatal(err)
}
_, err = repo.CreateTag(name, head.Hash(), &gogit.CreateTagOptions{
Tagger: testSig(),
Message: "release " + name,
})
if err != nil {
t.Fatalf("create annotated tag %s: %v", name, err)
}
}
// ── IsWorkingTreeClean ────────────────────────────────────────────────────────
func TestIsWorkingTreeClean(t *testing.T) {
t.Run("clean after commit", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if !clean {
t.Error("expected clean working tree after commit")
}
})
t.Run("dirty when tracked file is modified", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "test.txt"), []byte("modified"), 0644)
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if clean {
t.Error("expected dirty working tree after modification")
}
})
t.Run("dirty when new file is staged", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "staged.txt"), []byte("new"), 0644)
w, _ := repo.Worktree()
w.Add("staged.txt")
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if clean {
t.Error("expected dirty working tree with staged file")
}
})
t.Run("untracked file does not dirty the tree", func(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("new"), 0644)
clean, err := IsWorkingTreeClean(repo)
if err != nil {
t.Fatal(err)
}
if !clean {
t.Error("untracked file should not dirty the working tree")
}
})
}
// ── CurrentBranch ─────────────────────────────────────────────────────────────
func TestCurrentBranch(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
name, err := CurrentBranch(repo)
if err != nil {
t.Fatal(err)
}
if name == "" {
t.Error("branch name should not be empty")
}
}
func TestCurrentBranchDetached(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "initial")
// Detach HEAD by replacing the symbolic ref with a hash ref
head, _ := repo.Head()
detached := plumbing.NewHashReference(plumbing.HEAD, head.Hash())
if err := repo.Storer.SetReference(detached); err != nil {
t.Fatal(err)
}
_, err := CurrentBranch(repo)
if err == nil {
t.Error("expected error for detached HEAD")
}
}
// ── LatestTag ─────────────────────────────────────────────────────────────────
func TestLatestTagNoTags(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: first", "v1")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "" || patch != -1 {
t.Errorf("expected no tag: got %q patch=%d", tag, patch)
}
}
func TestLatestTagSingle(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: first", "v1")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: second", "v2")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0", tag, patch)
}
}
func TestLatestTagPicksHighestPatch(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: c2", "v2")
addTag(t, repo, "v1.2.1")
addCommit(t, repo, dir, "feat: c3", "v3")
addTag(t, repo, "v1.2.5")
addCommit(t, repo, dir, "fix: c4", "v4") // HEAD after the last tag
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.5" || patch != 5 {
t.Errorf("got tag=%q patch=%d, want v1.2.5 patch=5", tag, patch)
}
}
func TestLatestTagCrossVersionIgnored(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0") // matching
addCommit(t, repo, dir, "fix: c2", "v2")
addTag(t, repo, "v1.3.0") // different minor — must be ignored
addCommit(t, repo, dir, "fix: c3", "v3")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want v1.2.0 patch=0 (v1.3.0 must be ignored)", tag, patch)
}
}
func TestLatestTagNoPrefixVariant(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "2.0.0") // no "v" prefix
addCommit(t, repo, dir, "fix: c2", "v2")
info := branch.Info{Major: 2, Minor: 0, TagPrefix: ""}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "2.0.0" || patch != 0 {
t.Errorf("got tag=%q patch=%d, want 2.0.0 patch=0", tag, patch)
}
}
func TestLatestTagAnnotated(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addAnnotatedTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: c2", "v2")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
if tag != "v1.2.0" || patch != 0 {
t.Errorf("annotated tag: got %q patch=%d, want v1.2.0", tag, patch)
}
}
// ── CommitsSince ──────────────────────────────────────────────────────────────
func TestCommitsSince(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: baseline", "v0")
addTag(t, repo, "v1.2.0")
addCommit(t, repo, dir, "fix: bug fix", "v1")
addCommit(t, repo, dir, "feat: new feature", "v2")
msgs, err := CommitsSince(repo, "v1.2.0")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 2 {
t.Fatalf("expected 2 commits since v1.2.0, got %d: %v", len(msgs), msgs)
}
// Log is reverse-chronological: newest first
if !strings.Contains(msgs[0], "feat: new feature") {
t.Errorf("msgs[0] = %q, want feat: new feature", msgs[0])
}
if !strings.Contains(msgs[1], "fix: bug fix") {
t.Errorf("msgs[1] = %q, want fix: bug fix", msgs[1])
}
}
func TestCommitsSinceTagOnHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// HEAD IS the tag — no commits since
msgs, err := CommitsSince(repo, "v1.2.0")
if err != nil {
t.Fatal(err)
}
if len(msgs) != 0 {
t.Errorf("expected 0 commits since tag on HEAD, got %d", len(msgs))
}
}
// ── AllCommits ────────────────────────────────────────────────────────────────
func TestAllCommits(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addCommit(t, repo, dir, "fix: c2", "v2")
addCommit(t, repo, dir, "fix: c3", "v3")
msgs, err := AllCommits(repo)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 {
t.Errorf("expected 3 commits, got %d", len(msgs))
}
}
// ── CommitFile ────────────────────────────────────────────────────────────────
func TestCommitFile(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: initial", "init")
pomPath := filepath.Join(dir, "pom.xml")
if err := os.WriteFile(pomPath, []byte("<project><version>1.2.4</version></project>"), 0644); err != nil {
t.Fatal(err)
}
hash, err := CommitFile(repo, "pom.xml", "chore(release): v1.2.4 [skip ci]", "Releaser", "ci@example.com")
if err != nil {
t.Fatal(err)
}
if hash.IsZero() {
t.Error("expected non-zero commit hash")
}
commit, err := repo.CommitObject(hash)
if err != nil {
t.Fatal(err)
}
if commit.Message != "chore(release): v1.2.4 [skip ci]" {
t.Errorf("commit message = %q", commit.Message)
}
if commit.Author.Name != "Releaser" {
t.Errorf("author name = %q, want Releaser", commit.Author.Name)
}
if commit.Author.Email != "ci@example.com" {
t.Errorf("author email = %q", commit.Author.Email)
}
}
// ── CreateTag ─────────────────────────────────────────────────────────────────
func TestCreateTag(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
if err := CreateTag(repo, "v1.2.0"); err != nil {
t.Fatal(err)
}
ref, err := repo.Tag("v1.2.0")
if err != nil {
t.Fatalf("tag v1.2.0 not found after creation: %v", err)
}
head, _ := repo.Head()
if ref.Hash() != head.Hash() {
t.Error("lightweight tag does not point to HEAD")
}
}
func TestCreateTagDuplicate(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
if err := CreateTag(repo, "v1.2.0"); err == nil {
t.Error("expected error when creating duplicate tag")
}
}
// ── Push (error path only — no real remote needed) ───────────────────────────
func TestPushRemoteFails(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// Remote config exists but points nowhere → Push will error on remote.Push
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{"/nonexistent/bare/repo"},
}); err != nil {
t.Fatal(err)
}
err := Push(repo, "master", "v1.2.0", "")
if err == nil {
t.Error("expected push error for invalid remote path")
}
}
func TestPushNoRemote(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
err := Push(repo, "master", "v1.0.0", "")
if err == nil {
t.Error("expected error when no remote is configured")
}
}
// ── bare-repo error paths ─────────────────────────────────────────────────────
func TestIsWorkingTreeCleanBareRepo(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, true) // bare = true
if err != nil {
t.Fatal(err)
}
_, err = IsWorkingTreeClean(repo)
if err == nil {
t.Error("expected error for bare repo (no worktree)")
}
}
func TestCurrentBranchEmptyRepo(t *testing.T) {
// Fresh repo with no commits — HEAD symbolic ref points to refs/heads/master
// but that ref doesn't exist yet, so Head() returns plumbing.ErrReferenceNotFound.
repo, _ := newTestRepo(t)
_, err := CurrentBranch(repo)
if err == nil {
t.Error("expected error for empty repo (no HEAD commit)")
}
}
func TestAllCommitsEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
_, err := AllCommits(repo)
if err == nil {
t.Error("expected error for empty repo")
}
}
func TestCreateTagEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
err := CreateTag(repo, "v1.0.0")
if err == nil {
t.Error("expected error when creating tag on empty repo")
}
}
func TestLatestTagEmptyRepo(t *testing.T) {
repo, _ := newTestRepo(t)
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
if err == nil {
t.Error("expected error for empty repo")
}
}
func TestCommitFileBareRepo(t *testing.T) {
dir := t.TempDir()
repo, err := gogit.PlainInit(dir, true)
if err != nil {
t.Fatal(err)
}
_, err = CommitFile(repo, "pom.xml", "chore: test", "Test", "t@t.com")
if err == nil {
t.Error("expected error for bare repo (no worktree)")
}
}
func TestCommitFileNonexistentFile(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// Try to stage a file that doesn't exist in the worktree
_, err := CommitFile(repo, "nonexistent.xml", "chore: bad", "Test", "t@t.com")
if err == nil {
t.Error("expected error when staging nonexistent file")
}
}
// ── LatestTag edge cases ──────────────────────────────────────────────────────
func TestLatestTagOnHead(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
addTag(t, repo, "v1.2.0") // tag is ON HEAD, not behind HEAD
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
// Tag at HEAD should still be found (it is the current release base)
if tag != "v1.2.0" || patch != 0 {
t.Errorf("got %q patch=%d, want v1.2.0 patch=0 when tag is on HEAD", tag, patch)
}
}
func TestLatestTagNonAncestorIgnored(t *testing.T) {
repo, dir := newTestRepo(t)
baseHash := addCommit(t, repo, dir, "chore: base", "base")
// Create sibling branch from base commit and put a v1.2.0 tag on it
w, _ := repo.Worktree()
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("sibling"),
Hash: baseHash,
Create: true,
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "feat: sibling work", "sibling")
addTag(t, repo, "v1.2.0")
// Switch back to master and add a commit (diverges from sibling)
if err := w.Checkout(&gogit.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName("master"),
}); err != nil {
t.Fatal(err)
}
addCommit(t, repo, dir, "fix: mainline only", "mainline")
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatal(err)
}
// v1.2.0 is on the sibling branch — not an ancestor of current HEAD
if tag != "" || patch != -1 {
t.Errorf("non-ancestor tag should be ignored: got %q patch=%d", tag, patch)
}
}
func TestLatestTagSkipsMalformedRef(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Create a v1.2.0 tag reference pointing to a hash that doesn't exist.
// LatestTag must silently skip it instead of returning an error.
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
info := branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}
tag, patch, err := LatestTag(repo, info)
if err != nil {
t.Fatalf("LatestTag should not error on malformed tag ref: %v", err)
}
if tag != "" || patch != -1 {
t.Errorf("malformed tag should be skipped: got %q patch=%d", tag, patch)
}
}
// ── resolveTagToCommit default case ──────────────────────────────────────────
func TestResolveTagToCommitBlobRef(t *testing.T) {
repo, dir := newTestRepo(t)
h := addCommit(t, repo, dir, "fix: c1", "content")
// Obtain a blob hash from the commit tree to use as an adversarial ref target.
commit, err := repo.CommitObject(h)
if err != nil {
t.Fatal(err)
}
tree, err := commit.Tree()
if err != nil || len(tree.Entries) == 0 {
t.Fatal("need at least one tree entry")
}
blobHash := tree.Entries[0].Hash
blobRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("blob-tag"), blobHash)
_, err = resolveTagToCommit(repo, blobRef)
if err == nil {
t.Error("expected error when tag points to a blob (not a commit or tag object)")
}
}
// ── CommitsSince error paths ──────────────────────────────────────────────────
func TestCommitsSinceBadTag(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
_, err := CommitsSince(repo, "v-does-not-exist")
if err == nil {
t.Error("expected error for nonexistent tag")
}
}
func TestCommitsSinceMalformedTagRef(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: c1", "v1")
// Create a tag that references a nonexistent hash so resolveTagToCommit fails.
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
fakeRef := plumbing.NewHashReference(plumbing.NewTagReferenceName("v1.2.0"), fakeHash)
if err := repo.Storer.SetReference(fakeRef); err != nil {
t.Fatal(err)
}
_, err := CommitsSince(repo, "v1.2.0")
if err == nil {
t.Error("expected error when resolveTagToCommit fails on a malformed tag ref")
}
}
// ── AuthorFromConfig ──────────────────────────────────────────────────────────
func TestAuthorFromConfigDoesNotPanic(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// May return empty strings in CI where ~/.gitconfig has no user section — must not panic
name, email := AuthorFromConfig(repo)
_ = name
_ = email
}
func TestAuthorFromConfigLocalOverride(t *testing.T) {
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "chore: init", "init")
// Write a local git config with user.name / user.email
localCfg := `[user]
name = LocalUser
email = local@example.com
`
if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte(localCfg), 0644); err != nil {
t.Fatal(err)
}
// Reload repo so it picks up the config file we just wrote
repo2, err := gogit.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
name, email := AuthorFromConfig(repo2)
if name != "LocalUser" {
t.Errorf("name = %q, want LocalUser", name)
}
if email != "local@example.com" {
t.Errorf("email = %q, want local@example.com", email)
}
}
// ── Push with a real bare remote ──────────────────────────────────────────────
func TestPushWithBareRemote(t *testing.T) {
// Create a bare repo to act as the remote
remoteDir := t.TempDir()
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
t.Fatal(err)
}
// Create the working repo
repo, dir := newTestRepo(t)
addCommit(t, repo, dir, "fix: something", "v1")
addTag(t, repo, "v1.2.0")
// Wire the bare repo as origin
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
Name: "origin",
URLs: []string{remoteDir},
}); err != nil {
t.Fatal(err)
}
// Push with a token (exercises the auth branch even though local transport ignores it)
err := Push(repo, "master", "v1.2.0", "dummy-token")
if err != nil {
t.Fatalf("Push to bare remote failed: %v", err)
}
// Push again — should hit the NoErrAlreadyUpToDate branch and return nil
err = Push(repo, "master", "v1.2.0", "dummy-token")
if err != nil {
t.Fatalf("second Push returned unexpected error: %v", err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package glclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Client is a minimal GitLab API client covering only the Releases endpoint.
type Client struct {
baseURL string
token string
projectID string // numeric ID or URL-encoded namespace/project
httpClient *http.Client
}
// New creates a Client. baseURL is the GitLab instance root (e.g. "https://gitlab.example.com").
// project can be a numeric ID ("123") or a namespace/project path ("group/myapp").
func New(baseURL, token, project string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
token: token,
projectID: encodeProjectPath(project),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// endpoint builds a full API URL for the given resource under the project.
// Uses url.URL{Path, RawPath} to preserve %2F encoding in the project path —
// url.Parse would silently decode %2F back to / in the Path field.
func (c *Client) endpoint(resource string) string {
rawPath := fmt.Sprintf("/api/v4/projects/%s/%s", c.projectID, resource)
u, err := url.Parse(c.baseURL)
if err != nil {
return c.baseURL + rawPath // fallback, handles only simple cases
}
u.Path = strings.ReplaceAll(rawPath, "%2F", "/")
u.RawPath = rawPath
return u.String()
}
// encodeProjectPath URL-encodes a GitLab project identifier for use in an API path.
// Numeric IDs ("123") pass through unchanged.
// Path-style IDs ("group/project") have each segment encoded and slashes replaced with %2F,
// because url.PathEscape does not encode / (it is a valid path separator).
func encodeProjectPath(project string) string {
parts := strings.Split(project, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "%2F")
}
type createReleaseRequest struct {
Name string `json:"name"`
TagName string `json:"tag_name"`
Description string `json:"description"`
}
// CreateRelease creates a GitLab release on an existing tag.
// The tag must already be pushed to the remote before calling this.
func (c *Client) CreateRelease(ctx context.Context, tagName, description string) error {
// json.Marshal never fails for a struct with only string fields
body, _ := json.Marshal(createReleaseRequest{
Name: tagName,
TagName: tagName,
Description: description,
})
endpoint := c.endpoint("releases")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("PRIVATE-TOKEN", c.token)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitLab API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck
if errBody.Message != "" {
return fmt.Errorf("GitLab API returned %d: %s", resp.StatusCode, errBody.Message)
}
return fmt.Errorf("GitLab API returned %d", resp.StatusCode)
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package glclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCreateRelease(t *testing.T) {
var received createReleaseRequest
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
gotToken = r.Header.Get("PRIVATE-TOKEN")
json.NewDecoder(r.Body).Decode(&received) //nolint:errcheck
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "my-token", "123")
err := client.CreateRelease(context.Background(), "v1.2.4", "## v1.2.4\n\n### Bug Fixes\n- patch something")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotToken != "my-token" {
t.Errorf("PRIVATE-TOKEN = %q, want %q", gotToken, "my-token")
}
if received.TagName != "v1.2.4" {
t.Errorf("tag_name = %q, want %q", received.TagName, "v1.2.4")
}
if received.Name != "v1.2.4" {
t.Errorf("name = %q, want %q", received.Name, "v1.2.4")
}
if received.Description == "" {
t.Error("description should not be empty")
}
}
func TestCreateReleaseAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
w.Write([]byte(`{"message":"Tag already has a release"}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "token", "42")
err := client.CreateRelease(context.Background(), "v1.2.4", "notes")
if err == nil {
t.Fatal("expected error for 422 response")
}
if err.Error() == "" {
t.Error("error message should not be empty")
}
}
func TestProjectPathEncoding(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// EscapedPath returns RawPath when set, preserving %2F encoding.
// r.URL.Path is always decoded so we must not use it here.
gotPath = r.URL.EscapedPath()
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`)) //nolint:errcheck
}))
defer srv.Close()
client := New(srv.URL, "token", "group/my-app")
client.CreateRelease(context.Background(), "v1.0.0", "") //nolint:errcheck
// "group/my-app" must be URL-encoded as "group%2Fmy-app"
if gotPath != "/api/v4/projects/group%2Fmy-app/releases" {
t.Errorf("path = %q, want /api/v4/projects/group%%2Fmy-app/releases", gotPath)
}
}
func TestEndpointFallback(t *testing.T) {
// "://" is an invalid URL that url.Parse will reject,
// exercising the fallback branch in endpoint().
c := New("://invalid", "token", "123")
got := c.endpoint("releases")
want := "://invalid/api/v4/projects/123/releases"
if got != want {
t.Errorf("endpoint fallback = %q, want %q", got, want)
}
}
func TestCreateReleaseStatusNoMessage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`not json`)) //nolint:errcheck
}))
defer srv.Close()
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected error for 500 response")
}
// Should contain just the status code, no message field
if !strings.Contains(err.Error(), "500") {
t.Errorf("error should mention status 500: %v", err)
}
}
func TestCreateReleaseInvalidURL(t *testing.T) {
// "://" is unparseable as a URL scheme, so endpoint() returns the raw fallback
// string and http.NewRequestWithContext fails when it tries to parse it.
c := New("://", "token", "42")
err := c.CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected error for client with invalid base URL")
}
}
func TestCreateReleaseNetworkError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
srv.Close() // close immediately so the request fails at TCP level
err := New(srv.URL, "token", "42").CreateRelease(context.Background(), "v1.0.0", "notes")
if err == nil {
t.Fatal("expected network error after server is closed")
}
}
// FuzzEncodeProjectPath verifies that the encoder never panics and never emits
// a raw slash when the input contains one.
func FuzzEncodeProjectPath(f *testing.F) {
f.Add("group/project")
f.Add("123")
f.Add("")
f.Add("group/subgroup/project")
f.Add("a/b/c/d")
f.Add("/leading/slash")
f.Add("trailing/slash/")
f.Add(strings.Repeat("a/", 50))
f.Fuzz(func(t *testing.T, project string) {
result := encodeProjectPath(project)
// A slash in the input must NOT appear as a literal slash in the result
if strings.Contains(project, "/") && strings.Contains(result, "/") {
t.Errorf("encodeProjectPath(%q) = %q: contains unencoded slash", project, result)
}
})
}
+64
View File
@@ -0,0 +1,64 @@
package maven
import (
"fmt"
"os"
"regexp"
"strings"
)
var parentBlockRe = regexp.MustCompile(`(?s)<parent>.*?</parent>`)
var versionTagRe = regexp.MustCompile(`<version>([^<]+)</version>`)
// ReadVersion returns the project version from a pom.xml file.
// It ignores the <parent> block version and dependency versions.
func ReadVersion(pomPath string) (string, error) {
data, err := os.ReadFile(pomPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", pomPath, err)
}
// Strip <parent>…</parent> before searching so we don't pick up the parent version.
stripped := parentBlockRe.ReplaceAllString(string(data), "")
m := versionTagRe.FindStringSubmatch(stripped)
if m == nil {
return "", fmt.Errorf("no <version> element found in %s", pomPath)
}
return strings.TrimSpace(m[1]), nil
}
// WriteVersion replaces the project version in a pom.xml file in-place.
// oldVersion must match what ReadVersion returned. The <parent> version is never touched.
func WriteVersion(pomPath, oldVersion, newVersion string) error {
data, err := os.ReadFile(pomPath)
if err != nil {
return fmt.Errorf("read %s: %w", pomPath, err)
}
updated := replaceProjectVersion(string(data), oldVersion, newVersion)
if updated == string(data) {
return fmt.Errorf("version %q not found in %s", oldVersion, pomPath)
}
return os.WriteFile(pomPath, []byte(updated), 0644)
}
// replaceProjectVersion replaces the first <version>oldVersion</version> that is not
// inside a <parent> block. This preserves the parent version and dependency versions.
func replaceProjectVersion(content, oldVersion, newVersion string) string {
target := "<version>" + oldVersion + "</version>"
replacement := "<version>" + newVersion + "</version>"
// If there is a <parent> block, start searching after it.
if idx := strings.Index(content, "</parent>"); idx >= 0 {
after := content[idx:]
if pos := strings.Index(after, target); pos >= 0 {
abs := idx + pos
return content[:abs] + replacement + content[abs+len(target):]
}
return content
}
return strings.Replace(content, target, replacement, 1)
}
+213
View File
@@ -0,0 +1,213 @@
package maven
import (
"os"
"path/filepath"
"strings"
"testing"
)
const simplePom = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
<name>My App</name>
</project>`
const pomWithParent = `<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>myapp</artifactId>
<version>1.2.3</version>
</project>`
const pomWithSnapshot = `<?xml version="1.0" encoding="UTF-8"?>
<project>
<artifactId>myapp</artifactId>
<version>1.2.4-SNAPSHOT</version>
</project>`
func writePom(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "pom.xml")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
return path
}
func TestReadVersion(t *testing.T) {
cases := []struct {
name string
content string
want string
}{
{"simple pom", simplePom, "1.2.3"},
{"pom with parent", pomWithParent, "1.2.3"},
{"snapshot version", pomWithSnapshot, "1.2.4-SNAPSHOT"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := ReadVersion(writePom(t, c.content))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}
func TestReadVersionParentNotMatched(t *testing.T) {
// ReadVersion must return the project version, NOT the parent version (3.2.0).
got, err := ReadVersion(writePom(t, pomWithParent))
if err != nil {
t.Fatal(err)
}
if got != "1.2.3" {
t.Errorf("got %q, want 1.2.3 (parent version must be ignored)", got)
}
}
func TestWriteVersion(t *testing.T) {
cases := []struct {
name string
content string
oldVersion string
newVersion string
wantInFile string
}{
{"simple replace", simplePom, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"with parent", pomWithParent, "1.2.3", "1.2.4", "<version>1.2.4</version>"},
{"snapshot to release", pomWithSnapshot, "1.2.4-SNAPSHOT", "1.2.4", "<version>1.2.4</version>"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
path := writePom(t, c.content)
if err := WriteVersion(path, c.oldVersion, c.newVersion); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, _ := os.ReadFile(path)
content := string(data)
if !contains(content, c.wantInFile) {
t.Errorf("expected %q in updated pom, got:\n%s", c.wantInFile, content)
}
// Parent version must be unchanged in the parent block case.
if c.name == "with parent" && !contains(content, "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
})
}
}
func TestWriteVersionParentPreserved(t *testing.T) {
path := writePom(t, pomWithParent)
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
t.Fatal(err)
}
got, _ := ReadVersion(path)
if got != "1.2.4" {
t.Errorf("project version = %q, want 1.2.4", got)
}
data, _ := os.ReadFile(path)
if !contains(string(data), "<version>3.2.0</version>") {
t.Error("parent version was modified")
}
}
func TestReadVersionMissingFile(t *testing.T) {
_, err := ReadVersion(filepath.Join(t.TempDir(), "nonexistent.xml"))
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestReadVersionNoVersionTag(t *testing.T) {
_, err := ReadVersion(writePom(t, "<project><artifactId>app</artifactId></project>"))
if err == nil {
t.Error("expected error when no <version> tag present")
}
}
func TestWriteVersionMissingFile(t *testing.T) {
err := WriteVersion(filepath.Join(t.TempDir(), "nosuchfile.xml"), "1.0", "1.1")
if err == nil {
t.Error("expected error for missing pom file")
}
}
func TestWriteVersionNotFound(t *testing.T) {
err := WriteVersion(writePom(t, simplePom), "9.9.9", "9.9.10")
if err == nil {
t.Error("expected error when old version not found in pom")
}
}
func TestReplaceVersionParentNoMatch(t *testing.T) {
// Has </parent> but the target version only appears inside the parent block,
// not after it. replaceProjectVersion should return content unchanged.
content := `<project><parent><version>3.0.0</version></parent></project>`
result := replaceProjectVersion(content, "3.0.0", "4.0.0")
if result != content {
t.Errorf("expected content unchanged; got:\n%s", result)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
// FuzzReadVersion verifies that ReadVersion never panics on arbitrary file content.
func FuzzReadVersion(f *testing.F) {
f.Add(simplePom)
f.Add(pomWithParent)
f.Add(pomWithSnapshot)
f.Add("")
f.Add("<project></project>")
f.Add("<version>1.0</version>")
f.Add("\x00\x01\xff")
f.Fuzz(func(t *testing.T, content string) {
path := filepath.Join(t.TempDir(), "pom.xml")
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
// Must not panic regardless of content
ReadVersion(path) //nolint:errcheck
})
}
// FuzzReplaceProjectVersion verifies the internal replacer is robust against arbitrary inputs.
func FuzzReplaceProjectVersion(f *testing.F) {
f.Add(simplePom, "1.2.3", "1.2.4")
f.Add(pomWithParent, "1.2.3", "2.0.0")
f.Add("", "1.0", "2.0")
f.Add("<version>1.0</version>", "1.0", "1.1")
f.Add("\x00", "", "1.0")
f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) {
result := replaceProjectVersion(content, oldVersion, newVersion)
// Result must always be a string (no panic)
// If a replacement happened, oldVersion must not appear where newVersion was inserted
if oldVersion == newVersion {
return
}
if result != content {
// Replacement occurred — newVersion must appear in result
if !strings.Contains(result, "<version>"+newVersion+"</version>") {
t.Errorf("replacement happened but newVersion not found in result")
}
}
})
}
+65
View File
@@ -0,0 +1,65 @@
package notes
import (
"fmt"
"regexp"
"strings"
"git.k3nny.fr/releaser/internal/commits"
)
// headerSubjectRe captures the subject (description) part of a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Generate produces grouped markdown release notes from a list of commit messages.
// Commits are grouped into Breaking Changes, Features, and Bug Fixes.
// Commits with no releasable type are omitted.
func Generate(tagName string, messages []string) string {
var breaking, feats, fixes []string
for _, msg := range messages {
t := commits.Parse(msg)
if t == commits.TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := extractSubject(first)
switch t {
case commits.TypeBreaking:
breaking = append(breaking, subject)
case commits.TypeFeat:
feats = append(feats, subject)
case commits.TypeFix:
fixes = append(fixes, subject)
}
}
var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n", tagName)
writeSection(&sb, "Breaking Changes", breaking)
writeSection(&sb, "Features", feats)
writeSection(&sb, "Bug Fixes", fixes)
return strings.TrimRight(sb.String(), "\n")
}
func writeSection(sb *strings.Builder, title string, items []string) {
if len(items) == 0 {
return
}
fmt.Fprintf(sb, "\n### %s\n", title)
for _, item := range items {
fmt.Fprintf(sb, "- %s\n", item)
}
}
// extractSubject returns the description part of a conventional commit header.
// Falls back to the raw header if the pattern does not match.
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
+109
View File
@@ -0,0 +1,109 @@
package notes
import (
"strings"
"testing"
)
func TestGenerate(t *testing.T) {
messages := []string{
"feat: add OAuth2 login",
"fix: correct null pointer in user service",
"feat(search): improve performance",
"chore: update dependencies", // ignored
"Merge branch 'feature/x' into main", // ignored
"feat!: remove legacy API",
"fix: handle empty response",
"WIP something", // ignored
}
got := Generate("v1.2.4", messages)
must := []string{
"## v1.2.4",
"### Breaking Changes",
"- remove legacy API",
"### Features",
"- add OAuth2 login",
"- improve performance",
"### Bug Fixes",
"- correct null pointer in user service",
"- handle empty response",
}
for _, want := range must {
if !strings.Contains(got, want) {
t.Errorf("missing %q in output:\n%s", want, got)
}
}
absent := []string{"update dependencies", "Merge branch", "WIP"}
for _, s := range absent {
if strings.Contains(got, s) {
t.Errorf("unexpected %q in output:\n%s", s, got)
}
}
}
func TestGenerateOnlyFixes(t *testing.T) {
messages := []string{
"fix: patch SQL injection",
"fix: escape HTML output",
}
got := Generate("v1.2.1", messages)
if strings.Contains(got, "### Features") {
t.Error("Features section should be absent when there are no feat commits")
}
if strings.Contains(got, "### Breaking Changes") {
t.Error("Breaking Changes section should be absent")
}
if !strings.Contains(got, "### Bug Fixes") {
t.Error("Bug Fixes section should be present")
}
}
func TestGenerateEmpty(t *testing.T) {
got := Generate("v1.2.0", []string{"chore: bump deps", "docs: update readme"})
if strings.Contains(got, "###") {
t.Errorf("no sections expected when no releasable commits, got:\n%s", got)
}
}
func TestExtractSubject(t *testing.T) {
cases := []struct {
header string
want string
}{
{"feat: add login", "add login"},
{"feat(auth): add OAuth2", "add OAuth2"},
{"feat!: remove API", "remove API"},
{"FIX:typo", "typo"},
{"plain message", "plain message"},
}
for _, c := range cases {
got := extractSubject(c.header)
if got != c.want {
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
}
}
}
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and
// always includes the tag name in the output.
func FuzzGenerate(f *testing.F) {
f.Add("v1.2.4", "feat: add thing")
f.Add("v1.0.0", "fix: patch")
f.Add("1.2.0", "feat!: breaking change")
f.Add("v0.0.1", "")
f.Add("", "feat: msg")
f.Add("v1.0.0", "BREAKING CHANGE: dropped")
f.Add("tag\nwith\nnewline", "feat: something")
f.Add("v1.0.0", strings.Repeat("feat: x\n", 1000))
f.Fuzz(func(t *testing.T, tag, msg string) {
result := Generate(tag, []string{msg})
if !strings.Contains(result, tag) {
t.Errorf("output does not contain tag %q\noutput: %s", tag, result)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
package version
import (
"fmt"
"git.k3nny.fr/releaser/internal/commits"
)
// Next computes the next version string (without tag prefix, e.g. "1.2.4").
// currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) {
for _, t := range types {
if t != commits.TypeNone {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
}
}
return "", false
}
+73
View File
@@ -0,0 +1,73 @@
package version
import (
"testing"
"git.k3nny.fr/releaser/internal/commits"
)
func TestNext(t *testing.T) {
cases := []struct {
desc string
major, minor int
currentPatch int
types []commits.Type
want string
wantOk bool
}{
{
desc: "first release with feat",
major: 1, minor: 2, currentPatch: -1,
types: []commits.Type{commits.TypeFeat},
want: "1.2.0",
wantOk: true,
},
{
desc: "bump from patch 3",
major: 1, minor: 2, currentPatch: 3,
types: []commits.Type{commits.TypeFix},
want: "1.2.4",
wantOk: true,
},
{
desc: "breaking change still bumps patch on release branch",
major: 2, minor: 0, currentPatch: 1,
types: []commits.Type{commits.TypeBreaking},
want: "2.0.2",
wantOk: true,
},
{
desc: "only chore commits — nothing to release",
major: 1, minor: 2, currentPatch: 5,
types: []commits.Type{commits.TypeNone, commits.TypeNone},
want: "",
wantOk: false,
},
{
desc: "no commits at all",
major: 1, minor: 2, currentPatch: 0,
types: nil,
want: "",
wantOk: false,
},
{
desc: "one releasable commit among chores",
major: 1, minor: 3, currentPatch: 7,
types: []commits.Type{commits.TypeNone, commits.TypeFix, commits.TypeNone},
want: "1.3.8",
wantOk: true,
},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types)
if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk)
}
if got != c.want {
t.Errorf("got %q, want %q", got, c.want)
}
})
}
}