diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..891c617 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..b894315 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,8 @@ +--- +name: Custom issue template +about: Describe this issue template's purpose here. +title: '' +labels: '' +assignees: '' + +--- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a686e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Golang Test +on: + pull_request: + branches: + - main +jobs: + test: + name: Run Tests + runs-on: ubuntu-latest + env: + GO111MODULE: on + steps: + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6 + with: + go-version: '1.26' + + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + + - name: Run tests + run: go test ./... -v + env: + GOFLAGS: -mod=readonly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..fc5feb5 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,101 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + pull_request: + branches: [ "main" ] + schedule: + - cron: '17 19 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + env: + GOPROXY: proxy.golang.org,direct + + strategy: + fail-fast: false + matrix: + include: + - language: go + build-mode: autobuild + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6 + with: + go-version: '1.26' # The Go version to download (if necessary) and use. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 #v4 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/ensure_changelog.yml b/.github/workflows/ensure_changelog.yml new file mode 100644 index 0000000..c08e8b6 --- /dev/null +++ b/.github/workflows/ensure_changelog.yml @@ -0,0 +1,16 @@ +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/go-test-coverage.yml b/.github/workflows/go-test-coverage.yml new file mode 100644 index 0000000..dc4cda8 --- /dev/null +++ b/.github/workflows/go-test-coverage.yml @@ -0,0 +1,31 @@ +name: Go Test Coverage +on: + pull_request: + branches: + - main +jobs: + check-coverage: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: '1.26' + check-latest: true + + - name: Generate test coverage + run: | + go test \ + -race \ + -covermode=atomic \ + -coverpkg=./internal/...,./cmd/... \ + -coverprofile=coverage.out \ + ./... + + - name: Check test coverage + uses: vladopajic/go-test-coverage@679e6807f68f2440a4c43d386442a1d0041838a9 # v2.18.3 + with: + config: ./.testcoverage.yml diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 0000000..35864f2 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,70 @@ +name: golangci-lint +on: + pull_request: + branches: + - main + +permissions: + # Required: allow read access to the content for analysis. + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + pull-requests: read + # Optional: allow write access to checks to allow the action to annotate code in the PR. + checks: write + +jobs: + detect-modules: + name: detect-modules + runs-on: ubuntu-latest + outputs: + modules: ${{ steps.set-modules.outputs.modules }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - id: set-modules + run: | + # Find all directories containing go.mod files + modules=$(find . -name "go.mod" -type f | sed 's|/go.mod$||' | sort) + + # Convert to JSON array - ensure proper JSON format + echo "$modules" | jq -R . | jq -s -c . > modules.json + + echo "Found modules:" + cat modules.json | jq -r '.[]' + + # Set output with compact JSON + echo "modules=$(cat modules.json)" >> $GITHUB_OUTPUT + + golangci: + name: golangci-lint + runs-on: ubuntu-latest + needs: detect-modules + strategy: + matrix: + module: ${{ fromJSON(needs.detect-modules.outputs.modules) }} + steps: + - name: Setup Go + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 + with: + go-version: '1.26' + check-latest: true + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version + version: latest + + # Optional: working directory, useful for monorepos + # working-directory: somedir + + # Optional: golangci-lint command line arguments. + args: --config=${{ github.workspace }}/.golangci.yml --timeout=10m + + # Optional: show only new issues if it's a pull request. The default value is `false`. + only-new-issues: true + + # Optional: if set to true then the action will use pre-installed Go. + # skip-go-installation: true + working-directory: ${{ matrix.module }} diff --git a/.github/workflows/releasegen.yml b/.github/workflows/releasegen.yml new file mode 100644 index 0000000..a8d3e2f --- /dev/null +++ b/.github/workflows/releasegen.yml @@ -0,0 +1,106 @@ +name: Release by Changelog + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + branch: + description: 'Branch to create a release from' + required: true + default: 'main' + version: + description: 'Specify the semantic version for the release' + required: true + reason: + description: 'Reason for the manual release' + required: false + +permissions: + contents: write + packages: write + +jobs: + release: + runs-on: ubuntu-latest + + env: + GOPROXY: proxy.golang.org,direct + + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASEGEN_APP_ID }} + private-key: ${{ secrets.RELEASEGEN_APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + with: + ref: ${{ github.event.inputs.branch || github.ref_name }} + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token }} + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 #v6 + with: + go-version: '1.26' + + - name: Build and run ReleaseGen + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_ACTOR: ${{ github.actor }} + 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 + run: | + go build -trimpath -ldflags "-s -w" -o release-gen ./cmd/releasegen + # releasegen prints the self-release version on stdout (and only + # when releasegen itself was the module released this run); all + # logs go to stderr. tail -n1 hardens against any stray stdout + # output from future changes. + VERSION=$(./release-gen | tail -n1) + echo "VERSION=$VERSION" >> $GITHUB_ENV + + - name: Log in to GHCR + if: env.VERSION != '' + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + if: env.VERSION != '' + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Extract Docker metadata + id: meta + if: env.VERSION != '' + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.7.0 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest + type=raw,value=${{ github.sha }} + type=semver,pattern={{version}},value=v${{ env.VERSION }} + type=semver,pattern=v{{major}},value=v${{ env.VERSION }} + + - name: Build and push Docker image + if: env.VERSION != '' + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: deployments/Dockerfile + push: true + build-args: | + VERSION=${{ env.VERSION }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache + cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max diff --git a/.github/workflows/scripts/changelog.sh b/.github/workflows/scripts/changelog.sh new file mode 100644 index 0000000..f996218 --- /dev/null +++ b/.github/workflows/scripts/changelog.sh @@ -0,0 +1,86 @@ +#!/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/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..2a7d969 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,155 @@ +version: "2" +run: + go: "1.26" + issues-exit-code: 1 + tests: true + allow-parallel-runners: false +output: + formats: + text: + path: stdout + print-linter-name: true + print-issued-lines: true + path-prefix: "" +linters: + default: none + enable: + - bodyclose + - errcheck + - errname + - errorlint + - gocritic + - gocyclo + - gosec + - govet + - ineffassign + - intrange + - lll + - misspell + - nolintlint + - perfsprint + - prealloc + - revive + - staticcheck + - tagliatelle + - testifylint + - unconvert + - unparam + - unused + - usetesting + - wastedassign + - whitespace + settings: + errcheck: + check-type-assertions: false + check-blank: false + gocritic: + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + - hugeParam + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + settings: + captLocal: + paramsOnly: true + elseif: + skipBalanced: true + nestingReduce: + bodyWidth: 5 + rangeExprCopy: + sizeThreshold: 512 + skipTestFuncs: true + rangeValCopy: + sizeThreshold: 128 + skipTestFuncs: true + truncateCmp: + skipArchDependent: true + underef: + skipRecvDeref: true + unnamedResult: + checkExported: true + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G115 # Integer overflow conversion - too many false positives + - G117 # Exported field names matching secret patterns (false positives for Options structs) + - G101 # Hardcoded credentials detection (false positives in test files with example data) + - G703 # Path traversal taint analysis (false positives for legitimate file operations) + - G705 # XSS taint analysis (false positives for stdout/stderr output) + lll: + line-length: 140 + tab-width: 1 + misspell: + locale: US + nolintlint: + require-explanation: false + require-specific: false + allow-unused: false + revive: + severity: warning + staticcheck: + checks: + - -ST1000 + - -ST1003 + - -ST1016 + - -ST1020 + - -ST1021 + - -ST1022 + - all + tagliatelle: + # The JSON run-summary (see internal/runner) is a stable, snake_case + # output contract consumed by downstream workflow steps. + case: + rules: + json: snake + unparam: + check-exported: false + exclusions: + generated: lax + rules: + - linters: + - mnd + path: _test\.go + - linters: + - lll + source: '^//go:generate ' + paths: + - docs + - mocks +issues: + max-issues-per-linter: 50 + max-same-issues: 3 + uniq-by-line: true + new: false +severity: + default: error +formatters: + enable: + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/c2fo/) + custom-order: false + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/c2fo/ + exclusions: + generated: lax + paths: + - docs + - mocks diff --git a/.prenup.yaml b/.prenup.yaml new file mode 100644 index 0000000..28a9d8e --- /dev/null +++ b/.prenup.yaml @@ -0,0 +1,21 @@ +# Prenup v2 configuration. See https://github.com/C2FO/prenup for docs. +version: 2 +module_markers: + - go.mod +exclude: + - .github/** + - '**/*.yaml' + - '**/*.yml' +output: auto +tasks: + - name: Run tests + default_selected: true + command: go test ./... + per_module: true + - name: Run golangci-lint + 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 + default_selected: true + command: /bin/bash {{.repo_root}}/.github/workflows/scripts/changelog.sh diff --git a/.testcoverage.yml b/.testcoverage.yml new file mode 100644 index 0000000..d2aa6f7 --- /dev/null +++ b/.testcoverage.yml @@ -0,0 +1,24 @@ +# Configuration for vladopajic/go-test-coverage. +# See https://github.com/vladopajic/go-test-coverage for full schema. + +# Path to coverage profile file produced by `go test -coverprofile`. +profile: coverage.out + +# Coverage thresholds (percentages, 0-100). Bump as coverage improves; never lower. +threshold: + # Minimum overall project coverage. + total: 81 + # Minimum coverage required for each package. + package: 66 + # Minimum coverage required for individual files. + file: 65 + +# Exclude generated and non-testable code from coverage statistics. +exclude: + paths: + - mocks/ + +breakdown-file-name: '' + +diff: + base-breakdown-file-name: '' diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f32249d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +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 +- Initial release of the project. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..24632a0 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +john.judd@c2fo.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d612f23 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2026 C2FO, Inc + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 8cbd9b9..67ba6dc 100644 --- a/README.md +++ b/README.md @@ -1 +1,471 @@ -# releasegen \ No newline at end of file +![releasegen-logo.png](docs/images/releasegen-logo.png) + +# ReleaseGen + +--- + +`ReleaseGen` is a Go application designed to automate versioning and release creation based on the content of `CHANGELOG.md` files. The application adheres to Semantic Versioning (SemVer) principles, ensuring that versions are incremented correctly based on the types of changes documented in your changelog. + +You write a normal, human-readable [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) entry under `## [Unreleased]`; when you merge to your release branch, ReleaseGen decides the next version, promotes those notes into a numbered section, commits and tags it, and publishes a matching GitHub Release. That's the whole job — no plugins, no runtime, no DSL. + +## Why ReleaseGen? + +Most release automation derives the version and notes from **commit messages** (Conventional Commits) or from special **intent files**. ReleaseGen takes the position that the changelog you already maintain *is* the source of truth, and that the only thing standing between a merged PR and a published release is mechanical work a tool should do for you. + +It is a good fit when you want: + +- **Changelog-driven, not commit-driven.** Your `CHANGELOG.md` is the contract. You don't have to enforce Conventional Commits, squash policies, or commit linting across every contributor to get correct versions. +- **Language-agnostic monorepo releases.** A "module" is just any directory containing a `CHANGELOG.md`. Each gets its own independent version line and tag (e.g. `services/api/v1.2.3`). It works equally well for Go, Node, Python, or polyglot repos — it never inspects your source. +- **One small, auditable step.** A single static binary (or container image) that does exactly one thing: turn curated changelog intent into a tag + GitHub Release. It composes with whatever builds and publishes your artifacts, rather than replacing them. +- **Safe by default.** `--dry-run` previews every decision, runs fail atomically (a bad module aborts the run rather than half-releasing), bearer tokens are scrubbed from error output, and structured exit codes let CI branch on the failure class instead of grepping logs. + +![releasegen-features.png](docs/images/releasegen-features.png) + +### How it compares + +| Tool | Decides version from | Monorepo model | Scope | +| ---- | -------------------- | -------------- | ----- | +| **ReleaseGen** | Curated `CHANGELOG.md` (Keep a Changelog) | Any dir with a changelog; per-module tags | Tag + GitHub Release only | +| semantic-release | Conventional Commit messages | Plugins / extra config | Versioning + publishing (Node-centric) | +| release-please | Conventional Commit messages | Release PRs per package | Release PR + tag + release | +| Changesets | Hand-written intent files (`.changeset/`) | First-class (JS/TS workspaces) | Versioning + publishing (JS-centric) | +| GoReleaser | Existing git tags | N/A (builds artifacts) | Build + package + publish | + +ReleaseGen deliberately does **not** build artifacts, publish to package registries, open PRs, or write your changelog for you. If you need those, run ReleaseGen for the version/tag/release step and pair it with your existing build tooling. + +## Quick Start + +Install the CLI with Go: + +```bash +go install github.com/c2fo/releasegen/cmd/releasegen@latest +``` + +Or pull the container image from GitHub Container Registry: + +```bash +docker pull ghcr.io/c2fo/releasegen:latest +``` + +Preview what would happen for your repo without changing anything: + +```bash +releasegen --dry-run --repo-root . --repository your-org/your-repo --branch main --actor "$USER" --token "$GH_TOKEN" +``` + +When you're ready to automate it, drop the [example GitHub Actions workflow](#workflow-example) into `.github/workflows/`. + +## Features + +--- + +- **Automatic Versioning**: Detects changes in `CHANGELOG.md` and increments your project’s version following SemVer. +- **Monorepo Support**: Discovers changes to `CHANGELOG.md` files across different directories and generates appropriate release tags for each module. +- **Release Tagging**: Creates a Git tag for the new version, optionally prefixed by the directory path if the `CHANGELOG.md` file is located outside the repo’s root. +- **GitHub Releases**: Automatically creates a GitHub release, pulling release notes directly from your changelog. + +## How It Works + +--- + +### Versioning Logic + +ReleaseGen inspects the `CHANGELOG.md` file for notable changes and applies version bumps according to SemVer: + +- **Major Version Bump**: If the words "BREAKING CHANGE" appear under any “Changed” or “Removed” sections, indicating backward-incompatible changes. +- **Minor Version Bump**: If there are new features, non-breaking changes, deprecations, or security updates. +- **Patch Version Bump**: If only bug fixes are found. +- **Manual Version Override**: If the MANUAL_VERSION environment variable is set, it overrides the calculated version. + +### Monorepo Support & Release Tag Naming + +If you maintain multiple modules in a single repository (a monorepo), ReleaseGen will: +1. Detect all `CHANGELOG.md` files in different subdirectories. +2. Assign separate release tags to each directory that has new changes under ## [Unreleased]. + +![releasegen-mono.png](docs/images/releasegen-mono.png) + +#### Single Module (Root) + +- The tag is simply `vX.Y.Z` (e.g., `v1.2.3`). + +#### Multiple Modules (Monorepo) + +- The tag is prefixed by the path to the module, (e.g. `worker/v2.3.4` or `services/api/v0.2.0`). + +This convention keeps releases organized in larger repositories. A future enhancement may allow “flat” tag naming if you prefer to omit directory prefixes. + +### Custom Change Types + +You can define custom change types and their corresponding bump types using the `CUSTOM_CHANGE_TYPES` environment variable. For example: + +```yaml +CUSTOM_CHANGE_TYPES: | + documentation:patch + performance:minor +``` + +### Debug Mode + +For troubleshooting tag detection issues, enable detailed logging with the `DEBUG` environment variable or the `--debug` flag: + +```yaml +DEBUG: true +``` + +When enabled, ReleaseGen will output detailed information about: + +- Which tags are being processed +- Module names extracted from tags +- Which tags are successfully added vs skipped + +This is particularly useful for diagnosing issues in multi-module repositories or when tags aren't being detected as expected. + +### v2 highlights + +Releasegen v2 ships several quality-of-life and safety improvements while +keeping the v1 contract intact for end users (CHANGELOG format, env vars, +GitHub Actions integration). The breaking changes are mostly internal / +distribution-side: + +- **New module path.** When consumed as a library, the import is + `github.com/c2fo/releasegen/...`. The CLI binary path is + unchanged inside the docker image. +- **`c2fo/vfs/v7` and `golang.org/x/oauth2` are gone.** The binary uses the + standard library + `google/go-github/v68` only. +- **CLI flags for every env var.** Every documented env var has a matching + `--flag`. Flags > env > built-in defaults. +- **`--dry-run`.** Prints what would happen (next version, bump type) without + rewriting files, committing, pushing, tagging, or publishing. Safe to run + locally against your real repo. +- **`--summary-file`** / **`SUMMARY_FILE`.** Writes a JSON summary of the + run that downstream workflow steps can read instead of screen-scraping + logs. +- **`--repo-root`** / **`REPO_ROOT`.** Run releasegen against a worktree + that isn't `.`. +- **`--version`.** Prints the build-time version. +- **Structured exit codes.** `0` (success / nothing to do), `1` (config), + `2` (changelog validation), `3` (git), `4` (GitHub API), `10` (internal). + CI scripts can branch on these instead of grepping logs. +- **Structured logging.** `log/slog`-based; in GitHub Actions + (`GITHUB_ACTIONS=true`) it still emits `::group::`, `::endgroup::`, and + `::error::` markers. Locally, output is plain text. +- **Validated `MANUAL_VERSION`.** Must be a valid semver string before it is + used. +- **Token scrubbing.** Bearer tokens are stripped from go-git push errors + before they reach the logs. +- **Configurable self-release.** The "releasegen releasing itself" detection + (`RELEASEGEN_SELF_MODULE` / `RELEASEGEN_SELF_REPO`) is now overridable; + defaults are unchanged for c2fo/releasegen. + +### Building locally + +```bash +go build -ldflags "-X main.version=$(git describe --tags --always)" -o release-gen ./cmd/releasegen +./release-gen --help +# Preview what a release would do against another checkout, without writing anything: +./release-gen --dry-run --repo-root /path/to/your/repo --repository your-org/your-repo --branch main --actor you --token "$GH_TOKEN" +``` + +## Example `CHANGELOG.md` + +--- + +Your CHANGELOG.md should follow the `CHANGELOG.md` files following the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. For example: + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +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 feature X. + +### Changed +- Modified behavior of Y. +- **BREAKING CHANGE**: Changed API behavior in module Z. + +### Removed +- Deprecated feature W removed. +- **BREAKING CHANGE**: Removed support for legacy API. + +### Deprecated +- Feature V is now deprecated. + +### Security +- Updated dependencies for security patches. + +### Fixed +- Fixed bug related to issue #123. + +## [my-project/v1.2.3] - 2024-08-09 +### Added +- Another new feature. + +### Fixed +- Fixed a minor bug. + +``` + +Note that while we require adhering to the Keep a Changelog format, `ReleaseGen` allows for custom change type headings when used with the env var `CUSTOM_CHANGE_TYPES`. + +## Developer Expectations + +--- + +When using `ReleaseGen`, developers should follow these guidelines to ensure the application can parse the `CHANGELOG.md` file correctly: + +1. **Use Section Headings**: (`### Added`, `### Changed`, `### Removed`, `### Deprecated`, `### Security`, `### Fixed`) — these are case-insensitive. +2. **Mark Breaking Changes**: Include the exact phrase “BREAKING CHANGE” for backward-incompatible changes to ensure a major version bump. This safeguards against unintentional major version increments. +3. **Don’t Manually Change Versions**: Keep new changes under ## [Unreleased]. Let `ReleaseGen` handle the version bump when merged to `main`. +4. **Maintain Consistency**: Be clear and consistent in wording so the application can parse changes accurately. +5. **Organize New Entries**: Always add new changes under the ## [Unreleased] section so that `ReleaseGen` can move them into the next release. + +## Integrating ReleaseGen into a GitHub Actions Workflow + +--- + +### Prerequisites: GitHub App Setup for Branch Protection + +To enable ReleaseGen to work with branch protection rules (requiring PR reviews, status checks, etc.), you need to create a GitHub App that can bypass these protections: + +1. **Create a GitHub App**: + - Go to `https://github.com/settings/apps` (personal) or `https://github.com/organizations/YOUR_ORG/settings/apps` (organization) + - Click **New GitHub App** + - Set a name (e.g., `releasegen-bot`) + - Set Homepage URL to your repository URL + - Uncheck **Webhook** → Active + - Set **Repository permissions**: + - Contents: **Read and write** + - Click **Create GitHub App** + - **Save the App ID** shown at the top of the settings page +2. **Generate Private Key**: + - On the app settings page, scroll to "Private keys" + - Click **Generate a private key** + - Download and save the `.pem` file securely +3. **Install the App**: + - Go to app settings → **Install App** (left sidebar) + - Install on your organization or account + - Select the repositories where you want to use ReleaseGen +4. **Add Secrets and Variables**: + - For each repository, go to Settings → Secrets and variables → Actions + - Add secret: `RELEASEGEN_APP_PRIVATE_KEY` = contents of the `.pem` file + - Add secret: `RELEASEGEN_APP_ID` = your App ID +5. **Configure Branch Protection**: + - Go to repository Settings → Rules + - Create or edit branch protection for `main` + - Enable desired protections (PR reviews, status checks, etc.) + - Under **Bypass list**, add your GitHub App by name + +### Workflow Example + +Below is an example GitHub Actions workflow to automate releases using `ReleaseGen`: + +```yaml +name: Release by Changelog + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + branch: + description: 'Branch to create a release from' + required: true + default: 'main' + version: + description: 'Specify the semantic version for the release (vX.Y.Z)' + required: true + reason: + description: 'Reason for the manual release' + required: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.RELEASEGEN_APP_ID }} + private-key: ${{ secrets.RELEASEGEN_APP_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6 + with: + ref: ${{ github.event.inputs.branch || github.ref_name }} + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token }} + + - name: Run ReleaseGen + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_REF_NAME: ${{ github.event.inputs.branch || github.ref_name }} + MANUAL_VERSION: ${{ github.event.inputs.version || '' }} + REASON: ${{ github.event.inputs.reason || '' }} + # Optional: EXCLUDE_DIRS exclude certain directories from changelog generation + EXCLUDE_DIRS: | + some/app + some/other/app + # Optional: CUSTOM_CHANGE_TYPES allow for custom change types + CUSTOM_CHANGE_TYPES: | + documentation:patch + performance:minor + run: | + docker run --rm \ + -e GITHUB_TOKEN \ + -e GITHUB_REPOSITORY \ + -e GITHUB_ACTOR \ + -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 +``` + +> The image's entrypoint is `/usr/local/bin/release-gen`, so any args after +> the image name are passed directly. Use `--dry-run` to preview without +> publishing, or `--summary-file /workspace/release-summary.json` to capture +> a machine-readable result. + +### Explanation of the Workflow + +- **Generate GitHub App token**: Creates a short-lived authentication token from your GitHub App credentials that can bypass branch protection rules. +- **Checkout repository**: Checks out your repository using the app token so that the workflow has access to the code and `CHANGELOG.md`. +- **Run ReleaseGen**: Runs the `ReleaseGen` Docker container, which reads the `CHANGELOG.md`, determines the next version, commits the updated changelog back to the main branch, creates tags, and generates a GitHub release. +- **Environment Variables**: + - `GITHUB_TOKEN`: The GitHub App token for authentication (required) + - `GITHUB_REPOSITORY`: The repository identifier (required) + - `GITHUB_ACTOR`: The user who triggered the workflow (required) + - `GITHUB_REF_NAME`: The release branch (required; usually injected by Actions) + - `MANUAL_VERSION` / `REASON`: Used by the manual workflow dispatch to force a specific version + - `EXCLUDE_DIRS`: Optional list of directories to exclude from changelog-based releases + - `CUSTOM_CHANGE_TYPES`: Optional custom change types and their corresponding version increments + - `REPO_ROOT`: Optional path to the git working tree (defaults to `.`; useful when invoking from outside the repo) + - `SUMMARY_FILE`: Optional path; when set, releasegen writes a JSON summary of the run there + - `DEBUG`: When `true`, emits verbose tag/discovery diagnostics + - `RELEASEGEN_SELF_MODULE` / `RELEASEGEN_SELF_REPO`: Identify the "releasegen releasing itself" case so that the resolved version is printed to stdout for downstream workflow steps. Defaults are `releasegen` and `c2fo/releasegen`; override only if you fork. + +Every environment variable above also has an equivalent CLI flag. Flag values take precedence over environment values, which take precedence over built-in defaults. + +### Manual Release Workflow Dispatch + +If you want to **manually** trigger a release on a different branch: + +1. Go to **Actions** in your repository. +2. Select **Release by Changelog**. +3. Click **Run workflow**. +4. Choose the branch. +5. (Optional) Add a reason or message. +6. Click **Run workflow** again. + +This will create a release based on the changes in the specified branch. + +## FAQ + +--- + +### Table of Contents + +1. [What happens when no tags exist yet?](#what-happens-when-no-tags-exist-yet) +2. [What if there are no changes in the CHANGELOG.md?](#what-if-there-are-no-changes-in-the-changelogmd) +3. [How does ReleaseGen determine which version to increment?](#how-does-releasegen-determine-which-version-to-increment) +4. [Will a major version bump automatically update my go.mod in a Golang project?](#will-a-major-version-bump-automatically-update-my-gomod-in-a-golang-project) +5. [Can I exclude certain directories from release generation?](#can-i-exclude-certain-directories-from-release-generation) +6. [What if there are multiple CHANGELOG.md files in different directories?](#what-if-there-are-multiple-changelogmd-files-in-different-directories) +7. [Why does the release tag include the directory path in a monorepo?](#why-does-the-release-tag-include-the-directory-path-in-a-monorepo) +8. [Can I manually trigger a release from a specific branch?](#can-i-manually-trigger-a-release-from-a-specific-branch) +9. [What if I want to advance the version to a specific number?](#what-if-i-want-to-advance-the-version-to-a-specific-number) +10. [What if an error occurs during the release process?](#what-if-an-error-occurs-during-the-release-process) +11. [Can I customize the versioning logic?](#can-i-customize-the-versioning-logic) +12. [How can I contribute to ReleaseGen?](#how-can-i-contribute-to-releasegen) + +### What happens when no tags exist yet? + +`ReleaseGen` treats the repository as though it started at v0.0.0. It will create the first tag according to the changes found under ## [Unreleased]. + +### What if there are no changes in the CHANGELOG.md? + +No new release is created. `ReleaseGen` only processes a release if it finds valid entries under ## [Unreleased]. + +### How does ReleaseGen determine which version to increment? + +`ReleaseGen` scans each `CHANGELOG.md` under the ## [Unreleased] section and looks for specific keywords or headings (e.g., BREAKING CHANGE) to decide whether to bump the major, minor, or patch version. + +### Will a major version bump automatically update my go.mod in a Golang project? + +No. You must update your go.mod file manually if you wish to reflect the new major version. + +### Can I exclude certain directories from release generation? + +Yes. Set the `EXCLUDE_DIRS` environment variable (in YAML, as shown above) to a list of directories you want to skip. + +### What if there are multiple CHANGELOG.md files in different directories? + +`ReleaseGen` will independently process each file. Each directory’s changes result in its own release tag (e.g., `worker/vX.Y.Z`). + +### Why does the release tag include the directory path in a monorepo? + +Prefixing tags (e.g., `services/api/v1.2.3`) keeps releases organized and prevents collisions in complex repos. A flat naming option may be considered in the future. + +### Can I manually trigger a release from a specific branch? + +Yes. You can use the workflow_dispatch event in GitHub Actions to specify the branch (see above workflow example). `ReleaseGen` will then create a release based on that branch’s `CHANGELOG.md`. + +### What if I want to advance the version to a specific number? + +Use the `MANUAL_VERSION` env var (or `--manual-version` flag, or the +`version` input on the manual workflow dispatch) to force a specific +semantic version. `REASON` / `--reason` is appended to the changelog footer +to record why the manual bump was needed. The value must be a valid semver +string; releasegen rejects anything else with exit code 1. + +### What if an error occurs during the release process? + +The process exits non-zero, the failure is logged with a `::error::` +GitHub Actions marker, and no further modules are released. The exit code +tells you which layer failed: + +| Code | Meaning | +| ---- | ------------------------------------------- | +| 0 | Success or "nothing to release" | +| 1 | Configuration error (missing/invalid input) | +| 2 | Changelog validation error (malformed `[Unreleased]`, unknown change type, incomplete `BREAKING CHANGE`) | +| 3 | Git error (push, tag, commit, etc.) | +| 4 | GitHub API error (release creation) | +| 10 | Internal error (bug; please file an issue) | + +If the run wrote any tags or releases before failing, those are not rolled +back — fix the failing module, push a new commit, and rerun. + +### Can I customize the versioning logic? + +By default, `ReleaseGen` follows the Keep a Changelog headings and SemVer rules. You can define additional headings and the bump they trigger via the `CUSTOM_CHANGE_TYPES` environment variable (or the `--custom-change-types` flag) using newline-separated `:` pairs, where `` is `major`, `minor`, or `patch`. For example, to make a `### Documentation` section trigger a minor release: + +```yaml +CUSTOM_CHANGE_TYPES: | + Documentation:minor +``` + +See the [Workflow Example](#workflow-example) for how to set this in a GitHub Actions workflow. + +### How can I contribute to ReleaseGen? + +We welcome bug reports, feature requests, and pull requests. Feel free to open an issue or submit a PR on our repository. + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/cmd/releasegen/main.go b/cmd/releasegen/main.go new file mode 100644 index 0000000..b4db2a8 --- /dev/null +++ b/cmd/releasegen/main.go @@ -0,0 +1,241 @@ +// Command releasegen is the entry point for the changelog-driven release tool. +// +// It is intentionally tiny: parse flags + env, build dependencies, hand off +// to internal/runner, translate the result into an exit code, and print the +// "self-release" version on stdout for downstream workflow steps. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/c2fo/releasegen/internal/changelog" + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/forge" + "github.com/c2fo/releasegen/internal/logging" + "github.com/c2fo/releasegen/internal/runner" + "github.com/c2fo/releasegen/internal/vcs" +) + +// version is set at build time via -ldflags "-X main.version=...". +var version = "dev" + +// Exit codes. Stable across runs so CI can branch on them. +const ( + exitOK = 0 + exitConfigErr = 1 + exitChangelogErr = 2 + exitVCSErr = 3 + exitForgeErr = 4 + exitInternal = 10 +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(exitCodeFor(err)) + } +} + +func newRootCmd() *cobra.Command { + var ( + repoRoot string + dryRun bool + debug bool + summaryFile string + manualVersion string + reason string + excludeDirs string + customTypes string + ownerRepo string + actor string + branch string + token string + showVersion bool + ) + + cmd := &cobra.Command{ + Use: "releasegen", + Short: "Changelog-driven release tool for monorepos", + Long: "Releasegen promotes each CHANGELOG.md's [Unreleased] section into a tagged GitHub release. See README.md for details.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + if showVersion { + fmt.Println(version) + return nil + } + + cfg, err := config.FromEnv() + if err != nil { + return cliError{code: exitConfigErr, err: err} + } + + if err := applyFlagOverrides(cfg, flagOverrides{ + repoRoot: repoRoot, + dryRun: dryRun, + debug: debug, + summaryFile: summaryFile, + manualVersion: manualVersion, + reason: reason, + excludeDirs: excludeDirs, + customTypes: customTypes, + ownerRepo: ownerRepo, + actor: actor, + branch: branch, + token: token, + }); err != nil { + return cliError{code: exitConfigErr, err: err} + } + + if err := cfg.Validate(); err != nil { + return cliError{code: exitConfigErr, err: err} + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + ctx, cancel := context.WithTimeout(ctx, 30*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}) + + repo, err := vcs.Open(cfg.RepoRoot, cfg.Branch, log) + if err != nil { + return cliError{code: exitVCSErr, err: err} + } + releaser := forge.NewGitHubReleaser(cfg.Token) + + r := runner.New(runner.Options{ + Config: cfg, + Repo: repo, + Releaser: releaser, + Logger: log, + CI: ci, + Stderr: os.Stderr, + }) + + summary, err := r.Run(ctx) + if summary != nil && summary.ReleaseGenReleased { + fmt.Println(summary.ReleaseGenVersion) + } + return err + }, + } + + cmd.Flags().BoolVar(&showVersion, "version", false, "print version and exit") + cmd.Flags().StringVar(&repoRoot, "repo-root", "", "path to the git working tree (overrides REPO_ROOT, defaults to \".\")") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "compute and print actions without committing, pushing, tagging, or publishing") + cmd.Flags().BoolVar(&debug, "debug", false, "verbose tag/discovery logging (overrides DEBUG env var)") + cmd.Flags().StringVar(&summaryFile, "summary-file", "", "if set, write a JSON summary of the run to this path (overrides SUMMARY_FILE)") + cmd.Flags().StringVar(&manualVersion, "manual-version", "", "explicit version override (overrides MANUAL_VERSION)") + cmd.Flags().StringVar(&reason, "reason", "", "reason for a manual release (overrides REASON)") + cmd.Flags().StringVar(&excludeDirs, "exclude-dirs", "", + "comma- or newline-separated directory prefixes to exclude (overrides EXCLUDE_DIRS)") + cmd.Flags().StringVar(&customTypes, "custom-change-types", "", "newline-separated : pairs (overrides CUSTOM_CHANGE_TYPES)") + cmd.Flags().StringVar(&ownerRepo, "repository", "", "/ (overrides GITHUB_REPOSITORY)") + cmd.Flags().StringVar(&actor, "actor", "", "GitHub actor (overrides GITHUB_ACTOR)") + cmd.Flags().StringVar(&branch, "branch", "", "release branch (overrides GITHUB_REF_NAME)") + cmd.Flags().StringVar(&token, "token", "", "GitHub token (overrides GITHUB_TOKEN)") + + return cmd +} + +type flagOverrides struct { + repoRoot string + dryRun bool + debug bool + summaryFile string + manualVersion string + reason string + excludeDirs string + customTypes string + ownerRepo string + actor string + branch string + token string +} + +func applyFlagOverrides(cfg *config.Config, f flagOverrides) error { + if f.repoRoot != "" { + cfg.RepoRoot = f.repoRoot + } + if f.dryRun { + cfg.DryRun = true + } + if f.debug { + cfg.Debug = true + } + if f.summaryFile != "" { + cfg.SummaryFile = f.summaryFile + } + if f.manualVersion != "" { + cfg.ManualVersion = f.manualVersion + } + if f.reason != "" { + cfg.Reason = f.reason + } + if f.excludeDirs != "" { + cfg.ExcludeDirs = config.ParseExcludeDirs(f.excludeDirs) + } + if f.customTypes != "" { + parsed, err := config.ParseCustomTypes(f.customTypes) + if err != nil { + return fmt.Errorf("--custom-change-types: %w", err) + } + cfg.CustomTypes = parsed + } + if f.ownerRepo != "" { + cfg.OwnerRepo = f.ownerRepo + } + if f.actor != "" { + cfg.Actor = f.actor + } + if f.branch != "" { + cfg.Branch = f.branch + } + if f.token != "" { + cfg.Token = f.token + } + return nil +} + +// cliError wraps an error with an explicit exit code. +type cliError struct { + code int + err error +} + +func (c cliError) Error() string { return c.err.Error() } +func (c cliError) Unwrap() error { return c.err } + +func exitCodeFor(err error) int { + if err == nil { + return exitOK + } + var c cliError + if errors.As(err, &c) { + return c.code + } + switch { + case errors.Is(err, changelog.ErrUnrecognizedChangeType), + errors.Is(err, changelog.ErrIncompleteBreaking): + return exitChangelogErr + case errors.Is(err, vcs.ErrVCS): + return exitVCSErr + case errors.Is(err, forge.ErrForge): + return exitForgeErr + default: + return exitInternal + } +} diff --git a/cmd/releasegen/main_test.go b/cmd/releasegen/main_test.go new file mode 100644 index 0000000..9560bfb --- /dev/null +++ b/cmd/releasegen/main_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/changelog" + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/forge" + "github.com/c2fo/releasegen/internal/vcs" +) + +type CLITestSuite struct { + suite.Suite +} + +func TestCLITestSuite(t *testing.T) { + suite.Run(t, new(CLITestSuite)) +} + +func (s *CLITestSuite) TestApplyFlagOverrides_AllFieldsApplied() { + cfg := &config.Config{} + err := applyFlagOverrides(cfg, flagOverrides{ + repoRoot: "/repo", + dryRun: true, + debug: true, + summaryFile: "/tmp/sum.json", + manualVersion: "v9.9.9", + reason: "hotfix", + excludeDirs: "vendor/, third_party/", + ownerRepo: "owner/repo", + actor: "alice", + branch: "main", + token: "tok", + }) + s.Require().NoError(err) + s.Equal("/repo", cfg.RepoRoot) + s.True(cfg.DryRun) + s.True(cfg.Debug) + s.Equal("/tmp/sum.json", cfg.SummaryFile) + s.Equal("v9.9.9", cfg.ManualVersion) + s.Equal("hotfix", cfg.Reason) + s.Equal([]string{"vendor/", "third_party/"}, cfg.ExcludeDirs) + s.Equal("owner/repo", cfg.OwnerRepo) + s.Equal("alice", cfg.Actor) + s.Equal("main", cfg.Branch) + s.Equal("tok", cfg.Token) +} + +func (s *CLITestSuite) TestApplyFlagOverrides_DoesNotClobberWithEmpty() { + cfg := &config.Config{ + RepoRoot: "/preset", + OwnerRepo: "owner/repo", + Token: "tok", + } + err := applyFlagOverrides(cfg, flagOverrides{}) + s.Require().NoError(err) + s.Equal("/preset", cfg.RepoRoot) + s.Equal("owner/repo", cfg.OwnerRepo) + s.Equal("tok", cfg.Token) +} + +func (s *CLITestSuite) TestApplyFlagOverrides_BadCustomTypesReturnsError() { + cfg := &config.Config{} + err := applyFlagOverrides(cfg, flagOverrides{customTypes: "not-a-pair"}) + s.Require().Error(err) + s.Contains(err.Error(), "custom-change-types") +} + +func (s *CLITestSuite) TestExitCodeFor() { + cases := []struct { + name string + err error + want int + }{ + {"nil", nil, exitOK}, + {"cliError-config", cliError{code: exitConfigErr, err: errors.New("bad")}, exitConfigErr}, + {"unwrapped-cliError", fmt.Errorf("wrap: %w", cliError{code: exitVCSErr, err: errors.New("x")}), exitVCSErr}, + {"changelog-unknown", fmt.Errorf("module x: %w", changelog.ErrUnrecognizedChangeType), exitChangelogErr}, + {"changelog-breaking", fmt.Errorf("module x: %w", changelog.ErrIncompleteBreaking), exitChangelogErr}, + {"vcs", fmt.Errorf("module x: %w", vcs.ErrVCS), exitVCSErr}, + {"forge", fmt.Errorf("module x: %w", forge.ErrForge), exitForgeErr}, + {"unknown", errors.New("something else"), exitInternal}, + } + for _, tc := range cases { + s.Run(tc.name, func() { + s.Equal(tc.want, exitCodeFor(tc.err)) + }) + } +} + +func (s *CLITestSuite) TestCliError_UnwrapAndError() { + inner := errors.New("boom") + wrapped := cliError{code: exitVCSErr, err: inner} + + s.Equal("boom", wrapped.Error()) + s.Same(inner, wrapped.Unwrap()) + s.Require().ErrorIs(wrapped, inner) +} + +func (s *CLITestSuite) TestNewRootCmd_HasExpectedFlags() { + cmd := newRootCmd() + s.NotNil(cmd.Flags().Lookup("dry-run")) + s.NotNil(cmd.Flags().Lookup("repository")) + s.NotNil(cmd.Flags().Lookup("custom-change-types")) + s.NotNil(cmd.Flags().Lookup("summary-file")) + s.NotNil(cmd.Flags().Lookup("version")) +} + +func (s *CLITestSuite) TestNewRootCmd_VersionShortCircuits() { + cmd := newRootCmd() + cmd.SetArgs([]string{"--version"}) + s.Require().NoError(cmd.Execute()) +} diff --git a/deployments/Dockerfile b/deployments/Dockerfile new file mode 100644 index 0000000..26f6536 --- /dev/null +++ b/deployments/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1.7 + +# Builder stage +FROM golang:1.26 AS builder + +WORKDIR /app +ENV CGO_ENABLED=0 + +ARG VERSION=dev + +# Cache module downloads. +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source and build the v2 binary from cmd/releasegen. +COPY . . +RUN go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/release-gen ./cmd/releasegen + +# Final stage +FROM alpine:latest +WORKDIR /app + +# go-git is pure Go and does not shell out, so we do not need the git CLI at +# runtime. ca-certificates is required for the GitHub REST API calls. +RUN apk add --no-cache ca-certificates + +COPY --from=builder /out/release-gen /usr/local/bin/release-gen + +ENTRYPOINT ["/usr/local/bin/release-gen"] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6c0f3e7 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# ReleaseGen v2 Architecture + +This document maps each section of [PRD.md](./PRD.md) to the package(s) +that implement it. Use it as a starting point when navigating the codebase. + +## Layout + +``` +releasegen/ +├── cmd/releasegen/ # tiny CLI entrypoint (Cobra) +└── internal/ + ├── config/ # typed Config, env+flag parsing, validation, BumpType + ├── changelog/ # pure parser, classifier, rewriter, Update + ├── logging/ # slog handler with GitHub Actions awareness + ├── vcs/ # Repo interface + go-git implementation (Open, GitRepo) + ├── forge/ # Releaser interface + GitHub implementation + ├── discovery/ # CHANGELOG.md walker, exclude rules, module resolution + └── runner/ # per-module orchestration: discover -> rewrite -> commit/tag/push -> publish +``` + +The CLI in `cmd/releasegen` is the only place that reads environment +variables and constructs concrete types; the rest of the code is wired +through interfaces (`vcs.Repo`, `forge.Releaser`) for testability. + +## PRD section -> package map + +| PRD section | Package(s) implementing it | +| ----------------------------------- | ----------------------------------------------------------------------- | +| §5.1 Discovery | `internal/discovery`, `internal/vcs` (`AllChangelogPaths`, `ReachableTags`, `IsChangelogModifiedSinceTag`) | +| §5.2 Version calculation | `internal/changelog` (`ExtractUnreleased`, `ExtractCurrentVersion`, `Classify`, `NextVersion`) | +| §5.3 Custom change types | `internal/config` (`ParseCustomTypes`, `BumpType`); `internal/changelog` (`Classify`) | +| §5.4 Manual version override | `internal/config` (validation), `internal/changelog/Update` (footer + override), `internal/runner` (wiring) | +| §5.5 Changelog rewrite | `internal/changelog/Rewrite` | +| §5.6 Commit, tag, push | `internal/vcs` (`GitRepo.CommitTagAndPush`) | +| §5.7 GitHub Release publication | `internal/forge` (`GitHubReleaser.CreateRelease`) | +| §5.8 Multi-module runs | `internal/runner` (`Runner.Run`) | +| §5.9 Self-release awareness | `internal/runner` (`Summary.ReleaseGenReleased/Version`); `cmd/releasegen` (prints to stdout) | +| §6 Inputs & configuration | `internal/config` (`FromEnv`, `Validate`); `cmd/releasegen` (flag overrides) | +| §7 Outputs (logs, exit codes) | `internal/logging`; `cmd/releasegen` (`exitCodeFor`) | +| §8 Constraints / guard-rails | `internal/changelog` (`ErrIncompleteBreaking`, `ErrUnrecognizedChangeType`) | +| §9 Failure modes | All packages return wrapped errors; surfaced via slog at ERROR level | + +## Differences from v1 + +- Single `package main` with global env-derived vars -> typed `Config` injected from `cmd/releasegen`. +- `panic`/`recover` for ordinary errors -> returned errors with structured exit codes. +- `c2fo/vfs/v7` and `golang.org/x/oauth2` removed; `os` and + `go-github.WithAuthToken` are sufficient. +- `bumpType string` -> typed `config.BumpType` enum with numeric priority. +- New `--dry-run`, `--summary-file`, `--version`, and per-env-var flag set. +- `internal/vcs` and `internal/forge` are interfaces -> the runner is fully + unit-testable with fakes (see `internal/runner/runner_test.go`). +- Tag/changelog logic now context-aware (`context.Context` propagated end-to-end). + +## Adding a new code-host backend (e.g. GitLab) + +1. Implement `forge.Releaser` for GitLab in a new file under `internal/forge`. +2. Branch on a flag/env in `cmd/releasegen` to construct the right releaser. +3. The runner does not need to change. + +## Adding a new VCS backend + +The `vcs.Repo` interface is small (4 methods) — implement it against your +backend and inject it into `runner.Options`. The runner does not depend on +`go-git` directly. diff --git a/docs/PRD.md b/docs/PRD.md new file mode 100644 index 0000000..069bd7d --- /dev/null +++ b/docs/PRD.md @@ -0,0 +1,397 @@ +# ReleaseGen — Product Requirements Document + +## 1. Overview + +**ReleaseGen** is an automated, changelog-driven release tool. It removes the +human guesswork from versioning and release publication by treating the +project's `CHANGELOG.md` as the single source of truth for *what changed* and +*how significant the change is*. + +When a repository is merged to its release branch, ReleaseGen reads the +`## [Unreleased]` section of every relevant changelog, decides the next +[Semantic Version](https://semver.org/spec/v2.0.0.html) based on the kinds of +entries it finds, rewrites the changelog to "promote" those notes to a numbered +release, commits and tags the result, and publishes a corresponding GitHub +Release whose body is the freshly-cut release notes. + +ReleaseGen is purpose-built for **monorepos**: it understands that a single +repository can host many independently versioned modules, each with its own +`CHANGELOG.md`, its own version history, and its own tag namespace. A change to +one module produces a release for that module only, and never accidentally +implies a release of its siblings. + +ReleaseGen is designed to run unattended inside CI (typically a GitHub Actions +workflow on `push` to `main`, with an optional `workflow_dispatch` escape +hatch). It is opinionated, conventions-first, and deliberately small in scope: +it does not generate code, write changelog entries for the developer, or open +pull requests. It only completes the last mile — turning curated changelog +intent into a real, tagged, published release. + +## 2. Goals & Non-Goals + +### Goals +- Make releases a side effect of merging well-formed changelog entries, not a + separate manual ritual. +- Enforce [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) conventions + so that humans and the tool agree on what each section means. +- Apply SemVer rigorously and predictably: the highest-impact change in the + unreleased section determines the bump. +- Treat each module in a monorepo as an independent release unit, with its own + version line and its own tag. +- Produce releases that are self-describing: tag, GitHub Release, and the + changelog entry all reference the same notes and link back to each other. +- Be safe to run on every merge: do nothing if there is nothing to release; + fail loudly and atomically if something is malformed. +- Provide a controlled override for humans (manual version, manual reason) + without compromising the automated path. + +### Non-Goals +- ReleaseGen does **not** author changelog content. Developers (or their + tooling) must add entries to `## [Unreleased]` themselves. +- ReleaseGen does **not** open or merge pull requests, run tests, build + artifacts, or publish packages to language-specific registries. +- ReleaseGen does **not** rewrite version strings inside source code (e.g. + `go.mod` major version paths, `package.json` version, etc.). That is the + developer's responsibility. +- ReleaseGen does **not** support pre-release identifiers (`-rc.1`, `-beta`) + or build metadata as a first-class workflow. +- ReleaseGen does **not** support release branches other than the branch it is + invoked on; it is not a release-train manager. +- ReleaseGen does **not** delete or amend existing releases or tags. + +## 3. Users & Use Cases + +### Primary users +- **Application and library maintainers** who want a no-friction + "merge to main → cut a release" loop. +- **Monorepo platform owners** who need each module in a shared repository to + be released independently and without coordination. +- **CI/CD engineers** who want a single, auditable Docker step in their + release workflow. + +### Representative use cases +1. **Single-module repository.** A developer adds an `### Added` entry under + `## [Unreleased]` in the root `CHANGELOG.md`, opens a PR, and merges. + ReleaseGen cuts `v1.4.0`, tags it, and publishes a GitHub Release. +2. **Monorepo with many modules.** A PR touches `services/api/` and adds + a `### Fixed` entry in that module's changelog. ReleaseGen produces only + `services/api/v0.2.4`; siblings are untouched. +3. **Coordinated multi-module change.** A PR adds entries in two different + module changelogs simultaneously. ReleaseGen produces two independent + releases in the same run, each with its own version, tag, and GitHub + Release. +4. **Breaking change.** A maintainer documents a `### Changed` item that + includes the literal phrase **"BREAKING CHANGE"**. ReleaseGen bumps the + major version. +5. **Manual override.** An on-call engineer triggers the workflow via + `workflow_dispatch`, supplying an explicit version (e.g. `v2.0.0`) and a + reason. ReleaseGen ignores the calculated bump, uses the supplied version, + and appends the override reason and actor to the release notes. +6. **First-ever release.** A brand-new module has a `CHANGELOG.md` with only + an `## [Unreleased]` section and no prior tags. ReleaseGen treats the + starting point as `0.0.0`, applies the bump, and creates the module's + first tag and release. + +## 4. Core Concepts + +### Changelog as source of truth +The `CHANGELOG.md` file, written in Keep a Changelog format, is the contract +between the developer and ReleaseGen. The developer expresses intent by +choosing a section heading (`### Added`, `### Fixed`, etc.) and writing +human-readable bullets. ReleaseGen interprets that intent. + +### Module +A *module* is the directory in which a `CHANGELOG.md` lives. The module name +is the directory path relative to the repository root. A `CHANGELOG.md` at the +repository root has an empty module name and is treated as the "root module". +Every other changelog defines its own module, identified by its full relative +directory path (e.g. `worker`, `services/api`, `pkg/logger`). + +### Module-scoped tag +Each module has its own independent version history expressed through tags: +- Root module: `vX.Y.Z` (e.g. `v1.2.3`). +- Sub-module: `/vX.Y.Z` (e.g. `services/api/v0.2.0`). + +Tags are the canonical record of what has been released. ReleaseGen reads +existing tags to determine each module's current version and writes a new tag +to record the new one. + +### Unreleased section +Each changelog has exactly one `## [Unreleased]` section. Everything between +that heading and the most recent versioned heading represents work that has +been merged but not yet released. ReleaseGen consumes this section, decides +the next version from its contents, and rewrites the file so that the +unreleased section becomes a versioned section and a new (empty) unreleased +section takes its place at the top. + +### Bump type +The bump type — *major*, *minor*, or *patch* — is computed from the +section headings present in the unreleased block, plus any user-defined +custom change types. The highest-impact match wins. + +## 5. Behavior + +### 5.1 Discovery + +On startup, ReleaseGen scans the working tree of the current commit for every +file named `CHANGELOG.md`. From this set it removes any path under a directory +listed in the `EXCLUDE_DIRS` configuration. For each remaining changelog, it +identifies the module name from the file's directory. + +For each module, ReleaseGen finds the most recent existing tag belonging to +that specific module by reading all tags in the repository, parsing the +module prefix from each tag name, and selecting the newest one (by tagger +date) whose commit is reachable from the branch being released. Tags whose +commits are not reachable from the current branch are ignored. Modules with +no prior tag are treated as if they were at version `0.0.0` and are always +considered eligible for release. + +ReleaseGen then determines whether each changelog has actually been modified +since its module's most recent tag. Changelogs that have not changed are +silently dropped from the run. The remaining changelogs are the candidates +to release. + +### 5.2 Version calculation + +For each candidate changelog, ReleaseGen extracts the contents of the +`## [Unreleased]` section and the most recent prior version recorded in the +file. (If no prior version exists in the file, the starting version is +`0.0.0`.) + +It then classifies the unreleased content: + +- **Major** — A `### Changed` or `### Removed` heading is present *and* the + literal phrase `BREAKING CHANGE` appears somewhere in the unreleased block. +- **Minor** — Any `### Added`, `### Deprecated`, `### Security` heading is + present, or a `### Changed` / `### Removed` heading is present together + with `BREAKING CHANGE` text (already classified as major above), or a + custom change type is configured at minor. +- **Patch** — Only `### Fixed` entries (or custom change types configured at + patch) are present. + +If a `### Changed` or `### Removed` heading is present but `BREAKING CHANGE` +is **not**, ReleaseGen refuses to proceed and surfaces an explicit error +explaining that those headings are reserved for breaking changes and +suggesting an appropriate alternative section. This is a deliberate +guard-rail against accidental major bumps and against misuse of the +sections. + +If the unreleased section exists but contains no recognized change type +(default or custom), ReleaseGen errors out for that module. + +If the unreleased section is empty or absent, ReleaseGen treats the module +as having "no changes detected" and skips it without error. + +The calculated next version is produced by incrementing the appropriate +component of the current version using SemVer rules. + +### 5.3 Custom change types + +Operators may extend the default vocabulary by configuring additional section +names mapped to bump types via `CUSTOM_CHANGE_TYPES`. For example, +`documentation:patch` causes a `### Documentation` section to drive a patch +bump, and `performance:minor` causes a `### Performance` section to drive a +minor bump. + +Custom types follow the same priority rule as built-in types (major beats +minor beats patch), and a custom *major* mapping still requires the +`BREAKING CHANGE` text in the unreleased section before it will actually +produce a major bump. + +### 5.4 Manual version override + +If a `MANUAL_VERSION` is supplied (typically via `workflow_dispatch` input), +ReleaseGen uses it verbatim as the next version, bypassing its calculation. +When a manual version is used, ReleaseGen also appends a footer to the +release notes for that release in the form +`Manual release by : `, where the actor and the reason are +also supplied by the workflow context. This makes manual interventions +permanently visible in the changelog and the GitHub Release. + +A manual version is applied uniformly to every changelog being processed in +the run. + +### 5.5 Changelog rewrite + +For each module being released, ReleaseGen rewrites the changelog file in +place: + +- The existing `## [Unreleased]` heading is preserved at the top, but its + content is moved out of it, leaving it empty for the next cycle. +- A new versioned section is inserted directly below it. The heading takes + the form `## [[]()] - `, where + `` is the module-scoped release identifier (`vX.Y.Z` or + `/vX.Y.Z`), `` deep-links to the GitHub Release that + is about to be created, and the date is the current UTC date. +- The body of the new section is the original unreleased content (plus the + manual-release footer if applicable). + +### 5.6 Commit, tag, push + +For each released module, ReleaseGen stages the rewritten changelog file, +commits it on the current branch with the message +`chore: release version /v () [skip ci]`, and pushes +the commit. The `[skip ci]` marker prevents the release commit from +re-triggering the release workflow. + +It then creates an annotated tag whose name follows the module-scoped tag +convention (`vX.Y.Z` or `/vX.Y.Z`) pointing at the new commit, and +pushes that tag to the remote. The tagger identity is the standard +GitHub Actions bot. + +### 5.7 GitHub Release publication + +After the tag is in place, ReleaseGen calls the GitHub API to create a +Release for that tag. The release name is `[] - `, and +the release body is the unreleased section that was promoted (including the +manual override footer, when applicable). This means the GitHub Release, the +changelog entry, and the tag are all consistent with one another and easy to +navigate between. + +### 5.8 Multi-module runs + +When a single invocation produces releases for several modules, each module is +processed independently and sequentially: discover → bump → rewrite → commit +→ tag → push → publish. Modules that have nothing to release are skipped +with a clear log message. A failure on one module aborts the entire run with +a non-zero exit code; ReleaseGen does not attempt partial recovery or +rollback. + +### 5.9 Self-release awareness + +When ReleaseGen is releasing itself (i.e. running inside the `c2fo/releasegen` +repository and producing a new version of the `releasegen` module), it emits +the new releasegen version to standard output. This is intended to be +captured by the surrounding workflow so that a downstream step (such as a +container image build) can be conditionally executed only when releasegen +itself was bumped. + +## 6. Inputs & Configuration + +ReleaseGen is configured entirely through environment variables. There is no +configuration file. This keeps the tool trivial to run as a CI step. + +### Required (typically supplied by the CI environment) +- `GITHUB_TOKEN` — Token used to push commits/tags and to create the GitHub + Release. Must be authorized to bypass branch protection on the release + branch (a GitHub App token is the supported pattern). +- `GITHUB_REPOSITORY` — `/` identifier of the repository being + released. +- `GITHUB_ACTOR` — User attributed to the run; surfaced in commit messages + and in the manual-release footer. +- `GITHUB_REF_NAME` — The branch being released. Used to determine which + tags are reachable and to push to the right ref. + +### Optional +- `MANUAL_VERSION` — Explicit version string that overrides the calculated + bump for every module released in the run. +- `REASON` — Free-text justification appended (along with the actor) to the + release notes when `MANUAL_VERSION` is set. +- `EXCLUDE_DIRS` — Newline- or comma-separated list of directory prefixes to + exclude from changelog discovery. Useful to keep vendored or third-party + changelogs out of the release pipeline. +- `CUSTOM_CHANGE_TYPES` — Newline-separated list of `:` pairs + that extend the default Keep a Changelog vocabulary. +- `DEBUG` — When set to `true`, emits verbose logs about tag discovery, + module-name extraction, reachability decisions, and which tags were + accepted or skipped. Intended for diagnosing detection problems in + complex monorepos. + +## 7. Outputs + +- **Modified `CHANGELOG.md` files** — Committed back to the release branch. +- **Git commits** — One per released module, on the release branch, marked + with `[skip ci]`. +- **Git tags** — One per released module, annotated, pushed to the remote. +- **GitHub Releases** — One per released module, with notes lifted from the + promoted unreleased section. +- **stderr logs** — Grouped using GitHub Actions `::group::` markers for + readable workflow logs; errors are annotated with `::error::` so they + surface in the Actions UI. +- **stdout** — Empty in the general case; emits the new releasegen version + string when ReleaseGen has just released itself in `c2fo/releasegen`. +- **Exit code** — `0` on success (including the "nothing to release" case); + non-zero on any failure, with the failing error annotated in the logs. + +## 8. Constraints, Conventions, and Guard-Rails + +- **Keep a Changelog format is mandatory.** Section headings drive behavior; + free-form changelogs are not supported. +- **Section heading matching is case-insensitive** but the literal phrase + `BREAKING CHANGE` is matched case-sensitively. This is intentional: the + developer must opt in to a major bump deliberately. +- **One unreleased section per file.** ReleaseGen extracts the content + between `## [Unreleased]` and the next versioned section (or end of file + if none exists). +- **Tags are the version oracle.** The version recorded in the changelog + file is informational; the tag history is authoritative for determining + the current version of each module. +- **Reachability matters.** Tags whose commits are not reachable from the + current branch are ignored when determining a module's current version. + This prevents tags from feature branches or abandoned histories from + influencing releases. +- **Atomicity per module, not across modules.** A run that releases three + modules will release them one at a time. A failure mid-run leaves earlier + modules already released and later modules unreleased; the operator must + reconcile by adding a new commit. +- **No version downgrades.** ReleaseGen only ever increments. Manual + override does not validate that the supplied version is greater than the + current version; the operator is trusted. +- **`go.mod` is not touched.** A major bump for a Go module does not modify + the module path. Maintainers are expected to handle major-version + migrations manually. + +## 9. Failure Modes + +ReleaseGen aims to fail loudly and clearly. Notable failure conditions: + +- **Malformed unreleased section** — A `### Changed` or `### Removed` heading + without a `BREAKING CHANGE` marker. ReleaseGen errors out and explains the + rule. No release is created. +- **Unrecognized change type** — Unreleased section contains content that + matches no built-in or custom heading. ReleaseGen errors out. +- **Unparseable current version** — Existing tag/version cannot be parsed as + SemVer. ReleaseGen errors out. +- **Git push or tag failure** — Authentication problem, branch protection + not bypassed, or network failure. ReleaseGen errors out; any earlier + modules in the same run that already succeeded remain released. +- **GitHub Release API failure** — The tag exists but the Release call + failed. ReleaseGen errors out; the operator may need to delete the tag + before retrying or create the Release manually. +- **Empty unreleased section** — *Not* an error. The module is silently + skipped. + +## 10. Distribution and Execution + +ReleaseGen is distributed as a single binary packaged in a Docker image. The +expected invocation is from a GitHub Actions workflow that: + +1. Generates a short-lived GitHub App token authorized to bypass branch + protection on the release branch. +2. Checks out the repository at the chosen branch with full history + (`fetch-depth: 0`) so all tags are available. +3. Runs the ReleaseGen container, mounting the workspace and passing the + environment variables described in §6. + +A manual `workflow_dispatch` entry point is supported, allowing on-call +operators to specify a branch, an explicit version, and a reason for +auditability. + +## 11. Future Considerations + +The current design is intentionally narrow. Several extensions have been +identified but are out of scope for the present product: + +- **Flat tag naming.** Optionally drop the module-path prefix from tags in + monorepos where teams prefer a flat namespace. +- **Pre-release versions.** First-class support for `-rc`, `-beta`, etc. +- **Authoritative manual version targeting.** A first-class way to fast- + forward a module to a specific version without going through the + `MANUAL_VERSION` override path. +- **Automatic source updates** for files that need to track the version + (e.g. `go.mod` major path, `package.json`, embedded version constants). +- **Pull-request-based releases.** Open a release PR rather than committing + directly to the release branch, for repositories whose policies forbid + bot commits even with branch-protection bypass. +- **Richer release notes.** Optional inclusion of contributor lists, PR + links, or auto-categorization beyond what the changelog already states. diff --git a/docs/images/releasegen-features.png b/docs/images/releasegen-features.png new file mode 100644 index 0000000..2778f2f Binary files /dev/null and b/docs/images/releasegen-features.png differ diff --git a/docs/images/releasegen-logo.png b/docs/images/releasegen-logo.png new file mode 100644 index 0000000..7b6c316 Binary files /dev/null and b/docs/images/releasegen-logo.png differ diff --git a/docs/images/releasegen-mono.png b/docs/images/releasegen-mono.png new file mode 100644 index 0000000..4c6f3f4 Binary files /dev/null and b/docs/images/releasegen-mono.png differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d9ad58 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/c2fo/releasegen + +go 1.26 + +require ( + github.com/Masterminds/semver/v3 v3.4.0 + github.com/go-git/go-git/v5 v5.17.0 + github.com/google/go-github/v68 v68.0.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8a7c1e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,121 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/changelog/classifier.go b/internal/changelog/classifier.go new file mode 100644 index 0000000..323fd98 --- /dev/null +++ b/internal/changelog/classifier.go @@ -0,0 +1,116 @@ +package changelog + +import ( + "fmt" + "regexp" + "strings" + + "github.com/Masterminds/semver/v3" + + "github.com/c2fo/releasegen/internal/config" +) + +// breakingMarker is matched case-sensitively per the PRD: a major bump must +// be opted into deliberately. +const breakingMarker = "BREAKING CHANGE" + +var ( + breakingHeadingRE = regexp.MustCompile(heading3Prefix + `(?:Change|Remove)[sd]?`) + addedRE = regexp.MustCompile(heading3Prefix + `Add(?:s|ed)?`) + deprecatedRE = regexp.MustCompile(heading3Prefix + `Deprecate[sd]?`) + securityRE = regexp.MustCompile(heading3Prefix + `Security`) + changedRE = regexp.MustCompile(heading3Prefix + `Change[sd]?`) + removedRE = regexp.MustCompile(heading3Prefix + `Remove[sd]?`) + fixedRE = regexp.MustCompile(heading3Prefix + `Fixed`) +) + +// Classify inspects an unreleased section and returns the appropriate bump +// type, taking custom change types into account. +// +// The decision rules are: +// - A `### Changed` or `### Removed` heading without the literal phrase +// "BREAKING CHANGE" anywhere in the section returns ErrIncompleteBreaking. +// - With "BREAKING CHANGE" present, those sections drive a major bump. +// - `### Added`, `### Deprecated`, `### Security` drive a minor bump. +// - `### Fixed` drives a patch bump. +// - Custom types are evaluated under the same priority order; a custom +// "major" mapping still requires the BREAKING CHANGE marker. +// - The highest-priority match wins. +// - An empty section returns ErrNoChangesDetected. +// - Anything else returns ErrUnrecognizedChangeType. +func Classify(unreleased string, custom map[string]config.BumpType) (config.BumpType, error) { + if strings.TrimSpace(unreleased) == "" { + return config.BumpNone, ErrNoChangesDetected + } + + bump := classifyCustom(unreleased, custom) + + switch { + case breakingHeadingRE.MatchString(unreleased): + if !strings.Contains(unreleased, breakingMarker) { + return config.BumpNone, ErrIncompleteBreaking + } + bump = config.BumpMajor + case addedRE.MatchString(unreleased), + deprecatedRE.MatchString(unreleased), + securityRE.MatchString(unreleased), + changedRE.MatchString(unreleased), + removedRE.MatchString(unreleased): + if bump < config.BumpMinor { + bump = config.BumpMinor + } + case fixedRE.MatchString(unreleased): + if bump < config.BumpPatch { + bump = config.BumpPatch + } + default: + if bump == config.BumpNone { + return config.BumpNone, ErrUnrecognizedChangeType + } + } + + if bump == config.BumpNone { + return config.BumpNone, ErrUnrecognizedChangeType + } + return bump, nil +} + +func classifyCustom(unreleased string, custom map[string]config.BumpType) config.BumpType { + highest := config.BumpNone + for heading, b := range custom { + re := regexp.MustCompile(heading3Prefix + regexp.QuoteMeta(heading)) + if !re.MatchString(unreleased) { + continue + } + if b == config.BumpMajor && !strings.Contains(unreleased, breakingMarker) { + // Major custom types still require the breaking marker. + continue + } + if b > highest { + highest = b + } + } + return highest +} + +// NextVersion increments currentVersion by bump, returning the resulting +// SemVer string (without a "v" prefix). +func NextVersion(currentVersion string, bump config.BumpType) (string, error) { + v, err := semver.NewVersion(strings.TrimPrefix(currentVersion, "v")) + if err != nil { + return "", fmt.Errorf("invalid current version %q: %w", currentVersion, err) + } + switch bump { + case config.BumpMajor: + next := v.IncMajor() + return next.String(), nil + case config.BumpMinor: + next := v.IncMinor() + return next.String(), nil + case config.BumpPatch: + next := v.IncPatch() + return next.String(), nil + default: + return "", fmt.Errorf("invalid bump type: %s", bump) + } +} diff --git a/internal/changelog/classifier_test.go b/internal/changelog/classifier_test.go new file mode 100644 index 0000000..a5c1d69 --- /dev/null +++ b/internal/changelog/classifier_test.go @@ -0,0 +1,160 @@ +package changelog_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/changelog" + "github.com/c2fo/releasegen/internal/config" +) + +type ClassifierTestSuite struct { + suite.Suite +} + +func TestClassifierTestSuite(t *testing.T) { + suite.Run(t, new(ClassifierTestSuite)) +} + +func (s *ClassifierTestSuite) TestClassify() { + tests := []struct { + name string + section string + custom map[string]config.BumpType + wantBump config.BumpType + wantErr bool + wantErrMsg string + wantErrType error + }{ + { + name: "major with breaking change", + section: "### Changed\n- **BREAKING CHANGE**: API behavior changed.", + wantBump: config.BumpMajor, + }, + { + name: "minor with addition", + section: "### Added\n- New feature X.", + wantBump: config.BumpMinor, + }, + { + name: "minor with deprecation", + section: "### Deprecated\n- Old thing.", + wantBump: config.BumpMinor, + }, + { + name: "minor with security", + section: "### Security\n- Patched.", + wantBump: config.BumpMinor, + }, + { + name: "patch with fix", + section: "### Fixed\n- Fixed bug.", + wantBump: config.BumpPatch, + }, + { + name: "Changed without breaking marker errors", + section: "### Changed\n- Just a plain change.", + wantErr: true, + wantErrType: changelog.ErrIncompleteBreaking, + }, + { + name: "Empty section is ErrNoChangesDetected", + section: " \n \n", + wantErr: true, + wantErrType: changelog.ErrNoChangesDetected, + }, + { + name: "Unrecognized heading", + section: "### Notes\n- something", + wantErr: true, + wantErrType: changelog.ErrUnrecognizedChangeType, + }, + { + name: "Custom minor type Performance", + section: "### Performance\n- improved", + custom: map[string]config.BumpType{"performance": config.BumpMinor}, + wantBump: config.BumpMinor, + }, + { + name: "Custom patch Documentation", + section: "### Documentation\n- updated docs", + custom: map[string]config.BumpType{"documentation": config.BumpPatch}, + wantBump: config.BumpPatch, + }, + { + name: "Custom + builtin: builtin major wins", + section: "### Performance\n- improved\n### Changed\n- **BREAKING CHANGE**: x", + custom: map[string]config.BumpType{"performance": config.BumpMinor}, + wantBump: config.BumpMajor, + }, + { + name: "Custom + builtin: minor beats patch", + section: "### Documentation\n- docs\n### Added\n- new", + custom: map[string]config.BumpType{"documentation": config.BumpPatch}, + wantBump: config.BumpMinor, + }, + { + name: "Custom + builtin: patch when only fixed and docs", + section: "### Documentation\n- docs\n### Fixed\n- bug", + custom: map[string]config.BumpType{"documentation": config.BumpPatch}, + wantBump: config.BumpPatch, + }, + { + name: "Custom major with BREAKING CHANGE", + section: "### Blah\n- this is a BREAKING CHANGE", + custom: map[string]config.BumpType{"blah": config.BumpMajor}, + wantBump: config.BumpMajor, + }, + { + name: "Custom major without BREAKING CHANGE falls through", + section: "### Blah\n- not breaking", + custom: map[string]config.BumpType{"blah": config.BumpMajor}, + wantErr: true, + wantErrType: changelog.ErrUnrecognizedChangeType, + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + got, err := changelog.Classify(tt.section, tt.custom) + if tt.wantErr { + s.Require().Error(err) + if tt.wantErrType != nil { + s.Require().ErrorIs(err, tt.wantErrType) + } + return + } + s.Require().NoError(err) + s.Equal(tt.wantBump, got) + }) + } +} + +func (s *ClassifierTestSuite) TestNextVersion() { + tests := []struct { + name string + current string + bump config.BumpType + want string + wantErr bool + }{ + {"major", "1.2.3", config.BumpMajor, "2.0.0", false}, + {"minor", "1.2.3", config.BumpMinor, "1.3.0", false}, + {"patch", "1.2.3", config.BumpPatch, "1.2.4", false}, + {"strip v prefix", "v1.2.3", config.BumpPatch, "1.2.4", false}, + {"first release minor", "0.0.0", config.BumpMinor, "0.1.0", false}, + {"bad current", "not.a.version", config.BumpMinor, "", true}, + {"none", "1.2.3", config.BumpNone, "", true}, + } + for _, tt := range tests { + s.Run(tt.name, func() { + got, err := changelog.NextVersion(tt.current, tt.bump) + if tt.wantErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + s.Equal(tt.want, got) + }) + } +} diff --git a/internal/changelog/errors.go b/internal/changelog/errors.go new file mode 100644 index 0000000..8748405 --- /dev/null +++ b/internal/changelog/errors.go @@ -0,0 +1,26 @@ +// Package changelog parses, classifies, and rewrites Keep a Changelog files. +// +// All functions in this package are pure: they accept the changelog text and +// configuration as input and return new text and a classification result. +// They never read from the process environment, the file system, or git. +package changelog + +import "errors" + +// ErrNoChangesDetected is returned when an unreleased section is empty +// or absent and the module should be silently skipped. +var ErrNoChangesDetected = errors.New("no changes detected") + +// ErrUnrecognizedChangeType is returned when an unreleased section contains +// content that does not match any built-in or configured custom heading. +var ErrUnrecognizedChangeType = errors.New("missing or unrecognized change type in unreleased section") + +// ErrIncompleteBreaking is returned when a `### Changed` or `### Removed` +// heading is present but no entry under it contains the literal phrase +// "BREAKING CHANGE". +var ErrIncompleteBreaking = errors.New( + "### Changed or ### Removed section found with missing or incomplete description." + + " Per Keep a Changelog, these sections are for BREAKING CHANGES only and must include 'BREAKING CHANGE' in" + + " the description. If this is NOT a breaking change, please use a different section type: ### Added (new features)" + + ", ### Fixed (bug fixes), ### Deprecated, or ### Security", +) diff --git a/internal/changelog/parser.go b/internal/changelog/parser.go new file mode 100644 index 0000000..a56ceed --- /dev/null +++ b/internal/changelog/parser.go @@ -0,0 +1,65 @@ +package changelog + +import ( + "regexp" + "strings" +) + +const ( + heading2Prefix = `(?i)##\s*` + heading3Prefix = `(?i)###\s*` + + // SemverPattern is a regex pattern that matches a semantic version string + // (per https://semver.org/spec/v2.0.0.html). Exposed for callers that need + // to construct related expressions. + SemverPattern = `(?P0|[1-9]\d*)\.` + + `(?P0|[1-9]\d*)\.` + + `(?P0|[1-9]\d*)` + + `(?:-` + + `(?P` + + `(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)` + + `(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*` + + `)` + + `)?` + + `(?:\+` + + `(?P` + + `[0-9a-zA-Z-]+` + + `(?:\.[0-9a-zA-Z-]+)*` + + `)` + + `)?` +) + +var ( + unreleasedRE = regexp.MustCompile(`(?si)##\s*\[Unreleased\](.*?)##\s*\[{1,2}(?:.*?/)?v?` + SemverPattern + `\]`) + unreleasedNoOtherVersionRE = regexp.MustCompile(`(?si)##\s*\[Unreleased\](.*?)\z`) + existingVersionsRE = regexp.MustCompile(heading2Prefix + `\[{1,2}(?:.*?/)?v?` + SemverPattern + `\]`) + currentVersionRE = regexp.MustCompile(heading2Prefix + `\[{1,2}(?:.*?/)?v?(` + SemverPattern + `)\]`) +) + +// ExtractUnreleased returns the body of the `## [Unreleased]` section, +// trimmed of surrounding whitespace. It returns an empty string when no +// unreleased section exists or the section is empty. +func ExtractUnreleased(content string) string { + if !existingVersionsRE.MatchString(content) { + matches := unreleasedNoOtherVersionRE.FindStringSubmatch(content) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" + } + matches := unreleasedRE.FindStringSubmatch(content) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + return "" +} + +// ExtractCurrentVersion returns the most recent versioned heading in the +// changelog, or "0.0.0" when none is present (i.e. a first release). +func ExtractCurrentVersion(content string) string { + matches := currentVersionRE.FindStringSubmatch(content) + if len(matches) > 1 { + return matches[1] + } + return "0.0.0" +} diff --git a/internal/changelog/parser_test.go b/internal/changelog/parser_test.go new file mode 100644 index 0000000..0d4c717 --- /dev/null +++ b/internal/changelog/parser_test.go @@ -0,0 +1,150 @@ +package changelog_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/changelog" +) + +type ParserTestSuite struct { + suite.Suite +} + +func TestParserTestSuite(t *testing.T) { + suite.Run(t, new(ParserTestSuite)) +} + +func (s *ParserTestSuite) TestExtractUnreleased() { + tests := []struct { + name string + content string + expected string + }{ + { + name: "Unreleased section present", + content: ` +## [Unreleased] +### Added +- New feature X. + +## [1.2.3] - 2024-08-09 +### Added +- Another new feature. +`, + expected: "### Added\n- New feature X.", + }, + { + name: "Unreleased section with multiple lines", + content: ` +## [Unreleased] +### Added +- New feature X. +- Another feature. + +## [1.2.3] - 2024-08-09 +### Added +- Another new feature. +`, + expected: "### Added\n- New feature X.\n- Another feature.", + }, + { + name: "No unreleased section", + content: ` +## [1.2.3] - 2024-08-09 +### Added +- Another new feature. +`, + expected: "", + }, + { + name: "Unreleased section no previous release", + content: ` +## [Unreleased] +### Added +- New feature X. +`, + expected: "### Added\n- New feature X.", + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.expected, changelog.ExtractUnreleased(tt.content)) + }) + } +} + +func (s *ParserTestSuite) TestExtractCurrentVersion() { + tests := []struct { + name string + content string + expected string + }{ + { + name: "Current version present", + content: ` +## [Unreleased] +### Added +- x + +## [1.2.3] - 2024-08-09 +`, + expected: "1.2.3", + }, + { + name: "Current version present - submodule prefix", + content: ` +## [Unreleased] + +## [submodule/v1.2.3] - 2024-08-09 +`, + expected: "1.2.3", + }, + { + name: "First version (most recent appears first)", + content: ` +## [Unreleased] + +## [1.2.3] - 2024-08-09 +## [1.2.2] - 2024-08-08 +`, + expected: "1.2.3", + }, + { + name: "Linked version heading", + content: ` +## [Unreleased] + +## [[v4.22.0](https://github.com/owner/repo/releases/tag/v4.22.0)] - 2025-03-10 +`, + expected: "4.22.0", + }, + { + name: "No previous version present (first release)", + content: ` +## [Unreleased] +### Added +- New feature X. +`, + expected: "0.0.0", + }, + } + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.expected, changelog.ExtractCurrentVersion(tt.content)) + }) + } +} + +// FuzzExtractUnreleased ensures the parser never panics on arbitrary input. +func FuzzExtractUnreleased(f *testing.F) { + f.Add("") + f.Add("## [Unreleased]\n### Added\n- x\n## [1.0.0] - 2024-01-01\n") + f.Add("# garbage") + f.Add("## [Unreleased]\n## [foo/v1.2.3-rc.1+build.5]") + f.Fuzz(func(_ *testing.T, content string) { + _ = changelog.ExtractUnreleased(content) + _ = changelog.ExtractCurrentVersion(content) + }) +} diff --git a/internal/changelog/rewriter.go b/internal/changelog/rewriter.go new file mode 100644 index 0000000..2195ea0 --- /dev/null +++ b/internal/changelog/rewriter.go @@ -0,0 +1,63 @@ +package changelog + +import ( + "fmt" + "net/url" + "regexp" + "time" +) + +// RewriteOptions controls Rewrite. The new versioned heading links back to +// the GitHub Release URL derived from OwnerRepo, ModuleName, and +// NextVersion via ReleaseURL. +type RewriteOptions struct { + ModuleName string + NextVersion string // bare semver, e.g. "1.2.3" + OwnerRepo string // "/" + // MatchSection is the unreleased body to match in the source content + // (i.e. the text that was originally extracted by ExtractUnreleased). + MatchSection string + // PromoteAs is the text to emit under the new versioned heading. It may + // differ from MatchSection when, e.g., a manual-release footer is added. + // When empty, MatchSection is used. + PromoteAs string + Now time.Time +} + +// ReleaseName returns the module-scoped release identifier +// ("vX.Y.Z" or "/vX.Y.Z"). +func ReleaseName(moduleName, version string) string { + if moduleName == "" { + return "v" + version + } + return fmt.Sprintf("%s/v%s", moduleName, version) +} + +// ReleaseURL returns the GitHub Release URL for a given module/version. +func ReleaseURL(ownerRepo, moduleName, version string) string { + return fmt.Sprintf( + "https://github.com/%s/releases/tag/%s", + ownerRepo, + url.PathEscape(ReleaseName(moduleName, version)), + ) +} + +// Rewrite returns a new changelog body that promotes the unreleased section +// to a versioned section. The original `## [Unreleased]` heading is preserved +// at the top with an empty body, and a new versioned heading is inserted +// directly below it. +func Rewrite(content string, opts RewriteOptions) string { + promoted := opts.PromoteAs + if promoted == "" { + promoted = opts.MatchSection + } + pattern := regexp.MustCompile(`(?i)##\s*\[Unreleased\]\s*` + regexp.QuoteMeta(opts.MatchSection)) + releaseName := ReleaseName(opts.ModuleName, opts.NextVersion) + releaseURL := ReleaseURL(opts.OwnerRepo, opts.ModuleName, opts.NextVersion) + nowDate := opts.Now.UTC().Format("2006-01-02") + replacement := fmt.Sprintf( + "## [Unreleased]\n\n## [[%s](%s)] - %s\n%s", + releaseName, releaseURL, nowDate, promoted, + ) + return pattern.ReplaceAllString(content, replacement) +} diff --git a/internal/changelog/rewriter_test.go b/internal/changelog/rewriter_test.go new file mode 100644 index 0000000..bbb0612 --- /dev/null +++ b/internal/changelog/rewriter_test.go @@ -0,0 +1,110 @@ +package changelog_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/changelog" +) + +type RewriterTestSuite struct { + suite.Suite +} + +func TestRewriterTestSuite(t *testing.T) { + suite.Run(t, new(RewriterTestSuite)) +} + +func (s *RewriterTestSuite) TestRewrite() { + now := time.Date(2026, 4, 19, 0, 0, 0, 0, time.UTC) + tests := []struct { + name string + content string + unreleasedSection string + moduleName string + nextVersion string + expected string + }{ + { + name: "Basic update", + content: ` +## [Unreleased] + +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +### Added +- Another new feature. +`, + unreleasedSection: "### Added\n- New feature X.", + nextVersion: "1.2.4", + expected: ` +## [Unreleased] + +## [[v1.2.4](https://github.com/owner/repo/releases/tag/v1.2.4)] - %s +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +### Added +- Another new feature. +`, + }, + { + name: "With module name (path-escaped slash)", + content: ` +## [Unreleased] + +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +`, + unreleasedSection: "### Added\n- New feature X.", + moduleName: "mymodule", + nextVersion: "1.2.4", + expected: ` +## [Unreleased] + +## [[mymodule/v1.2.4](https://github.com/owner/repo/releases/tag/mymodule%%2Fv1.2.4)] - %s +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +`, + }, + } + nowStr := now.UTC().Format("2006-01-02") + for _, tt := range tests { + s.Run(tt.name, func() { + got := changelog.Rewrite(tt.content, changelog.RewriteOptions{ + ModuleName: tt.moduleName, + NextVersion: tt.nextVersion, + OwnerRepo: "owner/repo", + MatchSection: tt.unreleasedSection, + Now: now, + }) + s.Equal(fmt.Sprintf(tt.expected, nowStr), got) + }) + } +} + +func (s *RewriterTestSuite) TestReleaseName() { + s.Equal("v1.2.3", changelog.ReleaseName("", "1.2.3")) + s.Equal("mod/v1.2.3", changelog.ReleaseName("mod", "1.2.3")) +} + +func (s *RewriterTestSuite) TestReleaseURL() { + s.Equal( + "https://github.com/owner/repo/releases/tag/v1.2.3", + changelog.ReleaseURL("owner/repo", "", "1.2.3"), + ) + s.Equal( + "https://github.com/owner/repo/releases/tag/mod%2Fv1.2.3", + changelog.ReleaseURL("owner/repo", "mod", "1.2.3"), + ) +} diff --git a/internal/changelog/update.go b/internal/changelog/update.go new file mode 100644 index 0000000..7807b29 --- /dev/null +++ b/internal/changelog/update.go @@ -0,0 +1,85 @@ +package changelog + +import ( + "fmt" + "strings" + "time" + + "github.com/c2fo/releasegen/internal/config" +) + +// UpdateRequest describes a single changelog update. +type UpdateRequest struct { + Content string + ModuleName string + OwnerRepo string + CustomTypes map[string]config.BumpType + ManualVersion string // empty means "calculate from content" + ManualReason string + Actor string + Now time.Time +} + +// UpdateResult is the outcome of a successful Update. +type UpdateResult struct { + NextVersion string // bare semver, e.g. "1.2.3" + UnreleasedSection string // body promoted into the new versioned section (incl. manual footer) + NewContent string // full rewritten changelog body + Bump config.BumpType + Manual bool +} + +// Update performs the full pure transformation: +// extract -> classify -> bump -> (optional override) -> rewrite. +// It returns ErrNoChangesDetected when the unreleased section is empty. +func Update(req UpdateRequest) (UpdateResult, error) { + unreleased := ExtractUnreleased(req.Content) + if unreleased == "" { + return UpdateResult{}, ErrNoChangesDetected + } + + currentVersion := ExtractCurrentVersion(req.Content) + + bump, err := Classify(unreleased, req.CustomTypes) + if err != nil { + return UpdateResult{}, err + } + + nextVersion, err := NextVersion(currentVersion, bump) + if err != nil { + return UpdateResult{}, fmt.Errorf("unable to determine next semantic version: %w", err) + } + + manual := req.ManualVersion != "" + promoted := unreleased + if manual { + nextVersion = req.ManualVersion + footer := "Manual release by " + req.Actor + if strings.TrimSpace(req.ManualReason) != "" { + footer += ": " + req.ManualReason + } + promoted = unreleased + "\n\n" + footer + } + + now := req.Now + if now.IsZero() { + now = time.Now() + } + + newContent := Rewrite(req.Content, RewriteOptions{ + ModuleName: req.ModuleName, + NextVersion: nextVersion, + OwnerRepo: req.OwnerRepo, + MatchSection: unreleased, + PromoteAs: promoted, + Now: now, + }) + + return UpdateResult{ + NextVersion: nextVersion, + UnreleasedSection: promoted, + NewContent: newContent, + Bump: bump, + Manual: manual, + }, nil +} diff --git a/internal/changelog/update_test.go b/internal/changelog/update_test.go new file mode 100644 index 0000000..2d98bee --- /dev/null +++ b/internal/changelog/update_test.go @@ -0,0 +1,132 @@ +package changelog_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/changelog" +) + +type UpdateTestSuite struct { + suite.Suite +} + +func TestUpdateTestSuite(t *testing.T) { + suite.Run(t, new(UpdateTestSuite)) +} + +func (s *UpdateTestSuite) TestUpdate() { + now := time.Date(2026, 4, 19, 0, 0, 0, 0, time.UTC) + + tests := []struct { + name string + req changelog.UpdateRequest + wantVersion string + wantSection string + wantErr bool + }{ + { + name: "valid changelog with previous version", + req: changelog.UpdateRequest{ + Content: ` +## [Unreleased] + +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +### Added +- Another. +`, + OwnerRepo: "owner/repo", + Now: now, + }, + wantVersion: "1.3.0", + wantSection: "### Added\n- New feature X.", + }, + { + name: "no unreleased section returns ErrNoChangesDetected", + req: changelog.UpdateRequest{ + Content: `## [v1.2.3] - 2024-08-09 +### Added +- Another. +`, + OwnerRepo: "owner/repo", + Now: now, + }, + wantErr: true, + }, + { + name: "first release with module", + req: changelog.UpdateRequest{ + Content: `# Changelog + +## [Unreleased] +### Added +- Initial implementation +`, + ModuleName: "microservice/foo", + OwnerRepo: "owner/repo", + Now: now, + }, + wantVersion: "0.1.0", + wantSection: "### Added\n- Initial implementation", + }, + { + name: "manual override appends footer", + req: changelog.UpdateRequest{ + Content: ` +## [Unreleased] +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +`, + OwnerRepo: "owner/repo", + ManualVersion: "9.9.9", + ManualReason: "hotfix", + Actor: "operator", + Now: now, + }, + wantVersion: "9.9.9", + wantSection: "### Added\n- New feature X.\n\nManual release by operator: hotfix", + }, + { + name: "manual override without reason omits dangling colon", + req: changelog.UpdateRequest{ + Content: ` +## [Unreleased] +### Added +- New feature X. + +## [v1.2.3] - 2024-08-09 +`, + OwnerRepo: "owner/repo", + ManualVersion: "9.9.9", + ManualReason: "", + Actor: "operator", + Now: now, + }, + wantVersion: "9.9.9", + wantSection: "### Added\n- New feature X.\n\nManual release by operator", + }, + } + + for i := range tests { + tt := &tests[i] + s.Run(tt.name, func() { + res, err := changelog.Update(tt.req) + if tt.wantErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + s.Equal(tt.wantVersion, res.NextVersion) + s.Equal(tt.wantSection, res.UnreleasedSection) + s.Contains(res.NewContent, "## [Unreleased]") + s.Contains(res.NewContent, res.NextVersion) + }) + } +} diff --git a/internal/config/bump.go b/internal/config/bump.go new file mode 100644 index 0000000..7010c5c --- /dev/null +++ b/internal/config/bump.go @@ -0,0 +1,54 @@ +// Package config defines typed configuration for releasegen and the helpers +// used to load it from environment variables and CLI flags. +package config + +import ( + "fmt" + "strings" +) + +// BumpType is the SemVer increment classification for an unreleased section. +// +// Values are ordered from least- to most-significant so callers can use +// numeric comparison ("if bump > current { current = bump }") to pick the +// highest-priority bump. +type BumpType uint8 + +const ( + // BumpNone means no recognized change was detected. + BumpNone BumpType = iota + // BumpPatch corresponds to a SemVer patch increment. + BumpPatch + // BumpMinor corresponds to a SemVer minor increment. + BumpMinor + // BumpMajor corresponds to a SemVer major increment. + BumpMajor +) + +// String returns the lower-case textual name of the bump type. +func (b BumpType) String() string { + switch b { + case BumpMajor: + return "major" + case BumpMinor: + return "minor" + case BumpPatch: + return "patch" + default: + return "none" + } +} + +// ParseBumpType parses a textual bump name (case-insensitive) into a BumpType. +func ParseBumpType(s string) (BumpType, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "major": + return BumpMajor, nil + case "minor": + return BumpMinor, nil + case "patch": + return BumpPatch, nil + default: + return BumpNone, fmt.Errorf("unrecognized bump type %q (want major, minor, or patch)", s) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..25dabfd --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,177 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/Masterminds/semver/v3" +) + +// Config is the fully resolved runtime configuration for a single releasegen +// invocation. All fields are populated from environment variables and/or +// CLI flags before the runner starts; nothing else in the codebase reads +// the process environment. +type Config struct { + // Required GitHub Actions context. + Token string + OwnerRepo string // "/" + Actor string + Branch string + + // Optional manual override. + ManualVersion string + Reason string + + // Discovery / classification settings. + ExcludeDirs []string + CustomTypes map[string]BumpType // canonical lowercase heading -> bump + + // Operational flags. + DryRun bool // do not commit, push, tag, or publish + Debug bool // verbose tag/discovery diagnostics + RepoRoot string + + // SummaryFile, if non-empty, receives a JSON summary of the run. + SummaryFile string + + // SelfReleaseModule and SelfReleaseRepo together identify the + // "releasegen releasing itself" case: when a module with this name + // is released inside this repository, the resulting version is + // printed to stdout for downstream workflow steps to consume. + // Both must be non-empty for the feature to be active. + SelfReleaseModule string + SelfReleaseRepo string +} + +// Owner returns the "owner" portion of OwnerRepo. +func (c *Config) Owner() string { + owner, _, _ := strings.Cut(c.OwnerRepo, "/") + return owner +} + +// Repo returns the "repo" portion of OwnerRepo. +func (c *Config) Repo() string { + _, repo, _ := strings.Cut(c.OwnerRepo, "/") + return repo +} + +// Validate checks that required fields are present and well-formed. +func (c *Config) Validate() error { + var errs []error + + if c.Token == "" { + errs = append(errs, errors.New("GITHUB_TOKEN is required")) + } + if c.OwnerRepo == "" { + errs = append(errs, errors.New("GITHUB_REPOSITORY is required")) + } else if owner, repo, ok := strings.Cut(c.OwnerRepo, "/"); !ok || owner == "" || repo == "" { + errs = append(errs, fmt.Errorf("GITHUB_REPOSITORY %q must be in / form", c.OwnerRepo)) + } + if c.Actor == "" { + errs = append(errs, errors.New("GITHUB_ACTOR is required")) + } + if c.Branch == "" { + errs = append(errs, errors.New("GITHUB_REF_NAME is required")) + } + if c.ManualVersion != "" { + if _, err := semver.NewVersion(strings.TrimPrefix(c.ManualVersion, "v")); err != nil { + 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...) +} + +// FromEnv builds a Config from process environment variables. It does not +// validate; callers should invoke Validate after applying any flag overrides. +func FromEnv() (*Config, error) { + customTypes, err := ParseCustomTypes(os.Getenv("CUSTOM_CHANGE_TYPES")) + if err != nil { + return nil, err + } + return &Config{ + Token: os.Getenv("GITHUB_TOKEN"), + OwnerRepo: os.Getenv("GITHUB_REPOSITORY"), + Actor: os.Getenv("GITHUB_ACTOR"), + Branch: os.Getenv("GITHUB_REF_NAME"), + ManualVersion: os.Getenv("MANUAL_VERSION"), + Reason: os.Getenv("REASON"), + ExcludeDirs: ParseExcludeDirs(os.Getenv("EXCLUDE_DIRS")), + CustomTypes: customTypes, + Debug: strings.EqualFold(os.Getenv("DEBUG"), "true"), + RepoRoot: envOr("REPO_ROOT", "."), + SummaryFile: os.Getenv("SUMMARY_FILE"), + SelfReleaseModule: envOr("RELEASEGEN_SELF_MODULE", "releasegen"), + SelfReleaseRepo: envOr("RELEASEGEN_SELF_REPO", "c2fo/releasegen"), + }, nil +} + +// 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 { + return v + } + return fallback +} + +// ParseExcludeDirs splits a newline- or comma-separated list of directories, +// trims whitespace, and normalizes each entry to end with "/". +func ParseExcludeDirs(raw string) []string { + if raw == "" { + return nil + } + sep := "," + if strings.Contains(raw, "\n") { + sep = "\n" + } + parts := strings.Split(raw, sep) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if !strings.HasSuffix(p, "/") { + p += "/" + } + out = append(out, p) + } + return out +} + +// ParseCustomTypes parses a newline-separated list of ":" +// pairs into a canonical lower-case heading -> BumpType map. +func ParseCustomTypes(raw string) (map[string]BumpType, error) { + out := map[string]BumpType{} + if raw == "" { + return out, nil + } + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + heading, bumpStr, ok := strings.Cut(line, ":") + if !ok { + return nil, fmt.Errorf("invalid CUSTOM_CHANGE_TYPES entry %q (want :)", line) + } + bump, err := ParseBumpType(bumpStr) + if err != nil { + return nil, fmt.Errorf("custom change type %q: %w", heading, err) + } + out[strings.ToLower(strings.TrimSpace(heading))] = bump + } + return out, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..8ecdde4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,147 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/config" +) + +type ConfigTestSuite struct { + suite.Suite +} + +func TestConfigTestSuite(t *testing.T) { + suite.Run(t, new(ConfigTestSuite)) +} + +func (s *ConfigTestSuite) TestParseBumpType() { + tests := []struct { + name string + in string + want config.BumpType + wantErr bool + }{ + {"major", "major", config.BumpMajor, false}, + {"MINOR (case-insensitive)", "MINOR", config.BumpMinor, false}, + {"patch with whitespace", " patch ", config.BumpPatch, false}, + {"empty", "", config.BumpNone, true}, + {"unknown", "huge", config.BumpNone, true}, + } + for _, tt := range tests { + s.Run(tt.name, func() { + got, err := config.ParseBumpType(tt.in) + if tt.wantErr { + s.Require().Error(err) + return + } + s.Require().NoError(err) + s.Equal(tt.want, got) + }) + } +} + +func (s *ConfigTestSuite) TestBumpTypeString() { + s.Equal("major", config.BumpMajor.String()) + s.Equal("minor", config.BumpMinor.String()) + s.Equal("patch", config.BumpPatch.String()) + s.Equal("none", config.BumpNone.String()) +} + +func (s *ConfigTestSuite) TestParseExcludeDirs() { + tests := []struct { + name string + in string + want []string + }{ + {"empty", "", nil}, + {"comma-separated", "a,b", []string{"a/", "b/"}}, + {"newline-separated", "a\nb", []string{"a/", "b/"}}, + {"trailing slash preserved", "a/,b", []string{"a/", "b/"}}, + {"whitespace trimmed", " a , b ", []string{"a/", "b/"}}, + } + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.want, config.ParseExcludeDirs(tt.in)) + }) + } +} + +func (s *ConfigTestSuite) TestParseCustomTypes() { + got, err := config.ParseCustomTypes("Documentation:patch\nPerformance:minor\n") + s.Require().NoError(err) + s.Equal(map[string]config.BumpType{ + "documentation": config.BumpPatch, + "performance": config.BumpMinor, + }, got) +} + +func (s *ConfigTestSuite) TestParseCustomTypesError() { + _, err := config.ParseCustomTypes("Documentation") + s.Require().Error(err) + _, err = config.ParseCustomTypes("Documentation:bogus") + s.Require().Error(err) +} + +func (s *ConfigTestSuite) TestValidate() { + good := &config.Config{ + Token: "x", + OwnerRepo: "owner/repo", + Actor: "me", + Branch: "main", + RepoRoot: ".", + } + s.Require().NoError(good.Validate()) + + cases := map[string]func(c *config.Config){ + "missing token": func(c *config.Config) { c.Token = "" }, + "missing repo": func(c *config.Config) { c.OwnerRepo = "" }, + "malformed repo": func(c *config.Config) { c.OwnerRepo = "owner-only" }, + "missing actor": func(c *config.Config) { c.Actor = "" }, + "missing branch": func(c *config.Config) { c.Branch = "" }, + "missing repo root": func(c *config.Config) { c.RepoRoot = "" }, + "bad manual version": func(c *config.Config) { c.ManualVersion = "not-semver" }, + } + for name, mutate := range cases { + s.Run(name, func() { + c := *good + mutate(&c) + s.Require().Error(c.Validate()) + }) + } +} + +func (s *ConfigTestSuite) TestOwnerRepoSplit() { + c := &config.Config{OwnerRepo: "c2fo/releasegen"} + s.Equal("c2fo", c.Owner()) + s.Equal("releasegen", c.Repo()) +} + +func (s *ConfigTestSuite) TestFromEnv_Defaults() { + // Clear any inherited values to assert true defaults. + for _, k := range []string{"REPO_ROOT", "SUMMARY_FILE", "RELEASEGEN_SELF_MODULE", "RELEASEGEN_SELF_REPO"} { + s.T().Setenv(k, "") + s.Require().NoError(os.Unsetenv(k)) + } + cfg, err := config.FromEnv() + s.Require().NoError(err) + s.Equal(".", cfg.RepoRoot) + s.Empty(cfg.SummaryFile) + s.Equal("releasegen", cfg.SelfReleaseModule) + s.Equal("c2fo/releasegen", cfg.SelfReleaseRepo) +} + +func (s *ConfigTestSuite) TestFromEnv_OverridesViaEnv() { + s.T().Setenv("REPO_ROOT", "/work/checkout") + s.T().Setenv("SUMMARY_FILE", "/tmp/summary.json") + s.T().Setenv("RELEASEGEN_SELF_MODULE", "internal-tool") + s.T().Setenv("RELEASEGEN_SELF_REPO", "myorg/myrepo") + cfg, err := config.FromEnv() + s.Require().NoError(err) + s.Equal("/work/checkout", cfg.RepoRoot) + s.Equal("/tmp/summary.json", cfg.SummaryFile) + s.Equal("internal-tool", cfg.SelfReleaseModule) + s.Equal("myorg/myrepo", cfg.SelfReleaseRepo) +} diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go new file mode 100644 index 0000000..dec23db --- /dev/null +++ b/internal/discovery/discovery.go @@ -0,0 +1,103 @@ +// Package discovery walks the repo for CHANGELOG.md files, applies the +// configured exclude list, derives module names from paths, and pairs each +// candidate with its module-specific most-recent tag. +package discovery + +import ( + "context" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/c2fo/releasegen/internal/vcs" +) + +// Candidate is a single changelog that has actually been modified since +// its module's most recent tag (or has no prior tag). +type Candidate struct { + Path string // repo-relative path to CHANGELOG.md + ModuleName string // directory of the changelog ("" for repo root) + LatestTag string // "" when there is no prior tag for this module +} + +// Discoverer pairs a vcs.Repo with discovery configuration. +type Discoverer struct { + repo vcs.Repo + excludeDirs []string +} + +// New returns a Discoverer that reads from repo and applies excludeDirs +// as path prefixes (each entry should already end with "/"). +func New(repo vcs.Repo, excludeDirs []string) *Discoverer { + return &Discoverer{repo: repo, excludeDirs: excludeDirs} +} + +// Find returns the list of candidate changelogs to release. +func (d *Discoverer) Find(ctx context.Context) ([]Candidate, error) { + paths, err := d.repo.AllChangelogPaths(ctx) + if err != nil { + return nil, fmt.Errorf("list changelog files: %w", err) + } + paths = RemoveExcluded(paths, d.excludeDirs) + paths = slices.Compact(paths) + + tags, err := d.repo.ReachableTags(ctx) + if err != nil { + return nil, fmt.Errorf("list reachable tags: %w", err) + } + + var candidates []Candidate + for _, p := range paths { + module := ModuleName(p) + latest := vcs.LatestTagForModule(tags, module) + modified, err := d.repo.IsChangelogModifiedSinceTag(ctx, p, latest) + if err != nil { + return nil, fmt.Errorf("check %s: %w", p, err) + } + if !modified { + continue + } + candidates = append(candidates, Candidate{ + Path: p, + ModuleName: module, + LatestTag: latest, + }) + } + return candidates, nil +} + +// ModuleName returns the module name (directory) for a given changelog path. +// Root-level files return "". +func ModuleName(changelogPath string) string { + dir := filepath.Dir(changelogPath) + if dir == "." { + return "" + } + return filepath.ToSlash(dir) +} + +// RemoveExcluded filters out paths whose directory matches any prefix in +// excludeDirs. Each excludeDir must be a directory path ending in "/". +func RemoveExcluded(paths, excludeDirs []string) []string { + if len(excludeDirs) == 0 { + return paths + } + out := make([]string, 0, len(paths)) + for _, p := range paths { + excluded := false + for _, ex := range excludeDirs { + if !strings.HasSuffix(ex, "/") { + ex += "/" + } + if strings.HasPrefix(p, ex) { + excluded = true + break + } + } + if !excluded { + out = append(out, p) + } + } + return out +} diff --git a/internal/discovery/discovery_test.go b/internal/discovery/discovery_test.go new file mode 100644 index 0000000..06fa870 --- /dev/null +++ b/internal/discovery/discovery_test.go @@ -0,0 +1,82 @@ +package discovery_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/discovery" + "github.com/c2fo/releasegen/internal/vcs" + vcsmocks "github.com/c2fo/releasegen/internal/vcs/mocks" +) + +type DiscoveryTestSuite struct { + suite.Suite +} + +func TestDiscoveryTestSuite(t *testing.T) { + suite.Run(t, new(DiscoveryTestSuite)) +} + +func (s *DiscoveryTestSuite) TestModuleName() { + tests := map[string]string{ + "CHANGELOG.md": "", + "dir/CHANGELOG.md": "dir", + "a/b/c/CHANGELOG.md": "a/b/c", + } + for in, want := range tests { + s.Equal(want, discovery.ModuleName(in), in) + } +} + +func (s *DiscoveryTestSuite) TestRemoveExcluded() { + in := []string{"a/CHANGELOG.md", "b/CHANGELOG.md", "c/CHANGELOG.md"} + s.Equal(in, discovery.RemoveExcluded(in, nil)) + s.Equal([]string{"b/CHANGELOG.md", "c/CHANGELOG.md"}, discovery.RemoveExcluded(in, []string{"a/"})) + s.Equal([]string{"b/CHANGELOG.md"}, discovery.RemoveExcluded(in, []string{"a", "c"})) +} + +func (s *DiscoveryTestSuite) TestFind_HappyPath() { + repo := vcsmocks.NewRepo(s.T()) + repo.EXPECT().AllChangelogPaths(mock.Anything). + Return([]string{"CHANGELOG.md", "sub/CHANGELOG.md", "vendored/CHANGELOG.md"}, nil) + repo.EXPECT().ReachableTags(mock.Anything).Return([]vcs.TagInfo{ + {Name: "v1.0.0", ModuleName: "", Date: 100}, + {Name: "sub/v0.1.0", ModuleName: "sub", Date: 50}, + }, nil) + repo.EXPECT().IsChangelogModifiedSinceTag(mock.Anything, "CHANGELOG.md", "v1.0.0").Return(false, nil) + repo.EXPECT().IsChangelogModifiedSinceTag(mock.Anything, "sub/CHANGELOG.md", "sub/v0.1.0").Return(true, nil) + + d := discovery.New(repo, []string{"vendored/"}) + got, err := d.Find(context.Background()) + s.Require().NoError(err) + s.Len(got, 1) + s.Equal("sub/CHANGELOG.md", got[0].Path) + s.Equal("sub", got[0].ModuleName) + s.Equal("sub/v0.1.0", got[0].LatestTag) +} + +func (s *DiscoveryTestSuite) TestFind_FirstReleaseWhenNoTag() { + repo := vcsmocks.NewRepo(s.T()) + repo.EXPECT().AllChangelogPaths(mock.Anything).Return([]string{"new/CHANGELOG.md"}, nil) + repo.EXPECT().ReachableTags(mock.Anything).Return(nil, nil) + repo.EXPECT().IsChangelogModifiedSinceTag(mock.Anything, "new/CHANGELOG.md", "").Return(true, nil) + + d := discovery.New(repo, nil) + got, err := d.Find(context.Background()) + s.Require().NoError(err) + s.Len(got, 1) + s.Empty(got[0].LatestTag) +} + +func (s *DiscoveryTestSuite) TestFind_PropagatesErrors() { + repo := vcsmocks.NewRepo(s.T()) + repo.EXPECT().AllChangelogPaths(mock.Anything).Return(nil, errors.New("boom")) + + d := discovery.New(repo, nil) + _, err := d.Find(context.Background()) + s.Require().Error(err) +} diff --git a/internal/forge/forge.go b/internal/forge/forge.go new file mode 100644 index 0000000..69667ad --- /dev/null +++ b/internal/forge/forge.go @@ -0,0 +1,29 @@ +// Package forge abstracts the "publish a release" operation against a code +// hosting provider. The default implementation (GitHubReleaser) targets the +// GitHub REST API; the interface keeps the runner provider-agnostic. +package forge + +import ( + "context" + "errors" +) + +// ErrForge is the sentinel returned (wrapped) when a release-hosting +// operation fails. Callers should use errors.Is to branch on it. +var ErrForge = errors.New("forge error") + +//go:generate mockery + +// CreateReleaseOptions describes a single release publication. +type CreateReleaseOptions struct { + Owner string + Repo string + TagName string // e.g. "v1.2.3" or "module/v1.2.3" + Name string // human-readable release title + Body string // release notes (markdown) +} + +// Releaser is the abstraction for publishing a release. +type Releaser interface { + CreateRelease(ctx context.Context, opts CreateReleaseOptions) error +} diff --git a/internal/forge/github.go b/internal/forge/github.go new file mode 100644 index 0000000..15384a9 --- /dev/null +++ b/internal/forge/github.go @@ -0,0 +1,39 @@ +package forge + +import ( + "context" + "fmt" + + "github.com/google/go-github/v68/github" +) + +// GitHubReleaser publishes releases against the public GitHub API. +type GitHubReleaser struct { + client *github.Client +} + +// NewGitHubReleaser returns a Releaser configured with the supplied bearer +// token (typically a GitHub App installation token). +func NewGitHubReleaser(token string) *GitHubReleaser { + return &GitHubReleaser{client: github.NewClient(nil).WithAuthToken(token)} +} + +// NewGitHubReleaserFromClient is a test-friendly constructor that accepts a +// pre-configured *github.Client. Production code should prefer +// NewGitHubReleaser; tests can swap in a client pointed at httptest. +func NewGitHubReleaserFromClient(client *github.Client) *GitHubReleaser { + return &GitHubReleaser{client: client} +} + +// CreateRelease creates a GitHub Release. +func (g *GitHubReleaser) CreateRelease(ctx context.Context, opts CreateReleaseOptions) error { + release := &github.RepositoryRelease{ + TagName: github.Ptr(opts.TagName), + Name: github.Ptr(opts.Name), + Body: github.Ptr(opts.Body), + } + if _, _, err := g.client.Repositories.CreateRelease(ctx, opts.Owner, opts.Repo, release); err != nil { + return fmt.Errorf("%w: create GitHub release %s: %w", ErrForge, opts.TagName, err) + } + return nil +} diff --git a/internal/forge/github_test.go b/internal/forge/github_test.go new file mode 100644 index 0000000..32bee22 --- /dev/null +++ b/internal/forge/github_test.go @@ -0,0 +1,101 @@ +package forge_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + + "github.com/google/go-github/v68/github" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/forge" +) + +type GitHubReleaserTestSuite struct { + suite.Suite +} + +func TestGitHubReleaserTestSuite(t *testing.T) { + suite.Run(t, new(GitHubReleaserTestSuite)) +} + +func (s *GitHubReleaserTestSuite) TestNewGitHubReleaser_ConstructsClient() { + // The production constructor wires up a real *github.Client with token + // auth. We can't easily observe the bearer token without hitting the + // network, but we can verify the constructor returns a usable releaser + // whose CreateRelease will at least dispatch a request. + r := forge.NewGitHubReleaser("test-token") + s.Require().NotNil(r) +} + +// newReleaserAgainst returns a GitHubReleaser configured to talk to ts. +func (s *GitHubReleaserTestSuite) newReleaserAgainst(ts *httptest.Server) *forge.GitHubReleaser { + base, err := url.Parse(ts.URL + "/") + s.Require().NoError(err) + client := github.NewClient(ts.Client()).WithAuthToken("test-token") + client.BaseURL = base + return forge.NewGitHubReleaserFromClient(client) +} + +func (s *GitHubReleaserTestSuite) TestCreateRelease_HappyPath() { + var ( + gotAuth atomic.Value + gotBody atomic.Value + hits atomic.Int32 + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + gotAuth.Store(r.Header.Get("Authorization")) + body, _ := io.ReadAll(r.Body) + var rel github.RepositoryRelease + if err := json.Unmarshal(body, &rel); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + gotBody.Store(rel) + if r.Method != http.MethodPost || r.URL.Path != "/repos/owner/repo/releases" { + http.Error(w, "unexpected request", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":1,"tag_name":"v1.2.3"}`)) + })) + defer ts.Close() + + r := s.newReleaserAgainst(ts) + err := r.CreateRelease(context.Background(), forge.CreateReleaseOptions{ + Owner: "owner", + Repo: "repo", + TagName: "v1.2.3", + Name: "[v1.2.3] - 2026-04-19", + Body: "### Added\n- thing", + }) + s.Require().NoError(err) + s.Equal(int32(1), hits.Load()) + auth, _ := gotAuth.Load().(string) + s.Contains(auth, "Bearer test-token") + rel, _ := gotBody.Load().(github.RepositoryRelease) + s.Equal("v1.2.3", rel.GetTagName()) + s.Equal("[v1.2.3] - 2026-04-19", rel.GetName()) + s.Contains(rel.GetBody(), "thing") +} + +func (s *GitHubReleaserTestSuite) TestCreateRelease_ServerError_WrapsErrForge() { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"message":"validation failed"}`, http.StatusUnprocessableEntity) + })) + defer ts.Close() + + r := s.newReleaserAgainst(ts) + err := r.CreateRelease(context.Background(), forge.CreateReleaseOptions{ + Owner: "owner", Repo: "repo", TagName: "v1.2.3", + }) + s.Require().Error(err) + s.Require().ErrorIs(err, forge.ErrForge) + s.Contains(err.Error(), "v1.2.3") +} diff --git a/internal/forge/mocks/mocks.go b/internal/forge/mocks/mocks.go new file mode 100644 index 0000000..c5d9e00 --- /dev/null +++ b/internal/forge/mocks/mocks.go @@ -0,0 +1,96 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/c2fo/releasegen/internal/forge" + mock "github.com/stretchr/testify/mock" +) + +// NewReleaser creates a new instance of Releaser. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewReleaser(t interface { + mock.TestingT + Cleanup(func()) +}) *Releaser { + mock := &Releaser{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Releaser is an autogenerated mock type for the Releaser type +type Releaser struct { + mock.Mock +} + +type Releaser_Expecter struct { + mock *mock.Mock +} + +func (_m *Releaser) EXPECT() *Releaser_Expecter { + return &Releaser_Expecter{mock: &_m.Mock} +} + +// CreateRelease provides a mock function for the type Releaser +func (_mock *Releaser) CreateRelease(ctx context.Context, opts forge.CreateReleaseOptions) error { + ret := _mock.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for CreateRelease") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, forge.CreateReleaseOptions) error); ok { + r0 = returnFunc(ctx, opts) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Releaser_CreateRelease_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRelease' +type Releaser_CreateRelease_Call struct { + *mock.Call +} + +// CreateRelease is a helper method to define mock.On call +// - ctx context.Context +// - opts forge.CreateReleaseOptions +func (_e *Releaser_Expecter) CreateRelease(ctx interface{}, opts interface{}) *Releaser_CreateRelease_Call { + return &Releaser_CreateRelease_Call{Call: _e.mock.On("CreateRelease", ctx, opts)} +} + +func (_c *Releaser_CreateRelease_Call) Run(run func(ctx context.Context, opts forge.CreateReleaseOptions)) *Releaser_CreateRelease_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 forge.CreateReleaseOptions + if args[1] != nil { + arg1 = args[1].(forge.CreateReleaseOptions) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Releaser_CreateRelease_Call) Return(err error) *Releaser_CreateRelease_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Releaser_CreateRelease_Call) RunAndReturn(run func(ctx context.Context, opts forge.CreateReleaseOptions) error) *Releaser_CreateRelease_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..b11cd27 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,132 @@ +// Package logging provides a slog logger configured for releasegen. +// +// Two output modes are supported: +// +// - GitHub Actions mode (the default when GITHUB_ACTIONS=true is set): +// records at ERROR level are prefixed with "::error::" so they surface +// in the Actions UI, and the Group / EndGroup helpers emit the +// "::group::" / "::endgroup::" markers around per-module work. +// - Local mode: a plain text handler suitable for terminal use. +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "strings" +) + +// Options controls New. +type Options struct { + // Writer is the destination for log records. If nil, os.Stderr is used. + Writer io.Writer + // Level is the minimum log level. Defaults to LevelInfo. + Level slog.Level + // CI, when true, formats records for GitHub Actions (::error::, + // ::group::, ::endgroup:: markers). New does not auto-detect this; + // callers should set it explicitly, typically via DetectCI(). + CI bool +} + +// New constructs a *slog.Logger using the supplied options. +func New(opts Options) *slog.Logger { + if opts.Writer == nil { + opts.Writer = os.Stderr + } + handler := &actionsHandler{ + w: opts.Writer, + level: opts.Level, + ci: opts.CI, + } + return slog.New(handler) +} + +// DetectCI returns true when running inside GitHub Actions. +func DetectCI() bool { + return strings.EqualFold(os.Getenv("GITHUB_ACTIONS"), "true") +} + +// Group prints a "::group::" marker (in CI mode) or a section header (locally). +// Write errors are intentionally ignored: log helpers must not fail the run. +func Group(w io.Writer, ci bool, title string) { + if ci { + _, _ = fmt.Fprintf(w, "::group::%s\n", title) + } else { + _, _ = fmt.Fprintf(w, "==> %s\n", title) + } +} + +// EndGroup prints the matching "::endgroup::" marker. Write errors are +// intentionally ignored: log helpers must not fail the run. +func EndGroup(w io.Writer, ci bool) { + if ci { + _, _ = fmt.Fprintln(w, "::endgroup::") + } +} + +// actionsHandler is a small slog.Handler that emits a single line per record +// and prefixes errors with "::error::" when running in GitHub Actions. +type actionsHandler struct { + w io.Writer + level slog.Level + ci bool + attrs []slog.Attr + group string +} + +func (h *actionsHandler) Enabled(_ context.Context, lvl slog.Level) bool { + return lvl >= h.level +} + +func (h *actionsHandler) Handle(_ context.Context, r slog.Record) error { + var b strings.Builder + if h.ci && r.Level >= slog.LevelError { + b.WriteString("::error::") + } else { + b.WriteString(r.Level.String()) + b.WriteString(": ") + } + b.WriteString(r.Message) + for _, a := range h.attrs { + appendAttr(&b, a, h.group) + } + r.Attrs(func(a slog.Attr) bool { + appendAttr(&b, a, h.group) + return true + }) + b.WriteByte('\n') + _, err := io.WriteString(h.w, b.String()) + return err +} + +func (h *actionsHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + cloned := *h + cloned.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...) + return &cloned +} + +func (h *actionsHandler) WithGroup(name string) slog.Handler { + cloned := *h + if h.group == "" { + cloned.group = name + } else { + cloned.group = h.group + "." + name + } + return &cloned +} + +func appendAttr(b *strings.Builder, a slog.Attr, group string) { + if a.Equal(slog.Attr{}) { + return + } + b.WriteString(" ") + if group != "" { + b.WriteString(group) + b.WriteByte('.') + } + b.WriteString(a.Key) + b.WriteString("=") + b.WriteString(a.Value.String()) +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000..2196834 --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,125 @@ +package logging_test + +import ( + "bytes" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/logging" +) + +type LoggingTestSuite struct { + suite.Suite +} + +func TestLoggingTestSuite(t *testing.T) { + suite.Run(t, new(LoggingTestSuite)) +} + +func (s *LoggingTestSuite) TestErrorEmitsActionsMarkerInCI() { + var buf bytes.Buffer + log := logging.New(logging.Options{Writer: &buf, Level: slog.LevelDebug, CI: true}) + log.Error("boom", "module", "foo") + out := buf.String() + s.Contains(out, "::error::") + s.Contains(out, "boom") + s.Contains(out, "module=foo") +} + +func (s *LoggingTestSuite) TestInfoLocal() { + var buf bytes.Buffer + log := logging.New(logging.Options{Writer: &buf, Level: slog.LevelDebug, CI: false}) + log.Info("hi", "k", "v") + out := buf.String() + s.NotContains(out, "::error::") + s.Contains(out, "INFO: hi") + s.Contains(out, "k=v") +} + +func (s *LoggingTestSuite) TestLevelFiltering() { + var buf bytes.Buffer + log := logging.New(logging.Options{Writer: &buf, Level: slog.LevelWarn}) + log.Debug("hidden") + log.Info("hidden") + log.Warn("shown") + s.NotContains(buf.String(), "hidden") + s.Contains(buf.String(), "shown") +} + +func (s *LoggingTestSuite) TestWithAttrs_AttachesPersistentFields() { + var buf bytes.Buffer + base := logging.New(logging.Options{Writer: &buf, Level: slog.LevelDebug}) + scoped := base.With("module", "alpha", "request_id", "abc") + scoped.Info("processing") + out := buf.String() + s.Contains(out, "module=alpha") + s.Contains(out, "request_id=abc") + s.Contains(out, "processing") +} + +func (s *LoggingTestSuite) TestWithGroup_NamespacesAttrs() { + var buf bytes.Buffer + base := logging.New(logging.Options{Writer: &buf, Level: slog.LevelDebug}) + scoped := base.WithGroup("step") + scoped.Info("done", "name", "release") + out := buf.String() + s.Contains(out, "done") + s.Contains(out, "name=release") +} + +func (s *LoggingTestSuite) TestWithAttrs_PreservesCIErrorMarker() { + var buf bytes.Buffer + base := logging.New(logging.Options{Writer: &buf, Level: slog.LevelDebug, CI: true}) + scoped := base.With("module", "alpha") + scoped.Error("kapow") + out := buf.String() + s.Contains(out, "::error::") + s.Contains(out, "kapow") + s.Contains(out, "module=alpha") +} + +func (s *LoggingTestSuite) TestDetectCI() { + cases := []struct { + name string + value string + set bool + want bool + }{ + {name: "unset", set: false, want: false}, + {name: "empty", value: "", set: true, want: false}, + {name: "true", value: "true", set: true, want: true}, + {name: "TRUE mixed case", value: "TRUE", set: true, want: true}, + {name: "True", value: "True", set: true, want: true}, + {name: "false", value: "false", set: true, want: false}, + {name: "1 is not true", value: "1", set: true, want: false}, + } + for _, tc := range cases { + s.Run(tc.name, func() { + if tc.set { + s.T().Setenv("GITHUB_ACTIONS", tc.value) + } else { + s.T().Setenv("GITHUB_ACTIONS", "") + s.Require().NoError(os.Unsetenv("GITHUB_ACTIONS")) + } + s.Equal(tc.want, logging.DetectCI()) + }) + } +} + +func (s *LoggingTestSuite) TestGroupMarkers() { + var buf bytes.Buffer + logging.Group(&buf, true, "title") + logging.EndGroup(&buf, true) + out := buf.String() + s.Contains(out, "::group::title") + s.Contains(out, "::endgroup::") + + buf.Reset() + logging.Group(&buf, false, "title") + logging.EndGroup(&buf, false) + s.Contains(buf.String(), "==> title") + s.NotContains(buf.String(), "::group::") +} diff --git a/internal/runner/runner.go b/internal/runner/runner.go new file mode 100644 index 0000000..b286ac1 --- /dev/null +++ b/internal/runner/runner.go @@ -0,0 +1,266 @@ +// Package runner orchestrates the per-module release pipeline: +// discover -> rewrite -> commit/tag/push -> publish. +package runner + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "github.com/c2fo/releasegen/internal/changelog" + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/discovery" + "github.com/c2fo/releasegen/internal/forge" + "github.com/c2fo/releasegen/internal/logging" + "github.com/c2fo/releasegen/internal/vcs" +) + +// Status describes the outcome for a single module. +type Status string + +// Module pipeline outcomes. +const ( + StatusReleased Status = "released" + StatusSkipped Status = "skipped" + StatusFailed Status = "failed" + StatusDryRun Status = "dry-run" +) + +// ModuleResult captures the outcome of a single module's pipeline. +type ModuleResult struct { + Module string `json:"module"` + ChangelogPath string `json:"changelog_path"` + Status Status `json:"status"` + NextVersion string `json:"next_version,omitempty"` + ReleaseName string `json:"release_name,omitempty"` + Bump string `json:"bump,omitempty"` + Manual bool `json:"manual,omitempty"` + Error string `json:"error,omitempty"` +} + +// Summary is the aggregated outcome of an entire run. +type Summary struct { + Modules []ModuleResult `json:"modules"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + ReleaseGenVersion string `json:"releasegen_version,omitempty"` + ReleaseGenReleased bool `json:"releasegen_released"` +} + +// Runner orchestrates a single invocation. +type Runner struct { + cfg *config.Config + repo vcs.Repo + releaser forge.Releaser + discoverer *discovery.Discoverer + log *slog.Logger + now func() time.Time + ci bool + stderr io.Writer +} + +// Options bundles Runner dependencies. +type Options struct { + Config *config.Config + Repo vcs.Repo + Releaser forge.Releaser + Logger *slog.Logger + Now func() time.Time + CI bool + Stderr io.Writer // for raw GitHub Actions group markers +} + +// New constructs a Runner. Config, Repo, and Releaser are required. Logger, +// Now, and Stderr are optional and default to a stderr logger, time.Now, and +// os.Stderr respectively, so a zero-valued Logger never causes a nil panic. +func New(opts Options) *Runner { + if opts.Now == nil { + opts.Now = time.Now + } + if opts.Stderr == nil { + opts.Stderr = os.Stderr + } + if opts.Logger == nil { + opts.Logger = logging.New(logging.Options{Writer: opts.Stderr}) + } + return &Runner{ + cfg: opts.Config, + repo: opts.Repo, + releaser: opts.Releaser, + discoverer: discovery.New(opts.Repo, opts.Config.ExcludeDirs), + log: opts.Logger, + now: opts.Now, + ci: opts.CI, + stderr: opts.Stderr, + } +} + +// Run executes the pipeline. It returns a Summary even when an error +// occurs partway through, so the caller can write a summary file or +// surface partial-success state. +// +// When SummaryFile is configured, it is written exactly once on every exit +// path (success, per-module failure, discovery failure, context cancel) so +// downstream automation can rely on its presence to inspect the run. +func (r *Runner) Run(ctx context.Context) (*Summary, error) { + summary := &Summary{StartedAt: r.now()} + defer func() { + summary.FinishedAt = r.now() + if r.cfg.SummaryFile == "" { + return + } + if err := writeSummary(r.cfg.SummaryFile, summary); err != nil { + r.log.Warn("failed to write summary file", "path", r.cfg.SummaryFile, "err", err.Error()) + } + }() + + logging.Group(r.stderr, r.ci, "Discovering modified changelog files") + candidates, err := r.discoverer.Find(ctx) + logging.EndGroup(r.stderr, r.ci) + if err != nil { + return summary, fmt.Errorf("discover: %w", err) + } + r.log.Info("discovered changelogs", "count", len(candidates)) + + for _, c := range candidates { + if ctxErr := ctx.Err(); ctxErr != nil { + return summary, ctxErr + } + res, modErr := r.processModule(ctx, c) + summary.Modules = append(summary.Modules, res) + if modErr != nil { + return summary, fmt.Errorf("module %s: %w", res.Module, modErr) + } + if r.isSelfRelease(c.ModuleName) && res.Status == StatusReleased { + summary.ReleaseGenReleased = true + summary.ReleaseGenVersion = res.NextVersion + } + } + + return summary, nil +} + +// processModule runs the per-module pipeline. The error returned via +// res.err preserves wrapping (errors.Is/As work) while res.Error is the +// human-readable form for the JSON summary. +func (r *Runner) processModule(ctx context.Context, c discovery.Candidate) (ModuleResult, error) { + now := r.now() + res := ModuleResult{ + Module: c.ModuleName, + ChangelogPath: c.Path, + } + + logging.Group(r.stderr, r.ci, "Handling "+c.Path) + defer logging.EndGroup(r.stderr, r.ci) + + abs, err := filepath.Abs(filepath.Join(r.cfg.RepoRoot, c.Path)) + if err != nil { + return r.fail(res, fmt.Errorf("absolute path: %w", err)) + } + + content, err := os.ReadFile(abs) //nolint:gosec // path comes from repo discovery + if err != nil { + return r.fail(res, fmt.Errorf("read changelog: %w", err)) + } + + upd, err := changelog.Update(changelog.UpdateRequest{ + Content: string(content), + ModuleName: c.ModuleName, + OwnerRepo: r.cfg.OwnerRepo, + CustomTypes: r.cfg.CustomTypes, + ManualVersion: strings.TrimPrefix(r.cfg.ManualVersion, "v"), + ManualReason: r.cfg.Reason, + Actor: r.cfg.Actor, + Now: now, + }) + if errors.Is(err, changelog.ErrNoChangesDetected) { + res.Status = StatusSkipped + r.log.Info("skipping module, no changes detected", "module", c.ModuleName) + return res, nil + } + if err != nil { + return r.fail(res, err) + } + + res.NextVersion = upd.NextVersion + res.Bump = upd.Bump.String() + res.Manual = upd.Manual + res.ReleaseName = changelog.ReleaseName(c.ModuleName, upd.NextVersion) + + if r.cfg.DryRun { + r.log.Info( + "dry-run: would release", + "module", c.ModuleName, + "next_version", upd.NextVersion, + "bump", upd.Bump.String(), + ) + res.Status = StatusDryRun + return res, nil + } + + if err := os.WriteFile(abs, []byte(upd.NewContent), 0o600); err != nil { + return r.fail(res, fmt.Errorf("write changelog: %w", err)) + } + + if err := r.repo.CommitTagAndPush(ctx, vcs.CommitTagPushOptions{ + ChangelogPath: c.Path, + ModuleName: c.ModuleName, + Version: upd.NextVersion, + Actor: r.cfg.Actor, + Token: r.cfg.Token, + }); err != nil { + return r.fail(res, err) + } + + if err := r.releaser.CreateRelease(ctx, forge.CreateReleaseOptions{ + Owner: r.cfg.Owner(), + Repo: r.cfg.Repo(), + TagName: res.ReleaseName, + Name: fmt.Sprintf("[%s] - %s", res.ReleaseName, now.Format("2006-01-02")), + Body: upd.UnreleasedSection, + }); err != nil { + return r.fail(res, err) + } + + res.Status = StatusReleased + r.log.Info( + "released", + "module", c.ModuleName, + "version", upd.NextVersion, + "release", res.ReleaseName, + ) + return res, nil +} + +// fail records a failure on the result and returns the wrapped error so the +// caller can propagate it (preserving errors.Is/As targets). +func (r *Runner) fail(res ModuleResult, err error) (ModuleResult, error) { + res.Status = StatusFailed + res.Error = err.Error() + return res, err +} + +// isSelfRelease reports whether the named module is the releasegen module +// inside the configured self-release repository. +func (r *Runner) isSelfRelease(module string) bool { + if r.cfg.SelfReleaseModule == "" || r.cfg.SelfReleaseRepo == "" { + return false + } + return module == r.cfg.SelfReleaseModule && + strings.EqualFold(r.cfg.OwnerRepo, r.cfg.SelfReleaseRepo) +} + +func writeSummary(path string, s *Summary) error { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go new file mode 100644 index 0000000..6caf085 --- /dev/null +++ b/internal/runner/runner_test.go @@ -0,0 +1,224 @@ +package runner_test + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/config" + "github.com/c2fo/releasegen/internal/forge" + forgemocks "github.com/c2fo/releasegen/internal/forge/mocks" + "github.com/c2fo/releasegen/internal/runner" + "github.com/c2fo/releasegen/internal/vcs" + vcsmocks "github.com/c2fo/releasegen/internal/vcs/mocks" +) + +type RunnerTestSuite struct { + suite.Suite + tmpDir string + repo *vcsmocks.Repo + releaser *forgemocks.Releaser + cfg *config.Config + now time.Time +} + +func TestRunnerTestSuite(t *testing.T) { + suite.Run(t, new(RunnerTestSuite)) +} + +func (s *RunnerTestSuite) SetupTest() { + s.tmpDir = s.T().TempDir() + s.now = time.Date(2026, 4, 19, 0, 0, 0, 0, time.UTC) + s.repo = vcsmocks.NewRepo(s.T()) + s.releaser = forgemocks.NewReleaser(s.T()) + s.cfg = &config.Config{ + Token: "tok", + OwnerRepo: "owner/repo", + Actor: "tester", + Branch: "main", + RepoRoot: s.tmpDir, + SelfReleaseModule: "releasegen", + SelfReleaseRepo: "c2fo/releasegen", + } +} + +// stageChangelog creates a changelog under tmpDir at relPath and registers +// the discovery expectations on the mock repo. It always reports the file +// as modified since the (absent) latest tag, since the runner-level tests +// always exercise the "needs release" path; the no-changes branch is +// covered by changelog-package tests. +func (s *RunnerTestSuite) stageChangelog(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)) + s.repo.EXPECT().AllChangelogPaths(mock.Anything).Return([]string{relPath}, nil).Once() + s.repo.EXPECT().ReachableTags(mock.Anything).Return(nil, nil).Once() + s.repo.EXPECT(). + IsChangelogModifiedSinceTag(mock.Anything, relPath, ""). + Return(true, nil).Once() +} + +func (s *RunnerTestSuite) newRunner() *runner.Runner { + return runner.New(runner.Options{ + Config: s.cfg, + Repo: s.repo, + Releaser: s.releaser, + Logger: slog.New(slog.DiscardHandler), + Now: func() time.Time { return s.now }, + Stderr: nil, + }) +} + +func (s *RunnerTestSuite) TestHappyPath_SingleModuleReleased() { + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Added +- new feature + +## [v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.MatchedBy(func(o vcs.CommitTagPushOptions) bool { + return o.Version == "1.1.0" && o.ChangelogPath == "CHANGELOG.md" + })).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.MatchedBy(func(o forge.CreateReleaseOptions) bool { + return o.TagName == "v1.1.0" && o.Owner == "owner" && o.Repo == "repo" + })).Return(nil).Once() + + sum, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + s.Require().Len(sum.Modules, 1) + res := sum.Modules[0] + s.Equal(runner.StatusReleased, res.Status) + s.Equal("1.1.0", res.NextVersion) + s.Equal("v1.1.0", res.ReleaseName) +} + +func (s *RunnerTestSuite) TestNewDefaultsLoggerWhenNil() { + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Added +- new feature + +## [v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.Anything).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.Anything).Return(nil).Once() + + // A nil Logger must not cause a nil-pointer panic; New should default it. + r := runner.New(runner.Options{ + Config: s.cfg, + Repo: s.repo, + Releaser: s.releaser, + Logger: nil, + Now: func() time.Time { return s.now }, + Stderr: io.Discard, + }) + sum, err := r.Run(context.Background()) + s.Require().NoError(err) + s.Require().Len(sum.Modules, 1) + s.Equal(runner.StatusReleased, sum.Modules[0].Status) +} + +func (s *RunnerTestSuite) TestSkipsModuleWithEmptyUnreleased() { + s.stageChangelog("sub/CHANGELOG.md", `## [Unreleased] + +## [sub/v0.1.0] - 2024-01-01 +`) + + sum, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + s.Require().Len(sum.Modules, 1) + s.Equal(runner.StatusSkipped, sum.Modules[0].Status) +} + +func (s *RunnerTestSuite) TestDryRunDoesNotCommitOrPublish() { + s.cfg.DryRun = true + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Fixed +- bug +## [v1.0.0] - 2024-01-01 +`) + + sum, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + s.Equal(runner.StatusDryRun, sum.Modules[0].Status) + s.Equal("1.0.1", sum.Modules[0].NextVersion) + + got, err := os.ReadFile(filepath.Join(s.tmpDir, "CHANGELOG.md")) + s.Require().NoError(err) + s.Contains(string(got), "## [Unreleased]\n### Fixed") +} + +func (s *RunnerTestSuite) TestForgeFailureBubblesAsModuleFailure() { + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Added +- x +## [v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.Anything).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.Anything). + Return(errors.New("api down")).Once() + + sum, err := s.newRunner().Run(context.Background()) + s.Require().Error(err) + s.Require().Len(sum.Modules, 1) + s.Equal(runner.StatusFailed, sum.Modules[0].Status) + s.Contains(sum.Modules[0].Error, "api down") +} + +func (s *RunnerTestSuite) TestManualVersionIsHonored() { + s.cfg.ManualVersion = "v9.9.9" + s.cfg.Reason = "hotfix" + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Added +- x +## [v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.Anything).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.Anything).Return(nil).Once() + + sum, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + s.Equal("9.9.9", sum.Modules[0].NextVersion) + s.True(sum.Modules[0].Manual) +} + +func (s *RunnerTestSuite) TestSummaryFileWritten() { + out := filepath.Join(s.tmpDir, "summary.json") + s.cfg.SummaryFile = out + s.stageChangelog("CHANGELOG.md", `## [Unreleased] +### Fixed +- x +## [v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.Anything).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.Anything).Return(nil).Once() + + _, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + data, err := os.ReadFile(out) //nolint:gosec // path written by the same test + s.Require().NoError(err) + s.Contains(string(data), `"next_version": "1.0.1"`) +} + +func (s *RunnerTestSuite) TestReleaseGenSelfReleaseTracked() { + s.cfg.OwnerRepo = "c2fo/releasegen" + s.stageChangelog("releasegen/CHANGELOG.md", `## [Unreleased] +### Added +- x +## [releasegen/v1.0.0] - 2024-01-01 +`) + s.repo.EXPECT().CommitTagAndPush(mock.Anything, mock.Anything).Return(nil).Once() + s.releaser.EXPECT().CreateRelease(mock.Anything, mock.Anything).Return(nil).Once() + + sum, err := s.newRunner().Run(context.Background()) + s.Require().NoError(err) + s.True(sum.ReleaseGenReleased) + s.Equal("1.1.0", sum.ReleaseGenVersion) +} diff --git a/internal/vcs/extract_module_name_internal_test.go b/internal/vcs/extract_module_name_internal_test.go new file mode 100644 index 0000000..fffd820 --- /dev/null +++ b/internal/vcs/extract_module_name_internal_test.go @@ -0,0 +1,41 @@ +package vcs + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ExtractModuleNameTestSuite struct { + suite.Suite +} + +func TestExtractModuleNameTestSuite(t *testing.T) { + suite.Run(t, new(ExtractModuleNameTestSuite)) +} + +func (s *ExtractModuleNameTestSuite) TestExtractModuleName() { + cases := []struct { + name string + tag string + want string + }{ + {name: "root tag", tag: "v1.2.3", want: ""}, + {name: "root tag without v prefix is not a release tag", tag: "1.2.3", want: ""}, + {name: "single-segment module", tag: "mod/v1.2.3", want: "mod"}, + {name: "multi-segment module", tag: "a/b/v1.2.3", want: "a/b"}, + {name: "deeply nested module", tag: "contrib/backend/dropbox/v0.1.0", want: "contrib/backend/dropbox"}, + {name: "module name containing v", tag: "vfsevents/v2.0.0", want: "vfsevents"}, + {name: "no slash v separator", tag: "release-2026-01-01", want: ""}, + {name: "empty string", tag: "", want: ""}, + {name: "module name with trailing slash before v", tag: "mod/v", want: "mod"}, + {name: "pre-release qualifier", tag: "mod/v1.2.3-rc.1", want: "mod"}, + {name: "build metadata", tag: "mod/v1.2.3+build.5", want: "mod"}, + {name: "module path containing a v segment uses last /v", tag: "vendor/v2/v1.0.0", want: "vendor/v2"}, + } + for _, tc := range cases { + s.Run(tc.name, func() { + s.Equal(tc.want, extractModuleName(tc.tag)) + }) + } +} diff --git a/internal/vcs/git.go b/internal/vcs/git.go new file mode 100644 index 0000000..29548ee --- /dev/null +++ b/internal/vcs/git.go @@ -0,0 +1,320 @@ +package vcs + +import ( + "context" + "errors" + "fmt" + "log/slog" + "path" + "sort" + "strings" + "time" + + "github.com/go-git/go-git/v5" + "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" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +const changelogFileName = "CHANGELOG.md" + +// GitRepo is a Repo implementation backed by go-git operating on an on-disk +// repository. +type GitRepo struct { + repo *git.Repository + branch string + log *slog.Logger +} + +// Open opens the git repository at the given path and returns a GitRepo +// configured for the named release branch. +func Open(repoPath, branch string, log *slog.Logger) (*GitRepo, error) { + if log == nil { + log = slog.Default() + } + r, err := git.PlainOpen(repoPath) + if err != nil { + return nil, fmt.Errorf("%w: open repository at %q: %w", ErrVCS, repoPath, err) + } + return &GitRepo{repo: r, branch: branch, log: log}, nil +} + +// AllChangelogPaths walks HEAD's tree and returns every CHANGELOG.md path. +func (g *GitRepo) AllChangelogPaths(ctx context.Context) ([]string, error) { + if err := ctx.Err(); 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) + } + tree, err := headCommit.Tree() + if err != nil { + return nil, fmt.Errorf("%w: load HEAD tree: %w", ErrVCS, err) + } + + var paths []string + err = tree.Files().ForEach(func(f *object.File) error { + // Match only files literally named CHANGELOG.md so we do not pick up + // neighbors like MYCHANGELOG.md or release-CHANGELOG.md. go-git + // reports tree entries with forward slashes regardless of host OS, + // so path.Base (not filepath.Base) is the right tool. + if path.Base(f.Name) == changelogFileName { + paths = append(paths, f.Name) + } + return nil + }) + if err != nil { + return nil, fmt.Errorf("%w: walk HEAD tree: %w", ErrVCS, err) + } + return paths, nil +} + +// ReachableTags returns all tags whose target commits are ancestors of the +// configured release branch tip. +func (g *GitRepo) ReachableTags(ctx context.Context) ([]TagInfo, error) { + branchRef, err := g.repo.Reference(plumbing.NewBranchReferenceName(g.branch), true) + if err != nil { + return nil, fmt.Errorf("%w: resolve branch %q: %w", ErrVCS, g.branch, err) + } + branchCommit, err := g.repo.CommitObject(branchRef.Hash()) + if err != nil { + return nil, fmt.Errorf("%w: load branch commit: %w", ErrVCS, err) + } + + tagsIter, err := g.repo.Tags() + if err != nil { + return nil, fmt.Errorf("%w: iterate tags: %w", ErrVCS, err) + } + + var tags []TagInfo + err = tagsIter.ForEach(func(ref *plumbing.Reference) error { + if err := ctx.Err(); err != nil { + return err + } + tagName := ref.Name().Short() + moduleName := extractModuleName(tagName) + + hash := ref.Hash() + var date int64 + + if obj, err := g.repo.TagObject(hash); err == nil { + hash = obj.Target + date = obj.Tagger.When.Unix() + } else { + c, err := g.repo.CommitObject(ref.Hash()) + if err != nil { + g.log.Debug("skipping tag, cannot resolve commit", "tag", tagName, "err", err) + return nil + } + date = c.Committer.When.Unix() + } + + commit, err := g.repo.CommitObject(hash) + if err != nil { + g.log.Debug("skipping tag, cannot load commit", "tag", tagName, "err", err) + return nil + } + ancestor, err := commit.IsAncestor(branchCommit) + if err != nil { + g.log.Debug("skipping tag, ancestor check failed", "tag", tagName, "err", err) + return nil + } + if !ancestor { + g.log.Debug("skipping tag, not reachable from branch", "tag", tagName, "branch", g.branch) + return nil + } + + tags = append(tags, TagInfo{ + Name: tagName, + ModuleName: moduleName, + Date: date, + Hash: hash, + }) + return nil + }) + if err != nil { + return nil, err + } + return tags, nil +} + +// IsChangelogModifiedSinceTag returns true if changelogPath was changed +// between the commit referenced by tagName and HEAD. When tagName is empty +// the function returns true (first release). +func (g *GitRepo) IsChangelogModifiedSinceTag(ctx context.Context, changelogPath, tagName string) (bool, error) { + if tagName == "" { + return true, nil + } + if err := ctx.Err(); err != nil { + return false, err + } + ref, err := g.repo.Reference(plumbing.NewTagReferenceName(tagName), true) + if err != nil { + return false, fmt.Errorf("%w: resolve tag %q: %w", ErrVCS, tagName, err) + } + + tagCommit, err := g.repo.CommitObject(ref.Hash()) + if err != nil { + tagObj, err := g.repo.TagObject(ref.Hash()) + if err != nil { + return false, fmt.Errorf("%w: resolve commit for tag %q: %w", ErrVCS, tagName, err) + } + tagCommit, err = g.repo.CommitObject(tagObj.Target) + if err != nil { + return false, fmt.Errorf("%w: resolve commit for annotated tag %q: %w", ErrVCS, tagName, err) + } + } + + headRef, err := g.repo.Head() + if err != nil { + return false, fmt.Errorf("%w: resolve HEAD: %w", ErrVCS, err) + } + headCommit, err := g.repo.CommitObject(headRef.Hash()) + if err != nil { + return false, fmt.Errorf("%w: load HEAD commit: %w", ErrVCS, err) + } + + // Compare only the changelog blob between the two commit trees instead of + // computing a full repo-wide patch. This is O(tree lookup) rather than + // O(repo size), which matters on large repositories. A differing hash + // (including the file being added or removed, represented by the zero + // hash) means the changelog changed between the tag and HEAD. + tagHash, err := changelogBlobHash(tagCommit, changelogPath) + if err != nil { + return false, fmt.Errorf("%w: read %s at tag %q: %w", ErrVCS, changelogPath, tagName, err) + } + headHash, err := changelogBlobHash(headCommit, changelogPath) + if err != nil { + return false, fmt.Errorf("%w: read %s at HEAD: %w", ErrVCS, changelogPath, err) + } + return tagHash != headHash, nil +} + +// changelogBlobHash returns the blob hash of changelogPath within the given +// commit's tree. When the file does not exist in that tree it returns the +// zero hash (so an added or removed file registers as a change). +func changelogBlobHash(c *object.Commit, changelogPath string) (plumbing.Hash, error) { + tree, err := c.Tree() + if err != nil { + return plumbing.ZeroHash, err + } + entry, err := tree.FindEntry(changelogPath) + if err != nil { + if errors.Is(err, object.ErrEntryNotFound) || errors.Is(err, object.ErrDirectoryNotFound) { + return plumbing.ZeroHash, nil + } + return plumbing.ZeroHash, err + } + return entry.Hash, 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. +func (g *GitRepo) CommitTagAndPush(ctx context.Context, opts CommitTagPushOptions) error { + if err := ctx.Err(); err != nil { + return err + } + wt, err := g.repo.Worktree() + if err != nil { + return fmt.Errorf("%w: worktree: %w", ErrVCS, err) + } + if _, err := wt.Add(opts.ChangelogPath); err != nil { + return fmt.Errorf("%w: git add %s: %w", ErrVCS, opts.ChangelogPath, err) + } + + commitMsg := fmt.Sprintf( + "chore: release version %s/v%s (%s) [skip ci]", + opts.ModuleName, opts.Version, opts.Actor, + ) + if opts.ModuleName == "" { + commitMsg = fmt.Sprintf( + "chore: release version v%s (%s) [skip ci]", + opts.Version, opts.Actor, + ) + } + + sig := &object.Signature{ + Name: "github-actions[bot]", + Email: "41898282+github-actions[bot]@users.noreply.github.com", + When: time.Now(), + } + if _, err := wt.Commit(commitMsg, &git.CommitOptions{Author: sig}); err != nil { + return fmt.Errorf("%w: git commit: %w", ErrVCS, err) + } + + headRef, err := g.repo.Head() + if err != nil { + return fmt.Errorf("%w: resolve HEAD after commit: %w", ErrVCS, err) + } + + auth := &http.BasicAuth{ + Username: "github-actions[bot]", + Password: opts.Token, + } + if err := g.repo.PushContext(ctx, &git.PushOptions{Auth: auth}); err != nil { + return fmt.Errorf("%w: git push: %w", ErrVCS, scrubURL(err, opts.Token)) + } + + tagName := opts.ModuleName + "/v" + opts.Version + if opts.ModuleName == "" { + tagName = "v" + opts.Version + } + if _, err := g.repo.CreateTag(tagName, headRef.Hash(), &git.CreateTagOptions{ + Tagger: sig, + Message: tagName, + }); err != nil { + return fmt.Errorf("%w: create tag %s: %w", ErrVCS, tagName, err) + } + if err := g.repo.PushContext(ctx, &git.PushOptions{ + Auth: auth, + RefSpecs: []config.RefSpec{config.RefSpec("refs/tags/" + tagName + ":refs/tags/" + tagName)}, + }); err != nil { + return fmt.Errorf("%w: push tag %s: %w", ErrVCS, tagName, scrubURL(err, opts.Token)) + } + return nil +} + +// extractModuleName returns "" for "v1.2.3", "mod" for "mod/v1.2.3", +// "a/b" for "a/b/v1.2.3", and "" for anything that does not contain "/v". +func extractModuleName(tagName string) string { + if idx := strings.LastIndex(tagName, "/v"); idx != -1 { + return tagName[:idx] + } + return "" +} + +// scrubURL removes the bearer token from go-git error messages that may +// embed the remote URL (which contains the basic-auth credentials). +func scrubURL(err error, token string) error { + if err == nil || token == "" { + return err + } + msg := err.Error() + if !strings.Contains(msg, token) { + return err + } + return fmt.Errorf("%s", strings.ReplaceAll(msg, token, "***")) +} + +// LatestTagForModule returns the most recent tag in tags belonging to the +// named module (empty name = root). It returns "" when no tag is found. +func LatestTagForModule(tags []TagInfo, moduleName string) string { + var matching []TagInfo + for _, t := range tags { + if t.ModuleName == moduleName { + matching = append(matching, t) + } + } + if len(matching) == 0 { + return "" + } + sort.Slice(matching, func(i, j int) bool { return matching[i].Date > matching[j].Date }) + return matching[0].Name +} diff --git a/internal/vcs/git_test.go b/internal/vcs/git_test.go new file mode 100644 index 0000000..119102e --- /dev/null +++ b/internal/vcs/git_test.go @@ -0,0 +1,184 @@ +package vcs_test + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + "time" + + "github.com/go-git/go-git/v5" + gitconfig "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/suite" + + "github.com/c2fo/releasegen/internal/vcs" +) + +type VCSTestSuite struct { + suite.Suite +} + +func TestVCSTestSuite(t *testing.T) { + suite.Run(t, new(VCSTestSuite)) +} + +// TestLatestTagForModule covers the pure tag-selection helper. +func (s *VCSTestSuite) TestLatestTagForModule() { + tags := []vcs.TagInfo{ + {Name: "mod-a/v1.0.0", Date: 1000, ModuleName: "mod-a"}, + {Name: "mod-b/v2.0.0", Date: 2000, ModuleName: "mod-b"}, + {Name: "mod-a/v1.1.0", Date: 1500, ModuleName: "mod-a"}, + {Name: "v0.1.0", Date: 500, ModuleName: ""}, + } + s.Equal("mod-a/v1.1.0", vcs.LatestTagForModule(tags, "mod-a")) + s.Equal("mod-b/v2.0.0", vcs.LatestTagForModule(tags, "mod-b")) + s.Equal("v0.1.0", vcs.LatestTagForModule(tags, "")) + s.Empty(vcs.LatestTagForModule(tags, "missing")) + s.Empty(vcs.LatestTagForModule(nil, "any")) +} + +// TestIntegration spins up a real git repo in a tmpdir, creates module +// changelogs with module-prefixed tags, and exercises AllChangelogPaths, +// ReachableTags, and IsChangelogModifiedSinceTag end-to-end. +func (s *VCSTestSuite) TestIntegration() { + 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) + } + + writeFile("CHANGELOG.md", "## [Unreleased]\n") + writeFile("submodule/CHANGELOG.md", "## [Unreleased]\n") + c1, err := wt.Commit("initial", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + + // Tag the root module at v1.0.0 reachable from main (HEAD). + _, err = repo.CreateTag("v1.0.0", c1, &git.CreateTagOptions{Tagger: sig, Message: "v1.0.0"}) + s.Require().NoError(err) + + // Modify the submodule changelog and add a brand-new module changelog + // that did not exist at the v1.0.0 tag. + writeFile("submodule/CHANGELOG.md", "## [Unreleased]\n### Added\n- new\n") + writeFile("newmod/CHANGELOG.md", "## [Unreleased]\n### Added\n- first\n") + _, err = wt.Commit("update sub", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + + // Branch reference must exist for ReachableTags to work; rename HEAD. + headRef, err := repo.Head() + s.Require().NoError(err) + branchName := headRef.Name().Short() // master or main depending on git version + + g, err := vcs.Open(dir, branchName, slog.New(slog.DiscardHandler)) + s.Require().NoError(err) + + ctx := context.Background() + + paths, err := g.AllChangelogPaths(ctx) + s.Require().NoError(err) + s.ElementsMatch([]string{"CHANGELOG.md", "submodule/CHANGELOG.md", "newmod/CHANGELOG.md"}, paths) + + tags, err := g.ReachableTags(ctx) + s.Require().NoError(err) + s.Len(tags, 1) + s.Equal("v1.0.0", tags[0].Name) + s.Empty(tags[0].ModuleName) + + rootMod, err := g.IsChangelogModifiedSinceTag(ctx, "CHANGELOG.md", "v1.0.0") + s.Require().NoError(err) + s.False(rootMod, "root changelog was not touched after v1.0.0") + + subMod, err := g.IsChangelogModifiedSinceTag(ctx, "submodule/CHANGELOG.md", "v1.0.0") + s.Require().NoError(err) + s.True(subMod, "submodule changelog was modified after v1.0.0") + + // A changelog that did not exist at the tag but exists at HEAD counts as + // modified (absent blob -> zero hash differs from the real blob). + addedMod, err := g.IsChangelogModifiedSinceTag(ctx, "newmod/CHANGELOG.md", "v1.0.0") + s.Require().NoError(err) + s.True(addedMod, "newmod changelog was added after v1.0.0") + + // A changelog that exists in neither tree is unchanged (both zero hash). + absent, err := g.IsChangelogModifiedSinceTag(ctx, "nonexistent/CHANGELOG.md", "v1.0.0") + s.Require().NoError(err) + s.False(absent, "a path absent from both trees is not a modification") + + // Initial release case (no tag) returns true. + first, err := g.IsChangelogModifiedSinceTag(ctx, "submodule/CHANGELOG.md", "") + s.Require().NoError(err) + s.True(first) +} + +// 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. +func (s *VCSTestSuite) TestCommitTagAndPush_PushedToBareRemote() { + bareDir := s.T().TempDir() + _, err := git.PlainInit(bareDir, true) + s.Require().NoError(err) + + workDir := s.T().TempDir() + repo, err := git.PlainInit(workDir, false) + s.Require().NoError(err) + _, err = repo.CreateRemote(&gitconfig.RemoteConfig{ + Name: "origin", + URLs: []string{bareDir}, + }) + s.Require().NoError(err) + + wt, err := repo.Worktree() + s.Require().NoError(err) + sig := &object.Signature{Name: "tester", Email: "t@example.com", When: time.Now()} + + cl := filepath.Join(workDir, "sub", "CHANGELOG.md") + s.Require().NoError(os.MkdirAll(filepath.Dir(cl), 0o750)) + s.Require().NoError(os.WriteFile(cl, []byte("## [Unreleased]\n"), 0o600)) + _, err = wt.Add("sub/CHANGELOG.md") + s.Require().NoError(err) + _, err = wt.Commit("seed", &git.CommitOptions{Author: sig}) + s.Require().NoError(err) + + headRef, err := repo.Head() + s.Require().NoError(err) + branchName := headRef.Name().Short() + + g, err := vcs.Open(workDir, branchName, slog.New(slog.DiscardHandler)) + s.Require().NoError(err) + + // Mutate the changelog so there is a diff to commit. + s.Require().NoError(os.WriteFile(cl, []byte("## [Unreleased]\n### Added\n- x\n"), 0o600)) + + err = g.CommitTagAndPush(context.Background(), vcs.CommitTagPushOptions{ + ChangelogPath: "sub/CHANGELOG.md", + ModuleName: "sub", + Version: "1.2.3", + Actor: "tester", + Token: "irrelevant-for-local-bare", + }) + s.Require().NoError(err) + + // Reopen the bare remote and confirm the tag landed. + bare, err := git.PlainOpen(bareDir) + s.Require().NoError(err) + tagRef, err := bare.Tag("sub/v1.2.3") + s.Require().NoError(err) + s.NotNil(tagRef) +} + +// TestCommitTagAndPush_OpenFailureWrapsErrVCS makes sure the sentinel is +// preserved when the underlying go-git call errors out. +func (s *VCSTestSuite) TestCommitTagAndPush_OpenFailureWrapsErrVCS() { + _, err := vcs.Open(filepath.Join(s.T().TempDir(), "does-not-exist"), "main", slog.New(slog.DiscardHandler)) + s.Require().Error(err) + s.Require().ErrorIs(err, vcs.ErrVCS) +} diff --git a/internal/vcs/mocks/mocks.go b/internal/vcs/mocks/mocks.go new file mode 100644 index 0000000..760b1dc --- /dev/null +++ b/internal/vcs/mocks/mocks.go @@ -0,0 +1,292 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + "github.com/c2fo/releasegen/internal/vcs" + mock "github.com/stretchr/testify/mock" +) + +// NewRepo creates a new instance of Repo. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewRepo(t interface { + mock.TestingT + Cleanup(func()) +}) *Repo { + mock := &Repo{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// Repo is an autogenerated mock type for the Repo type +type Repo struct { + mock.Mock +} + +type Repo_Expecter struct { + mock *mock.Mock +} + +func (_m *Repo) EXPECT() *Repo_Expecter { + return &Repo_Expecter{mock: &_m.Mock} +} + +// AllChangelogPaths provides a mock function for the type Repo +func (_mock *Repo) AllChangelogPaths(ctx context.Context) ([]string, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for AllChangelogPaths") + } + + var r0 []string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]string, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []string); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Repo_AllChangelogPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllChangelogPaths' +type Repo_AllChangelogPaths_Call struct { + *mock.Call +} + +// AllChangelogPaths is a helper method to define mock.On call +// - ctx context.Context +func (_e *Repo_Expecter) AllChangelogPaths(ctx interface{}) *Repo_AllChangelogPaths_Call { + return &Repo_AllChangelogPaths_Call{Call: _e.mock.On("AllChangelogPaths", ctx)} +} + +func (_c *Repo_AllChangelogPaths_Call) Run(run func(ctx context.Context)) *Repo_AllChangelogPaths_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Repo_AllChangelogPaths_Call) Return(strings []string, err error) *Repo_AllChangelogPaths_Call { + _c.Call.Return(strings, err) + return _c +} + +func (_c *Repo_AllChangelogPaths_Call) RunAndReturn(run func(ctx context.Context) ([]string, error)) *Repo_AllChangelogPaths_Call { + _c.Call.Return(run) + return _c +} + +// CommitTagAndPush provides a mock function for the type Repo +func (_mock *Repo) CommitTagAndPush(ctx context.Context, opts vcs.CommitTagPushOptions) error { + ret := _mock.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for CommitTagAndPush") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, vcs.CommitTagPushOptions) error); ok { + r0 = returnFunc(ctx, opts) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// Repo_CommitTagAndPush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitTagAndPush' +type Repo_CommitTagAndPush_Call struct { + *mock.Call +} + +// CommitTagAndPush is a helper method to define mock.On call +// - ctx context.Context +// - opts vcs.CommitTagPushOptions +func (_e *Repo_Expecter) CommitTagAndPush(ctx interface{}, opts interface{}) *Repo_CommitTagAndPush_Call { + return &Repo_CommitTagAndPush_Call{Call: _e.mock.On("CommitTagAndPush", ctx, opts)} +} + +func (_c *Repo_CommitTagAndPush_Call) Run(run func(ctx context.Context, opts vcs.CommitTagPushOptions)) *Repo_CommitTagAndPush_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 vcs.CommitTagPushOptions + if args[1] != nil { + arg1 = args[1].(vcs.CommitTagPushOptions) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *Repo_CommitTagAndPush_Call) Return(err error) *Repo_CommitTagAndPush_Call { + _c.Call.Return(err) + return _c +} + +func (_c *Repo_CommitTagAndPush_Call) RunAndReturn(run func(ctx context.Context, opts vcs.CommitTagPushOptions) error) *Repo_CommitTagAndPush_Call { + _c.Call.Return(run) + return _c +} + +// IsChangelogModifiedSinceTag provides a mock function for the type Repo +func (_mock *Repo) IsChangelogModifiedSinceTag(ctx context.Context, changelogPath string, tagName string) (bool, error) { + ret := _mock.Called(ctx, changelogPath, tagName) + + if len(ret) == 0 { + panic("no return value specified for IsChangelogModifiedSinceTag") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok { + return returnFunc(ctx, changelogPath, tagName) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = returnFunc(ctx, changelogPath, tagName) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, changelogPath, tagName) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Repo_IsChangelogModifiedSinceTag_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsChangelogModifiedSinceTag' +type Repo_IsChangelogModifiedSinceTag_Call struct { + *mock.Call +} + +// IsChangelogModifiedSinceTag is a helper method to define mock.On call +// - ctx context.Context +// - changelogPath string +// - tagName string +func (_e *Repo_Expecter) IsChangelogModifiedSinceTag(ctx interface{}, changelogPath interface{}, tagName interface{}) *Repo_IsChangelogModifiedSinceTag_Call { + return &Repo_IsChangelogModifiedSinceTag_Call{Call: _e.mock.On("IsChangelogModifiedSinceTag", ctx, changelogPath, tagName)} +} + +func (_c *Repo_IsChangelogModifiedSinceTag_Call) Run(run func(ctx context.Context, changelogPath string, tagName string)) *Repo_IsChangelogModifiedSinceTag_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *Repo_IsChangelogModifiedSinceTag_Call) Return(b bool, err error) *Repo_IsChangelogModifiedSinceTag_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *Repo_IsChangelogModifiedSinceTag_Call) RunAndReturn(run func(ctx context.Context, changelogPath string, tagName string) (bool, error)) *Repo_IsChangelogModifiedSinceTag_Call { + _c.Call.Return(run) + return _c +} + +// ReachableTags provides a mock function for the type Repo +func (_mock *Repo) ReachableTags(ctx context.Context) ([]vcs.TagInfo, error) { + ret := _mock.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for ReachableTags") + } + + var r0 []vcs.TagInfo + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context) ([]vcs.TagInfo, error)); ok { + return returnFunc(ctx) + } + if returnFunc, ok := ret.Get(0).(func(context.Context) []vcs.TagInfo); ok { + r0 = returnFunc(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]vcs.TagInfo) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = returnFunc(ctx) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// Repo_ReachableTags_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReachableTags' +type Repo_ReachableTags_Call struct { + *mock.Call +} + +// ReachableTags is a helper method to define mock.On call +// - ctx context.Context +func (_e *Repo_Expecter) ReachableTags(ctx interface{}) *Repo_ReachableTags_Call { + return &Repo_ReachableTags_Call{Call: _e.mock.On("ReachableTags", ctx)} +} + +func (_c *Repo_ReachableTags_Call) Run(run func(ctx context.Context)) *Repo_ReachableTags_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *Repo_ReachableTags_Call) Return(tagInfos []vcs.TagInfo, err error) *Repo_ReachableTags_Call { + _c.Call.Return(tagInfos, err) + return _c +} + +func (_c *Repo_ReachableTags_Call) RunAndReturn(run func(ctx context.Context) ([]vcs.TagInfo, error)) *Repo_ReachableTags_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/vcs/scrub_internal_test.go b/internal/vcs/scrub_internal_test.go new file mode 100644 index 0000000..4914d07 --- /dev/null +++ b/internal/vcs/scrub_internal_test.go @@ -0,0 +1,38 @@ +package vcs + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ScrubURLTestSuite struct { + suite.Suite +} + +func TestScrubURLTestSuite(t *testing.T) { + suite.Run(t, new(ScrubURLTestSuite)) +} + +func (s *ScrubURLTestSuite) TestNilError() { + s.NoError(scrubURL(nil, "tok")) +} + +func (s *ScrubURLTestSuite) TestEmptyTokenIsPassThrough() { + in := errors.New("https://x:tok@example.com failed") + s.Equal(in, scrubURL(in, "")) +} + +func (s *ScrubURLTestSuite) TestTokenAbsent_ReturnsOriginal() { + in := errors.New("plain failure with no secrets") + s.Equal(in, scrubURL(in, "tok")) +} + +func (s *ScrubURLTestSuite) TestTokenScrubbed() { + in := errors.New("auth failed for https://x:supersecret@example.com/repo.git") + out := scrubURL(in, "supersecret") + s.Require().Error(out) + s.NotContains(out.Error(), "supersecret") + s.Contains(out.Error(), "***") +} diff --git a/internal/vcs/vcs.go b/internal/vcs/vcs.go new file mode 100644 index 0000000..6e59bd0 --- /dev/null +++ b/internal/vcs/vcs.go @@ -0,0 +1,59 @@ +// Package vcs abstracts the version-control operations releasegen needs. +// +// The Repo interface is defined here in the consumer package per Go best +// practice. The default implementation is GitRepo (in git.go), which uses +// go-git. Tests use mockery-generated mocks under internal/vcs/mocks. +package vcs + +import ( + "context" + "errors" + + "github.com/go-git/go-git/v5/plumbing" +) + +// ErrVCS is the sentinel returned (wrapped) when any git-side operation +// (open, walk, commit, tag, push) fails. Callers should use errors.Is to +// branch on it for exit code mapping rather than scanning error strings. +var ErrVCS = errors.New("vcs error") + +//go:generate mockery + +// TagInfo describes a single tag reachable from the release branch. +type TagInfo struct { + Name string // e.g. "v1.2.3" or "module/v1.2.3" + ModuleName string // empty for root tags + Date int64 // unix seconds (commit or tagger date) + Hash plumbing.Hash // the commit the tag points at +} + +// CommitTagPushOptions describes a single per-module commit + tag + push. +type CommitTagPushOptions struct { + ChangelogPath string + ModuleName string + Version string // bare semver + Actor string + Token string // pushed via basic auth +} + +// Repo is the abstraction the runner uses to interact with git. It is +// intentionally tiny so it can be mocked easily. +type Repo interface { + // AllChangelogPaths returns every CHANGELOG.md file in HEAD's tree. + AllChangelogPaths(ctx context.Context) ([]string, error) + + // ReachableTags returns all tags whose commits are reachable from the + // configured release branch. + ReachableTags(ctx context.Context) ([]TagInfo, error) + + // IsChangelogModifiedSinceTag reports whether the file at changelogPath + // has been modified between the commit pointed at by tagName and HEAD. + // When tagName is empty (no prior tag) the function returns true. + IsChangelogModifiedSinceTag(ctx context.Context, changelogPath, tagName string) (bool, error) + + // CommitTagAndPush stages the changelog file, commits it with a + // "[skip ci]" message, pushes the commit, creates an annotated tag for + // the release name, and pushes the tag. It is intentionally a single + // operation so the implementation can attempt cleanup on failure. + CommitTagAndPush(ctx context.Context, opts CommitTagPushOptions) error +} diff --git a/releasegen.png b/releasegen.png new file mode 100644 index 0000000..6105a5c Binary files /dev/null and b/releasegen.png differ