diff --git a/.github/workflows/test-smart-release-please.yaml b/.github/workflows/test-smart-release-please.yaml deleted file mode 100644 index 0cc7128..0000000 --- a/.github/workflows/test-smart-release-please.yaml +++ /dev/null @@ -1,23 +0,0 @@ -name: Test smart release-please action - -on: - pull_request: - paths: - - 'actions/smart-release-please/**' - -jobs: - test-action: - runs-on: ubuntu-latest - steps: - - name: Checkout this repo - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.10' - - - name: Run smart release-please action tests - shell: bash - run: python3 test/test_rc_align.py -v - diff --git a/.gitignore b/.gitignore index b616f6d..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ node_modules/ -.secrets diff --git a/EDGE-CASE-TEST-RESULTS.md b/EDGE-CASE-TEST-RESULTS.md deleted file mode 100644 index b9c8f8b..0000000 --- a/EDGE-CASE-TEST-RESULTS.md +++ /dev/null @@ -1,440 +0,0 @@ -# Smart Release Please - Edge Case & Developer Error Testing - -**Test Repository:** https://github.com/MapColonies/multi-level-release-test -**Test Focus:** Realistic developer errors, edge cases, and boundary conditions -**Test Date:** January 29, 2026 -**Tester:** OpenCode AI - ---- - -## ๐ŸŽฏ Testing Philosophy - -This document covers **realistic edge cases** that could occur in actual development: -- Developer typos and mistakes -- Manual git operations gone wrong -- Concurrent workflow executions -- Unusual but valid commit formats -- Tag manipulation errors - ---- - -## ๐Ÿ“Š Executive Summary - -**Total Edge Case Tests:** 12 -**Passed:** โœ… 10 -**Failed:** โŒ 1 (Race condition) -**Warnings:** โš ๏ธ 2 (Ignored typos, manual tag confusion) -**Critical Issues Found:** ๐Ÿ”ด 1 - ---- - -## ๐Ÿงช Detailed Test Results - -### Category 1: Developer Typos in Conventional Commits - -#### **T-EDGE-1: Typo in Commit Type** โœ… HANDLED -**Scenario:** Developer types `feta:` instead of `feat:` -**Commit:** `feta: add new feature` - -**Expected Behavior:** Should be ignored (not a valid conventional commit) -**Actual Behavior:** โœ… Treated as non-conventional commit, not counted for version bump -**Impact:** None - release-please handles this correctly - -**Finding:** The script only analyzes the **latest** commit for impact type. If latest is invalid, it falls back to previous valid commits. This is correct behavior. - ---- - -#### **T-EDGE-2: Missing Space After Colon** โœ… HANDLED -**Scenario:** Developer forgets space: `fix:no space after colon` -**Commit:** `fix:no space after colon` - -**Expected Behavior:** Should still be recognized by release-please -**Actual Behavior:** โœ… Parsed correctly -**Impact:** None - both rc_align.py and release-please handle this - -**Regex Used:** `r"^feat(\(.*\))?:"` - doesn't require space after colon -**Status:** Working as expected - ---- - -#### **T-EDGE-3: Wrong Case (Uppercase)** โœ… HANDLED -**Scenario:** Developer uses uppercase: `FEAT: uppercase commit type` -**Commit:** `FEAT: uppercase commit type` - -**Expected Behavior:** Should be ignored (case-sensitive matching) -**Actual Behavior:** โœ… Treated as non-conventional commit -**Impact:** None - conventional commits spec is case-sensitive - -**Note:** This is correct behavior per the Conventional Commits specification. - ---- - -### Category 2: Batch Operations & Concurrency - -#### **T-EDGE-4: Multiple Commits Pushed at Once** โœ… PASS -**Scenario:** Developer pushes 3 commits simultaneously: -1. `feat: batch feature 1` -2. `fix: batch fix 2` -3. `feat!: batch breaking 3` - -**Expected Behavior:** Should analyze latest commit, count all commits -**Actual Behavior:** โœ… Correctly: -- Analyzed: `feat!: batch breaking 3` (latest commit) -- Detected: Breaking change โ†’ major bump -- Counted: All 3 commits in depth calculation -- Result: `v2.0.0-rc.1` (correct) - -**Key Finding:** The script uses `--first-parent` and analyzes the **last** commit in the range for impact type, but counts **all commits** for RC incrementing. This is smart behavior. - ---- - -#### **T-EDGE-12: Race Condition - Rapid Commits** ๐Ÿ”ด **CRITICAL BUG FOUND** -**Scenario:** Multiple commits pushed in quick succession trigger overlapping workflows - -**Timeline:** -``` -09:30:14 - Workflow A starts (commit with long scope) -09:30:21 - Workflow B starts (commit with slash in scope) -09:30:31 - Workflow C starts (commit with special chars) -``` - -**What Happened:** -1. Workflow A calculates version, adds `Release-As` footer commit -2. Workflow A tries to push โ†’ **REJECTED** (non-fast-forward) -3. Error: `! [rejected] next -> next (non-fast-forward)` -4. Workflow fails with exit code 1 - -**Root Cause:** -While Workflow A is running, another commit was pushed (Workflow B trigger). When A tries to push its `Release-As` commit, the branch has moved forward. - -**Error Log:** -``` -Injecting Release-As: 1.1.0-rc.1 footer -[next a4ed80f] chore: enforce correct rc version -To https://github.com/MapColonies/multi-level-release-test.git - ! [rejected] next -> next (non-fast-forward) -error: failed to push some refs -##[error]Process completed with exit code 1. -``` - -**Impact:** โš ๏ธ Medium -- Workflow fails but doesn't break the system -- Subsequent workflow succeeded and created PR -- However, failed workflow creates noise and confusion - -**Recommendation:** -Add retry logic with pull/rebase when push fails: -```bash -if ! git push ...; then - git pull --rebase origin ${{ github.ref_name }} - git push origin ${{ github.ref_name }} -fi -``` - -**Status:** ๐Ÿ”ด **BUG - Needs Fix** - ---- - -### Category 3: Manual Tag Manipulation - -#### **T-EDGE-5: Developer Creates Malformed Tag** โš ๏ธ WARNING -**Scenario:** Developer manually creates: `v999.999.999-wrong` -**Tag Format:** Valid semver pattern but suspicious version - -**Expected Behavior:** Should be ignored or handled gracefully -**Actual Behavior:** โœ… Tag ignored by baseline detection -**Reason:** Script prioritizes stable tags and uses proper sorting - -**Finding:** The `find_baseline_tag()` function sorts by: -1. Major, minor, patch versions -2. Stable tags preferred over RC tags -3. RC number (for RCs) - -**Status:** โœ… Handled correctly - ---- - -#### **T-EDGE-6: Tag Without 'v' Prefix** โœ… HANDLED -**Scenario:** Developer creates: `1.2.3` (no v-prefix) -**Tag Format:** Missing required prefix - -**Expected Behavior:** Should be ignored -**Actual Behavior:** โœ… Ignored by rc_align.py -**Reason:** Script uses `git tag -l "v*"` filter - -**Code:** `tags_output = run_git_command(["tag", "-l", "v*"])` -**Status:** โœ… Working as expected - ---- - -#### **T-EDGE-7: Manual High RC Number** โš ๏ธ POTENTIAL CONFUSION -**Scenario:** Developer manually creates: `v1.0.0-rc.100` -**Confusion Risk:** Script might pick this as baseline - -**Expected Behavior:** Should be considered in baseline selection -**Actual Behavior:** โœ… Tag was available but `v1.0.0` (stable) was chosen instead - -**Baseline Selection Logic:** -```python -def version_key(t): - maj, min, pat, rc = parse_semver(t) - is_stable = 1 if "-rc" not in t else 0 - return (maj, min, pat, is_stable, rc) -``` - -**Key:** Stable tags (is_stable=1) sort higher than RC tags (is_stable=0) - -**Status:** โœ… Working correctly but could confuse developers - -**Recommendation:** Add warning log when manual RC tags exist that deviate from expected sequence - ---- - -### Category 4: Git Operations - -#### **T-EDGE-8: Force Push / History Rewrite** โœ… HANDLED -**Scenario:** Developer force pushes after `git reset --hard HEAD~3` -**History:** Rewrote 3 commits and pushed with `--force` - -**Expected Behavior:** Should recalculate from new HEAD -**Actual Behavior:** โœ… Correctly recalculated: -- Previous version calculation ignored -- New calculation from clean baseline -- Result: `v1.1.0-rc.1` based on new commit - -**Finding:** The script is **stateless** - it recalculates every time from git history. Force pushes don't break the logic. - -**Status:** โœ… Robust against force pushes - ---- - -#### **T-EDGE-9: Empty Commit** โœ… HANDLED -**Scenario:** Developer creates empty commit: `git commit --allow-empty` -**Commit:** `chore: empty commit for testing` - -**Expected Behavior:** Should be counted in depth but not affect version type -**Actual Behavior:** โœ… Included in commit count -**Impact:** RC number incremented correctly - -**Status:** โœ… Working as expected - ---- - -### Category 5: Special Characters & Edge Formats - -#### **T-EDGE-10: Very Long Scope Name** โœ… HANDLED -**Scenario:** Scope with 70+ characters -**Commit:** `feat(this-is-a-very-long-scope-name-that-might-break-parsing-or-display-in-various-places): test` - -**Expected Behavior:** Should parse correctly -**Actual Behavior:** โœ… Parsed correctly -**Regex:** `r"^feat(\(.*\))?:"` handles any scope content - -**Status:** โœ… No issues with long scopes - ---- - -#### **T-EDGE-11: Special Characters in Scope** โœ… HANDLED -**Scenario:** Slash in scope: `fix(api/v2): scope with slash` -**Commit:** Contains `/` character in scope - -**Expected Behavior:** Should parse correctly -**Actual Behavior:** โœ… Parsed correctly -**Regex:** `\(.*\)` captures any characters including `/` - -**Status:** โœ… Handles special characters properly - ---- - -#### **T-ADV-1: Command Injection Characters** โœ… SAFE -**Scenario:** Commit message with shell syntax: `feat: test injection $(whoami) \`date\`` -**Security Test:** Backticks and command substitution - -**Expected Behavior:** Should be treated as literal text -**Actual Behavior:** โœ… No command execution -**Reason:** Python's `subprocess.run()` with list arguments doesn't invoke shell - -**Security Finding:** โœ… **SAFE** - No shell injection possible - -**Code Review:** -```python -result = subprocess.run(["git"] + args, stdout=subprocess.PIPE, text=True, check=fail_on_error) -``` - -The arguments are passed as a list, not a shell command string, so `$(...)` and backticks are literal. - -**Status:** โœ… No security vulnerability - ---- - -## ๐Ÿ” Summary of Findings - -### โœ… What Worked Well - -1. **Typo Resilience** - Invalid commit types are ignored, doesn't break workflow -2. **Case Sensitivity** - Correctly enforces lowercase conventional commit types -3. **Batch Commit Handling** - Analyzes latest commit, counts all commits correctly -4. **Tag Sorting** - Stable tags properly prioritized over RC tags -5. **Manual Tag Filtering** - Non-v-prefixed tags ignored -6. **Force Push Resilience** - Stateless recalculation works after history rewrites -7. **Empty Commits** - Handled in depth calculation -8. **Long Scopes** - No parsing issues with lengthy scope names -9. **Special Characters** - Slashes and other chars in scopes work fine -10. **Security** - No command injection vulnerability - -### ๐Ÿ”ด Critical Issues - -#### **Issue #3: Race Condition in Concurrent Workflows** -**Severity:** High -**Location:** `action.yaml` line 80 - Push step -**Description:** When multiple commits are pushed rapidly, parallel workflows can conflict when pushing the `Release-As` footer commit. - -**Current Code:** -```bash -git commit --allow-empty -m "chore: enforce correct rc version" -m "Release-As: $TARGET_VER" -git push "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }} -``` - -**Problem:** No retry logic for non-fast-forward rejections - -**Fix Recommendation:** -```bash -git commit --allow-empty -m "chore: enforce correct rc version" -m "Release-As: $TARGET_VER" - -# Add retry logic -for i in {1..3}; do - if git push "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }}; then - break - fi - - if [ $i -lt 3 ]; then - echo "Push failed, retrying with rebase..." - git pull --rebase "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }} - else - echo "Failed to push after 3 attempts" - exit 1 - fi -done -``` - -**Impact:** Workflow failures during concurrent operations, though system eventually self-heals on next run. - ---- - -### โš ๏ธ Warnings & Recommendations - -#### **Warning #1: Developer Confusion with Manual Tags** -**Issue:** Developers can create manual tags that don't follow the workflow pattern (e.g., `v1.0.0-rc.100`) -**Impact:** Low - Script handles correctly but could confuse team members -**Recommendation:** Add documentation warning against manual RC tag creation - -#### **Warning #2: Silent Typo Handling** -**Issue:** Typos in commit types (like `feta:` or `FEAT:`) are silently ignored -**Impact:** Low - Doesn't break anything but might surprise developers -**Recommendation:** Consider logging INFO message when non-conventional commits are encountered - ---- - -## ๐Ÿ“‹ Edge Case Test Matrix - -| Test ID | Scenario | Status | Impact | Notes | -|---------|----------|--------|--------|-------| -| T-EDGE-1 | Typo in commit type | โœ… PASS | None | Ignored correctly | -| T-EDGE-2 | Missing space after : | โœ… PASS | None | Parsed correctly | -| T-EDGE-3 | Uppercase commit type | โœ… PASS | None | Ignored (spec compliant) | -| T-EDGE-4 | Batch commits (3x) | โœ… PASS | None | Latest commit analyzed | -| T-EDGE-5 | Malformed manual tag | โœ… PASS | None | Ignored by sorting | -| T-EDGE-6 | Tag without v-prefix | โœ… PASS | None | Filtered out | -| T-EDGE-7 | Manual high RC tag | โœ… PASS | Low | Could confuse devs | -| T-EDGE-8 | Force push rewrite | โœ… PASS | None | Stateless recalc | -| T-EDGE-9 | Empty commit | โœ… PASS | None | Counted in depth | -| T-EDGE-10 | Very long scope | โœ… PASS | None | Parsed fine | -| T-EDGE-11 | Special chars in scope | โœ… PASS | None | Works correctly | -| T-EDGE-12 | Race condition | โŒ FAIL | High | **Needs fix** | -| T-ADV-1 | Command injection | โœ… PASS | None | No vulnerability | - ---- - -## ๐ŸŽฏ Recommendations for PR #105 - -### Must Fix Before Merge - -1. **Race Condition (Issue #3)** - - Add retry logic with rebase for push failures - - Prevent workflow failures during concurrent operations - - Priority: **HIGH** - -### Should Consider - -2. **Enhanced Logging** - - Log INFO when non-conventional commits detected - - Warn when manual RC tags exist - - Help developers understand what's being counted - -3. **Documentation Updates** - - Add "Common Mistakes" section to README - - Document that commit types are case-sensitive - - Warn against manual RC tag creation - -### Nice to Have - -4. **Validation Step** - - Add pre-flight check for manual tags - - Detect and warn about potential race conditions - - Log all commits being analyzed for transparency - ---- - -## ๐Ÿงช Test Evidence - -### Failed Workflow (Race Condition) -``` -Run: https://github.com/MapColonies/multi-level-release-test/actions/runs/21472825568 -Status: Failed -Error: ! [rejected] next -> next (non-fast-forward) -Cause: Concurrent workflow pushed while this workflow was running -``` - -### Successful Recovery -``` -Run: https://github.com/MapColonies/multi-level-release-test/actions/runs/21472834729 -Status: Success -Note: Subsequent run succeeded despite previous failure -Result: Created PR #14 with correct version 1.0.1-rc.1 -``` - -### Tag Sorting Behavior -```bash -$ git tag -l "v*" | sort -V -v0.1.0 -v0.2.0-rc.1 -v0.2.0-rc.4 -v0.2.0-rc.5 -v1.0.0 โ† Chosen as baseline (stable preferred) -v1.0.0-rc.1 -v1.0.0-rc.100 โ† Manual tag ignored in favor of stable -v999.999.999-wrong โ† Malformed but didn't break sorting -``` - ---- - -## ๐Ÿ Final Verdict - -**Overall Status:** ๐ŸŸก **MOSTLY READY - ONE CRITICAL FIX NEEDED** - -The Smart Release Please action handles developer errors and edge cases remarkably well: -- โœ… Robust regex parsing -- โœ… Smart baseline detection -- โœ… Stateless design prevents corruption -- โœ… No security vulnerabilities -- โœ… Handles special characters gracefully - -**However:** -- ๐Ÿ”ด Race condition needs fixing before production use -- โš ๏ธ Enhanced logging would improve developer experience - -**Recommendation:** Fix the race condition (Issue #3) before merging to production. The action is otherwise production-ready and handles edge cases better than expected. - ---- - -**End of Edge Case Report** diff --git a/SMART-RELEASE-PLEASE-TEST-RESULTS.md b/SMART-RELEASE-PLEASE-TEST-RESULTS.md deleted file mode 100644 index ec8cc60..0000000 --- a/SMART-RELEASE-PLEASE-TEST-RESULTS.md +++ /dev/null @@ -1,299 +0,0 @@ -# Smart Release Please - Manual Test Report -**Test Repository:** https://github.com/MapColonies/multi-level-release-test -**Branch Under Test:** smart-release-please -**PR:** #105 -**Test Date:** January 29, 2026 -**Tester:** OpenCode AI - ---- - -## ๐Ÿ“Š Executive Summary - -**Total Tests Planned:** 22 -**Tests Executed:** 12 (core functionality tests) -**Passed:** โœ… 11 -**Failed:** โŒ 0 -**Issues Found:** โš ๏ธ 2 -**Overall Status:** ๐ŸŸข **WORKING - Minor Issues Found** - -The Smart Release Please action is functioning correctly for its core use cases. The version calculation logic, RC incrementing, breaking change detection, and stable release promotion all work as expected. Two configuration issues were identified and documented below. - ---- - -## ๐Ÿงช Detailed Test Results - -### Phase 1: RC Release Testing (Next Branch) - -| Test ID | Test Name | Status | Expected | Actual | Notes | -|---------|-----------|--------|----------|--------|-------| -| **T1** | First RC from Stable Tag | โœ… **PASS** | `v0.2.0-rc.1` | `v0.2.0-rc.1` | Baseline: v0.1.0 (stable), feat commit correctly bumped minor to rc.1 | -| **T2** | Second RC (Fix Commit) | โœ… **PASS** | `v0.2.0-rc.2` | `v0.2.0-rc.4` | RC incremented correctly based on commit depth (had 3 commits, so rc.1 + 3 = rc.4). Working as designed. | -| **T3** | Third RC (Another Fix) | โœ… **PASS** | `v0.2.0-rc.5` | `v0.2.0-rc.5` | Continued incrementing correctly | -| **T4** | Major Bump (Breaking) | โœ… **PASS** | `v1.0.0-rc.1` | `v1.0.0-rc.1` | Breaking change (`feat!:`) correctly bumped major version | -| **T5** | Patch Bump from Stable | โญ๏ธ **SKIP** | `v0.2.1-rc.1` | N/A | Skipped due to test flow | -| **T6** | Multiple Commits | โœ… **PASS** | Accumulate RC | `v0.2.0-rc.4` | Verified in T2 - depth calculation works | -| **T7** | Feature with Scope | โญ๏ธ **SKIP** | `v0.4.0-rc.1` | N/A | Core functionality validated in other tests | -| **T8** | BREAKING CHANGE Footer | โญ๏ธ **SKIP** | Major bump | N/A | Similar to T4 | - -### Phase 2: Stable Release Testing (Master Branch) - -| Test ID | Test Name | Status | Expected | Actual | Notes | -|---------|-----------|--------|----------|--------|-------| -| **T9** | Promote RC to Stable | โœ… **PASS** | `v1.0.0` | `v1.0.0` | Successfully stripped RC suffix from `v1.0.0-rc.1` | -| **T10** | First Stable Release | โœ… **PASS** | `v0.1.0` | `v0.1.0` | Initial setup worked correctly | -| **T11** | Manifest Update | โœ… **PASS** | Updated | Updated | Manifest correctly updated during stable release | - -### Phase 3: Edge Cases & Special Scenarios - -| Test ID | Test Name | Status | Expected | Actual | Notes | -|---------|-----------|--------|----------|--------|-------| -| **T12** | No Commits Since Baseline | โญ๏ธ **SKIP** | Skip gracefully | N/A | - | -| **T13** | Bot Commits Only | โœ… **PASS** | Skip | Skipped | Workflow correctly detected release-please merge and skipped | -| **T14** | Release-As Footer Exists | โœ… **PASS** | No duplicate | No duplicate | Manually specified version was respected, no duplicate footer added | -| **T15** | Stale PR Exists | โœ… **PASS** | Close old PR | PR closed | Old PRs automatically closed when new version calculated | -| **T16** | Merge Commit from Main | โญ๏ธ **SKIP** | Skip | N/A | - | -| **T17** | High RC Number | โญ๏ธ **SKIP** | Continue | N/A | - | - -### Phase 4: Workflow Integration Tests - -| Test ID | Test Name | Status | Expected | Actual | Notes | -|---------|-----------|--------|----------|--------|-------| -| **T18** | PR Creation on Next | โœ… **PASS** | Auto-create PR | PR created | PRs created with correct labels: "autorelease: pending" | -| **T19** | PR Update After Commit | โœ… **PASS** | Regenerate PR | New PR created | Old PR closed, new PR created with updated version | -| **T20** | Release Creation | โœ… **PASS** | Create release | Release created | GitHub releases created correctly | -| **T21** | Tag Creation | โœ… **PASS** | Proper tag format | `v1.0.0-rc.1` | Tags follow correct semver format with v-prefix | -| **T22** | Changelog Generation | โœ… **PASS** | CHANGELOG.md | Generated | CHANGELOG.md created and updated with commits grouped by type | - ---- - -## ๐Ÿ” Issues Found - -### โš ๏ธ Issue #1: Python Version Specification (Minor) -**Severity:** Low -**Location:** `actions/smart-release-please/action.yaml:22` -**Description:** The workflow specifies `python-version: '3.14'` which doesn't exist yet. Python 3.14 is not released. - -**Current Code:** -```yaml -- name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.14' -``` - -**Recommendation:** -```yaml -python-version: '3.x' # Use latest stable Python 3 -``` - -**Impact:** No impact observed in testing as GitHub Actions appears to fall back to a valid Python version. However, this should be corrected for clarity. - ---- - -### โš ๏ธ Issue #2: Tag Naming Configuration (Configuration) -**Severity:** Medium (Configuration Issue) -**Location:** `release-please-config.next.json` -**Description:** Initially, release-please created tags with the package name prefix (`multi-level-release-test-v0.2.0-rc.1`) instead of just the version (`v0.2.0-rc.1`). This caused rc_align.py to not find RC tags since it looks for `v*` pattern. - -**Root Cause:** Missing `include-component-in-tag: false` in the next branch config. - -**Fix Applied During Testing:** -```json -{ - "include-component-in-tag": false, - "include-v-in-tag": true, - ... -} -``` - -**Impact:** This prevented proper RC baseline detection until the config was corrected. Users must ensure their config files specify `include-component-in-tag: false` for single-package repositories. - -**Recommendation:** Add clear documentation or examples showing the required config structure for single-package repos vs multi-package monorepos. - ---- - -## โœ… What Worked Well - -1. **Version Calculation Logic** - Accurately calculates next version based on conventional commits -2. **Commit Depth Tracking** - Correctly counts real commits and filters bot commits -3. **Breaking Change Detection** - Detects both `!` syntax and `BREAKING CHANGE` footer -4. **RC Incrementing** - Properly increments RC number based on commit count since baseline -5. **Baseline Detection** - Finds correct baseline (latest RC or stable tag) -6. **Stable Release Promotion** - Strips RC suffix when merging to master -7. **PR Management** - Closes stale PRs and regenerates with new versions -8. **Release-As Footer Injection** - Adds footer when needed, avoids duplicates -9. **Skip Logic** - Correctly skips on release-please merges and when no commits -10. **Changelog Generation** - Creates proper CHANGELOG.md with grouped commits -11. **GitHub Integration** - Seamless integration with release-please-action - ---- - -## ๐Ÿ“‹ Test Evidence - -### Sample Workflow Run Logs - -**T1: First RC Calculation** -``` -INFO: Baseline (Stable): v0.1.0 -INFO: Found 1 commits since v0.1.0 -INFO: Analyzing latest commit: 'feat: add user authentication' -OUTPUT: next_version=0.2.0-rc.1 -``` - -**T2: RC Increment with Multiple Commits** -``` -INFO: Baseline (RC): v0.2.0-rc.1 -INFO: Found 3 commits since v0.2.0-rc.1 -INFO: Analyzing latest commit: 'fix: resolve login timeout' -OUTPUT: next_version=0.2.0-rc.4 -``` - -**T4: Breaking Change Detection** -``` -INFO: Baseline (RC): v0.2.0-rc.5 -INFO: Found 1 commits since v0.2.0-rc.5 -INFO: Analyzing latest commit: 'feat!: redesign authentication API' -OUTPUT: next_version=1.0.0-rc.1 -``` - -**T9: Stable Release on Master** -``` -INFO: Updating manifest to version: 1.0.0 -``` - -### Created Releases -- โœ… `v0.2.0-rc.1` - First RC from stable -- โœ… `v0.2.0-rc.4` - RC increment with fixes -- โœ… `v0.2.0-rc.5` - Another RC increment -- โœ… `v1.0.0-rc.1` - Major version bump from breaking change -- โœ… `v1.0.0` - Stable release promoted from RC -- โœ… `v1.0.1-rc.1` - Patch RC with manual Release-As footer - -### Sample Pull Requests -- PR #2: `chore(next): release multi-level-release-test 0.2.0-rc.1` - โœ… Merged -- PR #5: `chore(next): release 0.2.0-rc.4` - โœ… Merged -- PR #6: `chore(next): release 0.2.0-rc.5` - โœ… Merged -- PR #7: `chore(next): release 1.0.0-rc.1` - โœ… Merged -- PR #8: `chore(master): release 1.0.0` - โœ… Merged -- PR #9: `chore(next): release 1.0.1-rc.1` - ๐ŸŸก Open - ---- - -## ๐ŸŽฏ Version Calculation Examples Verified - -| Scenario | Baseline | Commit | Result | Status | -|----------|----------|--------|--------|--------| -| Feature from stable | `v0.1.0` | `feat:` | `v0.2.0-rc.1` | โœ… Verified | -| Fix from RC | `v0.2.0-rc.1` | `fix:` (x3) | `v0.2.0-rc.4` | โœ… Verified | -| Breaking change | `v0.2.0-rc.5` | `feat!:` | `v1.0.0-rc.1` | โœ… Verified | -| Stable promotion | `v1.0.0-rc.1` | Merge to master | `v1.0.0` | โœ… Verified | - ---- - -## ๐Ÿ”ง Configuration Files Used - -### release-please-config.next.json -```json -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "draft-pull-request": false, - "include-v-in-tag": true, - "include-component-in-tag": false, - "prerelease": true, - "prerelease-type": "rc", - "packages": { - ".": { - "release-type": "node", - "package-name": "multi-level-release-test", - "extra-files": ["package.json"] - } - } -} -``` - -### release-please-config.master.json -```json -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-component-in-tag": false, - "tag-separator": "-", - "initial-version": "0.1.0", - "packages": { - ".": { - "release-type": "node", - "extra-files": ["package.json"] - } - } -} -``` - ---- - -## ๐Ÿ“ Recommendations - -### For PR #105: - -1. **Fix Python Version** - - Change `python-version: '3.14'` to `'3.x'` in action.yaml:22 - -2. **Enhance Documentation** - - Add clear examples of required config for single-package repos - - Document the importance of `include-component-in-tag: false` - - Add troubleshooting section for tag naming issues - -3. **Consider Config Validation** - - Add a step to validate config files before running - - Warn if `include-component-in-tag` is not set for single-package repos - -### For Future Enhancements: - -4. **Better Error Messages** - - When no RC tags are found, log a hint about checking tag naming format - - Add validation that tags match expected pattern - -5. **Testing** - - Add integration tests that run in a real GitHub Actions environment - - Consider adding tests for multi-package monorepo scenarios - ---- - -## ๐Ÿš€ Deployment Readiness - -**Overall Assessment:** โœ… **READY FOR MERGE WITH MINOR FIXES** - -The Smart Release Please action is functionally complete and works correctly for its intended use cases. The two issues identified are: -1. A trivial Python version specification (cosmetic fix) -2. A documentation gap about config requirements (user error prevention) - -Neither issue prevents the action from working correctly when properly configured. - -**Recommendation:** Merge PR #105 after fixing the Python version and enhancing documentation with clear configuration examples. - ---- - -## ๐Ÿ“Ž Appendix - -### Test Repository Setup -- Repository: https://github.com/MapColonies/multi-level-release-test -- Branches: `master`, `next` -- Initial Version: `v0.1.0` -- Final Version: `v1.0.0` (stable), `v1.0.1-rc.1` (next) - -### Workflow Files -- `.github/workflows/release-master.yaml` - Stable releases -- `.github/workflows/release-next.yaml` - RC releases - -### Test Duration -- Setup: ~5 minutes -- Testing: ~15 minutes -- Total: ~20 minutes - -### Test Methodology -- Sequential testing following realistic release workflow -- Manual commit creation and PR merging -- Real GitHub Actions execution (not mocked) -- Live monitoring of workflow logs and outputs - ---- - -**End of Report** diff --git a/actions/smart-release-please/README.md b/actions/smart-release-please/README.md deleted file mode 100644 index 14b65e9..0000000 --- a/actions/smart-release-please/README.md +++ /dev/null @@ -1,113 +0,0 @@ -# Smart Release Please - -A GitHub Action that intelligently manages semantic versioning for both release candidates (RC) and stable releases. It wraps [googleapis/release-please-action](https://github.com/googleapis/release-please-action) and enforces consistent version calculation based on conventional commits. - -## โœจ How It Works - -### Next Branch (RC Releases) -1. Finds baseline tag (latest RC or stable tag, defaults to `0.0.0`) -2. Counts real commits since baseline (filters bot commits) -3. Analyzes latest commit impact (breaking, feat, fix) -4. Calculates next RC version -5. Injects `Release-As:` footer if needed -6. Closes stale PRs and runs release-please - -### Main/Master Branch (Stable Releases) -1. Finds latest tag and strips RC suffix (`v1.2.3-rc.5` โ†’ `1.2.3`) -2. Updates `.release-please-manifest.json` -3. Runs release-please to create stable release - -## ๐Ÿ”„ Version Examples - -| Baseline | Commit Type | Result | -|----------|-------------|--------| -| `v1.2.3` | `feat:` | `v1.3.0-rc.1` | -| `v1.2.3` | `fix:` | `v1.2.4-rc.1` | -| `v1.2.3` | `feat!:` | `v2.0.0-rc.1` | -| `v1.2.3` | `chore:` | `v1.2.4-rc.1` | -| `v1.3.0-rc.2` (+ 1 fix) | `fix:` | `v1.3.0-rc.3` | -| `v1.3.0-rc.2` (+ 3 fixes) | `fix:` | `v1.3.0-rc.5` | - -## Usage - -```yaml -name: Smart Release Please - -on: - push: - branches: [next, main] - -permissions: - contents: write - pull-requests: write - -jobs: - release-please: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ secrets.GH_PAT }} - - - uses: MapColonies/shared-workflows/actions/smart-release-please@smart-release-please-v0.1.0 - with: - token: ${{ secrets.GH_PAT }} -``` - -### Required Config Files - -- `release-please-config.next.json` - RC releases config -- `release-please-config.main.json` - Stable releases config -- `.release-please-manifest.json` - Version manifest - -## ๐Ÿ“ Conventional Commits - -### Supported Semver Types - -- `feat:` - Bumps minor version (new feature) -- `fix:` - Bumps patch version (bug fix) -- `feat!:`, `fix!:`, `refactor!:` or `BREAKING CHANGE:` footer - Bumps major version -- `chore:`, `docs:`, `style:`, `test:`, `ci:`, `build:`, `perf:`, `refactor:` (without `!`) - **Still increment RC number** but don't trigger version bumps on stable - -### Chore Commits Behavior - -**Important:** Non-semver commits (like `chore:`, `docs:`, etc.) **still increment the RC counter**: - -``` -v1.3.0-rc.1 + chore: update deps โ†’ v1.3.0-rc.2 -v1.3.0-rc.2 + docs: fix typo โ†’ v1.3.0-rc.3 -v1.3.0-rc.3 + fix: bug โ†’ v1.3.0-rc.4 -``` - -### When to Use `chore:` vs `fix:` - -- **Use `fix:`** when fixing bugs or issues that affect users - - Bug fixes, error handling, functionality corrections - - Will create changelog entries - - Bumps patch version on stable releases - -- **Use `chore:`** for maintenance work that doesn't affect functionality - - Dependency updates, configuration changes - - Refactoring without behavior changes - - Build scripts, CI/CD updates - - Won't appear in changelogs or affect stable version bumps - -## ๐Ÿงช Testing - -```bash -python3 test_rc_align.py # Run 65 comprehensive tests -``` - -See `SRP-tests-coverage.md` for detailed coverage. - -## ๐Ÿ”— Related - -- [Release Please](https://github.com/googleapis/release-please) -- [Conventional Commits](https://www.conventionalcommits.org/) - -## ๐Ÿ“ Architecture - -For a visual overview of the workflow logic, see the architecture diagram: - -![Architecture](images/architecture.png) diff --git a/actions/smart-release-please/action.yaml b/actions/smart-release-please/action.yaml deleted file mode 100644 index 5adafea..0000000 --- a/actions/smart-release-please/action.yaml +++ /dev/null @@ -1,128 +0,0 @@ -name: "Smart Release Please" -description: "Manages version control for RC releases on 'next' and stable releases on 'main'." - -inputs: - token: - description: "GitHub Token (PAT) for authentication" - required: false - default: ${{ github.token }} - -runs: - using: "composite" - steps: - - name: Checkout Repository - uses: actions/checkout@v6 - with: - fetch-depth: 0 - token: ${{ inputs.token }} - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - - name: Configure Git Identity - shell: bash - run: | - git config user.name "mapcolonies[bot]" - git config user.email "devops[bot]@mapcolonies.com" - - - name: Pull Latest Changes - shell: bash - run: git pull "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }} - - - name: Calculate Next Version - id: calc_rc - shell: bash - run: python3 "${{ github.action_path }}/rc_align.py" - - # ============================================================ - # MAIN BRANCH ONLY: Update manifest for stable releases - # ============================================================ - - name: Update Release Manifest (Main Branch) - if: (github.ref_name == 'main' || github.ref_name == 'master') && steps.calc_rc.outputs.next_version != '' - shell: bash - run: | - TARGET_VER="${{ steps.calc_rc.outputs.next_version }}" - echo "Updating manifest to version: $TARGET_VER" - echo "{\".\": \"$TARGET_VER\"}" > .release-please-manifest.json - - git add .release-please-manifest.json - - if ! git diff --staged --quiet; then - git commit -m "chore: update manifest to $TARGET_VER for stable release" - git push "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }} - else - echo "No changes to manifest, skipping commit" - fi - - # ============================================================ - # VERSION ENFORCEMENT: Add Release-As footer when needed - # Skip if: already has Release-As, is a release commit, or is a merge from main - # ============================================================ - - name: Enforce Version with Release-As Footer - if: >- - ${{ - steps.calc_rc.outputs.next_version != '' && - !contains(github.event.head_commit.message, 'Release-As:') && - !(startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release')) && - !(contains(github.event.head_commit.message, 'Merge branch') && contains(github.event.head_commit.message, 'main')) - }} - shell: bash - run: | - TARGET_VER="${{ steps.calc_rc.outputs.next_version }}" - LAST_MSG=$(git log -1 --pretty=%B) - - # Prevent infinite loop: check if footer already exists - if [[ "$LAST_MSG" == *"Release-As: $TARGET_VER"* ]]; then - echo "โœ“ Release-As footer already present, skipping" - exit 0 - fi - - # Add Release-As footer to guide release-please - echo "Injecting Release-As: $TARGET_VER footer" - git commit --allow-empty -m "chore: enforce correct rc version" -m "Release-As: $TARGET_VER" - git push "https://x-access-token:${{ inputs.token }}@github.com/${{ github.repository }}.git" ${{ github.ref_name }} - - # Signal to skip release-please in this run (it will run on the next trigger) - echo "SKIP_RELEASE=true" >> $GITHUB_ENV - - # ============================================================ - # CLEANUP: Close stale release PRs to force regeneration - # ============================================================ - - name: Close Stale Release PRs - if: env.SKIP_RELEASE != 'true' && !contains(github.ref, 'release-please--branches') - shell: bash - env: - GH_TOKEN: ${{ inputs.token }} - run: | - echo "Checking for stale release PRs on branch '${{ github.ref_name }}'..." - - PR_DATA=$(gh pr list \ - --base "${{ github.ref_name }}" \ - --label "autorelease: pending" \ - --json number,state \ - --jq '.[0]') - - if [ -z "$PR_DATA" ] || [ "$PR_DATA" == "null" ]; then - echo "โœ“ No open release PRs found" - exit 0 - fi - - PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number') - PR_STATE=$(echo "$PR_DATA" | jq -r '.state') - - if [ "$PR_STATE" == "OPEN" ]; then - echo "Closing stale release PR #$PR_NUMBER to force regeneration..." - gh pr close "$PR_NUMBER" --delete-branch - else - echo "Found PR #$PR_NUMBER but it's already $PR_STATE, skipping" - fi - - - name: Run Release Please - if: env.SKIP_RELEASE != 'true' - uses: googleapis/release-please-action@v4 - with: - token: ${{ inputs.token }} - target-branch: ${{ github.ref_name }} - config-file: release-please-config.${{ github.ref_name }}.json diff --git a/actions/smart-release-please/images/architecture.png b/actions/smart-release-please/images/architecture.png deleted file mode 100644 index 40e5021..0000000 Binary files a/actions/smart-release-please/images/architecture.png and /dev/null differ diff --git a/actions/smart-release-please/rc_align.py b/actions/smart-release-please/rc_align.py deleted file mode 100644 index 5273905..0000000 --- a/actions/smart-release-please/rc_align.py +++ /dev/null @@ -1,194 +0,0 @@ -import os -import re -import subprocess -import sys - -BOT_COMMIT_MSG = "chore: enforce correct rc version" -BOT_FOOTER_TAG = "Release-As:" - -def run_git_command(args, fail_on_error=True): - try: - result = subprocess.run(["git"] + args, stdout=subprocess.PIPE, text=True, check=fail_on_error) - return result.stdout.strip() - except subprocess.CalledProcessError: - return None - -def parse_semver(tag): - if not tag: - return 0, 0, 0, 0 - - m_rc = re.match(r"^v(\d+)\.(\d+)\.(\d+)-rc\.(\d+)$", tag) - if m_rc: - return int(m_rc[1]), int(m_rc[2]), int(m_rc[3]), int(m_rc[4]) - - m_stable = re.match(r"^v(\d+)\.(\d+)\.(\d+)$", tag) - if m_stable: - return int(m_stable[1]), int(m_stable[2]), int(m_stable[3]), 0 - - return 0, 0, 0, 0 - -def find_baseline_tag(): - run_git_command(["fetch", "--tags"], fail_on_error=False) - tags_output = run_git_command(["tag", "-l", "v*"], fail_on_error=False) - - if not tags_output: - print("INFO: No tags found. Assuming 0.0.0 baseline.") - return None, True - - all_tags = tags_output.split('\n') - - def version_key(t): - maj, min, pat, rc = parse_semver(t) - is_stable = 1 if "-rc" not in t else 0 - return (maj, min, pat, is_stable, rc) - - sorted_tags = sorted(all_tags, key=version_key, reverse=True) - best_tag = sorted_tags[0] - - if "-rc" in best_tag: - print(f"INFO: Baseline (RC): {best_tag}") - return best_tag, False - - print(f"INFO: Baseline (Stable): {best_tag}") - return best_tag, True - -def get_commit_depth(baseline_tag): - rev_range = f"{baseline_tag}..HEAD" if baseline_tag else "HEAD" - raw_subjects = run_git_command(["log", rev_range, "--first-parent", "--pretty=format:%s"], fail_on_error=False) - - if not raw_subjects: - return 0 - - real_commits = [] - for s in raw_subjects.split('\n'): - if any(x in s for x in [BOT_FOOTER_TAG, BOT_COMMIT_MSG]): - continue - if re.match(r"^chore(\(.*\))?: release", s): - continue - real_commits.append(s) - - print(f"INFO: Found {len(real_commits)} commits since {baseline_tag or 'start'}") - return len(real_commits) - -def analyze_impact_from_latest(baseline_tag): - rev_range = f"{baseline_tag}..HEAD" if baseline_tag else "HEAD" - raw_subjects = run_git_command(["log", rev_range, "--first-parent", "--pretty=format:%s", "--reverse"], fail_on_error=False) - - if not raw_subjects: - return False, False - - real_commits = [] - for s in raw_subjects.split('\n'): - if any(x in s for x in [BOT_FOOTER_TAG, BOT_COMMIT_MSG]): - continue - if re.match(r"^chore(\(.*\))?: release", s): - continue - real_commits.append(s) - - if not real_commits: - return False, False - - latest = real_commits[-1] - latest_body = run_git_command(["log", "-1", "--pretty=format:%B"], fail_on_error=False) - - print(f"INFO: Analyzing latest commit: '{latest}'") - - breaking_pattern = r"^(feat|fix|refactor)(\(.*\))?!:" - is_breaking = re.search(breaking_pattern, latest) or "BREAKING CHANGE" in latest_body - is_feat = re.search(r"^feat(\(.*\))?:", latest) - - return bool(is_breaking), bool(is_feat) - -def calculate_next_version(major, minor, patch, rc, depth, is_breaking, is_feat, from_stable): - if is_breaking: - return f"{major + 1}.0.0-rc.1" - - if is_feat: - if from_stable: - return f"{major}.{minor + 1}.0-rc.1" - else: - if patch > 0: - return f"{major}.{minor + 1}.0-rc.1" - else: - return f"{major}.{minor}.{patch}-rc.{rc + depth}" - - if from_stable: - return f"{major}.{minor}.{patch + 1}-rc.1" - else: - return f"{major}.{minor}.{patch}-rc.{rc + depth}" - -def main(): - branch = os.environ.get("GITHUB_REF_NAME") - last_commit = run_git_command(["log", "-1", "--pretty=%s"], fail_on_error=False) - - if branch == "next": - head_tags = run_git_command(["tag", "--points-at", "HEAD"], fail_on_error=False) - if head_tags: - for tag in head_tags.split('\n'): - if tag.startswith('v') and re.match(r'^v\d+\.\d+\.\d+$', tag): - print(f"INFO: Stable tag at HEAD. Skipping.") - return - - skip_patterns = [ - (r"^chore(\(.*\))?: release", "release-please commit"), - ("release-please", "release-please merge"), - ] - - for pattern, desc in skip_patterns: - if last_commit and re.search(pattern, last_commit): - print(f"INFO: Detected {desc}. Skipping.") - return - - if branch in ["main", "master"]: - try: - run_git_command(["fetch", "origin", branch], fail_on_error=False) - run_git_command(["fetch", "--tags", "--force"], fail_on_error=False) - - tags_output = run_git_command(["tag", "-l", "v*"], fail_on_error=False) - - if not tags_output: - stable_version = "0.1.0" - else: - all_tags = tags_output.split('\n') - - def version_key(t): - maj, min, pat, rc = parse_semver(t) - is_stable = 1 if "-rc" not in t else 0 - return (maj, min, pat, is_stable, rc) - - latest_tag = sorted(all_tags, key=version_key, reverse=True)[0] - clean_tag = re.sub(r'-rc(\.\d+)?$', '', latest_tag) - stable_version = clean_tag.lstrip('v') - - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"next_version={stable_version}\n") - print(f"OUTPUT: next_version={stable_version}") - return - - except Exception as e: - print(f"ERROR: {e}") - sys.exit(1) - - try: - tag, from_stable = find_baseline_tag() - depth = get_commit_depth(tag) - - if depth == 0: - print("INFO: No commits since baseline. Exiting.") - return - - major, minor, patch, rc = parse_semver(tag) - is_breaking, is_feat = analyze_impact_from_latest(tag) - next_ver = calculate_next_version(major, minor, patch, rc, depth, is_breaking, is_feat, from_stable) - - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"next_version={next_ver}\n") - - print(f"OUTPUT: next_version={next_ver}") - - except Exception as e: - print(f"ERROR: {e}") - sys.exit(1) - -if __name__ == "__main__": - main() diff --git a/release-please-config.master.json b/release-please-config.master.json deleted file mode 100644 index 316d205..0000000 --- a/release-please-config.master.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "include-component-in-tag": false, - "tag-separator": "-", - "initial-version": "0.1.0", - "packages": { - ".": { - "release-type": "simple", - "extra-files": [ - { - "type": "yaml", - "path": "helm/Chart.yaml", - "jsonpath": "$.version" - }, - { - "type": "yaml", - "path": "helm/Chart.yaml", - "jsonpath": "$.appVersion" - }, - { - "type": "yaml", - "path": "openapi3.yaml", - "jsonpath": "$.info.version" - } - ] - } - } -} diff --git a/release-please-config.next.json b/release-please-config.next.json deleted file mode 100644 index 280c842..0000000 --- a/release-please-config.next.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "draft-pull-request": false, - "include-v-in-tag": true, - "prerelease": true, - "prerelease-type": "rc", - "separate-pull-requests": true, - "packages": { - "actions/helm-lint": { - "release-type": "simple", - "package-name": "helm-lint", - "extra-files": ["README.md"] - }, - "actions/update-artifacts-file": { - "release-type": "simple", - "package-name": "update-artifacts-file", - "extra-files": ["README.md"] - }, - "actions/init-npm": { - "release-type": "simple", - "package-name": "init-npm", - "extra-files": ["README.md"] - }, - "actions/validate-domain": { - "release-type": "simple", - "package-name": "validate-domain", - "extra-files": ["README.md"] - }, - "actions/update-chart-version": { - "release-type": "simple", - "package-name": "update-chart-version", - "extra-files": ["README.md"] - } - } -} diff --git a/test/SRP-tests-coverage.md b/test/SRP-tests-coverage.md deleted file mode 100644 index e7f35a6..0000000 --- a/test/SRP-tests-coverage.md +++ /dev/null @@ -1,162 +0,0 @@ -# Test Coverage Checklist for rc_align.py - -## ๐Ÿ“Š Test Summary -**Total Tests: 65** | **All Passing โœ…** - ---- - -## 1๏ธโƒฃ Core Functions Tests - -### `run_git_command()` - 3 tests -- โœ… Successful git command execution -- โœ… Failed command with fail_on_error=True (returns None) -- โœ… Failed command with fail_on_error=False (returns None) - -### `parse_semver()` - 5 tests -- โœ… Parse RC version (v1.2.3-rc.4 โ†’ 1, 2, 3, 4) -- โœ… Parse stable version (v1.2.3 โ†’ 1, 2, 3, 0) -- โœ… Parse None/no tags (None โ†’ 0, 0, 0, 0) -- โœ… Parse major version (v5.0.0 โ†’ 5, 0, 0, 0) -- โœ… Parse high RC number (v2.5.10-rc.99 โ†’ 2, 5, 10, 99) - -### `find_baseline_tag()` - 3 tests -- โœ… RC tag exists as baseline -- โœ… Stable tag exists as baseline -- โœ… No tags found (returns None, assumes 0.0.0) - -### `get_commit_depth()` - 5 tests -- โœ… No commits (returns 0) -- โœ… Count only user commits (3 user commits = depth 3) -- โœ… Filter bot commits with "Release-As:" footer -- โœ… Filter bot commits with "chore: enforce correct rc version" -- โœ… Mixed user and bot commits (only count user commits) - -### `analyze_impact_from_latest()` - 8 tests -- โœ… Breaking change with exclamation mark (feat!:) -- โœ… Breaking change with BREAKING CHANGE footer -- โœ… Feature commit (feat:) -- โœ… Fix commit (fix:) -- โœ… Breaking fix (fix!:) -- โœ… Feature with scope (feat(api):) -- โœ… No commits (returns False, False) -- โœ… Filters bot commits correctly - -### `calculate_next_version()` - 6 tests -- โœ… Breaking change bumps major (v1.2.3 โ†’ v2.0.0-rc.1) -- โœ… Breaking from high version (v10.5.2 โ†’ v11.0.0-rc.1) -- โœ… Feature from stable bumps minor (v1.2.3 โ†’ v1.3.0-rc.1) -- โœ… Feature from RC with patch>0 bumps minor (v1.2.1-rc.2 โ†’ v1.3.0-rc.1) -- โœ… Feature from RC without patch increments RC (v1.2.0-rc.2 โ†’ v1.2.0-rc.3) -- โœ… Fix from stable bumps patch (v1.2.3 โ†’ v1.2.4-rc.1) -- โœ… Fix from RC increments RC (v1.2.3-rc.2 โ†’ v1.2.3-rc.3) -- โœ… Multiple commits increment RC by depth (v1.2.3-rc.1 + 5 commits โ†’ v1.2.3-rc.6) - ---- - -## 2๏ธโƒฃ Main Function Tests (11 tests) - -### Skip Scenarios -- โœ… No commits since baseline (exits early) -- โœ… Exception handling (exits gracefully with exit code 0) -- โœ… Skips release-please commit (chore(main): release) -- โœ… Skips stable tag at HEAD on next branch -- โœ… Skips release-please merge commits - -### Main/Master Branch -- โœ… No tags (outputs 0.1.0) -- โœ… RC tag exists (strips RC: v1.2.3-rc.5 โ†’ 1.2.3) -- โœ… Stable tag exists (uses as-is: v2.0.0 โ†’ 2.0.0) -- โœ… Master branch works identically to main -- โœ… Mixed stable and RC tags (picks latest) -- โœ… Handles high RC numbers (v1.0.0-rc.100 โ†’ 1.0.0) - -### Next Branch -- โœ… Complete flow with feature commit (v1.2.3 โ†’ v1.3.0-rc.1) -- โœ… Breaking change (v1.5.2 โ†’ v2.0.0-rc.1) -- โœ… From RC baseline (v1.2.0-rc.3 + 2 commits โ†’ v1.2.0-rc.5) - ---- - -## 3๏ธโƒฃ Integration Scenarios (7 tests) - -### Version Calculation Logic -- โœ… Scenario 1: v1.2.3 + feat โ†’ v1.3.0-rc.1 (minor bump) -- โœ… Scenario 2: v1.3.0-rc.2 + fix โ†’ v1.3.0-rc.3 (RC increment) -- โœ… Scenario 3: v2.5.1 + feat! โ†’ v3.0.0-rc.1 (major bump) - -### RC Progression -- โœ… Track full lifecycle: v1.0.0 โ†’ v1.1.0-rc.1 โ†’ v1.1.0-rc.2 โ†’ v1.1.0-rc.3 - -### Bumping Rules -- โœ… Breaking changes always bump major (v0.5.2 โ†’ v1.0.0-rc.1, v2.5.3-rc.4 โ†’ v3.0.0-rc.1) -- โœ… Patch bump from stable (v1.2.3 + fix โ†’ v1.2.4-rc.1) -- โœ… Multiple fixes accumulate RC (v1.0.0-rc.1 + 5 fixes โ†’ v1.0.0-rc.6) -- โœ… Feature on RC with patch>0 bumps minor (v1.2.1-rc.3 + feat โ†’ v1.3.0-rc.1) -- โœ… Feature on RC with patch=0 increments RC (v1.2.0-rc.3 + feat โ†’ v1.2.0-rc.4) - ---- - -## 4๏ธโƒฃ Edge Cases & Boundary Conditions (14 tests) - -### Version Boundaries -- โœ… Version 0.0.0 (first release: v0.0.0 + feat โ†’ v0.1.0-rc.1) -- โœ… Very high RC number (v1.0.0-rc.100 + 5 โ†’ v1.0.0-rc.105) -- โœ… Breaking from v0.x.x bumps to v1.0.0-rc.1 -- โœ… Double-digit version numbers (v12.34.56-rc.78 + 10 โ†’ v12.34.56-rc.88) - -### Input Validation -- โœ… Empty commit message (returns depth 0) -- โœ… Invalid version format (returns 0, 0, 0, 0) -- โœ… Version without 'v' prefix (returns 0, 0, 0, 0) - -### Special Cases -- โœ… Only RC tags exist (picks highest RC) -- โœ… Only bot commits (depth = 0) -- โœ… Refactor with breaking change (refactor!:) -- โœ… BREAKING CHANGE in commit body only -- โœ… Tag sorting with mixed versions (v1.10.0 vs v1.2.0 โ†’ correct sorting) - ---- - -## ๐Ÿ“‹ Test Coverage by Category - -| Category | Tests | Description | -|----------|-------|-------------| -| **Unit Tests** | 35 | Individual function testing | -| **Integration Tests** | 7 | End-to-end scenario testing | -| **Main Function Tests** | 11 | Complete workflow testing | -| **Edge Cases** | 12 | Boundary conditions & special cases | ---- - -## ๐Ÿš€ Running the Tests - -```bash -# Run all tests -python3 test_rc_align.py - -# Run with pytest (verbose) -python3 -m pytest test_rc_align.py -v - -# Run specific test class -python3 -m pytest test_rc_align.py::TestCalculateNextVersion -v - -# Run with coverage -python3 -m pytest test_rc_align.py --cov=rc_align --cov-report=html -``` - ---- - -## ๐Ÿ“ Notes - -- All tests use mocking to avoid dependency on actual git repository -- Tests verify both output messages and return values -- Environment variables are properly mocked for GitHub Actions context -- Each test includes descriptive docstrings with examples -- Tests are organized by function and scenario for easy navigation - ---- - -**Last Updated:** January 28, 2026 -**Test Framework:** Python unittest -**Total Test Count:** 65 -**Pass Rate:** 100% โœ… diff --git a/test/test_rc_align.py b/test/test_rc_align.py deleted file mode 100644 index 1611bc0..0000000 --- a/test/test_rc_align.py +++ /dev/null @@ -1,940 +0,0 @@ -""" -Unit tests for rc_align.py - -Run with: python3 -m pytest test_rc_align.py -v -Or: python3 test_rc_align.py -""" - -import unittest -import sys -import os -from unittest.mock import patch, MagicMock, mock_open -from io import StringIO - -# Add parent directory to path to import rc_align -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'actions', 'smart-release-please')) - -# Import the module to test -import rc_align - - -class TestRunGitCommand(unittest.TestCase): - """Test the run_git_command function""" - - @patch('subprocess.run') - def test_successful_command(self, mock_run): - """Test successful git command execution""" - mock_run.return_value = MagicMock(stdout="v1.0.0\n", returncode=0) - result = rc_align.run_git_command(["describe", "--tags"]) - self.assertEqual(result, "v1.0.0") - mock_run.assert_called_once() - - @patch('subprocess.run') - def test_failed_command_with_fail_on_error_true(self, mock_run): - """Test failed command with fail_on_error=True""" - from subprocess import CalledProcessError - mock_run.side_effect = CalledProcessError(1, 'git') - result = rc_align.run_git_command(["invalid"], fail_on_error=True) - # Should catch exception and return None - self.assertIsNone(result) - - @patch('subprocess.run') - def test_failed_command_with_fail_on_error_false(self, mock_run): - """Test failed command with fail_on_error=False""" - from subprocess import CalledProcessError - mock_run.side_effect = CalledProcessError(1, 'git') - result = rc_align.run_git_command(["invalid"], fail_on_error=False) - self.assertIsNone(result) - - -class TestFindBaselineTag(unittest.TestCase): - """Test the find_baseline_tag function""" - - @patch('rc_align.run_git_command') - def test_rc_tag_found(self, mock_git): - """Test when RC tag exists""" - mock_git.return_value = "v1.2.3-rc.4" - tag, from_stable = rc_align.find_baseline_tag() - self.assertEqual(tag, "v1.2.3-rc.4") - self.assertFalse(from_stable) - - @patch('rc_align.run_git_command') - def test_stable_tag_found(self, mock_git): - """Test when only stable tag exists""" - mock_git.return_value = "v1.2.3" - tag, from_stable = rc_align.find_baseline_tag() - self.assertEqual(tag, "v1.2.3") - self.assertTrue(from_stable) - - @patch('rc_align.run_git_command') - @patch('sys.stdout', new_callable=StringIO) - def test_no_tags_found(self, mock_stdout, mock_git): - """Test when no tags exist""" - mock_git.return_value = None - tag, from_stable = rc_align.find_baseline_tag() - self.assertIsNone(tag) - self.assertTrue(from_stable) - self.assertIn("No tags found", mock_stdout.getvalue()) - - -class TestGetCommitDepth(unittest.TestCase): - """Test the get_commit_depth function""" - - @patch('rc_align.run_git_command') - def test_no_commits(self, mock_git): - """ - Test with no commits - Example: Empty history โ†’ depth = 0 - """ - mock_git.return_value = None - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 0) - - @patch('rc_align.run_git_command') - def test_user_commits_only(self, mock_git): - """ - Test counting only user commits - Example: 3 user commits โ†’ depth = 3 - """ - mock_git.return_value = "feat: new feature\nfix: bug fix\ndocs: update readme" - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 3) - - @patch('rc_align.run_git_command') - def test_filter_bot_commits_with_release_as(self, mock_git): - """ - Test filtering bot commits with Release-As footer - Example: 3 commits (1 bot with "Release-As:") โ†’ depth = 2 - """ - commits = "feat: new feature\nchore: something Release-As: 1.0.0\nfix: bug fix" - mock_git.return_value = commits - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 2) - - @patch('rc_align.run_git_command') - def test_filter_bot_commits_with_enforce_message(self, mock_git): - """ - Test filtering bot commits with enforce message - Example: 3 commits (1 bot with "chore: enforce correct rc version") โ†’ depth = 2 - """ - commits = "feat: new feature\nchore: enforce correct rc version\nfix: bug fix" - mock_git.return_value = commits - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 2) - - @patch('rc_align.run_git_command') - def test_mixed_commits(self, mock_git): - """ - Test with mixed user and bot commits - Example: 5 total commits (2 bot) โ†’ depth = 3 - - feat: new feature (user) - - chore: enforce correct rc version (bot - filtered) - - fix: bug fix (user) - - Release-As: 1.2.3 (bot - filtered) - - docs: update (user) - """ - commits = "\n".join([ - "feat: new feature", - "chore: enforce correct rc version", - "fix: bug fix", - "Release-As: 1.2.3", - "docs: update", - ]) - mock_git.return_value = commits - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 3) - - -class TestParseSemver(unittest.TestCase): - """Test the parse_semver function""" - - def test_parse_rc_version(self): - """ - Test parsing RC version - Example: "v1.2.3-rc.4" โ†’ (1, 2, 3, 4) - """ - major, minor, patch, rc = rc_align.parse_semver("v1.2.3-rc.4") - self.assertEqual((major, minor, patch, rc), (1, 2, 3, 4)) - - def test_parse_stable_version(self): - """ - Test parsing stable version - Example: "v1.2.3" โ†’ (1, 2, 3, 0) - """ - major, minor, patch, rc = rc_align.parse_semver("v1.2.3") - self.assertEqual((major, minor, patch, rc), (1, 2, 3, 0)) - - def test_parse_none_version(self): - """ - Test parsing None version (no tags) - Example: None โ†’ (0, 0, 0, 0) - """ - major, minor, patch, rc = rc_align.parse_semver(None) - self.assertEqual((major, minor, patch, rc), (0, 0, 0, 0)) - - def test_parse_major_version(self): - """ - Test parsing major version - Example: "v5.0.0" โ†’ (5, 0, 0, 0) - """ - major, minor, patch, rc = rc_align.parse_semver("v5.0.0") - self.assertEqual((major, minor, patch, rc), (5, 0, 0, 0)) - - def test_parse_high_rc_number(self): - """ - Test parsing high RC number - Example: "v2.5.10-rc.99" โ†’ (2, 5, 10, 99) - """ - major, minor, patch, rc = rc_align.parse_semver("v2.5.10-rc.99") - self.assertEqual((major, minor, patch, rc), (2, 5, 10, 99)) - - -class TestAnalyzeImpactFromLatest(unittest.TestCase): - """Test the analyze_impact_from_latest function""" - - @patch('rc_align.run_git_command') - def test_breaking_change_with_exclamation(self, mock_git): - """ - Test detecting breaking change with exclamation mark - Example: "feat!: breaking change" โ†’ breaking=True, feat=False - Note: feat! is detected as breaking but not as feat (regex is strict) - """ - # Mock two calls: one for log subjects, one for latest body - mock_git.side_effect = [ - "feat!: breaking change", # log subjects - "feat!: breaking change\nSome details" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertTrue(is_breaking) - self.assertFalse(is_feat) - - @patch('rc_align.run_git_command') - def test_breaking_change_with_footer(self, mock_git): - """ - Test detecting breaking change with BREAKING CHANGE footer - Example: "feat: new\nBREAKING CHANGE: API changed" โ†’ breaking=True, feat=True - """ - mock_git.side_effect = [ - "feat: new feature", # log subjects - "feat: new feature\n\nBREAKING CHANGE: API changed" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertTrue(is_breaking) - self.assertTrue(is_feat) - - @patch('rc_align.run_git_command') - def test_feature_commit(self, mock_git): - """ - Test detecting feature commit - Example: "feat: new feature" โ†’ breaking=False, feat=True - """ - mock_git.side_effect = [ - "feat: new feature", # log subjects - "feat: new feature\nSome details" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertFalse(is_breaking) - self.assertTrue(is_feat) - - @patch('rc_align.run_git_command') - def test_fix_commit(self, mock_git): - """ - Test detecting fix commit - Example: "fix: bug fix" โ†’ breaking=False, feat=False - """ - mock_git.side_effect = [ - "fix: bug fix", # log subjects - "fix: bug fix\nSome details" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertFalse(is_breaking) - self.assertFalse(is_feat) - - @patch('rc_align.run_git_command') - def test_breaking_fix(self, mock_git): - """ - Test detecting breaking fix - Example: "fix!: breaking bug fix" โ†’ breaking=True, feat=False - """ - mock_git.side_effect = [ - "fix!: breaking bug fix", # log subjects - "fix!: breaking bug fix" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertTrue(is_breaking) - self.assertFalse(is_feat) - - @patch('rc_align.run_git_command') - def test_feature_with_scope(self, mock_git): - """ - Test detecting feature with scope - Example: "feat(api): new endpoint" โ†’ breaking=False, feat=True - """ - mock_git.side_effect = [ - "feat(api): new endpoint", # log subjects - "feat(api): new endpoint" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertFalse(is_breaking) - self.assertTrue(is_feat) - - @patch('rc_align.run_git_command') - def test_no_commits(self, mock_git): - """Test with no commits""" - mock_git.return_value = None - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertFalse(is_breaking) - self.assertFalse(is_feat) - - @patch('rc_align.run_git_command') - def test_filters_bot_commits(self, mock_git): - """ - Test that bot commits are filtered out - Example: Multiple commits with bot commits filtered โ†’ analyzes last real commit - """ - mock_git.side_effect = [ - "feat: feature\nchore: enforce correct rc version\nfix: bug fix", # log subjects - "fix: bug fix" # latest body - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertFalse(is_breaking) - self.assertFalse(is_feat) - - -class TestCalculateNextVersion(unittest.TestCase): - """Test the calculate_next_version function""" - - def test_breaking_change_bump_major(self): - """ - Test breaking change bumps major version - Example: v1.2.3 + feat!: breaking โ†’ v2.0.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=0, - depth=1, is_breaking=True, is_feat=False, from_stable=True - ) - self.assertEqual(result, "2.0.0-rc.1") - - def test_feature_from_stable_bump_minor(self): - """ - Test feature from stable bumps minor version - Example: v1.2.3 + feat: new feature โ†’ v1.3.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=0, - depth=1, is_breaking=False, is_feat=True, from_stable=True - ) - self.assertEqual(result, "1.3.0-rc.1") - - def test_feature_from_rc_with_patch_bump_minor(self): - """ - Test feature from RC with patch>0 bumps minor - Example: v1.2.1-rc.2 + feat: feature โ†’ v1.3.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=1, rc=2, - depth=1, is_breaking=False, is_feat=True, from_stable=False - ) - self.assertEqual(result, "1.3.0-rc.1") - - def test_feature_from_rc_increment_rc(self): - """ - Test feature from RC increments RC number - Example: v1.2.0-rc.2 + feat: feature โ†’ v1.2.0-rc.3 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=0, rc=2, - depth=1, is_breaking=False, is_feat=True, from_stable=False - ) - self.assertEqual(result, "1.2.0-rc.3") - - def test_fix_from_stable_bump_patch(self): - """ - Test fix from stable bumps patch version - Example: v1.2.3 + fix: bug fix โ†’ v1.2.4-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=0, - depth=1, is_breaking=False, is_feat=False, from_stable=True - ) - self.assertEqual(result, "1.2.4-rc.1") - - def test_fix_from_rc_increment_rc(self): - """ - Test fix from RC increments RC number - Example: v1.2.3-rc.2 + fix: bug fix โ†’ v1.2.3-rc.3 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=2, - depth=1, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.2.3-rc.3") - - def test_multiple_commits_increment_rc(self): - """ - Test multiple commits increment RC by depth - Example: v1.2.3-rc.1 + 5 commits โ†’ v1.2.3-rc.6 (1 + 5) - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=1, - depth=5, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.2.3-rc.6") - - def test_breaking_change_from_high_version(self): - """ - Test breaking change from high version - Example: v10.5.2 + feat!: breaking โ†’ v11.0.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=10, minor=5, patch=2, rc=0, - depth=1, is_breaking=True, is_feat=True, from_stable=True - ) - self.assertEqual(result, "11.0.0-rc.1") - - -class TestMainFunction(unittest.TestCase): - """Test the main function""" - - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('rc_align.find_baseline_tag') - @patch('rc_align.get_commit_depth') - @patch('sys.stdout', new_callable=StringIO) - def test_main_no_commits(self, mock_stdout, mock_depth, mock_baseline, mock_git, mock_env): - """Test main with no commits""" - mock_env.return_value = "next" - mock_git.return_value = "feat: some feature" - mock_baseline.return_value = ("v1.0.0", True) - mock_depth.return_value = 0 - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("No commits since baseline", output) - - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('rc_align.find_baseline_tag') - @patch('sys.stdout', new_callable=StringIO) - def test_main_exception_handling(self, mock_stdout, mock_baseline, mock_git, mock_env): - """Test main handles exceptions gracefully""" - mock_env.return_value = "next" - mock_git.return_value = "feat: some feature" - mock_baseline.side_effect = Exception("Test error") - - # Main should handle exception and exit with error code 1 - with self.assertRaises(SystemExit) as cm: - rc_align.main() - self.assertEqual(cm.exception.code, 1) - - output = mock_stdout.getvalue() - self.assertIn("ERROR", output) - - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('sys.stdout', new_callable=StringIO) - def test_main_skips_release_please_commit(self, mock_stdout, mock_git, mock_env): - """Test main skips when detecting a release-please commit""" - mock_env.return_value = "next" - mock_git.return_value = "chore(main): release 1.0.0" - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("Detected release-please commit", output) - self.assertIn("Skipping", output) - - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('sys.stdout', new_callable=StringIO) - def test_main_skips_stable_tag_on_next(self, mock_stdout, mock_git, mock_env): - """Test main skips when stable tag is at HEAD on next branch""" - mock_env.return_value = "next" - # First call: last commit, second call: tags at HEAD - mock_git.side_effect = [ - "feat: some feature", # last commit - "v1.0.0" # tags at HEAD - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("Stable tag at HEAD", output) - self.assertIn("Skipping", output) - - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('sys.stdout', new_callable=StringIO) - def test_main_skips_release_please_merge(self, mock_stdout, mock_git, mock_env): - """Test main skips release-please merge commits""" - mock_env.return_value = "next" - mock_git.return_value = "Merge pull request #123 from release-please" - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("Detected release-please merge", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_main_branch_no_tags(self, mock_stdout, mock_file, mock_git, mock_env): - """Test main branch with no existing tags outputs 0.1.0""" - mock_env.return_value = "main" - mock_git.side_effect = [ - "feat: initial commit", # last commit - None, # fetch origin main - None, # fetch tags - None # tag -l v* - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=0.1.0", output) - mock_file.assert_called() - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_main_branch_with_rc_tag(self, mock_stdout, mock_file, mock_git, mock_env): - """Test main branch strips RC from latest tag""" - mock_env.return_value = "main" - mock_git.side_effect = [ - "feat: some feature", # last commit - None, # fetch origin main - None, # fetch tags - "v1.2.3-rc.5\nv1.2.2\nv1.2.1-rc.1" # tag -l v* - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=1.2.3", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_main_branch_with_stable_tag(self, mock_stdout, mock_file, mock_git, mock_env): - """Test main branch uses stable tag as-is""" - mock_env.return_value = "main" - mock_git.side_effect = [ - "feat: some feature", # last commit - None, # fetch origin main - None, # fetch tags - "v2.0.0\nv1.9.0\nv1.8.0" # tag -l v* - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=2.0.0", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_master_branch_works_same_as_main(self, mock_stdout, mock_file, mock_git, mock_env): - """Test master branch behaves identically to main""" - mock_env.return_value = "master" - mock_git.side_effect = [ - "feat: some feature", # last commit - None, # fetch origin master - None, # fetch tags - "v1.5.0-rc.2" # tag -l v* - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=1.5.0", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('rc_align.find_baseline_tag') - @patch('rc_align.get_commit_depth') - @patch('rc_align.analyze_impact_from_latest') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_next_branch_complete_flow(self, mock_stdout, mock_file, mock_analyze, mock_depth, mock_baseline, mock_git, mock_env): - """Test complete flow on next branch with feature commit""" - mock_env.return_value = "next" - mock_git.side_effect = [ - "feat: new feature", # last commit - None # tags at HEAD - ] - mock_baseline.return_value = ("v1.2.3", True) - mock_depth.return_value = 1 - mock_analyze.return_value = (False, True) # is_breaking=False, is_feat=True - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=1.3.0-rc.1", output) - - -class TestEdgeCases(unittest.TestCase): - """Test edge cases and boundary conditions""" - - def test_version_zero_point_zero(self): - """ - Test calculating from 0.0.0 (first release) - Example: v0.0.0 (no tags) + feat: initial โ†’ v0.1.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=0, minor=0, patch=0, rc=0, - depth=1, is_breaking=False, is_feat=True, from_stable=True - ) - self.assertEqual(result, "0.1.0-rc.1") - - def test_very_high_rc_number(self): - """ - Test with very high RC number - Example: v1.0.0-rc.100 + 5 commits โ†’ v1.0.0-rc.105 - """ - result = rc_align.calculate_next_version( - major=1, minor=0, patch=0, rc=100, - depth=5, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.0.0-rc.105") - - @patch('rc_align.run_git_command') - def test_empty_commit_message(self, mock_git): - """Test with empty commit message""" - mock_git.return_value = "" - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 0) - - def test_parse_invalid_version_format(self): - """Test parsing invalid version format""" - result = rc_align.parse_semver("invalid") - self.assertEqual(result, (0, 0, 0, 0)) - - def test_parse_version_without_v_prefix(self): - """Test parsing version without 'v' prefix returns zeros""" - result = rc_align.parse_semver("1.2.3") - self.assertEqual(result, (0, 0, 0, 0)) - - def test_breaking_from_version_zero(self): - """ - Test breaking change from v0.x.x bumps to v1.0.0 - """ - result = rc_align.calculate_next_version( - major=0, minor=5, patch=2, rc=0, - depth=1, is_breaking=True, is_feat=False, from_stable=True - ) - self.assertEqual(result, "1.0.0-rc.1") - - @patch('rc_align.run_git_command') - def test_find_baseline_with_only_rc_tags(self, mock_git): - """Test finding baseline when only RC tags exist""" - mock_git.side_effect = [ - None, # fetch tags - "v1.0.0-rc.1\nv0.9.0-rc.5\nv0.8.0-rc.2" # tag list - ] - tag, from_stable = rc_align.find_baseline_tag() - self.assertEqual(tag, "v1.0.0-rc.1") - self.assertFalse(from_stable) - - @patch('rc_align.run_git_command') - def test_commit_depth_with_only_bot_commits(self, mock_git): - """Test commit depth when all commits are bot commits""" - commits = "\n".join([ - "chore: enforce correct rc version", - "Release-As: 1.0.0", - "chore(main): release 1.0.0" - ]) - mock_git.return_value = commits - depth = rc_align.get_commit_depth("v1.0.0") - self.assertEqual(depth, 0) - - @patch('rc_align.run_git_command') - def test_analyze_with_refactor_breaking(self, mock_git): - """Test analyze detects breaking change in refactor commits""" - mock_git.side_effect = [ - "refactor!: major API change", - "refactor!: major API change" - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertTrue(is_breaking) - self.assertFalse(is_feat) - - @patch('rc_align.run_git_command') - def test_analyze_with_breaking_in_body_only(self, mock_git): - """Test analyze detects BREAKING CHANGE in commit body""" - mock_git.side_effect = [ - "refactor: change API", - "refactor: change API\n\nBREAKING CHANGE: Removed old method" - ] - is_breaking, is_feat = rc_align.analyze_impact_from_latest("v1.0.0") - self.assertTrue(is_breaking) - self.assertFalse(is_feat) - - def test_version_with_double_digit_numbers(self): - """Test version calculation with double-digit version numbers""" - result = rc_align.calculate_next_version( - major=12, minor=34, patch=56, rc=78, - depth=10, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "12.34.56-rc.88") - - @patch('rc_align.run_git_command') - def test_tag_sorting_with_mixed_versions(self, mock_git): - """Test that tags are sorted correctly by version""" - mock_git.side_effect = [ - None, # fetch tags - "v1.10.0\nv1.2.0\nv1.9.0\nv2.0.0-rc.1\nv1.1.0" - ] - tag, from_stable = rc_align.find_baseline_tag() - # v2.0.0-rc.1 should be picked as highest - self.assertEqual(tag, "v2.0.0-rc.1") - self.assertFalse(from_stable) - - -class TestIntegrationScenarios(unittest.TestCase): - """Integration tests for complete scenarios""" - - def test_scenario_version_calculation_logic(self): - """ - Test complete version calculation scenarios - - Scenario 1: v1.2.3 โ†’ v1.3.0-rc.1 - Current: v1.2.3 (stable) - Commit: feat: new feature - Result: v1.3.0-rc.1 (minor bump) - - Scenario 2: v1.3.0-rc.2 โ†’ v1.3.0-rc.3 - Current: v1.3.0-rc.2 (RC) - Commit: fix: bug fix - Result: v1.3.0-rc.3 (RC increment) - - Scenario 3: v2.5.1 โ†’ v3.0.0-rc.1 - Current: v2.5.1 (stable) - Commit: feat!: breaking change - Result: v3.0.0-rc.1 (major bump) - """ - # Test 1: Feature from stable โ†’ minor bump - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=0, - depth=1, is_breaking=False, is_feat=True, from_stable=True - ) - self.assertEqual(result, "1.3.0-rc.1") - - # Test 2: Fix from RC โ†’ RC increment - result = rc_align.calculate_next_version( - major=1, minor=3, patch=0, rc=2, - depth=1, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.3.0-rc.3") - - # Test 3: Breaking change from stable โ†’ major bump - result = rc_align.calculate_next_version( - major=2, minor=5, patch=1, rc=0, - depth=1, is_breaking=True, is_feat=True, from_stable=True - ) - self.assertEqual(result, "3.0.0-rc.1") - - def test_scenario_rc_progression(self): - """ - Test RC progression scenarios - - Scenario: Track progression from stable through multiple RCs - v1.0.0 โ†’ v1.1.0-rc.1 โ†’ v1.1.0-rc.2 โ†’ v1.1.0-rc.3 โ†’ v1.1.0 - """ - # Step 1: feat from stable - result = rc_align.calculate_next_version( - major=1, minor=0, patch=0, rc=0, - depth=1, is_breaking=False, is_feat=True, from_stable=True - ) - self.assertEqual(result, "1.1.0-rc.1") - - # Step 2: fix on RC (1 commit) - result = rc_align.calculate_next_version( - major=1, minor=1, patch=0, rc=1, - depth=1, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.1.0-rc.2") - - # Step 3: another fix on RC (1 commit) - result = rc_align.calculate_next_version( - major=1, minor=1, patch=0, rc=2, - depth=1, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.1.0-rc.3") - - def test_scenario_breaking_changes_always_bump_major(self): - """ - Test that breaking changes always bump major, regardless of current version - """ - # From stable v0.1.0 - result = rc_align.calculate_next_version( - major=0, minor=1, patch=0, rc=0, - depth=1, is_breaking=True, is_feat=False, from_stable=True - ) - self.assertEqual(result, "1.0.0-rc.1") - - # From RC v2.5.3-rc.4 - result = rc_align.calculate_next_version( - major=2, minor=5, patch=3, rc=4, - depth=1, is_breaking=True, is_feat=True, from_stable=False - ) - self.assertEqual(result, "3.0.0-rc.1") - - def test_scenario_patch_bump_from_stable(self): - """ - Test patch bumps (fixes) from stable versions - - v1.2.3 + fix: bug โ†’ v1.2.4-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=3, rc=0, - depth=1, is_breaking=False, is_feat=False, from_stable=True - ) - self.assertEqual(result, "1.2.4-rc.1") - - def test_scenario_multiple_fixes_accumulate_rc(self): - """ - Test multiple fix commits accumulate RC numbers - - v1.0.0-rc.1 + 5 fix commits โ†’ v1.0.0-rc.6 - """ - result = rc_align.calculate_next_version( - major=1, minor=0, patch=0, rc=1, - depth=5, is_breaking=False, is_feat=False, from_stable=False - ) - self.assertEqual(result, "1.0.0-rc.6") - - def test_scenario_feature_on_rc_with_patch(self): - """ - Test feature on RC with patch > 0 bumps minor - - v1.2.1-rc.3 + feat: new โ†’ v1.3.0-rc.1 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=1, rc=3, - depth=1, is_breaking=False, is_feat=True, from_stable=False - ) - self.assertEqual(result, "1.3.0-rc.1") - - def test_scenario_feature_on_rc_without_patch(self): - """ - Test feature on RC with patch = 0 increments RC - - v1.2.0-rc.3 + feat: new โ†’ v1.2.0-rc.4 - """ - result = rc_align.calculate_next_version( - major=1, minor=2, patch=0, rc=3, - depth=1, is_breaking=False, is_feat=True, from_stable=False - ) - self.assertEqual(result, "1.2.0-rc.4") - - -class TestMainBranchScenarios(unittest.TestCase): - """Test main/master branch specific scenarios""" - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_main_branch_mixed_tags(self, mock_stdout, mock_file, mock_git, mock_env): - """Test main branch with mixed stable and RC tags""" - mock_env.return_value = "main" - mock_git.side_effect = [ - "feat: feature", - None, None, - "v2.1.0-rc.5\nv2.0.0\nv1.9.0\nv1.9.0-rc.3" - ] - - rc_align.main() - - output = mock_stdout.getvalue() - # Should pick latest (v2.1.0-rc.5) and strip to v2.1.0 - self.assertIn("OUTPUT: next_version=2.1.0", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_main_branch_handles_regex_in_rc(self, mock_stdout, mock_file, mock_git, mock_env): - """Test main branch correctly strips various RC formats""" - mock_env.return_value = "main" - mock_git.side_effect = [ - "feat: feature", - None, None, - "v1.0.0-rc.100" # High RC number - ] - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=1.0.0", output) - - -class TestNextBranchScenarios(unittest.TestCase): - """Test next branch specific scenarios""" - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('rc_align.find_baseline_tag') - @patch('rc_align.get_commit_depth') - @patch('rc_align.analyze_impact_from_latest') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_next_branch_breaking_change(self, mock_stdout, mock_file, mock_analyze, mock_depth, mock_baseline, mock_git, mock_env): - """Test next branch with breaking change""" - mock_env.return_value = "next" - mock_git.side_effect = ["feat!: breaking", None] - mock_baseline.return_value = ("v1.5.2", True) - mock_depth.return_value = 1 - mock_analyze.return_value = (True, True) - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=2.0.0-rc.1", output) - - @patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/test_output'}) - @patch('os.environ.get') - @patch('rc_align.run_git_command') - @patch('rc_align.find_baseline_tag') - @patch('rc_align.get_commit_depth') - @patch('rc_align.analyze_impact_from_latest') - @patch('builtins.open', new_callable=mock_open) - @patch('sys.stdout', new_callable=StringIO) - def test_next_branch_from_rc_baseline(self, mock_stdout, mock_file, mock_analyze, mock_depth, mock_baseline, mock_git, mock_env): - """Test next branch calculating from RC baseline""" - mock_env.return_value = "next" - mock_git.side_effect = ["fix: bug", None] - mock_baseline.return_value = ("v1.2.0-rc.3", False) # from_stable=False - mock_depth.return_value = 2 - mock_analyze.return_value = (False, False) # fix - - rc_align.main() - - output = mock_stdout.getvalue() - self.assertIn("OUTPUT: next_version=1.2.0-rc.5", output) # 3 + 2 = 5 - - -def run_tests(): - """Run all tests""" - loader = unittest.TestLoader() - suite = loader.loadTestsFromModule(sys.modules[__name__]) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - return 0 if result.wasSuccessful() else 1 - - -if __name__ == '__main__': - sys.exit(run_tests())