From 5af107b06d6ca562c2481ed5ea5c5982e2f83b87 Mon Sep 17 00:00:00 2001 From: k3nny Date: Sat, 11 Jul 2026 00:15:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(releaser):=20release=20v1.4.0=20=E2=80=94?= =?UTF-8?q?=20GitHub,=20SSH=20agent,=20configurable=20bumps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .releaser.gitlab-ci.yml | 4 + CHANGELOG.md | 19 ++++ README.md | 45 ++++++---- ROADMAP.md | 4 +- cmd/main.go | 147 ++++++++++++++++++++----------- internal/changelog/changelog.go | 37 ++------ internal/commits/commits.go | 56 ++++++++++++ internal/commits/commits_test.go | 50 +++++++++++ internal/config/config.go | 59 +++++++++---- internal/ghclient/ghclient.go | 73 +++++++++++++++ internal/gitutil/gitutil.go | 46 +++++++++- internal/notes/notes.go | 34 +------ internal/notes/notes_test.go | 19 ---- internal/version/version.go | 8 +- internal/version/version_test.go | 2 +- 15 files changed, 428 insertions(+), 175 deletions(-) create mode 100644 internal/ghclient/ghclient.go diff --git a/.releaser.gitlab-ci.yml b/.releaser.gitlab-ci.yml index 2b965a3..395caed 100644 --- a/.releaser.gitlab-ci.yml +++ b/.releaser.gitlab-ci.yml @@ -45,5 +45,9 @@ --branch "$CI_COMMIT_BRANCH" $RELEASER_EXTRA_ARGS + artifacts: + reports: + dotenv: release.env # exposes NEXT_VERSION to downstream jobs + environment: name: release/$CI_COMMIT_BRANCH diff --git a/CHANGELOG.md b/CHANGELOG.md index c823a4c..f6dac3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to this project will be documented in this file. 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 ### Added diff --git a/README.md b/README.md index 0b22148..29ff2bf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -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. -`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 ``` 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 - └─ 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`) 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 +5. **Release** — updates `pom.xml`, commits, tags, creates GitLab or GitHub release ## Version bump rules @@ -44,13 +44,13 @@ releaser --init # Simulate next version (no side effects) 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 -# Commit and tag locally — skip push and GitLab release +# Commit and tag locally — skip push and release creation 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 # Update files but stop before committing (review first) @@ -75,6 +75,9 @@ releaser --tag-prefix "" # Override branch pattern (e.g. also match hotfix/ branches) 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 @@ -88,6 +91,10 @@ git: commit_message: "chore(release): {version} [skip ci]" author_name: "" # defaults to git config user.name author_email: "" # defaults to git config user.email + releasable_types: # default: all three + - fix + - feat + - breaking maven: pom_path: "pom.xml" # relative to repo root @@ -96,18 +103,23 @@ 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 + +github: + token: "" # env GITHUB_TOKEN (never commit this) + repo: "" # "owner/repo" format ``` ### Environment variables -GitLab-related fields are automatically read from the CI environment if not set in the config file: +| Variable | Used for | +|--------------------|-----------------------------------| +| `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth | +| `CI_SERVER_URL` | GitLab instance URL | +| `CI_PROJECT_ID` | GitLab project identifier (numeric) | +| `CI_PROJECT_PATH` | GitLab project identifier (fallback) | +| `GITHUB_TOKEN` | GitHub API auth | -| 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) | +When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. ## CI integration (GitLab CI example) @@ -121,4 +133,7 @@ release: GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope script: - releaser + artifacts: + reports: + dotenv: release.env # exposes NEXT_VERSION to downstream jobs ``` diff --git a/ROADMAP.md b/ROADMAP.md index 18a0fe7..debeb8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -71,11 +71,13 @@ - [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] ~~`release.env` dotenv artifact~~ — ✓ shipped v1.3.0 (`NEXT_VERSION=` 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 ## 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) diff --git a/cmd/main.go b/cmd/main.go index dc517f3..d1e32e8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,8 +15,9 @@ import ( "git.k3nny.fr/releaser/internal/changelog" "git.k3nny.fr/releaser/internal/commits" "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/glclient" "git.k3nny.fr/releaser/internal/maven" "git.k3nny.fr/releaser/internal/notes" semver "git.k3nny.fr/releaser/internal/version" @@ -43,6 +44,12 @@ git: # author_name: "" # author_email: "" + # Limit which commit types trigger a release (default: fix, feat, breaking). + # releasable_types: + # - fix + # - feat + # - breaking + maven: # Path to pom.xml, relative to the repository root. # pom_path: "pom.xml" @@ -59,6 +66,14 @@ gitlab: # Numeric project ID or "namespace/project" path. # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. # 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 ( @@ -69,23 +84,45 @@ var ( // exitFn is a variable so tests can intercept os.Exit calls. 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 { 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 - patternSet bool + 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 + patternSet bool + releaseEnvFile string ) root := &cobra.Command{ @@ -112,6 +149,7 @@ func newRootCmd() *cobra.Command { noRelease: noRelease, noCommit: noCommit, 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(&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(&noRelease, "no-release", false, "push commit and tag but skip creating the 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 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)") @@ -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(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix 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 } @@ -159,6 +198,7 @@ type options struct { noRelease bool noCommit bool tagOnly bool + releaseEnvFile string } 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.author_name", cfg.Git.AuthorName}, {"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}, {"gitlab.url", cfg.GitLab.URL}, {"gitlab.token", func() string { @@ -178,6 +224,13 @@ func printVerboseConfig(cfg config.Config, src config.Sources) { return "(not set)" }()}, {"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 { source := src[r.key] @@ -220,15 +273,7 @@ func run(o options) error { 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) - } + cfg, src, err := config.LoadWithSources(absRepo) if err != nil { return err } @@ -237,21 +282,15 @@ func run(o options) error { // 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" - } + src["git.tag_prefix"] = "flag: --tag-prefix" } if 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 { 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 { @@ -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 { logWarn("no releasable commits found") return errNothingToRelease @@ -388,11 +428,13 @@ func run(o options) error { } // --- release.env (GitLab CI dotenv artifact) --- - releaseEnvPath := filepath.Join(absRepo, "release.env") - if err := os.WriteFile(releaseEnvPath, []byte("NEXT_VERSION="+nextTag+"\n"), 0644); err != nil { - return fmt.Errorf("write release.env: %w", err) + if o.releaseEnvFile != "" { + releaseEnvPath := filepath.Join(absRepo, o.releaseEnvFile) + if err := os.WriteFile(releaseEnvPath, []byte("NEXT_VERSION="+nextTag+"\n"), 0644); err != nil { + 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) --- if !o.tagOnly { @@ -465,28 +507,27 @@ func run(o options) error { } logDone("pushed") - // --- GitLab release --- if o.noRelease { fmt.Printf("released %s\n", nextTag) 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) 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) + if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { + return fmt.Errorf("create release: %w", err) } - - logDone("GitLab release created: %s", nextTag) + logDone("release created: %s", nextTag) fmt.Printf("released %s\n", nextTag) return nil } diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index c8d0dd6..bb12aa7 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -4,19 +4,17 @@ 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. +// If a section for version already exists the file is left untouched (idempotent). func Update(path, tag, version string, messages []string) error { section := buildSection(version, messages) if section == "" { @@ -31,6 +29,10 @@ func Update(path, tag, version string, messages []string) error { return fmt.Errorf("read %s: %w", path, err) } + if strings.Contains(existing, "## ["+version+"]") { + return nil + } + var out string if existing == "" { 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 { - 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) - } - } - + breaking, feats, fixes := commits.Group(messages) if len(breaking)+len(feats)+len(fixes) == 0 { return "" } @@ -91,11 +74,3 @@ func writeSection(sb *strings.Builder, title string, items []string) { 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) -} diff --git a/internal/commits/commits.go b/internal/commits/commits.go index 03441f5..31f1912 100644 --- a/internal/commits/commits.go +++ b/internal/commits/commits.go @@ -5,6 +5,62 @@ import ( "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 Type int diff --git a/internal/commits/commits_test.go b/internal/commits/commits_test.go index ad3459b..08df4cf 100644 --- a/internal/commits/commits_test.go +++ b/internal/commits/commits_test.go @@ -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) { cases := []struct { t Type diff --git a/internal/config/config.go b/internal/config/config.go index 2aa306d..550700b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,14 +17,16 @@ type Config struct { Git GitConfig `yaml:"git"` Maven MavenConfig `yaml:"maven"` GitLab GitLabConfig `yaml:"gitlab"` + GitHub GitHubConfig `yaml:"github"` } 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"` + 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"` + ReleasableTypes []string `yaml:"releasable_types"` } type MavenConfig struct { @@ -37,6 +39,11 @@ type GitLabConfig struct { Project string `yaml:"project"` } +type GitHubConfig struct { + Token string `yaml:"token"` + Repo string `yaml:"repo"` // "owner/repo" +} + func defaults() Config { return Config{ Git: GitConfig{ @@ -57,15 +64,18 @@ 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", + "git.tag_prefix": "default", + "git.branch_pattern": "default", + "git.commit_message": "default", + "git.author_name": "default", + "git.author_email": "default", + "git.releasable_types": "default", + "maven.pom_path": "default", + "gitlab.url": "default", + "gitlab.token": "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 != "" { src["git.author_email"] = "config file" } + if len(overlay.Git.ReleasableTypes) > 0 { + src["git.releasable_types"] = "config file" + } if overlay.Maven.PomPath != "" { src["maven.pom_path"] = "config file" } @@ -125,11 +138,17 @@ func LoadWithSources(dir string) (Config, Sources, error) { if overlay.GitLab.Project != "" { 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 } -// 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. func (c *Config) ApplyEnv() { c.ApplyEnvWithSources(nil) @@ -147,7 +166,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) { } } if c.GitLab.URL == "" { - // CI_SERVER_URL is the cleanest source ("https://gitlab.example.com") if v := os.Getenv("CI_SERVER_URL"); v != "" { c.GitLab.URL = v if src != nil { @@ -156,7 +174,6 @@ func (c *Config) ApplyEnvWithSources(src Sources) { } } 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 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" + } + } + } } diff --git a/internal/ghclient/ghclient.go b/internal/ghclient/ghclient.go new file mode 100644 index 0000000..9e0713c --- /dev/null +++ b/internal/ghclient/ghclient.go @@ -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 +} diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index e071ea4..e25dd3b 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -6,14 +6,16 @@ import ( "os" "os/exec" "sort" + "strings" "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" + 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" ) @@ -241,15 +243,53 @@ func CreateTag(repo *gogit.Repository, tagName string) error { // 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 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. +// When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first. +// 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 { if 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) } +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 { remote, err := repo.Remote("origin") if err != nil { diff --git a/internal/notes/notes.go b/internal/notes/notes.go index 659fbde..ab2cdae 100644 --- a/internal/notes/notes.go +++ b/internal/notes/notes.go @@ -2,38 +2,16 @@ 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) - } - } + breaking, feats, fixes := commits.Group(messages) var sb strings.Builder 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) } } - -// 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) -} diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go index cc66be5..f3e54b0 100644 --- a/internal/notes/notes_test.go +++ b/internal/notes/notes_test.go @@ -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 // always includes the tag name in the output. func FuzzGenerate(f *testing.F) { diff --git a/internal/version/version.go b/internal/version/version.go index 863c8b1..a200d91 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,10 +8,14 @@ import ( // 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). +// releasable is the set of commit types that trigger a bump; nil defaults to all three. // 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 { - if t != commits.TypeNone { + if releasable[t] { return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true } } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index e70ec35..1bebc4b 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -61,7 +61,7 @@ func TestNext(t *testing.T) { for _, c := range cases { 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 { t.Errorf("ok=%v, want %v", ok, c.wantOk) }