feat(releaser): release v1.4.0 — GitHub, SSH agent, configurable bumps

- GitHub release support: internal/ghclient (no SDK, minimal HTTP client);
  GITHUB_TOKEN env var; github.token/github.repo config; GitHub takes
  precedence over GitLab when both are configured; publisher interface
  (releasePublisher) in cmd/main.go makes providers interchangeable
- SSH agent push: gitutil.Push() now tries gitssh.NewSSHAgentAuth for
  git@/ssh:// remotes before falling back to the system git binary
- --release-env-file flag: override dotenv artifact path; pass "" to disable
- git.releasable_types config: filter which commit types trigger a bump
  (defaults to fix, feat, breaking); useful for maintenance branches
- commits.Group(), ExtractSubject(), ReleasableSet() exported helpers:
  notes.go and changelog.go now delegate to these instead of duplicating
- CHANGELOG deduplication guard: changelog.Update() is idempotent — skips
  write if ## [version] section already exists
- Always load config sources: LoadWithSources() called unconditionally;
  removes the dual config path that previously only tracked sources in
  --verbose mode
- .releaser.gitlab-ci.yml: adds artifacts: reports: dotenv: release.env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:15:09 +02:00
parent 62a702fb89
commit 6fa745a6bd
15 changed files with 428 additions and 175 deletions
+4
View File
@@ -45,5 +45,9 @@
--branch "$CI_COMMIT_BRANCH" --branch "$CI_COMMIT_BRANCH"
$RELEASER_EXTRA_ARGS $RELEASER_EXTRA_ARGS
artifacts:
reports:
dotenv: release.env # exposes NEXT_VERSION to downstream jobs
environment: environment:
name: release/$CI_COMMIT_BRANCH name: release/$CI_COMMIT_BRANCH
+19
View File
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.4.0] - 2026-07-11
### Added
- **GitHub release support** — new `internal/ghclient` package (minimal HTTP client, no SDK); configured via `github.token` + `github.repo` in `.releaser.yml` or `GITHUB_TOKEN` env var; GitHub takes precedence over GitLab when both are configured
- **SSH agent push** — `gitutil.Push()` now attempts go-git SSH agent auth (`gitssh.NewSSHAgentAuth`) for `git@` / `ssh://` remotes before falling back to the system `git` binary; no extra configuration needed
- **`--release-env-file` flag** — override the dotenv artifact path (relative to repo root; default `release.env`); pass `""` to disable writing the file entirely (e.g. for local runs)
- **`git.releasable_types` config** — opt-in list of commit types that count as releasable (`fix`, `feat`, `breaking`); defaults to all three; useful for maintenance branches where some types should not trigger a release
- **`commits.Group()`, `ExtractSubject()`, `ReleasableSet()`** — exported helpers in `internal/commits`; shared by `notes` and `changelog`, eliminating duplicated grouping and subject-extraction logic
- **CHANGELOG deduplication guard** — `changelog.Update()` is now idempotent; skips the write if a `## [version]` section already exists, preventing duplicate entries on CI reruns
- **Publisher interface** — `releasePublisher` interface + `buildPublisher()` in `cmd/main.go`; GitLab and GitHub are now interchangeable backends; new providers can be added without touching the orchestration logic
- **`artifacts: reports: dotenv: release.env`** in `.releaser.gitlab-ci.yml` — exposes `NEXT_VERSION` to downstream GitLab CI jobs out of the box
### Changed
- **Always load config sources** — `LoadWithSources()` is now called unconditionally instead of only in `--verbose` mode; single code path, no behavioural difference
- **`version.Next()` signature** — accepts a `map[commits.Type]bool` releasable set as a fifth parameter; `nil` defaults to all three types (no change to existing behaviour)
- **Verbose config table** — now includes `git.releasable_types`, `github.token`, and `github.repo` rows
## [1.3.0] - 2026-07-07 ## [1.3.0] - 2026-07-07
### Added ### Added
+29 -14
View File
@@ -1,6 +1,6 @@
# releaser # releaser
![release](https://img.shields.io/badge/release-v1.3.0-blue.svg) ![release](https://img.shields.io/badge/release-v1.4.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -8,22 +8,22 @@ A CI-friendly release automation tool for GitFlow workflows using Conventional C
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. 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. `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/GitHub tag+release creation.
## How it works ## How it works
``` ```
release/1.2 branch release/1.2 branch
└─ last tag: v1.2.3 (or none → start at v1.2.0) └─ last tag: 1.2.3 (or none → start at 1.2.0)
└─ commits since tag → Conventional Commits analysis └─ commits since tag → Conventional Commits analysis
└─ next version: v1.2.4 └─ next version: 1.2.4
``` ```
1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2``1.2`) 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 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 3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (the minor is owned by the branch) 4. **Version bump** — increments patch (the minor is owned by the branch)
5. **Release** — updates `pom.xml`, commits, tags, creates GitLab release 5. **Release** — updates `pom.xml`, commits, tags, creates GitLab or GitHub release
## Version bump rules ## Version bump rules
@@ -44,13 +44,13 @@ releaser --init
# Simulate next version (no side effects) # Simulate next version (no side effects)
releaser --dry-run releaser --dry-run
# Full release: update pom.xml + CHANGELOG.md, commit, tag, push, GitLab release # Full release: update pom.xml + CHANGELOG.md, commit, tag, push, create release
releaser releaser
# Commit and tag locally — skip push and GitLab release # Commit and tag locally — skip push and release creation
releaser --no-push releaser --no-push
# Push commit and tag but skip creating the GitLab release # Push commit and tag but skip creating the release
releaser --no-release releaser --no-release
# Update files but stop before committing (review first) # Update files but stop before committing (review first)
@@ -75,6 +75,9 @@ releaser --tag-prefix ""
# Override branch pattern (e.g. also match hotfix/ branches) # Override branch pattern (e.g. also match hotfix/ branches)
releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$" releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
# Write dotenv artifact to a custom path (or "" to disable)
releaser --release-env-file deploy/version.env
``` ```
## Configuration ## Configuration
@@ -88,6 +91,10 @@ git:
commit_message: "chore(release): {version} [skip ci]" commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name author_name: "" # defaults to git config user.name
author_email: "" # defaults to git config user.email author_email: "" # defaults to git config user.email
releasable_types: # default: all three
- fix
- feat
- breaking
maven: maven:
pom_path: "pom.xml" # relative to repo root pom_path: "pom.xml" # relative to repo root
@@ -96,18 +103,23 @@ gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # env GITLAB_TOKEN (never commit this) token: "" # env GITLAB_TOKEN (never commit this)
project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH
github:
token: "" # env GITHUB_TOKEN (never commit this)
repo: "" # "owner/repo" format
``` ```
### Environment variables ### Environment variables
GitLab-related fields are automatically read from the CI environment if not set in the config file:
| Variable | Used for | | Variable | Used for |
|-------------------|-----------------------------------| |--------------------|-----------------------------------|
| `GITLAB_TOKEN` | API auth + HTTPS push auth | | `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth |
| `CI_SERVER_URL` | GitLab instance URL | | `CI_SERVER_URL` | GitLab instance URL |
| `CI_PROJECT_ID` | Project identifier (numeric) | | `CI_PROJECT_ID` | GitLab project identifier (numeric) |
| `CI_PROJECT_PATH` | Project identifier (fallback) | | `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
| `GITHUB_TOKEN` | GitHub API auth |
When both `github.*` and `gitlab.*` are configured, GitHub takes precedence.
## CI integration (GitLab CI example) ## CI integration (GitLab CI example)
@@ -121,4 +133,7 @@ release:
GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope
script: script:
- releaser - releaser
artifacts:
reports:
dotenv: release.env # exposes NEXT_VERSION to downstream jobs
``` ```
+3 -1
View File
@@ -71,11 +71,13 @@
- [x] ~~Name and version header on every run~~ — ✓ shipped v1.2.0 - [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) - [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)
- [x] ~~`release.env` dotenv artifact~~ — ✓ shipped v1.3.0 (`NEXT_VERSION=<tag>` written on every release for GitLab CI downstream jobs) - [x] ~~`release.env` dotenv artifact~~ — ✓ shipped v1.3.0 (`NEXT_VERSION=<tag>` written on every release for GitLab CI downstream jobs)
- [x] ~~GitHub release support~~ — ✓ shipped v1.4.0 (`internal/ghclient`, `GITHUB_TOKEN` env, `github.token`/`github.repo` config; GitHub takes precedence over GitLab)
- [x] ~~SSH agent push~~ — ✓ shipped v1.4.0 (go-git `gitssh.NewSSHAgentAuth` for `git@`/`ssh://` remotes)
- [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release)
- [ ] Documentation site - [ ] Documentation site
## Future / backlog ## Future / backlog
- GitHub release support (parity with GitLab)
- Multi-module Maven support (multiple `pom.xml` paths) - Multi-module Maven support (multiple `pom.xml` paths)
- Gradle support (`build.gradle` / `build.gradle.kts`) - Gradle support (`build.gradle` / `build.gradle.kts`)
- `package.json` version bump support (Node.js projects) - `package.json` version bump support (Node.js projects)
+75 -34
View File
@@ -15,8 +15,9 @@ import (
"git.k3nny.fr/releaser/internal/changelog" "git.k3nny.fr/releaser/internal/changelog"
"git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config" "git.k3nny.fr/releaser/internal/config"
"git.k3nny.fr/releaser/internal/glclient" "git.k3nny.fr/releaser/internal/ghclient"
"git.k3nny.fr/releaser/internal/gitutil" "git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/maven" "git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/notes" "git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version" semver "git.k3nny.fr/releaser/internal/version"
@@ -43,6 +44,12 @@ git:
# author_name: "" # author_name: ""
# author_email: "" # author_email: ""
# Limit which commit types trigger a release (default: fix, feat, breaking).
# releasable_types:
# - fix
# - feat
# - breaking
maven: maven:
# Path to pom.xml, relative to the repository root. # Path to pom.xml, relative to the repository root.
# pom_path: "pom.xml" # pom_path: "pom.xml"
@@ -59,6 +66,14 @@ gitlab:
# Numeric project ID or "namespace/project" path. # Numeric project ID or "namespace/project" path.
# Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables.
# project: "" # project: ""
github:
# GitHub personal access token with repo scope.
# Falls back to the GITHUB_TOKEN environment variable.
# token: ""
# Repository in "owner/repo" format.
# repo: ""
` `
var ( var (
@@ -69,6 +84,27 @@ var (
// exitFn is a variable so tests can intercept os.Exit calls. // exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os.Exit var exitFn = os.Exit
// releasePublisher is implemented by both glclient and ghclient.
type releasePublisher interface {
CreateRelease(ctx context.Context, tagName, body string) error
}
// buildPublisher selects and returns the active release publisher based on config.
// GitHub takes precedence over GitLab when both are configured.
// Returns (nil, nil) when no provider is configured — caller should skip release creation.
func buildPublisher(cfg config.Config) (releasePublisher, error) {
if cfg.GitHub.Token != "" && cfg.GitHub.Repo != "" {
return ghclient.New(cfg.GitHub.Token, cfg.GitHub.Repo), nil
}
if cfg.GitLab.URL != "" && cfg.GitLab.Project != "" {
if cfg.GitLab.Token == "" {
return nil, fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
}
return glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project), nil
}
return nil, nil
}
func newRootCmd() *cobra.Command { func newRootCmd() *cobra.Command {
var ( var (
init_ bool init_ bool
@@ -86,6 +122,7 @@ func newRootCmd() *cobra.Command {
tagPrefixSet bool tagPrefixSet bool
patternFlag string patternFlag string
patternSet bool patternSet bool
releaseEnvFile string
) )
root := &cobra.Command{ root := &cobra.Command{
@@ -112,6 +149,7 @@ func newRootCmd() *cobra.Command {
noRelease: noRelease, noRelease: noRelease,
noCommit: noCommit, noCommit: noCommit,
tagOnly: tagOnly, tagOnly: tagOnly,
releaseEnvFile: releaseEnvFile,
}) })
}, },
} }
@@ -119,8 +157,8 @@ func newRootCmd() *cobra.Command {
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") 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(&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(&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(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a release")
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release") root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the release")
root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push") 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().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(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
@@ -129,6 +167,7 @@ func newRootCmd() *cobra.Command {
root.Flags().StringVar(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root") 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(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
root.Flags().StringVar(&releaseEnvFile, "release-env-file", "release.env", "write NEXT_VERSION dotenv to this path (relative to repo root; empty to disable)")
return root return root
} }
@@ -159,6 +198,7 @@ type options struct {
noRelease bool noRelease bool
noCommit bool noCommit bool
tagOnly bool tagOnly bool
releaseEnvFile string
} }
func printVerboseConfig(cfg config.Config, src config.Sources) { func printVerboseConfig(cfg config.Config, src config.Sources) {
@@ -169,6 +209,12 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
{"git.commit_message", cfg.Git.CommitMessage}, {"git.commit_message", cfg.Git.CommitMessage},
{"git.author_name", cfg.Git.AuthorName}, {"git.author_name", cfg.Git.AuthorName},
{"git.author_email", cfg.Git.AuthorEmail}, {"git.author_email", cfg.Git.AuthorEmail},
{"git.releasable_types", func() string {
if len(cfg.Git.ReleasableTypes) == 0 {
return "(all)"
}
return strings.Join(cfg.Git.ReleasableTypes, ", ")
}()},
{"maven.pom_path", cfg.Maven.PomPath}, {"maven.pom_path", cfg.Maven.PomPath},
{"gitlab.url", cfg.GitLab.URL}, {"gitlab.url", cfg.GitLab.URL},
{"gitlab.token", func() string { {"gitlab.token", func() string {
@@ -178,6 +224,13 @@ func printVerboseConfig(cfg config.Config, src config.Sources) {
return "(not set)" return "(not set)"
}()}, }()},
{"gitlab.project", cfg.GitLab.Project}, {"gitlab.project", cfg.GitLab.Project},
{"github.token", func() string {
if cfg.GitHub.Token != "" {
return "(set)"
}
return "(not set)"
}()},
{"github.repo", cfg.GitHub.Repo},
} }
for _, r := range rows { for _, r := range rows {
source := src[r.key] source := src[r.key]
@@ -220,15 +273,7 @@ func run(o options) error {
return initConfig(absRepo) return initConfig(absRepo)
} }
var ( cfg, src, err := config.LoadWithSources(absRepo)
cfg config.Config
src config.Sources
)
if o.verbose {
cfg, src, err = config.LoadWithSources(absRepo)
} else {
cfg, err = config.Load(absRepo)
}
if err != nil { if err != nil {
return err return err
} }
@@ -237,22 +282,16 @@ func run(o options) error {
// CLI flags take precedence over config file and env vars // CLI flags take precedence over config file and env vars
if o.tagPrefixSet { if o.tagPrefixSet {
cfg.Git.TagPrefix = o.tagPrefixFlag cfg.Git.TagPrefix = o.tagPrefixFlag
if src != nil {
src["git.tag_prefix"] = "flag: --tag-prefix" src["git.tag_prefix"] = "flag: --tag-prefix"
} }
}
if o.pomOverride != "" { if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride cfg.Maven.PomPath = o.pomOverride
if src != nil {
src["maven.pom_path"] = "flag: --pom" src["maven.pom_path"] = "flag: --pom"
} }
}
if o.patternSet { if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag cfg.Git.BranchPattern = o.patternFlag
if src != nil {
src["git.branch_pattern"] = "flag: --branch-pattern" src["git.branch_pattern"] = "flag: --branch-pattern"
} }
}
if o.verbose { if o.verbose {
printVerboseConfig(cfg, src) printVerboseConfig(cfg, src)
@@ -358,7 +397,8 @@ func run(o options) error {
} }
} }
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types) releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes)
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable)
if !ok { if !ok {
logWarn("no releasable commits found") logWarn("no releasable commits found")
return errNothingToRelease return errNothingToRelease
@@ -388,11 +428,13 @@ func run(o options) error {
} }
// --- release.env (GitLab CI dotenv artifact) --- // --- release.env (GitLab CI dotenv artifact) ---
releaseEnvPath := filepath.Join(absRepo, "release.env") if o.releaseEnvFile != "" {
releaseEnvPath := filepath.Join(absRepo, o.releaseEnvFile)
if err := os.WriteFile(releaseEnvPath, []byte("NEXT_VERSION="+nextTag+"\n"), 0644); err != nil { if err := os.WriteFile(releaseEnvPath, []byte("NEXT_VERSION="+nextTag+"\n"), 0644); err != nil {
return fmt.Errorf("write release.env: %w", err) return fmt.Errorf("write %s: %w", o.releaseEnvFile, err)
}
logDone("%s: NEXT_VERSION=%s", o.releaseEnvFile, nextTag)
} }
logDone("release.env: NEXT_VERSION=%s", nextTag)
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) --- // --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
if !o.tagOnly { if !o.tagOnly {
@@ -465,28 +507,27 @@ func run(o options) error {
} }
logDone("pushed") logDone("pushed")
// --- GitLab release ---
if o.noRelease { if o.noRelease {
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
logWarn("GitLab URL or project not configured — skipping release creation") // --- Release creation ---
publisher, err := buildPublisher(cfg)
if err != nil {
return err
}
if publisher == nil {
logWarn("no release provider configured — skipping release creation")
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
if cfg.GitLab.Token == "" {
return fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
}
releaseNotes := notes.Generate(nextTag, messages) releaseNotes := notes.Generate(nextTag, messages)
gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project) if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create release: %w", err)
if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create GitLab release: %w", err)
} }
logDone("release created: %s", nextTag)
logDone("GitLab release created: %s", nextTag)
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
+6 -31
View File
@@ -4,19 +4,17 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
"git.k3nny.fr/releaser/internal/commits" "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. // 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. // If the file does not exist it is created with a standard header.
// Only commits with a releasable type (fix, feat, breaking) produce bullets; // Only commits with a releasable type (fix, feat, breaking) produce bullets;
// if none are found the file is left untouched. // if none are found the file is left untouched.
// If a section for version already exists the file is left untouched (idempotent).
func Update(path, tag, version string, messages []string) error { func Update(path, tag, version string, messages []string) error {
section := buildSection(version, messages) section := buildSection(version, messages)
if section == "" { if section == "" {
@@ -31,6 +29,10 @@ func Update(path, tag, version string, messages []string) error {
return fmt.Errorf("read %s: %w", path, err) return fmt.Errorf("read %s: %w", path, err)
} }
if strings.Contains(existing, "## ["+version+"]") {
return nil
}
var out string var out string
if existing == "" { if existing == "" {
out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" +
@@ -48,26 +50,7 @@ func Update(path, tag, version string, messages []string) error {
} }
func buildSection(version string, messages []string) string { func buildSection(version string, messages []string) string {
var breaking, feats, fixes []string breaking, feats, fixes := commits.Group(messages)
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 { if len(breaking)+len(feats)+len(fixes) == 0 {
return "" return ""
} }
@@ -91,11 +74,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
fmt.Fprintf(sb, "- %s\n", item) 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)
}
+56
View File
@@ -5,6 +5,62 @@ import (
"strings" "strings"
) )
// headerSubjectRe captures the subject (description) from a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// ExtractSubject returns the description part of a conventional commit header.
// Falls back to the trimmed 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)
}
// Group splits messages into breaking changes, features, and fixes.
// Only the first line of each message is considered; the subject is extracted.
// Messages with TypeNone are silently dropped.
func Group(messages []string) (breaking, feats, fixes []string) {
for _, msg := range messages {
t := Parse(msg)
if t == TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := ExtractSubject(first)
switch t {
case TypeBreaking:
breaking = append(breaking, subject)
case TypeFeat:
feats = append(feats, subject)
case TypeFix:
fixes = append(fixes, subject)
}
}
return
}
// ReleasableSet converts a slice of type-name strings to a set for use in version.Next.
// An empty or nil slice defaults to all three releasable types (fix, feat, breaking).
func ReleasableSet(typeNames []string) map[Type]bool {
if len(typeNames) == 0 {
return map[Type]bool{TypeFix: true, TypeFeat: true, TypeBreaking: true}
}
m := make(map[Type]bool, len(typeNames))
for _, name := range typeNames {
switch strings.ToLower(name) {
case "fix":
m[TypeFix] = true
case "feat":
m[TypeFeat] = true
case "breaking":
m[TypeBreaking] = true
}
}
return m
}
// Type represents the semantic weight of a commit for versioning purposes. // Type represents the semantic weight of a commit for versioning purposes.
type Type int type Type int
+50
View File
@@ -35,6 +35,56 @@ func FuzzParse(f *testing.F) {
}) })
} }
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)
}
}
}
func TestGroup(t *testing.T) {
messages := []string{
"feat: add login",
"fix: patch null pointer",
"feat!: remove legacy API",
"chore: update deps",
"fix: handle empty response",
}
breaking, feats, fixes := Group(messages)
if len(breaking) != 1 || breaking[0] != "remove legacy API" {
t.Errorf("breaking = %v, want [remove legacy API]", breaking)
}
if len(feats) != 1 || feats[0] != "add login" {
t.Errorf("feats = %v, want [add login]", feats)
}
if len(fixes) != 2 {
t.Errorf("fixes = %v, want 2 items", fixes)
}
}
func TestReleasableSet(t *testing.T) {
all := ReleasableSet(nil)
if !all[TypeFix] || !all[TypeFeat] || !all[TypeBreaking] {
t.Error("nil input should return all three types")
}
only := ReleasableSet([]string{"fix"})
if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] {
t.Errorf("fix-only set: %v", only)
}
}
func TestTypeString(t *testing.T) { func TestTypeString(t *testing.T) {
cases := []struct { cases := []struct {
t Type t Type
+28 -3
View File
@@ -17,6 +17,7 @@ type Config struct {
Git GitConfig `yaml:"git"` Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"` Maven MavenConfig `yaml:"maven"`
GitLab GitLabConfig `yaml:"gitlab"` GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"`
} }
type GitConfig struct { type GitConfig struct {
@@ -25,6 +26,7 @@ type GitConfig struct {
CommitMessage string `yaml:"commit_message"` CommitMessage string `yaml:"commit_message"`
AuthorName string `yaml:"author_name"` AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"` AuthorEmail string `yaml:"author_email"`
ReleasableTypes []string `yaml:"releasable_types"`
} }
type MavenConfig struct { type MavenConfig struct {
@@ -37,6 +39,11 @@ type GitLabConfig struct {
Project string `yaml:"project"` Project string `yaml:"project"`
} }
type GitHubConfig struct {
Token string `yaml:"token"`
Repo string `yaml:"repo"` // "owner/repo"
}
func defaults() Config { func defaults() Config {
return Config{ return Config{
Git: GitConfig{ Git: GitConfig{
@@ -62,10 +69,13 @@ func defaultSources() Sources {
"git.commit_message": "default", "git.commit_message": "default",
"git.author_name": "default", "git.author_name": "default",
"git.author_email": "default", "git.author_email": "default",
"git.releasable_types": "default",
"maven.pom_path": "default", "maven.pom_path": "default",
"gitlab.url": "default", "gitlab.url": "default",
"gitlab.token": "default", "gitlab.token": "default",
"gitlab.project": "default", "gitlab.project": "default",
"github.token": "default",
"github.repo": "default",
} }
} }
@@ -113,6 +123,9 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if overlay.Git.AuthorEmail != "" { if overlay.Git.AuthorEmail != "" {
src["git.author_email"] = "config file" src["git.author_email"] = "config file"
} }
if len(overlay.Git.ReleasableTypes) > 0 {
src["git.releasable_types"] = "config file"
}
if overlay.Maven.PomPath != "" { if overlay.Maven.PomPath != "" {
src["maven.pom_path"] = "config file" src["maven.pom_path"] = "config file"
} }
@@ -125,11 +138,17 @@ func LoadWithSources(dir string) (Config, Sources, error) {
if overlay.GitLab.Project != "" { if overlay.GitLab.Project != "" {
src["gitlab.project"] = "config file" src["gitlab.project"] = "config file"
} }
if overlay.GitHub.Token != "" {
src["github.token"] = "config file"
}
if overlay.GitHub.Repo != "" {
src["github.repo"] = "config file"
}
return cfg, src, nil return cfg, src, nil
} }
// ApplyEnv fills empty GitLab fields from the standard GitLab CI environment variables. // ApplyEnv fills empty GitLab and GitHub fields from environment variables.
// Values already set in the config file are never overwritten. // Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() { func (c *Config) ApplyEnv() {
c.ApplyEnvWithSources(nil) c.ApplyEnvWithSources(nil)
@@ -147,7 +166,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
} }
} }
if c.GitLab.URL == "" { if c.GitLab.URL == "" {
// CI_SERVER_URL is the cleanest source ("https://gitlab.example.com")
if v := os.Getenv("CI_SERVER_URL"); v != "" { if v := os.Getenv("CI_SERVER_URL"); v != "" {
c.GitLab.URL = v c.GitLab.URL = v
if src != nil { if src != nil {
@@ -156,7 +174,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
} }
} }
if c.GitLab.Project == "" { if c.GitLab.Project == "" {
// Prefer numeric ID; fall back to namespace/project path
if id := os.Getenv("CI_PROJECT_ID"); id != "" { if id := os.Getenv("CI_PROJECT_ID"); id != "" {
c.GitLab.Project = id c.GitLab.Project = id
if src != nil { if src != nil {
@@ -169,4 +186,12 @@ func (c *Config) ApplyEnvWithSources(src Sources) {
} }
} }
} }
if c.GitHub.Token == "" {
if v := os.Getenv("GITHUB_TOKEN"); v != "" {
c.GitHub.Token = v
if src != nil {
src["github.token"] = "env: GITHUB_TOKEN"
}
}
}
} }
+73
View File
@@ -0,0 +1,73 @@
package ghclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client is a minimal GitHub API client covering only the Releases endpoint.
type Client struct {
token string
repo string // "owner/repo"
httpClient *http.Client
}
// New creates a Client. repo must be in "owner/repo" format.
func New(token, repo string) *Client {
return &Client{
token: token,
repo: repo,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type createReleaseRequest struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
// CreateRelease creates a GitHub 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, body string) error {
payload, _ := json.Marshal(createReleaseRequest{
TagName: tagName,
Name: tagName,
Body: body,
})
url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.repo)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitHub 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("GitHub API returned %d: %s", resp.StatusCode, errBody.Message)
}
return fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
return nil
}
+43 -3
View File
@@ -6,14 +6,16 @@ import (
"os" "os"
"os/exec" "os/exec"
"sort" "sort"
"strings"
"time" "time"
gogit "github.com/go-git/go-git/v5" gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config" 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"
"github.com/go-git/go-git/v5/plumbing/object" "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" "github.com/go-git/go-git/v5/plumbing/storer"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"git.k3nny.fr/releaser/internal/branch" "git.k3nny.fr/releaser/internal/branch"
) )
@@ -241,15 +243,53 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
// Push pushes the given branch and tag to the "origin" remote. // Push pushes the given branch and tag to the "origin" remote.
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI. // 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, // When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first.
// SSH agents, and netrc are all available as they would be for a regular git push. // Falls back to the system git binary so that credential helpers, netrc, and SSH keys work normally.
func Push(repo *gogit.Repository, branchName, tagName, token string) error { func Push(repo *gogit.Repository, branchName, tagName, token string) error {
if token != "" { if token != "" {
return pushWithGoGit(repo, branchName, tagName, token) return pushWithGoGit(repo, branchName, tagName, token)
} }
// Try SSH agent auth when the remote URL uses SSH transport.
if remote, err := repo.Remote("origin"); err == nil {
urls := remote.Config().URLs
if len(urls) > 0 && isSSHURL(urls[0]) {
if err := pushWithSSHAgent(repo, branchName, tagName); err == nil {
return nil
}
}
}
return pushWithCLI(repo, branchName, tagName) return pushWithCLI(repo, branchName, tagName)
} }
func isSSHURL(u string) bool {
return strings.HasPrefix(u, "git@") || strings.HasPrefix(u, "ssh://")
}
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
auth, err := gitssh.NewSSHAgentAuth("git")
if err != nil {
return err
}
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)),
},
Auth: auth,
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
return fmt.Errorf("git push via SSH agent: %w", err)
}
return nil
}
func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error { func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin") remote, err := repo.Remote("origin")
if err != nil { if err != nil {
+1 -33
View File
@@ -2,38 +2,16 @@ package notes
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"git.k3nny.fr/releaser/internal/commits" "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. // Generate produces grouped markdown release notes from a list of commit messages.
// Commits are grouped into Breaking Changes, Features, and Bug Fixes. // Commits are grouped into Breaking Changes, Features, and Bug Fixes.
// Commits with no releasable type are omitted. // Commits with no releasable type are omitted.
func Generate(tagName string, messages []string) string { func Generate(tagName string, messages []string) string {
var breaking, feats, fixes []string breaking, feats, fixes := commits.Group(messages)
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 var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n", tagName) fmt.Fprintf(&sb, "## %s\n", tagName)
@@ -53,13 +31,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
fmt.Fprintf(sb, "- %s\n", item) 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)
}
-19
View File
@@ -69,25 +69,6 @@ func TestGenerateEmpty(t *testing.T) {
} }
} }
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 // FuzzGenerate verifies that Generate never panics on arbitrary inputs and
// always includes the tag name in the output. // always includes the tag name in the output.
func FuzzGenerate(f *testing.F) { func FuzzGenerate(f *testing.F) {
+6 -2
View File
@@ -8,10 +8,14 @@ import (
// Next computes the next version string (without tag prefix, e.g. "1.2.4"). // 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). // currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// releasable is the set of commit types that trigger a bump; nil defaults to all three.
// Returns ("", false) when there are no releasable commits. // Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) { func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool) (string, bool) {
if releasable == nil {
releasable = commits.ReleasableSet(nil)
}
for _, t := range types { for _, t := range types {
if t != commits.TypeNone { if releasable[t] {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
} }
} }
+1 -1
View File
@@ -61,7 +61,7 @@ func TestNext(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.desc, func(t *testing.T) { t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types) got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil)
if ok != c.wantOk { if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk) t.Errorf("ok=%v, want %v", ok, c.wantOk)
} }