feat(releaser): initial release v0.4.0
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:
@@ -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/...
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
# build output
|
||||
/bin/
|
||||
releaser-*
|
||||
|
||||
# test coverage
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
@@ -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
|
||||
@@ -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: ""
|
||||
@@ -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
@@ -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"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# releaser
|
||||
|
||||

|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user