diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml new file mode 100644 index 0000000..523ad09 --- /dev/null +++ b/.github/workflows/changelog-check.yml @@ -0,0 +1,111 @@ +name: Changelog Check + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + check-changelog: + name: Check CHANGELOG.md + runs-on: ubuntu-latest + # Skip for certain types of PRs + if: | + !contains(github.event.pull_request.labels.*.name, 'skip-changelog') && + !startsWith(github.event.pull_request.title, 'docs:') && + !startsWith(github.event.pull_request.title, 'ci:') && + !startsWith(github.event.pull_request.title, 'chore:') + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if CHANGELOG.md was modified + id: changelog_modified + run: | + # Get the list of changed files + changed_files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + if echo "$changed_files" | grep -q "^CHANGELOG.md$"; then + echo "modified=true" >> $GITHUB_OUTPUT + echo "✅ CHANGELOG.md has been modified" + else + echo "modified=false" >> $GITHUB_OUTPUT + echo "❌ CHANGELOG.md has not been modified" + fi + + - name: Validate CHANGELOG format + if: steps.changelog_modified.outputs.modified == 'true' + run: | + # Check for [Unreleased] section + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "❌ No [Unreleased] section found in CHANGELOG.md" + exit 1 + fi + + # Check for valid Keep a Changelog sections + valid_sections="Added|Changed|Deprecated|Removed|Fixed|Security" + + # Extract content between [Unreleased] and next version section + unreleased_content=$(sed -n '/## \[Unreleased\]/,/## \[.*\] -/{//!p;}' CHANGELOG.md) + + # Check if any valid section exists + if ! echo "$unreleased_content" | grep -qE "^### ($valid_sections)"; then + echo "❌ No valid Keep a Changelog sections found under [Unreleased]" + echo "Please use one of: ### Added, ### Changed, ### Deprecated, ### Removed, ### Fixed, ### Security" + exit 1 + fi + + # Check if there are actual entries (lines starting with -) + if ! echo "$unreleased_content" | grep -q "^- "; then + echo "❌ No change entries found. Please add entries starting with '- '" + exit 1 + fi + + echo "✅ CHANGELOG.md format is valid" + + - name: Comment on PR + if: steps.changelog_modified.outputs.modified == 'false' + uses: actions/github-script@v7 + with: + script: | + const comment = `## ⚠️ Missing CHANGELOG Update + + This PR appears to contain code changes but doesn't update the CHANGELOG.md file. + + Please update the \`[Unreleased]\` section in CHANGELOG.md with your changes using the [Keep a Changelog](https://keepachangelog.com/) format: + + - \`### Added\` - for new features + - \`### Changed\` - for changes in existing functionality + - \`### Deprecated\` - for soon-to-be removed features + - \`### Removed\` - for now removed features + - \`### Fixed\` - for any bug fixes + - \`### Security\` - for vulnerability fixes + + Example: + \`\`\`markdown + ## [Unreleased] + + ### Added + - New feature X that does Y + + ### Fixed + - Bug in component Z + \`\`\` + + If this PR doesn't require a changelog entry (e.g., documentation, CI changes), you can: + 1. Add the \`skip-changelog\` label to this PR + 2. Use conventional commit prefixes: \`docs:\`, \`ci:\`, or \`chore:\``; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: comment + }); + + - name: Fail if changelog not updated + if: steps.changelog_modified.outputs.modified == 'false' + run: | + echo "❌ CHANGELOG.md must be updated for this PR" + exit 1 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9016b05 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,259 @@ +name: Release + +on: + workflow_dispatch: + inputs: + prerelease: + description: 'Mark as pre-release' + required: false + type: boolean + default: false + test_pypi: + description: 'Upload to Test PyPI first' + required: false + type: boolean + default: false + release_notes: + description: 'Additional release notes (optional)' + required: false + type: string + +permissions: + contents: write + id-token: write # Required for trusted publishing + +jobs: + prepare-release: + name: Prepare Release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + version_bump: ${{ steps.version.outputs.bump_type }} + changelog: ${{ steps.changelog.outputs.content }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Validate CHANGELOG has unreleased content + id: validate + run: | + if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then + echo "❌ No [Unreleased] section found in CHANGELOG.md" + exit 1 + fi + + # Check if there are any entries under Unreleased + unreleased_content=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -E "^- " || true) + if [ -z "$unreleased_content" ]; then + echo "❌ No changes documented in [Unreleased] section" + exit 1 + fi + + echo "✅ Found unreleased changes in CHANGELOG.md" + + - name: Determine version bump type + id: version + run: | + # Extract current version from pyproject.toml + current_version=$(grep -E '^version = "' pyproject.toml | cut -d'"' -f2) + echo "Current version: $current_version" + + # Analyze changelog to determine bump type + major_changes=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -E "^### (Removed)" || true) + minor_changes=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -E "^### (Added|Changed|Deprecated)" || true) + patch_changes=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -E "^### (Fixed|Security)" || true) + + # Determine bump type (check in order: major -> minor -> patch) + if [ -n "$major_changes" ]; then + bump_type="major" + elif [ -n "$minor_changes" ]; then + bump_type="minor" + elif [ -n "$patch_changes" ]; then + bump_type="patch" + else + # Default to patch if only individual items without category + bump_type="patch" + fi + + # Calculate new version + IFS='.' read -ra VERSION_PARTS <<< "$current_version" + major="${VERSION_PARTS[0]}" + minor="${VERSION_PARTS[1]}" + patch="${VERSION_PARTS[2]}" + + case $bump_type in + major) + new_version="$((major + 1)).0.0" + ;; + minor) + new_version="$major.$((minor + 1)).0" + ;; + patch) + new_version="$major.$minor.$((patch + 1))" + ;; + esac + + echo "Bump type: $bump_type" + echo "New version: $new_version" + + echo "version=$new_version" >> $GITHUB_OUTPUT + echo "bump_type=$bump_type" >> $GITHUB_OUTPUT + + - name: Extract changelog content + id: changelog + run: | + # Extract content between [Unreleased] and the next section + changelog=$(sed -n '/## \[Unreleased\]/,/## \[/{//!p;}' CHANGELOG.md | sed '/^$/d') + + # Write to file to preserve formatting + echo "$changelog" > changelog_content.txt + + # Set multiline output + echo "content<> $GITHUB_OUTPUT + echo "$changelog" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + quality-checks: + name: Quality Checks + needs: prepare-release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: | + uv venv + uv pip install -e ".[dev]" + + - name: Run tests + run: | + uv run pytest --cov=sprout --cov-report=term-missing + + - name: Run linting + run: | + uv run ruff check src tests + uv run ruff format --check src tests + + - name: Run type checking + run: | + uv run mypy src + + build-and-publish: + name: Build and Publish + needs: [prepare-release, quality-checks] + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/project/sprout-cli/ + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install build tools + run: | + pip install hatch twine + + - name: Update version in files + run: | + # Update pyproject.toml + sed -i 's/version = ".*"/version = "${{ needs.prepare-release.outputs.version }}"/' pyproject.toml + + # Update __init__.py + sed -i 's/__version__ = ".*"/__version__ = "${{ needs.prepare-release.outputs.version }}"/' src/sprout/__init__.py + + # Update CHANGELOG.md + today=$(date +%Y-%m-%d) + sed -i "s/## \[Unreleased\]/## [Unreleased]\n\n### Added\n\n### Changed\n\n### Deprecated\n\n### Removed\n\n### Fixed\n\n### Security\n\n## [${{ needs.prepare-release.outputs.version }}] - $today/" CHANGELOG.md + + # Update comparison link + echo "" >> CHANGELOG.md + echo "[${{ needs.prepare-release.outputs.version }}]: https://github.com/SecDev-Lab/sprout/compare/v${{ needs.prepare-release.outputs.version }}...HEAD" >> CHANGELOG.md + sed -i "s|\[Unreleased\]:.*|[Unreleased]: https://github.com/SecDev-Lab/sprout/compare/v${{ needs.prepare-release.outputs.version }}...HEAD|" CHANGELOG.md + + - name: Commit version updates + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add pyproject.toml src/sprout/__init__.py CHANGELOG.md + git commit -m "Release version ${{ needs.prepare-release.outputs.version }}" + + - name: Build package + run: | + hatch build + twine check dist/* + + - name: Upload to Test PyPI + if: github.event.inputs.test_pypi == 'true' + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TEST_PYPI_API_TOKEN }} + run: | + twine upload --repository testpypi dist/* + echo "📦 Package uploaded to Test PyPI" + echo "Install with: pip install --index-url https://test.pypi.org/simple/ sprout-cli" + + - name: Upload to PyPI + if: github.event.inputs.test_pypi != 'true' + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: | + twine upload dist/* + echo "📦 Package uploaded to PyPI" + echo "Install with: pip install sprout-cli" + + - name: Create and push tag + run: | + git tag -a "v${{ needs.prepare-release.outputs.version }}" -m "Release v${{ needs.prepare-release.outputs.version }}" + git push origin main --tags + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: v${{ needs.prepare-release.outputs.version }} + name: v${{ needs.prepare-release.outputs.version }} + body: | + ## What's Changed + + ${{ needs.prepare-release.outputs.changelog }} + + ${{ github.event.inputs.release_notes }} + + --- + + **Full Changelog**: https://github.com/SecDev-Lab/sprout/compare/v0.1.0...v${{ needs.prepare-release.outputs.version }} + draft: false + prerelease: ${{ github.event.inputs.prerelease == 'true' }} + files: | + dist/* + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a153fa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# 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.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial implementation of sprout CLI tool +- `create` command to create new git worktrees with Docker Compose support +- `ls` command to list all sprout worktrees +- `rm` command to remove sprout worktrees +- `path` command to get the path of a sprout worktree +- Rich terminal output with progress indicators +- Comprehensive test suite with pytest +- Type checking with mypy +- Linting and formatting with ruff +- CI/CD pipeline with GitHub Actions +- Support for Python 3.11, 3.12, and 3.13 + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security + +[Unreleased]: https://github.com/SecDev-Lab/sprout/compare/v0.1.0...HEAD \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 969ae15..b5ac5bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,51 @@ Responses and documentation should be written in English. Always use English for When developing new features, place specifications under `docs/(feature-name)/*.md`. These specifications serve as references during implementation, but their main purpose is to look back on "what was the purpose, how, and what was implemented" after implementation. +## Changelog Management + +**IMPORTANT**: All code changes MUST be documented in CHANGELOG.md following the Keep a Changelog format. + +### Rules for CHANGELOG Updates +1. **Every PR with code changes** must update the `[Unreleased]` section in CHANGELOG.md +2. **Use Keep a Changelog format** strictly: + - `### Added` - for new features (triggers MINOR version bump) + - `### Changed` - for changes in existing functionality (triggers MINOR version bump) + - `### Deprecated` - for soon-to-be removed features (triggers MINOR version bump) + - `### Removed` - for now removed features (triggers MAJOR version bump) + - `### Fixed` - for any bug fixes (triggers PATCH version bump) + - `### Security` - for vulnerability fixes (triggers PATCH version bump) + +3. **Exceptions** (no changelog required): + - Documentation-only changes (PRs starting with `docs:`) + - CI/CD changes (PRs starting with `ci:`) + - Chore/maintenance tasks (PRs starting with `chore:`) + - PRs with `skip-changelog` label + +### Changelog Entry Format +```markdown +## [Unreleased] + +### Added +- New feature X that does Y +- Support for Z functionality + +### Fixed +- Bug in component A when condition B occurs +``` + +### Release Process +- Releases are triggered manually from GitHub Actions UI +- Version bumps are **automatic** based on changelog sections: + - Major: When `### Removed` section has entries + - Minor: When `### Added`, `### Changed`, or `### Deprecated` sections have entries + - Patch: When only `### Fixed` or `### Security` sections have entries +- The release workflow will: + 1. Validate changelog has unreleased entries + 2. Determine version bump type automatically + 3. Update version in all relevant files + 4. Create git tag and GitHub release + 5. Publish to PyPI + ## Intermediate File Management ### Using tmp/ Directory diff --git a/docs/release-workflow.md b/docs/release-workflow.md new file mode 100644 index 0000000..dc0b6a4 --- /dev/null +++ b/docs/release-workflow.md @@ -0,0 +1,116 @@ +# Release Workflow Documentation + +This document describes the release process for the sprout-cli package. + +## Overview + +The release process is fully automated through GitHub Actions and can be triggered entirely from the GitHub Web UI. No CLI commands are required. + +## Prerequisites + +1. **Repository Secrets**: The following secrets must be configured in GitHub repository settings: + - `PYPI_API_TOKEN`: PyPI authentication token for production releases + - `TEST_PYPI_API_TOKEN`: Test PyPI token (optional, for testing releases) + +2. **Changelog**: All changes must be documented in `CHANGELOG.md` using the Keep a Changelog format. + +## Release Process + +### 1. Update CHANGELOG + +Before releasing, ensure all changes are documented in the `[Unreleased]` section of `CHANGELOG.md`: + +```markdown +## [Unreleased] + +### Added +- New feature descriptions + +### Fixed +- Bug fix descriptions +``` + +### 2. Trigger Release + +1. Go to the [Actions tab](https://github.com/SecDev-Lab/sprout/actions) in your GitHub repository +2. Click on "Release" workflow in the left sidebar +3. Click "Run workflow" button +4. Configure the release options: + - **Mark as pre-release**: Check if this is a beta/alpha release + - **Upload to Test PyPI first**: Check to test the release on Test PyPI before production + - **Additional release notes**: Optional extra notes for the GitHub release + +5. Click "Run workflow" to start the release + +### 3. Automatic Version Detection + +The workflow automatically determines the version bump based on your changelog entries: + +- **Major version** (1.0.0 → 2.0.0): When `### Removed` section has entries +- **Minor version** (1.0.0 → 1.1.0): When `### Added`, `### Changed`, or `### Deprecated` sections have entries +- **Patch version** (1.0.0 → 1.0.1): When only `### Fixed` or `### Security` sections have entries + +### 4. Release Steps + +The workflow performs these steps automatically: + +1. **Validation**: Checks that CHANGELOG has unreleased entries +2. **Version Detection**: Determines version bump type from changelog +3. **Quality Checks**: Runs all tests, linting, and type checking +4. **Version Updates**: Updates version in `pyproject.toml`, `__init__.py`, and CHANGELOG +5. **Build**: Creates wheel and source distributions +6. **Publish**: Uploads to PyPI (or Test PyPI if selected) +7. **Git Operations**: Creates git tag and pushes changes +8. **GitHub Release**: Creates release with changelog content and artifacts + +## Changelog Guidelines + +### PR Requirements + +All PRs with code changes must update CHANGELOG.md unless they are: +- Documentation-only changes (PRs starting with `docs:`) +- CI/CD changes (PRs starting with `ci:`) +- Chore tasks (PRs starting with `chore:`) +- PRs with `skip-changelog` label + +### Changelog Format + +Use the Keep a Changelog format: + +```markdown +## [Unreleased] + +### Added +- New endpoints, features, or functionality + +### Changed +- Changes in existing functionality + +### Deprecated +- Soon-to-be removed features + +### Removed +- Now removed features + +### Fixed +- Bug fixes + +### Security +- Vulnerability fixes +``` + +## Troubleshooting + +### Release Fails at Validation +- Ensure CHANGELOG.md has entries in the `[Unreleased]` section +- Check that entries follow the correct format + +### PyPI Upload Fails +- Verify `PYPI_API_TOKEN` secret is correctly set +- Check that the package name is available on PyPI +- Try using Test PyPI first to debug issues + +### Version Already Exists +- The version might have been manually released +- Check PyPI and git tags to verify +- Update CHANGELOG with a new version if needed \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e03e83c..7dce969 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "sprout" +name = "sprout-cli" version = "0.1.0" description = "CLI tool to automate git worktree and Docker Compose development workflows" readme = "README.md"