Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f07220b0c6 | |||
| 5af107b06d | |||
| 62a702fb89 | |||
| 2614f23856 |
@@ -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
|
||||
|
||||
+29
-2
@@ -19,10 +19,37 @@ git:
|
||||
# author_name: ""
|
||||
# author_email: ""
|
||||
|
||||
maven:
|
||||
# Path to pom.xml, relative to the repository root.
|
||||
# Limit which commit types trigger a release (default: fix, feat, breaking).
|
||||
# releasable_types:
|
||||
# - fix
|
||||
# - feat
|
||||
# - breaking
|
||||
|
||||
# Configure which version component each commit type bumps.
|
||||
# Valid values: "patch" (default) or "minor".
|
||||
# bump_rules:
|
||||
# breaking: "minor"
|
||||
# feat: "patch"
|
||||
# fix: "patch"
|
||||
|
||||
# maven:
|
||||
# Single pom.xml path, relative to the repository root.
|
||||
# pom_path: "pom.xml"
|
||||
|
||||
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
|
||||
# pom_paths:
|
||||
# - "pom.xml"
|
||||
# - "module-a/pom.xml"
|
||||
|
||||
# node:
|
||||
# Single package.json path (node processing is opt-in — no default).
|
||||
# package_json: "package.json"
|
||||
|
||||
# Multiple package.json paths for monorepos (overrides package_json).
|
||||
# package_jsons:
|
||||
# - "packages/frontend/package.json"
|
||||
# - "packages/backend/package.json"
|
||||
|
||||
gitlab:
|
||||
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
|
||||
# url: "https://gitlab.example.com"
|
||||
|
||||
@@ -3,6 +3,46 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [1.5.0] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-module Maven support** — `maven.pom_paths: [...]` lists multiple `pom.xml` paths (e.g. root + sub-modules); overrides `pom_path`; each path is updated and committed in the same release commit
|
||||
- **Node.js `package.json` support** — opt-in via `node.package_json` (single path) or `node.package_jsons` (list, overrides the single path); version is bumped in-place alongside `pom.xml` and `CHANGELOG.md`
|
||||
- **`git.bump_rules` config** — controls which version component each commit type bumps: `breaking`, `feat`, `fix` each accept `"patch"` (default) or `"minor"`; allows e.g. `feat: "minor"` to bump the minor component instead of patch
|
||||
- **Injectable function vars in `cmd/main.go`** — `absPath`, `gitAllCommits`, `gitCommitsSince`, `gitCommitFiles` are now package-level vars overridable in tests to inject errors, enabling 100% per-package statement coverage across all 12 packages
|
||||
|
||||
### Changed
|
||||
|
||||
- **`--pom` flag** now clears `maven.pom_paths` before setting `maven.pom_path`, ensuring the CLI flag always wins over a multi-path config file entry
|
||||
- **`version.Next()` signature** — accepts a `map[commits.Type]semver.BumpLevel` bump-rules map as a sixth parameter; `nil` defaults to all-patch (no change to existing behaviour)
|
||||
- **Verbose config table** — now includes `git.bump_rules.breaking/feat/fix`, `maven.pom_paths` (effective list), and `node.paths` rows
|
||||
|
||||
## [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
|
||||
|
||||
- **`release.env` dotenv artifact** — on every real release (not `--dry-run`) a `release.env` file is written to the repository root containing `NEXT_VERSION=<tag>`; the file is never committed, allowing GitLab CI to expose it as a dotenv artifact and pass the version to downstream jobs (deploy, notify, etc.)
|
||||
|
||||
## [1.2.0] - 2026-07-07
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# releaser
|
||||
|
||||

|
||||

|
||||
|
||||
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
|
||||
|
||||
@@ -8,31 +8,33 @@ 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
|
||||
|
||||
| Commit type | Bump | Notes |
|
||||
|------------------|---------|--------------------------------------------|
|
||||
| `fix:` | patch | |
|
||||
| `feat:` | patch | minor is pinned to branch |
|
||||
| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary |
|
||||
| `chore:`, `docs:`, etc. | none | |
|
||||
By default, all releasable commits bump the **patch** component (minor is pinned to the branch). You can override this per commit type via `git.bump_rules` in `.releaser.yml`:
|
||||
|
||||
| Commit type | Default | Configurable via `bump_rules` |
|
||||
|------------------|---------|------------------------------------------------|
|
||||
| `fix:` | patch | `fix: "minor"` to bump minor instead |
|
||||
| `feat:` | patch | `feat: "minor"` to bump minor instead |
|
||||
| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor |
|
||||
| `chore:`, `docs:`, etc. | none | — |
|
||||
| unparseable msg | none | non-strict mode: silently ignored |
|
||||
|
||||
## Usage
|
||||
@@ -44,13 +46,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 +77,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,26 +93,49 @@ 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
|
||||
bump_rules: # which version component each type bumps
|
||||
breaking: "patch" # "minor" to bump minor on breaking changes
|
||||
feat: "patch"
|
||||
fix: "patch"
|
||||
|
||||
maven:
|
||||
pom_path: "pom.xml" # relative to repo root
|
||||
pom_path: "pom.xml" # single pom.xml, relative to repo root
|
||||
# pom_paths: # multi-module: list overrides pom_path
|
||||
# - "pom.xml"
|
||||
# - "module-a/pom.xml"
|
||||
# - "module-b/pom.xml"
|
||||
|
||||
node: # opt-in — no default; omit to skip
|
||||
# package_json: "package.json" # single path
|
||||
# package_jsons: # monorepo: list overrides package_json
|
||||
# - "packages/frontend/package.json"
|
||||
# - "packages/backend/package.json"
|
||||
|
||||
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` | API auth + HTTPS push auth |
|
||||
|--------------------|-----------------------------------|
|
||||
| `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth |
|
||||
| `CI_SERVER_URL` | GitLab instance URL |
|
||||
| `CI_PROJECT_ID` | Project identifier (numeric) |
|
||||
| `CI_PROJECT_PATH` | Project identifier (fallback) |
|
||||
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
|
||||
| `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)
|
||||
|
||||
@@ -121,4 +149,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
|
||||
```
|
||||
|
||||
+12
-4
@@ -70,13 +70,21 @@
|
||||
- [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)
|
||||
- [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
|
||||
|
||||
## v1.5 — Multi-module, Node.js, configurable bump rules ✅
|
||||
|
||||
- [x] Multi-module Maven support (`maven.pom_paths: [...]` updates multiple `pom.xml` files in one release)
|
||||
- [x] `package.json` version bump for Node.js projects (`node.package_json` / `node.package_jsons`)
|
||||
- [x] Configurable bump rules per commit type (`git.bump_rules.breaking/feat/fix: "minor" | "patch"`)
|
||||
- [x] 100% per-package statement coverage across all 12 packages
|
||||
|
||||
## Future / backlog
|
||||
|
||||
- GitHub release support (parity with GitLab)
|
||||
- Multi-module Maven support (multiple `pom.xml` paths)
|
||||
- Gradle support (`build.gradle` / `build.gradle.kts`)
|
||||
- `package.json` version bump support (Node.js projects)
|
||||
- Slack / Teams notification on release
|
||||
- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch)
|
||||
- Documentation site
|
||||
|
||||
+182
-43
@@ -15,9 +15,11 @@ 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/node"
|
||||
"git.k3nny.fr/releaser/internal/notes"
|
||||
semver "git.k3nny.fr/releaser/internal/version"
|
||||
)
|
||||
@@ -43,10 +45,39 @@ git:
|
||||
# author_name: ""
|
||||
# author_email: ""
|
||||
|
||||
# Limit which commit types trigger a release (default: fix, feat, breaking).
|
||||
# releasable_types:
|
||||
# - fix
|
||||
# - feat
|
||||
# - breaking
|
||||
|
||||
# Configure which version component each commit type bumps.
|
||||
# Valid values: "patch" (default) or "minor".
|
||||
# bump_rules:
|
||||
# breaking: "minor" # bump minor version instead of patch on breaking changes
|
||||
# feat: "patch"
|
||||
# fix: "patch"
|
||||
|
||||
maven:
|
||||
# Path to pom.xml, relative to the repository root.
|
||||
# Single pom.xml path, relative to the repository root.
|
||||
# pom_path: "pom.xml"
|
||||
|
||||
# Multiple pom.xml paths for multi-module projects (overrides pom_path).
|
||||
# pom_paths:
|
||||
# - "pom.xml"
|
||||
# - "module-a/pom.xml"
|
||||
# - "module-b/pom.xml"
|
||||
|
||||
node:
|
||||
# Single package.json path (node processing is opt-in — no default).
|
||||
# package_json: "package.json"
|
||||
|
||||
# Multiple package.json paths for monorepos (overrides package_json).
|
||||
# package_jsons:
|
||||
# - "package.json"
|
||||
# - "packages/frontend/package.json"
|
||||
# - "packages/backend/package.json"
|
||||
|
||||
gitlab:
|
||||
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
|
||||
# url: "https://gitlab.example.com"
|
||||
@@ -59,6 +90,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,6 +108,35 @@ var (
|
||||
// exitFn is a variable so tests can intercept os.Exit calls.
|
||||
var exitFn = os.Exit
|
||||
|
||||
// injectable function variables for testing error paths.
|
||||
var (
|
||||
absPath = filepath.Abs
|
||||
gitAllCommits = gitutil.AllCommits
|
||||
gitCommitsSince = gitutil.CommitsSince
|
||||
gitCommitFiles = gitutil.CommitFiles
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -86,6 +154,7 @@ func newRootCmd() *cobra.Command {
|
||||
tagPrefixSet bool
|
||||
patternFlag string
|
||||
patternSet bool
|
||||
releaseEnvFile string
|
||||
)
|
||||
|
||||
root := &cobra.Command{
|
||||
@@ -112,6 +181,7 @@ func newRootCmd() *cobra.Command {
|
||||
noRelease: noRelease,
|
||||
noCommit: noCommit,
|
||||
tagOnly: tagOnly,
|
||||
releaseEnvFile: releaseEnvFile,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -119,8 +189,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 +199,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 +230,7 @@ type options struct {
|
||||
noRelease bool
|
||||
noCommit bool
|
||||
tagOnly bool
|
||||
releaseEnvFile string
|
||||
}
|
||||
|
||||
func printVerboseConfig(cfg config.Config, src config.Sources) {
|
||||
@@ -169,7 +241,38 @@ 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},
|
||||
{"maven.pom_path", cfg.Maven.PomPath},
|
||||
{"git.releasable_types", func() string {
|
||||
if len(cfg.Git.ReleasableTypes) == 0 {
|
||||
return "(all)"
|
||||
}
|
||||
return strings.Join(cfg.Git.ReleasableTypes, ", ")
|
||||
}()},
|
||||
{"git.bump_rules.breaking", func() string {
|
||||
if cfg.Git.BumpRules.Breaking == "" {
|
||||
return "patch"
|
||||
}
|
||||
return cfg.Git.BumpRules.Breaking
|
||||
}()},
|
||||
{"git.bump_rules.feat", func() string {
|
||||
if cfg.Git.BumpRules.Feat == "" {
|
||||
return "patch"
|
||||
}
|
||||
return cfg.Git.BumpRules.Feat
|
||||
}()},
|
||||
{"git.bump_rules.fix", func() string {
|
||||
if cfg.Git.BumpRules.Fix == "" {
|
||||
return "patch"
|
||||
}
|
||||
return cfg.Git.BumpRules.Fix
|
||||
}()},
|
||||
{"maven.pom_paths", strings.Join(cfg.Maven.EffectivePomPaths(), ", ")},
|
||||
{"node.paths", func() string {
|
||||
paths := cfg.Node.EffectivePaths()
|
||||
if len(paths) == 0 {
|
||||
return "(not configured)"
|
||||
}
|
||||
return strings.Join(paths, ", ")
|
||||
}()},
|
||||
{"gitlab.url", cfg.GitLab.URL},
|
||||
{"gitlab.token", func() string {
|
||||
if cfg.GitLab.Token != "" {
|
||||
@@ -178,6 +281,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]
|
||||
@@ -204,11 +314,25 @@ func initConfig(absRepo string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseBumpRules(rules config.BumpRulesConfig) map[commits.Type]semver.BumpLevel {
|
||||
m := map[commits.Type]semver.BumpLevel{}
|
||||
if rules.Breaking == "minor" {
|
||||
m[commits.TypeBreaking] = semver.BumpMinor
|
||||
}
|
||||
if rules.Feat == "minor" {
|
||||
m[commits.TypeFeat] = semver.BumpMinor
|
||||
}
|
||||
if rules.Fix == "minor" {
|
||||
m[commits.TypeFix] = semver.BumpMinor
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func run(o options) error {
|
||||
logHeader(version)
|
||||
|
||||
// --- Config ---
|
||||
absRepo, err := filepath.Abs(o.repoPath)
|
||||
absRepo, err := absPath(o.repoPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve repo path: %w", err)
|
||||
}
|
||||
@@ -220,15 +344,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,22 +353,17 @@ 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"
|
||||
}
|
||||
}
|
||||
if o.pomOverride != "" {
|
||||
cfg.Maven.PomPath = o.pomOverride
|
||||
if src != nil {
|
||||
src["maven.pom_path"] = "flag: --pom"
|
||||
}
|
||||
cfg.Maven.PomPaths = nil
|
||||
src["maven.pom_paths"] = "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)
|
||||
@@ -306,9 +417,9 @@ func run(o options) error {
|
||||
// --- Commit range ---
|
||||
var messages []string
|
||||
if lastTag == "" {
|
||||
messages, err = gitutil.AllCommits(repo)
|
||||
messages, err = gitAllCommits(repo)
|
||||
} else {
|
||||
messages, err = gitutil.CommitsSince(repo, lastTag)
|
||||
messages, err = gitCommitsSince(repo, lastTag)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read commits: %w", err)
|
||||
@@ -358,7 +469,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, parseBumpRules(cfg.Git.BumpRules))
|
||||
if !ok {
|
||||
logWarn("no releasable commits found")
|
||||
return errNothingToRelease
|
||||
@@ -387,18 +499,30 @@ func run(o options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- release.env (GitLab CI dotenv artifact) ---
|
||||
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)
|
||||
}
|
||||
|
||||
// --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
|
||||
if !o.tagOnly {
|
||||
var filesToCommit []string
|
||||
|
||||
// pom.xml
|
||||
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath)
|
||||
// pom.xml (supports multi-module via pom_paths)
|
||||
anyPom := false
|
||||
for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
|
||||
pomPath := filepath.Join(absRepo, relPomPath)
|
||||
_, statErr := os.Stat(pomPath)
|
||||
hasPom := !errors.Is(statErr, os.ErrNotExist)
|
||||
if statErr != nil && hasPom {
|
||||
return fmt.Errorf("check pom path: %w", statErr)
|
||||
}
|
||||
if hasPom {
|
||||
anyPom = true
|
||||
currentPomVersion, err := maven.ReadVersion(pomPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read pom version: %w", err)
|
||||
@@ -406,12 +530,28 @@ func run(o options) error {
|
||||
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 {
|
||||
logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, relPomPath)
|
||||
}
|
||||
}
|
||||
if !anyPom {
|
||||
logWarn("no pom.xml — skipping version bump")
|
||||
}
|
||||
|
||||
// package.json (opt-in via node.package_json / node.package_jsons)
|
||||
for _, relPkgPath := range cfg.Node.EffectivePaths() {
|
||||
pkgPath := filepath.Join(absRepo, relPkgPath)
|
||||
currentNodeVersion, err := node.ReadVersion(pkgPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read package.json version: %w", err)
|
||||
}
|
||||
if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil {
|
||||
return fmt.Errorf("update package.json version: %w", err)
|
||||
}
|
||||
logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion)
|
||||
filesToCommit = append(filesToCommit, relPkgPath)
|
||||
}
|
||||
|
||||
// CHANGELOG.md
|
||||
changelogAbsPath := filepath.Join(absRepo, o.changelogFile)
|
||||
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
|
||||
@@ -434,7 +574,7 @@ func run(o options) error {
|
||||
authorEmail = cfg.Git.AuthorEmail
|
||||
}
|
||||
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag)
|
||||
if _, err := gitutil.CommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
|
||||
if _, err := gitCommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
logDone("committed: %s", commitMsg)
|
||||
@@ -458,28 +598,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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -15,6 +16,8 @@ import (
|
||||
gitcfg "github.com/go-git/go-git/v5/config"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/config"
|
||||
)
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -658,6 +661,47 @@ func TestRunChangelogFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReleaseEnv(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)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "release.env"))
|
||||
if err != nil {
|
||||
t.Fatal("expected release.env to be created")
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content != "NEXT_VERSION=1.2.0" {
|
||||
t.Errorf("release.env content = %q, want %q", content, "NEXT_VERSION=1.2.0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReleaseEnvDryRun(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, "--dry-run", "--branch", "release/1.2", "--repo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "release.env")); err == nil {
|
||||
t.Error("release.env must not be created in --dry-run mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVerbose(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// feat")
|
||||
@@ -701,3 +745,458 @@ func TestRunVerbose(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── ui.go coverage ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPaintColor(t *testing.T) {
|
||||
old := useColor
|
||||
useColor = true
|
||||
defer func() { useColor = old }()
|
||||
|
||||
got := paint(ansiGreen, "hello")
|
||||
if !strings.Contains(got, "hello") || !strings.Contains(got, ansiReset) || !strings.Contains(got, ansiGreen) {
|
||||
t.Errorf("paint with color = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtSourceEnv(t *testing.T) {
|
||||
old := useColor
|
||||
useColor = false
|
||||
defer func() { useColor = old }()
|
||||
|
||||
got := fmtSource("env: GITLAB_TOKEN")
|
||||
if got != "[env: GITLAB_TOKEN]" {
|
||||
t.Errorf("fmtSource env = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtSourceFlag(t *testing.T) {
|
||||
old := useColor
|
||||
useColor = false
|
||||
defer func() { useColor = old }()
|
||||
|
||||
got := fmtSource("flag: --tag-prefix")
|
||||
if got != "[flag: --tag-prefix]" {
|
||||
t.Errorf("fmtSource flag = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFmtSourceConfigFile(t *testing.T) {
|
||||
old := useColor
|
||||
useColor = false
|
||||
defer func() { useColor = old }()
|
||||
|
||||
got := fmtSource("config file")
|
||||
if got != "[config file]" {
|
||||
t.Errorf("fmtSource config file = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildPublisher coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestBuildPublisherGitHub(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
GitHub: config.GitHubConfig{Token: "ghtoken", Repo: "owner/repo"},
|
||||
}
|
||||
pub, err := buildPublisher(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("buildPublisher GitHub: %v", err)
|
||||
}
|
||||
if pub == nil {
|
||||
t.Fatal("expected non-nil publisher for GitHub config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPublisherGitLabNoToken(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
GitLab: config.GitLabConfig{URL: "https://gitlab.example.com", Project: "42"},
|
||||
}
|
||||
_, err := buildPublisher(cfg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when GitLab URL+Project set but token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// ── printVerboseConfig coverage ───────────────────────────────────────────────
|
||||
|
||||
func TestPrintVerboseConfigDirect(t *testing.T) {
|
||||
// Use a sparse Sources map (missing keys → source == "" → hits "default" branch).
|
||||
// Also set non-empty ReleasableTypes and both tokens to cover those branches.
|
||||
cfg := config.Config{
|
||||
Git: config.GitConfig{
|
||||
ReleasableTypes: []string{"fix", "feat"},
|
||||
},
|
||||
GitLab: config.GitLabConfig{Token: "secret"},
|
||||
GitHub: config.GitHubConfig{Token: "ghsecret"},
|
||||
}
|
||||
src := config.Sources{} // empty → all lookups return ""
|
||||
|
||||
old := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
|
||||
printVerboseConfig(cfg, src)
|
||||
|
||||
w.Close()
|
||||
os.Stderr = old
|
||||
out, _ := io.ReadAll(r)
|
||||
|
||||
if !strings.Contains(string(out), "fix, feat") {
|
||||
t.Error("expected releasable types joined in output")
|
||||
}
|
||||
if !strings.Contains(string(out), "(set)") {
|
||||
t.Error("expected '(set)' for configured tokens")
|
||||
}
|
||||
}
|
||||
|
||||
// ── initConfig coverage ───────────────────────────────────────────────────────
|
||||
|
||||
func TestInitConfigWriteFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.Chmod(dir, 0555)
|
||||
defer os.Chmod(dir, 0755)
|
||||
|
||||
err := initConfig(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing .releaser.yml to read-only directory")
|
||||
}
|
||||
}
|
||||
|
||||
// ── run() injectable error paths ─────────────────────────────────────────────
|
||||
|
||||
func TestRunVerboseInit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := execCmd(t, "--init", "--verbose", "--repo", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("--init --verbose: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".releaser.yml")); err != nil {
|
||||
t.Error("expected .releaser.yml to be created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAbsPathError(t *testing.T) {
|
||||
old := absPath
|
||||
absPath = func(string) (string, error) { return "", fmt.Errorf("injected abs error") }
|
||||
defer func() { absPath = old }()
|
||||
|
||||
err := execCmd(t, "--repo", ".")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when filepath.Abs fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkingTreeCheckFails(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Corrupt the git index so IsWorkingTreeClean fails.
|
||||
os.WriteFile(filepath.Join(dir, ".git", "index"), []byte("garbage"), 0644)
|
||||
|
||||
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupt git index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLatestTagFails(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
|
||||
os.Chmod(tagsDir, 0000)
|
||||
defer os.Chmod(tagsDir, 0755)
|
||||
|
||||
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreadable tags directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAllCommitsError(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
old := gitAllCommits
|
||||
gitAllCommits = func(_ *gogit.Repository) ([]string, error) {
|
||||
return nil, fmt.Errorf("injected AllCommits error")
|
||||
}
|
||||
defer func() { gitAllCommits = old }()
|
||||
|
||||
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from AllCommits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommitsSinceError(t *testing.T) {
|
||||
repo, dir := setupRepo(t)
|
||||
head, _ := repo.Head()
|
||||
repo.CreateTag("1.2.0", head.Hash(), nil)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
old := gitCommitsSince
|
||||
gitCommitsSince = func(_ *gogit.Repository, _ string) ([]string, error) {
|
||||
return nil, fmt.Errorf("injected CommitsSince error")
|
||||
}
|
||||
defer func() { gitCommitsSince = old }()
|
||||
|
||||
err := execCmd(t, "--dry-run", "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CommitsSince")
|
||||
}
|
||||
}
|
||||
|
||||
// ── verbose commit section coverage ──────────────────────────────────────────
|
||||
|
||||
func TestRunVerboseBreakingAndFix(t *testing.T) {
|
||||
// Covers: verbose "since: lastTag", message truncation (>70 chars),
|
||||
// TypeBreaking color (ansiRed+ansiBold), TypeFix color (ansiGreen).
|
||||
repo, dir := setupRepo(t)
|
||||
head, _ := repo.Head()
|
||||
repo.CreateTag("1.2.0", head.Hash(), nil)
|
||||
|
||||
w, _ := repo.Worktree()
|
||||
|
||||
addFile(t, dir, "a.go", "a")
|
||||
w.Add("a.go")
|
||||
w.Commit("feat!: redesign the entire public API surface which is a very long commit message header", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
addFile(t, dir, "b.go", "b")
|
||||
w.Add("b.go")
|
||||
w.Commit("fix: correct null pointer in edge case handler", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
old := os.Stderr
|
||||
r, wp, _ := os.Pipe()
|
||||
os.Stderr = wp
|
||||
|
||||
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
|
||||
|
||||
wp.Close()
|
||||
os.Stderr = old
|
||||
io.ReadAll(r)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("verbose breaking+fix: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── release.env write error ───────────────────────────────────────────────────
|
||||
|
||||
func TestRunReleaseEnvWriteFails(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Empty directory is invisible to git — dirty check passes.
|
||||
os.Mkdir(filepath.Join(dir, "release.env"), 0755)
|
||||
|
||||
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when release.env is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
// ── pom stat error ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRunPomStatError(t *testing.T) {
|
||||
// A null byte in the path makes os.Stat return EINVAL (not ErrNotExist),
|
||||
// so hasPom=true and the stat error is propagated.
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "pom\x00.xml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for pom path with null byte (EINVAL)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── changelog update error ────────────────────────────────────────────────────
|
||||
|
||||
func TestRunChangelogUpdateFails(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Empty directory is invisible to git — dirty check passes.
|
||||
// changelog.Update will fail trying to ReadFile on a directory.
|
||||
os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755)
|
||||
|
||||
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when CHANGELOG.md is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitFiles error ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestRunCommitFilesError(t *testing.T) {
|
||||
_, dir := setupRepo(t)
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
repo, _ := gogit.PlainOpen(dir)
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
old := gitCommitFiles
|
||||
gitCommitFiles = func(_ *gogit.Repository, _ []string, _, _, _ string) (plumbing.Hash, error) {
|
||||
return plumbing.ZeroHash, fmt.Errorf("injected commit error")
|
||||
}
|
||||
defer func() { gitCommitFiles = old }()
|
||||
|
||||
err := execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from CommitFiles")
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseBumpRules coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestParseBumpRules(t *testing.T) {
|
||||
rules := config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"}
|
||||
m := parseBumpRules(rules)
|
||||
if len(m) != 3 {
|
||||
t.Errorf("expected 3 entries in bump rules map, got %d", len(m))
|
||||
}
|
||||
}
|
||||
|
||||
// ── printVerboseConfig — bump_rules and node rows ─────────────────────────────
|
||||
|
||||
func TestPrintVerboseConfigBumpRulesAndNode(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
Git: config.GitConfig{
|
||||
BumpRules: config.BumpRulesConfig{Breaking: "minor", Feat: "minor", Fix: "minor"},
|
||||
},
|
||||
Node: config.NodeConfig{PackageJSON: "package.json"},
|
||||
}
|
||||
src := config.Sources{}
|
||||
|
||||
old := os.Stderr
|
||||
r, wp, _ := os.Pipe()
|
||||
os.Stderr = wp
|
||||
printVerboseConfig(cfg, src)
|
||||
wp.Close()
|
||||
os.Stderr = old
|
||||
out, _ := io.ReadAll(r)
|
||||
output := string(out)
|
||||
|
||||
if !strings.Contains(output, "minor") {
|
||||
t.Error("expected 'minor' in output for bump_rules")
|
||||
}
|
||||
if !strings.Contains(output, "package.json") {
|
||||
t.Error("expected 'package.json' in output for node.paths")
|
||||
}
|
||||
}
|
||||
|
||||
// ── node package.json handling ────────────────────────────────────────────────
|
||||
|
||||
func writePackageJSON(t *testing.T, dir, ver string) {
|
||||
t.Helper()
|
||||
content := fmt.Sprintf(`{"name": "my-app", "version": "%s"}`, ver)
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNodeVersionBump(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePackageJSON(t, dir, "0.0.0")
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
// .releaser.yml is untracked — go-git IsClean ignores untracked files
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("node version bump: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(filepath.Join(dir, "package.json"))
|
||||
if !strings.Contains(string(data), `"version": "1.2.0"`) {
|
||||
t.Errorf("expected version 1.2.0 in package.json, got: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNodeReadVersionFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// invalid JSON — ReadVersion will fail
|
||||
os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{not json`), 0644)
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when package.json has invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNodeWriteVersionFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePackageJSON(t, dir, "0.0.0")
|
||||
commitAll(t, repo, dir, "chore: init")
|
||||
|
||||
os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("node:\n package_json: \"package.json\"\n"), 0644)
|
||||
|
||||
addFile(t, dir, "x.go", "// fix")
|
||||
w, _ := repo.Worktree()
|
||||
w.Add("x.go")
|
||||
w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()})
|
||||
|
||||
// Make package.json read-only so WriteVersion fails
|
||||
os.Chmod(filepath.Join(dir, "package.json"), 0444)
|
||||
defer os.Chmod(filepath.Join(dir, "package.json"), 0644)
|
||||
|
||||
err = execCmd(t, "--branch", "release/1.2", "--repo", dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when package.json is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -99,6 +99,28 @@ func TestUpdateBreakingSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExistingFileNoHeading(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
// File with content but no ## [ heading — new section appended at bottom.
|
||||
os.WriteFile(path, []byte("# Changelog\n\nSome preamble.\n"), 0644)
|
||||
|
||||
err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
s := string(data)
|
||||
if !strings.Contains(s, "## [1.0.0]") {
|
||||
t.Error("expected version header appended")
|
||||
}
|
||||
if !strings.Contains(s, "# Changelog") {
|
||||
t.Error("expected original content preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateReadError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Create a directory where the file should be — ReadFile will error.
|
||||
@@ -109,3 +131,21 @@ func TestUpdateReadError(t *testing.T) {
|
||||
t.Error("expected error when path is a directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateIdempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "CHANGELOG.md")
|
||||
|
||||
// Pre-seed with the version heading already present.
|
||||
os.WriteFile(path, []byte("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n- fix: something\n"), 0644)
|
||||
|
||||
// Second call must be a no-op (returns nil, file unchanged).
|
||||
if err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"}); err != nil {
|
||||
t.Fatalf("idempotent Update should not error, got: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.Count(string(data), "## [1.0.0]") != 1 {
|
||||
t.Error("version heading should appear exactly once after idempotent call")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -35,6 +35,64 @@ 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)
|
||||
}
|
||||
onlyFeat := ReleasableSet([]string{"feat"})
|
||||
if onlyFeat[TypeFix] || !onlyFeat[TypeFeat] || onlyFeat[TypeBreaking] {
|
||||
t.Errorf("feat-only set: %v", onlyFeat)
|
||||
}
|
||||
onlyBreaking := ReleasableSet([]string{"breaking"})
|
||||
if onlyBreaking[TypeFix] || onlyBreaking[TypeFeat] || !onlyBreaking[TypeBreaking] {
|
||||
t.Errorf("breaking-only set: %v", onlyBreaking)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeString(t *testing.T) {
|
||||
cases := []struct {
|
||||
t Type
|
||||
|
||||
@@ -16,7 +16,9 @@ const filename = ".releaser.yml"
|
||||
type Config struct {
|
||||
Git GitConfig `yaml:"git"`
|
||||
Maven MavenConfig `yaml:"maven"`
|
||||
Node NodeConfig `yaml:"node"`
|
||||
GitLab GitLabConfig `yaml:"gitlab"`
|
||||
GitHub GitHubConfig `yaml:"github"`
|
||||
}
|
||||
|
||||
type GitConfig struct {
|
||||
@@ -25,10 +27,50 @@ type GitConfig struct {
|
||||
CommitMessage string `yaml:"commit_message"`
|
||||
AuthorName string `yaml:"author_name"`
|
||||
AuthorEmail string `yaml:"author_email"`
|
||||
ReleasableTypes []string `yaml:"releasable_types"`
|
||||
BumpRules BumpRulesConfig `yaml:"bump_rules"`
|
||||
}
|
||||
|
||||
// BumpRulesConfig controls what version component each commit type bumps.
|
||||
// Valid values: "patch" (default) or "minor".
|
||||
type BumpRulesConfig struct {
|
||||
Breaking string `yaml:"breaking"`
|
||||
Feat string `yaml:"feat"`
|
||||
Fix string `yaml:"fix"`
|
||||
}
|
||||
|
||||
type MavenConfig struct {
|
||||
PomPath string `yaml:"pom_path"`
|
||||
PomPath string `yaml:"pom_path"` // single path (default: "pom.xml")
|
||||
PomPaths []string `yaml:"pom_paths"` // multiple paths; overrides PomPath when set
|
||||
}
|
||||
|
||||
// EffectivePomPaths returns the list of pom.xml paths to process.
|
||||
// PomPaths takes precedence over PomPath; falls back to ["pom.xml"].
|
||||
func (m MavenConfig) EffectivePomPaths() []string {
|
||||
if len(m.PomPaths) > 0 {
|
||||
return m.PomPaths
|
||||
}
|
||||
if m.PomPath != "" {
|
||||
return []string{m.PomPath}
|
||||
}
|
||||
return []string{"pom.xml"}
|
||||
}
|
||||
|
||||
type NodeConfig struct {
|
||||
PackageJSON string `yaml:"package_json"` // single path
|
||||
PackageJSONs []string `yaml:"package_jsons"` // multiple paths; overrides PackageJSON when set
|
||||
}
|
||||
|
||||
// EffectivePaths returns the list of package.json paths to process.
|
||||
// Returns nil when no node paths are configured (node processing is opt-in).
|
||||
func (n NodeConfig) EffectivePaths() []string {
|
||||
if len(n.PackageJSONs) > 0 {
|
||||
return n.PackageJSONs
|
||||
}
|
||||
if n.PackageJSON != "" {
|
||||
return []string{n.PackageJSON}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GitLabConfig struct {
|
||||
@@ -37,6 +79,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{
|
||||
@@ -62,10 +109,19 @@ func defaultSources() Sources {
|
||||
"git.commit_message": "default",
|
||||
"git.author_name": "default",
|
||||
"git.author_email": "default",
|
||||
"git.releasable_types": "default",
|
||||
"git.bump_rules.breaking": "default",
|
||||
"git.bump_rules.feat": "default",
|
||||
"git.bump_rules.fix": "default",
|
||||
"maven.pom_path": "default",
|
||||
"maven.pom_paths": "default",
|
||||
"node.package_json": "default",
|
||||
"node.package_jsons": "default",
|
||||
"gitlab.url": "default",
|
||||
"gitlab.token": "default",
|
||||
"gitlab.project": "default",
|
||||
"github.token": "default",
|
||||
"github.repo": "default",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +169,30 @@ 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.Git.BumpRules.Breaking != "" {
|
||||
src["git.bump_rules.breaking"] = "config file"
|
||||
}
|
||||
if overlay.Git.BumpRules.Feat != "" {
|
||||
src["git.bump_rules.feat"] = "config file"
|
||||
}
|
||||
if overlay.Git.BumpRules.Fix != "" {
|
||||
src["git.bump_rules.fix"] = "config file"
|
||||
}
|
||||
if overlay.Maven.PomPath != "" {
|
||||
src["maven.pom_path"] = "config file"
|
||||
}
|
||||
if len(overlay.Maven.PomPaths) > 0 {
|
||||
src["maven.pom_paths"] = "config file"
|
||||
}
|
||||
if overlay.Node.PackageJSON != "" {
|
||||
src["node.package_json"] = "config file"
|
||||
}
|
||||
if len(overlay.Node.PackageJSONs) > 0 {
|
||||
src["node.package_jsons"] = "config file"
|
||||
}
|
||||
if overlay.GitLab.URL != "" {
|
||||
src["gitlab.url"] = "config file"
|
||||
}
|
||||
@@ -125,11 +202,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 +230,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 +238,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 +250,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,175 @@ func TestApplyEnvProjectPathFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadWithSourcesFullConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
content := `
|
||||
git:
|
||||
tag_prefix: "v"
|
||||
branch_pattern: "^release/(\\d+)$"
|
||||
commit_message: "release {version}"
|
||||
author_name: "Bot"
|
||||
author_email: "bot@example.com"
|
||||
releasable_types: ["fix", "feat"]
|
||||
bump_rules:
|
||||
breaking: "minor"
|
||||
feat: "patch"
|
||||
fix: "patch"
|
||||
maven:
|
||||
pom_path: "sub/pom.xml"
|
||||
pom_paths:
|
||||
- "a/pom.xml"
|
||||
- "b/pom.xml"
|
||||
node:
|
||||
package_json: "frontend/package.json"
|
||||
package_jsons:
|
||||
- "pkg-a/package.json"
|
||||
- "pkg-b/package.json"
|
||||
gitlab:
|
||||
url: "https://gitlab.example.com"
|
||||
token: "gitlab-token"
|
||||
project: "42"
|
||||
github:
|
||||
token: "github-token"
|
||||
repo: "owner/repo"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, src, err := LoadWithSources(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantConfigFile := []string{
|
||||
"git.tag_prefix", "git.branch_pattern", "git.commit_message",
|
||||
"git.author_name", "git.author_email", "git.releasable_types",
|
||||
"git.bump_rules.breaking", "git.bump_rules.feat", "git.bump_rules.fix",
|
||||
"maven.pom_path", "maven.pom_paths",
|
||||
"node.package_json", "node.package_jsons",
|
||||
"gitlab.url", "gitlab.token", "gitlab.project",
|
||||
"github.token", "github.repo",
|
||||
}
|
||||
for _, key := range wantConfigFile {
|
||||
if got := src[key]; got != "config file" {
|
||||
t.Errorf("src[%q] = %q, want %q", key, got, "config file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectivePomPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg MavenConfig
|
||||
want []string
|
||||
}{
|
||||
{"default", MavenConfig{PomPath: "pom.xml"}, []string{"pom.xml"}},
|
||||
{"single override", MavenConfig{PomPath: "sub/pom.xml"}, []string{"sub/pom.xml"}},
|
||||
{"multi overrides single", MavenConfig{PomPath: "pom.xml", PomPaths: []string{"a/pom.xml", "b/pom.xml"}}, []string{"a/pom.xml", "b/pom.xml"}},
|
||||
{"empty falls back to default", MavenConfig{}, []string{"pom.xml"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.cfg.EffectivePomPaths()
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("got %v, want %v", got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeEffectivePaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg NodeConfig
|
||||
want []string
|
||||
}{
|
||||
{"empty — opt-in, skip by default", NodeConfig{}, nil},
|
||||
{"single", NodeConfig{PackageJSON: "package.json"}, []string{"package.json"}},
|
||||
{"multi overrides single", NodeConfig{PackageJSON: "package.json", PackageJSONs: []string{"a/package.json", "b/package.json"}}, []string{"a/package.json", "b/package.json"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.cfg.EffectivePaths()
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("got %v, want %v", got, c.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("[%d] got %q, want %q", i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvWithSourcesCIProjectID(t *testing.T) {
|
||||
t.Setenv("CI_PROJECT_ID", "123")
|
||||
t.Setenv("CI_PROJECT_PATH", "")
|
||||
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
if src["gitlab.project"] != "env: CI_PROJECT_ID" {
|
||||
t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvWithSourcesGitHubToken(t *testing.T) {
|
||||
t.Setenv("GITHUB_TOKEN", "ghtoken")
|
||||
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
if src["github.token"] != "env: GITHUB_TOKEN" {
|
||||
t.Errorf("src[github.token] = %q, want %q", src["github.token"], "env: GITHUB_TOKEN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvWithSourcesCIProjectPath(t *testing.T) {
|
||||
t.Setenv("CI_PROJECT_ID", "")
|
||||
t.Setenv("CI_PROJECT_PATH", "group/project")
|
||||
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
if src["gitlab.project"] != "env: CI_PROJECT_PATH" {
|
||||
t.Errorf("src[gitlab.project] = %q, want %q", src["gitlab.project"], "env: CI_PROJECT_PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvWithSourcesGitLabToken(t *testing.T) {
|
||||
t.Setenv("GITLAB_TOKEN", "mytoken")
|
||||
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
if src["gitlab.token"] != "env: GITLAB_TOKEN" {
|
||||
t.Errorf("src[gitlab.token] = %q, want %q", src["gitlab.token"], "env: GITLAB_TOKEN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyEnvWithSourcesCIServerURL(t *testing.T) {
|
||||
t.Setenv("CI_SERVER_URL", "https://gitlab.example.com")
|
||||
|
||||
cfg := defaults()
|
||||
src := defaultSources()
|
||||
cfg.ApplyEnvWithSources(src)
|
||||
|
||||
if src["gitlab.url"] != "env: CI_SERVER_URL" {
|
||||
t.Errorf("src[gitlab.url] = %q, want %q", src["gitlab.url"], "env: CI_SERVER_URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPartialOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Only override tag_prefix — commit_message should keep its default
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package ghclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
c := New("tok", "owner/repo")
|
||||
if c.token != "tok" || c.repo != "owner/repo" || c.httpClient == nil {
|
||||
t.Fatalf("New fields: token=%q repo=%q httpClient=%v", c.token, c.repo, c.httpClient)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", r.Method)
|
||||
}
|
||||
if !strings.HasSuffix(r.URL.Path, "/releases") {
|
||||
t.Errorf("path = %q, want .../releases", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer test-token" {
|
||||
t.Errorf("Authorization = %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
var req struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.TagName != "v1.0.0" {
|
||||
t.Errorf("tag_name = %q, want v1.0.0", req.TagName)
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New("test-token", "owner/repo")
|
||||
c.httpClient = srv.Client()
|
||||
// Override the URL by pointing the client at the test server.
|
||||
// We can't easily override the URL without a custom transport, so use a
|
||||
// round-trip wrapper instead.
|
||||
c.httpClient.Transport = rewriteTransport{base: http.DefaultTransport, target: srv.URL}
|
||||
|
||||
if err := c.CreateRelease(context.Background(), "v1.0.0", "release notes"); err != nil {
|
||||
t.Fatalf("CreateRelease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseAPIError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
io.WriteString(w, `{"message":"Validation Failed"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New("tok", "owner/repo")
|
||||
c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}}
|
||||
|
||||
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 422 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "422") {
|
||||
t.Errorf("error should mention status code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseAPIErrorNoMessage(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
io.WriteString(w, `not json`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := New("tok", "owner/repo")
|
||||
c.httpClient = &http.Client{Transport: rewriteTransport{base: http.DefaultTransport, target: srv.URL}}
|
||||
|
||||
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for 500 response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "500") {
|
||||
t.Errorf("error should mention status code: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseRequestFails(t *testing.T) {
|
||||
c := New("tok", "owner/repo")
|
||||
// Use a transport that always fails.
|
||||
c.httpClient = &http.Client{Transport: alwaysFailTransport{}}
|
||||
|
||||
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when HTTP request fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateReleaseBadURL(t *testing.T) {
|
||||
// A repo containing a null byte makes the URL unparseable by http.NewRequestWithContext.
|
||||
c := New("tok", "owner/repo\x00bad")
|
||||
|
||||
err := c.CreateRelease(context.Background(), "v1.0.0", "body")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid URL")
|
||||
}
|
||||
}
|
||||
|
||||
// rewriteTransport redirects all requests to a test server URL.
|
||||
type rewriteTransport struct {
|
||||
base http.RoundTripper
|
||||
target string
|
||||
}
|
||||
|
||||
func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req2 := req.Clone(req.Context())
|
||||
req2.URL.Scheme = "http"
|
||||
req2.URL.Host = strings.TrimPrefix(rt.target, "http://")
|
||||
return rt.base.RoundTrip(req2)
|
||||
}
|
||||
|
||||
// alwaysFailTransport returns an error for every request.
|
||||
type alwaysFailTransport struct{}
|
||||
|
||||
func (alwaysFailTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, &testTransportError{"connection refused"}
|
||||
}
|
||||
|
||||
type testTransportError struct{ msg string }
|
||||
|
||||
func (e *testTransportError) Error() string { return e.msg }
|
||||
+68
-15
@@ -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"
|
||||
)
|
||||
@@ -83,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
commitHash, err := resolveTagToCommit(repo, ref)
|
||||
tagCommit, err := resolveTagToCommitObj(repo, ref)
|
||||
if err != nil {
|
||||
return nil // silently skip malformed tags
|
||||
}
|
||||
|
||||
tagCommit, err := repo.CommitObject(commitHash)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tagCommit.Hash == headCommit.Hash {
|
||||
candidates = append(candidates, tagCandidate{name, patch})
|
||||
return nil
|
||||
}
|
||||
|
||||
anc, err := tagCommit.IsAncestor(headCommit)
|
||||
if err != nil || !anc {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !anc {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -239,17 +239,61 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshPush is the function used for SSH agent push; replaced in tests to avoid requiring a live agent.
|
||||
var sshPush = pushWithSSHAgent
|
||||
|
||||
// newSSHAgentAuth creates an SSH agent auth method; replaced in tests.
|
||||
var newSSHAgentAuth = gitssh.NewSSHAgentAuth
|
||||
|
||||
// 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 := sshPush(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 := 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 {
|
||||
@@ -291,22 +335,31 @@ func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTagToCommit follows tag objects until it reaches a commit.
|
||||
// resolveTagToCommitObj follows tag objects until it reaches a commit and returns it.
|
||||
// Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit).
|
||||
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
|
||||
func resolveTagToCommitObj(repo *gogit.Repository, ref *plumbing.Reference) (*object.Commit, error) {
|
||||
hash := ref.Hash()
|
||||
for {
|
||||
obj, err := repo.Object(plumbing.AnyObject, hash)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
return nil, err
|
||||
}
|
||||
switch o := obj.(type) {
|
||||
case *object.Commit:
|
||||
return o.Hash, nil
|
||||
return o, nil
|
||||
case *object.Tag:
|
||||
hash = o.Target
|
||||
default:
|
||||
return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
|
||||
return nil, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTagToCommit follows tag objects until it reaches a commit and returns its hash.
|
||||
func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) {
|
||||
c, err := resolveTagToCommitObj(repo, ref)
|
||||
if err != nil {
|
||||
return plumbing.ZeroHash, err
|
||||
}
|
||||
return c.Hash, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gitutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
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"
|
||||
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
|
||||
|
||||
"git.k3nny.fr/releaser/internal/branch"
|
||||
)
|
||||
@@ -699,3 +701,408 @@ func TestPushWithBareRemote(t *testing.T) {
|
||||
t.Fatalf("second Push returned unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── IsWorkingTreeClean: w.Status() error path ────────────────────────────────
|
||||
|
||||
func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) {
|
||||
// Use a filesystem repo so we can corrupt the on-disk index.
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
// Overwrite .git/index with garbage so go-git fails to parse it.
|
||||
indexPath := filepath.Join(dir, ".git", "index")
|
||||
if err := os.WriteFile(indexPath, []byte("not a valid git index"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Reopen — fresh repository object with no cached index.
|
||||
repo2, err := gogit.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = IsWorkingTreeClean(repo2)
|
||||
if err == nil {
|
||||
t.Error("expected error when git index is corrupt")
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatestTag: head commit object missing ────────────────────────────────────
|
||||
|
||||
func TestLatestTagHeadCommitMissing(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// Detach HEAD to a fake hash that has no backing commit object.
|
||||
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
||||
if err == nil {
|
||||
t.Error("expected error when HEAD commit object is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatestTag: Tags() iterator fails ────────────────────────────────────────
|
||||
|
||||
func TestLatestTagTagsIterFails(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
// Make .git/refs/tags/ unreadable so that go-git's walkReferencesTree
|
||||
// returns EPERM when it tries to list the directory, triggering the
|
||||
// Tags() error path. Skip when running as root (chmod has no effect).
|
||||
tagsDir := filepath.Join(dir, ".git", "refs", "tags")
|
||||
if err := os.Chmod(tagsDir, 0000); err != nil {
|
||||
t.Skipf("cannot chmod %s: %v", tagsDir, err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(tagsDir, 0755) })
|
||||
|
||||
// Reopen so the filesystem storer holds no cached state.
|
||||
repo2, err := gogit.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Skipf("PlainOpen failed (likely running as root): %v", err)
|
||||
}
|
||||
|
||||
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
||||
if err == nil {
|
||||
t.Error("expected error when refs/tags is unreadable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── LatestTag: IsAncestor fails → ForEach propagates error ───────────────────
|
||||
|
||||
func TestLatestTagIsAncestorFails(t *testing.T) {
|
||||
// Topology: c0 (base) → c1 (sibling branch, tagged v1.2.0)
|
||||
// → c2 (master HEAD — diverged from sibling)
|
||||
// The tag is NOT an ancestor of HEAD. IsAncestor must walk master's history
|
||||
// all the way back to c0; corrupting c0 makes that walk fail.
|
||||
repo, dir := newTestRepo(t)
|
||||
c0 := addCommit(t, repo, dir, "chore: base", "base")
|
||||
|
||||
w, _ := repo.Worktree()
|
||||
if err := w.Checkout(&gogit.CheckoutOptions{
|
||||
Branch: plumbing.NewBranchReferenceName("sibling"),
|
||||
Hash: c0,
|
||||
Create: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addCommit(t, repo, dir, "feat: sibling work", "sibling")
|
||||
addTag(t, repo, "v1.2.0") // tag on the sibling commit (not an ancestor of master)
|
||||
|
||||
if err := w.Checkout(&gogit.CheckoutOptions{
|
||||
Branch: plumbing.NewBranchReferenceName("master"),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addCommit(t, repo, dir, "fix: mainline", "mainline") // HEAD on master
|
||||
|
||||
// Corrupt c0 (the common base) so that IsAncestor's commit-graph walk
|
||||
// fails when it tries to read c0 as a parent of the master HEAD commit.
|
||||
hashStr := c0.String()
|
||||
objPath := filepath.Join(dir, ".git", "objects", hashStr[:2], hashStr[2:])
|
||||
if err := os.Chmod(objPath, 0644); err != nil {
|
||||
t.Fatalf("chmod object: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(objPath, []byte("corrupt"), 0444); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
repo2, err := gogit.PlainOpen(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The ForEach callback propagates the IsAncestor error, so LatestTag
|
||||
// must return a non-nil error (covers the refs.ForEach error path).
|
||||
_, _, err = LatestTag(repo2, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"})
|
||||
if err == nil {
|
||||
t.Error("expected error when commit graph is corrupt during IsAncestor")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitsSince: repo.Head() fails after tag resolve ────────────────────────
|
||||
|
||||
func TestCommitsSinceHeadRemoved(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
|
||||
// Remove HEAD so repo.Head() returns ErrReferenceNotFound.
|
||||
if err := repo.Storer.RemoveReference(plumbing.HEAD); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CommitsSince(repo, "v1.2.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when HEAD reference is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitsSince: repo.Log() fails ───────────────────────────────────────────
|
||||
|
||||
func TestCommitsSinceFakeHead(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.2.0")
|
||||
addCommit(t, repo, dir, "fix: c2", "v2")
|
||||
|
||||
// Point HEAD directly to a non-existent commit hash.
|
||||
// repo.Head() succeeds (returns the hash) but repo.Log() fails eagerly.
|
||||
fakeHash := plumbing.NewHash("cafebabecafebabecafebabecafebabecafebabe")
|
||||
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := CommitsSince(repo, "v1.2.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when HEAD commit object is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── AllCommits: repo.Log() fails ─────────────────────────────────────────────
|
||||
|
||||
func TestAllCommitsFakeHead(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
// Point HEAD to a non-existent commit hash so repo.Log() fails eagerly.
|
||||
fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := AllCommits(repo)
|
||||
if err == nil {
|
||||
t.Error("expected error when HEAD commit object is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// ── CommitFiles: w.Commit() fails ────────────────────────────────────────────
|
||||
|
||||
func TestCommitFilesUnchanged(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "chore: init", "initial")
|
||||
|
||||
// test.txt already committed and unchanged — w.Add succeeds, w.Commit fails
|
||||
// (go-git rejects empty commits when AllowEmptyCommits is false).
|
||||
_, err := CommitFiles(repo, []string{"test.txt"}, "chore: empty", "Test", "t@t.com")
|
||||
if err == nil {
|
||||
t.Error("expected error when committing unchanged file (empty commit)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Push: SSH agent success / failure paths ──────────────────────────────────
|
||||
|
||||
func TestPushSSHAgentSucceeds(t *testing.T) {
|
||||
// Mock sshPush so it succeeds without a real SSH agent.
|
||||
orig := sshPush
|
||||
sshPush = func(_ *gogit.Repository, _, _ string) error { return nil }
|
||||
defer func() { sshPush = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"git@example.com:owner/repo.git"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Push(repo, "master", "v1.0.0", ""); err != nil {
|
||||
t.Fatalf("Push with mocked SSH agent should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushSSHAgentFailsFallsBackToCLI(t *testing.T) {
|
||||
// SSH URL remote + sshPush fails → falls through to pushWithCLI.
|
||||
orig := sshPush
|
||||
sshPush = func(_ *gogit.Repository, _, _ string) error { return fmt.Errorf("no agent") }
|
||||
defer func() { sshPush = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"git@example.com:owner/repo.git"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// CLI push will fail (no real remote) — we just verify it ran at all.
|
||||
err := Push(repo, "master", "v1.0.0", "")
|
||||
if err == nil {
|
||||
t.Error("expected error after SSH fallback to CLI with unreachable remote")
|
||||
}
|
||||
}
|
||||
|
||||
// ── pushWithSSHAgent internals ────────────────────────────────────────────────
|
||||
|
||||
func TestPushWithSSHAgentAuthFails(t *testing.T) {
|
||||
orig := newSSHAgentAuth
|
||||
newSSHAgentAuth = func(_ string) (*gitssh.PublicKeysCallback, error) {
|
||||
return nil, fmt.Errorf("SSH_AUTH_SOCK not set")
|
||||
}
|
||||
defer func() { newSSHAgentAuth = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
|
||||
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when SSH agent auth fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithSSHAgentNoRemote(t *testing.T) {
|
||||
orig := newSSHAgentAuth
|
||||
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
||||
return &gitssh.PublicKeysCallback{User: user}, nil
|
||||
}
|
||||
defer func() { newSSHAgentAuth = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
// No remote configured → repo.Remote("origin") fails.
|
||||
|
||||
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when no origin remote is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithSSHAgentPushFails(t *testing.T) {
|
||||
orig := newSSHAgentAuth
|
||||
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
||||
return &gitssh.PublicKeysCallback{User: user}, nil
|
||||
}
|
||||
defer func() { newSSHAgentAuth = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.0.0")
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"/nonexistent/bare/repo"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := pushWithSSHAgent(repo, "master", "v1.0.0")
|
||||
if err == nil {
|
||||
t.Error("expected error when remote push fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithSSHAgentSuccess(t *testing.T) {
|
||||
remoteDir := t.TempDir()
|
||||
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
orig := newSSHAgentAuth
|
||||
newSSHAgentAuth = func(user string) (*gitssh.PublicKeysCallback, error) {
|
||||
return &gitssh.PublicKeysCallback{User: user}, nil
|
||||
}
|
||||
defer func() { newSSHAgentAuth = orig }()
|
||||
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.0.0")
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{remoteDir},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Local transport ignores auth — push succeeds regardless of the mock callback.
|
||||
if err := pushWithSSHAgent(repo, "master", "v1.0.0"); err != nil {
|
||||
t.Fatalf("expected success pushing to local bare remote: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── pushWithGoGit error paths ─────────────────────────────────────────────────
|
||||
|
||||
func TestPushWithGoGitNoRemote(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
// No remote → repo.Remote("origin") fails inside pushWithGoGit.
|
||||
err := Push(repo, "master", "v1.0.0", "some-token")
|
||||
if err == nil {
|
||||
t.Error("expected error when no origin remote is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithGoGitPushFails(t *testing.T) {
|
||||
repo, dir := newTestRepo(t)
|
||||
addCommit(t, repo, dir, "fix: c1", "v1")
|
||||
addTag(t, repo, "v1.0.0")
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{"/nonexistent/bare/repo"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := Push(repo, "master", "v1.0.0", "some-token")
|
||||
if err == nil {
|
||||
t.Error("expected error when remote push fails")
|
||||
}
|
||||
}
|
||||
|
||||
// ── pushWithCLI: bare repo → no worktree ────────────────────────────────────
|
||||
|
||||
func TestPushWithCLIBareRepo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
repo, err := gogit.PlainInit(dir, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// No token, no SSH URL → goes to pushWithCLI → Worktree() fails for bare repo.
|
||||
err = Push(repo, "master", "v1.0.0", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for bare repo (no worktree)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushWithCLISuccess(t *testing.T) {
|
||||
// Non-bare repo + local bare remote + no token + no SSH URL → pushWithCLI → success.
|
||||
repo, dir := newTestRepo(t)
|
||||
sig := testSig()
|
||||
|
||||
wt, _ := repo.Worktree()
|
||||
if err := os.WriteFile(filepath.Join(dir, "f.txt"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wt.Add("f.txt")
|
||||
hash, err := wt.Commit("init", &gogit.CommitOptions{Author: sig})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.CreateTag("v1.0.0", hash, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remoteDir := t.TempDir()
|
||||
if _, err := gogit.PlainInit(remoteDir, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.CreateRemote(&gitconfig.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{remoteDir},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Detect default branch name (go-git uses "master" but git config may differ).
|
||||
head, _ := repo.Head()
|
||||
branchName := head.Name().Short()
|
||||
|
||||
if err := Push(repo, branchName, "v1.0.0", ""); err != nil {
|
||||
t.Fatalf("pushWithCLI success: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReadVersion returns the version field from a package.json file.
|
||||
func ReadVersion(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
var pkg struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return "", fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
if pkg.Version == "" {
|
||||
return "", fmt.Errorf("no version field in %s", path)
|
||||
}
|
||||
return pkg.Version, nil
|
||||
}
|
||||
|
||||
// WriteVersion replaces the version field in a package.json file in-place.
|
||||
// oldVersion must match what ReadVersion returned. Formatting is preserved.
|
||||
func WriteVersion(path, oldVersion, newVersion string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
old := `"version": "` + oldVersion + `"`
|
||||
repl := `"version": "` + newVersion + `"`
|
||||
if !strings.Contains(string(data), old) {
|
||||
return fmt.Errorf("version %q not found in %s", oldVersion, path)
|
||||
}
|
||||
updated := strings.Replace(string(data), old, repl, 1)
|
||||
return os.WriteFile(path, []byte(updated), 0644)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeJSON(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "package.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
const simplePackage = `{
|
||||
"name": "my-app",
|
||||
"version": "1.2.3",
|
||||
"description": "test"
|
||||
}`
|
||||
|
||||
func TestReadVersion(t *testing.T) {
|
||||
got, err := ReadVersion(writeJSON(t, simplePackage))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "1.2.3" {
|
||||
t.Errorf("got %q, want 1.2.3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionMissingFile(t *testing.T) {
|
||||
_, err := ReadVersion(filepath.Join(t.TempDir(), "package.json"))
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionInvalidJSON(t *testing.T) {
|
||||
_, err := ReadVersion(writeJSON(t, `{not valid json`))
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVersionNoVersionField(t *testing.T) {
|
||||
_, err := ReadVersion(writeJSON(t, `{"name":"app"}`))
|
||||
if err == nil {
|
||||
t.Error("expected error when version field is absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersion(t *testing.T) {
|
||||
path := writeJSON(t, simplePackage)
|
||||
if err := WriteVersion(path, "1.2.3", "1.2.4"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(data), `"version": "1.2.4"`) {
|
||||
t.Errorf("expected updated version in file; got:\n%s", data)
|
||||
}
|
||||
// name and description must be preserved
|
||||
if !strings.Contains(string(data), `"name": "my-app"`) {
|
||||
t.Error("name field was lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersionMissingFile(t *testing.T) {
|
||||
err := WriteVersion(filepath.Join(t.TempDir(), "package.json"), "1.0.0", "1.0.1")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteVersionNotFound(t *testing.T) {
|
||||
err := WriteVersion(writeJSON(t, simplePackage), "9.9.9", "9.9.10")
|
||||
if err == nil {
|
||||
t.Error("expected error when old version not found")
|
||||
}
|
||||
}
|
||||
|
||||
// FuzzReadVersion verifies ReadVersion never panics on arbitrary content.
|
||||
func FuzzReadVersion(f *testing.F) {
|
||||
f.Add(simplePackage)
|
||||
f.Add(`{}`)
|
||||
f.Add(`{"version":"1.0.0"}`)
|
||||
f.Add(`not json at all`)
|
||||
f.Add(``)
|
||||
f.Add("\x00\xff")
|
||||
|
||||
f.Fuzz(func(t *testing.T, content string) {
|
||||
path := filepath.Join(t.TempDir(), "package.json")
|
||||
os.WriteFile(path, []byte(content), 0644) //nolint:errcheck
|
||||
ReadVersion(path) //nolint:errcheck
|
||||
})
|
||||
}
|
||||
+1
-33
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,14 +6,38 @@ import (
|
||||
"git.k3nny.fr/releaser/internal/commits"
|
||||
)
|
||||
|
||||
// BumpLevel controls which version component is incremented.
|
||||
type BumpLevel int
|
||||
|
||||
const (
|
||||
BumpPatch BumpLevel = iota
|
||||
BumpMinor
|
||||
)
|
||||
|
||||
// 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.
|
||||
// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all.
|
||||
// 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, bumpRules map[commits.Type]BumpLevel) (string, bool) {
|
||||
if releasable == nil {
|
||||
releasable = commits.ReleasableSet(nil)
|
||||
}
|
||||
found := false
|
||||
useMinor := false
|
||||
for _, t := range types {
|
||||
if t != commits.TypeNone {
|
||||
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
|
||||
if releasable[t] {
|
||||
found = true
|
||||
if bumpRules[t] == BumpMinor {
|
||||
useMinor = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
if useMinor {
|
||||
return fmt.Sprintf("%d.%d.0", major, minor+1), true
|
||||
}
|
||||
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestNext(t *testing.T) {
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
desc: "breaking change still bumps patch on release branch",
|
||||
desc: "breaking change still bumps patch by default",
|
||||
major: 2, minor: 0, currentPatch: 1,
|
||||
types: []commits.Type{commits.TypeBreaking},
|
||||
want: "2.0.2",
|
||||
@@ -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, nil)
|
||||
if ok != c.wantOk {
|
||||
t.Errorf("ok=%v, want %v", ok, c.wantOk)
|
||||
}
|
||||
@@ -71,3 +71,73 @@ func TestNext(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextMinorBump(t *testing.T) {
|
||||
rules := map[commits.Type]BumpLevel{
|
||||
commits.TypeBreaking: BumpMinor,
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
desc string
|
||||
major, minor int
|
||||
currentPatch int
|
||||
types []commits.Type
|
||||
want string
|
||||
}{
|
||||
{
|
||||
desc: "breaking → minor bump",
|
||||
major: 1, minor: 2, currentPatch: 3,
|
||||
types: []commits.Type{commits.TypeBreaking},
|
||||
want: "1.3.0",
|
||||
},
|
||||
{
|
||||
desc: "feat (patch rule) with breaking (minor rule) → minor wins",
|
||||
major: 1, minor: 2, currentPatch: 3,
|
||||
types: []commits.Type{commits.TypeFeat, commits.TypeBreaking},
|
||||
want: "1.3.0",
|
||||
},
|
||||
{
|
||||
desc: "fix only — no minor rule → patch bump",
|
||||
major: 1, minor: 2, currentPatch: 3,
|
||||
types: []commits.Type{commits.TypeFix},
|
||||
want: "1.2.4",
|
||||
},
|
||||
{
|
||||
desc: "first release with minor bump",
|
||||
major: 2, minor: 0, currentPatch: -1,
|
||||
types: []commits.Type{commits.TypeBreaking},
|
||||
want: "2.1.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.desc, func(t *testing.T) {
|
||||
got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, rules)
|
||||
if !ok {
|
||||
t.Fatal("expected ok=true")
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextAllMinorRules(t *testing.T) {
|
||||
rules := map[commits.Type]BumpLevel{
|
||||
commits.TypeBreaking: BumpMinor,
|
||||
commits.TypeFeat: BumpMinor,
|
||||
commits.TypeFix: BumpMinor,
|
||||
}
|
||||
got, ok := Next(1, 4, 2, []commits.Type{commits.TypeFix}, nil, rules)
|
||||
if !ok || got != "1.5.0" {
|
||||
t.Errorf("got %q ok=%v, want 1.5.0 true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextNilBumpRulesDefaultsToPatch(t *testing.T) {
|
||||
got, ok := Next(1, 2, 5, []commits.Type{commits.TypeBreaking}, nil, nil)
|
||||
if !ok || got != "1.2.6" {
|
||||
t.Errorf("got %q ok=%v, want 1.2.6 true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user