Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12cb3a71af | |||
| 16b25da396 | |||
| 6984fcc547 | |||
| 153d65bc53 | |||
| 46a10c70dc | |||
| 5d0489dd71 | |||
| 6ffe282105 | |||
| 0dc6d0747d |
@@ -3,6 +3,31 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.2.0] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- **`--verbose` flag** — prints a configuration table (each key, its value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and bump decision, and shows the final version choice; all output goes to stderr
|
||||
- **Colored, structured CLI output** — progress lines use `·` / `✓` / `!` prefix symbols; `--verbose` mode uses `▸` section headers (configuration / branch / commits / version); commit type column colored by kind (cyan=feat, green=fix, red=breaking); config source tags colored; respects `NO_COLOR` and `TERM=dumb`; auto-disabled when stderr is not a TTY
|
||||
- **Name and version header** — `releaser vX.Y.Z` printed to stderr at the start of every invocation
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default `tag_prefix` is now empty** — tags are bare version numbers (`1.2.3`) by default; add `tag_prefix: "v"` to `.releaser.yml` or pass `--tag-prefix v` to opt in to the `v`-prefixed convention
|
||||
|
||||
## [1.1.0] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- **CHANGELOG.md auto-update** — every release now writes a new dated section to `CHANGELOG.md` (grouped by Breaking Changes / Added / Fixed), committed alongside `pom.xml` in the release commit; file is created if it does not exist
|
||||
- **`--changelog-file` flag** — override the default `CHANGELOG.md` path (e.g. `--changelog-file CHANGES.md`)
|
||||
- **`--init` flag** — scaffolds a fully-commented default `.releaser.yml` in the repository root; errors if the file already exists
|
||||
- **`CommitFiles`** in `gitutil` — internal helper that stages multiple files before a single commit, used to bundle `pom.xml` + `CHANGELOG.md` in one release commit
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Push without token** — go-git's HTTPS transport does not use the system credential store; when `GITLAB_TOKEN` is unset the push now delegates to the `git` CLI so credential helpers, SSH agents, and `netrc` all work as expected
|
||||
|
||||
## [0.4.2] - 2026-07-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# releaser
|
||||
|
||||

|
||||

|
||||
|
||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
||||
|
||||
@@ -38,16 +38,22 @@ release/1.2 branch
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Scaffold a default .releaser.yml in the current repository
|
||||
releaser --init
|
||||
|
||||
# Simulate next version (no side effects)
|
||||
releaser --dry-run
|
||||
|
||||
# Full release: bump pom.xml, commit, tag, push, GitLab release
|
||||
# Full release: update pom.xml + CHANGELOG.md, 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)
|
||||
# Push commit and tag but skip creating the GitLab release
|
||||
releaser --no-release
|
||||
|
||||
# Update files but stop before committing (review first)
|
||||
releaser --no-commit
|
||||
# … then commit manually and re-run:
|
||||
releaser --tag-only
|
||||
@@ -55,6 +61,12 @@ releaser --tag-only
|
||||
# Explicitly target a branch (useful in detached HEAD CI)
|
||||
releaser --branch release/1.2
|
||||
|
||||
# Write changelog to a custom file
|
||||
releaser --changelog-file CHANGES.md
|
||||
|
||||
# Show configuration sources, commit list, and version decision
|
||||
releaser --verbose --dry-run
|
||||
|
||||
# Target a specific pom.xml
|
||||
releaser --pom path/to/pom.xml
|
||||
|
||||
@@ -71,7 +83,7 @@ releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
|
||||
|
||||
```yaml
|
||||
git:
|
||||
tag_prefix: "v" # set to "" for tags without prefix
|
||||
tag_prefix: "" # default: no prefix; set to "v" for v-prefixed tags
|
||||
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
|
||||
|
||||
+9
-3
@@ -55,15 +55,21 @@
|
||||
- [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
|
||||
## v0.5 — Changelog ✅
|
||||
|
||||
- [ ] `CHANGELOG.md` generation / append (grouped by commit type)
|
||||
- [ ] `--changelog-file` flag
|
||||
- [x] `CHANGELOG.md` generation / append (grouped by commit type: Breaking Changes / Added / Fixed)
|
||||
- [x] `--changelog-file` flag to use a custom filename
|
||||
- [x] `--init` flag to scaffold a default `.releaser.yml`
|
||||
- [x] Push falls back to system `git` CLI when no token is set (uses credential helpers, SSH, netrc)
|
||||
|
||||
## 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)
|
||||
- [x] ~~`--verbose` flag~~ — ✓ shipped v1.2.0 (shows config sources, commit analysis, version decision)
|
||||
- [x] ~~Colored, structured CLI output~~ — ✓ shipped v1.2.0 (`·` / `✓` / `!` symbols, `▸` section headers in verbose, TTY-aware ANSI colors)
|
||||
- [x] ~~Name and version header on every run~~ — ✓ shipped v1.2.0
|
||||
- [x] ~~Default tag prefix changed to empty~~ — ✓ shipped v1.2.0 (bare `1.2.3` tags by default; opt in to `v` prefix via config)
|
||||
- [ ] Documentation site
|
||||
|
||||
## Future / backlog
|
||||
|
||||
+234
-35
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
"git.k3nny.fr/releaser/internal/changelog"
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
"git.k3nny.fr/releaser/internal/config"
|
||||
"git.k3nny.fr/releaser/internal/glclient"
|
||||
@@ -21,6 +22,45 @@ import (
|
||||
semver "git.k3nny.fr/releaser/internal/version"
|
||||
)
|
||||
|
||||
const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.fr/releaser
|
||||
# All fields are optional. Uncomment and adjust what you need.
|
||||
# CLI flags always take precedence over values set here.
|
||||
|
||||
git:
|
||||
# Prefix prepended to every version tag (default: no prefix).
|
||||
# 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: ""
|
||||
`
|
||||
|
||||
var (
|
||||
version = "dev" // overridden at build time via -ldflags "-X main.version=..."
|
||||
errNothingToRelease = errors.New("nothing to release")
|
||||
@@ -31,13 +71,17 @@ var exitFn = os.Exit
|
||||
|
||||
func newRootCmd() *cobra.Command {
|
||||
var (
|
||||
init_ bool
|
||||
verbose bool
|
||||
dryRun bool
|
||||
noPush bool
|
||||
noRelease bool
|
||||
noCommit bool
|
||||
tagOnly bool
|
||||
branchOverride string
|
||||
repoPath string
|
||||
pomOverride string
|
||||
changelogFile string
|
||||
tagPrefixFlag string
|
||||
tagPrefixSet bool
|
||||
patternFlag string
|
||||
@@ -53,28 +97,36 @@ func newRootCmd() *cobra.Command {
|
||||
tagPrefixSet = cmd.Flags().Changed("tag-prefix")
|
||||
patternSet = cmd.Flags().Changed("branch-pattern")
|
||||
return run(options{
|
||||
init: init_,
|
||||
verbose: verbose,
|
||||
repoPath: repoPath,
|
||||
branchOverride: branchOverride,
|
||||
pomOverride: pomOverride,
|
||||
changelogFile: changelogFile,
|
||||
tagPrefixFlag: tagPrefixFlag,
|
||||
tagPrefixSet: tagPrefixSet,
|
||||
patternFlag: patternFlag,
|
||||
patternSet: patternSet,
|
||||
dryRun: dryRun,
|
||||
noPush: noPush,
|
||||
noRelease: noRelease,
|
||||
noCommit: noCommit,
|
||||
tagOnly: tagOnly,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
|
||||
root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision")
|
||||
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().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release")
|
||||
root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push")
|
||||
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)")
|
||||
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(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root")
|
||||
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
|
||||
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
|
||||
|
||||
@@ -92,41 +144,118 @@ func main() {
|
||||
}
|
||||
|
||||
type options struct {
|
||||
init bool
|
||||
verbose bool
|
||||
repoPath string
|
||||
branchOverride string
|
||||
pomOverride string
|
||||
changelogFile string
|
||||
tagPrefixFlag string
|
||||
tagPrefixSet bool
|
||||
patternFlag string
|
||||
patternSet bool
|
||||
dryRun bool
|
||||
noPush bool
|
||||
noRelease bool
|
||||
noCommit bool
|
||||
tagOnly bool
|
||||
}
|
||||
|
||||
func printVerboseConfig(cfg config.Config, src config.Sources) {
|
||||
logSection("configuration")
|
||||
rows := []struct{ key, val string }{
|
||||
{"git.tag_prefix", cfg.Git.TagPrefix},
|
||||
{"git.branch_pattern", cfg.Git.BranchPattern},
|
||||
{"git.commit_message", cfg.Git.CommitMessage},
|
||||
{"git.author_name", cfg.Git.AuthorName},
|
||||
{"git.author_email", cfg.Git.AuthorEmail},
|
||||
{"maven.pom_path", cfg.Maven.PomPath},
|
||||
{"gitlab.url", cfg.GitLab.URL},
|
||||
{"gitlab.token", func() string {
|
||||
if cfg.GitLab.Token != "" {
|
||||
return "(set)"
|
||||
}
|
||||
return "(not set)"
|
||||
}()},
|
||||
{"gitlab.project", cfg.GitLab.Project},
|
||||
}
|
||||
for _, r := range rows {
|
||||
source := src[r.key]
|
||||
if source == "" {
|
||||
source = "default"
|
||||
}
|
||||
val := r.val
|
||||
if val == "" {
|
||||
val = paint(ansiDim, "(empty)")
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %-25s = %-45s %s\n", r.key, val, fmtSource(source))
|
||||
}
|
||||
}
|
||||
|
||||
func initConfig(absRepo string) error {
|
||||
path := filepath.Join(absRepo, ".releaser.yml")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf(".releaser.yml already exists in %s — delete it first if you want to reset", absRepo)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0644); err != nil {
|
||||
return fmt.Errorf("write .releaser.yml: %w", err)
|
||||
}
|
||||
fmt.Printf("created %s\n", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func run(o options) error {
|
||||
logHeader(version)
|
||||
|
||||
// --- Config ---
|
||||
absRepo, err := filepath.Abs(o.repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve repo path: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(absRepo)
|
||||
if o.init {
|
||||
if o.verbose {
|
||||
logStep("creating .releaser.yml in %s", absRepo)
|
||||
}
|
||||
return initConfig(absRepo)
|
||||
}
|
||||
|
||||
var (
|
||||
cfg config.Config
|
||||
src config.Sources
|
||||
)
|
||||
if o.verbose {
|
||||
cfg, src, err = config.LoadWithSources(absRepo)
|
||||
} else {
|
||||
cfg, err = config.Load(absRepo)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ApplyEnv()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
// CLI flags take precedence over config file and env vars
|
||||
if o.tagPrefixSet {
|
||||
cfg.Git.TagPrefix = o.tagPrefixFlag
|
||||
if src != nil {
|
||||
src["git.tag_prefix"] = "flag: --tag-prefix"
|
||||
}
|
||||
}
|
||||
if o.pomOverride != "" {
|
||||
cfg.Maven.PomPath = o.pomOverride
|
||||
if src != nil {
|
||||
src["maven.pom_path"] = "flag: --pom"
|
||||
}
|
||||
}
|
||||
if o.patternSet {
|
||||
cfg.Git.BranchPattern = o.patternFlag
|
||||
if src != nil {
|
||||
src["git.branch_pattern"] = "flag: --branch-pattern"
|
||||
}
|
||||
}
|
||||
|
||||
if o.verbose {
|
||||
printVerboseConfig(cfg, src)
|
||||
}
|
||||
|
||||
// --- Git ---
|
||||
@@ -149,6 +278,13 @@ func run(o options) error {
|
||||
}
|
||||
info.TagPrefix = cfg.Git.TagPrefix
|
||||
|
||||
if o.verbose {
|
||||
logSection("branch")
|
||||
fmt.Fprintf(os.Stderr, " %s → major=%d, minor=%d %s\n",
|
||||
paint(ansiBold, branchName), info.Major, info.Minor,
|
||||
paint(ansiDim, "(pinned by branch)"))
|
||||
}
|
||||
|
||||
// --- Dirty check (before any changes) ---
|
||||
// Skipped in --no-commit mode: the user intentionally has changes in flight.
|
||||
if !o.dryRun && !o.noCommit {
|
||||
@@ -170,17 +306,21 @@ func run(o options) error {
|
||||
// --- 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))
|
||||
if !o.verbose {
|
||||
if lastTag == "" {
|
||||
logStep("no previous tag — scanning all %d commit(s)", len(messages))
|
||||
} else {
|
||||
logStep("last tag: %s (%d commit(s) to analyze)", lastTag, len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Version calculation ---
|
||||
types := make([]commits.Type, len(messages))
|
||||
@@ -188,42 +328,100 @@ func run(o options) error {
|
||||
types[i] = commits.Parse(msg)
|
||||
}
|
||||
|
||||
if o.verbose {
|
||||
logSection(fmt.Sprintf("commits (%d)", len(messages)))
|
||||
if lastTag != "" {
|
||||
fmt.Fprintf(os.Stderr, " since: %s (patch=%d)\n", paint(ansiCyan, lastTag), currentPatch)
|
||||
}
|
||||
for i, msg := range messages {
|
||||
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
|
||||
if len(first) > 70 {
|
||||
first = first[:67] + "..."
|
||||
}
|
||||
t := types[i]
|
||||
typeLabel := fmt.Sprintf("%-9s", t.String())
|
||||
if t == commits.TypeNone {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", paint(ansiDim, typeLabel+first))
|
||||
} else {
|
||||
var col string
|
||||
switch t {
|
||||
case commits.TypeBreaking:
|
||||
col = ansiRed + ansiBold
|
||||
case commits.TypeFeat:
|
||||
col = ansiCyan
|
||||
default: // fix
|
||||
col = ansiGreen
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " %s %s %s\n",
|
||||
paint(col, typeLabel), first, paint(ansiDim, "→ patch bump"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types)
|
||||
if !ok {
|
||||
fmt.Fprintln(os.Stderr, "info: no releasable commits found")
|
||||
logWarn("no releasable commits found")
|
||||
return errNothingToRelease
|
||||
}
|
||||
|
||||
nextTag := info.TagName(nextVersion)
|
||||
|
||||
if o.verbose {
|
||||
highestType := commits.TypeNone
|
||||
for _, t := range types {
|
||||
if t > highestType {
|
||||
highestType = t
|
||||
}
|
||||
}
|
||||
logSection("version")
|
||||
fmt.Fprintf(os.Stderr, " highest type: %s → next: %s (tag: %s)\n",
|
||||
paint(ansiCyan, highestType.String()),
|
||||
paint(ansiBold, nextVersion),
|
||||
paint(ansiBold+ansiCyan, nextTag))
|
||||
}
|
||||
|
||||
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
|
||||
|
||||
if o.dryRun {
|
||||
fmt.Fprintln(os.Stderr, "dry-run: no changes made")
|
||||
logStep("dry-run: no changes made")
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- pom.xml (skipped with --tag-only or when the file does not exist) ---
|
||||
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
||||
_, statErr := os.Stat(pomPath)
|
||||
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
||||
if statErr != nil && hasPom {
|
||||
return fmt.Errorf("check pom path: %w", statErr)
|
||||
}
|
||||
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
|
||||
if !o.tagOnly {
|
||||
var filesToCommit []string
|
||||
|
||||
if !o.tagOnly && hasPom {
|
||||
currentPomVersion, err := maven.ReadVersion(pomPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pom version: %w", err)
|
||||
// pom.xml
|
||||
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
||||
_, statErr := os.Stat(pomPath)
|
||||
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
||||
if statErr != nil && hasPom {
|
||||
return fmt.Errorf("check pom path: %w", statErr)
|
||||
}
|
||||
if hasPom {
|
||||
currentPomVersion, err := maven.ReadVersion(pomPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pom version: %w", err)
|
||||
}
|
||||
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
|
||||
return fmt.Errorf("update pom version: %w", err)
|
||||
}
|
||||
logDone("pom.xml: %s → %s", currentPomVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, cfg.Maven.PomPath)
|
||||
} else {
|
||||
logWarn("no pom.xml — skipping version bump")
|
||||
}
|
||||
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)
|
||||
// CHANGELOG.md
|
||||
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
|
||||
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
|
||||
return fmt.Errorf("update changelog: %w", err)
|
||||
}
|
||||
logDone("%s updated", o.changelogFile)
|
||||
filesToCommit = append(filesToCommit, o.changelogFile)
|
||||
|
||||
if o.noCommit {
|
||||
fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
|
||||
fmt.Printf("files updated to %s — commit manually then re-run with --tag-only\n", nextVersion)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -235,21 +433,18 @@ func run(o options) error {
|
||||
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)
|
||||
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg)
|
||||
} else if !o.tagOnly && !hasPom {
|
||||
fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump commit")
|
||||
logDone("committed: %s", 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)
|
||||
logDone("tag: %s", nextTag)
|
||||
|
||||
if o.noPush {
|
||||
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
|
||||
@@ -257,15 +452,19 @@ func run(o options) error {
|
||||
}
|
||||
|
||||
// --- Push ---
|
||||
fmt.Fprintln(os.Stderr, "info: pushing commit and tag...")
|
||||
logStep("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")
|
||||
logDone("pushed")
|
||||
|
||||
// --- GitLab release ---
|
||||
if o.noRelease {
|
||||
fmt.Printf("released %s\n", nextTag)
|
||||
return nil
|
||||
}
|
||||
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
|
||||
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation")
|
||||
logWarn("GitLab URL or project not configured — skipping release creation")
|
||||
fmt.Printf("released %s\n", nextTag)
|
||||
return nil
|
||||
}
|
||||
@@ -280,7 +479,7 @@ func run(o options) error {
|
||||
return fmt.Errorf("create GitLab release: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag)
|
||||
logDone("GitLab release created: %s", nextTag)
|
||||
fmt.Printf("released %s\n", nextTag)
|
||||
return nil
|
||||
}
|
||||
|
||||
+144
-11
@@ -2,10 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -226,9 +228,9 @@ func TestRunMissingPom(t *testing.T) {
|
||||
|
||||
// Tag must still have been created.
|
||||
repo2, _ := gogit.PlainOpen(dir)
|
||||
_, err = repo2.Tag("v1.2.0")
|
||||
_, err = repo2.Tag("1.2.0")
|
||||
if err != nil {
|
||||
t.Error("expected tag v1.2.0 to be created")
|
||||
t.Error("expected tag 1.2.0 to be created")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,9 +250,9 @@ func TestRunNoPomAtDefaultPath(t *testing.T) {
|
||||
}
|
||||
|
||||
repo2, _ := gogit.PlainOpen(dir)
|
||||
_, err = repo2.Tag("v2.0.0")
|
||||
_, err = repo2.Tag("2.0.0")
|
||||
if err != nil {
|
||||
t.Error("expected tag v2.0.0 to be created")
|
||||
t.Error("expected tag 2.0.0 to be created")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,12 +366,12 @@ func TestRunDuplicateTag(t *testing.T) {
|
||||
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.
|
||||
// Pre-create a 1.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.
|
||||
// so run() calculates "1.2.0" as the first-ever version — then
|
||||
// CreateTag("1.2.0") fails because the ref already exists.
|
||||
fakeRef := plumbing.NewHashReference(
|
||||
plumbing.NewTagReferenceName("v1.2.0"),
|
||||
plumbing.NewTagReferenceName("1.2.0"),
|
||||
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
|
||||
)
|
||||
if err := repo.Storer.SetReference(fakeRef); err != nil {
|
||||
@@ -378,7 +380,7 @@ func TestRunDuplicateTag(t *testing.T) {
|
||||
|
||||
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")
|
||||
t.Fatal("expected error: 1.2.0 ref already exists")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,9 +431,9 @@ func TestRunGitLabError(t *testing.T) {
|
||||
func TestRunWithPreviousTag(t *testing.T) {
|
||||
repo, dir := setupRepo(t)
|
||||
|
||||
// Tag the initial commit as v1.2.0 (simulates a prior release)
|
||||
// Tag the initial commit as 1.2.0 (simulates a prior release)
|
||||
initialHead, _ := repo.Head()
|
||||
repo.CreateTag("v1.2.0", initialHead.Hash(), nil)
|
||||
repo.CreateTag("1.2.0", initialHead.Hash(), nil)
|
||||
|
||||
// Fix commit after the tag — run() will use CommitsSince, not AllCommits
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
@@ -460,6 +462,25 @@ func TestRunSkipGitLab(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoRelease(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-release skips GitLab release even when credentials are configured
|
||||
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
|
||||
t.Setenv("CI_PROJECT_ID", "42")
|
||||
t.Setenv("GITLAB_TOKEN", "test-token")
|
||||
|
||||
err := execCmd(t, "--no-release", "--branch", "release/1.2", "--repo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("--no-release: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMissingToken(t *testing.T) {
|
||||
_, dir := setupRepoWithRemote(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
@@ -568,3 +589,115 @@ func TestMainError(t *testing.T) {
|
||||
t.Errorf("expected exit code 1 for general error, got %d", gotCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := execCmd(t, "--init", "--repo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("--init: unexpected error: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, ".releaser.yml"))
|
||||
if err != nil {
|
||||
t.Fatal("expected .releaser.yml to be created")
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("expected non-empty .releaser.yml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInitAlreadyExists(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("existing"), 0644)
|
||||
err := execCmd(t, "--init", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when .releaser.yml already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChangelogCreated(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// feat")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("feat: add shiny feature", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CHANGELOG.md"))
|
||||
if err != nil {
|
||||
t.Fatal("expected CHANGELOG.md to be created")
|
||||
}
|
||||
s := string(data)
|
||||
if !strings.Contains(s, "## [1.2.0]") {
|
||||
t.Error("expected version header in CHANGELOG")
|
||||
}
|
||||
if !strings.Contains(s, "add shiny feature") {
|
||||
t.Error("expected feat subject in CHANGELOG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChangelogFile(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()})
|
||||
|
||||
err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--changelog-file", "CHANGES.md")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "CHANGES.md")); err != nil {
|
||||
t.Error("expected CHANGES.md to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVerbose(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// feat")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("feat: add new thing", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Capture stderr output by redirecting it temporarily.
|
||||
old := os.Stderr
|
||||
r, wPipe, _ := os.Pipe()
|
||||
os.Stderr = wPipe
|
||||
|
||||
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
|
||||
|
||||
wPipe.Close()
|
||||
os.Stderr = old
|
||||
|
||||
rawBytes, _ := io.ReadAll(r)
|
||||
output := string(rawBytes)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("--verbose: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
checks := []string{
|
||||
"▸ configuration",
|
||||
"git.tag_prefix",
|
||||
"[default]",
|
||||
"▸ branch",
|
||||
"release/1.2",
|
||||
"major=1, minor=2",
|
||||
"▸ commits",
|
||||
"feat: add new thing",
|
||||
"patch bump",
|
||||
"▸ version",
|
||||
}
|
||||
for _, want := range checks {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReset = "\033[0m"
|
||||
ansiBold = "\033[1m"
|
||||
ansiDim = "\033[2m"
|
||||
ansiRed = "\033[31m"
|
||||
ansiGreen = "\033[32m"
|
||||
ansiYellow = "\033[33m"
|
||||
ansiCyan = "\033[36m"
|
||||
)
|
||||
|
||||
var useColor bool
|
||||
|
||||
func init() {
|
||||
fi, err := os.Stderr.Stat()
|
||||
tty := err == nil && (fi.Mode()&os.ModeCharDevice) != 0
|
||||
useColor = tty && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
|
||||
}
|
||||
|
||||
func paint(code, s string) string {
|
||||
if !useColor {
|
||||
return s
|
||||
}
|
||||
return code + s + ansiReset
|
||||
}
|
||||
|
||||
// logStep writes a neutral progress line to stderr.
|
||||
func logStep(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiDim, "·"), msg)
|
||||
}
|
||||
|
||||
// logDone writes a success completion line to stderr.
|
||||
func logDone(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiGreen+ansiBold, "✓"), msg)
|
||||
}
|
||||
|
||||
// logWarn writes a warning line to stderr.
|
||||
func logWarn(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiYellow, "!"), msg)
|
||||
}
|
||||
|
||||
// logHeader prints the tool name and version banner to stderr.
|
||||
func logHeader(ver string) {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n",
|
||||
paint(ansiBold, "releaser"),
|
||||
paint(ansiDim, "v"+ver))
|
||||
}
|
||||
|
||||
// logSection writes a bold section header to stderr (used in verbose mode).
|
||||
func logSection(title string) {
|
||||
fmt.Fprintf(os.Stderr, "\n%s\n", paint(ansiBold, "▸ "+title))
|
||||
}
|
||||
|
||||
// fmtSource returns a colored "[source]" tag for a config key source.
|
||||
func fmtSource(src string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "env:"):
|
||||
return paint(ansiCyan, "["+src+"]")
|
||||
case strings.HasPrefix(src, "flag:"):
|
||||
return paint(ansiGreen, "["+src+"]")
|
||||
case src == "default":
|
||||
return paint(ansiDim, "[default]")
|
||||
default: // "config file"
|
||||
return paint(ansiBold, "["+src+"]")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package changelog
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
|
||||
|
||||
// Update inserts a new release section into the CHANGELOG file at path.
|
||||
// If the file does not exist it is created with a standard header.
|
||||
// Only commits with a releasable type (fix, feat, breaking) produce bullets;
|
||||
// if none are found the file is left untouched.
|
||||
func Update(path, tag, version string, messages []string) error {
|
||||
section := buildSection(version, messages)
|
||||
if section == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing := ""
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
existing = string(data)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var out string
|
||||
if existing == "" {
|
||||
out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" +
|
||||
section + "\n"
|
||||
} else {
|
||||
// Insert above the first ## [ heading so newest release is always at top.
|
||||
if idx := strings.Index(existing, "\n## ["); idx >= 0 {
|
||||
out = existing[:idx+1] + section + "\n\n" + existing[idx+1:]
|
||||
} else {
|
||||
out = strings.TrimRight(existing, "\n") + "\n\n" + section + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(path, []byte(out), 0644)
|
||||
}
|
||||
|
||||
func buildSection(version 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)
|
||||
}
|
||||
}
|
||||
|
||||
if len(breaking)+len(feats)+len(fixes) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
date := time.Now().Format("2006-01-02")
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "## [%s] - %s\n", version, date)
|
||||
writeSection(&sb, "Breaking Changes", breaking)
|
||||
writeSection(&sb, "Added", feats)
|
||||
writeSection(&sb, "Fixed", 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)
|
||||
}
|
||||
}
|
||||
|
||||
func extractSubject(header string) string {
|
||||
m := headerSubjectRe.FindStringSubmatch(header)
|
||||
if m != nil {
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
return strings.TrimSpace(header)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package changelog
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateNewFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
if err := Update(path, "v1.2.0", "1.2.0", []string{
|
||||
"feat: add widget",
|
||||
"fix: off-by-one in parser",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
s := string(data)
|
||||
if !strings.Contains(s, "## [1.2.0]") {
|
||||
t.Error("expected version header")
|
||||
}
|
||||
if !strings.Contains(s, "### Added") {
|
||||
t.Error("expected Added section")
|
||||
}
|
||||
if !strings.Contains(s, "### Fixed") {
|
||||
t.Error("expected Fixed section")
|
||||
}
|
||||
if !strings.Contains(s, "add widget") {
|
||||
t.Error("expected feat subject")
|
||||
}
|
||||
if !strings.Contains(s, "off-by-one in parser") {
|
||||
t.Error("expected fix subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
// Seed with an older release.
|
||||
os.WriteFile(path, []byte("# Changelog\n\n## [1.1.0] - 2026-01-01\n\n### Added\n- old stuff\n"), 0644)
|
||||
|
||||
if err := Update(path, "v1.2.0", "1.2.0", []string{"feat: new thing"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
s := string(data)
|
||||
|
||||
newIdx := strings.Index(s, "## [1.2.0]")
|
||||
oldIdx := strings.Index(s, "## [1.1.0]")
|
||||
if newIdx < 0 || oldIdx < 0 {
|
||||
t.Fatal("both versions should appear in CHANGELOG")
|
||||
}
|
||||
if newIdx > oldIdx {
|
||||
t.Error("new version should appear before old version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoReleasableCommits(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
if err := Update(path, "v1.0.1", "1.0.1", []string{
|
||||
"chore: update deps",
|
||||
"docs: fix typo",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// File should NOT have been created.
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
t.Error("file should not be created when there are no releasable commits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBreakingSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
if err := Update(path, "v2.0.0", "2.0.0", []string{
|
||||
"feat!: redesign API",
|
||||
"fix(core): nil panic",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
s := string(data)
|
||||
if !strings.Contains(s, "### Breaking Changes") {
|
||||
t.Error("expected Breaking Changes section")
|
||||
}
|
||||
if !strings.Contains(s, "redesign API") {
|
||||
t.Error("expected breaking subject")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReadError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a directory where the file should be — ReadFile will error.
|
||||
os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755)
|
||||
|
||||
err := Update(filepath.Join(dir, "CHANGELOG.md"), "v1.0.0", "1.0.0", []string{"fix: something"})
|
||||
if err == nil {
|
||||
t.Error("expected error when path is a directory")
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type GitLabConfig struct {
|
||||
func defaults() Config {
|
||||
return Config{
|
||||
Git: GitConfig{
|
||||
TagPrefix: "v",
|
||||
TagPrefix: "",
|
||||
BranchPattern: branch.DefaultBranchPattern,
|
||||
CommitMessage: "chore(release): {version} [skip ci]",
|
||||
},
|
||||
@@ -50,42 +50,123 @@ func defaults() Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Sources records where each config value came from.
|
||||
// Keys are "section.field" (e.g. "git.tag_prefix").
|
||||
// Values are one of: "default", "config file", "env: VARNAME", "flag: --flag-name".
|
||||
type Sources map[string]string
|
||||
|
||||
func defaultSources() Sources {
|
||||
return Sources{
|
||||
"git.tag_prefix": "default",
|
||||
"git.branch_pattern": "default",
|
||||
"git.commit_message": "default",
|
||||
"git.author_name": "default",
|
||||
"git.author_email": "default",
|
||||
"maven.pom_path": "default",
|
||||
"gitlab.url": "default",
|
||||
"gitlab.token": "default",
|
||||
"gitlab.project": "default",
|
||||
}
|
||||
}
|
||||
|
||||
// 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, _, err := LoadWithSources(dir)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
// LoadWithSources is like Load but also returns a Sources map recording where each
|
||||
// value came from ("default" or "config file").
|
||||
func LoadWithSources(dir string) (Config, Sources, error) {
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, filename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return cfg, nil
|
||||
return cfg, src, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read %s: %w", filename, err)
|
||||
return cfg, src, fmt.Errorf("read %s: %w", filename, err)
|
||||
}
|
||||
|
||||
// Unmarshal into cfg (merges over defaults).
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("parse %s: %w", filename, err)
|
||||
return cfg, src, fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
// Detect which fields the file explicitly set by unmarshaling into a zero overlay.
|
||||
var overlay Config
|
||||
_ = yaml.Unmarshal(data, &overlay)
|
||||
if overlay.Git.TagPrefix != "" {
|
||||
src["git.tag_prefix"] = "config file"
|
||||
}
|
||||
if overlay.Git.BranchPattern != "" {
|
||||
src["git.branch_pattern"] = "config file"
|
||||
}
|
||||
if overlay.Git.CommitMessage != "" {
|
||||
src["git.commit_message"] = "config file"
|
||||
}
|
||||
if overlay.Git.AuthorName != "" {
|
||||
src["git.author_name"] = "config file"
|
||||
}
|
||||
if overlay.Git.AuthorEmail != "" {
|
||||
src["git.author_email"] = "config file"
|
||||
}
|
||||
if overlay.Maven.PomPath != "" {
|
||||
src["maven.pom_path"] = "config file"
|
||||
}
|
||||
if overlay.GitLab.URL != "" {
|
||||
src["gitlab.url"] = "config file"
|
||||
}
|
||||
if overlay.GitLab.Token != "" {
|
||||
src["gitlab.token"] = "config file"
|
||||
}
|
||||
if overlay.GitLab.Project != "" {
|
||||
src["gitlab.project"] = "config file"
|
||||
}
|
||||
|
||||
return cfg, src, 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() {
|
||||
c.ApplyEnvWithSources(nil)
|
||||
}
|
||||
|
||||
// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each
|
||||
// field it fills. src may be nil.
|
||||
func (c *Config) ApplyEnvWithSources(src Sources) {
|
||||
if c.GitLab.Token == "" {
|
||||
c.GitLab.Token = os.Getenv("GITLAB_TOKEN")
|
||||
if v := os.Getenv("GITLAB_TOKEN"); v != "" {
|
||||
c.GitLab.Token = v
|
||||
if src != nil {
|
||||
src["gitlab.token"] = "env: 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 v := os.Getenv("CI_SERVER_URL"); v != "" {
|
||||
c.GitLab.URL = v
|
||||
if src != nil {
|
||||
src["gitlab.url"] = "env: 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")
|
||||
if src != nil {
|
||||
src["gitlab.project"] = "env: CI_PROJECT_ID"
|
||||
}
|
||||
} else if p := os.Getenv("CI_PROJECT_PATH"); p != "" {
|
||||
c.GitLab.Project = p
|
||||
if src != nil {
|
||||
src["gitlab.project"] = "env: CI_PROJECT_PATH"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Git.TagPrefix != "v" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v")
|
||||
if cfg.Git.TagPrefix != "" {
|
||||
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
|
||||
}
|
||||
if cfg.Maven.PomPath != "pom.xml" {
|
||||
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
|
||||
|
||||
+43
-11
@@ -3,6 +3,8 @@ package gitutil
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
@@ -194,15 +196,17 @@ func AuthorFromConfig(repo *gogit.Repository) (name, email string) {
|
||||
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) {
|
||||
// CommitFiles stages all filePaths (relative to worktree root) and creates a commit.
|
||||
func CommitFiles(repo *gogit.Repository, filePaths []string, 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)
|
||||
for _, p := range filePaths {
|
||||
if _, err := w.Add(p); err != nil {
|
||||
return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
hash, err := w.Commit(message, &gogit.CommitOptions{
|
||||
@@ -218,6 +222,11 @@ func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEma
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// CommitFile stages a single file and creates a commit.
|
||||
func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) {
|
||||
return CommitFiles(repo, []string{filePath}, message, authorName, authorEmail)
|
||||
}
|
||||
|
||||
// CreateTag creates a lightweight tag on HEAD.
|
||||
func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||
head, err := repo.Head()
|
||||
@@ -231,9 +240,17 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
|
||||
// When token is empty, the system git binary is invoked so that credential helpers,
|
||||
// SSH agents, and netrc are all available as they would be for a regular git push.
|
||||
func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||
if token != "" {
|
||||
return pushWithGoGit(repo, branchName, tagName, token)
|
||||
}
|
||||
return pushWithCLI(repo, branchName, tagName)
|
||||
}
|
||||
|
||||
func pushWithGoGit(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)
|
||||
@@ -244,13 +261,10 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||
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{
|
||||
Auth: &githttp.BasicAuth{
|
||||
Username: "oauth2",
|
||||
Password: token,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
|
||||
@@ -259,6 +273,24 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
|
||||
wt, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get worktree: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "-C", wt.Filesystem.Root(), "push", "origin",
|
||||
fmt.Sprintf("HEAD:refs/heads/%s", branchName),
|
||||
fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName),
|
||||
)
|
||||
cmd.Stdout = os.Stderr // git push status goes to stderr conventionally
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user