9 Commits

Author SHA1 Message Date
k3nny f07220b0c6 feat(releaser): release v1.5.0 — Node.js, multi-module Maven, configurable bump rules
ci / vet, staticcheck, test, build (push) Failing after 4m11s
release / Build and publish release (push) Successful in 5m7s
- Add internal/node package: reads/writes package.json version (single or
  multi-path via node.package_json / node.package_jsons)
- Add maven.pom_paths support: update multiple pom.xml files in one release
  commit; pom_paths overrides pom_path; --pom flag clears pom_paths
- Add git.bump_rules config: per-type control of which version component bumps
  (breaking/feat/fix accept "patch" or "minor"); wired through version.Next()
  as a new sixth parameter
- Extract injectable function vars (absPath, gitAllCommits, gitCommitsSince,
  gitCommitFiles) to enable error-path testing without interfaces
- Achieve 100% per-package statement coverage across all 12 packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 16:59:28 +02:00
k3nny 5af107b06d feat(releaser): release v1.4.0 — GitHub, SSH agent, configurable bumps
ci / vet, staticcheck, test, build (push) Successful in 3m54s
release / Build and publish release (push) Successful in 4m11s
- GitHub release support: internal/ghclient (no SDK, minimal HTTP client);
  GITHUB_TOKEN env var; github.token/github.repo config; GitHub takes
  precedence over GitLab when both are configured; publisher interface
  (releasePublisher) in cmd/main.go makes providers interchangeable
- SSH agent push: gitutil.Push() now tries gitssh.NewSSHAgentAuth for
  git@/ssh:// remotes before falling back to the system git binary
- --release-env-file flag: override dotenv artifact path; pass "" to disable
- git.releasable_types config: filter which commit types trigger a bump
  (defaults to fix, feat, breaking); useful for maintenance branches
- commits.Group(), ExtractSubject(), ReleasableSet() exported helpers:
  notes.go and changelog.go now delegate to these instead of duplicating
- CHANGELOG deduplication guard: changelog.Update() is idempotent — skips
  write if ## [version] section already exists
- Always load config sources: LoadWithSources() called unconditionally;
  removes the dual config path that previously only tracked sources in
  --verbose mode
- .releaser.gitlab-ci.yml: adds artifacts: reports: dotenv: release.env

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-11 00:15:09 +02:00
k3nny 62a702fb89 feat(releaser): release v1.3.0 — release.env dotenv artifact
ci / vet, staticcheck, test, build (push) Successful in 2m48s
release / Build and publish release (push) Successful in 4m35s
Documents the release.env feature: a NEXT_VERSION=<tag> file written
to the repo root on every real release, intended as a GitLab CI dotenv
artifact for passing the version to downstream pipeline jobs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 13:57:06 +02:00
k3nny 2614f23856 feat(releaser): write release.env dotenv artifact on every release
ci / vet, staticcheck, test, build (push) Successful in 3m9s
After the next version is determined and dry-run is confirmed off,
release.env is written to the repository root:

  NEXT_VERSION=<nextTag>

This file is not committed — it is left as an untracked artifact so
GitLab CI can expose it as a dotenv artifact and pass NEXT_VERSION to
downstream jobs (e.g. deploy, notify).

The file is skipped in --dry-run mode. Two tests added:
TestRunReleaseEnv and TestRunReleaseEnvDryRun.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 13:54:38 +02:00
k3nny 12cb3a71af feat(releaser): release v1.2.0 — verbose, colored output, no-v default
ci / vet, staticcheck, test, build (push) Successful in 3m18s
release / Build and publish release (push) Successful in 4m46s
- --verbose flag: config source table, per-commit type analysis, version
  decision explanation; output always to stderr
- Colored structured output: logStep/logDone/logWarn symbols, ▸ verbose
  section headers, TTY-aware ANSI colors (NO_COLOR / TERM=dumb respected)
- Name + version header printed at the start of every invocation
- Default tag_prefix changed from "v" to "" (bare 1.2.3 tags by default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 12:01:51 +02:00
k3nny 16b25da396 feat(config): change default tag_prefix to empty (no prefix)
ci / vet, staticcheck, test, build (push) Successful in 3m35s
Tags are now bare version numbers by default (e.g. 1.2.3).
Set tag_prefix: "v" in .releaser.yml or pass --tag-prefix v to opt in
to the v-prefixed convention.

Updated all affected tests, the .releaser.yml template comment, and
the README configuration reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:58:29 +02:00
k3nny 6984fcc547 feat(ui): print releaser name and version header on every run
ci / vet, staticcheck, test, build (push) Successful in 3m28s
Adds a logHeader() helper that prints "releaser  v<version>" to stderr
at the start of every invocation, before any other output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:53:22 +02:00
k3nny 153d65bc53 feat(ui): add colored, structured CLI output
ci / vet, staticcheck, test, build (push) Successful in 3m15s
Replace flat "info:" / "warning:" stderr lines with:
- logStep (·), logDone (✓), logWarn (!) prefix symbols
- ANSI colors when stderr is a TTY; auto-disabled via NO_COLOR or TERM=dumb
- Verbose mode uses ▸ section headers (configuration / branch / commits / version)
- Config table source tags colored: dim=[default], bold=[config file],
  cyan=[env:], green=[flag:]
- Commit table in verbose mode: type column colored by kind (cyan=feat,
  green=fix, red=breaking), ignored commits dimmed
- New cmd/ui.go holds all color helpers (paint, logStep, logDone, logWarn,
  logSection, fmtSource); removes the vlog() helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:46:42 +02:00
k3nny 46a10c70dc feat(releaser): add --verbose flag for configuration and decision tracing
ci / vet, staticcheck, test, build (push) Successful in 3m10s
- Prints a configuration table on startup showing each key, its value,
  and the source (default / config file / env: VARNAME / flag: --name)
- Lists every commit since the last tag with its parsed type and
  the version-bump decision (feat/fix/breaking → patch bump, or ignored)
- Explains the final version choice: highest commit type → next tag
- All verbose output goes to stderr so it never pollutes stdout captures
- Sources tracking wired through config.LoadWithSources and
  ApplyEnvWithSources; LoadWithSources uses a two-pass approach to
  detect which YAML fields were explicitly set vs defaulted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 11:35:22 +02:00
24 changed files with 2555 additions and 228 deletions
+4
View File
@@ -45,5 +45,9 @@
--branch "$CI_COMMIT_BRANCH" --branch "$CI_COMMIT_BRANCH"
$RELEASER_EXTRA_ARGS $RELEASER_EXTRA_ARGS
artifacts:
reports:
dotenv: release.env # exposes NEXT_VERSION to downstream jobs
environment: environment:
name: release/$CI_COMMIT_BRANCH name: release/$CI_COMMIT_BRANCH
+29 -2
View File
@@ -19,10 +19,37 @@ git:
# author_name: "" # author_name: ""
# author_email: "" # author_email: ""
maven: # Limit which commit types trigger a release (default: fix, feat, breaking).
# Path to pom.xml, relative to the repository root. # 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" # 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:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com" # url: "https://gitlab.example.com"
+52
View File
@@ -3,6 +3,58 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [1.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
- **`--verbose` flag** — prints a configuration table (each key, its value, and source: `default` / `config file` / `env: VARNAME` / `flag: --name`), lists every commit since the last tag with its parsed type and bump decision, and shows the final version choice; all output goes to stderr
- **Colored, structured CLI output** — progress lines use `·` / `✓` / `!` prefix symbols; `--verbose` mode uses `▸` section headers (configuration / branch / commits / version); commit type column colored by kind (cyan=feat, green=fix, red=breaking); config source tags colored; respects `NO_COLOR` and `TERM=dumb`; auto-disabled when stderr is not a TTY
- **Name and version header** — `releaser vX.Y.Z` printed to stderr at the start of every invocation
### Changed
- **Default `tag_prefix` is now empty** — tags are bare version numbers (`1.2.3`) by default; add `tag_prefix: "v"` to `.releaser.yml` or pass `--tag-prefix v` to opt in to the `v`-prefixed convention
## [1.1.0] - 2026-07-07 ## [1.1.0] - 2026-07-07
### Added ### Added
+58 -24
View File
@@ -1,6 +1,6 @@
# releaser # releaser
![release](https://img.shields.io/badge/release-v1.1.0-blue.svg) ![release](https://img.shields.io/badge/release-v1.5.0-blue.svg)
A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. A CI-friendly release automation tool for GitFlow workflows using Conventional Commits.
@@ -8,32 +8,34 @@ A CI-friendly release automation tool for GitFlow workflows using Conventional C
Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration. Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration.
`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab tag+release creation. `releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab/GitHub tag+release creation.
## How it works ## How it works
``` ```
release/1.2 branch release/1.2 branch
└─ last tag: v1.2.3 (or none → start at v1.2.0) └─ last tag: 1.2.3 (or none → start at 1.2.0)
└─ commits since tag → Conventional Commits analysis └─ commits since tag → Conventional Commits analysis
└─ next version: v1.2.4 └─ next version: 1.2.4
``` ```
1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2``1.2`) 1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2``1.2`)
2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch 2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch
3. **Commit analysis** — parses Conventional Commits between last tag and HEAD 3. **Commit analysis** — parses Conventional Commits between last tag and HEAD
4. **Version bump** — increments patch (the minor is owned by the branch) 4. **Version bump** — increments patch (the minor is owned by the branch)
5. **Release** — updates `pom.xml`, commits, tags, creates GitLab release 5. **Release** — updates `pom.xml`, commits, tags, creates GitLab or GitHub release
## Version bump rules ## Version bump rules
| Commit type | Bump | Notes | 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`:
|------------------|---------|--------------------------------------------|
| `fix:` | patch | | | Commit type | Default | Configurable via `bump_rules` |
| `feat:` | patch | minor is pinned to branch | |------------------|---------|------------------------------------------------|
| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary | | `fix:` | patch | `fix: "minor"` to bump minor instead |
| `chore:`, `docs:`, etc. | none | | | `feat:` | patch | `feat: "minor"` to bump minor instead |
| unparseable msg | none | non-strict mode: silently ignored | | `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor |
| `chore:`, `docs:`, etc. | none | — |
| unparseable msg | none | non-strict mode: silently ignored |
## Usage ## Usage
@@ -44,13 +46,13 @@ releaser --init
# Simulate next version (no side effects) # Simulate next version (no side effects)
releaser --dry-run releaser --dry-run
# Full release: update pom.xml + CHANGELOG.md, commit, tag, push, GitLab release # Full release: update pom.xml + CHANGELOG.md, commit, tag, push, create release
releaser releaser
# Commit and tag locally — skip push and GitLab release # Commit and tag locally — skip push and release creation
releaser --no-push releaser --no-push
# Push commit and tag but skip creating the GitLab release # Push commit and tag but skip creating the release
releaser --no-release releaser --no-release
# Update files but stop before committing (review first) # Update files but stop before committing (review first)
@@ -64,6 +66,9 @@ releaser --branch release/1.2
# Write changelog to a custom file # Write changelog to a custom file
releaser --changelog-file CHANGES.md releaser --changelog-file CHANGES.md
# Show configuration sources, commit list, and version decision
releaser --verbose --dry-run
# Target a specific pom.xml # Target a specific pom.xml
releaser --pom path/to/pom.xml releaser --pom path/to/pom.xml
@@ -72,6 +77,9 @@ releaser --tag-prefix ""
# Override branch pattern (e.g. also match hotfix/ branches) # Override branch pattern (e.g. also match hotfix/ branches)
releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$" releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
# Write dotenv artifact to a custom path (or "" to disable)
releaser --release-env-file deploy/version.env
``` ```
## Configuration ## Configuration
@@ -80,31 +88,54 @@ releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$"
```yaml ```yaml
git: git:
tag_prefix: "v" # set to "" for tags without prefix tag_prefix: "" # default: no prefix; set to "v" for v-prefixed tags
branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor
commit_message: "chore(release): {version} [skip ci]" commit_message: "chore(release): {version} [skip ci]"
author_name: "" # defaults to git config user.name author_name: "" # defaults to git config user.name
author_email: "" # defaults to git config user.email author_email: "" # defaults to git config user.email
releasable_types: # default: all three
- fix
- feat
- breaking
bump_rules: # which version component each type bumps
breaking: "patch" # "minor" to bump minor on breaking changes
feat: "patch"
fix: "patch"
maven: 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: gitlab:
url: "https://gitlab.example.com" # or env CI_SERVER_URL url: "https://gitlab.example.com" # or env CI_SERVER_URL
token: "" # env GITLAB_TOKEN (never commit this) token: "" # env GITLAB_TOKEN (never commit this)
project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH
github:
token: "" # env GITHUB_TOKEN (never commit this)
repo: "" # "owner/repo" format
``` ```
### Environment variables ### Environment variables
GitLab-related fields are automatically read from the CI environment if not set in the config file: | Variable | Used for |
|--------------------|-----------------------------------|
| `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth |
| `CI_SERVER_URL` | GitLab instance URL |
| `CI_PROJECT_ID` | GitLab project identifier (numeric) |
| `CI_PROJECT_PATH` | GitLab project identifier (fallback) |
| `GITHUB_TOKEN` | GitHub API auth |
| Variable | Used for | When both `github.*` and `gitlab.*` are configured, GitHub takes precedence.
|-------------------|-----------------------------------|
| `GITLAB_TOKEN` | API auth + HTTPS push auth |
| `CI_SERVER_URL` | GitLab instance URL |
| `CI_PROJECT_ID` | Project identifier (numeric) |
| `CI_PROJECT_PATH` | Project identifier (fallback) |
## CI integration (GitLab CI example) ## CI integration (GitLab CI example)
@@ -118,4 +149,7 @@ release:
GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope
script: script:
- releaser - releaser
artifacts:
reports:
dotenv: release.env # exposes NEXT_VERSION to downstream jobs
``` ```
+16 -4
View File
@@ -66,13 +66,25 @@
- [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos) - [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos)
- [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64) - [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64)
- [x] ~~`--verbose` flag~~ — ✓ shipped v1.2.0 (shows config sources, commit analysis, version decision)
- [x] ~~Colored, structured CLI output~~ — ✓ shipped v1.2.0 (`·` / `` / `!` symbols, `` section headers in verbose, TTY-aware ANSI colors)
- [x] ~~Name and version header on every run~~ — ✓ shipped v1.2.0
- [x] ~~Default tag prefix changed to empty~~ — ✓ shipped v1.2.0 (bare `1.2.3` tags by default; opt in to `v` prefix via config)
- [x] ~~`release.env` dotenv artifact~~ — ✓ shipped v1.3.0 (`NEXT_VERSION=<tag>` written on every release for GitLab CI downstream jobs)
- [x] ~~GitHub release support~~ — ✓ shipped v1.4.0 (`internal/ghclient`, `GITHUB_TOKEN` env, `github.token`/`github.repo` config; GitHub takes precedence over GitLab)
- [x] ~~SSH agent push~~ — ✓ shipped v1.4.0 (go-git `gitssh.NewSSHAgentAuth` for `git@`/`ssh://` remotes)
- [x] ~~Configurable bump rules~~ — ✓ shipped v1.4.0 (`git.releasable_types` config; filter which commit types trigger a release)
- [ ] Documentation site - [ ] Documentation site
## 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 ## Future / backlog
- GitHub release support (parity with GitLab)
- Multi-module Maven support (multiple `pom.xml` paths)
- Gradle support (`build.gradle` / `build.gradle.kts`) - Gradle support (`build.gradle` / `build.gradle.kts`)
- `package.json` version bump support (Node.js projects)
- Slack / Teams notification on release - Slack / Teams notification on release
- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch) - Documentation site
+318 -63
View File
@@ -15,9 +15,11 @@ import (
"git.k3nny.fr/releaser/internal/changelog" "git.k3nny.fr/releaser/internal/changelog"
"git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/commits"
"git.k3nny.fr/releaser/internal/config" "git.k3nny.fr/releaser/internal/config"
"git.k3nny.fr/releaser/internal/glclient" "git.k3nny.fr/releaser/internal/ghclient"
"git.k3nny.fr/releaser/internal/gitutil" "git.k3nny.fr/releaser/internal/gitutil"
"git.k3nny.fr/releaser/internal/glclient"
"git.k3nny.fr/releaser/internal/maven" "git.k3nny.fr/releaser/internal/maven"
"git.k3nny.fr/releaser/internal/node"
"git.k3nny.fr/releaser/internal/notes" "git.k3nny.fr/releaser/internal/notes"
semver "git.k3nny.fr/releaser/internal/version" semver "git.k3nny.fr/releaser/internal/version"
) )
@@ -27,7 +29,7 @@ const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.f
# CLI flags always take precedence over values set here. # CLI flags always take precedence over values set here.
git: git:
# Prefix prepended to every version tag. # Prefix prepended to every version tag (default: no prefix).
# tag_prefix: "v" # tag_prefix: "v"
# Regex that identifies release branches. Must contain exactly two capture # Regex that identifies release branches. Must contain exactly two capture
@@ -43,10 +45,39 @@ git:
# author_name: "" # author_name: ""
# author_email: "" # author_email: ""
# Limit which commit types trigger a release (default: fix, feat, breaking).
# releasable_types:
# - fix
# - feat
# - breaking
# 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: maven:
# Path to pom.xml, relative to the repository root. # Single pom.xml path, relative to the repository root.
# pom_path: "pom.xml" # 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:
# GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable.
# url: "https://gitlab.example.com" # url: "https://gitlab.example.com"
@@ -59,6 +90,14 @@ gitlab:
# Numeric project ID or "namespace/project" path. # Numeric project ID or "namespace/project" path.
# Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables.
# project: "" # project: ""
github:
# GitHub personal access token with repo scope.
# Falls back to the GITHUB_TOKEN environment variable.
# token: ""
# Repository in "owner/repo" format.
# repo: ""
` `
var ( var (
@@ -69,22 +108,53 @@ var (
// exitFn is a variable so tests can intercept os.Exit calls. // exitFn is a variable so tests can intercept os.Exit calls.
var exitFn = os.Exit var exitFn = os.Exit
// 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 { func newRootCmd() *cobra.Command {
var ( var (
init_ bool init_ bool
dryRun bool verbose bool
noPush bool dryRun bool
noRelease bool noPush bool
noCommit bool noRelease bool
tagOnly bool noCommit bool
branchOverride string tagOnly bool
repoPath string branchOverride string
pomOverride string repoPath string
changelogFile string pomOverride string
tagPrefixFlag string changelogFile string
tagPrefixSet bool tagPrefixFlag string
patternFlag string tagPrefixSet bool
patternSet bool patternFlag string
patternSet bool
releaseEnvFile string
) )
root := &cobra.Command{ root := &cobra.Command{
@@ -97,6 +167,7 @@ func newRootCmd() *cobra.Command {
patternSet = cmd.Flags().Changed("branch-pattern") patternSet = cmd.Flags().Changed("branch-pattern")
return run(options{ return run(options{
init: init_, init: init_,
verbose: verbose,
repoPath: repoPath, repoPath: repoPath,
branchOverride: branchOverride, branchOverride: branchOverride,
pomOverride: pomOverride, pomOverride: pomOverride,
@@ -110,14 +181,16 @@ func newRootCmd() *cobra.Command {
noRelease: noRelease, noRelease: noRelease,
noCommit: noCommit, noCommit: noCommit,
tagOnly: tagOnly, tagOnly: tagOnly,
releaseEnvFile: releaseEnvFile,
}) })
}, },
} }
root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit")
root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision")
root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes")
root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a release")
root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the GitLab release") root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the release")
root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push") root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push")
root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)") root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)")
root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)") root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)")
@@ -126,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(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root")
root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config") root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config")
root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config")
root.Flags().StringVar(&releaseEnvFile, "release-env-file", "release.env", "write NEXT_VERSION dotenv to this path (relative to repo root; empty to disable)")
return root return root
} }
@@ -142,6 +216,7 @@ func main() {
type options struct { type options struct {
init bool init bool
verbose bool
repoPath string repoPath string
branchOverride string branchOverride string
pomOverride string pomOverride string
@@ -155,6 +230,76 @@ type options struct {
noRelease bool noRelease bool
noCommit bool noCommit bool
tagOnly bool tagOnly bool
releaseEnvFile string
}
func printVerboseConfig(cfg config.Config, src config.Sources) {
logSection("configuration")
rows := []struct{ key, val string }{
{"git.tag_prefix", cfg.Git.TagPrefix},
{"git.branch_pattern", cfg.Git.BranchPattern},
{"git.commit_message", cfg.Git.CommitMessage},
{"git.author_name", cfg.Git.AuthorName},
{"git.author_email", cfg.Git.AuthorEmail},
{"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 != "" {
return "(set)"
}
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]
if source == "" {
source = "default"
}
val := r.val
if val == "" {
val = paint(ansiDim, "(empty)")
}
fmt.Fprintf(os.Stderr, " %-25s = %-45s %s\n", r.key, val, fmtSource(source))
}
} }
func initConfig(absRepo string) error { func initConfig(absRepo string) error {
@@ -169,32 +314,59 @@ func initConfig(absRepo string) error {
return nil 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 { func run(o options) error {
logHeader(version)
// --- Config --- // --- Config ---
absRepo, err := filepath.Abs(o.repoPath) absRepo, err := absPath(o.repoPath)
if err != nil { if err != nil {
return fmt.Errorf("resolve repo path: %w", err) return fmt.Errorf("resolve repo path: %w", err)
} }
if o.init { if o.init {
if o.verbose {
logStep("creating .releaser.yml in %s", absRepo)
}
return initConfig(absRepo) return initConfig(absRepo)
} }
cfg, err := config.Load(absRepo) cfg, src, err := config.LoadWithSources(absRepo)
if err != nil { if err != nil {
return err return err
} }
cfg.ApplyEnv() cfg.ApplyEnvWithSources(src)
// CLI flags take precedence over config file and env vars // CLI flags take precedence over config file and env vars
if o.tagPrefixSet { if o.tagPrefixSet {
cfg.Git.TagPrefix = o.tagPrefixFlag cfg.Git.TagPrefix = o.tagPrefixFlag
src["git.tag_prefix"] = "flag: --tag-prefix"
} }
if o.pomOverride != "" { if o.pomOverride != "" {
cfg.Maven.PomPath = o.pomOverride cfg.Maven.PomPath = o.pomOverride
cfg.Maven.PomPaths = nil
src["maven.pom_paths"] = "flag: --pom"
} }
if o.patternSet { if o.patternSet {
cfg.Git.BranchPattern = o.patternFlag cfg.Git.BranchPattern = o.patternFlag
src["git.branch_pattern"] = "flag: --branch-pattern"
}
if o.verbose {
printVerboseConfig(cfg, src)
} }
// --- Git --- // --- Git ---
@@ -217,6 +389,13 @@ func run(o options) error {
} }
info.TagPrefix = cfg.Git.TagPrefix info.TagPrefix = cfg.Git.TagPrefix
if o.verbose {
logSection("branch")
fmt.Fprintf(os.Stderr, " %s → major=%d, minor=%d %s\n",
paint(ansiBold, branchName), info.Major, info.Minor,
paint(ansiDim, "(pinned by branch)"))
}
// --- Dirty check (before any changes) --- // --- Dirty check (before any changes) ---
// Skipped in --no-commit mode: the user intentionally has changes in flight. // Skipped in --no-commit mode: the user intentionally has changes in flight.
if !o.dryRun && !o.noCommit { if !o.dryRun && !o.noCommit {
@@ -238,17 +417,21 @@ func run(o options) error {
// --- Commit range --- // --- Commit range ---
var messages []string var messages []string
if lastTag == "" { if lastTag == "" {
fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits") messages, err = gitAllCommits(repo)
messages, err = gitutil.AllCommits(repo)
} else { } else {
fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag) messages, err = gitCommitsSince(repo, lastTag)
messages, err = gitutil.CommitsSince(repo, lastTag)
} }
if err != nil { if err != nil {
return fmt.Errorf("read commits: %w", err) return fmt.Errorf("read commits: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages)) if !o.verbose {
if lastTag == "" {
logStep("no previous tag — scanning all %d commit(s)", len(messages))
} else {
logStep("last tag: %s (%d commit(s) to analyze)", lastTag, len(messages))
}
}
// --- Version calculation --- // --- Version calculation ---
types := make([]commits.Type, len(messages)) types := make([]commits.Type, len(messages))
@@ -256,44 +439,117 @@ func run(o options) error {
types[i] = commits.Parse(msg) types[i] = commits.Parse(msg)
} }
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types) if o.verbose {
logSection(fmt.Sprintf("commits (%d)", len(messages)))
if lastTag != "" {
fmt.Fprintf(os.Stderr, " since: %s (patch=%d)\n", paint(ansiCyan, lastTag), currentPatch)
}
for i, msg := range messages {
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
if len(first) > 70 {
first = first[:67] + "..."
}
t := types[i]
typeLabel := fmt.Sprintf("%-9s", t.String())
if t == commits.TypeNone {
fmt.Fprintf(os.Stderr, " %s\n", paint(ansiDim, typeLabel+first))
} else {
var col string
switch t {
case commits.TypeBreaking:
col = ansiRed + ansiBold
case commits.TypeFeat:
col = ansiCyan
default: // fix
col = ansiGreen
}
fmt.Fprintf(os.Stderr, " %s %s %s\n",
paint(col, typeLabel), first, paint(ansiDim, "→ patch bump"))
}
}
}
releasable := commits.ReleasableSet(cfg.Git.ReleasableTypes)
nextVersion, ok := semver.Next(info.Major, info.Minor, currentPatch, types, releasable, parseBumpRules(cfg.Git.BumpRules))
if !ok { if !ok {
fmt.Fprintln(os.Stderr, "info: no releasable commits found") logWarn("no releasable commits found")
return errNothingToRelease return errNothingToRelease
} }
nextTag := info.TagName(nextVersion) nextTag := info.TagName(nextVersion)
if o.verbose {
highestType := commits.TypeNone
for _, t := range types {
if t > highestType {
highestType = t
}
}
logSection("version")
fmt.Fprintf(os.Stderr, " highest type: %s → next: %s (tag: %s)\n",
paint(ansiCyan, highestType.String()),
paint(ansiBold, nextVersion),
paint(ansiBold+ansiCyan, nextTag))
}
fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag) fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag)
if o.dryRun { if o.dryRun {
fmt.Fprintln(os.Stderr, "dry-run: no changes made") logStep("dry-run: no changes made")
return nil 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) --- // --- pom.xml + CHANGELOG.md (skipped with --tag-only) ---
if !o.tagOnly { if !o.tagOnly {
var filesToCommit []string var filesToCommit []string
// pom.xml // pom.xml (supports multi-module via pom_paths)
pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) anyPom := false
_, statErr := os.Stat(pomPath) for _, relPomPath := range cfg.Maven.EffectivePomPaths() {
hasPom := !errors.Is(statErr, os.ErrNotExist) pomPath := filepath.Join(absRepo, relPomPath)
if statErr != nil && hasPom { _, statErr := os.Stat(pomPath)
return fmt.Errorf("check pom path: %w", statErr) 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)
}
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil {
return fmt.Errorf("update pom version: %w", err)
}
logDone("%s: %s → %s", relPomPath, currentPomVersion, nextVersion)
filesToCommit = append(filesToCommit, relPomPath)
}
} }
if hasPom { if !anyPom {
currentPomVersion, err := maven.ReadVersion(pomPath) 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 { if err != nil {
return fmt.Errorf("read pom version: %w", err) return fmt.Errorf("read package.json version: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion) if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil {
if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { return fmt.Errorf("update package.json version: %w", err)
return fmt.Errorf("update pom version: %w", err)
} }
filesToCommit = append(filesToCommit, cfg.Maven.PomPath) logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion)
} else { filesToCommit = append(filesToCommit, relPkgPath)
fmt.Fprintln(os.Stderr, "info: no pom.xml found — skipping version bump")
} }
// CHANGELOG.md // CHANGELOG.md
@@ -301,7 +557,7 @@ func run(o options) error {
if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil {
return fmt.Errorf("update changelog: %w", err) return fmt.Errorf("update changelog: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: %s updated\n", o.changelogFile) logDone("%s updated", o.changelogFile)
filesToCommit = append(filesToCommit, o.changelogFile) filesToCommit = append(filesToCommit, o.changelogFile)
if o.noCommit { if o.noCommit {
@@ -318,17 +574,17 @@ func run(o options) error {
authorEmail = cfg.Git.AuthorEmail authorEmail = cfg.Git.AuthorEmail
} }
commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) 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) return fmt.Errorf("commit: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) logDone("committed: %s", commitMsg)
} }
// --- Git tag --- // --- Git tag ---
if err := gitutil.CreateTag(repo, nextTag); err != nil { if err := gitutil.CreateTag(repo, nextTag); err != nil {
return fmt.Errorf("create tag: %w", err) return fmt.Errorf("create tag: %w", err)
} }
fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag) logDone("tag: %s", nextTag)
if o.noPush { if o.noPush {
fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag) fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag)
@@ -336,34 +592,33 @@ func run(o options) error {
} }
// --- Push --- // --- Push ---
fmt.Fprintln(os.Stderr, "info: pushing commit and tag...") logStep("pushing commit and tag...")
if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil { if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil {
return fmt.Errorf("push: %w", err) return fmt.Errorf("push: %w", err)
} }
fmt.Fprintln(os.Stderr, "info: pushed") logDone("pushed")
// --- GitLab release ---
if o.noRelease { if o.noRelease {
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" {
fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation") // --- Release creation ---
publisher, err := buildPublisher(cfg)
if err != nil {
return err
}
if publisher == nil {
logWarn("no release provider configured — skipping release creation")
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
if cfg.GitLab.Token == "" {
return fmt.Errorf("GITLAB_TOKEN not set — required for release creation")
}
releaseNotes := notes.Generate(nextTag, messages) releaseNotes := notes.Generate(nextTag, messages)
gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project) if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create release: %w", err)
if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil {
return fmt.Errorf("create GitLab release: %w", err)
} }
logDone("release created: %s", nextTag)
fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag)
fmt.Printf("released %s\n", nextTag) fmt.Printf("released %s\n", nextTag)
return nil return nil
} }
+555 -11
View File
@@ -2,6 +2,8 @@ package main
import ( import (
"errors" "errors"
"fmt"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -14,6 +16,8 @@ import (
gitcfg "github.com/go-git/go-git/v5/config" 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"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
"git.k3nny.fr/releaser/internal/config"
) )
// ── helpers ────────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────────
@@ -227,9 +231,9 @@ func TestRunMissingPom(t *testing.T) {
// Tag must still have been created. // Tag must still have been created.
repo2, _ := gogit.PlainOpen(dir) repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v1.2.0") _, err = repo2.Tag("1.2.0")
if err != nil { if err != nil {
t.Error("expected tag v1.2.0 to be created") t.Error("expected tag 1.2.0 to be created")
} }
} }
@@ -249,9 +253,9 @@ func TestRunNoPomAtDefaultPath(t *testing.T) {
} }
repo2, _ := gogit.PlainOpen(dir) repo2, _ := gogit.PlainOpen(dir)
_, err = repo2.Tag("v2.0.0") _, err = repo2.Tag("2.0.0")
if err != nil { if err != nil {
t.Error("expected tag v2.0.0 to be created") t.Error("expected tag 2.0.0 to be created")
} }
} }
@@ -365,12 +369,12 @@ func TestRunDuplicateTag(t *testing.T) {
w.Add("x.go") w.Add("x.go")
w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()})
// Pre-create a v1.2.0 ref pointing to a garbage hash. // Pre-create a 1.2.0 ref pointing to a garbage hash.
// LatestTag skips it (resolveTagToCommit fails for garbage hash), // LatestTag skips it (resolveTagToCommit fails for garbage hash),
// so run() calculates "v1.2.0" as the first-ever version — then // so run() calculates "1.2.0" as the first-ever version — then
// CreateTag("v1.2.0") fails because the ref already exists. // CreateTag("1.2.0") fails because the ref already exists.
fakeRef := plumbing.NewHashReference( fakeRef := plumbing.NewHashReference(
plumbing.NewTagReferenceName("v1.2.0"), plumbing.NewTagReferenceName("1.2.0"),
plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
) )
if err := repo.Storer.SetReference(fakeRef); err != nil { if err := repo.Storer.SetReference(fakeRef); err != nil {
@@ -379,7 +383,7 @@ func TestRunDuplicateTag(t *testing.T) {
err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir) err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir)
if err == nil { if err == nil {
t.Fatal("expected error: v1.2.0 ref already exists") t.Fatal("expected error: 1.2.0 ref already exists")
} }
} }
@@ -430,9 +434,9 @@ func TestRunGitLabError(t *testing.T) {
func TestRunWithPreviousTag(t *testing.T) { func TestRunWithPreviousTag(t *testing.T) {
repo, dir := setupRepo(t) repo, dir := setupRepo(t)
// Tag the initial commit as v1.2.0 (simulates a prior release) // Tag the initial commit as 1.2.0 (simulates a prior release)
initialHead, _ := repo.Head() initialHead, _ := repo.Head()
repo.CreateTag("v1.2.0", initialHead.Hash(), nil) repo.CreateTag("1.2.0", initialHead.Hash(), nil)
// Fix commit after the tag — run() will use CommitsSince, not AllCommits // Fix commit after the tag — run() will use CommitsSince, not AllCommits
addFile(t, dir, "x.go", "// fix") addFile(t, dir, "x.go", "// fix")
@@ -656,3 +660,543 @@ func TestRunChangelogFile(t *testing.T) {
t.Error("expected CHANGES.md to be created") t.Error("expected CHANGES.md to be created")
} }
} }
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")
repo, _ := gogit.PlainOpen(dir)
w, _ := repo.Worktree()
w.Add("x.go")
w.Commit("feat: add new thing", &gogit.CommitOptions{Author: testSig()})
// Capture stderr output by redirecting it temporarily.
old := os.Stderr
r, wPipe, _ := os.Pipe()
os.Stderr = wPipe
err := execCmd(t, "--dry-run", "--verbose", "--branch", "release/1.2", "--repo", dir)
wPipe.Close()
os.Stderr = old
rawBytes, _ := io.ReadAll(r)
output := string(rawBytes)
if err != nil {
t.Fatalf("--verbose: unexpected error: %v", err)
}
checks := []string{
"▸ configuration",
"git.tag_prefix",
"[default]",
"▸ branch",
"release/1.2",
"major=1, minor=2",
"▸ commits",
"feat: add new thing",
"patch bump",
"▸ version",
}
for _, want := range checks {
if !strings.Contains(output, want) {
t.Errorf("--verbose output missing %q\nfull output:\n%s", want, output)
}
}
}
// ── 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")
}
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"fmt"
"os"
"strings"
)
const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiCyan = "\033[36m"
)
var useColor bool
func init() {
fi, err := os.Stderr.Stat()
tty := err == nil && (fi.Mode()&os.ModeCharDevice) != 0
useColor = tty && os.Getenv("NO_COLOR") == "" && os.Getenv("TERM") != "dumb"
}
func paint(code, s string) string {
if !useColor {
return s
}
return code + s + ansiReset
}
// logStep writes a neutral progress line to stderr.
func logStep(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiDim, "·"), msg)
}
// logDone writes a success completion line to stderr.
func logDone(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiGreen+ansiBold, "✓"), msg)
}
// logWarn writes a warning line to stderr.
func logWarn(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(os.Stderr, " %s %s\n", paint(ansiYellow, "!"), msg)
}
// logHeader prints the tool name and version banner to stderr.
func logHeader(ver string) {
fmt.Fprintf(os.Stderr, "%s %s\n",
paint(ansiBold, "releaser"),
paint(ansiDim, "v"+ver))
}
// logSection writes a bold section header to stderr (used in verbose mode).
func logSection(title string) {
fmt.Fprintf(os.Stderr, "\n%s\n", paint(ansiBold, "▸ "+title))
}
// fmtSource returns a colored "[source]" tag for a config key source.
func fmtSource(src string) string {
switch {
case strings.HasPrefix(src, "env:"):
return paint(ansiCyan, "["+src+"]")
case strings.HasPrefix(src, "flag:"):
return paint(ansiGreen, "["+src+"]")
case src == "default":
return paint(ansiDim, "[default]")
default: // "config file"
return paint(ansiBold, "["+src+"]")
}
}
+6 -31
View File
@@ -4,19 +4,17 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
"git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/commits"
) )
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Update inserts a new release section into the CHANGELOG file at path. // Update inserts a new release section into the CHANGELOG file at path.
// If the file does not exist it is created with a standard header. // If the file does not exist it is created with a standard header.
// Only commits with a releasable type (fix, feat, breaking) produce bullets; // Only commits with a releasable type (fix, feat, breaking) produce bullets;
// if none are found the file is left untouched. // if none are found the file is left untouched.
// If a section for version already exists the file is left untouched (idempotent).
func Update(path, tag, version string, messages []string) error { func Update(path, tag, version string, messages []string) error {
section := buildSection(version, messages) section := buildSection(version, messages)
if section == "" { if section == "" {
@@ -31,6 +29,10 @@ func Update(path, tag, version string, messages []string) error {
return fmt.Errorf("read %s: %w", path, err) return fmt.Errorf("read %s: %w", path, err)
} }
if strings.Contains(existing, "## ["+version+"]") {
return nil
}
var out string var out string
if existing == "" { if existing == "" {
out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" +
@@ -48,26 +50,7 @@ func Update(path, tag, version string, messages []string) error {
} }
func buildSection(version string, messages []string) string { func buildSection(version string, messages []string) string {
var breaking, feats, fixes []string breaking, feats, fixes := commits.Group(messages)
for _, msg := range messages {
t := commits.Parse(msg)
if t == commits.TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := extractSubject(first)
switch t {
case commits.TypeBreaking:
breaking = append(breaking, subject)
case commits.TypeFeat:
feats = append(feats, subject)
case commits.TypeFix:
fixes = append(fixes, subject)
}
}
if len(breaking)+len(feats)+len(fixes) == 0 { if len(breaking)+len(feats)+len(fixes) == 0 {
return "" return ""
} }
@@ -91,11 +74,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
fmt.Fprintf(sb, "- %s\n", item) fmt.Fprintf(sb, "- %s\n", item)
} }
} }
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
+40
View File
@@ -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) { func TestUpdateReadError(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Create a directory where the file should be — ReadFile will error. // 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") 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")
}
}
+56
View File
@@ -5,6 +5,62 @@ import (
"strings" "strings"
) )
// headerSubjectRe captures the subject (description) from a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// ExtractSubject returns the description part of a conventional commit header.
// Falls back to the trimmed raw header if the pattern does not match.
func ExtractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
// Group splits messages into breaking changes, features, and fixes.
// Only the first line of each message is considered; the subject is extracted.
// Messages with TypeNone are silently dropped.
func Group(messages []string) (breaking, feats, fixes []string) {
for _, msg := range messages {
t := Parse(msg)
if t == TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := ExtractSubject(first)
switch t {
case TypeBreaking:
breaking = append(breaking, subject)
case TypeFeat:
feats = append(feats, subject)
case TypeFix:
fixes = append(fixes, subject)
}
}
return
}
// ReleasableSet converts a slice of type-name strings to a set for use in version.Next.
// An empty or nil slice defaults to all three releasable types (fix, feat, breaking).
func ReleasableSet(typeNames []string) map[Type]bool {
if len(typeNames) == 0 {
return map[Type]bool{TypeFix: true, TypeFeat: true, TypeBreaking: true}
}
m := make(map[Type]bool, len(typeNames))
for _, name := range typeNames {
switch strings.ToLower(name) {
case "fix":
m[TypeFix] = true
case "feat":
m[TypeFeat] = true
case "breaking":
m[TypeBreaking] = true
}
}
return m
}
// Type represents the semantic weight of a commit for versioning purposes. // Type represents the semantic weight of a commit for versioning purposes.
type Type int type Type int
+58
View File
@@ -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) { func TestTypeString(t *testing.T) {
cases := []struct { cases := []struct {
t Type t Type
+188 -18
View File
@@ -16,19 +16,61 @@ const filename = ".releaser.yml"
type Config struct { type Config struct {
Git GitConfig `yaml:"git"` Git GitConfig `yaml:"git"`
Maven MavenConfig `yaml:"maven"` Maven MavenConfig `yaml:"maven"`
Node NodeConfig `yaml:"node"`
GitLab GitLabConfig `yaml:"gitlab"` GitLab GitLabConfig `yaml:"gitlab"`
GitHub GitHubConfig `yaml:"github"`
} }
type GitConfig struct { type GitConfig struct {
TagPrefix string `yaml:"tag_prefix"` TagPrefix string `yaml:"tag_prefix"`
BranchPattern string `yaml:"branch_pattern"` BranchPattern string `yaml:"branch_pattern"`
CommitMessage string `yaml:"commit_message"` CommitMessage string `yaml:"commit_message"`
AuthorName string `yaml:"author_name"` AuthorName string `yaml:"author_name"`
AuthorEmail string `yaml:"author_email"` AuthorEmail string `yaml:"author_email"`
ReleasableTypes []string `yaml:"releasable_types"`
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 { 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 { type GitLabConfig struct {
@@ -37,10 +79,15 @@ type GitLabConfig struct {
Project string `yaml:"project"` Project string `yaml:"project"`
} }
type GitHubConfig struct {
Token string `yaml:"token"`
Repo string `yaml:"repo"` // "owner/repo"
}
func defaults() Config { func defaults() Config {
return Config{ return Config{
Git: GitConfig{ Git: GitConfig{
TagPrefix: "v", TagPrefix: "",
BranchPattern: branch.DefaultBranchPattern, BranchPattern: branch.DefaultBranchPattern,
CommitMessage: "chore(release): {version} [skip ci]", CommitMessage: "chore(release): {version} [skip ci]",
}, },
@@ -50,42 +97,165 @@ func defaults() Config {
} }
} }
// Sources records where each config value came from.
// Keys are "section.field" (e.g. "git.tag_prefix").
// Values are one of: "default", "config file", "env: VARNAME", "flag: --flag-name".
type Sources map[string]string
func defaultSources() Sources {
return Sources{
"git.tag_prefix": "default",
"git.branch_pattern": "default",
"git.commit_message": "default",
"git.author_name": "default",
"git.author_email": "default",
"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",
}
}
// Load reads .releaser.yml from dir and merges it over the defaults. // Load reads .releaser.yml from dir and merges it over the defaults.
// Missing file is not an error — defaults are returned as-is. // Missing file is not an error — defaults are returned as-is.
func Load(dir string) (Config, error) { func Load(dir string) (Config, error) {
cfg, _, err := LoadWithSources(dir)
return cfg, err
}
// LoadWithSources is like Load but also returns a Sources map recording where each
// value came from ("default" or "config file").
func LoadWithSources(dir string) (Config, Sources, error) {
cfg := defaults() cfg := defaults()
src := defaultSources()
data, err := os.ReadFile(filepath.Join(dir, filename)) data, err := os.ReadFile(filepath.Join(dir, filename))
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return cfg, nil return cfg, src, nil
} }
if err != nil { if err != nil {
return cfg, fmt.Errorf("read %s: %w", filename, err) return cfg, src, fmt.Errorf("read %s: %w", filename, err)
} }
// Unmarshal into cfg (merges over defaults).
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse %s: %w", filename, err) return cfg, src, fmt.Errorf("parse %s: %w", filename, err)
} }
return cfg, nil // Detect which fields the file explicitly set by unmarshaling into a zero overlay.
var overlay Config
_ = yaml.Unmarshal(data, &overlay)
if overlay.Git.TagPrefix != "" {
src["git.tag_prefix"] = "config file"
}
if overlay.Git.BranchPattern != "" {
src["git.branch_pattern"] = "config file"
}
if overlay.Git.CommitMessage != "" {
src["git.commit_message"] = "config file"
}
if overlay.Git.AuthorName != "" {
src["git.author_name"] = "config file"
}
if overlay.Git.AuthorEmail != "" {
src["git.author_email"] = "config file"
}
if 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"
}
if overlay.GitLab.Token != "" {
src["gitlab.token"] = "config file"
}
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. // Values already set in the config file are never overwritten.
func (c *Config) ApplyEnv() { func (c *Config) ApplyEnv() {
c.ApplyEnvWithSources(nil)
}
// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each
// field it fills. src may be nil.
func (c *Config) ApplyEnvWithSources(src Sources) {
if c.GitLab.Token == "" { if c.GitLab.Token == "" {
c.GitLab.Token = os.Getenv("GITLAB_TOKEN") if v := os.Getenv("GITLAB_TOKEN"); v != "" {
c.GitLab.Token = v
if src != nil {
src["gitlab.token"] = "env: GITLAB_TOKEN"
}
}
} }
if c.GitLab.URL == "" { 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 = os.Getenv("CI_SERVER_URL") c.GitLab.URL = v
if src != nil {
src["gitlab.url"] = "env: CI_SERVER_URL"
}
}
} }
if c.GitLab.Project == "" { if c.GitLab.Project == "" {
// Prefer numeric ID; fall back to namespace/project path
if id := os.Getenv("CI_PROJECT_ID"); id != "" { if id := os.Getenv("CI_PROJECT_ID"); id != "" {
c.GitLab.Project = id c.GitLab.Project = id
} else { if src != nil {
c.GitLab.Project = os.Getenv("CI_PROJECT_PATH") src["gitlab.project"] = "env: CI_PROJECT_ID"
}
} else if p := os.Getenv("CI_PROJECT_PATH"); p != "" {
c.GitLab.Project = p
if src != nil {
src["gitlab.project"] = "env: CI_PROJECT_PATH"
}
}
}
if c.GitHub.Token == "" {
if v := os.Getenv("GITHUB_TOKEN"); v != "" {
c.GitHub.Token = v
if src != nil {
src["github.token"] = "env: GITHUB_TOKEN"
}
} }
} }
} }
+171 -2
View File
@@ -11,8 +11,8 @@ func TestLoadDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if cfg.Git.TagPrefix != "v" { if cfg.Git.TagPrefix != "" {
t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v") t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "")
} }
if cfg.Maven.PomPath != "pom.xml" { if cfg.Maven.PomPath != "pom.xml" {
t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml") t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml")
@@ -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) { func TestLoadPartialOverride(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
// Only override tag_prefix — commit_message should keep its default // Only override tag_prefix — commit_message should keep its default
+73
View File
@@ -0,0 +1,73 @@
package ghclient
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client is a minimal GitHub API client covering only the Releases endpoint.
type Client struct {
token string
repo string // "owner/repo"
httpClient *http.Client
}
// New creates a Client. repo must be in "owner/repo" format.
func New(token, repo string) *Client {
return &Client{
token: token,
repo: repo,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type createReleaseRequest struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
}
// CreateRelease creates a GitHub release on an existing tag.
// The tag must already be pushed to the remote before calling this.
func (c *Client) CreateRelease(ctx context.Context, tagName, body string) error {
payload, _ := json.Marshal(createReleaseRequest{
TagName: tagName,
Name: tagName,
Body: body,
})
url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.repo)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("GitHub API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var errBody struct {
Message string `json:"message"`
}
json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck
if errBody.Message != "" {
return fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, errBody.Message)
}
return fmt.Errorf("GitHub API returned %d", resp.StatusCode)
}
return nil
}
+138
View File
@@ -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
View File
@@ -6,14 +6,16 @@ import (
"os" "os"
"os/exec" "os/exec"
"sort" "sort"
"strings"
"time" "time"
gogit "github.com/go-git/go-git/v5" gogit "github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config" gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/storer" "github.com/go-git/go-git/v5/plumbing/storer"
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"git.k3nny.fr/releaser/internal/branch" "git.k3nny.fr/releaser/internal/branch"
) )
@@ -83,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) {
return nil return nil
} }
commitHash, err := resolveTagToCommit(repo, ref) tagCommit, err := resolveTagToCommitObj(repo, ref)
if err != nil { if err != nil {
return nil // silently skip malformed tags return nil // silently skip malformed tags
} }
tagCommit, err := repo.CommitObject(commitHash)
if err != nil {
return nil
}
if tagCommit.Hash == headCommit.Hash { if tagCommit.Hash == headCommit.Hash {
candidates = append(candidates, tagCandidate{name, patch}) candidates = append(candidates, tagCandidate{name, patch})
return nil return nil
} }
anc, err := tagCommit.IsAncestor(headCommit) anc, err := tagCommit.IsAncestor(headCommit)
if err != nil || !anc { if err != nil {
return err
}
if !anc {
return nil return nil
} }
@@ -239,17 +239,61 @@ func CreateTag(repo *gogit.Repository, tagName string) error {
return nil 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. // Push pushes the given branch and tag to the "origin" remote.
// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI. // When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI.
// When token is empty, the system git binary is invoked so that credential helpers, // When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first.
// SSH agents, and netrc are all available as they would be for a regular git push. // Falls back to the system git binary so that credential helpers, netrc, and SSH keys work normally.
func Push(repo *gogit.Repository, branchName, tagName, token string) error { func Push(repo *gogit.Repository, branchName, tagName, token string) error {
if token != "" { if token != "" {
return pushWithGoGit(repo, branchName, tagName, token) return pushWithGoGit(repo, branchName, tagName, token)
} }
// Try SSH agent auth when the remote URL uses SSH transport.
if remote, err := repo.Remote("origin"); err == nil {
urls := remote.Config().URLs
if len(urls) > 0 && isSSHURL(urls[0]) {
if err := sshPush(repo, branchName, tagName); err == nil {
return nil
}
}
}
return pushWithCLI(repo, branchName, tagName) return pushWithCLI(repo, branchName, tagName)
} }
func isSSHURL(u string) bool {
return strings.HasPrefix(u, "git@") || strings.HasPrefix(u, "ssh://")
}
func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error {
auth, err := newSSHAgentAuth("git")
if err != nil {
return err
}
remote, err := repo.Remote("origin")
if err != nil {
return fmt.Errorf("remote origin not found: %w", err)
}
opts := &gogit.PushOptions{
RefSpecs: []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)),
gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)),
},
Auth: auth,
}
if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) {
return fmt.Errorf("git push via SSH agent: %w", err)
}
return nil
}
func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error { func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error {
remote, err := repo.Remote("origin") remote, err := repo.Remote("origin")
if err != nil { if err != nil {
@@ -291,22 +335,31 @@ func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error {
return nil 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). // 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() hash := ref.Hash()
for { for {
obj, err := repo.Object(plumbing.AnyObject, hash) obj, err := repo.Object(plumbing.AnyObject, hash)
if err != nil { if err != nil {
return plumbing.ZeroHash, err return nil, err
} }
switch o := obj.(type) { switch o := obj.(type) {
case *object.Commit: case *object.Commit:
return o.Hash, nil return o, nil
case *object.Tag: case *object.Tag:
hash = o.Target hash = o.Target
default: 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
}
+407
View File
@@ -1,6 +1,7 @@
package gitutil package gitutil
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -11,6 +12,7 @@ import (
gitconfig "github.com/go-git/go-git/v5/config" gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/object"
gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"git.k3nny.fr/releaser/internal/branch" "git.k3nny.fr/releaser/internal/branch"
) )
@@ -699,3 +701,408 @@ func TestPushWithBareRemote(t *testing.T) {
t.Fatalf("second Push returned unexpected error: %v", err) 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)
}
}
+42
View File
@@ -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)
}
+99
View File
@@ -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
View File
@@ -2,38 +2,16 @@ package notes
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/commits"
) )
// headerSubjectRe captures the subject (description) part of a conventional commit header.
var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`)
// Generate produces grouped markdown release notes from a list of commit messages. // Generate produces grouped markdown release notes from a list of commit messages.
// Commits are grouped into Breaking Changes, Features, and Bug Fixes. // Commits are grouped into Breaking Changes, Features, and Bug Fixes.
// Commits with no releasable type are omitted. // Commits with no releasable type are omitted.
func Generate(tagName string, messages []string) string { func Generate(tagName string, messages []string) string {
var breaking, feats, fixes []string breaking, feats, fixes := commits.Group(messages)
for _, msg := range messages {
t := commits.Parse(msg)
if t == commits.TypeNone {
continue
}
first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0]
subject := extractSubject(first)
switch t {
case commits.TypeBreaking:
breaking = append(breaking, subject)
case commits.TypeFeat:
feats = append(feats, subject)
case commits.TypeFix:
fixes = append(fixes, subject)
}
}
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "## %s\n", tagName) fmt.Fprintf(&sb, "## %s\n", tagName)
@@ -53,13 +31,3 @@ func writeSection(sb *strings.Builder, title string, items []string) {
fmt.Fprintf(sb, "- %s\n", item) fmt.Fprintf(sb, "- %s\n", item)
} }
} }
// extractSubject returns the description part of a conventional commit header.
// Falls back to the raw header if the pattern does not match.
func extractSubject(header string) string {
m := headerSubjectRe.FindStringSubmatch(header)
if m != nil {
return strings.TrimSpace(m[1])
}
return strings.TrimSpace(header)
}
-19
View File
@@ -69,25 +69,6 @@ func TestGenerateEmpty(t *testing.T) {
} }
} }
func TestExtractSubject(t *testing.T) {
cases := []struct {
header string
want string
}{
{"feat: add login", "add login"},
{"feat(auth): add OAuth2", "add OAuth2"},
{"feat!: remove API", "remove API"},
{"FIX:typo", "typo"},
{"plain message", "plain message"},
}
for _, c := range cases {
got := extractSubject(c.header)
if got != c.want {
t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want)
}
}
}
// FuzzGenerate verifies that Generate never panics on arbitrary inputs and // FuzzGenerate verifies that Generate never panics on arbitrary inputs and
// always includes the tag name in the output. // always includes the tag name in the output.
func FuzzGenerate(f *testing.F) { func FuzzGenerate(f *testing.F) {
+28 -4
View File
@@ -6,14 +6,38 @@ import (
"git.k3nny.fr/releaser/internal/commits" "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"). // Next computes the next version string (without tag prefix, e.g. "1.2.4").
// currentPatch is -1 when no tag exists yet (first release will be X.Y.0). // currentPatch is -1 when no tag exists yet (first release will be X.Y.0).
// releasable is the set of commit types that trigger a bump; nil defaults to all three.
// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all.
// Returns ("", false) when there are no releasable commits. // Returns ("", false) when there are no releasable commits.
func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) { func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool, bumpRules map[commits.Type]BumpLevel) (string, bool) {
if releasable == nil {
releasable = commits.ReleasableSet(nil)
}
found := false
useMinor := false
for _, t := range types { for _, t := range types {
if t != commits.TypeNone { if releasable[t] {
return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true found = true
if bumpRules[t] == BumpMinor {
useMinor = true
}
} }
} }
return "", false 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
} }
+72 -2
View File
@@ -30,7 +30,7 @@ func TestNext(t *testing.T) {
wantOk: true, 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, major: 2, minor: 0, currentPatch: 1,
types: []commits.Type{commits.TypeBreaking}, types: []commits.Type{commits.TypeBreaking},
want: "2.0.2", want: "2.0.2",
@@ -61,7 +61,7 @@ func TestNext(t *testing.T) {
for _, c := range cases { for _, c := range cases {
t.Run(c.desc, func(t *testing.T) { t.Run(c.desc, func(t *testing.T) {
got, ok := Next(c.major, c.minor, c.currentPatch, c.types) got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, nil)
if ok != c.wantOk { if ok != c.wantOk {
t.Errorf("ok=%v, want %v", ok, c.wantOk) t.Errorf("ok=%v, want %v", ok, c.wantOk)
} }
@@ -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)
}
}