diff --git a/.gitea/workflows/docs.yml b/.gitea/workflows/docs.yml new file mode 100644 index 0000000..728585f --- /dev/null +++ b/.gitea/workflows/docs.yml @@ -0,0 +1,67 @@ +name: docs + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.gitea/workflows/docs.yml' + +vars: + HUGO_VERSION: "0.128.2" + GEEKDOC_VERSION: "v4.1.1" + +jobs: + deploy: + name: Build and deploy docs + runs-on: ubuntu-latest + container: + image: alpine:latest + + steps: + - name: Install tools + run: apk add --no-cache curl git tar + + - name: Checkout + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + run: | + git clone --depth 1 \ + "$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" . + + - name: Install Hugo + env: + HUGO_VERSION: ${{ vars.HUGO_VERSION }} + run: | + curl -sSL "https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_Linux-64bit.tar.gz" \ + | tar xz -C /usr/local/bin hugo + + - name: Download Geekdoc theme + env: + GEEKDOC_VERSION: ${{ vars.GEEKDOC_VERSION }} + run: | + mkdir -p docs/themes/geekdoc + curl -sSL "https://github.com/thegeeklab/hugo-geekdoc/releases/download/${GEEKDOC_VERSION}/hugo-geekdoc.tar.gz" \ + | tar xz -C docs/themes/geekdoc + + - name: Build + run: hugo --source docs --destination public --minify + + - name: Deploy to pages branch + env: + TOKEN: ${{ secrets.GITHUB_TOKEN }} + SERVER_URL: ${{ github.server_url }} + REPO: ${{ github.repository }} + run: | + cd docs/public + git init + git config user.email "ci@git.k3nny.fr" + git config user.name "Gitea CI" + git add . + git commit -m "deploy docs $(date +%Y-%m-%dT%H:%M:%SZ)" + git push --force \ + "$(echo "$SERVER_URL" | sed "s|https://|https://oauth2:${TOKEN}@|")/${REPO}.git" \ + HEAD:gh-pages diff --git a/.gitignore b/.gitignore index ba37d26..78ec88e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ releaser-* coverage.out coverage.html +# docs build artifacts (downloaded at build time) +/docs/themes/ +/docs/public/ + diff --git a/.releaser.gitlab-ci.yml b/.releaser.gitlab-ci.yml index 2b965a3..395caed 100644 --- a/.releaser.gitlab-ci.yml +++ b/.releaser.gitlab-ci.yml @@ -45,5 +45,9 @@ --branch "$CI_COMMIT_BRANCH" $RELEASER_EXTRA_ARGS + artifacts: + reports: + dotenv: release.env # exposes NEXT_VERSION to downstream jobs + environment: name: release/$CI_COMMIT_BRANCH diff --git a/.releaser.yml b/.releaser.yml index a3d14b8..e48912a 100644 --- a/.releaser.yml +++ b/.releaser.yml @@ -19,10 +19,48 @@ git: # author_name: "" # author_email: "" -maven: - # Path to pom.xml, relative to the repository root. + # Limit which commit types trigger a release (default: fix, feat, breaking). + # releasable_types: + # - fix + # - feat + # - breaking + + # Configure which version component each commit type bumps. + # Valid values: "patch" (default) or "minor". + # bump_rules: + # breaking: "minor" + # feat: "patch" + # fix: "patch" + +# maven: + # Single pom.xml path, relative to the repository root. # pom_path: "pom.xml" + # Multiple pom.xml paths for multi-module projects (overrides pom_path). + # pom_paths: + # - "pom.xml" + # - "module-a/pom.xml" + +# node: + # Single package.json path (node processing is opt-in — no default). + # package_json: "package.json" + + # Multiple package.json paths for monorepos (overrides package_json). + # package_jsons: + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + +# gradle: + # Single build.gradle or build.gradle.kts path (opt-in — no default). + # Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported. + # build_file: "build.gradle" + + # Multiple build files for multi-module projects (overrides build_file). + # build_files: + # - "build.gradle" + # - "module-a/build.gradle" + # - "module-b/build.gradle" + gitlab: # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. # url: "https://gitlab.example.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a22b69..4dab795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,115 @@ All notable changes to this project will be documented in this file. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [0.4.1] - 2026-07-07 +## [1.6.2] - 2026-07-11 + +### Changed + +- **`task ci` now runs `go tool staticcheck ./...`** — runs between `go vet` and `go test`; matches the step already present in the Gitea CI workflow + +### Fixed + +- **SA4006 in `TestLatestTagTagsIterFails`** — `err` from `gogit.PlainOpen` was assigned then immediately overwritten without being read; added the missing `if err != nil { t.Fatalf(...) }` check + +## [1.6.1] - 2026-07-11 + +### Fixed + +- **CI root-permission failures** — four tests that used `os.Chmod` to force error paths were failing in Docker CI (which runs as root, where chmod has no enforcement effect); each now skips with `os.Getuid() == 0`; the gitutil test had broken skip logic that only fired if `Chmod` itself errored — replaced with the same upfront UID check + +## [1.6.0] - 2026-07-11 + +### Added + +- **Gradle support** — new `internal/gradle` package; reads and writes the version assignment in `build.gradle` (Groovy DSL, single-quoted) and `build.gradle.kts` (Kotlin DSL, double-quoted); original quote style preserved on write; `regexp.QuoteMeta` ensures version strings with dots or special chars are safe +- **`gradle.build_file` / `gradle.build_files` config** — opt-in, no default; `build_files` list overrides `build_file` for multi-module projects; follows the same multi-value pattern as `maven.pom_paths` and `node.package_jsons` +- **`--gradle ` flag** — overrides `gradle.build_file` from config and clears `gradle.build_files`; mirrored in verbose config table as `gradle.paths` +- **`FuzzReadVersion` / `FuzzWriteVersion`** in `internal/gradle` — 100% per-package statement coverage maintained across all 13 packages + +## [1.5.1] - 2026-07-11 + +### Added + +- **Documentation site** — Hugo + Geekdoc theme; content covers installation, CLI reference, configuration, CI integration, and changelog; deployed to `gh-pages` via Gitea CI on push to `main` +- **`docs:setup` / `docs:serve` / `docs:build` Taskfile tasks** — `docs:setup` downloads the Geekdoc theme bundle (idempotent); `docs:serve` runs Hugo with live reload; `docs:build` produces a minified static site +- **`FuzzUpdate`** in `internal/changelog` — fuzzes arbitrary existing file content paired with a commit message, covering the `\n## [` insertion logic and idempotency guard +- **`FuzzWriteVersion`** in `internal/node` — mirrors `FuzzReplaceProjectVersion` in `internal/maven`; fuzzes arbitrary JSON content with arbitrary old/new version strings + +### Changed + +- **CLAUDE.md** — new "Fuzzing" section: authoritative table of which packages require fuzz tests and why, list of exempt packages with rationale, seed corpus guidelines + +## [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=`; 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 + +### Added + +- **CHANGELOG.md auto-update** — every release now writes a new dated section to `CHANGELOG.md` (grouped by Breaking Changes / Added / Fixed), committed alongside `pom.xml` in the release commit; file is created if it does not exist +- **`--changelog-file` flag** — override the default `CHANGELOG.md` path (e.g. `--changelog-file CHANGES.md`) +- **`--init` flag** — scaffolds a fully-commented default `.releaser.yml` in the repository root; errors if the file already exists +- **`CommitFiles`** in `gitutil` — internal helper that stages multiple files before a single commit, used to bundle `pom.xml` + `CHANGELOG.md` in one release commit + +### Fixed + +- **Push without token** — go-git's HTTPS transport does not use the system credential store; when `GITLAB_TOKEN` is unset the push now delegates to the `git` CLI so credential helpers, SSH agents, and `netrc` all work as expected + +## [0.4.2] - 2026-07-07 ### Fixed - **CI build step** — `go build ./cmd/...` failed with "output already exists and is a directory" because Go tried to write a binary named `cmd`, conflicting with the source directory; fixed by passing `-o /dev/null` +- **Optional pom.xml** — releaser no longer fails when `pom.xml` (or the configured `maven.pom_path`) does not exist; it logs a notice and proceeds directly to tag and push, making the tool usable in non-Maven projects ## [0.4.0] - 2026-07-07 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8b2f2fb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md — project guidelines for releaser + +## Overview + +`releaser` is a single-binary Go tool for GitFlow-based release automation. It targets Conventional Commits, versioned release branches (`release/X.Y`), and GitLab / GitHub release creation. + +## Architecture + +``` +cmd/main.go — CLI entrypoint (cobra), run() pipeline, verbose output +internal/branch/ — branch name parser → major/minor +internal/changelog/ — CHANGELOG.md writer +internal/commits/ — Conventional Commits parser (non-strict) +internal/config/ — .releaser.yml loader + env var overlay + source tracking +internal/ghclient/ — minimal GitHub Releases API client +internal/gitutil/ — go-git helpers: tag discovery, commit walker, push +internal/glclient/ — minimal GitLab Releases API client +internal/maven/ — pom.xml version reader/writer +internal/node/ — package.json version reader/writer +internal/notes/ — release notes body generator +internal/version/ — semver next-version calculator +``` + +## Code conventions + +- **No third-party test frameworks** — stdlib `testing` only. +- **No interfaces for mocking** — inject function variables (`var absPath = filepath.Abs`) to test error paths. +- **No comments explaining what** — only comments explaining *why* (hidden constraints, invariants, non-obvious workarounds). +- **No error handling for impossible paths** — trust internal invariants; only validate at system boundaries. +- **No abstractions ahead of need** — three similar lines beats a premature helper. + +## Test coverage + +**100% per-package statement coverage is required** across all packages. Run: + +```bash +go test ./... -cover +``` + +Every package must show `coverage: 100.0% of statements`. + +Strategies used in this project: +- **Error path injection**: override `var absPath`, `var gitAllCommits`, etc. to return injected errors. +- **Filesystem tricks**: `os.Mkdir` where a file is expected (invisible to go-git dirty check; fails os.WriteFile/os.ReadFile); `os.Chmod(..., 0444)` to make files read-only. +- **Null byte paths**: `"path\x00name"` causes `os.Stat` to return `EINVAL` (not `ErrNotExist`), useful for testing stat-error paths that differ from file-not-found. +- **In-memory git repos**: use go-git `PlainInit` + local bare remote for push tests. +- **Direct function calls**: call unexported helpers (e.g. `printVerboseConfig`) directly with crafted inputs to cover branches that are dead via normal CLI flow. + +## Fuzzing + +**Every package that parses free-form text or reads/writes arbitrary file content must have at least one fuzz test.** Run the full seed corpus with: + +```bash +go test -run='^Fuzz' ./... +``` + +All seed cases must pass. The table below is authoritative — keep it in sync when adding packages or parsers: + +| Package | Fuzz target(s) | Why | +|---------|---------------|-----| +| `internal/branch` | `FuzzParse` | parses branch name strings | +| `internal/changelog` | `FuzzUpdate` | rewrites arbitrary existing file content | +| `internal/commits` | `FuzzParse` | parses arbitrary commit message strings | +| `internal/gradle` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary Gradle build file content | +| `internal/glclient` | `FuzzEncodeProjectPath` | encodes arbitrary project path strings | +| `internal/maven` | `FuzzReadVersion`, `FuzzReplaceProjectVersion` | reads/rewrites arbitrary XML file content | +| `internal/node` | `FuzzReadVersion`, `FuzzWriteVersion` | reads/rewrites arbitrary JSON file content | +| `internal/notes` | `FuzzGenerate` | generates notes from arbitrary commit messages | + +Packages **not** requiring fuzz tests (no free-form text parsing): `internal/config` (yaml.v3 handles parsing), `internal/ghclient` (HTTP client, no text parsing), `internal/gitutil` (git operations), `internal/version` (typed inputs only), `cmd` (CLI orchestration). When adding a new package, check whether it parses text or rewrites files — if yes, add a row above. + +Fuzz seed corpus guidelines: +- Include a realistic happy-path input as the first seed. +- Include empty string, binary/non-UTF-8 bytes (`"\x00\xff"`), and inputs that stress known branches (e.g. existing `## [version]` heading for changelog). +- The fuzz body must never assert on return values — only verify no panic. + +## Dependency rules + +- **No new external dependencies** unless absolutely necessary. The project deliberately avoids pulling in large ecosystems. +- go-git (`github.com/go-git/go-git/v5`) for all git operations. +- cobra for CLI parsing. +- gopkg.in/yaml.v3 for config. + +## Config design + +- New config fields go in the appropriate `*Config` struct in `internal/config/config.go`. +- `defaultSources()` must be updated to include every new key. +- `LoadWithSources` overlay detection must cover every new field. +- `printVerboseConfig` in `cmd/main.go` must show every new config value. + +## Multi-value config pattern + +When a config supports both a single value and multiple values (like `pom_path` / `pom_paths`): +- Single field: `PomPath string` +- Multi field: `PomPaths []string` +- `EffectivePomPaths()` method: `PomPaths` wins if non-empty, else `PomPath` if set, else default. +- `--pom` CLI flag clears `PomPaths` and sets `PomPath` only. diff --git a/README.md b/README.md index 4fd3455..96f979a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # releaser -![release](https://img.shields.io/badge/release-v0.4.1-blue.svg) +![release](https://img.shields.io/badge/release-v1.6.2-blue.svg) A CI-friendly release automation tool for GitFlow workflows using Conventional Commits. @@ -8,46 +8,54 @@ A CI-friendly release automation tool for GitFlow workflows using Conventional C Standard tools like `semantic-release` are designed for trunk-based development. In a GitFlow setup with versioned release branches (`release/1.1`, `release/1.2`), they either fail to respect the branch's version range or require brittle configuration. -`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab tag+release creation. +`releaser` is built for this exact workflow: it reads the branch name to pin the `major.minor`, parses Conventional Commits to determine the patch increment, and handles everything from `pom.xml` update to GitLab/GitHub tag+release creation. ## How it works ``` release/1.2 branch - └─ last tag: v1.2.3 (or none → start at v1.2.0) + └─ last tag: 1.2.3 (or none → start at 1.2.0) └─ commits since tag → Conventional Commits analysis - └─ next version: v1.2.4 + └─ next version: 1.2.4 ``` 1. **Branch parsing** — extracts `major.minor` from branch name (e.g. `release/1.2` → `1.2`) 2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch 3. **Commit analysis** — parses Conventional Commits between last tag and HEAD 4. **Version bump** — increments patch (the minor is owned by the branch) -5. **Release** — updates `pom.xml`, commits, tags, creates GitLab release +5. **Release** — updates `pom.xml` / `package.json` / `build.gradle`, commits, tags, creates GitLab or GitHub release ## Version bump rules -| Commit type | Bump | Notes | -|------------------|---------|--------------------------------------------| -| `fix:` | patch | | -| `feat:` | patch | minor is pinned to branch | -| `feat!:` / `BREAKING CHANGE` | patch | same — branch defines the minor boundary | -| `chore:`, `docs:`, etc. | none | | -| unparseable msg | none | non-strict mode: silently ignored | +By default, all releasable commits bump the **patch** component (minor is pinned to the branch). You can override this per commit type via `git.bump_rules` in `.releaser.yml`: + +| Commit type | Default | Configurable via `bump_rules` | +|------------------|---------|------------------------------------------------| +| `fix:` | patch | `fix: "minor"` to bump minor instead | +| `feat:` | patch | `feat: "minor"` to bump minor instead | +| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor | +| `chore:`, `docs:`, etc. | none | — | +| unparseable msg | none | non-strict mode: silently ignored | ## Usage ```bash +# Scaffold a default .releaser.yml in the current repository +releaser --init + # Simulate next version (no side effects) releaser --dry-run -# Full release: bump pom.xml, commit, tag, push, GitLab release +# Full release: update pom.xml + CHANGELOG.md, commit, tag, push, create release releaser -# Commit and tag locally — skip push and GitLab release +# Commit and tag locally — skip push and release creation releaser --no-push -# Update pom.xml but stop before committing (review first) +# Push commit and tag but skip creating the release +releaser --no-release + +# Update files but stop before committing (review first) releaser --no-commit # … then commit manually and re-run: releaser --tag-only @@ -55,6 +63,12 @@ releaser --tag-only # Explicitly target a branch (useful in detached HEAD CI) releaser --branch release/1.2 +# Write changelog to a custom file +releaser --changelog-file CHANGES.md + +# Show configuration sources, commit list, and version decision +releaser --verbose --dry-run + # Target a specific pom.xml releaser --pom path/to/pom.xml @@ -63,6 +77,9 @@ releaser --tag-prefix "" # Override branch pattern (e.g. also match hotfix/ branches) releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$" + +# Write dotenv artifact to a custom path (or "" to disable) +releaser --release-env-file deploy/version.env ``` ## Configuration @@ -71,31 +88,60 @@ releaser --branch-pattern "^(?:.*/)?(?:release|hotfix)/(\d+)\.(\d+)$" ```yaml git: - tag_prefix: "v" # set to "" for tags without prefix + tag_prefix: "" # default: no prefix; set to "v" for v-prefixed tags branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor commit_message: "chore(release): {version} [skip ci]" author_name: "" # defaults to git config user.name author_email: "" # defaults to git config user.email + releasable_types: # default: all three + - fix + - feat + - breaking + bump_rules: # which version component each type bumps + breaking: "patch" # "minor" to bump minor on breaking changes + feat: "patch" + fix: "patch" maven: - pom_path: "pom.xml" # relative to repo root + pom_path: "pom.xml" # single pom.xml, relative to repo root + # pom_paths: # multi-module: list overrides pom_path + # - "pom.xml" + # - "module-a/pom.xml" + # - "module-b/pom.xml" + +node: # opt-in — no default; omit to skip + # package_json: "package.json" # single path + # package_jsons: # monorepo: list overrides package_json + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + +gradle: # opt-in — no default; omit to skip + # build_file: "build.gradle" # Groovy or Kotlin DSL; single path + # build_files: # multi-module: list overrides build_file + # - "build.gradle" + # - "module-a/build.gradle" gitlab: url: "https://gitlab.example.com" # or env CI_SERVER_URL token: "" # env GITLAB_TOKEN (never commit this) project: "" # env CI_PROJECT_ID or CI_PROJECT_PATH + +github: + token: "" # env GITHUB_TOKEN (never commit this) + repo: "" # "owner/repo" format ``` ### Environment variables -GitLab-related fields are automatically read from the CI environment if not set in the config file: +| Variable | Used for | +|--------------------|-----------------------------------| +| `GITLAB_TOKEN` | GitLab API auth + HTTPS push auth | +| `CI_SERVER_URL` | GitLab instance URL | +| `CI_PROJECT_ID` | GitLab project identifier (numeric) | +| `CI_PROJECT_PATH` | GitLab project identifier (fallback) | +| `GITHUB_TOKEN` | GitHub API auth | -| Variable | Used for | -|-------------------|-----------------------------------| -| `GITLAB_TOKEN` | API auth + HTTPS push auth | -| `CI_SERVER_URL` | GitLab instance URL | -| `CI_PROJECT_ID` | Project identifier (numeric) | -| `CI_PROJECT_PATH` | Project identifier (fallback) | +When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. ## CI integration (GitLab CI example) @@ -109,4 +155,7 @@ release: GITLAB_TOKEN: $RELEASE_TOKEN # project/group CI variable with api + write_repository scope script: - releaser + artifacts: + reports: + dotenv: release.env # exposes NEXT_VERSION to downstream jobs ``` diff --git a/ROADMAP.md b/ROADMAP.md index dd6cbe2..60be26f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,22 +55,35 @@ - [x] Gitea release workflow (5-platform cross-compilation, release asset upload) - [x] 96% test coverage with real in-memory git repos and fuzz tests for all parsers -## v0.5 — Changelog +## v0.5 — Changelog ✅ -- [ ] `CHANGELOG.md` generation / append (grouped by commit type) -- [ ] `--changelog-file` flag +- [x] `CHANGELOG.md` generation / append (grouped by commit type: Breaking Changes / Added / Fixed) +- [x] `--changelog-file` flag to use a custom filename +- [x] `--init` flag to scaffold a default `.releaser.yml` +- [x] Push falls back to system `git` CLI when no token is set (uses credential helpers, SSH, netrc) ## v1.0 — Production ready - [x] ~~Integration tests against a real Git repo (with fixture commits and tags)~~ — ✓ shipped v0.4.0 (96% coverage, real in-memory repos) - [x] ~~Cross-compilation in CI (linux/amd64, linux/arm64, darwin/amd64)~~ — ✓ shipped v0.4.0 (Gitea release workflow, + darwin/arm64 + windows/amd64) -- [ ] Documentation site +- [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=` 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) +- [x] ~~Documentation site~~ — ✓ shipped v1.5.1 (Hugo + Geekdoc; installation, CLI reference, configuration, CI integration pages; deployed via Gitea CI to `gh-pages`) + +## v1.5 — Multi-module, Node.js, configurable bump rules ✅ + +- [x] Multi-module Maven support (`maven.pom_paths: [...]` updates multiple `pom.xml` files in one release) +- [x] `package.json` version bump for Node.js projects (`node.package_json` / `node.package_jsons`) +- [x] Configurable bump rules per commit type (`git.bump_rules.breaking/feat/fix: "minor" | "patch"`) +- [x] 100% per-package statement coverage across all 12 packages ## Future / backlog -- GitHub release support (parity with GitLab) -- Multi-module Maven support (multiple `pom.xml` paths) -- Gradle support (`build.gradle` / `build.gradle.kts`) -- `package.json` version bump support (Node.js projects) +- ~~Gradle support (`build.gradle` / `build.gradle.kts`)~~ — ✓ shipped v1.6.0 (`internal/gradle`; Groovy + Kotlin DSL; multi-module via `gradle.build_files`; `--gradle` flag) - Slack / Teams notification on release -- Configurable bump rules (e.g. treat `feat:` as minor on `main` branch) diff --git a/Taskfile.yml b/Taskfile.yml index 3b39f4d..1dd84dd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -4,6 +4,7 @@ vars: BIN: ./bin/releaser PKG: ./... FUZZ_TIME: 30s + GEEKDOC_VERSION: "v4.1.1" tasks: default: @@ -76,7 +77,7 @@ tasks: - go test -run='^Fuzz' {{.PKG}} ci: - desc: Full CI pipeline — tidy check, vet, test + desc: Full CI pipeline — tidy check, vet, staticcheck, test cmds: - task: tidy - | @@ -85,6 +86,7 @@ tasks: exit 1 fi - task: lint + - go tool staticcheck ./... - task: test clean: @@ -105,3 +107,23 @@ tasks: TAG: '{{.TAG | default "releaser:dev"}}' cmds: - docker run --rm {{.TAG}} {{.CLI_ARGS}} + + docs:setup: + desc: Download Geekdoc theme into docs/themes/geekdoc/ + cmds: + - mkdir -p docs/themes/geekdoc + - curl -sSL "https://github.com/thegeeklab/hugo-geekdoc/releases/download/{{.GEEKDOC_VERSION}}/hugo-geekdoc.tar.gz" | tar xz -C docs/themes/geekdoc + status: + - test -f docs/themes/geekdoc/theme.toml + + docs:serve: + desc: Serve docs locally with live reload (requires hugo) + deps: [docs:setup] + cmds: + - hugo server --source docs + + docs:build: + desc: Build docs to docs/public/ (requires hugo) + deps: [docs:setup] + cmds: + - hugo --source docs --destination public --minify diff --git a/cmd/main.go b/cmd/main.go index 80294ad..7b2dddf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -12,15 +12,106 @@ import ( "github.com/spf13/cobra" "git.k3nny.fr/releaser/internal/branch" + "git.k3nny.fr/releaser/internal/changelog" "git.k3nny.fr/releaser/internal/commits" "git.k3nny.fr/releaser/internal/config" - "git.k3nny.fr/releaser/internal/glclient" + "git.k3nny.fr/releaser/internal/ghclient" "git.k3nny.fr/releaser/internal/gitutil" + "git.k3nny.fr/releaser/internal/glclient" + "git.k3nny.fr/releaser/internal/gradle" "git.k3nny.fr/releaser/internal/maven" + "git.k3nny.fr/releaser/internal/node" "git.k3nny.fr/releaser/internal/notes" semver "git.k3nny.fr/releaser/internal/version" ) +const defaultConfigTemplate = `# .releaser.yml — configuration for git.k3nny.fr/releaser +# All fields are optional. Uncomment and adjust what you need. +# CLI flags always take precedence over values set here. + +git: + # Prefix prepended to every version tag (default: no prefix). + # tag_prefix: "v" + + # Regex that identifies release branches. Must contain exactly two capture + # groups: group 1 = major version, group 2 = minor version. + # branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" + + # Template for the version-bump commit message. + # {version} is replaced with the full tag name (e.g. "v1.2.3"). + # commit_message: "chore(release): {version} [skip ci]" + + # Override the git commit author. When omitted, releaser reads user.name + # and user.email from the repository's git config. + # author_name: "" + # author_email: "" + + # 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: + # Single pom.xml path, relative to the repository root. + # pom_path: "pom.xml" + + # Multiple pom.xml paths for multi-module projects (overrides pom_path). + # pom_paths: + # - "pom.xml" + # - "module-a/pom.xml" + # - "module-b/pom.xml" + +node: + # Single package.json path (node processing is opt-in — no default). + # package_json: "package.json" + + # Multiple package.json paths for monorepos (overrides package_json). + # package_jsons: + # - "package.json" + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + +gradle: + # Single build.gradle or build.gradle.kts path (opt-in — no default). + # Both Groovy DSL (single-quoted) and Kotlin DSL (double-quoted) are supported. + # build_file: "build.gradle" + + # Multiple build files for multi-module projects (overrides build_file). + # build_files: + # - "build.gradle" + # - "module-a/build.gradle" + # - "module-b/build.gradle" + +gitlab: + # GitLab instance URL. Falls back to the CI_SERVER_URL environment variable. + # url: "https://gitlab.example.com" + + # Personal or CI access token with api scope. + # Falls back to the GITLAB_TOKEN environment variable. + # Tip: never commit a real token here — use the environment variable instead. + # token: "" + + # Numeric project ID or "namespace/project" path. + # Falls back to CI_PROJECT_ID, then CI_PROJECT_PATH environment variables. + # project: "" + +github: + # GitHub personal access token with repo scope. + # Falls back to the GITHUB_TOKEN environment variable. + # token: "" + + # Repository in "owner/repo" format. + # repo: "" +` + var ( version = "dev" // overridden at build time via -ldflags "-X main.version=..." errNothingToRelease = errors.New("nothing to release") @@ -29,19 +120,54 @@ var ( // exitFn is a variable so tests can intercept os.Exit calls. var exitFn = os.Exit +// injectable function variables for testing error paths. +var ( + absPath = filepath.Abs + gitAllCommits = gitutil.AllCommits + gitCommitsSince = gitutil.CommitsSince + gitCommitFiles = gitutil.CommitFiles +) + +// releasePublisher is implemented by both glclient and ghclient. +type releasePublisher interface { + CreateRelease(ctx context.Context, tagName, body string) error +} + +// buildPublisher selects and returns the active release publisher based on config. +// GitHub takes precedence over GitLab when both are configured. +// Returns (nil, nil) when no provider is configured — caller should skip release creation. +func buildPublisher(cfg config.Config) (releasePublisher, error) { + if cfg.GitHub.Token != "" && cfg.GitHub.Repo != "" { + return ghclient.New(cfg.GitHub.Token, cfg.GitHub.Repo), nil + } + if cfg.GitLab.URL != "" && cfg.GitLab.Project != "" { + if cfg.GitLab.Token == "" { + return nil, fmt.Errorf("GITLAB_TOKEN not set — required for release creation") + } + return glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project), nil + } + return nil, nil +} + func newRootCmd() *cobra.Command { var ( - dryRun bool - noPush bool - noCommit bool - tagOnly bool - branchOverride string - repoPath string - pomOverride string - tagPrefixFlag string - tagPrefixSet bool - patternFlag string - patternSet bool + init_ bool + verbose bool + dryRun bool + noPush bool + noRelease bool + noCommit bool + tagOnly bool + branchOverride string + repoPath string + pomOverride string + gradleOverride string + changelogFile string + tagPrefixFlag string + tagPrefixSet bool + patternFlag string + patternSet bool + releaseEnvFile string ) root := &cobra.Command{ @@ -53,30 +179,42 @@ func newRootCmd() *cobra.Command { tagPrefixSet = cmd.Flags().Changed("tag-prefix") patternSet = cmd.Flags().Changed("branch-pattern") return run(options{ + init: init_, + verbose: verbose, repoPath: repoPath, branchOverride: branchOverride, pomOverride: pomOverride, + gradleOverride: gradleOverride, + changelogFile: changelogFile, tagPrefixFlag: tagPrefixFlag, tagPrefixSet: tagPrefixSet, patternFlag: patternFlag, patternSet: patternSet, dryRun: dryRun, noPush: noPush, + noRelease: noRelease, noCommit: noCommit, tagOnly: tagOnly, + releaseEnvFile: releaseEnvFile, }) }, } + root.Flags().BoolVar(&init_, "init", false, "create a default .releaser.yml in the repository and exit") + root.Flags().BoolVar(&verbose, "verbose", false, "print configuration sources, commit list, and version decision") root.Flags().BoolVar(&dryRun, "dry-run", false, "print next version without making changes") - root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a GitLab release") - root.Flags().BoolVar(&noCommit, "no-commit", false, "update pom.xml but do not commit, tag, or push") - root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating pom.xml (assumes version was already committed)") + root.Flags().BoolVar(&noPush, "no-push", false, "create commit and tag locally without pushing or creating a release") + root.Flags().BoolVar(&noRelease, "no-release", false, "push commit and tag but skip creating the release") + root.Flags().BoolVar(&noCommit, "no-commit", false, "update files but do not commit, tag, or push") + root.Flags().BoolVar(&tagOnly, "tag-only", false, "tag HEAD without updating files (assumes version was already committed)") root.Flags().StringVar(&branchOverride, "branch", "", "override branch name detection (required in detached HEAD)") root.Flags().StringVar(&repoPath, "repo", ".", "path to git repository") root.Flags().StringVar(&pomOverride, "pom", "", "override maven.pom_path from config") + root.Flags().StringVar(&gradleOverride, "gradle", "", "override gradle.build_file from config") + root.Flags().StringVar(&changelogFile, "changelog-file", "CHANGELOG.md", "path to changelog file relative to repo root") root.Flags().StringVar(&tagPrefixFlag, "tag-prefix", "", "override git.tag_prefix from config") root.Flags().StringVar(&patternFlag, "branch-pattern", "", "override git.branch_pattern from config") + 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 } @@ -92,41 +230,171 @@ func main() { } type options struct { - repoPath string - branchOverride string - pomOverride string - tagPrefixFlag string - tagPrefixSet bool - patternFlag string - patternSet bool - dryRun bool - noPush bool - noCommit bool - tagOnly bool + init bool + verbose bool + repoPath string + branchOverride string + pomOverride string + gradleOverride string + changelogFile string + tagPrefixFlag string + tagPrefixSet bool + patternFlag string + patternSet bool + dryRun bool + noPush bool + noRelease bool + noCommit 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, ", ") + }()}, + {"gradle.paths", func() string { + paths := cfg.Gradle.EffectiveBuildFiles() + 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 { + path := filepath.Join(absRepo, ".releaser.yml") + if _, err := os.Stat(path); err == nil { + return fmt.Errorf(".releaser.yml already exists in %s — delete it first if you want to reset", absRepo) + } + if err := os.WriteFile(path, []byte(defaultConfigTemplate), 0644); err != nil { + return fmt.Errorf("write .releaser.yml: %w", err) + } + fmt.Printf("created %s\n", path) + return nil +} + +func parseBumpRules(rules config.BumpRulesConfig) map[commits.Type]semver.BumpLevel { + m := map[commits.Type]semver.BumpLevel{} + if rules.Breaking == "minor" { + m[commits.TypeBreaking] = semver.BumpMinor + } + if rules.Feat == "minor" { + m[commits.TypeFeat] = semver.BumpMinor + } + if rules.Fix == "minor" { + m[commits.TypeFix] = semver.BumpMinor + } + return m } func run(o options) error { + logHeader(version) + // --- Config --- - absRepo, err := filepath.Abs(o.repoPath) + absRepo, err := absPath(o.repoPath) if err != nil { return fmt.Errorf("resolve repo path: %w", err) } - cfg, err := config.Load(absRepo) + if o.init { + if o.verbose { + logStep("creating .releaser.yml in %s", absRepo) + } + return initConfig(absRepo) + } + + cfg, src, err := config.LoadWithSources(absRepo) if err != nil { return err } - cfg.ApplyEnv() + cfg.ApplyEnvWithSources(src) // CLI flags take precedence over config file and env vars if o.tagPrefixSet { cfg.Git.TagPrefix = o.tagPrefixFlag + src["git.tag_prefix"] = "flag: --tag-prefix" } if o.pomOverride != "" { cfg.Maven.PomPath = o.pomOverride + cfg.Maven.PomPaths = nil + src["maven.pom_paths"] = "flag: --pom" + } + if o.gradleOverride != "" { + cfg.Gradle.BuildFile = o.gradleOverride + cfg.Gradle.BuildFiles = nil + src["gradle.build_files"] = "flag: --gradle" } if o.patternSet { cfg.Git.BranchPattern = o.patternFlag + src["git.branch_pattern"] = "flag: --branch-pattern" + } + + if o.verbose { + printVerboseConfig(cfg, src) } // --- Git --- @@ -149,6 +417,13 @@ func run(o options) error { } info.TagPrefix = cfg.Git.TagPrefix + if o.verbose { + logSection("branch") + fmt.Fprintf(os.Stderr, " %s → major=%d, minor=%d %s\n", + paint(ansiBold, branchName), info.Major, info.Minor, + paint(ansiDim, "(pinned by branch)")) + } + // --- Dirty check (before any changes) --- // Skipped in --no-commit mode: the user intentionally has changes in flight. if !o.dryRun && !o.noCommit { @@ -170,17 +445,21 @@ func run(o options) error { // --- Commit range --- var messages []string if lastTag == "" { - fmt.Fprintln(os.Stderr, "info: no previous tag found — scanning all commits") - messages, err = gitutil.AllCommits(repo) + messages, err = gitAllCommits(repo) } else { - fmt.Fprintf(os.Stderr, "info: last tag: %s\n", lastTag) - messages, err = gitutil.CommitsSince(repo, lastTag) + messages, err = gitCommitsSince(repo, lastTag) } if err != nil { return fmt.Errorf("read commits: %w", err) } - fmt.Fprintf(os.Stderr, "info: %d commit(s) to analyze\n", len(messages)) + if !o.verbose { + if lastTag == "" { + logStep("no previous tag — scanning all %d commit(s)", len(messages)) + } else { + logStep("last tag: %s (%d commit(s) to analyze)", lastTag, len(messages)) + } + } // --- Version calculation --- types := make([]commits.Type, len(messages)) @@ -188,36 +467,143 @@ func run(o options) error { 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 { - fmt.Fprintln(os.Stderr, "info: no releasable commits found") + logWarn("no releasable commits found") return errNothingToRelease } nextTag := info.TagName(nextVersion) + if o.verbose { + highestType := commits.TypeNone + for _, t := range types { + if t > highestType { + highestType = t + } + } + logSection("version") + fmt.Fprintf(os.Stderr, " highest type: %s → next: %s (tag: %s)\n", + paint(ansiCyan, highestType.String()), + paint(ansiBold, nextVersion), + paint(ansiBold+ansiCyan, nextTag)) + } + fmt.Printf("next version: %s (tag: %s)\n", nextVersion, nextTag) if o.dryRun { - fmt.Fprintln(os.Stderr, "dry-run: no changes made") + logStep("dry-run: no changes made") return nil } - // --- pom.xml (skipped with --tag-only) --- - if !o.tagOnly { - pomPath := filepath.Join(absRepo, cfg.Maven.PomPath) - currentPomVersion, err := maven.ReadVersion(pomPath) - if err != nil { - return fmt.Errorf("read pom version: %w", err) + // --- 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) } - fmt.Fprintf(os.Stderr, "info: pom.xml: %s → %s\n", currentPomVersion, nextVersion) + logDone("%s: NEXT_VERSION=%s", o.releaseEnvFile, nextTag) + } - if err := maven.WriteVersion(pomPath, currentPomVersion, nextVersion); err != nil { - return fmt.Errorf("update pom version: %w", err) + // --- pom.xml + CHANGELOG.md (skipped with --tag-only) --- + if !o.tagOnly { + var filesToCommit []string + + // pom.xml (supports multi-module via pom_paths) + anyPom := false + for _, relPomPath := range cfg.Maven.EffectivePomPaths() { + pomPath := filepath.Join(absRepo, relPomPath) + _, statErr := os.Stat(pomPath) + hasPom := !errors.Is(statErr, os.ErrNotExist) + if statErr != nil && hasPom { + return fmt.Errorf("check pom path: %w", statErr) + } + if hasPom { + anyPom = true + currentPomVersion, err := maven.ReadVersion(pomPath) + if err != nil { + return fmt.Errorf("read pom version: %w", err) + } + 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 !anyPom { + logWarn("no pom.xml — skipping version bump") + } + + // package.json (opt-in via node.package_json / node.package_jsons) + for _, relPkgPath := range cfg.Node.EffectivePaths() { + pkgPath := filepath.Join(absRepo, relPkgPath) + currentNodeVersion, err := node.ReadVersion(pkgPath) + if err != nil { + return fmt.Errorf("read package.json version: %w", err) + } + if err := node.WriteVersion(pkgPath, currentNodeVersion, nextVersion); err != nil { + return fmt.Errorf("update package.json version: %w", err) + } + logDone("%s: %s → %s", relPkgPath, currentNodeVersion, nextVersion) + filesToCommit = append(filesToCommit, relPkgPath) + } + + // build.gradle / build.gradle.kts (opt-in via gradle.build_file / gradle.build_files) + for _, relGradlePath := range cfg.Gradle.EffectiveBuildFiles() { + gradlePath := filepath.Join(absRepo, relGradlePath) + currentGradleVersion, err := gradle.ReadVersion(gradlePath) + if err != nil { + return fmt.Errorf("read gradle version: %w", err) + } + if err := gradle.WriteVersion(gradlePath, currentGradleVersion, nextVersion); err != nil { + return fmt.Errorf("update gradle version: %w", err) + } + logDone("%s: %s → %s", relGradlePath, currentGradleVersion, nextVersion) + filesToCommit = append(filesToCommit, relGradlePath) + } + + // CHANGELOG.md + changelogAbsPath := filepath.Join(absRepo, o.changelogFile) + if err := changelog.Update(changelogAbsPath, nextTag, nextVersion, messages); err != nil { + return fmt.Errorf("update changelog: %w", err) + } + logDone("%s updated", o.changelogFile) + filesToCommit = append(filesToCommit, o.changelogFile) if o.noCommit { - fmt.Printf("pom.xml updated to %s — commit manually then re-run with --tag-only\n", nextVersion) + fmt.Printf("files updated to %s — commit manually then re-run with --tag-only\n", nextVersion) return nil } @@ -229,19 +615,18 @@ func run(o options) error { if cfg.Git.AuthorEmail != "" { authorEmail = cfg.Git.AuthorEmail } - commitMsg := strings.ReplaceAll(cfg.Git.CommitMessage, "{version}", nextTag) - if _, err := gitutil.CommitFile(repo, cfg.Maven.PomPath, commitMsg, authorName, authorEmail); err != nil { - return fmt.Errorf("commit pom.xml: %w", err) + if _, err := gitCommitFiles(repo, filesToCommit, commitMsg, authorName, authorEmail); err != nil { + return fmt.Errorf("commit: %w", err) } - fmt.Fprintf(os.Stderr, "info: committed: %s\n", commitMsg) + logDone("committed: %s", commitMsg) } // --- Git tag --- if err := gitutil.CreateTag(repo, nextTag); err != nil { return fmt.Errorf("create tag: %w", err) } - fmt.Fprintf(os.Stderr, "info: tag created: %s\n", nextTag) + logDone("tag: %s", nextTag) if o.noPush { fmt.Printf("released %s locally — push manually with: git push && git push --tags\n", nextTag) @@ -249,30 +634,33 @@ func run(o options) error { } // --- Push --- - fmt.Fprintln(os.Stderr, "info: pushing commit and tag...") + logStep("pushing commit and tag...") if err := gitutil.Push(repo, branchName, nextTag, cfg.GitLab.Token); err != nil { return fmt.Errorf("push: %w", err) } - fmt.Fprintln(os.Stderr, "info: pushed") + logDone("pushed") - // --- GitLab release --- - if cfg.GitLab.URL == "" || cfg.GitLab.Project == "" { - fmt.Fprintln(os.Stderr, "warning: GitLab URL or project not configured — skipping release creation") + if o.noRelease { fmt.Printf("released %s\n", nextTag) return nil } - if cfg.GitLab.Token == "" { - return fmt.Errorf("GITLAB_TOKEN not set — required for release creation") + + // --- Release creation --- + publisher, err := buildPublisher(cfg) + if err != nil { + return err + } + if publisher == nil { + logWarn("no release provider configured — skipping release creation") + fmt.Printf("released %s\n", nextTag) + return nil } releaseNotes := notes.Generate(nextTag, messages) - gl := glclient.New(cfg.GitLab.URL, cfg.GitLab.Token, cfg.GitLab.Project) - - if err := gl.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { - return fmt.Errorf("create GitLab release: %w", err) + if err := publisher.CreateRelease(context.Background(), nextTag, releaseNotes); err != nil { + return fmt.Errorf("create release: %w", err) } - - fmt.Fprintf(os.Stderr, "info: GitLab release created: %s\n", nextTag) + logDone("release created: %s", nextTag) fmt.Printf("released %s\n", nextTag) return nil } diff --git a/cmd/main_test.go b/cmd/main_test.go index 6e18481..0f6ffdc 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -2,10 +2,13 @@ package main import ( "errors" + "fmt" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -13,6 +16,8 @@ import ( gitcfg "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + + "git.k3nny.fr/releaser/internal/config" ) // ── helpers ────────────────────────────────────────────────────────────────── @@ -211,6 +216,7 @@ func TestRunPomOverride(t *testing.T) { } func TestRunMissingPom(t *testing.T) { + // --pom points to a non-existent file: pom update is skipped, tag is still created. _, dir := setupRepo(t) addFile(t, dir, "x.go", "// fix") repo, _ := gogit.PlainOpen(dir) @@ -218,10 +224,38 @@ func TestRunMissingPom(t *testing.T) { w.Add("x.go") w.Commit("fix: patch", &gogit.CommitOptions{Author: testSig()}) - // Use --pom to point to a non-existent file; keeps the working tree clean. - err := execCmd(t, "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml") - if err == nil { - t.Fatal("expected error for missing pom.xml") + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--pom", "nonexistent.xml") + if err != nil { + t.Fatalf("missing pom should be skipped, got error: %v", err) + } + + // Tag must still have been created. + repo2, _ := gogit.PlainOpen(dir) + _, err = repo2.Tag("1.2.0") + if err != nil { + t.Error("expected tag 1.2.0 to be created") + } +} + +func TestRunNoPomAtDefaultPath(t *testing.T) { + // Repo with no pom.xml at the default path: runs without error, creates tag. + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + addFile(t, dir, "main.go", "package main") + commitAll(t, repo, dir, "fix: initial") + + err = execCmd(t, "--no-push", "--branch", "release/2.0", "--repo", dir) + if err != nil { + t.Fatalf("no pom.xml should not be an error: %v", err) + } + + repo2, _ := gogit.PlainOpen(dir) + _, err = repo2.Tag("2.0.0") + if err != nil { + t.Error("expected tag 2.0.0 to be created") } } @@ -335,12 +369,12 @@ func TestRunDuplicateTag(t *testing.T) { w.Add("x.go") w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) - // Pre-create a v1.2.0 ref pointing to a garbage hash. + // Pre-create a 1.2.0 ref pointing to a garbage hash. // LatestTag skips it (resolveTagToCommit fails for garbage hash), - // so run() calculates "v1.2.0" as the first-ever version — then - // CreateTag("v1.2.0") fails because the ref already exists. + // so run() calculates "1.2.0" as the first-ever version — then + // CreateTag("1.2.0") fails because the ref already exists. fakeRef := plumbing.NewHashReference( - plumbing.NewTagReferenceName("v1.2.0"), + plumbing.NewTagReferenceName("1.2.0"), plumbing.NewHash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), ) if err := repo.Storer.SetReference(fakeRef); err != nil { @@ -349,7 +383,7 @@ func TestRunDuplicateTag(t *testing.T) { err := execCmd(t, "--tag-only", "--no-push", "--branch", "release/1.2", "--repo", dir) if err == nil { - t.Fatal("expected error: v1.2.0 ref already exists") + t.Fatal("expected error: 1.2.0 ref already exists") } } @@ -400,9 +434,9 @@ func TestRunGitLabError(t *testing.T) { func TestRunWithPreviousTag(t *testing.T) { repo, dir := setupRepo(t) - // Tag the initial commit as v1.2.0 (simulates a prior release) + // Tag the initial commit as 1.2.0 (simulates a prior release) initialHead, _ := repo.Head() - repo.CreateTag("v1.2.0", initialHead.Hash(), nil) + repo.CreateTag("1.2.0", initialHead.Hash(), nil) // Fix commit after the tag — run() will use CommitsSince, not AllCommits addFile(t, dir, "x.go", "// fix") @@ -431,6 +465,25 @@ func TestRunSkipGitLab(t *testing.T) { } } +func TestRunNoRelease(t *testing.T) { + _, dir := setupRepoWithRemote(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: patch something", &gogit.CommitOptions{Author: testSig()}) + + // --no-release skips GitLab release even when credentials are configured + t.Setenv("CI_SERVER_URL", "https://gitlab.example.com") + t.Setenv("CI_PROJECT_ID", "42") + t.Setenv("GITLAB_TOKEN", "test-token") + + err := execCmd(t, "--no-release", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("--no-release: unexpected error: %v", err) + } +} + func TestRunMissingToken(t *testing.T) { _, dir := setupRepoWithRemote(t) addFile(t, dir, "x.go", "// fix") @@ -539,3 +592,732 @@ func TestMainError(t *testing.T) { t.Errorf("expected exit code 1 for general error, got %d", gotCode) } } + +func TestRunInit(t *testing.T) { + dir := t.TempDir() + err := execCmd(t, "--init", "--repo", dir) + if err != nil { + t.Fatalf("--init: unexpected error: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, ".releaser.yml")) + if err != nil { + t.Fatal("expected .releaser.yml to be created") + } + if len(data) == 0 { + t.Error("expected non-empty .releaser.yml") + } +} + +func TestRunInitAlreadyExists(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("existing"), 0644) + err := execCmd(t, "--init", "--repo", dir) + if err == nil { + t.Fatal("expected error when .releaser.yml already exists") + } +} + +func TestRunChangelogCreated(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// feat") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("feat: add shiny feature", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "CHANGELOG.md")) + if err != nil { + t.Fatal("expected CHANGELOG.md to be created") + } + s := string(data) + if !strings.Contains(s, "## [1.2.0]") { + t.Error("expected version header in CHANGELOG") + } + if !strings.Contains(s, "add shiny feature") { + t.Error("expected feat subject in CHANGELOG") + } +} + +func TestRunChangelogFile(t *testing.T) { + _, dir := setupRepo(t) + addFile(t, dir, "x.go", "// fix") + repo, _ := gogit.PlainOpen(dir) + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--changelog-file", "CHANGES.md") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(dir, "CHANGES.md")); err != nil { + t.Error("expected CHANGES.md to be created") + } +} + +func 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) { + if os.Getuid() == 0 { + t.Skip("skipping: chmod restrictions do not apply when running as root") + } + 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) { + if os.Getuid() == 0 { + t.Skip("skipping: chmod restrictions do not apply when running as root") + } + _, 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"}, + Gradle: config.GradleConfig{BuildFile: "build.gradle"}, + } + 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") + } + if !strings.Contains(output, "build.gradle") { + t.Error("expected 'build.gradle' in output for gradle.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") + } +} + +// ── gradle build file handling ──────────────────────────────────────────────── + +func writeGradleFile(t *testing.T, dir, ver string) { + t.Helper() + content := fmt.Sprintf("group = \"com.example\"\nversion = \"%s\"\n", ver) + if err := os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestRunGradleVersionBump(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + writeGradleFile(t, dir, "0.0.0") + commitAll(t, repo, dir, "chore: init") + + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir); err != nil { + t.Fatalf("gradle version bump: unexpected error: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "build.gradle")) + if !strings.Contains(string(data), `version = "1.2.0"`) { + t.Errorf("expected version 1.2.0 in build.gradle, got: %s", data) + } +} + +func TestRunGradleOverrideFlag(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + // Write gradle file at custom path + if err := os.MkdirAll(filepath.Join(dir, "sub"), 0755); err != nil { + t.Fatal(err) + } + content := "version = \"0.0.0\"\n" + os.WriteFile(filepath.Join(dir, "sub", "build.gradle"), []byte(content), 0644) + commitAll(t, repo, dir, "chore: init") + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + if err := execCmd(t, "--no-push", "--branch", "release/1.2", "--repo", dir, "--gradle", "sub/build.gradle"); err != nil { + t.Fatalf("--gradle flag: unexpected error: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "sub", "build.gradle")) + if !strings.Contains(string(data), `version = "1.2.0"`) { + t.Errorf("expected version 1.2.0, got: %s", data) + } +} + +func TestRunGradleReadVersionFails(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + // build.gradle with no version assignment + os.WriteFile(filepath.Join(dir, "build.gradle"), []byte(`group = "com.example"`), 0644) + commitAll(t, repo, dir, "chore: init") + + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil { + t.Fatal("expected error when build.gradle has no version assignment") + } +} + +func TestRunGradleWriteVersionFails(t *testing.T) { + dir := t.TempDir() + repo, err := gogit.PlainInit(dir, false) + if err != nil { + t.Fatal(err) + } + writeGradleFile(t, dir, "0.0.0") + commitAll(t, repo, dir, "chore: init") + + os.WriteFile(filepath.Join(dir, ".releaser.yml"), []byte("gradle:\n build_file: \"build.gradle\"\n"), 0644) + + addFile(t, dir, "x.go", "// fix") + w, _ := repo.Worktree() + w.Add("x.go") + w.Commit("fix: something", &gogit.CommitOptions{Author: testSig()}) + + os.Chmod(filepath.Join(dir, "build.gradle"), 0444) + defer os.Chmod(filepath.Join(dir, "build.gradle"), 0644) + + if err := execCmd(t, "--branch", "release/1.2", "--repo", dir); err == nil { + t.Fatal("expected error when build.gradle is read-only") + } +} diff --git a/cmd/ui.go b/cmd/ui.go new file mode 100644 index 0000000..d34f5f7 --- /dev/null +++ b/cmd/ui.go @@ -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+"]") + } +} diff --git a/docs/.hugo_build.lock b/docs/.hugo_build.lock new file mode 100644 index 0000000..e69de29 diff --git a/docs/content/_index.md b/docs/content/_index.md new file mode 100644 index 0000000..3ef18ba --- /dev/null +++ b/docs/content/_index.md @@ -0,0 +1,24 @@ +--- +title: releaser +--- + +**CI-friendly release automation for GitFlow workflows using Conventional Commits.** + +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` / `package.json` update to GitLab/GitHub tag and release creation. + +## How it works + +``` +release/1.2 branch + └─ last tag: 1.2.3 (or none → start at 1.2.0) + └─ commits since tag → Conventional Commits analysis + └─ next version: 1.2.4 +``` + +1. **Branch parsing** — extracts `major.minor` from branch name (`release/1.2` → `1.2`) +2. **Tag discovery** — finds the latest tag matching `major.minor.*` on the current branch +3. **Commit analysis** — parses Conventional Commits between last tag and HEAD +4. **Version bump** — increments patch (or minor, if configured via `bump_rules`) +5. **Release** — updates `pom.xml` / `package.json`, commits, tags, creates GitLab or GitHub release diff --git a/docs/content/changelog.md b/docs/content/changelog.md new file mode 100644 index 0000000..b2b2906 --- /dev/null +++ b/docs/content/changelog.md @@ -0,0 +1,76 @@ +--- +title: Changelog +weight: 50 +--- + +## v1.6.0 — 2026-07-11 + +### Added + +- **Gradle support** — opt-in via `gradle.build_file` (single path) or `gradle.build_files` (list, overrides single); supports both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`); original quote style preserved on write; `--gradle ` CLI flag for one-off overrides + +## v1.5.1 — 2026-07-11 + +### Added + +- **Documentation site** — Hugo + Geekdoc; installation, CLI reference, configuration, CI integration, and changelog pages; deployed to `gh-pages` via Gitea CI on push to `main` +- **`FuzzUpdate`** in `internal/changelog` and **`FuzzWriteVersion`** in `internal/node` — complete fuzz coverage for all file-rewriting packages + +### Changed + +- **CLAUDE.md** — fuzzing completeness guidelines with authoritative table + +## v1.5.0 — 2026-07-11 + +### Added + +- **Multi-module Maven support** — `maven.pom_paths: [...]` lists multiple `pom.xml` paths; 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); version 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"` +- **100% per-package statement coverage** across all 12 packages via injectable function vars + +### Changed + +- **`--pom` flag** now clears `maven.pom_paths` before setting `maven.pom_path` +- **`version.Next()` signature** — accepts a bump-rules map as a sixth parameter; `nil` defaults to all-patch +- **Verbose config table** — now includes `git.bump_rules.*`, `maven.pom_paths`, and `node.paths` rows + +## v1.4.0 — 2026-07-11 + +### Added + +- **GitHub release support** — `internal/ghclient` package; configured via `github.token` + `github.repo`; GitHub takes precedence over GitLab when both are configured +- **SSH agent push** — go-git `gitssh.NewSSHAgentAuth` for `git@` / `ssh://` remotes +- **`--release-env-file` flag** — override dotenv artifact path; pass `""` to disable +- **`git.releasable_types` config** — opt-in list of commit types that count as releasable +- **CHANGELOG deduplication guard** — `changelog.Update()` is idempotent; skips write if section already exists + +## v1.3.0 — 2026-07-07 + +### Added + +- **`release.env` dotenv artifact** — written on every real release containing `NEXT_VERSION=`; never committed; exposes the version to downstream GitLab CI jobs + +## v1.2.0 — 2026-07-07 + +### Added + +- **`--verbose` flag** — prints config table, commit list with parsed types, and version decision +- **Colored, structured CLI output** — `·` / `✓` / `!` prefix symbols; TTY-aware ANSI colors; respects `NO_COLOR` and `TERM=dumb` +- **Name and version header** on every invocation + +### Changed + +- **Default `tag_prefix` is now empty** — bare version numbers (`1.2.3`) by default; add `tag_prefix: "v"` to opt in + +## v1.1.0 — 2026-07-07 + +### Added + +- **CHANGELOG.md auto-update** — new dated section written on every release, grouped by commit type +- **`--changelog-file` flag** — override changelog path +- **`--init` flag** — scaffolds a fully-commented `.releaser.yml` + +## v1.0 and earlier + +See the [full CHANGELOG](https://git.k3nny.fr/releaser/src/branch/main/CHANGELOG.md) in the repository. diff --git a/docs/content/ci-integration.md b/docs/content/ci-integration.md new file mode 100644 index 0000000..bee2dde --- /dev/null +++ b/docs/content/ci-integration.md @@ -0,0 +1,109 @@ +--- +title: CI Integration +weight: 40 +--- + +## GitLab CI + +The simplest setup uses the reusable job template shipped alongside `releaser`: + +```yaml +# .gitlab-ci.yml +include: + - project: releaser/releaser + file: .releaser.gitlab-ci.yml + +release: + extends: .releaser + variables: + GITLAB_TOKEN: $RELEASE_TOKEN # project/group variable with api + write_repository scope +``` + +Or write it inline: + +```yaml +release: + stage: release + image: registry.example.com/releaser:latest + rules: + - if: $CI_COMMIT_BRANCH =~ /^release\/.+$/ + variables: + GITLAB_TOKEN: $RELEASE_TOKEN + script: + - releaser + artifacts: + reports: + dotenv: release.env # exposes NEXT_VERSION to downstream jobs +``` + +### Consuming `NEXT_VERSION` downstream + +The `release.env` dotenv artifact exports `NEXT_VERSION=` automatically. Downstream jobs can use it: + +```yaml +deploy: + stage: deploy + needs: + - job: release + artifacts: true + script: + - echo "Deploying version $NEXT_VERSION" +``` + +Disable the dotenv artifact (e.g. for local runs): + +```bash +releaser --release-env-file "" +``` + +Write it to a custom path: + +```bash +releaser --release-env-file deploy/version.env +``` + +## GitHub Actions / Gitea Actions + +```yaml +name: release +on: + push: + branches: + - 'release/**' + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history needed for tag discovery + + - name: Run releaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + curl -sSL https://git.k3nny.fr/releaser/releases/latest/download/releaser-linux-amd64 \ + -o /usr/local/bin/releaser + chmod +x /usr/local/bin/releaser + releaser +``` + +{{< hint warning >}} +`fetch-depth: 0` is required. A shallow clone (`--depth 1`) hides the previous tag, causing `releaser` to treat every commit as the first release. +{{< /hint >}} + +## Detached HEAD + +In CI environments where `git checkout` leaves the repository in detached HEAD state, pass the branch name explicitly: + +```yaml +script: + - releaser --branch "$CI_COMMIT_BRANCH" +``` + +## SSH push + +When pushing over SSH (`git@host:...` or `ssh://...` remotes), `releaser` attempts go-git SSH agent auth automatically — no extra configuration needed as long as the CI runner has an SSH agent socket available. + +For HTTPS remotes without a token, `releaser` delegates to the system `git` binary so credential helpers and `netrc` work as expected. diff --git a/docs/content/configuration.md b/docs/content/configuration.md new file mode 100644 index 0000000..fdb667d --- /dev/null +++ b/docs/content/configuration.md @@ -0,0 +1,150 @@ +--- +title: Configuration +weight: 30 +--- + +`releaser` reads `.releaser.yml` from the repository root. All fields are optional — missing values fall back to the defaults shown below. Run `releaser --init` to scaffold the file with annotations. + +## Full reference + +```yaml +git: + tag_prefix: "" # default: no prefix; "v" for v1.2.3 style + branch_pattern: "^(?:.*/)?release/(\\d+)\\.(\\d+)$" # two capture groups: major, minor + commit_message: "chore(release): {version} [skip ci]" + author_name: "" # defaults to git config user.name + author_email: "" # defaults to git config user.email + + # Limit which commit types trigger a release (default: all three). + releasable_types: + - fix + - feat + - breaking + + # Control which version component each commit type bumps. + # Valid values: "patch" (default) or "minor". + bump_rules: + breaking: "patch" + feat: "patch" + fix: "patch" + +maven: + pom_path: "pom.xml" # single pom.xml, relative to repo root + + # Multi-module: list overrides pom_path. + # pom_paths: + # - "pom.xml" + # - "module-a/pom.xml" + # - "module-b/pom.xml" + +node: # opt-in — omit section to skip + # package_json: "package.json" # single path + + # Monorepo: list overrides package_json. + # package_jsons: + # - "packages/frontend/package.json" + # - "packages/backend/package.json" + +gradle: # opt-in — omit section to skip + # build_file: "build.gradle" # Groovy or Kotlin DSL; single path + + # Multi-module: list overrides build_file. + # build_files: + # - "build.gradle" + # - "module-a/build.gradle" + +gitlab: + url: "https://gitlab.example.com" # or env CI_SERVER_URL + token: "" # prefer env GITLAB_TOKEN + project: "" # prefer env CI_PROJECT_ID or CI_PROJECT_PATH + +github: + token: "" # prefer env GITHUB_TOKEN + repo: "" # "owner/repo" format +``` + +{{< hint info >}} +When both `github.*` and `gitlab.*` are configured, GitHub takes precedence. +{{< /hint >}} + +## Environment variables + +| 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 | + +## Config sources + +Run `releaser --verbose --dry-run` to see every config key, its resolved value, and where it came from (`default` / `config file` / `env: VARNAME` / `flag: --name`). + +## `git.releasable_types` + +By default `fix`, `feat`, and `breaking` commits all trigger a release. Use `releasable_types` to restrict this — for example, on a maintenance branch where you want only bug fixes to release: + +```yaml +git: + releasable_types: + - fix +``` + +## `git.bump_rules` + +By default every releasable commit bumps the **patch** component. The `bump_rules` map lets you promote specific types to bump **minor** instead. This is useful on a branch that manages its own minor versioning: + +```yaml +git: + bump_rules: + feat: "minor" # feat: commits bump minor, not patch + breaking: "minor" # breaking changes bump minor too + fix: "patch" # fix: stays patch (this is the default) +``` + +## Multi-module Maven + +`pom_paths` accepts a list and overrides `pom_path`. All listed files are updated and committed in the same release commit: + +```yaml +maven: + pom_paths: + - "pom.xml" + - "module-a/pom.xml" + - "module-b/pom.xml" +``` + +The `--pom` CLI flag sets a single path and clears `pom_paths`. + +## Node.js support + +The `node` section is opt-in — if omitted, no `package.json` is touched. Use `package_jsons` for monorepos: + +```yaml +node: + package_jsons: + - "packages/frontend/package.json" + - "packages/backend/package.json" +``` + +## Gradle support + +The `gradle` section is opt-in — if omitted, no build file is touched. Both Groovy DSL (`version = '1.2.3'`) and Kotlin DSL (`version = "1.2.3"`) are supported; the original quote style is preserved on write. + +```yaml +gradle: + build_file: "build.gradle" +``` + +Use `build_files` for multi-module projects: + +```yaml +gradle: + build_files: + - "build.gradle" + - "module-a/build.gradle" + - "module-b/build.gradle" +``` + +The `--gradle ` CLI flag sets a single build file path and clears `build_files`. diff --git a/docs/content/installation.md b/docs/content/installation.md new file mode 100644 index 0000000..55a429b --- /dev/null +++ b/docs/content/installation.md @@ -0,0 +1,59 @@ +--- +title: Installation +weight: 10 +--- + +## Pre-built binaries + +Download the latest release for your platform from the [Releases page](https://git.k3nny.fr/releaser/releases). + +```bash +# Linux (amd64) +curl -sSL https://git.k3nny.fr/releaser/releases/download/v1.5.0/releaser-v1.5.0-linux-amd64 \ + -o /usr/local/bin/releaser +chmod +x /usr/local/bin/releaser +``` + +Available platforms: `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`, `windows-amd64.exe`. + +## Docker + +```bash +docker pull git.k3nny.fr/releaser/releaser:latest + +# Run in the current repository +docker run --rm \ + -v "$PWD:/repo" \ + -e GITLAB_TOKEN="$GITLAB_TOKEN" \ + git.k3nny.fr/releaser/releaser:latest +``` + +## Build from source + +Requires Go 1.21+. + +```bash +git clone https://git.k3nny.fr/releaser/releaser.git +cd releaser +go build -o /usr/local/bin/releaser ./cmd +``` + +## Verify + +```bash +releaser --version +``` + +## First run + +Scaffold a default `.releaser.yml` in your repository root: + +```bash +releaser --init +``` + +Then do a dry run to check the version that would be produced: + +```bash +releaser --dry-run +``` diff --git a/docs/content/usage.md b/docs/content/usage.md new file mode 100644 index 0000000..43b9d64 --- /dev/null +++ b/docs/content/usage.md @@ -0,0 +1,70 @@ +--- +title: CLI Reference +weight: 20 +--- + +## Common workflows + +```bash +# Scaffold a default .releaser.yml +releaser --init + +# Preview next version (no side effects) +releaser --dry-run + +# Full release: bump versions, commit, tag, push, create release +releaser + +# Commit and tag locally — skip push and release creation +releaser --no-push + +# Push commit and tag but skip creating the release +releaser --no-release + +# Update files but stop before committing +releaser --no-commit +# ... review changes, then commit manually and re-run: +releaser --tag-only + +# Verbose mode: show config sources, commit analysis, version decision +releaser --verbose --dry-run +``` + +## Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--dry-run` | false | Print next version and exit without making any changes | +| `--branch ` | auto-detected | Override branch name (useful in detached HEAD / CI) | +| `--branch-pattern ` | `^(?:.*/)?release/(\d+)\.(\d+)$` | Override branch pattern (two capture groups: major, minor) | +| `--tag-prefix ` | `""` | Prefix for version tags (e.g. `v` → `v1.2.3`) | +| `--pom ` | `pom.xml` | Path to pom.xml relative to repo root | +| `--gradle ` | — | Override `gradle.build_file` from config | +| `--changelog-file ` | `CHANGELOG.md` | Path to changelog file | +| `--release-env-file ` | `release.env` | Path for dotenv artifact; pass `""` to disable | +| `--no-commit` | false | Update version files but stop before committing | +| `--no-push` | false | Commit and tag locally, skip push and release | +| `--no-release` | false | Push branch and tag but skip release creation | +| `--tag-only` | false | Skip version file updates — tag HEAD and push | +| `--init` | false | Scaffold a default `.releaser.yml` and exit | +| `--verbose` | false | Print config table, commit analysis, and version decision | + +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Error (config, git, API, etc.) | +| `2` | No releasable commits found — nothing to do | + +## Version bump rules + +By default all releasable commits bump the **patch** component (minor is pinned to the branch). Override per commit type via `git.bump_rules` in `.releaser.yml`: + +| Commit type | Default | Configurable via `bump_rules` | +|-------------|---------|-------------------------------| +| `fix:` | patch | `fix: "minor"` to bump minor | +| `feat:` | patch | `feat: "minor"` to bump minor | +| `feat!:` / `BREAKING CHANGE` | patch | `breaking: "minor"` to bump minor | +| `chore:`, `docs:`, etc. | none | — | +| unparseable message | none | non-strict: silently ignored | diff --git a/docs/data/menu/main.yaml b/docs/data/menu/main.yaml new file mode 100644 index 0000000..ed3c95e --- /dev/null +++ b/docs/data/menu/main.yaml @@ -0,0 +1,16 @@ +main: + - name: Installation + ref: /installation + weight: 10 + - name: CLI Reference + ref: /usage + weight: 20 + - name: Configuration + ref: /configuration + weight: 30 + - name: CI Integration + ref: /ci-integration + weight: 40 + - name: Changelog + ref: /changelog + weight: 50 diff --git a/docs/hugo.toml b/docs/hugo.toml new file mode 100644 index 0000000..8aa4cdb --- /dev/null +++ b/docs/hugo.toml @@ -0,0 +1,21 @@ +baseURL = "/" +title = "releaser" +theme = "geekdoc" + +pygmentsUseClasses = true +pygmentsCodeFences = true + +[markup] + [markup.goldmark.renderer] + unsafe = true + [markup.tableOfContents] + startLevel = 1 + endLevel = 9 + +[params] + geekdocRepo = "https://git.k3nny.fr/releaser" + geekdocEditPath = "edit/main/docs/content" + geekdocSearch = true + geekdocMenuBundle = true + geekdocBreadcrumb = false + geekdocToC = true diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go new file mode 100644 index 0000000..bb12aa7 --- /dev/null +++ b/internal/changelog/changelog.go @@ -0,0 +1,76 @@ +package changelog + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "git.k3nny.fr/releaser/internal/commits" +) + +// Update inserts a new release section into the CHANGELOG file at path. +// If the file does not exist it is created with a standard header. +// Only commits with a releasable type (fix, feat, breaking) produce bullets; +// if none are found the file is left untouched. +// If a section for version already exists the file is left untouched (idempotent). +func Update(path, tag, version string, messages []string) error { + section := buildSection(version, messages) + if section == "" { + return nil + } + + existing := "" + data, err := os.ReadFile(path) + if err == nil { + existing = string(data) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read %s: %w", path, err) + } + + if strings.Contains(existing, "## ["+version+"]") { + return nil + } + + var out string + if existing == "" { + out = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n" + + section + "\n" + } else { + // Insert above the first ## [ heading so newest release is always at top. + if idx := strings.Index(existing, "\n## ["); idx >= 0 { + out = existing[:idx+1] + section + "\n\n" + existing[idx+1:] + } else { + out = strings.TrimRight(existing, "\n") + "\n\n" + section + "\n" + } + } + + return os.WriteFile(path, []byte(out), 0644) +} + +func buildSection(version string, messages []string) string { + breaking, feats, fixes := commits.Group(messages) + if len(breaking)+len(feats)+len(fixes) == 0 { + return "" + } + + date := time.Now().Format("2006-01-02") + var sb strings.Builder + fmt.Fprintf(&sb, "## [%s] - %s\n", version, date) + writeSection(&sb, "Breaking Changes", breaking) + writeSection(&sb, "Added", feats) + writeSection(&sb, "Fixed", fixes) + + return strings.TrimRight(sb.String(), "\n") +} + +func writeSection(sb *strings.Builder, title string, items []string) { + if len(items) == 0 { + return + } + fmt.Fprintf(sb, "\n### %s\n", title) + for _, item := range items { + fmt.Fprintf(sb, "- %s\n", item) + } +} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go new file mode 100644 index 0000000..35070f0 --- /dev/null +++ b/internal/changelog/changelog_test.go @@ -0,0 +1,170 @@ +package changelog + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateNewFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v1.2.0", "1.2.0", []string{ + "feat: add widget", + "fix: off-by-one in parser", + }); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "## [1.2.0]") { + t.Error("expected version header") + } + if !strings.Contains(s, "### Added") { + t.Error("expected Added section") + } + if !strings.Contains(s, "### Fixed") { + t.Error("expected Fixed section") + } + if !strings.Contains(s, "add widget") { + t.Error("expected feat subject") + } + if !strings.Contains(s, "off-by-one in parser") { + t.Error("expected fix subject") + } +} + +func TestUpdateExistingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + // Seed with an older release. + os.WriteFile(path, []byte("# Changelog\n\n## [1.1.0] - 2026-01-01\n\n### Added\n- old stuff\n"), 0644) + + if err := Update(path, "v1.2.0", "1.2.0", []string{"feat: new thing"}); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + + newIdx := strings.Index(s, "## [1.2.0]") + oldIdx := strings.Index(s, "## [1.1.0]") + if newIdx < 0 || oldIdx < 0 { + t.Fatal("both versions should appear in CHANGELOG") + } + if newIdx > oldIdx { + t.Error("new version should appear before old version") + } +} + +func TestUpdateNoReleasableCommits(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v1.0.1", "1.0.1", []string{ + "chore: update deps", + "docs: fix typo", + }); err != nil { + t.Fatal(err) + } + + // File should NOT have been created. + if _, err := os.Stat(path); err == nil { + t.Error("file should not be created when there are no releasable commits") + } +} + +func TestUpdateBreakingSection(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + if err := Update(path, "v2.0.0", "2.0.0", []string{ + "feat!: redesign API", + "fix(core): nil panic", + }); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "### Breaking Changes") { + t.Error("expected Breaking Changes section") + } + if !strings.Contains(s, "redesign API") { + t.Error("expected breaking subject") + } +} + +func TestUpdateExistingFileNoHeading(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + + // File with content but no ## [ heading — new section appended at bottom. + os.WriteFile(path, []byte("# Changelog\n\nSome preamble.\n"), 0644) + + err := Update(path, "v1.0.0", "1.0.0", []string{"fix: something"}) + if err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + s := string(data) + if !strings.Contains(s, "## [1.0.0]") { + t.Error("expected version header appended") + } + if !strings.Contains(s, "# Changelog") { + t.Error("expected original content preserved") + } +} + +func TestUpdateReadError(t *testing.T) { + dir := t.TempDir() + // Create a directory where the file should be — ReadFile will error. + os.Mkdir(filepath.Join(dir, "CHANGELOG.md"), 0755) + + err := Update(filepath.Join(dir, "CHANGELOG.md"), "v1.0.0", "1.0.0", []string{"fix: something"}) + if err == nil { + t.Error("expected error when path is a directory") + } +} + +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") + } +} + +// FuzzUpdate verifies Update never panics on arbitrary existing file content or commit messages. +func FuzzUpdate(f *testing.F) { + f.Add("", "feat: add thing") + f.Add("# Changelog\n\n## [1.0.0] - 2026-01-01\n\n### Added\n- something\n", "fix: something") + f.Add("some preamble\n", "feat!: breaking change") + f.Add("\n## [2.0.0] - 2026-01-01\n", "feat: another thing") + f.Add("", "chore: no release") + f.Add("", "") + + f.Fuzz(func(t *testing.T, existing, message string) { + dir := t.TempDir() + path := filepath.Join(dir, "CHANGELOG.md") + if existing != "" { + os.WriteFile(path, []byte(existing), 0644) //nolint:errcheck + } + Update(path, "v1.0.0", "1.0.0", []string{message}) //nolint:errcheck + }) +} diff --git a/internal/commits/commits.go b/internal/commits/commits.go index 03441f5..31f1912 100644 --- a/internal/commits/commits.go +++ b/internal/commits/commits.go @@ -5,6 +5,62 @@ import ( "strings" ) +// headerSubjectRe captures the subject (description) from a conventional commit header. +var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`) + +// ExtractSubject returns the description part of a conventional commit header. +// Falls back to the trimmed raw header if the pattern does not match. +func ExtractSubject(header string) string { + m := headerSubjectRe.FindStringSubmatch(header) + if m != nil { + return strings.TrimSpace(m[1]) + } + return strings.TrimSpace(header) +} + +// Group splits messages into breaking changes, features, and fixes. +// Only the first line of each message is considered; the subject is extracted. +// Messages with TypeNone are silently dropped. +func Group(messages []string) (breaking, feats, fixes []string) { + for _, msg := range messages { + t := Parse(msg) + if t == TypeNone { + continue + } + first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0] + subject := ExtractSubject(first) + switch t { + case TypeBreaking: + breaking = append(breaking, subject) + case TypeFeat: + feats = append(feats, subject) + case TypeFix: + fixes = append(fixes, subject) + } + } + return +} + +// ReleasableSet converts a slice of type-name strings to a set for use in version.Next. +// An empty or nil slice defaults to all three releasable types (fix, feat, breaking). +func ReleasableSet(typeNames []string) map[Type]bool { + if len(typeNames) == 0 { + return map[Type]bool{TypeFix: true, TypeFeat: true, TypeBreaking: true} + } + m := make(map[Type]bool, len(typeNames)) + for _, name := range typeNames { + switch strings.ToLower(name) { + case "fix": + m[TypeFix] = true + case "feat": + m[TypeFeat] = true + case "breaking": + m[TypeBreaking] = true + } + } + return m +} + // Type represents the semantic weight of a commit for versioning purposes. type Type int diff --git a/internal/commits/commits_test.go b/internal/commits/commits_test.go index ad3459b..bdbad4d 100644 --- a/internal/commits/commits_test.go +++ b/internal/commits/commits_test.go @@ -35,6 +35,64 @@ func FuzzParse(f *testing.F) { }) } +func TestExtractSubject(t *testing.T) { + cases := []struct { + header string + want string + }{ + {"feat: add login", "add login"}, + {"feat(auth): add OAuth2", "add OAuth2"}, + {"feat!: remove API", "remove API"}, + {"FIX:typo", "typo"}, + {"plain message", "plain message"}, + } + for _, c := range cases { + got := ExtractSubject(c.header) + if got != c.want { + t.Errorf("ExtractSubject(%q) = %q, want %q", c.header, got, c.want) + } + } +} + +func TestGroup(t *testing.T) { + messages := []string{ + "feat: add login", + "fix: patch null pointer", + "feat!: remove legacy API", + "chore: update deps", + "fix: handle empty response", + } + breaking, feats, fixes := Group(messages) + if len(breaking) != 1 || breaking[0] != "remove legacy API" { + t.Errorf("breaking = %v, want [remove legacy API]", breaking) + } + if len(feats) != 1 || feats[0] != "add login" { + t.Errorf("feats = %v, want [add login]", feats) + } + if len(fixes) != 2 { + t.Errorf("fixes = %v, want 2 items", fixes) + } +} + +func TestReleasableSet(t *testing.T) { + all := ReleasableSet(nil) + if !all[TypeFix] || !all[TypeFeat] || !all[TypeBreaking] { + t.Error("nil input should return all three types") + } + only := ReleasableSet([]string{"fix"}) + if !only[TypeFix] || only[TypeFeat] || only[TypeBreaking] { + t.Errorf("fix-only set: %v", only) + } + onlyFeat := ReleasableSet([]string{"feat"}) + if onlyFeat[TypeFix] || !onlyFeat[TypeFeat] || onlyFeat[TypeBreaking] { + t.Errorf("feat-only set: %v", onlyFeat) + } + onlyBreaking := ReleasableSet([]string{"breaking"}) + if onlyBreaking[TypeFix] || onlyBreaking[TypeFeat] || !onlyBreaking[TypeBreaking] { + t.Errorf("breaking-only set: %v", onlyBreaking) + } +} + func TestTypeString(t *testing.T) { cases := []struct { t Type diff --git a/internal/config/config.go b/internal/config/config.go index c8ab127..f804ea6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,19 +16,79 @@ const filename = ".releaser.yml" type Config struct { Git GitConfig `yaml:"git"` Maven MavenConfig `yaml:"maven"` + Node NodeConfig `yaml:"node"` + Gradle GradleConfig `yaml:"gradle"` GitLab GitLabConfig `yaml:"gitlab"` + GitHub GitHubConfig `yaml:"github"` } type GitConfig struct { - TagPrefix string `yaml:"tag_prefix"` - BranchPattern string `yaml:"branch_pattern"` - CommitMessage string `yaml:"commit_message"` - AuthorName string `yaml:"author_name"` - AuthorEmail string `yaml:"author_email"` + TagPrefix string `yaml:"tag_prefix"` + BranchPattern string `yaml:"branch_pattern"` + CommitMessage string `yaml:"commit_message"` + AuthorName string `yaml:"author_name"` + AuthorEmail string `yaml:"author_email"` + ReleasableTypes []string `yaml:"releasable_types"` + BumpRules BumpRulesConfig `yaml:"bump_rules"` +} + +// BumpRulesConfig controls what version component each commit type bumps. +// Valid values: "patch" (default) or "minor". +type BumpRulesConfig struct { + Breaking string `yaml:"breaking"` + Feat string `yaml:"feat"` + Fix string `yaml:"fix"` } type MavenConfig struct { - PomPath string `yaml:"pom_path"` + PomPath string `yaml:"pom_path"` // single path (default: "pom.xml") + PomPaths []string `yaml:"pom_paths"` // multiple paths; overrides PomPath when set +} + +// EffectivePomPaths returns the list of pom.xml paths to process. +// PomPaths takes precedence over PomPath; falls back to ["pom.xml"]. +func (m MavenConfig) EffectivePomPaths() []string { + if len(m.PomPaths) > 0 { + return m.PomPaths + } + if m.PomPath != "" { + return []string{m.PomPath} + } + return []string{"pom.xml"} +} + +type NodeConfig struct { + PackageJSON string `yaml:"package_json"` // single path + PackageJSONs []string `yaml:"package_jsons"` // multiple paths; overrides PackageJSON when set +} + +// EffectivePaths returns the list of package.json paths to process. +// Returns nil when no node paths are configured (node processing is opt-in). +func (n NodeConfig) EffectivePaths() []string { + if len(n.PackageJSONs) > 0 { + return n.PackageJSONs + } + if n.PackageJSON != "" { + return []string{n.PackageJSON} + } + return nil +} + +type GradleConfig struct { + BuildFile string `yaml:"build_file"` // single path (opt-in, no default) + BuildFiles []string `yaml:"build_files"` // multiple paths; overrides BuildFile +} + +// EffectiveBuildFiles returns the list of Gradle build file paths to process. +// Returns nil when no gradle paths are configured (gradle processing is opt-in). +func (g GradleConfig) EffectiveBuildFiles() []string { + if len(g.BuildFiles) > 0 { + return g.BuildFiles + } + if g.BuildFile != "" { + return []string{g.BuildFile} + } + return nil } type GitLabConfig struct { @@ -37,10 +97,15 @@ type GitLabConfig struct { Project string `yaml:"project"` } +type GitHubConfig struct { + Token string `yaml:"token"` + Repo string `yaml:"repo"` // "owner/repo" +} + func defaults() Config { return Config{ Git: GitConfig{ - TagPrefix: "v", + TagPrefix: "", BranchPattern: branch.DefaultBranchPattern, CommitMessage: "chore(release): {version} [skip ci]", }, @@ -50,42 +115,173 @@ 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", + "gradle.build_file": "default", + "gradle.build_files": "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. // Missing file is not an error — defaults are returned as-is. func Load(dir string) (Config, error) { + cfg, _, err := LoadWithSources(dir) + return cfg, err +} + +// LoadWithSources is like Load but also returns a Sources map recording where each +// value came from ("default" or "config file"). +func LoadWithSources(dir string) (Config, Sources, error) { cfg := defaults() + src := defaultSources() data, err := os.ReadFile(filepath.Join(dir, filename)) if errors.Is(err, os.ErrNotExist) { - return cfg, nil + return cfg, src, nil } if err != nil { - return cfg, fmt.Errorf("read %s: %w", filename, err) + return cfg, src, fmt.Errorf("read %s: %w", filename, err) } + // Unmarshal into cfg (merges over defaults). if err := yaml.Unmarshal(data, &cfg); err != nil { - return cfg, fmt.Errorf("parse %s: %w", filename, err) + return cfg, src, fmt.Errorf("parse %s: %w", filename, err) } - return cfg, nil + // Detect which fields the file explicitly set by unmarshaling into a zero overlay. + var overlay Config + _ = yaml.Unmarshal(data, &overlay) + if overlay.Git.TagPrefix != "" { + src["git.tag_prefix"] = "config file" + } + if overlay.Git.BranchPattern != "" { + src["git.branch_pattern"] = "config file" + } + if overlay.Git.CommitMessage != "" { + src["git.commit_message"] = "config file" + } + if overlay.Git.AuthorName != "" { + src["git.author_name"] = "config file" + } + if overlay.Git.AuthorEmail != "" { + src["git.author_email"] = "config file" + } + if 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.Gradle.BuildFile != "" { + src["gradle.build_file"] = "config file" + } + if len(overlay.Gradle.BuildFiles) > 0 { + src["gradle.build_files"] = "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. func (c *Config) ApplyEnv() { + c.ApplyEnvWithSources(nil) +} + +// ApplyEnvWithSources is like ApplyEnv but records the env var name in src for each +// field it fills. src may be nil. +func (c *Config) ApplyEnvWithSources(src Sources) { if c.GitLab.Token == "" { - c.GitLab.Token = os.Getenv("GITLAB_TOKEN") + if v := os.Getenv("GITLAB_TOKEN"); v != "" { + c.GitLab.Token = v + if src != nil { + src["gitlab.token"] = "env: GITLAB_TOKEN" + } + } } if c.GitLab.URL == "" { - // CI_SERVER_URL is the cleanest source ("https://gitlab.example.com") - c.GitLab.URL = os.Getenv("CI_SERVER_URL") + if v := os.Getenv("CI_SERVER_URL"); v != "" { + c.GitLab.URL = v + if src != nil { + src["gitlab.url"] = "env: CI_SERVER_URL" + } + } } if c.GitLab.Project == "" { - // Prefer numeric ID; fall back to namespace/project path if id := os.Getenv("CI_PROJECT_ID"); id != "" { c.GitLab.Project = id - } else { - c.GitLab.Project = os.Getenv("CI_PROJECT_PATH") + if src != nil { + src["gitlab.project"] = "env: CI_PROJECT_ID" + } + } else if p := os.Getenv("CI_PROJECT_PATH"); p != "" { + c.GitLab.Project = p + if src != nil { + src["gitlab.project"] = "env: CI_PROJECT_PATH" + } + } + } + if c.GitHub.Token == "" { + if v := os.Getenv("GITHUB_TOKEN"); v != "" { + c.GitHub.Token = v + if src != nil { + src["github.token"] = "env: GITHUB_TOKEN" + } } } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index aa65d6f..8ec882a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,8 +11,8 @@ func TestLoadDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - if cfg.Git.TagPrefix != "v" { - t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "v") + if cfg.Git.TagPrefix != "" { + t.Errorf("TagPrefix = %q, want %q", cfg.Git.TagPrefix, "") } if cfg.Maven.PomPath != "pom.xml" { t.Errorf("PomPath = %q, want %q", cfg.Maven.PomPath, "pom.xml") @@ -123,6 +123,221 @@ 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 TestGradleEffectiveBuildFiles(t *testing.T) { + // Neither set → nil (opt-in) + if got := (GradleConfig{}).EffectiveBuildFiles(); got != nil { + t.Errorf("empty config: got %v, want nil", got) + } + // BuildFile only + if got := (GradleConfig{BuildFile: "build.gradle"}).EffectiveBuildFiles(); len(got) != 1 || got[0] != "build.gradle" { + t.Errorf("BuildFile only: got %v", got) + } + // BuildFiles wins over BuildFile + g := GradleConfig{BuildFile: "build.gradle", BuildFiles: []string{"a/build.gradle", "b/build.gradle"}} + if got := g.EffectiveBuildFiles(); len(got) != 2 || got[0] != "a/build.gradle" { + t.Errorf("BuildFiles priority: got %v", got) + } +} + +func TestLoadGradleSources(t *testing.T) { + dir := t.TempDir() + content := "gradle:\n build_file: \"build.gradle\"\n" + 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) + } + if src["gradle.build_file"] != "config file" { + t.Errorf("src[gradle.build_file] = %q, want %q", src["gradle.build_file"], "config file") + } +} + +func TestLoadGradleBuildFilesSources(t *testing.T) { + dir := t.TempDir() + content := "gradle:\n build_files:\n - \"a/build.gradle\"\n - \"b/build.gradle\"\n" + 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) + } + if src["gradle.build_files"] != "config file" { + t.Errorf("src[gradle.build_files] = %q, want %q", src["gradle.build_files"], "config file") + } +} + func TestLoadPartialOverride(t *testing.T) { dir := t.TempDir() // Only override tag_prefix — commit_message should keep its default diff --git a/internal/ghclient/ghclient.go b/internal/ghclient/ghclient.go new file mode 100644 index 0000000..9e0713c --- /dev/null +++ b/internal/ghclient/ghclient.go @@ -0,0 +1,73 @@ +package ghclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Client is a minimal GitHub API client covering only the Releases endpoint. +type Client struct { + token string + repo string // "owner/repo" + httpClient *http.Client +} + +// New creates a Client. repo must be in "owner/repo" format. +func New(token, repo string) *Client { + return &Client{ + token: token, + repo: repo, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +type createReleaseRequest struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` +} + +// CreateRelease creates a GitHub release on an existing tag. +// The tag must already be pushed to the remote before calling this. +func (c *Client) CreateRelease(ctx context.Context, tagName, body string) error { + payload, _ := json.Marshal(createReleaseRequest{ + TagName: tagName, + Name: tagName, + Body: body, + }) + + url := fmt.Sprintf("https://api.github.com/repos/%s/releases", c.repo) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("GitHub API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var errBody struct { + Message string `json:"message"` + } + json.NewDecoder(resp.Body).Decode(&errBody) //nolint:errcheck + if errBody.Message != "" { + return fmt.Errorf("GitHub API returned %d: %s", resp.StatusCode, errBody.Message) + } + return fmt.Errorf("GitHub API returned %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/ghclient/ghclient_test.go b/internal/ghclient/ghclient_test.go new file mode 100644 index 0000000..3a8f953 --- /dev/null +++ b/internal/ghclient/ghclient_test.go @@ -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 } diff --git a/internal/gitutil/gitutil.go b/internal/gitutil/gitutil.go index 5ff7e72..bcb2d87 100644 --- a/internal/gitutil/gitutil.go +++ b/internal/gitutil/gitutil.go @@ -3,15 +3,19 @@ package gitutil import ( "errors" "fmt" + "os" + "os/exec" "sort" + "strings" "time" gogit "github.com/go-git/go-git/v5" gitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/storer" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "git.k3nny.fr/releaser/internal/branch" ) @@ -81,23 +85,21 @@ func LatestTag(repo *gogit.Repository, info branch.Info) (string, int, error) { return nil } - commitHash, err := resolveTagToCommit(repo, ref) + tagCommit, err := resolveTagToCommitObj(repo, ref) if err != nil { return nil // silently skip malformed tags } - tagCommit, err := repo.CommitObject(commitHash) - if err != nil { - return nil - } - if tagCommit.Hash == headCommit.Hash { candidates = append(candidates, tagCandidate{name, patch}) return nil } anc, err := tagCommit.IsAncestor(headCommit) - if err != nil || !anc { + if err != nil { + return err + } + if !anc { return nil } @@ -194,15 +196,17 @@ func AuthorFromConfig(repo *gogit.Repository) (name, email string) { return } -// CommitFile stages filePath (relative to worktree root) and creates a commit. -func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) { +// CommitFiles stages all filePaths (relative to worktree root) and creates a commit. +func CommitFiles(repo *gogit.Repository, filePaths []string, message, authorName, authorEmail string) (plumbing.Hash, error) { w, err := repo.Worktree() if err != nil { return plumbing.ZeroHash, err } - if _, err := w.Add(filePath); err != nil { - return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", filePath, err) + for _, p := range filePaths { + if _, err := w.Add(p); err != nil { + return plumbing.ZeroHash, fmt.Errorf("git add %s: %w", p, err) + } } hash, err := w.Commit(message, &gogit.CommitOptions{ @@ -218,6 +222,11 @@ func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEma return hash, nil } +// CommitFile stages a single file and creates a commit. +func CommitFile(repo *gogit.Repository, filePath, message, authorName, authorEmail string) (plumbing.Hash, error) { + return CommitFiles(repo, []string{filePath}, message, authorName, authorEmail) +} + // CreateTag creates a lightweight tag on HEAD. func CreateTag(repo *gogit.Repository, tagName string) error { head, err := repo.Head() @@ -230,10 +239,42 @@ func CreateTag(repo *gogit.Repository, tagName string) error { return nil } +// sshPush is the function used for SSH agent push; replaced in tests to avoid requiring a live agent. +var sshPush = pushWithSSHAgent + +// newSSHAgentAuth creates an SSH agent auth method; replaced in tests. +var newSSHAgentAuth = gitssh.NewSSHAgentAuth + // Push pushes the given branch and tag to the "origin" remote. -// If token is non-empty, HTTPS basic auth (oauth2/token) is used. -// Passing an empty token lets go-git use the system credential helper or SSH agent. +// When token is non-empty, go-git is used with HTTPS basic auth (oauth2/token) — suitable for CI. +// When token is empty and the remote URL is SSH, go-git SSH agent auth is attempted first. +// Falls back to the system git binary so that credential helpers, netrc, and SSH keys work normally. func Push(repo *gogit.Repository, branchName, tagName, token string) error { + if token != "" { + return pushWithGoGit(repo, branchName, tagName, token) + } + // Try SSH agent auth when the remote URL uses SSH transport. + if remote, err := repo.Remote("origin"); err == nil { + urls := remote.Config().URLs + if len(urls) > 0 && isSSHURL(urls[0]) { + if err := sshPush(repo, branchName, tagName); err == nil { + return nil + } + } + } + return pushWithCLI(repo, branchName, tagName) +} + +func isSSHURL(u string) bool { + return strings.HasPrefix(u, "git@") || strings.HasPrefix(u, "ssh://") +} + +func pushWithSSHAgent(repo *gogit.Repository, branchName, tagName string) error { + auth, err := newSSHAgentAuth("git") + if err != nil { + return err + } + remote, err := repo.Remote("origin") if err != nil { return fmt.Errorf("remote origin not found: %w", err) @@ -244,13 +285,30 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error { gitconfig.RefSpec(fmt.Sprintf("refs/heads/%s:refs/heads/%s", branchName, branchName)), gitconfig.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName)), }, + Auth: auth, } - if token != "" { - opts.Auth = &githttp.BasicAuth{ + if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { + return fmt.Errorf("git push via SSH agent: %w", err) + } + return nil +} + +func pushWithGoGit(repo *gogit.Repository, branchName, tagName, token string) error { + remote, err := repo.Remote("origin") + if err != nil { + 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: &githttp.BasicAuth{ Username: "oauth2", Password: token, - } + }, } if err := remote.Push(opts); err != nil && !errors.Is(err, gogit.NoErrAlreadyUpToDate) { @@ -259,22 +317,49 @@ func Push(repo *gogit.Repository, branchName, tagName, token string) error { return nil } -// resolveTagToCommit follows tag objects until it reaches a commit. +func pushWithCLI(repo *gogit.Repository, branchName, tagName string) error { + wt, err := repo.Worktree() + if err != nil { + return fmt.Errorf("get worktree: %w", err) + } + + cmd := exec.Command("git", "-C", wt.Filesystem.Root(), "push", "origin", + fmt.Sprintf("HEAD:refs/heads/%s", branchName), + fmt.Sprintf("refs/tags/%s:refs/tags/%s", tagName, tagName), + ) + cmd.Stdout = os.Stderr // git push status goes to stderr conventionally + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("git push: %w", err) + } + return nil +} + +// resolveTagToCommitObj follows tag objects until it reaches a commit and returns it. // Handles both lightweight tags (ref → commit) and annotated tags (ref → tag object → … → commit). -func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) { +func resolveTagToCommitObj(repo *gogit.Repository, ref *plumbing.Reference) (*object.Commit, error) { hash := ref.Hash() for { obj, err := repo.Object(plumbing.AnyObject, hash) if err != nil { - return plumbing.ZeroHash, err + return nil, err } switch o := obj.(type) { case *object.Commit: - return o.Hash, nil + return o, nil case *object.Tag: hash = o.Target default: - return plumbing.ZeroHash, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) + return nil, fmt.Errorf("unexpected object type %s at %s", obj.Type(), hash) } } } + +// resolveTagToCommit follows tag objects until it reaches a commit and returns its hash. +func resolveTagToCommit(repo *gogit.Repository, ref *plumbing.Reference) (plumbing.Hash, error) { + c, err := resolveTagToCommitObj(repo, ref) + if err != nil { + return plumbing.ZeroHash, err + } + return c.Hash, nil +} diff --git a/internal/gitutil/gitutil_test.go b/internal/gitutil/gitutil_test.go index d8216bd..147d4ab 100644 --- a/internal/gitutil/gitutil_test.go +++ b/internal/gitutil/gitutil_test.go @@ -1,6 +1,7 @@ package gitutil import ( + "fmt" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( gitconfig "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "git.k3nny.fr/releaser/internal/branch" ) @@ -699,3 +701,410 @@ func TestPushWithBareRemote(t *testing.T) { t.Fatalf("second Push returned unexpected error: %v", err) } } + +// ── IsWorkingTreeClean: w.Status() error path ──────────────────────────────── + +func TestIsWorkingTreeCleanCorruptIndex(t *testing.T) { + // Use a filesystem repo so we can corrupt the on-disk index. + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "chore: init", "initial") + + // Overwrite .git/index with garbage so go-git fails to parse it. + indexPath := filepath.Join(dir, ".git", "index") + if err := os.WriteFile(indexPath, []byte("not a valid git index"), 0644); err != nil { + t.Fatal(err) + } + + // Reopen — fresh repository object with no cached index. + repo2, err := gogit.PlainOpen(dir) + if err != nil { + t.Fatal(err) + } + + _, err = IsWorkingTreeClean(repo2) + if err == nil { + t.Error("expected error when git index is corrupt") + } +} + +// ── LatestTag: head commit object missing ──────────────────────────────────── + +func TestLatestTagHeadCommitMissing(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + addTag(t, repo, "v1.2.0") + + // Detach HEAD to a fake hash that has no backing commit object. + fakeHash := plumbing.NewHash("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + if err := repo.Storer.SetReference(plumbing.NewHashReference(plumbing.HEAD, fakeHash)); err != nil { + t.Fatal(err) + } + + _, _, err := LatestTag(repo, branch.Info{Major: 1, Minor: 2, TagPrefix: "v"}) + if err == nil { + t.Error("expected error when HEAD commit object is missing") + } +} + +// ── LatestTag: Tags() iterator fails ──────────────────────────────────────── + +func TestLatestTagTagsIterFails(t *testing.T) { + repo, dir := newTestRepo(t) + addCommit(t, repo, dir, "fix: c1", "v1") + + if os.Getuid() == 0 { + t.Skip("skipping: chmod restrictions do not apply when running as root") + } + + // 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. + tagsDir := filepath.Join(dir, ".git", "refs", "tags") + os.Chmod(tagsDir, 0000) + 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.Fatalf("PlainOpen: %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) + } +} diff --git a/internal/gradle/gradle.go b/internal/gradle/gradle.go new file mode 100644 index 0000000..43a1ab7 --- /dev/null +++ b/internal/gradle/gradle.go @@ -0,0 +1,54 @@ +package gradle + +import ( + "fmt" + "os" + "regexp" +) + +// versionRe matches a Gradle/Kotlin DSL version assignment on its own line. +// Group 1 captures the version string (without quotes). +// Handles both double-quoted (Kotlin/Groovy) and single-quoted (Groovy) forms, +// with or without spaces around =. +var versionRe = regexp.MustCompile(`(?m)^[ \t]*version\s*=\s*["']([^"']+)["']`) + +// ReadVersion returns the version value from a build.gradle or build.gradle.kts file. +func ReadVersion(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read %s: %w", path, err) + } + m := versionRe.FindStringSubmatch(string(data)) + if m == nil { + return "", fmt.Errorf("no version assignment found in %s", path) + } + return m[1], nil +} + +// WriteVersion replaces the version assignment in a build.gradle or build.gradle.kts +// file in-place. oldVersion must match what ReadVersion returned. Quote style 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) + } + updated, ok := replaceVersion(string(data), oldVersion, newVersion) + if !ok { + return fmt.Errorf("version %q not found in %s", oldVersion, path) + } + return os.WriteFile(path, []byte(updated), 0644) +} + +// replaceVersion finds and replaces the first version assignment line in a Gradle build file. +// Quote style (single or double) of the original line is preserved. +// Returns the updated content and true if a replacement was made. +func replaceVersion(content, oldVersion, newVersion string) (string, bool) { + re := regexp.MustCompile(`(?m)^([ \t]*version\s*=\s*)(["'])` + regexp.QuoteMeta(oldVersion) + `["']`) + m := re.FindStringSubmatchIndex(content) + if m == nil { + return content, false + } + prefix := content[m[2]:m[3]] // "version = " etc., preserving whitespace + quote := content[m[4]:m[5]] // " or ' + return content[:m[0]] + prefix + quote + newVersion + quote + content[m[1]:], true +} diff --git a/internal/gradle/gradle_test.go b/internal/gradle/gradle_test.go new file mode 100644 index 0000000..366a5f6 --- /dev/null +++ b/internal/gradle/gradle_test.go @@ -0,0 +1,183 @@ +package gradle + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const gradleGroovy = `plugins { + id 'java' +} + +group = 'com.example' +version = '1.2.3' +description = 'My project' +` + +const gradleKotlin = `plugins { + kotlin("jvm") version "1.9.0" +} + +group = "com.example" +version = "1.2.3" +description = "My project" +` + +func writeGradle(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "build.gradle") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +// ── ReadVersion ────────────────────────────────────────────────────────────── + +func TestReadVersionGroovy(t *testing.T) { + got, err := ReadVersion(writeGradle(t, gradleGroovy)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Errorf("got %q, want 1.2.3", got) + } +} + +func TestReadVersionKotlin(t *testing.T) { + got, err := ReadVersion(writeGradle(t, gradleKotlin)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.2.3" { + t.Errorf("got %q, want 1.2.3", got) + } +} + +func TestReadVersionNoSpaces(t *testing.T) { + got, err := ReadVersion(writeGradle(t, `version="1.0.0"`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "1.0.0" { + t.Errorf("got %q, want 1.0.0", got) + } +} + +func TestReadVersionMissingFile(t *testing.T) { + _, err := ReadVersion(filepath.Join(t.TempDir(), "build.gradle")) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestReadVersionNoVersionField(t *testing.T) { + _, err := ReadVersion(writeGradle(t, `group = "com.example"`)) + if err == nil { + t.Error("expected error when no version assignment is present") + } +} + +// ── WriteVersion ───────────────────────────────────────────────────────────── + +func TestWriteVersionKotlin(t *testing.T) { + path := writeGradle(t, gradleKotlin) + 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; got:\n%s", data) + } + // plugin version declaration must not be touched + if !strings.Contains(string(data), `kotlin("jvm") version "1.9.0"`) { + t.Error("plugin version was incorrectly modified") + } +} + +func TestWriteVersionGroovy(t *testing.T) { + path := writeGradle(t, gradleGroovy) + 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 with single quotes; got:\n%s", data) + } +} + +func TestWriteVersionMissingFile(t *testing.T) { + err := WriteVersion(filepath.Join(t.TempDir(), "build.gradle"), "1.0.0", "1.0.1") + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestWriteVersionNotFound(t *testing.T) { + path := writeGradle(t, gradleKotlin) + err := WriteVersion(path, "9.9.9", "9.9.10") + if err == nil { + t.Error("expected error when old version not found in file") + } +} + +func TestWriteVersionReadOnly(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("skipping: chmod restrictions do not apply when running as root") + } + path := writeGradle(t, gradleKotlin) + os.Chmod(path, 0444) + defer os.Chmod(path, 0644) + err := WriteVersion(path, "1.2.3", "1.2.4") + if err == nil { + t.Error("expected error writing to read-only file") + } +} + +// ── replaceVersion ──────────────────────────────────────────────────────────── + +func TestReplaceVersionNotFound(t *testing.T) { + content := `group = "com.example"` + got, ok := replaceVersion(content, "1.0.0", "1.0.1") + if ok { + t.Error("expected ok=false when version not present") + } + if got != content { + t.Error("content should be unchanged when not found") + } +} + +// ── fuzz ────────────────────────────────────────────────────────────────────── + +// FuzzReadVersion verifies ReadVersion never panics on arbitrary file content. +func FuzzReadVersion(f *testing.F) { + f.Add(gradleGroovy) + f.Add(gradleKotlin) + f.Add(`version="1.0.0"`) + f.Add(`group = "com.example"`) + f.Add("") + f.Add("\x00\xff") + + f.Fuzz(func(t *testing.T, content string) { + path := filepath.Join(t.TempDir(), "build.gradle") + os.WriteFile(path, []byte(content), 0644) //nolint:errcheck + ReadVersion(path) //nolint:errcheck + }) +} + +// FuzzWriteVersion verifies WriteVersion never panics on arbitrary content or version strings. +func FuzzWriteVersion(f *testing.F) { + f.Add(gradleGroovy, "1.2.3", "1.2.4") + f.Add(gradleKotlin, "1.2.3", "1.2.4") + f.Add(`version="1.0.0"`, "1.0.0", "1.0.1") + f.Add("", "1.0.0", "1.0.1") + f.Add(`version = "1.0.0"`, "", "1.0.1") + + f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) { + path := filepath.Join(t.TempDir(), "build.gradle") + os.WriteFile(path, []byte(content), 0644) //nolint:errcheck + WriteVersion(path, oldVersion, newVersion) //nolint:errcheck + }) +} diff --git a/internal/node/node.go b/internal/node/node.go new file mode 100644 index 0000000..bd0c2b9 --- /dev/null +++ b/internal/node/node.go @@ -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) +} diff --git a/internal/node/node_test.go b/internal/node/node_test.go new file mode 100644 index 0000000..a1ad82b --- /dev/null +++ b/internal/node/node_test.go @@ -0,0 +1,114 @@ +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") + } +} + +// FuzzWriteVersion verifies WriteVersion never panics on arbitrary content or version strings. +func FuzzWriteVersion(f *testing.F) { + f.Add(simplePackage, "1.2.3", "1.2.4") + f.Add(`{"version":"0.0.1"}`, "0.0.1", "0.0.2") + f.Add(`{}`, "1.0.0", "1.0.1") + f.Add("", "1.0.0", "1.0.1") + f.Add(`{"name":"app","version":"1.0.0","version":"dup"}`, "1.0.0", "1.0.1") + + f.Fuzz(func(t *testing.T, content, oldVersion, newVersion string) { + path := filepath.Join(t.TempDir(), "package.json") + os.WriteFile(path, []byte(content), 0644) //nolint:errcheck + WriteVersion(path, oldVersion, newVersion) //nolint:errcheck + }) +} + +// 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 + }) +} diff --git a/internal/notes/notes.go b/internal/notes/notes.go index 659fbde..ab2cdae 100644 --- a/internal/notes/notes.go +++ b/internal/notes/notes.go @@ -2,38 +2,16 @@ package notes import ( "fmt" - "regexp" "strings" "git.k3nny.fr/releaser/internal/commits" ) -// headerSubjectRe captures the subject (description) part of a conventional commit header. -var headerSubjectRe = regexp.MustCompile(`(?i)^\w+(?:\([^)]*\))?!?\s*:\s*(.+)`) - // Generate produces grouped markdown release notes from a list of commit messages. // Commits are grouped into Breaking Changes, Features, and Bug Fixes. // Commits with no releasable type are omitted. func Generate(tagName string, messages []string) string { - var breaking, feats, fixes []string - - for _, msg := range messages { - t := commits.Parse(msg) - if t == commits.TypeNone { - continue - } - first := strings.SplitN(strings.TrimSpace(msg), "\n", 2)[0] - subject := extractSubject(first) - - switch t { - case commits.TypeBreaking: - breaking = append(breaking, subject) - case commits.TypeFeat: - feats = append(feats, subject) - case commits.TypeFix: - fixes = append(fixes, subject) - } - } + breaking, feats, fixes := commits.Group(messages) var sb strings.Builder fmt.Fprintf(&sb, "## %s\n", tagName) @@ -53,13 +31,3 @@ func writeSection(sb *strings.Builder, title string, items []string) { fmt.Fprintf(sb, "- %s\n", item) } } - -// extractSubject returns the description part of a conventional commit header. -// Falls back to the raw header if the pattern does not match. -func extractSubject(header string) string { - m := headerSubjectRe.FindStringSubmatch(header) - if m != nil { - return strings.TrimSpace(m[1]) - } - return strings.TrimSpace(header) -} diff --git a/internal/notes/notes_test.go b/internal/notes/notes_test.go index cc66be5..f3e54b0 100644 --- a/internal/notes/notes_test.go +++ b/internal/notes/notes_test.go @@ -69,25 +69,6 @@ func TestGenerateEmpty(t *testing.T) { } } -func TestExtractSubject(t *testing.T) { - cases := []struct { - header string - want string - }{ - {"feat: add login", "add login"}, - {"feat(auth): add OAuth2", "add OAuth2"}, - {"feat!: remove API", "remove API"}, - {"FIX:typo", "typo"}, - {"plain message", "plain message"}, - } - for _, c := range cases { - got := extractSubject(c.header) - if got != c.want { - t.Errorf("extractSubject(%q) = %q, want %q", c.header, got, c.want) - } - } -} - // FuzzGenerate verifies that Generate never panics on arbitrary inputs and // always includes the tag name in the output. func FuzzGenerate(f *testing.F) { diff --git a/internal/version/version.go b/internal/version/version.go index 863c8b1..09baf1b 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -6,14 +6,38 @@ import ( "git.k3nny.fr/releaser/internal/commits" ) +// BumpLevel controls which version component is incremented. +type BumpLevel int + +const ( + BumpPatch BumpLevel = iota + BumpMinor +) + // Next computes the next version string (without tag prefix, e.g. "1.2.4"). // currentPatch is -1 when no tag exists yet (first release will be X.Y.0). +// releasable is the set of commit types that trigger a bump; nil defaults to all three. +// bumpRules maps each type to its bump level; nil defaults to BumpPatch for all. // Returns ("", false) when there are no releasable commits. -func Next(major, minor, currentPatch int, types []commits.Type) (string, bool) { +func Next(major, minor, currentPatch int, types []commits.Type, releasable map[commits.Type]bool, bumpRules map[commits.Type]BumpLevel) (string, bool) { + if releasable == nil { + releasable = commits.ReleasableSet(nil) + } + found := false + useMinor := false for _, t := range types { - if t != commits.TypeNone { - return fmt.Sprintf("%d.%d.%d", major, minor, currentPatch+1), true + if releasable[t] { + found = true + if bumpRules[t] == BumpMinor { + useMinor = true + } } } - 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 } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index e70ec35..afad054 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -30,7 +30,7 @@ func TestNext(t *testing.T) { wantOk: true, }, { - desc: "breaking change still bumps patch on release branch", + desc: "breaking change still bumps patch by default", major: 2, minor: 0, currentPatch: 1, types: []commits.Type{commits.TypeBreaking}, want: "2.0.2", @@ -61,7 +61,7 @@ func TestNext(t *testing.T) { for _, c := range cases { t.Run(c.desc, func(t *testing.T) { - got, ok := Next(c.major, c.minor, c.currentPatch, c.types) + got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, nil) if ok != c.wantOk { t.Errorf("ok=%v, want %v", ok, c.wantOk) } @@ -71,3 +71,73 @@ func TestNext(t *testing.T) { }) } } + +func TestNextMinorBump(t *testing.T) { + rules := map[commits.Type]BumpLevel{ + commits.TypeBreaking: BumpMinor, + } + + cases := []struct { + desc string + major, minor int + currentPatch int + types []commits.Type + want string + }{ + { + desc: "breaking → minor bump", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeBreaking}, + want: "1.3.0", + }, + { + desc: "feat (patch rule) with breaking (minor rule) → minor wins", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeFeat, commits.TypeBreaking}, + want: "1.3.0", + }, + { + desc: "fix only — no minor rule → patch bump", + major: 1, minor: 2, currentPatch: 3, + types: []commits.Type{commits.TypeFix}, + want: "1.2.4", + }, + { + desc: "first release with minor bump", + major: 2, minor: 0, currentPatch: -1, + types: []commits.Type{commits.TypeBreaking}, + want: "2.1.0", + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + got, ok := Next(c.major, c.minor, c.currentPatch, c.types, nil, rules) + if !ok { + t.Fatal("expected ok=true") + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestNextAllMinorRules(t *testing.T) { + rules := map[commits.Type]BumpLevel{ + commits.TypeBreaking: BumpMinor, + commits.TypeFeat: BumpMinor, + commits.TypeFix: BumpMinor, + } + got, ok := Next(1, 4, 2, []commits.Type{commits.TypeFix}, nil, rules) + if !ok || got != "1.5.0" { + t.Errorf("got %q ok=%v, want 1.5.0 true", got, ok) + } +} + +func TestNextNilBumpRulesDefaultsToPatch(t *testing.T) { + got, ok := Next(1, 2, 5, []commits.Type{commits.TypeBreaking}, nil, nil) + if !ok || got != "1.2.6" { + t.Errorf("got %q ok=%v, want 1.2.6 true", got, ok) + } +}