diff --git a/.github/workflows/ensure_changelog.yml b/.github/workflows/ensure_changelog.yml deleted file mode 100644 index c08e8b6..0000000 --- a/.github/workflows/ensure_changelog.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: CHANGELOG.md Check -on: - pull_request: - branches: - - main -jobs: - verify_changelog_job: - runs-on: ubuntu-latest - name: Did CHANGELOG.md change? - steps: - - name: checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: fetch - run: git fetch - - name: run changelog.sh - run: 'bash ${GITHUB_WORKSPACE}/.github/workflows/scripts/changelog.sh' diff --git a/.github/workflows/releasegen.yml b/.github/workflows/releasegen.yml index ea3cbcb..c1e8c57 100644 --- a/.github/workflows/releasegen.yml +++ b/.github/workflows/releasegen.yml @@ -56,8 +56,9 @@ jobs: GITHUB_REF_NAME: ${{ github.event.inputs.branch || github.ref_name }} MANUAL_VERSION: ${{ github.event.inputs.version || '' }} REASON: ${{ github.event.inputs.reason || '' }} - CUSTOM_CHANGE_TYPES: | - Documentation:patch + # Repo-shape options (custom_change_types, exclude_dirs, ...) live + # in .releasegen.yaml at the repo root so this workflow does not + # have to keep them in sync with the validate workflow. run: | go build -trimpath -ldflags "-s -w" -o release-gen ./cmd/releasegen # releasegen prints the self-release version on stdout (and only diff --git a/.github/workflows/scripts/changelog.sh b/.github/workflows/scripts/changelog.sh deleted file mode 100644 index f996218..0000000 --- a/.github/workflows/scripts/changelog.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash - -# Find all directories containing a CHANGELOG.md file -changelogDirs=$(find . -type f -name 'CHANGELOG.md' -exec dirname {} \; | sort -u) -changedDirs="" - -# Function to print a separator -print_separator() { - echo "----------------------------------------" -} - -# Check each directory for changes and ensure CHANGELOG.md is updated -for dir in $changelogDirs; do - if [[ "$dir" == "." ]]; then - continue - fi - print_separator - echo "Checking: $dir" - - # Check if there are any changes in the directory - dirChanges=$(git --no-pager diff -w --numstat origin/main -- $dir | wc -l) - if [[ "$dirChanges" -gt 0 ]]; then - echo " - changes detected" - - # Check if CHANGELOG.md has been modified - changelogMod=$(git --no-pager diff -w --numstat origin/main -- $dir/CHANGELOG.md) - if [[ -z "$changelogMod" ]]; then - echo " - $dir/CHANGELOG.md not modified - Please update it with your changes before merging to main." - exit 1 - else - echo " - $dir/CHANGELOG.md modified" - changelogLines=$(echo "$changelogMod" | awk '{print $1}') - if [[ "$changelogLines" -lt 1 ]]; then - echo " - didn't detect any substantial changes to CHANGELOG.md in $dir." - exit 1 - else - echo " - detected '$changelogLines' new non-whitespace lines in CHANGELOG.md in $dir. Thanks +1" - changedDirs+=" $dir" - fi - fi - else - echo " - no changes detected" - fi -done - -print_separator - -# Build exclusion pattern for directories with their own CHANGELOG.md -excludePatterns=(":!*/CHANGELOG.md") # always exclude sub-CHANGELOG.md -for dir in $changedDirs; do - excludePatterns+=(":!$dir/*") -done - -echo "Checking root: ./" -# Check for changes in the root directory and subdirectories without their own CHANGELOG.md -rootChanges=$(git --no-pager diff -w --numstat origin/main -- . "${excludePatterns[@]}" | wc -l) -if [[ "$rootChanges" -gt 0 ]]; then - echo " - changes detected" - - # Check if root CHANGELOG.md exists - if [[ ! -f "./CHANGELOG.md" ]]; then - echo "::warning:: - changes detected but no ./CHANGELOG.md was found" - else - echo " - ./CHANGELOG.md exists" - rootChangelogMod=$(git --no-pager diff -w --numstat origin/main -- ./CHANGELOG.md) - if [[ -z "$rootChangelogMod" ]]; then - echo " - ./CHANGELOG.md not modified - Please update it with your changes before merging to main." - exit 1 - else - echo " - ./CHANGELOG.md modified" - rootChangelogLines=$(echo "$rootChangelogMod" | awk '{print $1}') - if [[ "$rootChangelogLines" -lt 1 ]]; then - echo " - didn't detect any substantial changes to CHANGELOG.md in root CHANGELOG.md." - exit 1 - else - echo " - detected '$rootChangelogLines' new non-whitespace lines in root CHANGELOG.md. Thanks +1" - fi - fi - fi -else - echo " - no changes detected" -fi - -print_separator -echo "All directories with changes have updated CHANGELOG.md files." -exit 0 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..3756b36 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,30 @@ +name: Validate Changelog + +on: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: '1.26' + + - name: Validate changelogs + # While releasegen is dogfooding itself, build and run from source. + # External consumers can use the published image instead: + # docker run --rm -v "$(pwd):/workspace" \ + # ghcr.io/c2fo/releasegen:latest validate --repo-root /workspace + run: | + go build -trimpath -ldflags "-s -w" -o release-gen ./cmd/releasegen + ./release-gen validate diff --git a/.golangci.yml b/.golangci.yml index 2a7d969..a17134d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -106,11 +106,13 @@ linters: - -ST1022 - all tagliatelle: - # The JSON run-summary (see internal/runner) is a stable, snake_case - # output contract consumed by downstream workflow steps. + # The JSON run-summary (see internal/runner) and the `.releasegen.yaml` + # user config (see internal/config) are stable, snake_case output and + # input contracts consumed by users and downstream workflow steps. case: rules: json: snake + yaml: snake unparam: check-exported: false exclusions: diff --git a/.prenup.yaml b/.prenup.yaml index 28a9d8e..ca19eda 100644 --- a/.prenup.yaml +++ b/.prenup.yaml @@ -16,6 +16,10 @@ tasks: default_selected: true command: env -u GIT_INDEX_FILE -u GIT_INDEX_LOCK golangci-lint run --new-from-rev HEAD --max-same-issues 0 ./... per_module: true - - name: Check for changes in CHANGELOG.md + - name: Validate CHANGELOG.md default_selected: true - command: /bin/bash {{.repo_root}}/.github/workflows/scripts/changelog.sh + # Dogfoods releasegen's own validate subcommand: parses every + # [Unreleased] section AND (per .releasegen.yaml's validate block) + # requires a new entry for any module whose non-CHANGELOG files + # changed vs origin/main. Replaces the legacy changelog.sh script. + command: go run {{.repo_root}}/cmd/releasegen validate --repo-root {{.repo_root}} diff --git a/.releasegen.yaml b/.releasegen.yaml new file mode 100644 index 0000000..0e83f62 --- /dev/null +++ b/.releasegen.yaml @@ -0,0 +1,14 @@ +# ReleaseGen configuration for releasegen itself. +# +# Per-repo, rarely-changing options live here so the validate workflow and the +# release workflow read them from a single source. Per-invocation values +# (GITHUB_TOKEN, MANUAL_VERSION, etc.) stay in the workflow env / CLI flags. + +custom_change_types: + Documentation: patch + +validate: + # Require every PR that touches non-CHANGELOG files to also add at least + # one entry under [Unreleased]. Folds the historical ensure_changelog + # check into releasegen so contributors get one error report per CI run. + require_changelog_entry: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 17add6c..e844775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- New `releasegen validate` subcommand. Parses every `## [Unreleased]` section + in the repository and reports **every** malformed heading it finds — both + across files and within a single file — in a single batched report. Detects + `### Changed` / `### Removed` without the `BREAKING CHANGE` marker as well + as any heading not declared in `custom_change_types`, naming the offending + heading in each error. Needs no GitHub token, performs no I/O beyond reads, + and exits `0` on success / `2` on any validation failure — intended as a + required PR-time check before the release workflow. +- New `.releasegen.yaml` (also `.releasegen.yml`) config file at the repo root. + Supports `custom_change_types`, `exclude_dirs`, a `validate:` block (see + below), and the advanced `self_release_module` / `self_release_repo` + overrides. Precedence is flags > env > file > built-in defaults; unknown + keys cause a configuration error so typos surface early. Both `validate` + and the existing release path read it, so repo-shape options no longer + have to be duplicated across workflows. +- New optional `--require-changelog-entry` mode for `releasegen validate` + (also exposed as `validate.require_changelog_entry: true` in + `.releasegen.yaml` and `RELEASEGEN_REQUIRE_CHANGELOG_ENTRY=true` in the + environment). When enabled, validate fails any PR whose non-`CHANGELOG.md` + files changed vs the base ref but whose `[Unreleased]` section gained no + new lines. Modules are scoped by where `CHANGELOG.md` lives, with the + root changelog catching every file not claimed by a submodule changelog. + The base ref is configurable via `--base-ref` / `RELEASEGEN_BASE_REF` / + `validate.base_ref` and defaults to `origin/$GITHUB_BASE_REF` on GitHub + Actions pull-request runs, else `origin/main`. Subsumes the prior + external `ensure_changelog` workflow, which has been removed from this + repository in favor of the validate-driven check. ## [[v1.1.1](https://github.com/C2FO/releasegen/releases/tag/v1.1.1)] - 2026-06-18 ### Documentation diff --git a/README.md b/README.md index ccb2443..1dab447 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ That's the whole job — no plugins, no runtime, no DSL, and it never inspects y - [Writing Your `CHANGELOG.md`](#writing-your-changelogmd) - [GitHub Actions Integration](#github-actions-integration) - [Configuration](#configuration) + - [Configuration file (`.releasegen.yaml`)](#configuration-file-releasegenyaml) - [Building From Source](#building-from-source) - [FAQ](#faq) - [Contributing](#contributing) @@ -61,7 +62,9 @@ ReleaseGen deliberately does **not** build artifacts, publish to package registr - **Atomic, fail-fast runs.** A failing module aborts the run rather than leaving you half-released. - **Structured exit codes.** Distinct codes for config, changelog, Git, and API failures so CI can branch on the failure class. See [Exit Codes](#exit-codes). - **Machine-readable summaries.** `--summary-file` writes a JSON summary of the run for downstream steps to consume instead of scraping logs. -- **Config via flags or env.** Every environment variable has an equivalent CLI flag; flags take precedence over env, which takes precedence over built-in defaults. +- **PR-time validation.** A `releasegen validate` subcommand parses every `## [Unreleased]` section and reports every malformed heading it finds — across files and within a single file — before the merge. Opt in to `--require-changelog-entry` and validate also enforces that any module whose source files changed gained a new `[Unreleased]` entry vs the PR base. No token, no commits, no side effects. +- **Repo config file.** Drop a `.releasegen.yaml` (or `.releasegen.yml`) at your repo root to declare `custom_change_types`, `exclude_dirs`, and the self-release overrides in one place instead of duplicating them across workflows. +- **Config via flags, env, or file.** Every option resolves with precedence **flags > env > `.releasegen.yaml` > built-in defaults**. - **Custom change types.** Map your own changelog headings (e.g. `Documentation`) to a specific bump level. - **Debug logging.** `--debug` traces tag discovery and module-name extraction for troubleshooting. - **Secure by default.** Bearer tokens are scrubbed from Git push errors before they reach the logs. @@ -92,7 +95,7 @@ releasegen --dry-run \ --token "$GH_TOKEN" ``` -When you're ready to automate it, drop the [example GitHub Actions workflow](#workflow-example) into `.github/workflows/`. +When you're ready to automate it, drop the [example GitHub Actions workflows](#workflow-examples) into `.github/workflows/` — one for PR-time validation, one to cut the release on merge. ## How It Works @@ -203,7 +206,52 @@ If your release branch is protected (required reviews, status checks, etc.), the Repeat for every protected branch the workflow releases from (e.g. `main`, `v6`, etc.). -### Workflow Example +### Workflow Examples + +ReleaseGen is designed for a **two-workflow** setup: one fast, side-effect-free check on every pull request, and one release workflow that runs after merge. The validate workflow catches malformed changelog entries (missed `BREAKING CHANGE` markers, unknown headings, typos) before they're committed to your release branch. + +#### Validate Workflow (PR check) + +This runs on every pull request, needs no GitHub App token, and fails the PR if any changelog is malformed. Add it as a required status check on your release branch. + +```yaml +name: Validate Changelog + +on: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate changelogs + run: | + docker run --rm \ + -v "$(pwd):/workspace" \ + ghcr.io/c2fo/releasegen:latest \ + validate --repo-root /workspace +``` + +> If a `.releasegen.yaml` exists at the repo root, `validate` reads `custom_change_types`, `exclude_dirs`, and the `validate:` block from it automatically — no need to repeat them here. Exits `0` if every `## [Unreleased]` is valid (including empty), `2` if any are malformed. + +#### Enforcing a changelog entry on every PR + +Set `validate.require_changelog_entry: true` in `.releasegen.yaml` (or pass `--require-changelog-entry`) to fail the PR when a module's non-`CHANGELOG.md` files changed vs the base ref but its `[Unreleased]` section gained no new lines. Modules are scoped by where `CHANGELOG.md` lives; the root changelog catches every file not claimed by a submodule changelog. + +```yaml +validate: + require_changelog_entry: true + # base_ref: origin/main # optional; defaults to origin/$GITHUB_BASE_REF on PRs, else origin/main +``` + +The GitHub Actions `pull_request` event sets `GITHUB_BASE_REF` automatically, so the default works out of the box for PRs targeting any branch. `actions/checkout@v6` with `fetch-depth: 0` is required so the base ref resolves. + +#### Release Workflow ```yaml name: Release by Changelog @@ -251,14 +299,6 @@ jobs: GITHUB_REF_NAME: ${{ github.event.inputs.branch || github.ref_name }} MANUAL_VERSION: ${{ github.event.inputs.version || '' }} REASON: ${{ github.event.inputs.reason || '' }} - # Optional: skip directories from changelog-based releases. - EXCLUDE_DIRS: | - some/app - some/other/app - # Optional: map custom headings to bump levels. - CUSTOM_CHANGE_TYPES: | - Documentation:minor - Performance:patch run: | docker run --rm \ -e GITHUB_TOKEN \ @@ -267,8 +307,6 @@ jobs: -e GITHUB_REF_NAME \ -e MANUAL_VERSION \ -e REASON \ - -e EXCLUDE_DIRS \ - -e CUSTOM_CHANGE_TYPES \ -v "$(pwd):/workspace" \ ghcr.io/c2fo/releasegen:latest \ --repo-root /workspace @@ -276,6 +314,8 @@ jobs: > The image entrypoint is `/usr/local/bin/release-gen`, so anything after the image name is passed straight to the CLI. Add `--dry-run` to preview without publishing, or `--summary-file /workspace/release-summary.json` to capture a machine-readable result. > +> Repo-shape options like `custom_change_types` and `exclude_dirs` belong in a single [`.releasegen.yaml`](#configuration-file-releasegenyaml) at the repo root so both the validate workflow and the release workflow pick them up — no need to repeat them in each workflow's `env:` block. You can still override them per-run with env vars or flags. +> > The example uses readable version tags for clarity. For production, pin actions to a commit SHA. ### Manual Releases @@ -289,7 +329,44 @@ The `version` input maps to `MANUAL_VERSION` and `reason` to `REASON`; the reaso ## Configuration -Every option can be set by environment variable or CLI flag. **Flags override environment variables, which override built-in defaults.** +Every option can be set three ways: a `.releasegen.yaml` file at the repo root, environment variables, or CLI flags. **Precedence is flags > env > `.releasegen.yaml` > built-in defaults.** + +The split is deliberate: per-repo, rarely-changing options live in the file (so two workflows don't duplicate them), and per-invocation options stay in env/flags (where GitHub Actions sets them). + +### Configuration file (`.releasegen.yaml`) + +Drop a `.releasegen.yaml` (or `.releasegen.yml` — both are accepted) at your repo root: + +```yaml +custom_change_types: + Documentation: minor + Performance: patch +exclude_dirs: + - some/app + - some/other/app + +# Optional: validate subcommand knobs. Omit the block to leave them off. +validate: + require_changelog_entry: true + # base_ref: origin/main + +# Advanced — only needed if you fork ReleaseGen or rename the binary's module. +# self_release_module: "" +# self_release_repo: c2fo/releasegen +``` + +| Key | Type | Description | +| --- | ---- | ----------- | +| `custom_change_types` | map of heading → bump | Extra changelog headings recognized in addition to the Keep a Changelog defaults. Bump must be `major`, `minor`, or `patch`. | +| `exclude_dirs` | list of paths | Directory prefixes to skip during changelog discovery. Trailing `/` is optional. | +| `validate.require_changelog_entry` | bool | When true, `releasegen validate` fails any PR whose non-`CHANGELOG.md` files changed but whose `[Unreleased]` section gained no new lines vs `base_ref`. | +| `validate.base_ref` | string | Git revision the `require_changelog_entry` check diffs against. Defaults to `origin/$GITHUB_BASE_REF` on GitHub Actions PR runs, else `origin/main`. | +| `self_release_module` | string | (Advanced) Module path that triggers self-release stdout output. Empty means the root module. | +| `self_release_repo` | string | (Advanced) `owner/repo` that activates self-release output. Set to `""` to disable. | + +Unknown keys cause a configuration error (exit code `1`) so typos surface early instead of being silently ignored. + +### Flag / environment reference | Environment Variable | CLI Flag | Required | Description | | -------------------- | -------- | :------: | ----------- | @@ -304,6 +381,8 @@ Every option can be set by environment variable or CLI flag. **Flags override en | `REPO_ROOT` | `--repo-root` | | Path to the Git working tree (default `.`). | | `SUMMARY_FILE` | `--summary-file` | | Write a JSON summary of the run to this path. | | `DEBUG` | `--debug` | | Verbose tag/discovery diagnostics. | +| `RELEASEGEN_REQUIRE_CHANGELOG_ENTRY` | `--require-changelog-entry` | | (validate only) Require an `[Unreleased]` gain for modules whose other files changed vs `--base-ref`. | +| `RELEASEGEN_BASE_REF` | `--base-ref` | | (validate only) Git revision the changelog-entry check diffs against. Defaults to `origin/$GITHUB_BASE_REF` on PR runs, else `origin/main`. | | — | `--dry-run` | | Compute and print actions without writing anything. | | — | `--version` | | Print the build version and exit. | @@ -357,7 +436,7 @@ Yes — set `EXCLUDE_DIRS` (or `--exclude-dirs`) to the directories you want to Prefixing tags (e.g. `services/api/v1.2.3`) keeps releases organized and prevents collisions across modules. **Can I trigger a release from a specific branch?** -Yes — use the `workflow_dispatch` trigger shown in the [workflow example](#workflow-example) and pick the branch. +Yes — use the `workflow_dispatch` trigger shown in the [release workflow example](#release-workflow) and pick the branch. **Can I advance to a specific version?** Yes — set `MANUAL_VERSION` (or `--manual-version`, or the `version` workflow input). The value must be valid semver or the run exits with code `1`. diff --git a/cmd/releasegen/main.go b/cmd/releasegen/main.go index b4db2a8..e4725c4 100644 --- a/cmd/releasegen/main.go +++ b/cmd/releasegen/main.go @@ -77,6 +77,19 @@ func newRootCmd() *cobra.Command { return cliError{code: exitConfigErr, err: err} } + // Honor the --repo-root flag (if any) before searching for the + // config file so the lookup happens in the user-specified tree. + if repoRoot != "" { + cfg.RepoRoot = repoRoot + } + fc, _, err := config.LoadFile(cfg.RepoRoot) + if err != nil { + return cliError{code: exitConfigErr, err: err} + } + if err := config.ApplyFile(cfg, fc); err != nil { + return cliError{code: exitConfigErr, err: err} + } + if err := applyFlagOverrides(cfg, flagOverrides{ repoRoot: repoRoot, dryRun: dryRun, @@ -94,7 +107,7 @@ func newRootCmd() *cobra.Command { return cliError{code: exitConfigErr, err: err} } - if err := cfg.Validate(); err != nil { + if err := cfg.ValidateForRelease(); err != nil { return cliError{code: exitConfigErr, err: err} } @@ -148,6 +161,7 @@ func newRootCmd() *cobra.Command { cmd.Flags().StringVar(&branch, "branch", "", "release branch (overrides GITHUB_REF_NAME)") cmd.Flags().StringVar(&token, "token", "", "GitHub token (overrides GITHUB_TOKEN)") + cmd.AddCommand(newValidateCmd()) return cmd } diff --git a/cmd/releasegen/main_test.go b/cmd/releasegen/main_test.go index 9560bfb..4715820 100644 --- a/cmd/releasegen/main_test.go +++ b/cmd/releasegen/main_test.go @@ -115,3 +115,53 @@ func (s *CLITestSuite) TestNewRootCmd_VersionShortCircuits() { cmd.SetArgs([]string{"--version"}) s.Require().NoError(cmd.Execute()) } + +// TestNewRootCmd_FailsWithoutGitHubContext drives the actual root RunE +// (not the validate subcommand) past FromEnv -> LoadFile -> ApplyFile -> +// applyFlagOverrides and into ValidateForRelease, which must return a +// config error because no GitHub credentials are present. Exercising this +// path keeps main.go coverage honest without needing a full GitHub fixture. +func (s *CLITestSuite) TestNewRootCmd_FailsWithoutGitHubContext() { + for _, k := range []string{ + "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_ACTOR", "GITHUB_REF_NAME", + "GITHUB_BASE_REF", + "MANUAL_VERSION", "REASON", + "EXCLUDE_DIRS", "CUSTOM_CHANGE_TYPES", + "DEBUG", "REPO_ROOT", "SUMMARY_FILE", + "RELEASEGEN_SELF_MODULE", "RELEASEGEN_SELF_REPO", + "RELEASEGEN_REQUIRE_CHANGELOG_ENTRY", "RELEASEGEN_BASE_REF", + } { + s.T().Setenv(k, "") + } + // Use a tmpdir so LoadFile finds no .releasegen.yaml in this run. + cmd := newRootCmd() + cmd.SetArgs([]string{"--repo-root", s.T().TempDir()}) + err := cmd.Execute() + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitConfigErr, cliErr.code) + // Confirm the specific GitHub-context error surfaced (rather than, + // say, a yaml parse failure). + s.Contains(err.Error(), "GITHUB_TOKEN") +} + +func (s *CLITestSuite) TestNewRootCmd_BadCustomTypesFlagFailsFast() { + for _, k := range []string{ + "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_ACTOR", "GITHUB_REF_NAME", + "CUSTOM_CHANGE_TYPES", + } { + s.T().Setenv(k, "") + } + cmd := newRootCmd() + cmd.SetArgs([]string{ + "--repo-root", s.T().TempDir(), + "--custom-change-types", "not-a-pair", + }) + err := cmd.Execute() + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitConfigErr, cliErr.code) + s.Contains(err.Error(), "custom-change-types") +} diff --git a/cmd/releasegen/validate.go b/cmd/releasegen/validate.go new file mode 100644 index 0000000..24f3db9 --- /dev/null +++ b/cmd/releasegen/validate.go @@ -0,0 +1,448 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/c2fo/releasegen/internal/changelog" + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/discovery" + "github.com/c2fo/releasegen/internal/logging" + "github.com/c2fo/releasegen/internal/vcs" +) + +// newValidateCmd returns the `releasegen validate` subcommand. It is the +// no-side-effects PR-time check: discover every CHANGELOG.md tracked by the +// repo, parse each [Unreleased] section, and classify it. Any malformed +// section (unknown heading, breaking heading without the BREAKING CHANGE +// marker, no recognized change types) is reported with its file path, and +// the process exits with the changelog error code (2). +// +// Crucially, no module needs to *have* changes for validation to succeed — +// an empty [Unreleased] section is fine. The whole-repo "you didn't add any +// changelog entry" check is left to surrounding CI (e.g. an ensure_changelog +// step) so this command can run safely on any PR, including those that +// legitimately don't touch a changelog. +func newValidateCmd() *cobra.Command { + var ( + repoRoot string + excludeDirs string + customTypes string + debug bool + requireChangelogEntry bool + baseRef string + ) + + cmd := &cobra.Command{ + Use: "validate", + Short: "Validate every CHANGELOG.md without writing anything", + Long: "Validate scans the configured repository for CHANGELOG.md files, " + + "parses each [Unreleased] section, and reports any malformed " + + "headings (e.g. ### Changed without the BREAKING CHANGE marker, " + + "or an unknown heading not declared in CUSTOM_CHANGE_TYPES). " + + "It does not commit, push, tag, or contact GitHub.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := buildValidateConfig(cmd, validateFlagValues{ + repoRoot: repoRoot, + excludeDirs: excludeDirs, + customTypes: customTypes, + debug: debug, + requireChangelogEntry: requireChangelogEntry, + baseRef: baseRef, + }) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + level := slog.LevelInfo + if cfg.Debug { + level = slog.LevelDebug + } + ci := logging.DetectCI() + log := logging.New(logging.Options{Writer: os.Stderr, Level: level, CI: ci}) + + // We only need the changelog-listing capability of the repo, but + // vcs.Open requires a branch to scope tag walks. Use HEAD as a + // stable fallback so validate works in arbitrary checkouts + // (e.g. detached-HEAD PR checks where GITHUB_REF_NAME is the PR + // merge ref rather than a real branch). + branch := cfg.Branch + if branch == "" { + branch = "HEAD" + } + repo, err := vcs.Open(cfg.RepoRoot, branch, log) + if err != nil { + return cliError{code: exitVCSErr, err: err} + } + + paths, err := repo.AllChangelogPaths(ctx) + if err != nil { + return cliError{code: exitVCSErr, err: err} + } + paths = discovery.RemoveExcluded(paths, cfg.ExcludeDirs) + + return validateAll(ctx, cfg, paths, repo, log) + }, + } + + cmd.Flags().StringVar(&repoRoot, "repo-root", "", "path to the git working tree (overrides REPO_ROOT, defaults to \".\")") + cmd.Flags().StringVar(&excludeDirs, "exclude-dirs", "", + "comma- or newline-separated directory prefixes to exclude (overrides EXCLUDE_DIRS and any .releasegen.yaml value)") + cmd.Flags().StringVar(&customTypes, "custom-change-types", "", + "newline-separated : pairs (overrides CUSTOM_CHANGE_TYPES and any .releasegen.yaml value)") + cmd.Flags().BoolVar(&debug, "debug", false, "verbose logging (overrides DEBUG env var)") + cmd.Flags().BoolVar(&requireChangelogEntry, "require-changelog-entry", false, + "fail when a module's non-CHANGELOG files changed but its [Unreleased] section gained no new lines vs --base-ref "+ + "(overrides RELEASEGEN_REQUIRE_CHANGELOG_ENTRY and .releasegen.yaml validate.require_changelog_entry)") + cmd.Flags().StringVar(&baseRef, "base-ref", "", + "git revision to diff against for --require-changelog-entry "+ + "(defaults to origin/$GITHUB_BASE_REF on PR runs, else origin/main; "+ + "overrides RELEASEGEN_BASE_REF and .releasegen.yaml validate.base_ref)") + return cmd +} + +// validateFlagValues is the closure-captured flag state passed into +// buildValidateConfig. Kept as a tiny struct so the helper signature does +// not balloon with positional bool/string parameters. +type validateFlagValues struct { + repoRoot string + excludeDirs string + customTypes string + debug bool + requireChangelogEntry bool + baseRef string +} + +// buildValidateConfig assembles the fully-resolved Config for the validate +// subcommand: env -> file -> flag, with the cliError wrapping callers +// expect. Extracting it keeps RunE thin and well under the cyclo-budget. +func buildValidateConfig(cmd *cobra.Command, fv validateFlagValues) (*config.Config, error) { + cfg, err := config.FromEnv() + if err != nil { + return nil, cliError{code: exitConfigErr, err: err} + } + if fv.repoRoot != "" { + cfg.RepoRoot = fv.repoRoot + } + fc, _, err := config.LoadFile(cfg.RepoRoot) + if err != nil { + return nil, cliError{code: exitConfigErr, err: err} + } + if err := config.ApplyFile(cfg, fc); err != nil { + return nil, cliError{code: exitConfigErr, err: err} + } + if fv.excludeDirs != "" { + cfg.ExcludeDirs = config.ParseExcludeDirs(fv.excludeDirs) + } + if fv.customTypes != "" { + parsed, err := config.ParseCustomTypes(fv.customTypes) + if err != nil { + return nil, cliError{code: exitConfigErr, err: fmt.Errorf("--custom-change-types: %w", err)} + } + cfg.CustomTypes = parsed + } + if fv.debug { + cfg.Debug = true + } + // Flags win unconditionally over file/env, but only when the user + // actually passed them; cobra exposes that via Changed. + if cmd.Flags().Changed("require-changelog-entry") { + cfg.RequireChangelogEntry = fv.requireChangelogEntry + } + if cmd.Flags().Changed("base-ref") { + cfg.BaseRef = fv.baseRef + } + if err := cfg.Validate(); err != nil { + return nil, cliError{code: exitConfigErr, err: err} + } + return cfg, nil +} + +// validateAll orchestrates both validation phases: the content check (every +// [Unreleased] section is well-formed) and, when enabled, the diff-aware +// "you can't merge code without a changelog entry" check. Problems from both +// phases are batched into a single error so the operator sees everything in +// one CI run. +func validateAll( + ctx context.Context, + cfg *config.Config, + paths []string, + repo *vcs.GitRepo, + log *slog.Logger, +) error { + contentProblems := collectContentProblems(cfg, paths, log) + + var entryProblems []changelogProblem + if cfg.RequireChangelogEntry { + base := cfg.BaseRef + if base == "" { + base = config.DefaultBaseRef() + } + log.Debug("require_changelog_entry enabled", "base_ref", base) + var err error + entryProblems, err = collectEntryProblems(ctx, cfg, paths, repo, base, log) + if err != nil { + return cliError{code: exitVCSErr, err: err} + } + } + + problems := append(contentProblems, entryProblems...) //nolint:gocritic // intentional new slice + return reportProblems(cfg, problems, paths, log) +} + +// collectContentProblems is the pure (no-I/O-except-reads) per-file +// validation that catches malformed [Unreleased] sections. Kept exported to +// tests via validatePaths below. +func collectContentProblems(cfg *config.Config, paths []string, log *slog.Logger) []changelogProblem { + if len(paths) == 0 { + log.Warn("no changelog files found", "repo_root", cfg.RepoRoot) + return nil + } + + var problems []changelogProblem + for _, p := range paths { + abs, err := filepath.Abs(filepath.Join(cfg.RepoRoot, p)) + if err != nil { + problems = append(problems, changelogProblem{ + path: p, + err: fmt.Errorf("resolve path: %w", err), + }) + continue + } + content, err := os.ReadFile(abs) //nolint:gosec // path comes from repo discovery + if err != nil { + problems = append(problems, changelogProblem{ + path: p, + err: fmt.Errorf("read: %w", err), + }) + continue + } + section := changelog.ExtractUnreleased(string(content)) + if section == "" { + // Empty [Unreleased] is fine here; the diff-aware + // require_changelog_entry check enforces presence separately. + log.Debug("no unreleased changes", "path", p) + continue + } + for _, err := range changelog.ValidateSection(section, cfg.CustomTypes) { + problems = append(problems, changelogProblem{path: p, err: err}) + } + } + return problems +} + +// validatePaths is kept as a thin shim for existing tests that exercise the +// pure content-validation path. It returns the same batched cliError shape +// as the full command. Production code should use validateAll instead. +func validatePaths(cfg *config.Config, paths []string, log *slog.Logger) error { + return reportProblems(cfg, collectContentProblems(cfg, paths, log), paths, log) +} + +// collectEntryProblems implements the "you can't merge code without a +// changelog entry" guard. It groups every changed file into the deepest +// module that owns it (modules are defined by where CHANGELOG.md lives, +// with the root changelog catching everything not claimed by a sub-module +// changelog) and, for each module whose non-CHANGELOG files changed, +// requires that module's [Unreleased] section to have gained content vs the +// base ref. Problems are returned, not raised, so the caller can batch +// them with content-validation problems in one report. +func collectEntryProblems( + ctx context.Context, + cfg *config.Config, + paths []string, + repo *vcs.GitRepo, + base string, + log *slog.Logger, +) ([]changelogProblem, error) { + changed, err := repo.ChangedFiles(ctx, base) + if err != nil { + return nil, err + } + log.Debug("changed files vs base", "base_ref", base, "count", len(changed)) + if len(changed) == 0 { + return nil, nil + } + + // modulePrefixes maps "submodule/" -> "submodule/CHANGELOG.md" (and "" -> "CHANGELOG.md" + // when a root changelog exists). The empty prefix is the catch-all and + // must always be matched LAST so deeper modules win. + modulePrefixes := make(map[string]string, len(paths)) + hasRoot := false + for _, cl := range paths { + dir := filepath.ToSlash(filepath.Dir(cl)) + if dir == "." || dir == "" { + modulePrefixes[""] = cl + hasRoot = true + continue + } + modulePrefixes[dir+"/"] = cl + } + + // For each module, track whether a non-CHANGELOG file changed. + type moduleState struct { + changelog string + nonChangelogHit bool + } + states := make(map[string]*moduleState, len(modulePrefixes)) + for prefix, cl := range modulePrefixes { + states[prefix] = &moduleState{changelog: cl} + } + + for _, f := range changed { + f = filepath.ToSlash(f) + owner := assignModule(f, modulePrefixes, hasRoot) + st, ok := states[owner] + if !ok { + // File outside any known module (only possible when no root + // changelog exists). Skip — there is nothing for us to require. + continue + } + if f != st.changelog { + st.nonChangelogHit = true + } + } + + var problems []changelogProblem + for prefix, st := range states { + if !st.nonChangelogHit { + continue + } + gained, err := unreleasedGained(ctx, repo, base, st.changelog, cfg.RepoRoot) + if err != nil { + problems = append(problems, changelogProblem{ + path: st.changelog, + err: fmt.Errorf("compare [Unreleased] vs %s: %w", base, err), + }) + continue + } + if gained { + continue + } + problems = append(problems, changelogProblem{ + path: st.changelog, + err: fmt.Errorf( + "module %q has non-CHANGELOG changes vs %s but its [Unreleased] section gained no new lines", + prefixOrRoot(prefix), base, + ), + }) + } + return problems, nil +} + +// assignModule returns the prefix of the deepest module that owns f. When +// no sub-module matches and a root changelog exists, it returns "" (the +// root). When no root changelog exists, files outside any sub-module are +// reported as belonging to "" so the caller can skip them. +func assignModule(f string, prefixes map[string]string, hasRoot bool) string { + best := "" + bestLen := -1 + for prefix := range prefixes { + if prefix == "" { + continue + } + if strings.HasPrefix(f, prefix) && len(prefix) > bestLen { + best = prefix + bestLen = len(prefix) + } + } + if bestLen >= 0 { + return best + } + if hasRoot { + return "" + } + return "" +} + +// unreleasedGained reports whether the [Unreleased] section of changelogPath +// has more non-whitespace lines at HEAD than it did at base. A net-zero or +// shrinking change counts as "no new content," which catches both the +// "didn't touch the changelog" case and the (rarer) "edited a versioned +// section but not [Unreleased]" case. +func unreleasedGained( + ctx context.Context, + repo *vcs.GitRepo, + base, changelogPath, repoRoot string, +) (bool, error) { + baseBody, err := repo.FileAtRef(ctx, base, changelogPath) + if err != nil { + return false, err + } + headPath := filepath.Join(repoRoot, changelogPath) + headBytes, err := os.ReadFile(headPath) //nolint:gosec // path comes from repo discovery + if err != nil { + return false, fmt.Errorf("read %s: %w", changelogPath, err) + } + headLines := countNonWhitespaceLines(changelog.ExtractUnreleased(string(headBytes))) + baseLines := countNonWhitespaceLines(changelog.ExtractUnreleased(baseBody)) + return headLines > baseLines, nil +} + +func countNonWhitespaceLines(s string) int { + if s == "" { + return 0 + } + n := 0 + for _, line := range strings.Split(s, "\n") { + if strings.TrimSpace(line) != "" { + n++ + } + } + return n +} + +func prefixOrRoot(prefix string) string { + if prefix == "" { + return "." + } + return strings.TrimSuffix(prefix, "/") +} + +// reportProblems folds the (possibly empty) slice of problems into the +// success-or-cliError shape both validate paths share. +func reportProblems( + cfg *config.Config, + problems []changelogProblem, + paths []string, + log *slog.Logger, +) error { + if len(problems) == 0 { + log.Info("changelog validation passed", + "repo_root", cfg.RepoRoot, + "changelogs_found", len(paths), + ) + return nil + } + parts := make([]string, 0, len(problems)) + for _, pr := range problems { + log.Error("changelog validation failed", "path", pr.path, "err", pr.err.Error()) + parts = append(parts, fmt.Sprintf(" %s: %s", pr.path, pr.err.Error())) + } + return cliError{ + code: exitChangelogErr, + err: fmt.Errorf("%d changelog problem(s):\n%s", + len(problems), + strings.Join(parts, "\n"), + ), + } +} + +type changelogProblem struct { + path string + err error +} diff --git a/cmd/releasegen/validate_test.go b/cmd/releasegen/validate_test.go new file mode 100644 index 0000000..ca14a03 --- /dev/null +++ b/cmd/releasegen/validate_test.go @@ -0,0 +1,465 @@ +package main + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/spf13/cobra" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/vcs" +) + +type ValidateTestSuite struct { + suite.Suite + tmpDir string + log *slog.Logger +} + +func TestValidateTestSuite(t *testing.T) { + suite.Run(t, new(ValidateTestSuite)) +} + +func (s *ValidateTestSuite) SetupTest() { + s.tmpDir = s.T().TempDir() + // Discard logs so output stays clean while keeping handler shape realistic. + s.log = slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func (s *ValidateTestSuite) writeChangelog(relPath, body string) { + full := filepath.Join(s.tmpDir, relPath) + s.Require().NoError(os.MkdirAll(filepath.Dir(full), 0o750)) + s.Require().NoError(os.WriteFile(full, []byte(body), 0o600)) +} + +func (s *ValidateTestSuite) TestNoChangelogs() { + cfg := &config.Config{RepoRoot: s.tmpDir} + s.Require().NoError(validatePaths(cfg, nil, s.log)) +} + +func (s *ValidateTestSuite) TestAllEmptyUnreleased() { + s.writeChangelog("CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [v1.0.0] - 2024-01-01 +### Added +- thing +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + s.Require().NoError(validatePaths(cfg, []string{"CHANGELOG.md"}, s.log)) +} + +func (s *ValidateTestSuite) TestValid_StandardHeadings() { + s.writeChangelog("CHANGELOG.md", `## [Unreleased] +### Added +- new thing +### Fixed +- fixed thing +## [v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + s.Require().NoError(validatePaths(cfg, []string{"CHANGELOG.md"}, s.log)) +} + +func (s *ValidateTestSuite) TestInvalid_ChangedWithoutBreakingMarker() { + s.writeChangelog("CHANGELOG.md", `## [Unreleased] +### Changed +- changed something without saying it was breaking +## [v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + err := validatePaths(cfg, []string{"CHANGELOG.md"}, s.log) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitChangelogErr, cliErr.code) + s.Contains(err.Error(), "CHANGELOG.md") +} + +func (s *ValidateTestSuite) TestInvalid_UnknownHeading() { + s.writeChangelog("CHANGELOG.md", `## [Unreleased] +### Whimsy +- not a real heading +## [v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + err := validatePaths(cfg, []string{"CHANGELOG.md"}, s.log) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitChangelogErr, cliErr.code) +} + +func (s *ValidateTestSuite) TestCustomHeadingAccepted() { + s.writeChangelog("CHANGELOG.md", `## [Unreleased] +### Documentation +- improved the docs +## [v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{ + RepoRoot: s.tmpDir, + CustomTypes: map[string]config.BumpType{"documentation": config.BumpPatch}, + } + s.Require().NoError(validatePaths(cfg, []string{"CHANGELOG.md"}, s.log)) +} + +func (s *ValidateTestSuite) TestBatchesMultipleErrorsWithinOneFile() { + // One changelog with TWO distinct problems: ### Changed without the + // BREAKING CHANGE marker AND an unknown ### Whimsy heading. Both must + // surface in a single validate run. + s.writeChangelog("CHANGELOG.md", `## [Unreleased] +### Changed +- silently breaking, no marker +### Whimsy +- bogus heading +## [v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + err := validatePaths(cfg, []string{"CHANGELOG.md"}, s.log) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitChangelogErr, cliErr.code) + s.Contains(err.Error(), "BREAKING CHANGE") + s.Contains(err.Error(), "Whimsy") + s.Contains(err.Error(), "2 changelog problem(s)") +} + +func (s *ValidateTestSuite) TestBatchesMultipleErrors() { + // Two changelogs, both broken in different ways. validate must surface + // BOTH errors, not just the first. + s.writeChangelog("services/api/CHANGELOG.md", `## [Unreleased] +### Changed +- silently breaking, no marker +## [services/api/v0.1.0] - 2024-01-01 +`) + s.writeChangelog("worker/CHANGELOG.md", `## [Unreleased] +### Whimsy +- bogus heading +## [worker/v0.1.0] - 2024-01-01 +`) + cfg := &config.Config{RepoRoot: s.tmpDir} + err := validatePaths(cfg, []string{ + "services/api/CHANGELOG.md", + "worker/CHANGELOG.md", + }, s.log) + s.Require().Error(err) + s.Contains(err.Error(), "services/api/CHANGELOG.md") + s.Contains(err.Error(), "worker/CHANGELOG.md") +} + +// neutralEnv unsets every env var FromEnv looks at so a test runs with a +// known-empty baseline regardless of the developer's shell. The +// t.Setenv-based form auto-restores at test teardown. +func (s *ValidateTestSuite) neutralEnv() { + for _, k := range []string{ + "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_ACTOR", "GITHUB_REF_NAME", + "GITHUB_BASE_REF", + "MANUAL_VERSION", "REASON", + "EXCLUDE_DIRS", "CUSTOM_CHANGE_TYPES", + "DEBUG", "REPO_ROOT", "SUMMARY_FILE", + "RELEASEGEN_SELF_MODULE", "RELEASEGEN_SELF_REPO", + "RELEASEGEN_REQUIRE_CHANGELOG_ENTRY", "RELEASEGEN_BASE_REF", + } { + s.T().Setenv(k, "") + } +} + +func (s *ValidateTestSuite) TestBuildValidateConfig_AppliesFlagsOverEnv() { + s.neutralEnv() + s.T().Setenv("RELEASEGEN_REQUIRE_CHANGELOG_ENTRY", "true") + s.T().Setenv("RELEASEGEN_BASE_REF", "env-base-ref") + + cmd := newValidateCmd() + // Simulate flags being passed: we need cmd.Flags().Changed("...") to + // return true, which only happens via Parse on real args. + s.Require().NoError(cmd.ParseFlags([]string{ + "--repo-root", s.tmpDir, + "--require-changelog-entry=false", + "--base-ref", "flag-base-ref", + "--exclude-dirs", "vendor/", + "--custom-change-types", "Documentation:patch", + "--debug", + })) + + cfg, err := buildValidateConfig(cmd, validateFlagValues{ + repoRoot: s.tmpDir, + excludeDirs: "vendor/", + customTypes: "Documentation:patch", + debug: true, + requireChangelogEntry: false, + baseRef: "flag-base-ref", + }) + s.Require().NoError(err) + s.Equal(s.tmpDir, cfg.RepoRoot) + s.False(cfg.RequireChangelogEntry, "explicit --require-changelog-entry=false must beat env=true") + s.Equal("flag-base-ref", cfg.BaseRef, "--base-ref must beat RELEASEGEN_BASE_REF") + s.Equal([]string{"vendor/"}, cfg.ExcludeDirs) + s.True(cfg.Debug) + s.Contains(cfg.CustomTypes, "documentation") +} + +func (s *ValidateTestSuite) TestBuildValidateConfig_BadCustomTypesIsConfigErr() { + s.neutralEnv() + cmd := newValidateCmd() + _, err := buildValidateConfig(cmd, validateFlagValues{ + repoRoot: s.tmpDir, + customTypes: "not-a-pair", + }) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitConfigErr, cliErr.code) + s.Contains(err.Error(), "custom-change-types") +} + +func (s *ValidateTestSuite) TestBuildValidateConfig_BadConfigFileIsConfigErr() { + s.neutralEnv() + // Drop a malformed YAML so LoadFile / yaml.Decoder returns an error, + // exercising the file-load error branch of buildValidateConfig. + bad := filepath.Join(s.tmpDir, ".releasegen.yaml") + s.Require().NoError(os.WriteFile(bad, []byte("custom_change_types: [oops\n"), 0o600)) + cmd := newValidateCmd() + _, err := buildValidateConfig(cmd, validateFlagValues{repoRoot: s.tmpDir}) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitConfigErr, cliErr.code) +} + +func (s *ValidateTestSuite) TestValidateCmd_EndToEndAgainstRealRepo() { + // Drive the actual cobra subcommand against a real git repo so the + // RunE body (signal context, logging, vcs.Open, AllChangelogPaths, + // validateAll) is exercised end to end. + s.neutralEnv() + f := s.newRequireEntryFixture() + // Add a well-formed [Unreleased] entry so even with the entry check + // off, validation is clean. + f.write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n### Added\n- thing\n") + f.commit("with entry") + + cmd := newValidateCmd() + cmd.SetArgs([]string{"--repo-root", f.dir}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + s.Require().NoError(cmd.Execute()) +} + +func (s *ValidateTestSuite) TestValidateCmd_EndToEnd_ReportsFailure() { + s.neutralEnv() + f := s.newRequireEntryFixture() + // HEAD has a malformed [Unreleased] section so RunE must surface a + // cliError with exit code 2. + f.write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n### Whimsy\n- bogus\n") + f.commit("bad heading") + + cmd := newValidateCmd() + cmd.SetArgs([]string{"--repo-root", f.dir}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + err := cmd.Execute() + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitChangelogErr, cliErr.code) +} + +func (s *ValidateTestSuite) TestNewValidateCmdRegistered() { + root := newRootCmd() + var found *cobra.Command + for _, sub := range root.Commands() { + if sub.Name() != "validate" { + continue + } + found = sub + break + } + s.Require().NotNil(found, "validate subcommand must be registered on the root command") + s.NotNil(found.Flags().Lookup("repo-root")) + s.NotNil(found.Flags().Lookup("custom-change-types")) + s.NotNil(found.Flags().Lookup("exclude-dirs")) + s.NotNil(found.Flags().Lookup("debug")) + s.NotNil(found.Flags().Lookup("require-changelog-entry")) + s.NotNil(found.Flags().Lookup("base-ref")) +} + +// requireEntryFixture sets up a real git repo with one root module, one +// sub-module ("svc/"), and a base commit that establishes the "before" +// snapshot. The returned closure mutates HEAD per scenario and returns the +// opened vcs.GitRepo plus its discovered changelog paths. +type requireEntryFixture struct { + dir string + wt *git.Worktree + sig *object.Signature + repoOpen func() *vcs.GitRepo + suite *ValidateTestSuite +} + +func (s *ValidateTestSuite) newRequireEntryFixture() *requireEntryFixture { + dir := s.T().TempDir() + r, err := git.PlainInit(dir, false) + s.Require().NoError(err) + wt, err := r.Worktree() + s.Require().NoError(err) + sig := &object.Signature{Name: "tester", Email: "t@example.com", When: time.Now()} + + write := func(rel, body string) { + s.Require().NoError(os.MkdirAll(filepath.Join(dir, filepath.Dir(rel)), 0o750)) + s.Require().NoError(os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o600)) + _, err := wt.Add(rel) + s.Require().NoError(err) + } + // Base state: root + svc module, each with empty [Unreleased]. + write("main.go", "package x\n") + write("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n") + write("svc/foo.go", "package svc\n") + write("svc/CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n") + baseHash, err := wt.Commit("base", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + _, err = r.CreateTag("base-tag", baseHash, &git.CreateTagOptions{Tagger: sig, Message: "base"}) + s.Require().NoError(err) + + return &requireEntryFixture{ + dir: dir, wt: wt, sig: sig, suite: s, + repoOpen: func() *vcs.GitRepo { + g, err := vcs.Open(dir, "main", slog.New(slog.DiscardHandler)) + s.Require().NoError(err) + return g + }, + } +} + +func (f *requireEntryFixture) write(rel, body string) { + full := filepath.Join(f.dir, rel) + f.suite.Require().NoError(os.MkdirAll(filepath.Dir(full), 0o750)) + f.suite.Require().NoError(os.WriteFile(full, []byte(body), 0o600)) + _, err := f.wt.Add(rel) + f.suite.Require().NoError(err) +} + +func (f *requireEntryFixture) commit(msg string) { + _, err := f.wt.Commit(msg, &git.CommitOptions{Author: f.sig}) + f.suite.Require().NoError(err) +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_FailsWithoutEntry() { + f := s.newRequireEntryFixture() + // Modify svc source code but DON'T touch svc's CHANGELOG.md. + f.write("svc/foo.go", "package svc\n// updated\n") + f.commit("forgot the changelog") + + repo := f.repoOpen() + cfg := &config.Config{ + RepoRoot: f.dir, + RequireChangelogEntry: true, + BaseRef: "base-tag", + } + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + err := validateAll(context.Background(), cfg, paths, repo, s.log) + s.Require().Error(err) + s.Contains(err.Error(), "svc/CHANGELOG.md") + s.Contains(err.Error(), "[Unreleased] section gained no new lines") +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_PassesWithEntry() { + f := s.newRequireEntryFixture() + f.write("svc/foo.go", "package svc\n// updated\n") + f.write("svc/CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n### Added\n- did the thing\n") + f.commit("with changelog") + + repo := f.repoOpen() + cfg := &config.Config{ + RepoRoot: f.dir, + RequireChangelogEntry: true, + BaseRef: "base-tag", + } + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + s.NoError(validateAll(context.Background(), cfg, paths, repo, s.log)) +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_RootCatchesUnclaimedFiles() { + f := s.newRequireEntryFixture() + // Modify a root-level file (not under svc/) without touching the root + // CHANGELOG.md. The root module should be flagged; svc should not. + f.write("main.go", "package x\n// updated\n") + f.commit("root code change, no changelog") + + repo := f.repoOpen() + cfg := &config.Config{ + RepoRoot: f.dir, + RequireChangelogEntry: true, + BaseRef: "base-tag", + } + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + err := validateAll(context.Background(), cfg, paths, repo, s.log) + s.Require().Error(err) + s.Contains(err.Error(), "CHANGELOG.md") + s.NotContains(err.Error(), "svc/CHANGELOG.md") +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_BadBaseRefIsVCSError() { + f := s.newRequireEntryFixture() + f.write("svc/foo.go", "package svc\n// updated\n") + f.commit("any code change") + + repo := f.repoOpen() + cfg := &config.Config{ + RepoRoot: f.dir, + RequireChangelogEntry: true, + BaseRef: "no-such-revision", + } + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + err := validateAll(context.Background(), cfg, paths, repo, s.log) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitVCSErr, cliErr.code) + s.Contains(err.Error(), "no-such-revision") +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_EmptyBaseRefUsesDefault() { + // With BaseRef left blank, validateAll calls config.DefaultBaseRef() + // which resolves to "origin/main" outside of CI. The temp repo has no + // "origin" remote, so the resolution must fail with a VCS error — the + // important behavior here is that the default-base-ref fallback line + // was exercised. + f := s.newRequireEntryFixture() + f.write("svc/foo.go", "package svc\n// updated\n") + f.commit("any code change") + + // Make sure no ambient GITHUB_BASE_REF leaks in. + s.T().Setenv("GITHUB_BASE_REF", "") + repo := f.repoOpen() + cfg := &config.Config{ + RepoRoot: f.dir, + RequireChangelogEntry: true, + BaseRef: "", + } + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + err := validateAll(context.Background(), cfg, paths, repo, s.log) + s.Require().Error(err) + var cliErr cliError + s.Require().ErrorAs(err, &cliErr) + s.Equal(exitVCSErr, cliErr.code) + s.Contains(err.Error(), "origin/main") +} + +func (s *ValidateTestSuite) TestRequireChangelogEntry_DisabledByDefault() { + f := s.newRequireEntryFixture() + f.write("svc/foo.go", "package svc\n// updated\n") + f.commit("no entry, but check is off") + + repo := f.repoOpen() + cfg := &config.Config{RepoRoot: f.dir} // RequireChangelogEntry zero value + paths := []string{"CHANGELOG.md", "svc/CHANGELOG.md"} + s.NoError(validateAll(context.Background(), cfg, paths, repo, s.log)) +} diff --git a/go.mod b/go.mod index d60fe91..712e36b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/go-github/v68 v68.0.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -37,5 +38,4 @@ require ( golang.org/x/net v0.56.0 // indirect golang.org/x/sys v0.46.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/changelog/classifier.go b/internal/changelog/classifier.go index 323fd98..5c0c6d2 100644 --- a/internal/changelog/classifier.go +++ b/internal/changelog/classifier.go @@ -93,6 +93,80 @@ func classifyCustom(unreleased string, custom map[string]config.BumpType) config return highest } +// h3HeadingRE captures every "### Heading" line in a section body. The +// heading text is whatever follows the hashes (excluding markdown decorations +// at either end), trimmed of surrounding whitespace by the caller. +// +//nolint:gochecknoglobals // package-level compiled regex; cheap and reused. +var h3HeadingRE = regexp.MustCompile(`(?im)^###\s+(.+?)\s*$`) + +// builtInHeadings is the canonical set of Keep a Changelog ### headings. +// Keys are lower-case; values are the bump triggered by the heading. The +// "Changed" / "Removed" entries are special-cased by ValidateSection because +// they require the BREAKING CHANGE marker. +// +//nolint:gochecknoglobals // tiny lookup table, intentionally package-scoped. +var builtInHeadings = map[string]config.BumpType{ + "added": config.BumpMinor, + "changed": config.BumpMajor, + "removed": config.BumpMajor, + "deprecated": config.BumpMinor, + "security": config.BumpMinor, + "fixed": config.BumpPatch, +} + +// ValidateSection inspects the body of a `## [Unreleased]` section and +// returns every problem it finds, rather than stopping at the first like +// Classify does. The returned errors are wrappers around the package's +// existing sentinels (ErrUnrecognizedChangeType, ErrIncompleteBreaking) so +// callers can still match with errors.Is for exit-code mapping. +// +// An empty / whitespace-only section returns a single ErrNoChangesDetected +// so callers can distinguish "skip this module" from real problems. Callers +// that already treat empty sections as a non-error (e.g. the validate +// subcommand) should check for emptiness before calling. +// +// Unlike Classify, this function never returns the computed bump because +// validation is not the place to make release decisions. +func ValidateSection(unreleased string, custom map[string]config.BumpType) []error { + if strings.TrimSpace(unreleased) == "" { + return []error{ErrNoChangesDetected} + } + + headings := h3HeadingRE.FindAllStringSubmatch(unreleased, -1) + if len(headings) == 0 { + // Body had content but no ### headings at all. + return []error{ + fmt.Errorf("%w: section contains content but no ### heading", ErrUnrecognizedChangeType), + } + } + + var problems []error + hasBreakingHeading := false + for _, m := range headings { + h := strings.ToLower(strings.TrimSpace(m[1])) + if _, ok := builtInHeadings[h]; ok { + if h == "changed" || h == "removed" { + hasBreakingHeading = true + } + continue + } + if _, ok := custom[h]; ok { + continue + } + problems = append(problems, fmt.Errorf( + "%w: ### %s (declare it under custom_change_types if intentional)", + ErrUnrecognizedChangeType, m[1], + )) + } + + if hasBreakingHeading && !strings.Contains(unreleased, breakingMarker) { + problems = append(problems, ErrIncompleteBreaking) + } + + return problems +} + // NextVersion increments currentVersion by bump, returning the resulting // SemVer string (without a "v" prefix). func NextVersion(currentVersion string, bump config.BumpType) (string, error) { diff --git a/internal/changelog/classifier_test.go b/internal/changelog/classifier_test.go index a5c1d69..cec3634 100644 --- a/internal/changelog/classifier_test.go +++ b/internal/changelog/classifier_test.go @@ -1,6 +1,8 @@ package changelog_test import ( + "errors" + "strings" "testing" "github.com/stretchr/testify/suite" @@ -130,6 +132,100 @@ func (s *ClassifierTestSuite) TestClassify() { } } +func (s *ClassifierTestSuite) TestValidateSection() { + tests := []struct { + name string + section string + custom map[string]config.BumpType + wantNumErrs int + wantContain []string // substrings each present somewhere in the joined error list + wantSentinel []error + }{ + { + name: "empty section is not-a-change", + section: "", + wantNumErrs: 1, + wantSentinel: []error{changelog.ErrNoChangesDetected}, + }, + { + name: "valid standard headings, no problems", + section: "### Added\n- thing\n### Fixed\n- bug", + wantNumErrs: 0, + }, + { + name: "changed without BREAKING CHANGE marker", + section: "### Changed\n- thing", + wantNumErrs: 1, + wantSentinel: []error{changelog.ErrIncompleteBreaking}, + }, + { + name: "removed without marker also fails breaking check", + section: "### Removed\n- legacy API", + wantNumErrs: 1, + wantSentinel: []error{changelog.ErrIncompleteBreaking}, + }, + { + name: "changed with marker is fine", + section: "### Changed\n- **BREAKING CHANGE**: API behavior changed.", + wantNumErrs: 0, + }, + { + name: "unknown heading is flagged", + section: "### Whimsy\n- something", + wantNumErrs: 1, + wantContain: []string{"Whimsy"}, + wantSentinel: []error{changelog.ErrUnrecognizedChangeType}, + }, + { + name: "all problems reported in a single pass", + section: "### Changed\n- thing\n### Whimsy\n- another\n### Bogus\n- third", + wantNumErrs: 3, + wantContain: []string{"Whimsy", "Bogus", "BREAKING CHANGE"}, + wantSentinel: []error{ + changelog.ErrUnrecognizedChangeType, + changelog.ErrIncompleteBreaking, + }, + }, + { + name: "declared custom heading does not count as unknown", + section: "### Documentation\n- explained the thing", + custom: map[string]config.BumpType{"documentation": config.BumpPatch}, + wantNumErrs: 0, + }, + { + name: "content but no heading at all", + section: "- bare bullet, no heading\n- another", + wantNumErrs: 1, + wantContain: []string{"no ### heading"}, + wantSentinel: []error{changelog.ErrUnrecognizedChangeType}, + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + got := changelog.ValidateSection(tt.section, tt.custom) + s.Require().Lenf(got, tt.wantNumErrs, "errors: %v", got) + parts := make([]string, 0, len(got)) + for _, e := range got { + parts = append(parts, e.Error()) + } + joined := strings.Join(parts, "\n") + for _, sub := range tt.wantContain { + s.Contains(joined, sub) + } + for _, sentinel := range tt.wantSentinel { + var matched bool + for _, e := range got { + if errors.Is(e, sentinel) { + matched = true + break + } + } + s.Truef(matched, "expected an error wrapping %v in %v", sentinel, got) + } + }) + } +} + func (s *ClassifierTestSuite) TestNextVersion() { tests := []struct { name string diff --git a/internal/config/config.go b/internal/config/config.go index 53e36fc..2c89308 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,21 @@ type Config struct { // the repository being released; set SelfReleaseRepo to "" to disable. SelfReleaseModule string SelfReleaseRepo string + + // RequireChangelogEntry, when true, makes `releasegen validate` enforce + // that any module whose non-CHANGELOG files changed (vs BaseRef) also + // gained new content under its `## [Unreleased]` section. This folds + // the historical "ensure_changelog" pre-merge guard into releasegen so + // developers can't merge code without a changelog entry. Default false + // so the syntactic validation can be adopted independently. + RequireChangelogEntry bool + + // BaseRef is the git revision the changelog-entry check diffs against. + // Any revspec go-git can resolve is accepted (branch, tag, remote + // tracking ref like "origin/main", raw hash). Empty falls back to + // "origin/$GITHUB_BASE_REF" when running under GitHub Actions PR + // events, else "origin/main". + BaseRef string } // Owner returns the "owner" portion of OwnerRepo. @@ -60,10 +75,34 @@ func (c *Config) Repo() string { return repo } -// Validate checks that required fields are present and well-formed. +// Validate checks fields needed regardless of which command is running: +// repo root must be set and custom change types must be well-formed. +// Commands that need additional context (release vs. validate) should call +// the path-specific helpers below in addition to this method. func (c *Config) Validate() error { var errs []error + if c.RepoRoot == "" { + errs = append(errs, errors.New("repo root is required")) + } + for heading, bump := range c.CustomTypes { + if heading == "" { + errs = append(errs, errors.New("custom change type has empty heading")) + } + if bump == BumpNone { + errs = append(errs, fmt.Errorf("custom change type %q has invalid bump", heading)) + } + } + return errors.Join(errs...) +} +// ValidateForRelease enforces the additional fields required to perform an +// actual release (commit/tag/push and GitHub Release publication). Callers +// should invoke Validate() first so common errors aren't masked. +func (c *Config) ValidateForRelease() error { + var errs []error + if err := c.Validate(); err != nil { + errs = append(errs, err) + } if c.Token == "" { errs = append(errs, errors.New("GITHUB_TOKEN is required")) } @@ -83,17 +122,6 @@ func (c *Config) Validate() error { errs = append(errs, fmt.Errorf("MANUAL_VERSION %q is not a valid semver: %w", c.ManualVersion, err)) } } - for heading, bump := range c.CustomTypes { - if heading == "" { - errs = append(errs, errors.New("custom change type has empty heading")) - } - if bump == BumpNone { - errs = append(errs, fmt.Errorf("custom change type %q has invalid bump", heading)) - } - } - if c.RepoRoot == "" { - errs = append(errs, errors.New("repo root is required")) - } return errors.Join(errs...) } @@ -120,9 +148,26 @@ func FromEnv() (*Config, error) { // the root module (module name "") is the self-release module. SelfReleaseModule: os.Getenv("RELEASEGEN_SELF_MODULE"), SelfReleaseRepo: envOr("RELEASEGEN_SELF_REPO", "c2fo/releasegen"), + // RELEASEGEN_REQUIRE_CHANGELOG_ENTRY accepts the usual truthy + // strings; anything else (including unset) leaves the field at + // its zero value so the config file can supply the answer. + RequireChangelogEntry: strings.EqualFold(os.Getenv("RELEASEGEN_REQUIRE_CHANGELOG_ENTRY"), "true"), + BaseRef: os.Getenv("RELEASEGEN_BASE_REF"), }, nil } +// DefaultBaseRef returns the effective base ref for the changelog-entry +// check after taking ambient CI environment into account. Callers should +// only invoke this when cfg.BaseRef is still empty after flag/env/file +// resolution; the result is "origin/" if running under a +// GitHub Actions pull_request event, otherwise "origin/main". +func DefaultBaseRef() string { + if ghBase := os.Getenv("GITHUB_BASE_REF"); ghBase != "" { + return "origin/" + ghBase + } + return "origin/main" +} + // envOr returns the value of the named env var, or fallback when unset. func envOr(key, fallback string) string { if v, ok := os.LookupEnv(key); ok { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d9a40aa..9966625 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -85,7 +85,27 @@ func (s *ConfigTestSuite) TestParseCustomTypesError() { s.Require().Error(err) } -func (s *ConfigTestSuite) TestValidate() { +func (s *ConfigTestSuite) TestValidate_BaselineOnly() { + // Validate() is intentionally relaxed: it does not require GitHub + // credentials. Those are checked by ValidateForRelease. + good := &config.Config{RepoRoot: "."} + s.Require().NoError(good.Validate()) + + cases := map[string]func(c *config.Config){ + "missing repo root": func(c *config.Config) { c.RepoRoot = "" }, + "empty custom heading": func(c *config.Config) { c.CustomTypes = map[string]config.BumpType{"": config.BumpPatch} }, + "invalid custom bump": func(c *config.Config) { c.CustomTypes = map[string]config.BumpType{"docs": config.BumpNone} }, + } + for name, mutate := range cases { + s.Run(name, func() { + c := *good + mutate(&c) + s.Require().Error(c.Validate()) + }) + } +} + +func (s *ConfigTestSuite) TestValidateForRelease() { good := &config.Config{ Token: "x", OwnerRepo: "owner/repo", @@ -93,7 +113,7 @@ func (s *ConfigTestSuite) TestValidate() { Branch: "main", RepoRoot: ".", } - s.Require().NoError(good.Validate()) + s.Require().NoError(good.ValidateForRelease()) cases := map[string]func(c *config.Config){ "missing token": func(c *config.Config) { c.Token = "" }, @@ -108,7 +128,7 @@ func (s *ConfigTestSuite) TestValidate() { s.Run(name, func() { c := *good mutate(&c) - s.Require().Error(c.Validate()) + s.Require().Error(c.ValidateForRelease()) }) } } diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..53d299f --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,190 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// ConfigFileNames lists the file names (in lookup order) that LoadFile will +// try when discovering a config file at a given root directory. Each entry is +// a bare file name; LoadFile joins it with the supplied root. +// +//nolint:gochecknoglobals // intentionally exported and not meant to be mutated at runtime. +var ConfigFileNames = []string{".releasegen.yaml", ".releasegen.yml"} + +// FileConfig is the on-disk representation of a `.releasegen.yaml` / +// `.releasegen.yml` file. Only the fields here are considered "repo-shape" +// configuration; per-run values (GitHub creds, manual override, etc.) remain +// environment- and flag-only. +type FileConfig struct { + // CustomChangeTypes maps additional changelog headings to a bump level + // ("major", "minor", or "patch"). Headings are case-insensitive. + CustomChangeTypes map[string]string `yaml:"custom_change_types"` + + // ExcludeDirs lists directory prefixes to skip during changelog + // discovery. Trailing slashes are optional. + ExcludeDirs []string `yaml:"exclude_dirs"` + + // SelfReleaseModule is the module path (relative to the repo root) + // that should be treated as "releasegen releasing itself" so that the + // resolved version is printed to stdout. Empty means the root module. + SelfReleaseModule *string `yaml:"self_release_module"` + + // SelfReleaseRepo is the "owner/repo" string the self-release feature + // matches against. Empty disables the feature. + SelfReleaseRepo *string `yaml:"self_release_repo"` + + // Validate carries options specific to the `releasegen validate` + // subcommand. Kept as a nested block so the command can grow more + // knobs without polluting the top-level namespace. + Validate *ValidateFileConfig `yaml:"validate"` +} + +// ValidateFileConfig is the on-disk representation of the `validate:` block. +// All fields are pointers so we can distinguish "not specified" from +// "explicitly false / empty string": flags and env vars layer on top, and we +// must know whether a missing key should defer to those defaults. +type ValidateFileConfig struct { + // RequireChangelogEntry, when true, makes `validate` enforce that any + // module whose non-CHANGELOG files changed (vs BaseRef) also gained + // new content under its `## [Unreleased]` section. + RequireChangelogEntry *bool `yaml:"require_changelog_entry"` + + // BaseRef overrides the git revision the changelog-entry check diffs + // against. Falls back to the env-aware default in DefaultBaseRef when + // neither this nor the flag/env is set. + BaseRef *string `yaml:"base_ref"` +} + +// LoadFile looks for one of ConfigFileNames in repoRoot and returns a parsed +// FileConfig along with the absolute path it loaded from. If no file is +// present, (nil, "", nil) is returned. Errors are returned for unreadable or +// malformed files. +func LoadFile(repoRoot string) (*FileConfig, string, error) { + if repoRoot == "" { + repoRoot = "." + } + for _, name := range ConfigFileNames { + path := filepath.Join(repoRoot, name) + data, err := os.ReadFile(path) //nolint:gosec // path comes from repoRoot + known suffix + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return nil, "", fmt.Errorf("read %s: %w", path, err) + } + var fc FileConfig + dec := yaml.NewDecoder(strings.NewReader(string(data))) + dec.KnownFields(true) // catch typos in user configs early + if err := dec.Decode(&fc); err != nil { + return nil, "", fmt.Errorf("parse %s: %w", path, err) + } + return &fc, path, nil + } + return nil, "", nil +} + +// ApplyFile merges file-level config into cfg using the precedence rule +// "flags > env > file > defaults": file values are only applied where cfg +// is still at its zero value, i.e. neither the environment nor a flag has +// supplied an explicit value. +// +// ApplyFile must be called before any flag overrides are applied, since the +// flag layer is meant to win unconditionally. +func ApplyFile(cfg *Config, fc *FileConfig) error { + if fc == nil || cfg == nil { + return nil + } + + if len(fc.CustomChangeTypes) > 0 && len(cfg.CustomTypes) == 0 { + parsed, err := parseFileCustomTypes(fc.CustomChangeTypes) + if err != nil { + return err + } + cfg.CustomTypes = parsed + } + + if len(fc.ExcludeDirs) > 0 && len(cfg.ExcludeDirs) == 0 { + cfg.ExcludeDirs = normalizeExcludeDirs(fc.ExcludeDirs) + } + + // SelfReleaseModule defaults to "" which is meaningful (root module), + // so we use a pointer in FileConfig to distinguish "not set" from + // "explicitly set to empty". The corresponding env var is only applied + // when set, so a missing env var means cfg.SelfReleaseModule has its + // zero value and we can safely apply the file value. + if fc.SelfReleaseModule != nil && os.Getenv("RELEASEGEN_SELF_MODULE") == "" { + cfg.SelfReleaseModule = *fc.SelfReleaseModule + } + + // SelfReleaseRepo has a non-empty default ("c2fo/releasegen") applied + // by FromEnv, so we only let the file override when the env var is + // unset and we're still at that default. Forks need this to disable + // the feature without exporting an env var. + if fc.SelfReleaseRepo != nil { + if _, envSet := os.LookupEnv("RELEASEGEN_SELF_REPO"); !envSet { + cfg.SelfReleaseRepo = *fc.SelfReleaseRepo + } + } + + applyValidateBlock(cfg, fc.Validate) + return nil +} + +// applyValidateBlock merges the optional `validate:` config-file block into +// cfg, honoring env-var precedence. Flag overrides are applied by the +// command layer afterwards. +func applyValidateBlock(cfg *Config, vc *ValidateFileConfig) { + if vc == nil { + return + } + if vc.RequireChangelogEntry != nil { + if _, envSet := os.LookupEnv("RELEASEGEN_REQUIRE_CHANGELOG_ENTRY"); !envSet { + cfg.RequireChangelogEntry = *vc.RequireChangelogEntry + } + } + // base_ref: env wins if set, else apply file value. The env-aware + // "origin/ or origin/main" default is computed by + // DefaultBaseRef when cfg.BaseRef remains empty at use time. + if vc.BaseRef != nil { + if _, envSet := os.LookupEnv("RELEASEGEN_BASE_REF"); !envSet { + cfg.BaseRef = *vc.BaseRef + } + } +} + +func parseFileCustomTypes(in map[string]string) (map[string]BumpType, error) { + out := make(map[string]BumpType, len(in)) + for heading, bumpStr := range in { + bump, err := ParseBumpType(bumpStr) + if err != nil { + return nil, fmt.Errorf("custom change type %q: %w", heading, err) + } + key := strings.ToLower(strings.TrimSpace(heading)) + if key == "" { + return nil, errors.New("custom change type has empty heading") + } + out[key] = bump + } + return out, nil +} + +func normalizeExcludeDirs(in []string) []string { + out := make([]string, 0, len(in)) + for _, p := range in { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if !strings.HasSuffix(p, "/") { + p += "/" + } + out = append(out, p) + } + return out +} diff --git a/internal/config/file_test.go b/internal/config/file_test.go new file mode 100644 index 0000000..d920052 --- /dev/null +++ b/internal/config/file_test.go @@ -0,0 +1,166 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/config" +) + +type FileConfigTestSuite struct { + suite.Suite + tmpDir string +} + +func TestFileConfigTestSuite(t *testing.T) { + suite.Run(t, new(FileConfigTestSuite)) +} + +func (s *FileConfigTestSuite) SetupTest() { + s.tmpDir = s.T().TempDir() +} + +func (s *FileConfigTestSuite) writeFile(name, body string) { + s.T().Helper() + s.Require().NoError(os.WriteFile(filepath.Join(s.tmpDir, name), []byte(body), 0o600)) +} + +func (s *FileConfigTestSuite) TestLoadFile_NotPresent() { + fc, path, err := config.LoadFile(s.tmpDir) + s.Require().NoError(err) + s.Nil(fc) + s.Empty(path) +} + +func (s *FileConfigTestSuite) TestLoadFile_YamlExtension() { + s.writeFile(".releasegen.yaml", ` +custom_change_types: + Documentation: minor + Performance: patch +exclude_dirs: + - some/app + - some/other/app +self_release_module: tools/releasegen +self_release_repo: myorg/myrepo +`) + fc, path, err := config.LoadFile(s.tmpDir) + s.Require().NoError(err) + s.Require().NotNil(fc) + s.Equal(filepath.Join(s.tmpDir, ".releasegen.yaml"), path) + s.Equal("minor", fc.CustomChangeTypes["Documentation"]) + s.Equal("patch", fc.CustomChangeTypes["Performance"]) + s.Equal([]string{"some/app", "some/other/app"}, fc.ExcludeDirs) + s.Require().NotNil(fc.SelfReleaseModule) + s.Equal("tools/releasegen", *fc.SelfReleaseModule) + s.Require().NotNil(fc.SelfReleaseRepo) + s.Equal("myorg/myrepo", *fc.SelfReleaseRepo) +} + +func (s *FileConfigTestSuite) TestLoadFile_YmlExtensionAlsoAccepted() { + s.writeFile(".releasegen.yml", "exclude_dirs:\n - x\n") + fc, path, err := config.LoadFile(s.tmpDir) + s.Require().NoError(err) + s.Require().NotNil(fc) + s.Equal(filepath.Join(s.tmpDir, ".releasegen.yml"), path) + s.Equal([]string{"x"}, fc.ExcludeDirs) +} + +func (s *FileConfigTestSuite) TestLoadFile_YamlWinsOverYmlWhenBothExist() { + s.writeFile(".releasegen.yaml", "exclude_dirs:\n - from-yaml\n") + s.writeFile(".releasegen.yml", "exclude_dirs:\n - from-yml\n") + fc, path, err := config.LoadFile(s.tmpDir) + s.Require().NoError(err) + s.Require().NotNil(fc) + s.Equal(filepath.Join(s.tmpDir, ".releasegen.yaml"), path) + s.Equal([]string{"from-yaml"}, fc.ExcludeDirs) +} + +func (s *FileConfigTestSuite) TestLoadFile_UnknownKeyRejected() { + s.writeFile(".releasegen.yaml", "completely_made_up: 1\n") + _, _, err := config.LoadFile(s.tmpDir) + s.Require().Error(err) + s.Contains(err.Error(), "completely_made_up") +} + +func (s *FileConfigTestSuite) TestLoadFile_MalformedYAML() { + s.writeFile(".releasegen.yaml", "exclude_dirs: [unclosed\n") + _, _, err := config.LoadFile(s.tmpDir) + s.Require().Error(err) +} + +func (s *FileConfigTestSuite) TestApplyFile_FillsZeroFields() { + cfg := &config.Config{RepoRoot: s.tmpDir} + repo := "myorg/myrepo" + mod := "tools/releasegen" + fc := &config.FileConfig{ + CustomChangeTypes: map[string]string{"Documentation": "patch"}, + ExcludeDirs: []string{"a", "b/"}, + SelfReleaseModule: &mod, + SelfReleaseRepo: &repo, + } + // Make sure no env interference. + s.T().Setenv("RELEASEGEN_SELF_MODULE", "") + s.Require().NoError(os.Unsetenv("RELEASEGEN_SELF_MODULE")) + s.T().Setenv("RELEASEGEN_SELF_REPO", "") + s.Require().NoError(os.Unsetenv("RELEASEGEN_SELF_REPO")) + + s.Require().NoError(config.ApplyFile(cfg, fc)) + s.Equal(map[string]config.BumpType{"documentation": config.BumpPatch}, cfg.CustomTypes) + s.Equal([]string{"a/", "b/"}, cfg.ExcludeDirs) + s.Equal("tools/releasegen", cfg.SelfReleaseModule) + s.Equal("myorg/myrepo", cfg.SelfReleaseRepo) +} + +func (s *FileConfigTestSuite) TestApplyFile_EnvWinsOverFile() { + s.T().Setenv("RELEASEGEN_SELF_REPO", "env-org/env-repo") + envCfg, err := config.FromEnv() + s.Require().NoError(err) + + repo := "file-org/file-repo" + fc := &config.FileConfig{SelfReleaseRepo: &repo} + s.Require().NoError(config.ApplyFile(envCfg, fc)) + s.Equal("env-org/env-repo", envCfg.SelfReleaseRepo, "env value must beat the file") +} + +func (s *FileConfigTestSuite) TestApplyFile_FileBeatsBuiltInDefault() { + // Clear env so FromEnv leaves the built-in default in place. + s.T().Setenv("RELEASEGEN_SELF_REPO", "") + s.Require().NoError(os.Unsetenv("RELEASEGEN_SELF_REPO")) + cfg, err := config.FromEnv() + s.Require().NoError(err) + s.Equal("c2fo/releasegen", cfg.SelfReleaseRepo) + + disabled := "" + fc := &config.FileConfig{SelfReleaseRepo: &disabled} + s.Require().NoError(config.ApplyFile(cfg, fc)) + s.Empty(cfg.SelfReleaseRepo, "file value must replace the built-in default when env is unset") +} + +func (s *FileConfigTestSuite) TestApplyFile_DoesNotClobberExistingCustomTypes() { + cfg := &config.Config{ + RepoRoot: s.tmpDir, + CustomTypes: map[string]config.BumpType{"documentation": config.BumpMinor}, + } + fc := &config.FileConfig{ + CustomChangeTypes: map[string]string{"Documentation": "patch"}, + } + s.Require().NoError(config.ApplyFile(cfg, fc)) + s.Equal(config.BumpMinor, cfg.CustomTypes["documentation"], "env-provided custom types must beat the file") +} + +func (s *FileConfigTestSuite) TestApplyFile_NilFileIsNoOp() { + cfg := &config.Config{RepoRoot: s.tmpDir} + s.Require().NoError(config.ApplyFile(cfg, nil)) + s.Equal(&config.Config{RepoRoot: s.tmpDir}, cfg) +} + +func (s *FileConfigTestSuite) TestApplyFile_InvalidBump() { + cfg := &config.Config{RepoRoot: s.tmpDir} + fc := &config.FileConfig{ + CustomChangeTypes: map[string]string{"Documentation": "bogus"}, + } + s.Require().Error(config.ApplyFile(cfg, fc)) +} diff --git a/internal/vcs/git.go b/internal/vcs/git.go index 29548ee..82da0b6 100644 --- a/internal/vcs/git.go +++ b/internal/vcs/git.go @@ -214,6 +214,110 @@ func changelogBlobHash(c *object.Commit, changelogPath string) (plumbing.Hash, e return entry.Hash, nil } +// ChangedFiles returns the set of file paths that differ between the tree at +// baseRef and the tree at HEAD. The comparison is a two-dot diff (HEAD vs +// baseRef directly), matching the behavior of `git diff ` rather than +// the merge-base "three-dot" form. For PR-time validation this is precise +// enough and avoids a merge-base computation that go-git makes awkward. +// +// baseRef may be any revision spec go-git can resolve (branch name, tag, +// remote-tracking ref like "origin/main", or a raw hash). When baseRef does +// not resolve, an error wrapping ErrVCS is returned so the caller can map it +// to a useful CI message ("did you fetch with depth 0?"). +func (g *GitRepo) ChangedFiles(ctx context.Context, baseRef string) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + baseCommit, err := g.commitForRev(baseRef) + if err != nil { + return nil, err + } + headRef, err := g.repo.Head() + if err != nil { + return nil, fmt.Errorf("%w: resolve HEAD: %w", ErrVCS, err) + } + headCommit, err := g.repo.CommitObject(headRef.Hash()) + if err != nil { + return nil, fmt.Errorf("%w: load HEAD commit: %w", ErrVCS, err) + } + baseTree, err := baseCommit.Tree() + if err != nil { + return nil, fmt.Errorf("%w: load tree at %q: %w", ErrVCS, baseRef, err) + } + headTree, err := headCommit.Tree() + if err != nil { + return nil, fmt.Errorf("%w: load HEAD tree: %w", ErrVCS, err) + } + changes, err := baseTree.Diff(headTree) + if err != nil { + return nil, fmt.Errorf("%w: diff %q..HEAD: %w", ErrVCS, baseRef, err) + } + // Use a map to deduplicate when a path appears as both From and To + // (e.g. a rename). + seen := make(map[string]struct{}, len(changes)) + for _, c := range changes { + if c.From.Name != "" { + seen[c.From.Name] = struct{}{} + } + if c.To.Name != "" { + seen[c.To.Name] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out, nil +} + +// FileAtRef returns the contents of the file at filePath as it appears in +// the tree of ref's commit. When the file does not exist in that tree the +// empty string is returned with a nil error — callers can treat this as +// "the file was added in HEAD." Other errors (unresolvable ref, unreadable +// blob) are wrapped with ErrVCS. +func (g *GitRepo) FileAtRef(ctx context.Context, ref, filePath string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + commit, err := g.commitForRev(ref) + if err != nil { + return "", err + } + tree, err := commit.Tree() + if err != nil { + return "", fmt.Errorf("%w: load tree at %q: %w", ErrVCS, ref, err) + } + f, err := tree.File(filePath) + if err != nil { + if errors.Is(err, object.ErrFileNotFound) { + return "", nil + } + return "", fmt.Errorf("%w: find %s at %q: %w", ErrVCS, filePath, ref, err) + } + body, err := f.Contents() + if err != nil { + return "", fmt.Errorf("%w: read %s at %q: %w", ErrVCS, filePath, ref, err) + } + return body, nil +} + +// commitForRev resolves an arbitrary revision spec (branch, tag, remote +// tracking ref, hash) to its underlying commit. The error path covers the +// common CI failure mode of "you forgot to fetch with depth 0" by including +// the original ref in the wrapped message. +func (g *GitRepo) commitForRev(rev string) (*object.Commit, error) { + hash, err := g.repo.ResolveRevision(plumbing.Revision(rev)) + if err != nil { + return nil, fmt.Errorf("%w: resolve %q: %w", ErrVCS, rev, err) + } + commit, err := g.repo.CommitObject(*hash) + if err != nil { + return nil, fmt.Errorf("%w: load commit for %q: %w", ErrVCS, rev, err) + } + return commit, nil +} + // CommitTagAndPush stages, commits, pushes, tags, and pushes the tag for a // single module release. Errors are wrapped with the failing step name so // the caller can decide on recovery. diff --git a/internal/vcs/git_test.go b/internal/vcs/git_test.go index 119102e..2abeb98 100644 --- a/internal/vcs/git_test.go +++ b/internal/vcs/git_test.go @@ -119,6 +119,60 @@ func (s *VCSTestSuite) TestIntegration() { s.True(first) } +func (s *VCSTestSuite) TestChangedFilesAndFileAtRef() { + dir := s.T().TempDir() + repo, err := git.PlainInit(dir, false) + s.Require().NoError(err) + wt, err := repo.Worktree() + s.Require().NoError(err) + sig := &object.Signature{Name: "tester", Email: "t@example.com", When: time.Now()} + + writeFile := func(rel, body string) { + s.Require().NoError(os.MkdirAll(filepath.Join(dir, filepath.Dir(rel)), 0o750)) + s.Require().NoError(os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o600)) + _, err := wt.Add(rel) + s.Require().NoError(err) + } + + // Base: one source file, one changelog. + writeFile("main.go", "package x\n") + writeFile("CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n") + baseHash, err := wt.Commit("base", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + // Tag the base commit so we have a stable revspec to diff against. + _, err = repo.CreateTag("base-tag", baseHash, &git.CreateTagOptions{Tagger: sig, Message: "base"}) + s.Require().NoError(err) + + // HEAD: modify the source file and add a new file under a submodule. + writeFile("main.go", "package x\n// changed\n") + writeFile("submodule/foo.go", "package submodule\n") + _, err = wt.Commit("work", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + + g, err := vcs.Open(dir, "main", slog.New(slog.DiscardHandler)) + s.Require().NoError(err) + ctx := context.Background() + + changed, err := g.ChangedFiles(ctx, "base-tag") + s.Require().NoError(err) + s.ElementsMatch([]string{"main.go", "submodule/foo.go"}, changed) + + // FileAtRef returns the base version of main.go (no "// changed" line). + atBase, err := g.FileAtRef(ctx, "base-tag", "main.go") + s.Require().NoError(err) + s.Equal("package x\n", atBase) + + // A file that didn't exist at base resolves to empty, not an error. + atBaseNew, err := g.FileAtRef(ctx, "base-tag", "submodule/foo.go") + s.Require().NoError(err) + s.Empty(atBaseNew) + + // An unresolvable ref produces an ErrVCS-wrapped error. + _, err = g.ChangedFiles(ctx, "no-such-ref") + s.Require().Error(err) + s.ErrorIs(err, vcs.ErrVCS) +} + // TestCommitTagAndPush_PushedToBareRemote exercises the full // commit -> tag -> push pipeline against a local bare repository acting // as origin. It also verifies that errors carry the vcs.ErrVCS sentinel. diff --git a/releasegen.png b/releasegen.png deleted file mode 100644 index 6105a5c..0000000 Binary files a/releasegen.png and /dev/null differ